Der Schreibweg wattpilot/properties/<schluessel>/set war unbenutzbar. Die
Bibliothek reichte Zahlen als Zeichenkette an die Wallbox weiter
("value must be uint8_t"), griff bei unbekannten Namen und bei
Eigenschaften ohne rw-Angabe ungeprueft zu - ftt und fte haben keine, ein
Klick auf die Ladeplanung haette den paho-Callback getoetet - und meldete
nichts zurueck.
Jetzt bekommt der Wert den Typ aus der API-Beschreibung, so dass die
Wallbox 'lmo = 4' genauso nimmt wie 'lmo = Awattar'. Unbekanntes und
Schreibgeschuetztes wird abgelehnt statt zu werfen, und das Ergebnis geht
auf <schluessel>/result - genau wie es der go-e tut. Damit laesst sich das
Dashboard auf einen Weg fuer beide Wallboxen umstellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
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.
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_valuewird 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.
Ü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.
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.
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 | – |
Alle fünf stehen in transports.py. Eine sechste 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.sqleinmal eingespielt- Im Web-Verzeichnis muss in
restricted/deviceDiscovery/config.iniclear_tables = falsestehen. Discovery schreibt mitON DUPLICATE KEY UPDATEauf den URLs, das Leeren ist unnötig — einTRUNCATEwü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
python3 fetch_calendar.py # Feiertage und Ferien holen
python3 autoaction_runner.py --once --dry-run --verbose # Probelauf
--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
└── 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.