diff --git a/README.md b/README.md index 9cc32f2..72596fc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ Dieses Verzeichnis ist zugleich der Web-Root der NAS Der Arbeitsordner *ist* die laufende Anwendung — jede gespeicherte Datei ist sofort live. +Für zwei Teile reicht diese Landkarte nicht, weil ihr Verhalten aus dem +Zusammenspiel von Editor, Datenbank und Runner entsteht. Sie haben eigene, +ausführliche Dokumente unter [`doku/`](doku/README.md): +**[Automatiken](doku/automatiken.md)** und +**[Zeitleiste](doku/zeitleiste.md)**. + --- ## Inhalt diff --git a/doku/README.md b/doku/README.md new file mode 100644 index 0000000..64c7690 --- /dev/null +++ b/doku/README.md @@ -0,0 +1,79 @@ +# Vertiefende Dokumentation + +Die [README im Wurzelverzeichnis](../README.md) beschreibt das Ganze: welche +Seite es gibt, welche Datei was tut, wo eine Zahl herkommt. Sie ist die +Landkarte. + +Hier liegen die **Tiefenbohrungen** — für die Teile, bei denen die Landkarte +nicht reicht, weil das Verhalten aus dem Zusammenspiel mehrerer Prozesse +entsteht und die Regeln nicht aus dem Quelltext einer einzelnen Datei +abzulesen sind. + +| Dokument | Worum es geht | +|---|---| +| [automatiken.md](automatiken.md) | Die Automatiken (AutoActions): Datenmodell, Editor, Runner, wie eine Bedingung wirklich ausgewertet wird, Verkettung, Sperren, Fehlerbilder | +| [zeitleiste.md](zeitleiste.md) | Die Zeitleiste der Automatiken: wie der Server den Tag ausrechnet und wie der Browser ihn zeichnet | + +--- + +## Die drei Prozesse, die zusammenspielen + +Nichts hier ist eine einzelne Anwendung. Drei Dinge laufen gleichzeitig, und +sie reden nur über MQTT und über die Datenbanken miteinander — nie direkt. + +```mermaid +flowchart LR + subgraph Browser["Browser"] + UI["Weboberfläche
PHP + JS, /volume1/web/smart"] + end + + subgraph NAS["NAS, Hintergrundprozesse (SolarManager)"] + RUN["autoaction_runner.py
führt Automatiken aus"] + SM["solarManager.py
sammelt Anlagenwerte"] + SK["gatherSkodaData.py
Fahrzeug"] + end + + subgraph Speicher["Speicher"] + MQTT[["MQTT-Broker
alles Aktuelle"]] + HM[("homeMesh
Geräte, Automatiken, Grundriss")] + SL[("solarLog
Messreihen, Sonnenzeiten")] + end + + subgraph Geraete["Geräte"] + TAH["Tahoma-Box
Jalousien"] + SHE["Shelly, WLED, ESP32"] + end + + UI -- "liest/schreibt Regeln" --> HM + UI -- "abonniert" --> MQTT + UI -- "schaltet von Hand" --> TAH & SHE + RUN -- "Regelwerk" --> HM + RUN -- "Messwerte zurück" --> HM + RUN -- "Sonnenzeiten" --> SL + RUN -- "hört + schaltet" --> MQTT + RUN -- "liest + schaltet" --> TAH & SHE + SM --> MQTT & SL + SK --> SL +``` + +**Wichtig für das Verständnis aller folgenden Kapitel:** + +* Die **Weboberfläche schaltet nichts von selbst.** Sie beschreibt, was + gelten soll (`homeMesh`), und zeigt, was ist. Ausgeführt wird im Runner. +* **`actor_states.current_value` pflegt der Runner.** Ohne ihn stünde dort + der Wert vom Tag des Gerätesuchlaufs. Deshalb zeigt auch der Editor + aktuelle Zahlen, obwohl er nur eine Tabelle liest. +* Der Runner ist ein **Dauerprozess, kein Cronjob** — er braucht den + Vorzustand (`cond_met`), um steigende Flanken zu erkennen. + +--- + +## Lesereihenfolge + +1. [README](../README.md), Abschnitt „Auf einen Blick“ — Seiten und Dateien. +2. [automatiken.md](automatiken.md) — das Regelwerk und seine Ausführung. +3. [zeitleiste.md](zeitleiste.md) — die Darstellung desselben Regelwerks + über einen Tag. + +Wer nur etwas ändern will, findet die Einstiegspunkte am Ende beider +Dokumente unter „Wo fange ich an, wenn ich …“. diff --git a/doku/automatiken.md b/doku/automatiken.md new file mode 100644 index 0000000..d199eeb --- /dev/null +++ b/doku/automatiken.md @@ -0,0 +1,409 @@ +# 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 | diff --git a/doku/bilder/editor.png b/doku/bilder/editor.png new file mode 100644 index 0000000..ec294d6 Binary files /dev/null and b/doku/bilder/editor.png differ diff --git a/doku/bilder/zeitleiste.png b/doku/bilder/zeitleiste.png new file mode 100644 index 0000000..bcf6764 Binary files /dev/null and b/doku/bilder/zeitleiste.png differ diff --git a/doku/zeitleiste.md b/doku/zeitleiste.md new file mode 100644 index 0000000..bb73f77 --- /dev/null +++ b/doku/zeitleiste.md @@ -0,0 +1,236 @@ +# Die Zeitleiste der Automatiken + +Die Übersicht als Tabelle beantwortete nicht, was man eigentlich wissen will: +**Läuft der Tag stimmig, und warum ist etwas noch nicht gefahren?** Die +Zeitleiste legt deshalb jede Automatik auf die Stunde, zu der sie greift. + +![Die Zeitleiste](bilder/zeitleiste.png) + +Zwei Dateien teilen sich die Arbeit, und die Trennung ist scharf: + +| Datei | Aufgabe | +|---|---| +| `restricted/zeitleiste.php` | **rechnen**: wo liegt eine Automatik auf dem Tag, in welcher Bahn, ist sie heute dran, ist sie gelaufen | +| `js/solar/zeitleiste.js` | **zeichnen**: Bahnen, Stapeln, Etiketten, Kettenbögen, Karte, Filter | + +Geliefert wird über `ajax/AutoAction.php?action=zeitleiste&datum=YYYY-MM-DD` +als JSON. + +--- + +## 1. Was der Server ausrechnet + +```mermaid +flowchart TB + A["Alle Automatiken laden
loadAutomation() je Id"] --> B["Bedingungen je ODER-Gruppe einsortieren"] + B --> C{"Was trägt die Gruppe?"} + C -->|"Uhrzeit =, Sonne ±"| P["punkt"] + C -->|"Uhrzeit >= / <"| F["start / ende"] + C -->|"Automatik + Versatz"| K["kette + versatz"] + C -->|"alles andere"| S["sensor = true
wartet auf einen Messwert"] + P & F & K & S --> D["Art bestimmen:
punkt › kette › fenster › jederzeit"] + D --> E["Ketten auflösen
bis zu 6 Runden"] + E --> G["Bahn, Satz, Tagesprüfung, Läufe"] + G --> H[["JSON"]] +``` + +### 1.1 Die Regeln, wie eine Lage entsteht + +| Bedingung | Ergebnis auf dem Tag | +|---|---| +| feste Uhrzeit `um 07:00` | **Punkt** | +| Sonnenauf-/-untergang ± Versatz | **Punkt**, an diesem Tag ausgerechnet | +| andere Automatik `+ 00:10` | **Kette**: Punkt hinter dem Auslöser | +| `ab …` / `vor …` oder ein eingeengtes Zeitfenster | **Balken** über das Fenster | +| nur Messwerte, ganzer Tag | **„jederzeit“** (eigenes Band unter der Leiste) | + +Mehrere Gruppen (ODER) werden zusammengefasst: Gibt es Punkte, gewinnt der +früheste; sonst eine Kette; sonst das umschließende Fenster aus allen +Gruppengrenzen. Trägt eine Gruppe einen zeitlich bestimmten Beginn (etwa +„Sonnenuntergang + 30 **oder** dunkel“), merkt sich der Eintrag ihn als +`punkt` — er erscheint im Hinweis als „frühestens 19:01“. + +### 1.2 Ketten auflösen + +Die Lage eines Nachfolgers hängt vom Auslöser ab, und die Kette kann mehrere +Stufen haben (Wecker → Rollos → Schlafzimmer). Deshalb läuft die Auflösung +reihum, höchstens sechs Runden: + +| Zustand des Auslösers | Nachfolger | +|---|---| +| ist an diesem Tag schon **gelaufen** | Punkt bei der echten Laufzeit + Versatz | +| hat einen **Punkt** | Punkt dort + Versatz | +| ist ein **Fenster** | Fenster ab dessen Beginn + Versatz | +| hat selbst keine Lage / Kreis | „jederzeit“ | + +Immer begrenzt auf das eigene Zeitfenster. Und: Der Nachfolger **erbt**, ob +der Tag passt — läuft der Wecker heute nicht, steht auch das Rollo +gestrichelt da, mit dem Grund „Auslöser ‚Wecker Magdalena‘ ist nicht dran“. + +### 1.3 Ist sie heute überhaupt dran? + +`zlTagPasst()` rechnet **genau wie der Runner** (`tag_passt()` und +`gemeinter_tag()`): erst öffnen (Wochentag, „zusätzlich“ an Ferien/Feiertagen), +dann sperren (Ferien/Feiertage auf „nie“), und bei `next_day` für morgen. +Sonst hieße „heute nicht“ hier etwas anderes als dort. + +> Ausgeblendet wird trotzdem nichts. Wer nicht dran ist, steht gestrichelt +> da — mit Grund im Hinweis. Eine Zeitleiste, die Einträge verschweigt, +> beantwortet die Frage „warum ist das nicht gefahren?“ gerade nicht. + +### 1.4 Sonnenzeiten für beliebige Tage + +`solarLog.daylight` kennt heute, morgen und die Vergangenheit. Für einen +späteren Tag nimmt `zlSonne()` denselben Kalendertag eines Vorjahres — die +Sonne verschiebt sich von Jahr zu Jahr um weniger als eine Minute, und so +braucht es keinen Standort, der in einem anderen Haus wieder falsch wäre. +Im Hinweis steht dann „(Vorjahr)“. + +### 1.5 Die Bahn + +Die Zeile, in der eine Automatik steht, ergibt sich aus den **Geräten, die +sie schaltet** — dieselbe Einordnung wie im Raum-Modal (`bedienform()` in +`roomControls.php`), Mehrheit gewinnt, bei Gleichstand die Bahn weiter oben. +Bewässerung ist dort „sonstiges“ und wird am Gerätetyp erkannt. + +Bahnen: Licht · Beschattung · Heizung · Bewässerung · Schalter · Sonstiges. + +### 1.6 Das JSON + +```jsonc +{ + "datum": "2026-09-21", "heute": true, "jetzt": 964, // Minuten seit Mitternacht + "sonne": { "auf": 431, "unter": 1176, "genau": true }, + "bahnen": { "licht": {"titel": "Licht", "symbol": "bi-lightbulb"}, … }, + "etagen": [ {"code": "OG", "label": "Obergeschoss"}, … ], + "automatiken": [{ + "id": 18, "name": "Hitzeschutz Süd", "floor": "EG", "enabled": true, + "dran": true, "grund": "", + "bahn": "beschattung", + "satz": "Wozi Schiebetür, Wozi Fensterfront +1: Position+Neigung", + "wenn": "Sonne Süd: Helligkeit > 30000 Lux und …", + "laeufe": ["12:50"], // aus automation_log + "art": "fenster", // punkt | fenster | kette | jederzeit + "punkt": 570, "von": 570, "bis": 1080, + "kette": null, "versatz": 0, + "sonne": false, // Lage kommt vom Sonnenstand + "wartet": true // hängt an einem Messwert + }] +} +``` + +--- + +## 2. Was der Browser daraus macht + +```mermaid +flowchart TB + L["zeitleisteLaden()
fetch ?action=zeitleiste"] --> Z["zeitleisteZeichnen()"] + Z --> F["Etagenfilter anwenden"] + F --> T["Dreiteilung:
gelegt · jederzeit · pausiert"] + T --> ST["je Bahn: zlStapeln()
Zeilen wie im Kalender"] + ST --> HTML["HTML bauen:
Achse, Bahnen, Bänder, Legende"] + HTML --> KET["zlKettenZeichnen()
SVG-Bögen, nach dem Layout"] + KET --> SCR["Scrollstand setzen"] + SCR --> KAR["offene Karte wieder öffnen"] +``` + +Jede Automatik erscheint **genau einmal**: in ihrer Bahn, unter „Jederzeit“ +oder unter „Pausiert“. Der Zähler oben („22 von 22 · 3 pausiert“) ist die +Probe aufs Exempel — fehlte eine, fiele es dort auf. + +### 2.1 Stapeln: warum in Pixeln und nicht in Minuten + +Zwei Punkte zehn Minuten auseinander liegen zeitlich getrennt — ihre +**Beschriftungen** aber übereinander. `zlStapeln()` rechnet deshalb in +Pixeln: Es misst den Etikett-Text (einmal ein Canvas, dann nur messen) und +legt jeden Eintrag in die erste Zeile, in der er nichts überdeckt. + +``` + Zeile 0 ●07:00 Wecker Magdalena ▭ 09:30–18:00 Hitzeschutz Süd ✓12:50 + Zeile 1 ●07:08 Wecker Rollos ●15:59 Hitzeschutz Süd zurück + └── Nachfolger direkt unter seinem Auslöser +``` + +Drei Sonderfälle stecken darin: + +* **Punkt am rechten Rand** trägt sein Etikett links von sich — die Marke + bleibt, wo die Zeit ist. +* **Fenster-Etikett** rückt nie vor seinen Balken; sonst sähe „15:00–18:31“ + aus, als finge es um halb elf an. Reicht der Platz nicht, wird der Name + gekürzt (voll steht er im Hinweis). +* **Ketten bleiben beisammen:** Eingesetzt wird familienweise — erst der + Auslöser, gleich danach seine Nachfolger in die erste freie Zeile + *darunter*. Je Zeile werden belegte Strecken geführt, nicht nur ihr Ende, + weil ein Nachfolger links von etwas liegen kann, das schon steht. + +### 2.2 Zustand, Farbe, Form + +`zlZustand()` kennt fünf Zustände — sie bestimmen Farbe und Zeichen: + +| Zustand | Wann | Darstellung | +|---|---|---| +| `nichtdran` | Tag passt nicht | gestrichelt, Kalendersymbol, Grund im Hinweis | +| `gelaufen` | es gibt Läufe an dem Tag | Haken und echte Laufzeit | +| `vorbei` | Zeit liegt hinter „jetzt“, nichts gelaufen | matt | +| `aktiv` | „jetzt“ liegt im Fenster / hinter dem Punkt | hervorgehoben | +| `kommt` | liegt noch vor uns | normal | + +Beim Punkt zeigt das Etikett nach einem Lauf die **echte** Zeit, beim Fenster +immer das Fenster und den Lauf hinten mit Haken — sonst stünde „12:50“ am +Balkenanfang bei 09:30. + +### 2.3 Kettenbögen + +Erst **nach** dem Einsetzen gezeichnet, weil die Lage beider Enden aus dem +Layout kommt. Die Linie verlässt den Auslöser lotrecht nach unten und biegt +mit einer kleinen Rundung von links in den Nachfolger ein; liegt der +Nachfolger links vom Auslöser, kommt sie von oben an. Jeder Pfad trägt +`data-von`/`data-nach`, damit das Überfahren eines Eintrags seine ganze Kette +hell schaltet (`zlKetteHervorheben()`). + +### 2.4 Die Karte + +Ein Klick auf einen Eintrag öffnet eine kleine Karte: was die Automatik tut, +wann, wie sie heute steht — und die drei Dinge, die man damit tun will +(Bearbeiten, Pausieren, Löschen). Pausieren und Löschen gehen über dieselben +Funktionen wie in der Liste, samt Rückfrage nach abhängigen Automatiken. + +Zwei Fallen stecken in dieser Karte, beide behoben: + +1. **Die Karte machte eine zweite Bildlaufleiste.** AdminLTE gibt + `main.app-main` `overflow: auto` bei Inhaltshöhe. Wurde die Karte zum + Messen kurz ohne Lage eingehängt, stand sie unterhalb aller Bahnen und + ragte aus `main` — Chrome zeigte dann dauerhaft eine eigene Leiste. + Heute wird sie unsichtbar oben links eingehängt, gemessen und erst dann + platziert. +2. **Die Karte verschwand sofort wieder.** Die neue Leiste machte die + Zeitleiste schmaler, der `ResizeObserver` zeichnete neu — und dabei ging + die Karte verloren. Heute merkt sich `zeitleisteZeichnen()`, welche Karte + offen war, und öffnet sie danach wieder. Außerdem klappt die Karte nach + oben, wenn sie unten aus `main` liefe. + +### 2.5 Was ein Neuzeichnen auslöst + +| Anlass | Folge | +|---|---| +| Tag wechseln, Ansicht öffnen | `zeitleisteLaden()` — neue Daten vom Server | +| Etagenfilter | nur `zeitleisteZeichnen()`, Auswahl in `localStorage` | +| Breite ändert sich (> 4 px) | `zeitleisteZeichnen()` nach 120 ms | +| Minutentakt (nur heute, Tab sichtbar) | `zeitleisteLaden()` — „jetzt“-Linie und neue Läufe | +| nach Speichern/Pausieren/Löschen | `refreshAutomations()` lädt die Zeitleiste mit | + +Was sich der Browser merkt: offene Ansicht (Zeitleiste oder Liste), +Etagenfilter, Scrollstand innerhalb des Tages. + +--- + +## 3. Wo fange ich an, wenn ich … + +| Vorhaben | Ort | +|---|---| +| … eine Bahn hinzufügen oder umbenennen | `zeitleisteBahnen()` in `restricted/zeitleiste.php`; Farbe in `css/solar.css` (`.zl-bahn-`) | +| … das Symbol einer Bahn ändern | ebenda — dasselbe Symbol muss auch im Raum-Modal und im Editor stehen (siehe `GERAETE_SYMBOL` in `autoActionFuncs.js`) | +| … eine neue Art von Lage einführen | `zeitleiste()` (Gruppen-Auswertung + Art bestimmen), dann `zlLage()`/`zlEintrag()` im Browser | +| … an der Höhe/Dichte schrauben | `ZL_ZEILE`, `ZL_KOPF`, `ZL_MIN_SPUR` in `js/solar/zeitleiste.js` | +| … verstehen, warum ein Eintrag „nicht dran“ ist | `zlTagPasst()` — und zum Gegenprüfen `tag_passt()` im Runner |