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