# 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 |

---
## 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ätetabellen (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 |