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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:51:15 +02:00
co-authored by Claude Opus 5
parent 1f1700391d
commit 09ce5c56c7
4 changed files with 458 additions and 6 deletions
+115 -1
View File
@@ -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
```
+200 -4
View File
@@ -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)
+54
View File
@@ -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:<id>" 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';
+89 -1
View File
@@ -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")