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