Doku: Automatiken und Zeitleiste ausfuehrlich beschrieben

Neues Verzeichnis doku/ mit zwei Tiefenbohrungen: das Regelwerk der
Automatiken (Datenmodell, Editor, Runner, Auswertung, Verkettung, Sperren)
und die Zeitleiste (Serverrechnung, Zeichnen, Stapeln, Ketten, Karte).
Mit Diagrammen und je einem Bild; die Haupt-README verweist darauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 07:39:43 +02:00
co-authored by Claude Opus 5
parent 149cdb46e1
commit a0a8ad04de
6 changed files with 730 additions and 0 deletions
+6
View File
@@ -17,6 +17,12 @@ Dieses Verzeichnis ist zugleich der Web-Root der NAS
Der Arbeitsordner *ist* die laufende Anwendung — jede gespeicherte Datei ist Der Arbeitsordner *ist* die laufende Anwendung — jede gespeicherte Datei ist
sofort live. 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 ## Inhalt
+79
View File
@@ -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<br/><small>PHP + JS, /volume1/web/smart</small>"]
end
subgraph NAS["NAS, Hintergrundprozesse (SolarManager)"]
RUN["autoaction_runner.py<br/><small>führt Automatiken aus</small>"]
SM["solarManager.py<br/><small>sammelt Anlagenwerte</small>"]
SK["gatherSkodaData.py<br/><small>Fahrzeug</small>"]
end
subgraph Speicher["Speicher"]
MQTT[["MQTT-Broker<br/><small>alles Aktuelle</small>"]]
HM[("homeMesh<br/><small>Geräte, Automatiken, Grundriss</small>")]
SL[("solarLog<br/><small>Messreihen, Sonnenzeiten</small>")]
end
subgraph Geraete["Geräte"]
TAH["Tahoma-Box<br/><small>Jalousien</small>"]
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 …“.
+409
View File
@@ -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
────── ──────── ────────
MoFr, Uhrzeit um 07:00 Magdalena Fenster: Auf
nicht in Ferien, UND Außentemperatur > 10 °C Magdalena Tür: Auf
06:0009: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<br/>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:0009: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 (SoDo, 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<br/><small>Automatiken, Messwerte, Kommandos</small>"]
LOAD --> SUB["MQTT abonnieren<br/>Sammlerfäden starten"]
SUB --> TAKT
subgraph TAKT["Takt, alle tick_seconds (10 s)"]
direction TB
T1["Werte einsammeln<br/><small>MQTT-Puffer + Queue der Sammler</small>"]
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<br/><small>je Gerät der Reihe nach,<br/>über Geräte hinweg parallel</small>"]]
SAMMLER[["Sammler<br/><small>HTTP/WLED 1 min,<br/>Tahoma 5 min</small>"]] -. "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<br/><small>heute, mit next_day morgen</small>"] --> B{"tag_passt?<br/><small>Wochentag, Ferien, Feiertag</small>"}
B -- nein --> Z1
B -- ja --> C{"im Zeitfenster?"}
C -- nein --> Z1["Fenster zu:<br/>force_once prüfen,<br/>Flanke zurücksetzen"]
C -- ja --> D{"Bedingung erfüllt?<br/><small>any(all(gruppe))</small>"}
D -- nein --> E["cond_met = 0"]
D -- ja --> F{"cond_met schon 1?"}
F -- ja --> G["nichts tun<br/><small>keine neue Flanke</small>"]
F -- nein --> H{"once_per_day<br/>und lief heute?"}
H -- ja --> G
H -- nein --> I{"innerhalb<br/>lockout_secs?"}
I -- ja --> J["Flanke verwerfen"]
I -- nein --> K["ausloesen()"]
K --> L["Aktionen in den Versand,<br/>last_run + Protokoll,<br/>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:<id>`). Damit ist eine Automatik für den Editor ein Messwert wie jeder
andere — „Wecker Magdalena + 00:10“.
```mermaid
flowchart LR
W["Wecker Magdalena<br/><small>um 07:00</small>"] -->|"+ 00:10"| R["Magdalena Rollos<br/><small>und es ist hell</small>"]
R -->|"+ 00:05"| S["Schlafzimmer<br/>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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+236
View File
@@ -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<br/><small>loadAutomation() je Id</small>"] --> 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<br/><small>wartet auf einen Messwert</small>"]
P & F & K & S --> D["Art bestimmen:<br/>punkt kette fenster jederzeit"]
D --> E["Ketten auflösen<br/><small>bis zu 6 Runden</small>"]
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()<br/><small>fetch ?action=zeitleiste</small>"] --> Z["zeitleisteZeichnen()"]
Z --> F["Etagenfilter anwenden"]
F --> T["Dreiteilung:<br/>gelegt · jederzeit · pausiert"]
T --> ST["je Bahn: zlStapeln()<br/><small>Zeilen wie im Kalender</small>"]
ST --> HTML["HTML bauen:<br/>Achse, Bahnen, Bänder, Legende"]
HTML --> KET["zlKettenZeichnen()<br/><small>SVG-Bögen, nach dem Layout</small>"]
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:3018: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:0018: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-<name>`) |
| … 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 |