Die Wallbox schickt 34 Statusfelder, die die API-Beschreibung von wattpilot 0.2 nicht kennt. Die Bibliothek schlug jedes Feld ungeprueft nach und lief in einen KeyError, der die restliche Schleife mitriss - alle Werte hinter dem unbekannten Feld derselben Nachricht kamen nie beim MQTT-Broker an. Im Dauerbetrieb traf es alle 15 Sekunden fhz, lps und tpcm, nach einem Verbindungsaufbau deutlich mehr; sichtbar war es nur als Fehlerpaar 'clea' im Log, rund ein MB am Tag. wattpilot_bruecke.py ersetzt die betroffene Funktion zur Laufzeit und ueberspringt unbekannte Felder, jedes einmal mit einer Warnung. Die Reparatur steht hier und nicht in site-packages, weil eine Neuinstallation sie dort spurlos zurueckdrehen wuerde und wattpilot 0.2 seit Mai 2022 die letzte Veroeffentlichung ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
294 lines
14 KiB
Markdown
294 lines
14 KiB
Markdown
# 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_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.
|
||
|
||
Ü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.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
|
||
|
||
```bash
|
||
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. `wattpilot_bruecke.py` ersetzt
|
||
die betroffene Funktion zur Laufzeit und überspringt unbekannte Felder, jedes
|
||
einmal mit einer Warnung im Log. `startWattpilotMQTT.sh` startet seither die
|
||
Brücke statt der `wattpilotshell` direkt.
|
||
|
||
`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.
|