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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user