Files
SolarManager/autoActions/README.md
T
adminandClaude Opus 5 32e571099b Ferien und Feiertage dreiwertig, dazu eine Tagessperre
"An Wochenenden und Feiertagen" liess sich bisher gar nicht schreiben.
Die Wochentagsmaske kennt nur Samstag und Sonntag, und ein Feiertag am
Dienstag ist fuer sie eben ein Dienstag; on_holiday war ja/nein und
konnte nur wegnehmen, nie hinzufuegen.

on_vacation und on_holiday tragen deshalb jetzt drei Werte: 0 nie,
1 egal (Vorgabe), 2 zusaetzlich. Der dritte zaehlt wie ein angehakter
Wochentag - Sa+So angehakt und "Feiertage: zusaetzlich" ergibt den
gewuenschten Fall, und mit gar keinem angehakten Tag sogar "nur an
Feiertagen".

In tag_passt() wird erst geoeffnet, dann gesperrt: ein Verbot schlaegt
eine Erweiterung. Wer in den Ferien nie laeuft und an Feiertagen
zusaetzlich, laeuft an einem Feiertag in den Ferien nicht - andersherum
liesse sich "nie" nicht mehr verlassen.

Dazu once_per_day: nach dem Ausloesen bis Mitternacht Ruhe. Fuer alles,
was man hinterher von Hand wieder anders stellt - ein Rollladen, den man
um acht zugezogen hat, soll nicht um neun von selbst wieder auffahren,
weil eine Wolke weiterzieht und die Helligkeitsschwelle ein zweites Mal
steigt. Die Sperrzeit taugte dafuer nicht: sie zaehlt Sekunden und
muesste auf 86400 stehen, womit eine Automatik, die heute um 07:00:05
lief, morgen um 07:00:00 noch gesperrt waere und einen ganzen Tag
ausfiele. Gefragt wird deshalb nach dem Datum von last_run - dieselbe
Funktion, die auch force_once benutzt, nur mit umgekehrtem Vorzeichen.

Die Spalten bleiben TINYINT 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. rahmen_erweitern.sql
setzt nur Kommentare und legt once_per_day an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:07:36 +02:00

477 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#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.
## 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
```bash
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.