# Die Automatiken (AutoActions) Eine Automatik ist ein Satz: **„Wenn … , dann …, aber nur wenn der Rahmen passt.“** Sie wird im Browser gebaut, liegt in der Datenbank `homeMesh` und wird von einem Dauerprozess auf der NAS ausgeführt. ``` Rahmen Auslöser Aktionen ────── ──────── ──────── Mo–Fr, Uhrzeit um 07:00 Magdalena Fenster: Auf nicht in Ferien, UND Außentemperatur > −10 °C Magdalena Tür: Auf 06:00–09:00, Sperrzeit 15 min ``` Beteiligt sind drei Stellen: | Wo | Datei | Aufgabe | |---|---|---| | Browser | `js/solar/autoActionFuncs.js` | Editor: Modell bauen, zeichnen, abschicken | | Web-Server | `restricted/automations.php`, `ajax/AutoAction.php` | Laden, Prüfen, Speichern, Beschreiben | | NAS | `SolarManager/autoActions/autoaction_runner.py` | Auswerten und Ausführen | ![Der Automatik-Editor](bilder/editor.png) --- ## 1. Datenmodell Alles steht in `homeMesh` — dort, wo auch die Geräte liegen, damit Fremdschlüssel greifen können. Schema: [`homeMesh_automations.sql`](../homeMesh_automations.sql). ```mermaid erDiagram actors ||--o{ actor_states : "hat Messwerte" actors ||--o{ actor_commands : "hat Kommandos" actor_commands ||--o{ command_parameters : "hat Parameter" automations ||--o{ automation_conditions : "Auslöser" automations ||--o{ automation_actions : "Aktionen" automations ||--o{ automation_log : "Protokoll" automation_actions ||--o{ automation_action_params : "Werte" actor_states ||--o{ automation_conditions : "state_id" actor_commands ||--o{ automation_actions : "command_id" command_parameters ||--o{ automation_action_params : "parameter_id" automations { int id varchar name varchar floor "Reiter in der Übersicht" tinyint enabled "pausiert oder nicht" time window_from "Rahmen: Zeitfenster" time window_to tinyint weekdays "Bitmaske Mo..So" tinyint on_vacation "0 nie, 1 egal, 2 zusätzlich" tinyint on_holiday "0 nie, 1 egal, 2 zusätzlich" tinyint next_day "Rahmen gilt für morgen" tinyint force_once "am Fensterende notfalls doch" tinyint once_per_day "höchstens einmal am Tag" int lockout_secs "Sperrzeit nach einem Lauf" tinyint cond_met "Laufzustand: war die Bedingung zuletzt wahr?" datetime last_run "Laufzustand: wann zuletzt ausgelöst" timestamp changed "Signal an den Runner" } automation_conditions { int automation_id int group_no "gleich = UND, verschieden = ODER" int state_id "zeigt auf actor_states" enum operator "ASCII, nie das hübsche Zeichen" varchar value "Schwelle als Text" } ``` Drei Felder sind **nachgerüstet** und stehen deshalb nicht in der Schema-Datei, sondern in eigenen Skripten im SolarManager-Repo (`autoActions/`): `next_day` (`vorabend.sql`), `once_per_day` und die Dreiwertigkeit von `on_vacation`/`on_holiday` (`rahmen_erweitern.sql`), sowie der Datentyp `elapsed` samt gerechnetem Gerät „Automatiken“ (`automatik_ausloeser.sql`). Wer die Datenbank neu aufsetzt, spielt sie nach `homeMesh_automations.sql` ein. Drei Spalten in `automations` sind **kein Regelwerk, sondern Laufzustand**: `cond_met`, `last_run` und `changed`. Sie werden vom Runner geschrieben. Wer eine Automatik per SQL kopiert, sollte sie leeren. ### Warum `state_id` und nicht „Gerät + Feld“ Eine Bedingung zeigt mit einer einzigen Zahl auf `actor_states`. Damit hängen Gerät, Messwertname, Datentyp, Einheit und Quelle (Topic, HTTP-Feld, Tahoma) an dieser Zeile. Der Editor muss nichts davon wissen: Er listet Geräte und deren Messwerte. Genau deshalb funktioniert auch die Verkettung (→ [Abschnitt 6](#6-verkettung-eine-automatik-löst-die-nächste-aus)) ohne eine einzige Sonderregel im Editor. --- ## 2. Der Editor Der Editor arbeitet **auf einem Modell und zeichnet daraus die Oberfläche** — nicht umgekehrt: ```js autoModel.groups = [ [ {state_id, operator, value}, … ], … ] // innen UND, außen ODER autoModel.actions = [ {command_id, params: {parameterID: wert}}, … ] ``` Der Gerätekatalog (`deviceCatalog()`) kommt **im selben Dokument** mit, es gibt also kein Nachladen je Zeile. ```mermaid sequenceDiagram autonumber participant B as Browser
autoActionFuncs.js participant A as ajax/AutoAction.php participant M as restricted/automations.php participant DB as homeMesh B->>A: GET ?action=editor&id=42 A->>M: loadAutomation(42) + deviceCatalog() M->>DB: SELECT automations / conditions / actions A-->>B: HTML + Katalog + Datensatz (ein Dokument) Note over B: autoModel füllen, rendern loop alle 8 s, solange der Editor offen ist B->>A: GET ?action=werte&ids=… A->>DB: SELECT current_value FROM actor_states A-->>B: aktuelle Messwerte → Punkte neben den Zeilen end B->>A: POST ?action=save (JSON) A->>M: saveAutomation() M->>M: pruefeKreis() M->>DB: UPDATE automations; DELETE+INSERT conditions/actions A-->>B: {ok, id} Note over DB: changed = NOW() → der Runner lädt neu ``` Drei Eigenheiten, die man kennen sollte: * **Speichern heißt löschen und neu einfügen.** Bedingungen und Aktionen bekommen dabei neue IDs. Deshalb reicht `changed` allein dem Runner nicht, um Änderungen zu erkennen (→ [Abschnitt 4](#4-der-runner)). * **Live-Werte neben jeder Bedingung** kommen aus `actor_states`, nicht aus MQTT: Über den Broker kommt nur ein Teil der Geräte (Jalousien hängen an der Tahoma-Box, gerechnete Werte an gar nichts). Eine Tabelle, ein Zugriff, alle Gerätearten. * **Kreise werden beim Speichern abgelehnt** (`pruefeKreis()`), nicht im Betrieb geheilt. Im Protokoll sähe ein Kreis nur wie „läuft halt oft“ aus. ### Der Rahmen in Worten `rahmenText()` im Editor und `describeConditions()` auf dem Server erzeugen denselben Satz in Alltagssprache („werktags, 06:00–09:00, höchstens einmal am Tag“). Wer die Bedeutung eines Feldes ändert, muss beide anfassen — sonst steht in der Übersicht etwas anderes als im Editor. --- ## 3. Die drei Teile einer Automatik ### 3.1 Rahmen: darf sie überhaupt? | Feld | Bedeutung | Fallstrick | |---|---|---| | `enabled` | pausiert oder nicht | Pausierte laden der Runner gar nicht erst — ihre Nachfolger stehen mit still | | `weekdays` | Bitmaske, Bit 0 = Montag | | | `on_vacation`, `on_holiday` | **dreiwertig**: 0 nie, 1 egal, 2 zusätzlich | „zusätzlich“ zählt wie ein passender Wochentag; ein Verbot schlägt eine Erweiterung | | `next_day` | Der Rahmen gilt für **morgen** (Vorabend-Form) | Uhrzeit und Fenster bleiben bei heute | | `window_from`/`window_to` | Zeitfenster; `von > bis` heißt über Mitternacht | | | `lockout_secs` | Sperrzeit nach einem Lauf (0 / 60 / 900) | | | `once_per_day` | höchstens ein Lauf je Kalendertag | | | `force_once` | am Ende des Fensters notfalls doch ausführen | greift nur, wenn im Fenster gar nichts lief | Warum dreiwertig? „Wochenenden **und** Feiertage“ ließ sich vorher nicht schreiben: Die Wochentagsmaske kennt nur Sa und So, und ein Feiertag am Dienstag ist eben ein Dienstag. Mit „Feiertage: zusätzlich“ kommt er über den Kalender (`calendar_days`, gefüllt von `fetch_calendar.py`) herein. Warum `next_day`? „Kinderrollos zu, wenn **morgen** Schule ist“ ließ sich mit dem heutigen Tag nur annähern (So–Do, heute keine Ferien) — und das ging am letzten Ferientag, am Abend vor einem Feiertag und am Abend eines Feiertags daneben. ### 3.2 Auslöser: `any(all(gruppe))` Bedingungen mit derselben `group_no` sind mit **UND** verknüpft, verschiedene Gruppen mit **ODER**. Im Editor ist eine Gruppe ein gerahmter Block und zwischen den Blöcken steht ein ODER — die Klammerung ist also gezeichnet und nicht bloß vereinbart. > Eine Automatik **ohne** Bedingung löst nie aus. Sonst würde sie nach einem > Gerätesuchlauf, der ihren Messwert entfernt hat, plötzlich dauernd feuern. ### 3.3 Aktionen Eine Aktion ist ein Kommando eines Geräts plus je Parameter ein Wert (`automation_action_params`, eine Zeile je Parameter statt fester Spalten). Geschickt wird über denselben Transport-Zoo wie beim Lesen (→ [Abschnitt 4](#4-der-runner)); Jalousien bekommen dabei die Kugelschreiber-Mechanik (erst Neigung 0, dann das Ziel), siehe `TahomaTransport` und `sendeTahoma()` in `restricted/commands.php`. --- ## 4. Der Runner `autoaction_runner.py` läuft als Dauerprozess. ```mermaid flowchart TB START([Start]) --> LOAD["Regelwerk laden
Automatiken, Messwerte, Kommandos"] LOAD --> SUB["MQTT abonnieren
Sammlerfäden starten"] SUB --> TAKT subgraph TAKT["Takt, alle tick_seconds (10 s)"] direction TB T1["Werte einsammeln
MQTT-Puffer + Queue der Sammler"] T2["durchlauf(): jede Automatik prüfen"] T3["Ergebnisse des Versands verbuchen"] T4["alle reload_seconds: Signatur vergleichen"] T1 --> T2 --> T3 --> T4 end T4 -- "Signatur geändert" --> LOAD T2 -- "Aktionen" --> VERSAND[["Versand
je Gerät der Reihe nach,
über Geräte hinweg parallel
"]] SAMMLER[["Sammler
HTTP/WLED 1 min,
Tahoma 5 min
"]] -. "Queue" .-> T1 ``` **Warum ein Dauerprozess und nicht Cron?** 1. Schwellwerte („Temperatur über 22 °C“) sollen greifen, wenn die Nachricht hereinkommt — nicht im nächsten Minutenraster. 2. Nur so gibt es einen Vorzustand für die steigende Flanke. 3. `actor_states.current_value` würde sonst niemand fortschreiben. **Warum drei Fäden?** Früher wurde mitten in der Hauptschleife gepollt: neunzehn Tahoma-Geräte nacheinander, jedes mit bis zu zehn Sekunden Zeitlimit. Eine Runde dauerte 45 statt 30 Sekunden, dadurch kam **jede dritte Minute nie vor** — und ein Auslöser „um 18:26“ wurde schlicht nie wahr. Heute hat jeder Transport seinen eigenen Faden, die Hauptschleife macht nur noch Billiges, und die Uhr wird gar nicht mehr abgetastet, sondern gegen `datetime.now()` verglichen. ### Transporte | URL des Aktors | Transport | Lesen | Schreiben | |---|---|---|---| | `mqtt://…` | MQTT | abonniert, gepuffert | `publish` | | `http://…` | HTTP | Poll, 1 min | Abfrageargumente | | `wled://…` | WLED | Poll, 1 min | JSON an `/json/state` | | `io://`, `rts://`, `internal://`, `ogp://` | Tahoma | Poll, 5 min | `exec/apply` | | `Logic` | gerechnet | Uhrzeit, Datum, Sonnenauf-/-untergang | — | | `Automatik` | gerechnet | letzte Auslösung je Automatik | — | Ausnahme beim Lesen: Steht in `actor_states.url` ein **Topic**, liest der MQTT-Transport — auch wenn das Gerät über HTTP geschaltet wird. So sind die Shellys der zweiten Generation angebunden. ### Woran der Runner merkt, dass er neu laden muss Nicht an `changed` allein — eine gelöschte Automatik ändert den größten Zeitstempel nicht, und wer nur die Uhrzeit einer Bedingung verstellt, ändert in `automations` gar keine Spalte. Stattdessen eine **Signatur**: Zeilenzahl plus Summe der `CRC32` je Zeile, über Bedingungen, Aktionen, Aktionsparameter *und* die Geräte­tabellen (dort auch `url` und `value_path`, weil ein Discovery-Lauf bestehende Zeilen umschreibt). --- ## 5. Die Auswertung im Detail Das Herzstück, `durchlauf()`, für jede Automatik in topologischer Reihenfolge (Auslöser vor Nachfolger): ```mermaid flowchart TB A["Tag bestimmen
heute, mit next_day morgen"] --> B{"tag_passt?
Wochentag, Ferien, Feiertag"} B -- nein --> Z1 B -- ja --> C{"im Zeitfenster?"} C -- nein --> Z1["Fenster zu:
force_once prüfen,
Flanke zurücksetzen"] C -- ja --> D{"Bedingung erfüllt?
any(all(gruppe))"} D -- nein --> E["cond_met = 0"] D -- ja --> F{"cond_met schon 1?"} F -- ja --> G["nichts tun
keine neue Flanke"] F -- nein --> H{"once_per_day
und lief heute?"} H -- ja --> G H -- nein --> I{"innerhalb
lockout_secs?"} I -- ja --> J["Flanke verwerfen"] I -- nein --> K["ausloesen()"] K --> L["Aktionen in den Versand,
last_run + Protokoll,
eigenen Auslöserwert setzen"] ``` ### Nur die steigende Flanke `cond_met` hält fest, ob die Bedingung beim letzten Durchlauf schon erfüllt war. Ohne das würde „Temperatur über 22 °C“ in jedem Takt erneut feuern. ### Die drei Zeitformen | Schreibweise | Operator | Bedeutung | Gedacht für | |---|---|---|---| | `um 16:30` | `=` | ab dieser Minute, plus **Nachholfenster** (5 min) | der Normalfall: einmal täglich zu einem Termin | | `ab 16:30` | `>=` | von da bis Mitternacht wahr | alles, was auch nach einem verpassten Takt noch laufen soll (Bewässerung) | | `vor 16:30` | `<` | bis dahin wahr | als Zusatzbedingung | Ausgelöst wird in allen drei Fällen **höchstens einmal** — wegen der Flanke. Das Nachholfenster ersetzt die früher verlangte Punktgenauigkeit: Ein Neustart oder ein hängendes Gerät kostet die Automatik nicht mehr den ganzen Tag. Gerechnet wird modulo 24 h, damit ein Ziel um 23:58 auch um 00:01 noch zieht. ``` Takt: ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ (alle 10 s) │ │ um 16:30 └── wahr ──────┘ (16:30:00 bis 16:34:59, Nachholfenster) ↑ Flanke: genau hier wird ausgelöst ab 16:30 └── wahr ───────────────────────────────────► 23:59 ↑ Flanke: ebenfalls nur einmal ``` ### Sonnenstand und Ketten Beim Sonnenauf-/-untergang trägt der Operator zusätzlich **das Vorzeichen des Versatzes**: `+ 00:30` = eine halbe Stunde danach, `>=- 00:30` = ab einer halben Stunde davor, `<+ 00:30` = bis eine halbe Stunde danach. Über den Tagesrand wird gerechnet und nicht abgeschnitten — „sechs Stunden vor Sonnenaufgang“ landet dann eben am Vorabend. Beim Datentyp `elapsed` (Verkettung) ist es fast dasselbe, mit einem wichtigen Unterschied: Verglichen wird der **echte Abstand**, nicht die Uhrzeit im Tag. 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. `ab + 00:10` gilt zusätzlich nur am selben Kalendertag. ### Sperrzeit, „einmal am Tag“, „auf jeden Fall“ | Mittel | Wogegen | Verhalten | |---|---|---| | `lockout_secs` | Messwerte, die um die Schwelle pendeln (22,1 / 21,9 / 22,1) | Die Flanke wird **verworfen**, nicht aufgehoben — ein Rollladen, der eine Viertelstunde später doch losfährt, wäre unangenehmer als einer, der gar nicht fährt | | `once_per_day` | Dinge, die man hinterher von Hand anders stellt | Nach dem ersten Lauf ist bis Mitternacht Ruhe. Die Sperrzeit taugt dafür nicht: sie zählt Sekunden und verfehlte am nächsten Morgen den Termin | | `force_once` | verpasste Fenster | Beim Zugehen des Fensters, aber nur, wenn darin nichts lief (zusätzlich gegen `last_run` geprüft, damit ein Neustart nicht doppelt auslöst) | --- ## 6. Verkettung: eine Automatik löst die nächste aus Das gerechnete Gerät **„Automatiken“** führt jede Automatik als Messwert; ihr Wert ist der Zeitpunkt der letzten Auslösung (Datentyp `elapsed`, URL `auto:`). Damit ist eine Automatik für den Editor ein Messwert wie jeder andere — „Wecker Magdalena + 00:10“. ```mermaid flowchart LR W["Wecker Magdalena
um 07:00"] -->|"+ 00:10"| R["Magdalena Rollos
und es ist hell"] R -->|"+ 00:05"| S["Schlafzimmer
Tür auf"] ``` Warum nicht einfach eine Verzögerung an der Aktion? Weil der Nachfolger eine **vollwertige Automatik mit eigenem Rahmen** bleiben soll: „zehn Minuten später, aber nur wenn es dann schon hell ist“ ließe sich als bloße Verzögerung nicht formulieren — die Zusatzbedingung gilt erst zum späteren Zeitpunkt. Was daran hängt: * **Reihenfolge:** `reihenfolge_bestimmen()` sortiert topologisch, damit ein Versatz von null noch im selben Takt greift. Nach dem Auslösen trägt der Runner den eigenen Auslöserwert sofort nach. * **Pausieren wirkt weiter:** Der Runner lädt nur `enabled = 1`. Wer den Wecker pausiert, lässt morgens auch die Rollos unten — gewollt. * **Anlegen/Löschen der Messwerte** macht `ausloeser_nachfuehren()` vor jedem Regelwerk-Laden. Angelegt wird auch für pausierte Automatiken, sonst löschte `ON DELETE CASCADE` still die Bedingung des Nachfolgers. * **Löschen mit Anhang:** `deleteAutomation($id, "abhaengen"|"mitloeschen")`. Der Editor fragt nach, wenn Nachfolger existieren. * **Kreise** lehnt der Server beim Speichern ab; der Runner meldet sie nur und lässt die Beteiligten weiterlaufen. --- ## 7. Protokoll und Fehlerbilder `automation_log` bekommt je Lauf eine Zeile (`fired` / `forced`) und je fehlgeschlagenem Kommando eine weitere (`error`). Einträge älter als 30 Tage räumt der Runner selbst weg. | Beobachtung | Wahrscheinliche Ursache | |---|---| | Automatik läuft gar nicht, ohne Protokolleintrag | Rahmen: falscher Wochentag, Ferien/Feiertag auf „nie“, Fenster zu eng — oder die Bedingung war nie *neu* erfüllt (`cond_met` stand schon auf 1) | | Läuft einmal und dann nicht mehr am selben Tag | `once_per_day` | | Läuft kurz nacheinander nicht erneut | `lockout_secs` | | Kette bleibt stehen | Auslöser pausiert oder an dem Tag nicht dran | | Änderung im Editor wirkt nicht | Regelwerk-Signatur: erst beim nächsten `reload_seconds`-Fenster (30 s) | | `error` im Protokoll | Gerät nicht erreichbar, Kommando oder Transport gibt es nicht mehr | --- ## 8. Wo fange ich an, wenn ich … | Vorhaben | Ort | |---|---| | … einen neuen Operator oder Datentyp anbieten | `operatorsForType()` in `restricted/automations.php` **und** `bedingung_erfuellt()` im Runner | | … ein neues Rahmenfeld einführen | Spalte in `homeMesh_automations.sql`, `saveAutomation()`/`loadAutomation()`, Editor (`renderRahmen`, `leseFormular`), `durchlauf()` im Runner, Satz in `rahmenText()` | | … eine neue Geräteart anschließen | eine Klasse in `SolarManager/autoActions/transports.py` (Lesen + Senden), Erkennung an der Aktor-URL | | … verstehen, warum etwas lief | `automation_log`, dann die Zeitleiste des Tages (→ [zeitleiste.md](zeitleiste.md)) | | … das Nachholfenster ändern | `catchup_minutes` in `config.ini` des Runners |