Files
Smart-Dashboard/doku/automatiken.md
T
adminandClaude Opus 5 229910bcb8 Haltezeit frei in Minuten eingeben statt aus Stufen waehlen
Wonach man sucht, haengt am Messwert: eine offene Tuer faellt nach zwei
Minuten auf, ein laufender Wasserhahn erst nach Stunden. Eine Stufenliste
traf damit immer nur die Haelfte der Faelle.

Das Feld nimmt Minuten, das Modell rechnet in Sekunden weiter. Die Kopfzeile
des Rahmens rechnet beim Tippen mit - aus "210" wird dort "erst nach 3,5
Std.", also steht die lesbare Fassung neben der eingebbaren.

haltezeitPruefen() ersetzt die Whitelist: nicht unter null, nicht ueber
einen Tag, auf ganze Minuten gerundet. Sekunden gewinnen hier nichts, die
Messwerte kommen viel seltener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:21:24 +02:00

21 KiB
Raw Blame History

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


1. Datenmodell

Alles steht in homeMesh — dort, wo auch die Geräte liegen, damit Fremdschlüssel greifen können. Schema: homeMesh_automations.sql.

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"
    int hold_secs "Haltezeit vor dem Auslösen"
    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"
  }

Vier 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), hold_secs (haltezeit.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) ohne eine einzige Sonderregel im Editor.


2. Der Editor

Der Editor arbeitet auf einem Modell und zeichnet daraus die Oberfläche — nicht umgekehrt:

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.

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).
  • 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)
hold_secs Haltezeit: so lange muss die Bedingung ununterbrochen erfüllt sein (0 = sofort, frei in Minuten bis 24 Std.) Zählt im Speicher des Runners — ein Neustart fängt die Zeit von vorn an
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); 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.

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):

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 --> HZ{"hold_secs erfüllt?<br/><small>lange genug am Stück</small>"}
  HZ -- nein --> G
  HZ -- 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.

Haltezeit: erst, wenn es dabei bleibt

Manches zeigt sich nicht in einem einzelnen Messwert, sondern erst in seiner Dauer. „Der Wasserzähler läuft“ ist jedes Händewaschen; „läuft seit einer halben Stunde ohne Pause“ ist ein offener Hahn. hold_secs schiebt deshalb die Flanke nach hinten: Erst wenn die Bedingung so viele Sekunden am Stück erfüllt war, gilt sie als erfüllt.

Bedingung   ──┐   ┌──────────────────────────────────┐
              └───┘                                  └────
                  ↑            ↑                     ↑
              angefangen   nach 30 min:          Bedingung weg,
              (Zeit läuft)  ausgelöst            Zeit verfällt

Entscheidend ist, dass cond_met bis dahin auf 0 bleibt: Die Flanke wird aufgeschoben, nicht verbraucht. Alles danach — Tagessperre, Sperrzeit, Protokoll — bleibt unverändert.

Was man dazu wissen sollte:

  • Ein Aussetzer setzt zurück. Auch ein einzelner Takt genügt; genau das ist mit „ununterbrochen“ gemeint.
  • Gezählt wird im Speicher (erfuellt_seit), nicht in der Datenbank. Es ist Laufzustand wie war_aktiv — und nach einem Neustart weiß niemand, ob die Bedingung zwischendurch angelegen hat. Die Zeit beginnt dann von vorn.
  • Die Messrate ist die Untergrenze der Genauigkeit. Ein Zähler, der alle fünf Minuten meldet, macht die Haltezeit auf fünf Minuten genau. Mit einem punktgenauen Zeit-Auslöser („um 16:30“, nur im Nachholfenster wahr) ist sie gar nicht sinnvoll zu kombinieren — sie würde nie reif.
  • Das Zeitfenster schneidet sie ab. Geht das Fenster zu, verfällt eine angefangene Haltezeit: Sie soll innerhalb des Fensters voll gelaufen sein.

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
hold_secs Zustände, die erst durch ihre Dauer auffallen (Wasser läuft, Fenster steht offen) Die Flanke wird aufgeschoben, bis die Bedingung lange genug am Stück steht. Der Gegenspieler zur Sperrzeit: die bremst die Wiederholung, diese den ersten Lauf
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“.

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
Läuft gar nicht, obwohl die Bedingung sichtbar zutrifft hold_secs: sie war noch nicht lange genug am Stück erfüllt, oder ein Aussetzer hat die Zeit zurückgesetzt
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)
… das Nachholfenster ändern catchup_minutes in config.ini des Runners
… eine weitere Stufe für die Sperre lockoutChoices() in restricted/automations.php — eine Zeile, der Rest zieht nach
… die Grenzen der Haltezeit ändern haltezeitPruefen() in restricted/automations.php und max am Feld holdMin
… auf Dauerzustände reagieren (Wasser läuft, Tür steht offen) Haltezeit im Rahmen, dazu eine Aktion des Geräts „Benachrichtigungen“ (→ benachrichtigungen.md)