From 09ce5c56c7c4d84b9d14e1cca3d9d07d315109d2 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Wed, 9 Sep 2026 22:51:15 +0200 Subject: [PATCH] Automatiken koennen andere Automatiken ausloesen Anlass: "zehn Minuten nach dem Wecker den Rollladen hoch, aber nur wenn es dann schon hell ist". Als Verzoegerung an der Aktion war das nicht zu haben - die Zusatzbedingung gilt erst zum spaeteren Zeitpunkt, und eine verzoegerte Aktion, die selbst noch Bedingungen prueft, braeuchte ein zweites Bedingungssystem neben dem ersten. Der zweite Schritt ist also eine eigene Automatik; was ihr fehlte, war nur ein Bezug auf die erste. Den gibt jetzt das gerechnete Geraet "Automatiken", Gegenstueck zum vorhandenen "Zeitpunkt": jede Automatik ist dort ein Messwert, ihr Wert der Zeitpunkt der letzten Ausloesung. Der Editor braucht dafuer keine Zeile - er listet Geraete und deren Messwerte. Der neue Datentyp `elapsed` verhaelt sich dazu wie `deltatime` zum Sonnenaufgang: "+ 00:10", "ab + 00:10", "vor + 00:10". Ein Minus gibt es nicht. Gerechnet wird mit dem echten Abstand statt mit der Uhrzeit innerhalb des Tages - sonst machte ein Lauf von vorgestern die Bedingung heute wahr. Die offene Form endet trotzdem am Tagesrand, genau wie "ab 16:30". Ausgewertet wird topologisch, Ausloeser vor Nachfolger; nur so wirkt ein Versatz von null noch im selben Takt. Eine pausierte Automatik haelt ihre Nachfolger mit an: geladen werden nur die aktiven, und der Transport liefert fuer alle uebrigen einen leeren Wert. Die Messwerte pflegt ausloeser_nachfuehren() - fuer JEDE Automatik, auch fuer pausierte. Sonst loeschte ein Pausieren ueber fk_cond_state ON DELETE CASCADE die Bedingung des Nachfolgers, still. Geloescht wird nur, was keine Bedingung mehr benutzt. automatik_ausloeser.sql legt Datentyp und Geraet an. Co-Authored-By: Claude Opus 5 --- autoActions/README.md | 116 +++++++++++++++- autoActions/autoaction_runner.py | 204 +++++++++++++++++++++++++++- autoActions/automatik_ausloeser.sql | 54 ++++++++ autoActions/transports.py | 90 +++++++++++- 4 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 autoActions/automatik_ausloeser.sql diff --git a/autoActions/README.md b/autoActions/README.md index 4ba52c1..1ccf7de 100644 --- a/autoActions/README.md +++ b/autoActions/README.md @@ -101,6 +101,9 @@ oder danach liegen — deshalb dieselben drei Fälle mal zwei: `+ 00:30` eine halbe Stunde nach Sonnenaufgang, `ab - 00:30` ab einer halben Stunde davor, `vor + 00:30` bis eine halbe Stunde danach. +Dieselben drei Formen trägt die Verkettung einer Automatik mit der nächsten, +dort aber nur mit Plus — siehe „Eine Automatik löst die nächste aus". + Über den Tagesrand wird gerechnet, nicht abgeschnitten: „sechs Stunden vor Sonnenaufgang" landet am Vorabend, und das ist so gewollt — abgeschnitten wären solche Angaben gar nicht mehr formulierbar. Verglichen wird die Uhrzeit @@ -140,6 +143,108 @@ davon. `force_once` ist von der Sperre nicht betroffen: es greift nur, wenn im Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv. +## Eine Automatik löst die nächste aus + +Der Anlass war: *zehn Minuten nach dem Wecker den Rollladen hoch — aber nur, +wenn es dann schon hell ist.* + +Der naheliegende Weg wäre eine Verzögerung an der Aktion gewesen. Er trägt +nicht: die Zusatzbedingung gilt erst zum **späteren** Zeitpunkt, und eine +verzögerte Aktion, die selbst noch Bedingungen prüfen muss, ist keine Aktion +mehr — sie bräuchte ein zweites Bedingungssystem neben dem ersten. Der zweite +Schritt ist also eine eigene Automatik mit eigenen Rahmenbedingungen, und was +ihr fehlte, war nur ein Weg, sich auf die erste zu beziehen. + +Den gibt jetzt das gerechnete Gerät **„Automatiken"**, Gegenstück zum +vorhandenen „Zeitpunkt". Jede Automatik ist dort ein Messwert, ihr Wert ist +der Zeitpunkt der letzten Auslösung: + +``` +Wecker Magdalena um 05:50 → Licht auf Wakeup +Rollladen Magdalena Wecker Magdalena + 00:10 + UND Sonnenaufgang ab + 00:00 → Rollladen auf +``` + +Der Editor braucht dafür keine Zeile Änderung. Er listet Geräte und deren +Messwerte — „Automatiken" ist dann ein Gerät wie jedes andere. + +### Der Datentyp `elapsed` + +Er verhält sich zum Auslösezeitpunkt wie `deltatime` zum Sonnenaufgang: der +Messwert ist der Bezugspunkt, die Schwelle der Versatz, das Vorzeichen steckt +im Operator. Drei Formen, alle „danach" — ein Minus gibt es nicht, vor dem +Auslöser kann nichts liegen, das erst der Auslöser anstößt: + +| | wahr, wenn | +|---|---| +| `+ 00:10` | zehn Minuten nach der Auslösung, noch `catchup_minutes` lang | +| `ab + 00:10` | von da an bis Mitternacht | +| `vor + 00:10` | in den ersten zehn Minuten danach | + +Gerechnet wird mit dem **echten Abstand**, nicht mit der Uhrzeit innerhalb des +Tages. Das ist der eine Unterschied zu `deltatime`, und er ist nötig: 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. + +Die offene Form `ab +` endet trotzdem am Tagesrand — sonst wäre sie morgen +früh immer noch wahr, obwohl seither nichts geschehen ist. Ein Auslöser um +23:55 trägt seinen Nachfolger deshalb nicht über Mitternacht; dieselbe +Einschränkung hat `ab 23:55` auch. Die Punktform `+` braucht den +Tagesvergleich nicht und trägt darüber hinweg. + +### Reihenfolge, Pause, Kreise + +**Ausgewertet wird topologisch**, Auslöser vor Nachfolger. Nur damit wirkt ein +Versatz von null noch im selben Takt; bei jedem anderen Versatz wäre die +Reihenfolge gleichgültig, zehn Minuten sind länger als ein Takt. Dazu trägt +`ausloesen()` den eigenen Auslösewert sofort nach, statt bis zum nächsten +`werte_einsammeln()` zu warten. + +**Eine pausierte Automatik hält ihre Nachfolger mit an.** `Regelwerk.laden()` +holt nur `enabled = 1`, der Transport liefert für alle übrigen einen leeren +Wert, und der gilt jeder Bedingung als unerfüllt. Wer den Wecker pausiert, +will morgens auch den Rollladen unten lassen. + +**Kreise lehnt der Editor beim Speichern ab** (`pruefeKreis()` in +`restricted/automations.php`). Im Betrieb wären sie kaum zu bemerken: die +Sperrzeit begrenzt sie auf eine Auslösung je `lockout_secs`, und im Protokoll +sieht das aus wie eine Automatik, die halt oft läuft. Der Runner meldet einen +Kreis trotzdem — falls doch einer an der Datenbank vorbei entsteht — und +wertet die Beteiligten dann in ihrer ursprünglichen Reihenfolge aus. Sie +stillzulegen wäre schlimmer: eine wortlos abgeschaltete Automatik fällt +niemandem auf. + +### Löschen ist die gefährliche Stelle + +`fk_cond_state` steht auf `ON DELETE CASCADE`. Verschwindet der Messwert, +verschwindet die Bedingung — still. Bei der **letzten** Bedingung fängt +`gruppen_erfuellt()` das ab („eine Automatik ohne Bedingungen löst nie aus"). +Bei **einer von zweien** fängt es niemand: aus *zehn Minuten nach dem Wecker +UND es ist hell* würde ein bloßes *es ist hell*, und der Rollladen führe ab +morgen jeden Tag bei Sonnenaufgang hoch. + +Deshalb fragt der Editor vor dem Löschen, und der Runner räumt nur auf, was +niemand mehr benutzt: + +| | | +|---|---| +| **Abhängen** | die Bedingung wird entfernt **und der Nachfolger pausiert**. Eine pausierte Automatik mit sichtbarer Lücke ist besser als eine stille Regeländerung. | +| **Alle löschen** | der Nachfolger geht mit, und dessen Nachfolger auch. | + +Automatisch mitzulöschen wäre die falsche Vorgabe — „Wecker weg, Rollladen +still weg" bemerkt man erst im Winter. + +### Die Messwerte pflegt der Runner + +`ausloeser_nachfuehren()` legt bei jedem Laden des Regelwerks für **jede** +Automatik einen Messwert an, auch für pausierte, und zieht den Namen nach. +Geführt wird nach der Kennung (`auto:15`) und nicht nach dem Namen: wer +umbenennt, soll die abhängigen Bedingungen behalten. + +Für pausierte muss der Messwert stehen bleiben, sonst löschte ein Pausieren +über dieselbe Kaskade die Bedingung des Nachfolgers, und beim Fortsetzen wäre +sie weg. + ## Transporte Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`: @@ -151,8 +256,9 @@ Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`: | `wled://…` | Pfad in `/json/state` (`seg[0].col[0]`), gepollt | JSON-Vorlage mit Platzhaltern, als Ganzes gesendet | | Tahoma | Statusname (`core:ClosureState`), gepollt | `exec/apply` an die Box | | `Logic` | gerechnet: Uhrzeit, Datum, Sonne | – | +| `Automatik` | gerechnet: je Automatik ihre letzte Auslösung | – | -Alle fünf stehen in `transports.py`. Eine sechste Geräteart kommt als weitere +Alle sechs stehen in `transports.py`. Eine siebte Geräteart kommt als weitere Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`. Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der @@ -217,10 +323,17 @@ Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter. ```bash cp config.ini.example config.ini # ausfüllen: Datenbank, MQTT, Tahoma +mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < automatik_ausloeser.sql python3 fetch_calendar.py # Feiertage und Ferien holen python3 autoaction_runner.py --once --dry-run --verbose # Probelauf ``` +`automatik_ausloeser.sql` legt den Datentyp `elapsed` und das gerechnete +Gerät „Automatiken" an — beides braucht die Verkettung, siehe oben. Die +Messwerte darunter legt der Runner selbst an. Ohne das Skript läuft alles +Übrige weiter, es fehlt nur die Möglichkeit, eine Automatik als Auslöser zu +wählen. + `--dry-run` schaltet nichts, protokolliert aber jedes Kommando, das geschickt würde. `--once` macht einen einzigen Durchlauf. @@ -237,6 +350,7 @@ nicht im Web-Verzeichnis: ├── autoaction_runner.py ├── transports.py ├── fetch_calendar.py + ├── automatik_ausloeser.sql einmalig, siehe Einrichten └── config.ini Zugangsdaten, nicht im Git ``` diff --git a/autoActions/autoaction_runner.py b/autoActions/autoaction_runner.py index 3656ddf..f5d402b 100644 --- a/autoActions/autoaction_runner.py +++ b/autoActions/autoaction_runner.py @@ -48,6 +48,15 @@ Beim Sonnenauf- und -untergang traegt der Operator zusaetzlich das Vorzeichen des Versatzes: "+ 00:30" eine halbe Stunde danach, ">=- 00:30" ab einer halben Stunde davor, "<+ 00:30" bis eine halbe Stunde danach. +Dieselbe Schreibweise traegt die Verkettung: eine Automatik kann eine andere +ausloesen, indem sie deren letzte Ausloesung als Messwert abfragt - "Wecker +Magdalena + 00:10". Dafuer gibt es das gerechnete Geraet "Automatiken" +(AutomatikTransport) und den Datentyp `elapsed`. Der Nachfolger bleibt dabei +eine vollwertige Automatik mit eigenen Rahmenbedingungen; genau darum geht es +ja - "zehn Minuten spaeter, aber nur wenn es dann schon hell ist" waere als +blosse Verzoegerung an einer Aktion nicht formulierbar, weil die +Zusatzbedingung erst zum spaeteren Zeitpunkt gilt. + Tabellen siehe homeMesh_automations.sql, Konfiguration siehe config.ini.example. """ @@ -67,8 +76,10 @@ import pymysql import requests import paho.mqtt.client as mqtt -from transports import (HTTPTransport, LogicTransport, MQTTTransport, - TahomaTransport, WLEDTransport, ist_topic) +from transports import (AUTOMATIK_URL, AutomatikTransport, HTTPTransport, + LogicTransport, MQTTTransport, TahomaTransport, + WLEDTransport, ausloeser_kennung, ausloeser_url, + ist_topic) logger = logging.getLogger("autoaction") @@ -422,6 +433,37 @@ def bedingung_erfuellt(bedingung, state, wert, jetzt, fenster=NACHHOLFENSTER): if op.startswith("<"): return jetzt_m < ziel return im_nachholfenster(jetzt_m, ziel, fenster) + if typ == "elapsed": + # Der Messwert ist der Zeitpunkt, zu dem eine andere Automatik + # zuletzt gelaufen ist; die Schwelle der Versatz danach. + # + # Anders als bei `deltatime` wird hier NICHT mit der Uhrzeit + # innerhalb des Tages gerechnet, sondern mit dem echten Abstand. + # Sonst machte ein Lauf von vorgestern um 05:50 die Bedingung + # heute um 06:00 wahr, an einem Tag, an dem der Ausloeser gar + # nicht gelaufen ist. + letzter = als_zeitpunkt(wert) + if letzter is None or letzter > jetzt: + return False + verstrichen = (jetzt - letzter).total_seconds() / 60.0 + ziel = minuten(soll) + if op.startswith(">="): + # "ab + 00:10" laeuft sonst unbegrenzt weiter - morgen frueh + # waere es immer noch wahr, obwohl der Ausloeser seither + # nichts getan hat. Begrenzt wird wie bei "ab 16:30": bis + # Mitternacht. Ein Ausloeser um 23:55 traegt seinen + # Nachfolger deshalb nicht ueber den Tagesrand - dieselbe + # Einschraenkung hat "ab 23:55" auch. + if letzter.date() != jetzt.date(): + return False + return verstrichen >= ziel + if op.startswith("<"): + return verstrichen < ziel + # "um + 00:10": die Punktform, begrenzt durch das Nachholfenster. + # Sie braucht den Tagesvergleich nicht und traegt deshalb auch + # ueber Mitternacht. + return ziel <= verstrichen < ziel + max(1, fenster) + if typ in ("date", "datetime"): wandeln = als_datum if typ == "date" else als_zeitpunkt ist_d, soll_d = wandeln(wert), wandeln(soll) @@ -649,6 +691,8 @@ class Runner: self._sonne = (None, "00:00", "00:00") # (datum, aufgang, untergang) self._kalender = (None, {"feiertag": False, "ferien": False}) self._letzte_saeuberung = None + self.reihenfolge = [] # automation_id, Ausloeser vor Nachfolger + self.ausloeser_states = {} # automation_id -> state_id des Ausloesers self.versand = Versand() self.sammler = Sammler() @@ -661,6 +705,7 @@ class Runner: config.text("tahoma", "token"), config.zahl("tahoma", "timeout", 10), self.dry_run), LogicTransport(self.sonnenzeiten), + AutomatikTransport(self.ausloesezeiten), ] self.regelwerk = None @@ -711,6 +756,10 @@ class Runner: return self.transport_fuer(state["actor_url"]) def regelwerk_laden(self): + # Vor dem Laden, damit eine neu angelegte Automatik sofort als + # Ausloeser zur Verfuegung steht und ihre Zeile schon in der + # Signatur steckt - sonst laedt der naechste Takt gleich noch einmal. + self.ausloeser_nachfuehren() self.regelwerk = Regelwerk.laden(self.db) # Welche Geraete Position und Neigung zusammen koennen. Nur die haben @@ -746,6 +795,142 @@ class Runner: # noch einmal hineingeschrieben werden. self.geschrieben.setdefault(s["id"], s["current_value"]) + self.ausloeser_states = { + ausloeser_kennung(s["state_url"]): s["id"] + for s in self.regelwerk.states.values() + if s["actor_url"] == AUTOMATIK_URL + and ausloeser_kennung(s["state_url"]) is not None} + self.reihenfolge_bestimmen() + + def ausloeser_nachfuehren(self): + """ + Je Automatik einen Messwert am gerechneten Geraet "Automatiken". + + Damit taucht jede Automatik im Editor als Messwert auf und laesst sich + als Ausloeser einer anderen waehlen, ohne dass der Editor davon etwas + wissen muesste - er listet Geraete und deren Messwerte, mehr nicht. + + Gefuehrt wird nach der Kennung, nicht nach dem Namen: wer umbenennt, + soll die abhaengigen Bedingungen nicht verlieren. Der Name wird + nachgezogen, damit im Editor das Richtige steht. + + Angelegt wird fuer JEDE Automatik, auch fuer pausierte. Sonst loeschte + ein Pausieren ueber `fk_cond_state ON DELETE CASCADE` die Bedingung + des Nachfolgers - still, und beim Fortsetzen waere sie weg. + + Geloescht wird nur, was niemand mehr benutzt. Zeigt noch eine + Bedingung darauf, bleibt der Messwert stehen und es gibt eine Warnung: + die Kaskade wuerde sonst eine einzelne Bedingung aus einer Gruppe + entfernen und aus "zehn Minuten nach dem Wecker UND es ist hell" ein + blosses "es ist hell" machen. Bei der LETZTEN Bedingung faengt + gruppen_erfuellt() das ab, bei einer von zweien niemand. + """ + try: + with self.db.cursor() as c: + c.execute("SELECT id FROM actors WHERE url = %s", (AUTOMATIK_URL,)) + zeile = c.fetchone() + if not zeile: + logger.debug("Geraet \"Automatiken\" gibt es nicht - " + "automatik_ausloeser.sql noch nicht eingespielt") + return + aktor = zeile["id"] + c.execute("SELECT id FROM state_types WHERE type = 'elapsed'") + typ = c.fetchone() + typ_id = typ["id"] if typ else None + + c.execute("SELECT id, name FROM automations") + gewuenscht = {ausloeser_url(r["id"]): r["name"] for r in c.fetchall()} + + c.execute("""SELECT s.id, s.state_name, s.url, + (SELECT COUNT(*) FROM automation_conditions b + WHERE b.state_id = s.id) AS benutzt + FROM actor_states s WHERE s.actor_id = %s""", (aktor,)) + vorhanden = {r["url"]: r for r in c.fetchall()} + + for url, name in gewuenscht.items(): + alt = vorhanden.get(url) + if alt is None: + c.execute("""INSERT INTO actor_states + (actor_id, state_name, state_type, url, + possible_values) + VALUES (%s, %s, %s, %s, '')""", + (aktor, name, typ_id, url)) + elif alt["state_name"] != name: + c.execute("UPDATE actor_states SET state_name = %s WHERE id = %s", + (name, alt["id"])) + + for url, alt in vorhanden.items(): + if url in gewuenscht: + continue + if alt["benutzt"]: + logger.warning( + "Ausloeser %r zeigt auf eine geloeschte Automatik, wird " + "aber noch von %d Bedingung(en) benutzt - bleibt stehen", + alt["state_name"], alt["benutzt"]) + continue + c.execute("DELETE FROM actor_states WHERE id = %s", (alt["id"],)) + except Exception as fehler: + logger.warning("Ausloeser nicht nachfuehrbar: %r", fehler) + + def reihenfolge_bestimmen(self): + """ + Ausloeser vor Nachfolger auswerten. + + Nur damit wirkt ein Versatz von null noch im selben Takt. Bei jedem + anderen Versatz waere die Reihenfolge gleichgueltig - zehn Minuten + sind laenger als ein Takt. + + Ein Kreis (A loest B loest A) waere ein Fehler im Regelwerk; der + Editor lehnt ihn beim Speichern ab. Hier wird er nur gemeldet und die + Beteiligten laufen in ihrer urspruenglichen Reihenfolge weiter. Sie + deswegen stillzulegen waere schlimmer: die Sperrzeit begrenzt den + Schaden ohnehin auf eine Ausloesung je lockout_secs, eine wortlos + abgeschaltete Automatik dagegen faellt niemandem auf. + """ + automatiken = self.regelwerk.automatiken + vorgaenger = {aid: set() for aid in automatiken} + for aid, auto in automatiken.items(): + for bedingungen in auto["gruppen"].values(): + for b in bedingungen: + state = self.regelwerk.states.get(b["state_id"]) + if not state or state["actor_url"] != AUTOMATIK_URL: + continue + davor = ausloeser_kennung(state["state_url"]) + if davor == aid: + logger.error("%s loest sich selbst aus - Bedingung wird " + "nie wahr", auto["name"]) + elif davor in automatiken: + vorgaenger[aid].add(davor) + + reihenfolge, offen = [], dict(vorgaenger) + while offen: + frei = sorted(aid for aid, davor in offen.items() + if not davor & set(offen)) + if not frei: + logger.error("Automatiken loesen sich im Kreis aus: %s", + ", ".join(automatiken[aid]["name"] for aid in offen)) + reihenfolge.extend(offen) + break + reihenfolge.extend(frei) + for aid in frei: + del offen[aid] + self.reihenfolge = reihenfolge + + def ausloesezeiten(self): + """ + Wann jede Automatik zuletzt gelaufen ist - die Werte des gerechneten + Geraets "Automatiken". + + Es stehen nur die aktiven darin: `Regelwerk.laden` holt sich + `WHERE enabled = 1`. Eine pausierte Automatik liefert damit keinen + Zeitpunkt, und ihre Nachfolger stehen mit still. Das ist gewollt - + wer den Wecker pausiert, will morgens auch den Rollladen unten lassen. + """ + if not self.regelwerk: + return {} + return {aid: auto.get("last_run") + for aid, auto in self.regelwerk.automatiken.items()} + # --- Umgebung -------------------------------------------------------- def sonnenzeiten(self): @@ -820,7 +1005,8 @@ class Runner: """ neu = {} for transport in self.transporte: - billig = isinstance(transport, (MQTTTransport, LogicTransport)) + billig = isinstance(transport, (MQTTTransport, LogicTransport, + AutomatikTransport)) if billig or auch_geraete: neu.update(transport.zustaende_lesen()) neu.update(self.sammler.abholen()) @@ -906,6 +1092,13 @@ class Runner: logger.warning("Protokoll nicht schreibbar: %r", fehler) automatik["last_run"] = datetime.now() self.lief_im_fenster[automatik["id"]] = True + # Den eigenen Ausloeserwert gleich mitfuehren, statt bis zum naechsten + # werte_einsammeln() zu warten. Zusammen mit der topologischen + # Reihenfolge greift ein Nachfolger mit Versatz null dadurch noch im + # selben Takt. + state_id = self.ausloeser_states.get(automatik["id"]) + if state_id is not None: + self.werte[state_id] = automatik["last_run"].strftime("%Y-%m-%d %H:%M:%S") def ergebnisse_verbuchen(self): """ @@ -943,7 +1136,10 @@ class Runner: jetzt = datetime.now() kalender = self.kalender() - for automatik in self.regelwerk.automatiken.values(): + for automation_id in self.reihenfolge: + automatik = self.regelwerk.automatiken.get(automation_id) + if automatik is None: + continue aktiv = (tag_passt(automatik, jetzt, kalender) and im_zeitfenster(jetzt, automatik["window_from"], automatik["window_to"])) vorher_aktiv = self.war_aktiv.get(automatik["id"], aktiv) diff --git a/autoActions/automatik_ausloeser.sql b/autoActions/automatik_ausloeser.sql new file mode 100644 index 0000000..b05ed7b --- /dev/null +++ b/autoActions/automatik_ausloeser.sql @@ -0,0 +1,54 @@ +-- =========================================================================== +-- Automatiken als Ausloeser fuer andere Automatiken +-- +-- Anlass: "zehn Minuten nach dem Wecker den Rollladen hoch, aber nur wenn es +-- dann schon hell ist". Die Zusatzbedingung gilt erst zum spaeteren +-- Zeitpunkt - als blosse Verzoegerung an einer Aktion waere das nicht +-- formulierbar, dafuer braeuchte die Aktion ein eigenes Bedingungssystem. +-- Der zweite Schritt ist also eine eigene Automatik, und was ihr fehlte, war +-- nur ein Weg, sich auf die erste zu beziehen. +-- +-- Dieses Skript legt dafuer zwei Dinge an: +-- +-- 1. den Datentyp `elapsed` - "so lange ist es her". Er verhaelt sich zu +-- einem Zeitpunkt wie `deltatime` zum Sonnenaufgang: der Messwert ist +-- der Bezugspunkt, die Schwelle der Versatz, das Vorzeichen steckt im +-- Operator. Gerechnet wird aber mit dem echten Abstand und nicht mit +-- der Uhrzeit innerhalb des Tages - sonst machte ein Lauf von +-- vorgestern die Bedingung heute wahr. +-- +-- 2. das gerechnete Geraet "Automatiken", Gegenstueck zum schon +-- vorhandenen "Zeitpunkt". Seine Messwerte legt der Runner selbst an +-- und fuehrt sie nach: je Automatik einen, benannt wie sie, mit +-- "auto:" als URL. Deshalb steht hier nur der Aktor. +-- +-- Der Editor braucht dafuer keine Zeile Aenderung, um das anzubieten: er +-- listet Geraete und deren Messwerte, und "Automatiken" ist dann ein Geraet +-- wie jedes andere. +-- +-- Einspielen: +-- mysql -h 127.0.0.1 -P 3310 -u homeMesh -p homeMesh < automatik_ausloeser.sql +-- +-- Danach den Runner neu starten (startSolarServer.sh) - die Regelwerks- +-- Nachladung holt nur Daten, keinen neuen Code. +-- =========================================================================== + +-- Die IDs in state_types sind feste Kennungen, keine Auto-Increment-Werte +-- (0 = bool, 5 = time, 7 = deltatime ...). 10 ist die naechste freie. +INSERT INTO state_types (id, type) +SELECT 10, 'elapsed' + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM state_types WHERE type = 'elapsed'); + +-- Kein Raum und keine Etage: das Geraet steht nirgends im Haus, genau wie +-- "Zeitpunkt". Der Geraetewaehler im Editor sortiert es dadurch zu den +-- uebrigen ortlosen. +INSERT INTO actors (type, name, url, room) +SELECT 'LOGIC', 'Automatiken', 'Automatik', NULL + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM actors WHERE url = 'Automatik'); + +SELECT a.id AS aktor, a.name, a.url, + (SELECT id FROM state_types WHERE type = 'elapsed') AS typ_elapsed, + (SELECT COUNT(*) FROM actor_states WHERE actor_id = a.id) AS messwerte + FROM actors a WHERE a.url = 'Automatik'; diff --git a/autoActions/transports.py b/autoActions/transports.py index bc5b45d..3e8824f 100644 --- a/autoActions/transports.py +++ b/autoActions/transports.py @@ -13,6 +13,9 @@ URL des Aktors in der Tabelle `actors`: Funkart des Geraets, deshalb wird dort nicht danach entschieden, sondern an der Box-Kennung in der URL Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne) + Automatik das gerechnete Geraet "Automatiken" - jede Automatik ist + dort ein Messwert, ihr Wert der Zeitpunkt der letzten + Ausloesung Eine Ausnahme gibt es beim Lesen: meldet ein Geraet seine Messwerte an den Broker, obwohl es ueber HTTP geschaltet wird, so steht in `actor_states.url` @@ -21,7 +24,7 @@ Runner.transport_fuer_messwert(). Das betrifft die Shellys der zweiten Generation; die aelteren koennen kein MQTT und bleiben ganz bei HTTP. Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es -sind fuenf kurze Klassen, und wer eine sechste Geraeteart anschliesst, sieht +sind sechs kurze Klassen, und wer eine siebte Geraeteart anschliesst, sieht hier auf einen Blick, was dafuer zu tun ist. Jeder Transport hat zwei Haelften: @@ -707,3 +710,88 @@ class LogicTransport(Transport): def senden(self, aktion): raise RuntimeError("Das Geraet \"Zeitpunkt\" kann nichts schalten") + + +AUTOMATIK_URL = "Automatik" + + +def ausloeser_url(automation_id): + """ + Wie eine Automatik in actor_states.url steht: "auto:15". + + Die Kennung und nicht der Name, damit ein Umbenennen die Bedingungen der + abhaengigen Automatiken nicht ins Leere zeigen laesst. Kein Schema und + kein Schraegstrich - sonst hielte ist_topic() das fuer ein MQTT-Topic und + der MQTT-Transport waere zustaendig. + """ + return "auto:%d" % int(automation_id) + + +def ausloeser_kennung(state_url): + """Die Kennung zurueck aus "auto:15". None, wenn es keine ist.""" + text = str(state_url or "") + if not text.startswith("auto:"): + return None + try: + return int(text[5:]) + except ValueError: + return None + + +class AutomatikTransport(Transport): + """ + Automatiken als Ausloeser fuer andere Automatiken. + + Jede Automatik ist hier ein Messwert, und ihr Wert ist der Zeitpunkt, zu + dem sie zuletzt gelaufen ist. Eine Bedingung darauf liest sich dann als + "zehn Minuten nach dem Wecker"; den Abstand rechnet der Datentyp + `elapsed`, siehe bedingung_erfuellt() im Runner. + + Warum ueberhaupt ein Transport und keine eigene Art von Bedingung: so + braucht der Editor keine Zeile Aenderung, um das anzubieten. Er listet + Geraete und deren Messwerte - "Automatiken" ist dann ein Geraet wie jedes + andere, die Automatiken sind dessen Messwerte. Dieselbe Ueberlegung steht + hinter dem gerechneten Geraet "Zeitpunkt". + + Geschaltet wird hier nichts. Eine Automatik, die eine andere aufruft, ist + ausdruecklich nicht vorgesehen: die Verkettung laeuft immer ueber die + Bedingung, also von hinten nach vorn. Sonst gaebe es zwei Wege zum selben + Ziel, und einen davon koennte der Editor nicht anzeigen. + """ + + schema = AUTOMATIK_URL + + def __init__(self, ausloesezeiten): + """ausloesezeiten: Funktion() -> {automation_id: datetime oder None}.""" + self.ausloesezeiten = ausloesezeiten + self.states = [] + + def passt(self, actor_url): + return actor_url == AUTOMATIK_URL + + def zustaende_anmelden(self, states): + self.states = states + logger.info("Automatik: %d Ausloeser", len(states)) + + def zustaende_lesen(self): + """ + Leerer Text heisst "noch nicht gelaufen" - und gilt jeder Bedingung + als unerfuellt. + + Er steht auch dann da, wenn es die Automatik nicht mehr gibt oder sie + pausiert ist: der Runner laedt nur die aktiven, und eine pausierte + Automatik soll ihre Nachfolger mit anhalten. Genau deshalb wird hier + jeder Messwert bei jedem Takt gesetzt und nicht nur der geaenderte - + sonst bliebe nach dem Pausieren der letzte bekannte Zeitpunkt stehen + und der Nachfolger liefe noch einmal. + """ + zeiten = self.ausloesezeiten() + werte = {} + for s in self.states: + kennung = ausloeser_kennung(s["state_url"]) + letzter = zeiten.get(kennung) if kennung is not None else None + werte[s["id"]] = letzter.strftime("%Y-%m-%d %H:%M:%S") if letzter else "" + return werte + + def senden(self, aktion): + raise RuntimeError("Das Geraet \"Automatiken\" kann nichts schalten")