Files
SolarManager/autoActions/transports.py
T
adminandClaude Opus 5 09ce5c56c7 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>
2026-09-09 22:51:15 +02:00

798 lines
32 KiB
Python

#!/usr/bin/env python3
"""
Transporte fuer den AutoAction-Runner.
Ein Transport weiss, wie man mit einer Sorte Geraet redet - er liest deren
Messwerte und schickt deren Kommandos. Welcher zustaendig ist, entscheidet die
URL des Aktors in der Tabelle `actors`:
mqtt://... MQTT-Geraete (Home-Assistant-Discovery)
http://... Shelly und andere HTTP-Geraete
wled://... WLED-Lampen
io://, rts://, internal://, ogp:// Tahoma - das Schema haengt an der
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`
ein Topic. Solche Werte liest der MQTT-Transport - siehe ist_topic() und
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 sechs kurze Klassen, und wer eine siebte Geraeteart anschliesst, sieht
hier auf einen Blick, was dafuer zu tun ist.
Jeder Transport hat zwei Haelften:
zustaende_anmelden(states) einmalig beim Start
zustaende_lesen() liefert {state_id: wert} - nur was neu ist
senden(aktion) fuehrt ein Kommando aus
`states` ist eine Liste von Dicts mit actor_url, state_url, value_path und
id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
{url, name, wert}).
"""
import json
import logging
import re
import time
import urllib3
from datetime import datetime
from urllib.parse import quote
logger = logging.getLogger("autoaction.transport")
# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den
# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in
# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen
# Abfrage und uebertoent das eigentliche Protokoll.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def ist_topic(state_url):
"""
Sieht diese Zustands-URL nach einem MQTT-Topic aus?
Gebraucht fuer die neueren Shellys: sie melden ihre Messwerte von selbst
an den Broker, geschaltet werden sie aber weiter ueber HTTP. Ihr Geraet
steht deshalb mit einer http://-Adresse in `actors`, waehrend in ihren
Messwerten ein Topic steht ("Power_EG/status/em:0"). Wer nur auf das
Geraet schaut, wuerde solche Werte im Poll-Takt suchen - und nie finden.
Ein Topic hat Schraegstriche und kein Schema davor. Feldnamen der anderen
Transporte haben beides nicht: "a_voltage", "core:ClosureState",
"seg[0].col[0]".
"""
return bool(state_url) and "/" in state_url and "://" not in state_url
def wert_aus_pfad(daten, pfad):
"""
Einen Teilwert aus einer Nutzlast holen: "[3]", "ssid", "seg[0].col[0]".
Ein leerer Pfad heisst: die Nutzlast selbst.
Gebraucht wird das an zwei Stellen. MQTT-Geraete legen mehrere Messwerte
auf ein Topic - der go-eCharger schickt sechzehn Zahlen als JSON-Feld -,
und WLED liefert seinen gesamten Zustand als ein Dokument.
"""
if not pfad:
return daten
for teil in pfad.split("."):
treffer = re.match(r"^([^\[]*)((?:\[\d+\])*)$", teil)
if not treffer:
raise KeyError(pfad)
if treffer.group(1):
daten = daten[treffer.group(1)]
for index in re.findall(r"\[(\d+)\]", treffer.group(2)):
daten = daten[int(index)]
return daten
def uebersetze(wert, tabelle):
"""
Aus der gesendeten Zahl den Zustandsnamen machen: aus "2" wird "Charging".
Manche Geraete schicken einen Zahlencode und meinen einen Zustand. Welche
Zahl welchen Namen hat, steht in possible_values - derselben Spalte, aus
der auch der Editor seine Auswahlliste baut. Eine Bedingung vergleicht
damit genau den Klartext, den man dort ausgewaehlt hat.
Steht die Zahl nicht in der Tabelle, bleibt sie stehen: ein erfundener
Name waere schlimmer als ein roher Wert.
"""
if not tabelle:
return wert
if wert in tabelle:
return tabelle[wert]
try: # "2.0" und "2" meinen dieselbe Stufe
ganz = str(int(float(wert)))
except (TypeError, ValueError):
return wert
return tabelle.get(ganz, wert)
class Transport:
"""Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden."""
schema = ""
def passt(self, actor_url):
return actor_url.startswith(self.schema)
def zustaende_anmelden(self, states):
pass
def zustaende_lesen(self):
return {}
def senden(self, aktion):
raise NotImplementedError
def nachlesen(self, actor_url):
"""
Was das Geraet direkt nach einem Kommando meldet.
Fuer die meisten Transporte nichts: MQTT-Geraete melden sich von
selbst, HTTP und WLED werden ohnehin jede Minute gefragt. Nur bei
Tahoma lohnt es sich - dort liegen fuenf Minuten zwischen zwei
Abfragen, und eine Jalousie steht so lange mit ihrem alten Stand in
der Tabelle.
"""
return {}
# ---------------------------------------------------------------------------
# MQTT
# ---------------------------------------------------------------------------
class MQTTTransport(Transport):
"""
Die state_url ist hier das vollstaendige Topic, die parameter_url das
Kommando-Topic. Werte kommen von selbst herein und landen in einem
Zwischenspeicher, den der Runner im Takt abholt.
"""
schema = "mqtt://"
def __init__(self, client, dry_run=False):
self.client = client
self.dry_run = dry_run
self.topics = {} # topic -> [(state_id, value_path, wertetabelle), ...]
self.neu = {} # state_id -> wert
def zustaende_anmelden(self, states):
self.topics = {}
for s in states:
if not s["state_url"]:
continue
self.topics.setdefault(s["state_url"], []).append(
(s["id"], s.get("value_path"), s.get("wertetabelle") or {}))
for topic in self.topics:
self.client.subscribe(topic)
mehrfach = sum(1 for e in self.topics.values() if len(e) > 1)
logger.info("MQTT: %d Topics abonniert, %d davon mit mehreren Messwerten",
len(self.topics), mehrfach)
def nachricht(self, topic, payload):
"""Wird vom Runner aus dem on_message-Rueckruf gerufen."""
eintraege = self.topics.get(topic, [])
if not eintraege:
return
text = payload.decode("utf-8", "replace").strip() if isinstance(payload, bytes) else str(payload)
try:
daten = json.loads(text)
except ValueError:
daten = None # kein JSON - dann gilt der Rohtext
for state_id, pfad, tabelle in eintraege:
wert = self._wert(text, daten, pfad, tabelle)
if wert is not None:
self.neu[state_id] = wert
@staticmethod
def _wert(text, daten, pfad, tabelle=None):
"""
Aus der Nutzlast den Wert eines einzelnen Messwerts machen.
Mehrere Messwerte teilen sich oft ein Topic; welcher Teil gemeint ist,
steht in value_path - gelesen aus dem value_template der
Home-Assistant-Discovery. Ohne Pfad gilt die ganze Nutzlast, und
JSON-Skalare werden ausgepackt: manche Geraete schicken 21.4 mit
Anfuehrungszeichen, andere true statt ON.
Steht eine Werttabelle dabei, wird aus der gesendeten Zahl der
Zustandsname: aus 2 wird "Charging".
"""
if daten is None:
return uebersetze(text, tabelle)
try:
wert = wert_aus_pfad(daten, pfad)
except (KeyError, IndexError, TypeError):
logger.debug("Pfad %s nicht in der Nutzlast: %s", pfad, text[:80])
return None
if isinstance(wert, bool):
wert = "true" if wert else "false"
elif isinstance(wert, (int, float, str)):
wert = str(wert)
else:
return json.dumps(wert, ensure_ascii=False)
return uebersetze(wert, tabelle)
def zustaende_lesen(self):
werte, self.neu = self.neu, {}
return werte
def senden(self, aktion):
if not aktion["params"]:
# Kommando ohne Parameter: das Kommando selbst ist die Nutzlast.
self._publish(aktion["command_url"], "")
return
for p in aktion["params"]:
self._publish(p["url"] or aktion["command_url"], p["wert"])
def _publish(self, topic, nutzlast):
if not topic:
raise ValueError("Kommando ohne Topic")
if self.dry_run:
logger.info("[dry-run] MQTT %s <- %s", topic, nutzlast)
return
ergebnis = self.client.publish(topic, nutzlast, qos=1, retain=False)
ergebnis.wait_for_publish(timeout=5)
# ---------------------------------------------------------------------------
# HTTP (Shelly und Verwandte)
# ---------------------------------------------------------------------------
class HTTPTransport(Transport):
"""
Die actor_url ist der Endpunkt, die state_url ein Feldname in dessen
JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von
selbst, sie werden im Poll-Takt gefragt.
Beim Senden wird die command_url als Abfrageargument an die actor_url
gehaengt ("turn=on") und die Parameter mit ihrem eigenen Namen dazu.
Frueher stand hier eine Uebersetzungstabelle, weil die Shelly-Kommandos
ohne URL in der Datenbank landeten - das ist im Discovery behoben, die
Zuordnung gehoert dorthin und nicht in den Runner.
"""
schema = "http"
def __init__(self, requests_modul, timeout=5, dry_run=False):
self.requests = requests_modul
self.timeout = timeout
self.dry_run = dry_run
self.states = []
def zustaende_anmelden(self, states):
self.states = [s for s in states if s["state_url"]]
logger.info("HTTP: %d Messwerte an %d Endpunkten",
len(self.states), len({s["actor_url"] for s in self.states}))
def zustaende_lesen(self):
werte = {}
# Je Endpunkt eine Anfrage, auch wenn mehrere Messwerte daran haengen.
nach_url = {}
for s in self.states:
nach_url.setdefault(s["actor_url"], []).append(s)
for url, states in nach_url.items():
try:
antwort = self.requests.get(url, timeout=self.timeout)
daten = antwort.json()
except Exception as fehler:
logger.debug("HTTP %s nicht erreichbar: %s", url, fehler)
continue
for s in states:
if isinstance(daten, dict) and s["state_url"] in daten:
werte[s["id"]] = str(daten[s["state_url"]])
return werte
def senden(self, aktion):
argumente = {}
for teil in (aktion["command_url"] or "").split("&"):
if "=" in teil:
schluessel, wert = teil.split("=", 1)
argumente[schluessel] = wert
for p in aktion["params"]:
if p["url"]:
argumente[p["url"]] = p["wert"]
if not argumente:
raise RuntimeError("Kommando ohne URL und ohne Parameter - im "
"Geraetemodell fehlt die Angabe, was zu schicken ist")
if self.dry_run:
logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente)
return
antwort = self.requests.get(aktion["actor_url"], params=argumente, timeout=self.timeout)
if antwort.status_code >= 400:
raise RuntimeError("HTTP %d von %s" % (antwort.status_code, aktion["actor_url"]))
# ---------------------------------------------------------------------------
# WLED
# ---------------------------------------------------------------------------
class WLEDTransport(Transport):
"""
WLED-Lampen sprechen ueber eine einzige JSON-Schnittstelle:
GET http://IP/json/state liefert den Zustand, POST dorthin setzt ihn.
Das Geraetemodell nutzt das elegant aus - die command_url ist eine
JSON-Vorlage mit Platzhaltern:
{"bri":%brightness%}
{"seg":[{"col":[[%red%,%green%,%blue%]]}]}
Gesendet wird also nicht Argument fuer Argument, sondern die ausgefuellte
Vorlage am Stueck. Deshalb haben die Parameter hier auch keine eigene URL:
ihr Name ist der Platzhalter.
Die state_url ist ein Pfad in die Antwort ("on", "bri",
"seg[0].col[0]") - dieselbe Schreibweise, die auch in current_value steht.
"""
schema = "wled://"
def __init__(self, requests_modul, timeout=5, dry_run=False):
self.requests = requests_modul
self.timeout = timeout
self.dry_run = dry_run
self.states = []
@staticmethod
def _adresse(actor_url):
return "http://" + actor_url[len("wled://"):].rstrip("/")
def zustaende_anmelden(self, states):
self.states = [s for s in states if s["state_url"]]
logger.info("WLED: %d Messwerte an %d Lampen",
len(self.states), len({s["actor_url"] for s in self.states}))
def zustaende_lesen(self):
werte = {}
nach_lampe = {}
for s in self.states:
nach_lampe.setdefault(s["actor_url"], []).append(s)
for actor_url, states in nach_lampe.items():
try:
antwort = self.requests.get(self._adresse(actor_url) + "/json/state",
timeout=self.timeout)
daten = antwort.json()
except Exception as fehler:
logger.debug("WLED %s nicht erreichbar: %s", actor_url, fehler)
continue
for s in states:
# Bei WLED ist die state_url selbst schon der Pfad. Ein
# gesetzter value_path hat trotzdem Vorrang, falls das
# Geraetemodell spaeter darauf umgestellt wird.
pfad = s.get("value_path") or s["state_url"]
try:
werte[s["id"]] = str(wert_aus_pfad(daten, pfad))
except (KeyError, IndexError, TypeError):
logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, pfad)
return werte
def senden(self, aktion):
vorlage = aktion["command_url"]
if not vorlage:
raise RuntimeError("WLED-Kommando ohne Vorlage")
for p in aktion["params"]:
vorlage = vorlage.replace("%" + p["name"] + "%", str(p["wert"]))
try:
rumpf = json.loads(vorlage)
except ValueError:
# Ein nicht ersetzter Platzhalter oder ein Textwert an einer
# Stelle, wo eine Zahl stehen muss. Lieber hier abbrechen als der
# Lampe etwas Unverstaendliches schicken.
raise RuntimeError("WLED-Vorlage ergibt kein gueltiges JSON: " + vorlage[:120])
if self.dry_run:
logger.info("[dry-run] WLED %s <- %s", aktion["actor_url"],
json.dumps(rumpf, ensure_ascii=False))
return
antwort = self.requests.post(self._adresse(aktion["actor_url"]) + "/json/state",
json=rumpf, timeout=self.timeout)
if antwort.status_code >= 400:
raise RuntimeError("WLED antwortete mit %d" % antwort.status_code)
# ---------------------------------------------------------------------------
# Tahoma
# ---------------------------------------------------------------------------
class TahomaTransport(Transport):
"""
Die actor_url ist die deviceURL, die state_url ein Statusname
("core:ClosureState"), die command_url ein Kommandoname ("setClosure").
Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne
die dortige Sonderbehandlung fuer "faehrt gerade".
Zustaendig ist dieser Transport fuer alles, was die Kennung der eigenen
Box in der URL traegt. Am Schema laesst sich das nicht festmachen: es
beschreibt die Funkart, und dieselbe Box liefert io:// fuer die
Jalousien, rts:// fuer die Dachfenster und internal:// fuer die Alarm-
anlage. Ohne PIN in der config.ini ist niemand zustaendig - dann meldet
der Runner beim Ausloesen "kein Transport", statt still nichts zu tun.
"""
schema = "io://"
# So heissen die beiden Parameter einer Jalousie im Geraetemodell.
#
# An ihnen wird die Kugelschreiber-Mechanik erkannt, nicht am
# Kommandonamen: eine Hoehenfahrt rastet die Lamellen um, und aus der
# neuen Raststellung heraus laesst sich die Neigung nicht direkt
# anfahren - die Mechanik muss dafuer erst auf 0 % zurueck. Setzt ein
# Befehl beide Parameter, war eine Hoehenfahrt dabei und der Umweg ist
# faellig; setzt er nur die Neigung, steht die Mechanik schon richtig.
#
# Dasselbe steht in restricted/commands.php: beide Versender brauchen es.
NEIGUNG_PARAMETER = "Neigung"
POSITION_PARAMETER = "Position"
# So lange wird hoechstens auf das Ende einer Fahrt gewartet. Gemessen:
# eine Neigung von 100 % auf 0 % dauert gut fuenfzehn Sekunden, eine
# volle Fahrt von oben nach unten rund sechzig. Die Grenze ist die
# Notbremse, nicht die uebliche Dauer.
JALOUSIE_WARTE_SEKUNDEN = 120
# So lange gilt ein "faehrt nicht" direkt nach dem Absenden als noch
# nicht aussagekraeftig: die Box meldet core:MovingState traege, kurz
# nach einem Kommando steht dort noch der alte Wert.
JALOUSIE_VORLAUF_SEKUNDEN = 8
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
self.requests = requests_modul
self.pin = pin
self.token = token
self.timeout = timeout
self.dry_run = dry_run
self.states = []
self.kombigeraete = set()
def passt(self, actor_url):
return bool(self.pin) and ("://" + self.pin + "/") in actor_url
@property
def basis(self):
return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin
def _kopf(self):
return {"Content-Type": "application/json",
"Authorization": "Bearer " + self.token}
def zustaende_anmelden(self, states):
self.states = [s for s in states if s["state_url"]]
logger.info("Tahoma: %d Messwerte an %d Geraeten",
len(self.states), len({s["actor_url"] for s in self.states}))
def kombigeraete_setzen(self, urls):
"""
Welche Geraete Position und Neigung zusammen koennen - vom Runner aus
dem Geraetemodell gesetzt, damit der Transport dafuer nicht selbst in
die Datenbank greifen muss.
"""
self.kombigeraete = set(urls)
def _zustaende(self, actor_url):
"""
Alle Zustaende eines Geraets, oder None wenn die Box nicht antwortet.
Ein leeres Dict waere hier falsch: es hiesse "kennt keine Zustaende"
und liesse eine Warteschleife durchlaufen, statt sie warten zu lassen.
"""
try:
antwort = self.requests.get(
self.basis + "/setup/devices/" + quote(actor_url, safe="") + "/states",
headers=self._kopf(), timeout=self.timeout, verify=False)
return {z["name"]: z.get("value") for z in antwort.json()}
except Exception as fehler:
logger.debug("Tahoma %s nicht erreichbar: %s", actor_url, fehler)
return None
def _zuordnen(self, actor_url, zustaende):
"""Die gelesenen Felder den Messwert-Nummern dieses Geraets zuordnen."""
werte = {}
for s in self.states:
if s["actor_url"] == actor_url and s["state_url"] in zustaende:
werte[s["id"]] = str(zustaende[s["state_url"]])
return werte
def zustaende_lesen(self):
if not self.token:
return {}
werte = {}
for geraet in {s["actor_url"] for s in self.states}:
zustaende = self._zustaende(geraet)
if zustaende is None:
continue
werte.update(self._zuordnen(geraet, zustaende))
return werte
def nachlesen(self, actor_url):
"""
Nach einem Kommando abwarten, bis das Geraet steht, und dann seinen
Stand melden - statt ihn bis zur naechsten Runde (poll_tahoma, fuenf
Minuten) alt in der Tabelle stehen zu lassen.
Gewartet wird nur auf core:MovingState, ohne Zielwerte: welche das
waeren, weiss hier niemand, und ein Kommando muss auch keine Fahrt
ausloesen. Der Vorlauf faengt die Traegheit der Box ab, danach gilt
ein "faehrt nicht" als Stillstand.
"""
if not self.token:
return {}
start = time.time()
gestartet = False
zustaende = None
letzter_stand = None
while time.time() - start < self.JALOUSIE_WARTE_SEKUNDEN:
time.sleep(2)
gelesen = self._zustaende(actor_url)
if gelesen is None:
continue
zustaende = gelesen
faehrt = zustaende.get("core:MovingState") is True
# "faehrt nicht mehr" ist nicht "steht": die Box meldet das Ende
# der Fahrt, bevor Hoehe und Neigung darauf nachgezogen haben.
# Erst zwei gleiche Ablesungen hintereinander sind der Endstand.
stand = None if faehrt else (
str(zustaende.get("core:ClosureState")),
str(zustaende.get("core:SlateOrientationState")))
steht = stand is not None and stand == letzter_stand
letzter_stand = stand
if faehrt:
gestartet = True
continue
if steht and (gestartet
or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
break
return self._zuordnen(actor_url, zustaende) if zustaende else {}
def senden(self, aktion):
if not self.token:
raise RuntimeError("Kein Tahoma-Token in der config.ini")
# Die Reihenfolge der Parameter ist die aus command_parameters - bei
# setClosureAndOrientation also erst Position, dann Winkel.
befehl = aktion["command_url"]
parameter = [self._zahl(p["wert"]) for p in aktion["params"]]
neigung_index = None
position_index = None
for i, p in enumerate(aktion["params"]):
if p["name"] == self.NEIGUNG_PARAMETER:
neigung_index = i
elif p["name"] == self.POSITION_PARAMETER:
position_index = i
# "Zu" allein macht diese Jalousien nicht dicht: sie faehrt herunter,
# die Lamellen bleiben durch die Mechanik aber bei etwa 30 % offen.
# Gemeint ist "ganz unten, Lamellen geschlossen" - also dasselbe wie
# Position 100 mit Neigung 100, und damit ein Fall fuer die Regel.
if befehl == "down" and self._kannKombi(aktion["actor_url"]):
befehl = "setClosureAndOrientation"
parameter = [100, 100]
position_index, neigung_index = 0, 1
# Kugelschreiber-Mechanik, siehe NEIGUNG_PARAMETER. Geschickt wird
# derselbe Befehl zweimal - erst mit Neigung 0, dann mit dem
# gewuenschten Wert. Die Position bleibt dabei stehen, die Jalousie
# faehrt also nur einmal.
#
# Eine Aktion, die nur die Position setzt, hat kein Neigungsziel -
# fuer die gibt es hier nichts zu tun. Wer die Neigung nach einer
# Hoehenfahrt gestellt haben will, nimmt "Position+Neigung".
umweg = (neigung_index is not None
and position_index is not None
and isinstance(parameter[neigung_index], (int, float)))
vorstufe = list(parameter)
if umweg:
vorstufe[neigung_index] = 0
if self.dry_run:
logger.info("[dry-run] Tahoma %s %s%s", befehl, parameter,
" (zuerst %s, dann warten)" % vorstufe if umweg else "")
return
if umweg:
self._apply(aktion["actor_url"], befehl, vorstufe)
self._warteAufJalousie(aktion["actor_url"], 0,
None if position_index is None else int(parameter[position_index]))
self._apply(aktion["actor_url"], befehl, parameter)
def _kannKombi(self, actor_url):
"""
Hat das Geraet ein Kommando fuer Position und Neigung zusammen?
Nur solche Geraete sind Jalousien mit der Kugelschreiber-Mechanik.
"""
return actor_url in self.kombigeraete
def _apply(self, actor_url, name, parameter):
"""Ein Kommando an die Box schicken."""
rumpf = {"label": "AutoAction",
"actions": [{"deviceURL": actor_url,
"commands": [{"name": name, "parameters": parameter}]}]}
antwort = self.requests.post(self.basis + "/exec/apply", headers=self._kopf(),
data=json.dumps(rumpf), timeout=self.timeout, verify=False)
if antwort.status_code >= 400:
raise RuntimeError("Tahoma antwortete mit %d: %s"
% (antwort.status_code, antwort.text[:120]))
def _warteAufJalousie(self, actor_url, neigung_ziel, schliessung_ziel=None):
"""
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
Zwei Auskuenfte zusammen, weil einzeln keine traegt:
core:MovingState taugt fuer die lange Fahrt hoch und runter, wird bei
kurzen Neigungsfahrten aber nie gesetzt; die Zustandswerte sind die
eigentliche Wahrheit, zeigen kurz nach dem Kommando aber noch den
alten Stand. Fertig ist die Fahrt, wenn nichts mehr faehrt, die Ziele
erreicht sind und entweder ein "faehrt" gesehen wurde oder der
Vorlauf um ist.
"""
start = time.time()
gestartet = False
while time.time() - start < self.JALOUSIE_WARTE_SEKUNDEN:
time.sleep(2)
z = self._zustaende(actor_url)
if z is None:
continue
if z.get("core:MovingState") is True:
gestartet = True
continue
neigung = z.get("core:SlateOrientationState")
schliessung = z.get("core:ClosureState")
# Ein fehlendes Feld darf nicht als 0 durchgehen - das waere
# ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
neigung_ok = neigung is not None and int(neigung) == neigung_ziel
schliessung_ok = (schliessung_ziel is None
or (schliessung is not None and int(schliessung) == schliessung_ziel))
if neigung_ok and schliessung_ok and (
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
return True
logger.warning("%s hat Neigung %s%% nicht innerhalb von %d s erreicht",
actor_url, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN)
return False
@staticmethod
def _zahl(wert):
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""
try:
return int(wert)
except (TypeError, ValueError):
pass
try:
return float(wert)
except (TypeError, ValueError):
return wert
# ---------------------------------------------------------------------------
# Logic - das gerechnete Geraet
# ---------------------------------------------------------------------------
class LogicTransport(Transport):
"""
Uhrzeit, Datum, Sonnenauf- und -untergang. Es gibt nichts zu abonnieren und
nichts zu schalten, die Werte entstehen im Takt. Sonnenzeiten kommen aus
solarLog.daylight, dieselbe Tabelle, aus der auch ajax/getSunrise.php liest.
"""
schema = "Logic"
def __init__(self, sonnenzeiten):
"""sonnenzeiten: Funktion() -> (sonnenaufgang, sonnenuntergang) als "HH:MM"."""
self.sonnenzeiten = sonnenzeiten
self.states = []
def passt(self, actor_url):
return actor_url == "Logic"
def zustaende_anmelden(self, states):
self.states = states
logger.info("Logic: %d Messwerte", len(states))
def zustaende_lesen(self):
jetzt = datetime.now()
auf, unter = self.sonnenzeiten()
tabelle = {
"time": jetzt.strftime("%H:%M"),
"date": jetzt.strftime("%d.%m.%Y"),
"sunrise": auf,
"sunset": unter,
}
return {s["id"]: tabelle[s["state_url"]]
for s in self.states if s["state_url"] in tabelle}
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")