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