Der Funk von der Box zum Motor ist unbestaetigt. Die Box nimmt einen Befehl an und meldet Erfolg, auch wenn er den Motor nie erreicht - dann faehrt der Behang gar nicht. Erkennbar ist das allein daran, dass er hinterher nicht dort steht, wo er stehen soll. Genau das ist jetzt die Bedingung fuer den zweiten Versuch. Der Ansatz stand schon in der Datei, konnte aber nicht wirken: Die Endstellung wurde gegen Neigung 0 geprueft, obwohl gerade die gewuenschte Neigung geschickt worden war. Damit lieferte die Wartefunktion immer "nicht erreicht" - verlorener Befehl und geglueckte Fahrt sahen gleich aus, jedes Kommando lief zweimal 120 Sekunden und ging zweimal raus. aktion["actor_name"] gibt es im Auftrag nicht; der Name steht eine Ebene hoeher. Der KeyError flog nach der Vorstufe, also nachdem "Neigung 0" schon draussen war: der Behang fuhr auf Position und liess die Lamellen offen. 23 Mal im Protokoll, zuletzt am 21.09. um 18:49 an acht Jalousien. Jetzt: _positionsZiel() leitet das Ziel auch fuer "up" und "down" ab (0 bzw. 100 Prozent Schliessung). Ohne das haette ausgerechnet der haeufigste Befehl kein pruefbares Ziel und der Wiederholversuch liefe fuer ihn leer. _zieleErreicht() vergleicht mit zwei Prozentpunkten Spielraum: io-Motoren melden fuer befohlene 100 gern 99 oder 101. Ohne Toleranz gaelte eine geglueckte Fahrt als verloren. None heisst "ist egal" - damit funktionieren auch Rollladen ohne Lamellen und reine Neigungsbefehle. _fahren() haelt die Schleife an einer Stelle. Vor der Wiederholung wird noch einmal nachgesehen, weil der Stand nur alle zwei Sekunden gelesen wird und der Behang in der letzten Sekunde angekommen sein kann. Ohne pruefbares Ziel (stop, my, wink) wird einmal geschickt und nicht gewartet. Wiederholt wird ausdruecklich immer, wenn das Ziel nicht erreicht ist - auch wenn der Behang unterwegs war und woanders stehengeblieben ist. Die Unterscheidung waere ueber core:MovingState moeglich und ist bewusst nicht gewollt. Geprueft ohne Schaltbefehle: Ziel erreicht -> einmal gesendet; Befehl verloren -> zweimal; erster verloren, zweiter kommt an -> zweimal, Erfolg; 99 statt 100 -> einmal; Rollladen ohne Lamellen, stop und reine Neigung jeweils richtig. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1063 lines
43 KiB
Python
1063 lines
43 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
|
|
|
|
# So oft wird ein Kommando hoechstens geschickt, wenn der Behang danach
|
|
# nicht auf seinem Ziel steht. Zwei Versuche, weil ein dritter bei einem
|
|
# wirklich stummen Motor nur Zeit kostet - siehe _fahren().
|
|
JALOUSIE_VERSUCHE = 2
|
|
|
|
# Wie weit der gemeldete Stand vom Ziel abweichen darf, in Prozentpunkten.
|
|
JALOUSIE_TOLERANZ = 2
|
|
|
|
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
|
|
|
|
position_ziel = self._positionsZiel(befehl, parameter, position_index)
|
|
neigung_ziel = (int(parameter[neigung_index])
|
|
if neigung_index is not None else None)
|
|
|
|
if umweg:
|
|
# Dieselbe Position, aber Neigung 0 - das ist der ganze Sinn der
|
|
# Vorstufe.
|
|
self._fahren(aktion, befehl, vorstufe, 0, position_ziel, "Vorstufe")
|
|
self._fahren(aktion, befehl, parameter, neigung_ziel, position_ziel, "Endstellung")
|
|
|
|
def _positionsZiel(self, befehl, parameter, position_index):
|
|
"""
|
|
Wo der Behang danach stehen soll - oder None, wenn das niemand weiss.
|
|
|
|
Bei setClosure und setClosureAndOrientation steht es im Parameter.
|
|
"up" und "down" tragen keinen, meinen aber null bzw. hundert Prozent
|
|
Schliessung; ohne diese Zuordnung haette ausgerechnet der haeufigste
|
|
Befehl kein pruefbares Ziel, und der Wiederholversuch liefe fuer ihn
|
|
leer.
|
|
|
|
Bei "my", "stop" und "wink" gibt es wirklich keines. Dort wird nichts
|
|
geprueft und nichts wiederholt - siehe _fahren().
|
|
"""
|
|
if position_index is not None:
|
|
return int(parameter[position_index])
|
|
return {"up": 0, "down": 100}.get(befehl)
|
|
|
|
def _fahren(self, aktion, befehl, parameter, neigung_ziel, position_ziel, schritt):
|
|
"""
|
|
Ein Kommando schicken und wiederholen, bis der Behang sein Ziel zeigt.
|
|
|
|
Der Funk von der Box zum Motor ist unbestaetigt: die Box nimmt den
|
|
Befehl an und meldet Erfolg, auch wenn er den Motor nie erreicht -
|
|
dann faehrt der Behang gar nicht. Erkennbar ist das allein daran,
|
|
dass er hinterher nicht dort steht, wo er stehen soll. Genau das ist
|
|
die Bedingung fuer den zweiten Versuch.
|
|
|
|
Wiederholt wird in jedem Fall, in dem das Ziel nicht erreicht ist.
|
|
Ob der Behang unterwegs war, liesse sich an core:MovingState ablesen
|
|
und koennte einen Abbruch begruenden (Hindernis, Wandschalter) - das
|
|
ist hier bewusst nicht gewollt: ein Behang, der nicht dort steht, wo
|
|
er stehen soll, soll es noch einmal versuchen.
|
|
|
|
Ohne pruefbares Ziel wird einmal geschickt und nicht gewartet. Bei
|
|
"stop" auf das Ende einer Fahrt zu warten waere ein Widerspruch in
|
|
sich, und zu pruefen gaebe es nichts.
|
|
"""
|
|
name = aktion.get("actor_name") or aktion["actor_url"]
|
|
if neigung_ziel is None and position_ziel is None:
|
|
self._apply(aktion["actor_url"], befehl, parameter)
|
|
return True
|
|
|
|
for versuch in range(1, self.JALOUSIE_VERSUCHE + 1):
|
|
self._apply(aktion["actor_url"], befehl, parameter)
|
|
if self._warteAufJalousie(aktion["actor_url"],
|
|
"%s, %s, Versuch %d" % (name, schritt, versuch),
|
|
neigung_ziel, position_ziel):
|
|
return True
|
|
if versuch >= self.JALOUSIE_VERSUCHE:
|
|
break
|
|
# Vor der zweiten Fahrt noch einmal hinsehen. Der Stand wird alle
|
|
# zwei Sekunden gelesen und von der Box traege gemeldet - er kann
|
|
# in der letzten Sekunde des Fensters angekommen sein, und dann
|
|
# waere die Wiederholung eine Fahrt zuviel.
|
|
if self._zieleErreicht(self._zustaende(aktion["actor_url"]),
|
|
neigung_ziel, position_ziel):
|
|
return True
|
|
logger.info("%s (%s): nicht auf dem Ziel, %d. Versuch",
|
|
name, schritt, versuch + 1)
|
|
logger.warning("%s (%s): Ziel nach %d Versuchen nicht erreicht",
|
|
name, schritt, self.JALOUSIE_VERSUCHE)
|
|
return False
|
|
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 _zieleErreicht(self, zustaende, neigung_ziel, schliessung_ziel):
|
|
"""
|
|
Zeigt das Geraet die gewuenschten Werte? None heisst "ist egal".
|
|
|
|
Verglichen wird mit Spielraum: io-Motoren melden fuer befohlene 100 %
|
|
gern 99 oder 101. Ohne Toleranz gaelte eine geglueckte Fahrt als
|
|
verloren, und der Behang fuehre ein zweites Mal - genau das, was der
|
|
Wiederholversuch verhindern soll.
|
|
|
|
Ein fehlendes Feld gilt nie als erreicht. Als 0 durchgehen zu lassen
|
|
waere ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
|
|
"""
|
|
if zustaende is None:
|
|
return False
|
|
|
|
def passt(wert, ziel):
|
|
if ziel is None:
|
|
return True
|
|
return wert is not None and abs(int(wert) - ziel) <= self.JALOUSIE_TOLERANZ
|
|
|
|
return (passt(zustaende.get("core:SlateOrientationState"), neigung_ziel)
|
|
and passt(zustaende.get("core:ClosureState"), schliessung_ziel))
|
|
|
|
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
|
|
if self._zieleErreicht(z, neigung_ziel, schliessung_ziel) and (
|
|
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
|
|
return True
|
|
logger.warning("%s (%s) steht nach %d s nicht auf Neigung %s / Position %s",
|
|
actor_url, actor_name, self.JALOUSIE_WARTE_SEKUNDEN,
|
|
neigung_ziel, schliessung_ziel)
|
|
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
|