Benachrichtigungen: Web Push und E-Mail als Kanaele der Automatiken

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>
This commit is contained in:
2026-09-21 08:37:08 +02:00
co-authored by Claude Opus 5
parent 0a37d8d763
commit 35382a6ae1
29 changed files with 4985 additions and 5 deletions
+187 -4
View File
@@ -40,7 +40,9 @@ id, `aktion` ein Dict mit actor_url, command_url und params (Liste aus
import json
import logging
import os
import re
import sys
import time
import urllib3
from datetime import datetime
@@ -48,6 +50,14 @@ 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
@@ -600,9 +610,17 @@ class TahomaTransport(Transport):
if umweg:
self._apply(aktion["actor_url"], befehl, vorstufe)
self._warteAufJalousie(aktion["actor_url"], 0,
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):
"""
@@ -622,7 +640,7 @@ class TahomaTransport(Transport):
raise RuntimeError("Tahoma antwortete mit %d: %s"
% (antwort.status_code, antwort.text[:120]))
def _warteAufJalousie(self, actor_url, neigung_ziel, schliessung_ziel=None):
def _warteAufJalousie(self, actor_url, actor_name, neigung_ziel, schliessung_ziel=None):
"""
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
@@ -654,8 +672,8 @@ class TahomaTransport(Transport):
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)
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
@@ -795,3 +813,168 @@ class AutomatikTransport(Transport):
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