Files
SolarManager/autoActions
adminandClaude Opus 5 8340e8356e Runner: Kalender am Vorabend fuer den folgenden Tag pruefen
Mit next_day = 1 gelten Wochentage, Ferien und Feiertage fuer morgen
(gemeinter_tag). kalender() merkt sich dafuer heute und morgen und haelt
einen fehlgeschlagenen Abruf nicht mehr den ganzen Tag fest. Uhrzeit,
Zeitfenster und once_per_day bleiben beim heutigen Tag. vorabend.sql legt
die Spalte an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:33:59 +02:00
..

AutoActions

Automatiken, die im Web-UI unter „Automatismen" angelegt werden und hier ausgeführt werden: wenn Bedingung, dann Kommando.

Browser                     Datenbank homeMesh              Runner
──────────────────────      ─────────────────────────       ────────────────────
Karte „Automatismen"   ──▶  automations                ──▶  autoaction_runner.py
ajax/AutoAction.php         automation_conditions           MQTT / HTTP / Tahoma
restricted/automations.php  automation_actions              ──▶ Geräte
js/solar/autoActionFuncs.js automation_action_params
                            automation_log
                            calendar_days              ◀──  fetch_calendar.py

Wie eine Automatik aufgebaut ist

Eine Automatik hat Auslöser, Rahmenbedingungen und Aktionen.

Ein Auslöser vergleicht einen Messwert (actor_states) mit einer Schwelle. Mehrere Auslöser werden über group_no verknüpft: gleiche Nummer heißt UND, verschiedene Nummern heißen ODER — ausgewertet wird any(all(gruppe)). Im Editor ist eine Gruppe ein gerahmter Block mit eigenem „+ Bedingung", zwischen den Blöcken steht ein ODER. Die Klammerung ist damit gezeichnet und nicht bloß vereinbart, und darunter steht derselbe Ausdruck noch einmal als Satz.

Eine Aktion ist ein Kommando (actor_commands) mit einem Wert je Parameter (command_parameters) — nicht vier feste Spalten „Wert 1" bis „Wert 4", sondern so viele Zeilen, wie das Gerät Parameter hat.

Die Rahmenbedingungen (Wochentage, Zeitfenster, Ferien, Feiertage) sagen, wann die Automatik überhaupt hinsehen darf — siehe Der Rahmen.

Warum ein Dauerläufer und kein Cronjob

Zwei Gründe:

  • Schwellwert-Auslöser sollen greifen, wenn die MQTT-Nachricht hereinkommt, nicht erst im nächsten Minutenraster.
  • actor_states.current_value wird sonst von niemandem fortgeschrieben — es wird beim Geräte-Discovery einmal gesetzt und danach nie wieder. Ein zustandsloser Cronjob hätte gar nichts, womit er vergleichen könnte. Der Runner pflegt den Wert nebenbei mit — nur wenn er sich geändert hat und höchstens einmal je Minute —, wovon auch der Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand.

Was worauf wartet

Die Hauptschleife wartet auf nichts. Alles, was ein Netz braucht, läuft daneben:

wo Takt
MQTT-Nachrichten kommen von selbst, Zwischenspeicher im Transport sofort
Uhrzeit, Datum im Runner gerechnet jeder Takt
Sonnenauf-/-untergang solarLog.daylight einmal je Tag
HTTP- und WLED-Geräte Sammler, eigener Faden poll_http, poll_wled
Tahoma Sammler, eigener Faden poll_tahoma, Vorgabe 5 Minuten
Kommandos senden Versand, ein Faden je Gerät wenn etwas ansteht

Das war nicht immer so. Vorher wurden die Geräte mitten in der Schleife abgefragt — neunzehn Tahoma-Geräte nacheinander, jedes mit bis zu zehn Sekunden Zeitlimit. Eine Runde dauerte dadurch rund fünfundvierzig statt dreißig Sekunden, gepollt wurde erst jede zweite Runde, also alle neunzig Sekunden. Und weil die Uhr an derselben Abfrage hing, kam jede dritte Minute nie vor: um 18:26 wurde nie wahr.

Die Fäden fassen die Datenbank nicht an. Sie legen ihre Ergebnisse in eine Queue, geschrieben wird im Hauptfaden — eine pymysql-Verbindung gehört einem Faden.

Nur die steigende Flanke

automations.cond_met hält fest, ob die Bedingung beim letzten Durchlauf schon erfüllt war. Ohne das würde „Temperatur über 22 Grad" bei jedem Takt erneut feuern. Verlässt die Automatik ihr Zeitfenster, wird die Flanke zurückgesetzt, damit sie im nächsten Fenster wieder steigen kann.

Zeit-Auslöser gibt es in drei Formen:

wahr, wenn
um 16:30 ab dieser Minute, noch catchup_minutes lang
ab 16:30 von da an bis Mitternacht
vor 16:30 bis dahin

Ausgelöst wird in allen drei Fällen nur einmal, eben wegen der Flanke.

Das Nachholfenster bei um ist der Ersatz für die früher verlangte Punktgenauigkeit. Solange um 16:30 nur in genau dieser Minute wahr war, kostete jeder Aussetzer die Automatik für den ganzen Tag — und Aussetzer gab es reichlich, weil die Uhr am Geräte-Poll hing und jede dritte Minute übersprang. Jetzt gilt die Bedingung fünf Minuten lang (einstellbar), die Flanke sorgt weiterhin für genau einen Lauf, und ein Neustart mitten im Fenster holt den Lauf nach. Die breiten Zeitfenster in auto_watering.py folgen derselben Überlegung.

Beim Sonnenauf- und -untergang ist der Wert ein Versatz, und der kann davor oder danach liegen — deshalb dieselben drei Fälle mal zwei: + 00:30 eine halbe Stunde nach Sonnenaufgang, ab - 00:30 ab einer halben Stunde davor, vor + 00:30 bis eine halbe Stunde danach.

Dieselben drei Formen trägt die Verkettung einer Automatik mit der nächsten, dort aber nur mit Plus — siehe „Eine Automatik löst die nächste aus".

Über den Tagesrand wird gerechnet, nicht abgeschnitten: „sechs Stunden vor Sonnenaufgang" landet am Vorabend, und das ist so gewollt — abgeschnitten wären solche Angaben gar nicht mehr formulierbar. Verglichen wird die Uhrzeit innerhalb des Tages; ein Ziel jenseits von Mitternacht gilt als diese Uhrzeit am selben Tag. Bei + und - ist das genau der gemeinte Zeitpunkt, bei ab und vor verschiebt sich der wahre Bereich entsprechend mit.

force_once („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn das Fenster zugeht und in diesem Fenster noch nichts passiert ist.

Der Rahmen

Vor jeder Auswertung fragt tag_passt(), ob der heutige Tag überhaupt zählt. Drei Dinge entscheiden das: die Wochentagsmaske (weekdays, ein Bit je Tag, Montag ist Bit 0), und Ferien und Feiertage aus calendar_days.

Ferien und Feiertage sind dreiwertig, nicht ja/nein:

Editor on_vacation / on_holiday heißt
nie 0 an solchen Tagen läuft die Automatik nicht
egal 1 (Vorgabe) der Tag ändert nichts
immer 2 zählt wie ein angehakter Wochentag

Die ersten beiden gab es immer; „werktags" ist weekdays = 31 und on_holiday = 0. Der dritte Wert kam dazu, weil sich „an Wochenenden und Feiertagen" sonst gar nicht schreiben ließ: die Wochentagsmaske kennt nur Samstag und Sonntag, und ein Feiertag am Dienstag ist für sie eben ein Dienstag. Mit 2 genügt Sa+So angehakt und „Feiertage: immer", der Dienstag kommt dann über den Kalender herein. Und weil 2 allein trägt, ergibt weekdays = 0 mit „Feiertage: immer" ein nur an Feiertagen — das ging vorher gar nicht.

Erst wird geöffnet, dann gesperrt: ein Verbot schlägt eine Erweiterung. Wer in den Ferien nie läuft und an Feiertagen zusätzlich, läuft an einem Feiertag in den Ferien nicht. Andersherum ließe sich „nie" nicht mehr verlassen.

Die Spalten sind TINYINT geblieben und tragen die 2 ohne Weiteres; die vorhandenen Zeilen stehen auf 0 oder 1 und behalten damit genau ihre bisherige Bedeutung. Umzurechnen gibt es nichts, die Migration (rahmen_erweitern.sql) setzt nur Kommentare und legt once_per_day an.

Am Vorabend

Mit next_day = 1 gelten Wochentage, Ferien und Feiertage für morgen (gemeinter_tag()). Gedacht für alles, was abends für den nächsten Tag geschieht: „Kinderrollos zu, wenn morgen Schule ist“ heißt dann MoFr, Ferien nie, Feiertage nie — genau wie der Wecker. Mit dem heutigen Tag ließ sich das nur annähern (SoDo, heute keine Ferien) und ging am letzten Ferientag, am Abend vor einem Feiertag und am Abend eines Feiertags daneben.

Nur der Rahmen verschiebt sich. Uhrzeit, Zeitfenster und „nur einmal am Tag“ bleiben beim heutigen Tag, denn die Automatik läuft ja heute Abend. Die Spalte legt vorabend.sql an.

Nur einmal am Tag

once_per_day sperrt eine Automatik nach dem Auslösen bis Mitternacht.

Gedacht für alles, was man hinterher von Hand wieder anders stellt. Der Rollladen fährt morgens auf; um acht zieht man ihn nochmal zu, weil die Sonne blendet — und um neun zieht eine Wolke weiter, die Helligkeitsschwelle steigt ein zweites Mal, und die Automatik funkt dazwischen. Genau das verhindert der Haken.

Die Sperrzeit taugt dafür nicht. Sie zählt Sekunden und müsste auf 86 400 stehen; eine Automatik, die heute um 07:00:05 lief, wäre morgen um 07:00:00 noch gesperrt und fiele einen ganzen Tag aus. Gefragt wird deshalb nach dem Datum von last_run, nicht nach dem Abstand — dieselbe Funktion (lief_heute), die auch force_once benutzt, nur mit umgekehrtem Vorzeichen.

Sperrzeit

Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle pendelt: „Temperatur > 22" bei 22,1 / 21,9 / 22,1 °C ist jedes Mal eine echte steigende Flanke, und über MQTT können die Werte im Sekundentakt hereinkommen. automations.lockout_secs sagt, wie lange nach einer Auslösung nicht wieder geschaltet wird. Der Editor bietet drei Stufen an:

gedacht für
Ohne 0 s volle Geschwindigkeit, jede Flanke schaltet
Kurz 60 s Licht, Farbe, Dimmwert
Lang 900 s Rollläden, Ventile, alles mit Motor

Gespeichert werden Sekunden, angeboten werden nur die drei Stufen — eine vierte ist damit eine Zeile in lockoutChoices() und keine Wanderung durch die Datenbank.

Eine Flanke innerhalb der Sperrzeit wird verworfen, nicht aufgehoben. Ein Rollladen, der eine Viertelstunde später doch noch losfährt, weil vor langer Zeit einmal eine Schwelle gestreift wurde, wäre unangenehmer als einer, der gar nicht fährt — und der nächste echte Anlass nach Ablauf der Sperre kommt ohnehin durch. Verworfene Flanken stehen im Log auf DEBUG, nicht in automation_log; bei einem zappelnden Sensor wäre die Tabelle sonst voll davon.

force_once ist von der Sperre nicht betroffen: es greift nur, wenn im Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv.

Eine Automatik löst die nächste aus

Der Anlass war: zehn Minuten nach dem Wecker den Rollladen hoch — aber nur, wenn es dann schon hell ist.

Der naheliegende Weg wäre eine Verzögerung an der Aktion gewesen. Er trägt nicht: die Zusatzbedingung gilt erst zum späteren Zeitpunkt, und eine verzögerte Aktion, die selbst noch Bedingungen prüfen muss, ist keine Aktion mehr — sie bräuchte ein zweites Bedingungssystem neben dem ersten. Der zweite Schritt ist also eine eigene Automatik mit eigenen Rahmenbedingungen, und was ihr fehlte, war nur ein Weg, sich auf die erste zu beziehen.

Den gibt jetzt das gerechnete Gerät „Automatiken", Gegenstück zum vorhandenen „Zeitpunkt". Jede Automatik ist dort ein Messwert, ihr Wert ist der Zeitpunkt der letzten Auslösung:

Wecker Magdalena        um 05:50                    → Licht auf Wakeup
Rollladen Magdalena     Wecker Magdalena + 00:10
                        UND Sonnenaufgang ab + 00:00 → Rollladen auf

Der Editor braucht dafür keine Zeile Änderung. Er listet Geräte und deren Messwerte — „Automatiken" ist dann ein Gerät wie jedes andere.

Der Datentyp elapsed

Er verhält sich zum Auslösezeitpunkt wie deltatime zum Sonnenaufgang: der Messwert ist der Bezugspunkt, die Schwelle der Versatz, das Vorzeichen steckt im Operator. Drei Formen, alle „danach" — ein Minus gibt es nicht, vor dem Auslöser kann nichts liegen, das erst der Auslöser anstößt:

wahr, wenn
+ 00:10 zehn Minuten nach der Auslösung, noch catchup_minutes lang
ab + 00:10 von da an bis Mitternacht
vor + 00:10 in den ersten zehn Minuten danach

Gerechnet wird mit dem echten Abstand, nicht mit der Uhrzeit innerhalb des Tages. Das ist der eine Unterschied zu deltatime, und er ist nötig: sonst machte ein Lauf von vorgestern um 05:50 die Bedingung heute um 06:00 wahr, an einem Tag, an dem der Auslöser gar nicht lief.

Die offene Form ab + endet trotzdem am Tagesrand — sonst wäre sie morgen früh immer noch wahr, obwohl seither nichts geschehen ist. Ein Auslöser um 23:55 trägt seinen Nachfolger deshalb nicht über Mitternacht; dieselbe Einschränkung hat ab 23:55 auch. Die Punktform + braucht den Tagesvergleich nicht und trägt darüber hinweg.

Reihenfolge, Pause, Kreise

Ausgewertet wird topologisch, Auslöser vor Nachfolger. Nur damit wirkt ein Versatz von null noch im selben Takt; bei jedem anderen Versatz wäre die Reihenfolge gleichgültig, zehn Minuten sind länger als ein Takt. Dazu trägt ausloesen() den eigenen Auslösewert sofort nach, statt bis zum nächsten werte_einsammeln() zu warten.

Eine pausierte Automatik hält ihre Nachfolger mit an. Regelwerk.laden() holt nur enabled = 1, der Transport liefert für alle übrigen einen leeren Wert, und der gilt jeder Bedingung als unerfüllt. Wer den Wecker pausiert, will morgens auch den Rollladen unten lassen.

Kreise lehnt der Editor beim Speichern ab (pruefeKreis() in restricted/automations.php). Im Betrieb wären sie kaum zu bemerken: die Sperrzeit begrenzt sie auf eine Auslösung je lockout_secs, und im Protokoll sieht das aus wie eine Automatik, die halt oft läuft. Der Runner meldet einen Kreis trotzdem — falls doch einer an der Datenbank vorbei entsteht — und wertet die Beteiligten dann in ihrer ursprünglichen Reihenfolge aus. Sie stillzulegen wäre schlimmer: eine wortlos abgeschaltete Automatik fällt niemandem auf.

Löschen ist die gefährliche Stelle

fk_cond_state steht auf ON DELETE CASCADE. Verschwindet der Messwert, verschwindet die Bedingung — still. Bei der letzten Bedingung fängt gruppen_erfuellt() das ab („eine Automatik ohne Bedingungen löst nie aus"). Bei einer von zweien fängt es niemand: aus zehn Minuten nach dem Wecker UND es ist hell würde ein bloßes es ist hell, und der Rollladen führe ab morgen jeden Tag bei Sonnenaufgang hoch.

Deshalb fragt der Editor vor dem Löschen, und der Runner räumt nur auf, was niemand mehr benutzt:

Abhängen die Bedingung wird entfernt und der Nachfolger pausiert. Eine pausierte Automatik mit sichtbarer Lücke ist besser als eine stille Regeländerung.
Alle löschen der Nachfolger geht mit, und dessen Nachfolger auch.

Automatisch mitzulöschen wäre die falsche Vorgabe — „Wecker weg, Rollladen still weg" bemerkt man erst im Winter.

Die Messwerte pflegt der Runner

ausloeser_nachfuehren() legt bei jedem Laden des Regelwerks für jede Automatik einen Messwert an, auch für pausierte, und zieht den Namen nach. Geführt wird nach der Kennung (auto:15) und nicht nach dem Namen: wer umbenennt, soll die abhängigen Bedingungen behalten.

Für pausierte muss der Messwert stehen bleiben, sonst löschte ein Pausieren über dieselbe Kaskade die Bedingung des Nachfolgers, und beim Fortsetzen wäre sie weg.

Transporte

Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in actors:

URL Messwert (actor_states.url) Kommando (actor_commands.command_url)
mqtt://… vollständiges Topic, abonniert; bei mehreren Messwerten je Topic zusätzlich value_path Nutzlast auf das Parameter-Topic
http://… Feldname in der JSON-Antwort, gepollt Abfrageargumente an die Geräte-URL (turn=on)
wled://… Pfad in /json/state (seg[0].col[0]), gepollt JSON-Vorlage mit Platzhaltern, als Ganzes gesendet
Tahoma Statusname (core:ClosureState), gepollt exec/apply an die Box
Logic gerechnet: Uhrzeit, Datum, Sonne
Automatik gerechnet: je Automatik ihre letzte Auslösung

Alle sechs stehen in transports.py. Eine siebte Geräteart kommt als weitere Klasse dazu; sie braucht passt(), zustaende_lesen() und senden().

Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der Box-Kennung in der URL. Das Schema beschreibt dort die Funkart, und dieselbe Box liefert io:// für die Jalousien, rts:// für die Dachfenster und internal:// für die Alarmanlage. Ohne pin in der config.ini ist niemand zuständig — dann meldet der Runner beim Auslösen „kein Transport", statt still nichts zu tun.

Mehrere Messwerte teilen sich oft ein Topic: der go-eCharger schickt sechzehn Zahlen als JSON-Feld auf …/nrg, und erst das value_template der Home-Assistant-Discovery sagt, dass „Strom L1" das fünfte Element ist. Diese Angabe steht in actor_states.value_path — in derselben Schreibweise, die auch WLED benutzt: [4], ssid, seg[0].col[0]. Ohne Pfad gilt die ganze Nutzlast.

Gelesen wird nur der einfache Fall aus dem Template: ein Zugriff auf value_json und was danach an Punkten und Klammern folgt.

Werttabellen

Manche Geräte schicken eine Zahl und meinen einen Zustand:

{{ ['Unknown','Idle','Charging','WaitCar','Complete','Error'][value_json|int] }}
{{ ['Default','Eco','NextTrip'][value_json|int-3] }}

Das ist kein Pfad, sondern eine Übersetzung von Zahl nach Text. Sie landet in possible_values — in der Schreibweise, die WLED für seine Effektliste schon benutzt: eine Liste aus {Wert: Bezeichnung}. Ein Versatz im Ausdruck wandert dabei in die Schlüssel, aus [value_json|int-3] wird also {"3":"Default"}.

Der Runner übersetzt beim Lesen: aus der gesendeten 2 wird Charging. Eine Bedingung vergleicht damit genau den Klartext, den der Editor zur Auswahl stellt. Steht die Zahl nicht in der Tabelle, bleibt sie stehen — ein erfundener Name wäre schlimmer als ein roher Wert.

Auf beiden Seiten des Editors steckt dieselbe Tabelle, aber der gespeicherte Wert ist ein anderer:

angezeigt gespeichert
Messwert (Bedingung) Charging Charging — der Runner hat schon übersetzt
Parameter (Aktion) Blink 1 — das Gerät will die Zahl

Bei WLED trägt die Kommando-Vorlage alles: {"seg":[{"col":[[%red%,%green%,%blue%]]}]} wird mit den Parameterwerten gefüllt und am Stück geschickt. Deshalb haben die Parameter dort keine eigene URL — ihr Name ist der Platzhalter.

Voraussetzungen

  • Python 3 mit pymysql, requests, paho-mqtt
  • homeMesh_automations.sql einmal eingespielt
  • Im Web-Verzeichnis muss in restricted/deviceDiscovery/config.ini clear_tables = false stehen. Discovery schreibt mit ON DUPLICATE KEY UPDATE auf den URLs, das Leeren ist unnötig — ein TRUNCATE würde dagegen die Geräte-IDs neu vergeben, und die Automatiken zeigen per Fremdschlüssel genau auf diese IDs.

Einrichten

cp config.ini.example config.ini      # ausfüllen: Datenbank, MQTT, Tahoma
mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < automatik_ausloeser.sql
mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < rahmen_erweitern.sql
python3 fetch_calendar.py             # Feiertage und Ferien holen
python3 autoaction_runner.py --once --dry-run --verbose   # Probelauf

automatik_ausloeser.sql legt den Datentyp elapsed und das gerechnete Gerät „Automatiken" an — beides braucht die Verkettung, siehe oben. Die Messwerte darunter legt der Runner selbst an. Ohne das Skript läuft alles Übrige weiter, es fehlt nur die Möglichkeit, eine Automatik als Auslöser zu wählen.

rahmen_erweitern.sql gehört zum Rahmen: es beschriftet on_vacation und on_holiday mit ihren drei Bedeutungen und legt once_per_day an. Beide Skripte sind idempotent — ein zweiter Lauf schadet nicht.

--dry-run schaltet nichts, protokolliert aber jedes Kommando, das geschickt würde. --once macht einen einzigen Durchlauf.

Wo das läuft

Der Runner ist ein Hintergrundprozess und wohnt deshalb beim SolarManager, nicht im Web-Verzeichnis:

/volume1/homes/wagner/SolarManager/
├── solarManager.py
├── startSolarServer.sh          startet beide, siehe unten
└── autoActions/
    ├── autoaction_runner.py
    ├── transports.py
    ├── fetch_calendar.py
    ├── automatik_ausloeser.sql  einmalig, siehe Einrichten
    ├── rahmen_erweitern.sql     einmalig, siehe Einrichten
    └── config.ini               Zugangsdaten, nicht im Git

Das Web-UI kennt diesen Pfad nicht — Browser und Runner reden ausschließlich über die Datenbank homeMesh miteinander. Der Runner lädt das Regelwerk nach, sobald im Browser etwas gespeichert wurde; ein Neustart nach jeder Änderung ist nicht nötig.

startSolarServer.sh startet solarManager.py und den Runner gemeinsam und beendet vorher, was schon läuft. Aufgerufen wird es beim Booten (auf der Synology über den Aufgabenplaner, Ereignis „Hochfahren", als root); dasselbe Skript von Hand aufzurufen ist der normale Weg, den Runner neu zu starten. Zwei Instanzen dürfen nie gleichzeitig laufen — sie würden jedes Kommando doppelt schicken und sich gegenseitig vom MQTT-Broker werfen, weil beide dieselbe Client-Kennung benutzen. Genau davor schützt das Beenden am Anfang.

Ausgabe landet in autoActions.log neben solarOutput.log. SIGTERM fängt der Runner ab und fährt geordnet herunter.

Nachzulesen ist beides im Dashboard unter Protokolle — die Seite liest die Dateien direkt, filterbar nach Text und Level. Klein gehalten werden sie von logs_rotieren.sh samt logrotate.conf eine Ebene höher: täglich, vierzehn Stände, alles über 20 MB sofort. Weil die Prozesse monatelang durchlaufen, arbeitet die Rotation mit copytruncate — deshalb leiten die Startskripte mit >> um und nicht mehr mit &>. Wer das zurückdreht, bekommt nach der nächsten Rotation eine Logdatei, die vorn aus Nullbytes besteht.

Die lauteste dieser Dateien war wattpilotshell.log, rund ein MB am Tag. Die Wallbox schickt in ihren Statusnachrichten 34 Felder, die die API-Beschreibung der Bibliothek nicht kennt (clea, cle, opad, die ganze ocpp*-Familie) — wattpilot 0.2 ist seit Mai 2022 die letzte Veröffentlichung, ein Update behebt das also nicht. Die Bibliothek schlug jedes Feld ungeprüft nach und lief in einen KeyError, der die restliche Schleife mitriss: alles, was in derselben Nachricht dahinter stand, wurde nie veröffentlicht — im Dauerbetrieb alle 15 Sekunden fhz, lps und tpcm, nach einem Verbindungsaufbau deutlich mehr. Dieselbe Ursache aus der anderen Richtung traf lot: als integer beschrieben, als Objekt geschickt, von paho abgelehnt — gut vier von zehn Zeilen im alten Log. wattpilot_bruecke.py ersetzt beide betroffenen Funktionen zur Laufzeit, überspringt unbekannte Felder und verschickt Werte notfalls als JSON, jedes Feld einmal mit einer Warnung im Log. startWattpilotMQTT.sh startet seither die Brücke statt der wattpilotshell direkt.

Seit dem 04.09.2026 trägt dieselbe Brücke auch die Gegenrichtung. Sie nimmt Kommandos auf wattpilot/properties/<schlüssel>/set entgegen und meldet auf <schlüssel>/result, ob die Wallbox sie genommen hat — dasselbe Muster, das der go-e von Haus aus spricht. Das Dashboard steuert damit beide Wallboxen über einen Weg (restricted/wallboxen.php) und startet für die Wattpilot keinen eigenen Python-Prozess mehr. Nötig waren dafür drei Reparaturen an der Bibliothek: Zahlen kamen als Zeichenkette bei der Wallbox an (value must be uint8_t), ein unbekannter Name und jede Eigenschaft ohne rw-Angabe — ftt und fte zum Beispiel — töteten den paho-Callback, und eine Rückmeldung gab es überhaupt nicht.

fetch_calendar.py gehört einmal jährlich in den Cron:

0 4 1 1 * /usr/bin/python3 /volume1/homes/wagner/SolarManager/autoActions/fetch_calendar.py

Ein zusätzlicher Lauf im Herbst schadet nicht — die Ferientermine des übernächsten Schuljahres stehen erst später fest.

Nachsehen, was passiert ist

automation_log hält je Auslösung fest, ob sie durchlief (fired), wegen force_once nachgeholt wurde (forced) oder scheiterte (error, mit Grund in detail). Einträge älter als 30 Tage räumt der Runner selbst weg.