Neuer BenachrichtigungTransport hinter der Aktor-URL "Benachrichtigung": push schickt an alle angemeldeten Geraete (homeMesh.push_abos), mail ueber den SMTP-Zugang aus der config.ini. Ein Abo, das der Push-Dienst mit 404 oder 410 ablehnt, wird geloescht statt weiter angeschrieben. Daneben hoert der Runner auf benachrichtigung/# - darueber schickt die Probe aus den Einstellungen, ohne dass die Weboberflaeche verschluesseln muesste. Zugestellt wird im Haupttakt, nicht im MQTT-Faden. pywebpush, py_vapid und http_ece liegen wie die uebrigen Fremdpakete im Ordner, nicht in site-packages; transports.py haengt ihn an den Suchpfad. Das Schluesselpaar erzeugt vapid_erzeugen.py und bleibt ausserhalb des Git. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
981 lines
40 KiB
Python
981 lines
40 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 os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib3
|
|
from datetime import datetime
|
|
from urllib.parse import quote
|
|
|
|
logger = logging.getLogger("autoaction.transport")
|
|
|
|
# Die Pakete fuer Web Push (pywebpush, py_vapid, http_ece) liegen wie alle
|
|
# anderen Fremdpakete im SolarManager-Ordner, nicht in site-packages. Der
|
|
# Runner startet aber aus autoActions/ heraus - ohne diese Zeile faende er
|
|
# sie nicht.
|
|
_SOLARMANAGER = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if _SOLARMANAGER not in sys.path:
|
|
sys.path.append(_SOLARMANAGER)
|
|
|
|
# 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)
|
|
if(not self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 1.Versuch", 0,
|
|
None if position_index is None else int(parameter[position_index]))):
|
|
self._apply(aktion["actor_url"], befehl, vorstufe)
|
|
self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 2.Versuch", 0,
|
|
None if position_index is None else int(parameter[position_index]))
|
|
self._apply(aktion["actor_url"], befehl, parameter)
|
|
if(not self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 1.VersuchEndPos", 0,
|
|
None if position_index is None else int(parameter[position_index]))):
|
|
self._apply(aktion["actor_url"], befehl, parameter)
|
|
self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 2.VersuchEndPos", 0,
|
|
None if position_index is None else int(parameter[position_index]))
|
|
|
|
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, actor_name, 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(%s) hat Neigung %s%% nicht innerhalb von %d s erreicht",
|
|
actor_url,actor_name, 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")
|
|
|
|
|
|
# ===========================================================================
|
|
# Benachrichtigungen
|
|
# ===========================================================================
|
|
|
|
BENACHRICHTIGUNG_URL = "Benachrichtigung"
|
|
|
|
#: Themen, auf denen auch andere Stellen eine Meldung anstossen koennen - die
|
|
#: Probe aus den Einstellungen nimmt diesen Weg (restricted/push.php).
|
|
BENACHRICHTIGUNG_TOPIC = "benachrichtigung/#"
|
|
|
|
|
|
class BenachrichtigungTransport(Transport):
|
|
"""
|
|
Meldungen aufs Handy (Web Push) und per E-Mail.
|
|
|
|
Ein gerechnetes Geraet wie LogicTransport: Es steht hinter der Aktor-URL
|
|
"Benachrichtigung" und hat keine Messwerte, nur Kommandos - eines je
|
|
Kanal. Fuer den Editor ist das ein Geraet wie jedes andere, deshalb
|
|
braucht die Weboberflaeche dafuer keine Zeile.
|
|
|
|
Web Push braucht drei Dinge, die alle schon da sind: ein Abo je Geraet
|
|
(homeMesh.push_abos, angelegt vom Browser), ein VAPID-Schluesselpaar
|
|
(push_vapid.json) und die Pakete im SolarManager-Ordner. Verschluesselt
|
|
wird gegen die Schluessel des Geraets - der Push-Dienst des Herstellers
|
|
sieht nur ein Paket, das er nicht lesen kann.
|
|
|
|
Ein Abo, das der Dienst mit 404 oder 410 ablehnt, gibt es nicht mehr
|
|
(Browserdaten geloescht, Symbol entfernt). Es wird dann geloescht statt
|
|
ewig weiter angeschrieben: sonst haengt an jeder Meldung ein Fehler, den
|
|
niemand beheben kann.
|
|
"""
|
|
|
|
schema = "benachrichtigung"
|
|
|
|
def __init__(self, abos_lesen, abo_weg, abo_erfolg, mail_konfig, vapid_datei,
|
|
dry_run=False):
|
|
self.abos_lesen = abos_lesen # () -> [{id, endpoint, p256dh, auth}]
|
|
self.abo_weg = abo_weg # (id, grund) -> None
|
|
self.abo_erfolg = abo_erfolg # (id) -> None
|
|
self.mail = mail_konfig or {}
|
|
self.vapid_datei = vapid_datei
|
|
self.dry_run = dry_run
|
|
self._vapid = None
|
|
|
|
def passt(self, actor_url):
|
|
return str(actor_url or "") == BENACHRICHTIGUNG_URL
|
|
|
|
def zustaende_anmelden(self, states):
|
|
pass # nichts zu lesen - das Geraet meldet nichts
|
|
|
|
def zustaende_lesen(self):
|
|
return {}
|
|
|
|
# --- Kanaele ---------------------------------------------------------
|
|
|
|
def senden(self, aktion):
|
|
kanal = str(aktion.get("command_url") or "")
|
|
werte = {p.get("url") or p.get("name"): str(p.get("wert") or "")
|
|
for p in aktion.get("params") or []}
|
|
if kanal == "push":
|
|
self.push(werte.get("titel") or "Smarthome", werte.get("text") or "")
|
|
elif kanal == "mail":
|
|
self.mail_senden(werte.get("betreff") or "Smarthome", werte.get("text") or "")
|
|
else:
|
|
raise ValueError("Unbekannter Kanal: %s" % kanal)
|
|
|
|
def vapid(self):
|
|
"""
|
|
Das Schluesselpaar, einmal gelesen und in ein Vapid-Objekt gepackt.
|
|
|
|
pywebpush nimmt als Schluessel entweder einen Dateipfad, eine
|
|
base64-Zeichenkette oder ein fertiges Vapid-Objekt - aber nicht den
|
|
PEM-Text selbst. Der steht in push_vapid.json, also wird er hier
|
|
einmal eingelesen.
|
|
"""
|
|
if self._vapid is None:
|
|
from py_vapid import Vapid01
|
|
with open(self.vapid_datei) as f:
|
|
daten = json.load(f)
|
|
daten["vapid"] = Vapid01.from_pem(daten["private_key_pem"].encode("utf-8"))
|
|
self._vapid = daten
|
|
return self._vapid
|
|
|
|
def push(self, titel, text):
|
|
"""An alle angemeldeten Geraete."""
|
|
from pywebpush import webpush, WebPushException
|
|
|
|
abos = self.abos_lesen()
|
|
if not abos:
|
|
logger.info("Push \"%s\": kein Geraet angemeldet", titel)
|
|
return
|
|
if self.dry_run:
|
|
logger.info("[dry-run] Push an %d Geraet(e): %s / %s", len(abos), titel, text)
|
|
return
|
|
|
|
schluessel = self.vapid()
|
|
nutzlast = json.dumps({"titel": titel, "text": text, "url": "index.php"},
|
|
ensure_ascii=False)
|
|
fehler = []
|
|
for abo in abos:
|
|
try:
|
|
webpush(
|
|
subscription_info={
|
|
"endpoint": abo["endpoint"],
|
|
"keys": {"p256dh": abo["p256dh"], "auth": abo["auth"]},
|
|
},
|
|
data=nutzlast,
|
|
vapid_private_key=schluessel["vapid"],
|
|
vapid_claims={"sub": schluessel.get("subject", "mailto:admin@example.org")},
|
|
timeout=10,
|
|
)
|
|
self.abo_erfolg(abo["id"])
|
|
except WebPushException as f:
|
|
code = getattr(getattr(f, "response", None), "status_code", 0)
|
|
if code in (404, 410):
|
|
self.abo_weg(abo["id"], "vom Push-Dienst abgemeldet (%d)" % code)
|
|
logger.info("Push-Abo %s ist weg (%d), geloescht", abo["id"], code)
|
|
else:
|
|
fehler.append("%s: %s" % (abo.get("name") or abo["id"], f))
|
|
except Exception as f: # Netz, Zeitlimit, Schluessel
|
|
fehler.append("%s: %r" % (abo.get("name") or abo["id"], f))
|
|
if fehler:
|
|
raise RuntimeError("; ".join(fehler)[:250])
|
|
|
|
def mail_senden(self, betreff, text):
|
|
"""Eine Mail ueber den SMTP-Zugang aus der config.ini."""
|
|
import smtplib
|
|
import ssl
|
|
from email.message import EmailMessage
|
|
|
|
server = self.mail.get("server") or ""
|
|
an = [a.strip() for a in (self.mail.get("an") or "").split(",") if a.strip()]
|
|
if not server or not an:
|
|
raise RuntimeError("Kein Mailzugang eingetragen (Abschnitt [mail] in config.ini)")
|
|
if self.dry_run:
|
|
logger.info("[dry-run] Mail an %s: %s / %s", ", ".join(an), betreff, text)
|
|
return
|
|
|
|
nachricht = EmailMessage()
|
|
nachricht["From"] = self.mail.get("von") or an[0]
|
|
nachricht["To"] = ", ".join(an)
|
|
nachricht["Subject"] = betreff
|
|
nachricht.set_content(text)
|
|
|
|
port = int(self.mail.get("port") or 587)
|
|
# Zwei Bauarten: 465 spricht von Anfang an verschluesselt, 587 beginnt
|
|
# im Klartext und wechselt mit STARTTLS. Alles andere waere heute
|
|
# unverschluesselter Versand - den gibt es hier nicht.
|
|
if port == 465:
|
|
verbindung = smtplib.SMTP_SSL(server, port, timeout=15,
|
|
context=ssl.create_default_context())
|
|
else:
|
|
verbindung = smtplib.SMTP(server, port, timeout=15)
|
|
verbindung.starttls(context=ssl.create_default_context())
|
|
try:
|
|
if self.mail.get("benutzer"):
|
|
verbindung.login(self.mail["benutzer"], self.mail.get("passwort") or "")
|
|
verbindung.send_message(nachricht)
|
|
finally:
|
|
try:
|
|
verbindung.quit()
|
|
except Exception:
|
|
pass
|