wattpilotshell.log hatte 329 MB erreicht - die wattpilotshell meldet seit Monaten im Sekundentakt denselben Verbindungsfehler, und rotiert wurde nie. logs_rotieren.sh mit logrotate.conf raeumt das jetzt taeglich auf: vierzehn Staende, alles ueber 20 MB sofort, damit ein Prozess in einer Fehlerschleife nicht an einem Tag das Volume fuellt. Der erste Lauf hat aus den 329 MB 61 KB gemacht, verloren ist nichts. Nicht in /etc/logrotate.d abgelegt - das ist DSM-Gebiet und beim naechsten Systemupdate weg. Der Zustand kommt aus einer eigenen Datei statt aus /var/lib/logrotate, wo der von DSM liegt. Die Startskripte leiten dafuer mit ">>" um statt mit "&>". Die Prozesse laufen monatelang durch und sollen fuer eine Rotation nicht neu starten muessen, deshalb copytruncate - der Inhalt wird weggeschrieben und die Datei geleert, waehrend sie offen bleibt. Ohne Anhaengemodus schriebe der Prozess danach an seiner alten Stelle weiter und die Datei bekaeme vorn ein Loch aus Nullbytes. Nebenbei behoben: startWecker.sh leerte seine Logdatei bei jedem Start, und gestartet wird der Wecker jede Nacht um drei. Was er am Vortag gemeldet hatte, war morgens nicht mehr nachzulesen. Die Skripte bekommen ausserdem ihr Ausfuehrungsrecht in den Index - ueber die Windows-Freigabe sieht Git keine Unix-Rechte, ein frischer Checkout haette sie nicht starten koennen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
13 KiB
Markdown
281 lines
13 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.
|
||
|
||
`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.
|