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
+106 -1
View File
@@ -75,7 +75,8 @@ import pymysql
import requests
import paho.mqtt.client as mqtt
from transports import (AUTOMATIK_URL, AutomatikTransport, HTTPTransport,
from transports import (AUTOMATIK_URL, BENACHRICHTIGUNG_TOPIC,
BenachrichtigungTransport, AutomatikTransport, HTTPTransport,
LogicTransport, MQTTTransport, TahomaTransport,
WLEDTransport, ausloeser_kennung, ausloeser_url,
ist_topic)
@@ -113,6 +114,18 @@ class Config:
except ValueError:
return vorgabe
def abschnitt(self, sektion):
"""
Einen ganzen Abschnitt als Dict.
Fuer den Mailzugang: der besteht aus einem halben Dutzend Feldern und
wird als Ganzes an den Transport weitergereicht, statt sechsmal
einzeln abgefragt zu werden.
"""
if not self.cfg.has_section(sektion):
return {}
return {k: v.strip() for k, v in self.cfg.items(sektion)}
def ja(self, sektion, schluessel, vorgabe=False):
return self.text(sektion, schluessel, str(vorgabe)).lower() in ("true", "1", "yes", "on")
@@ -755,7 +768,25 @@ class Runner:
config.zahl("tahoma", "timeout", 10), self.dry_run),
LogicTransport(self.sonnenzeiten),
AutomatikTransport(self.ausloesezeiten),
BenachrichtigungTransport(self.push_abos, self.push_abo_weg,
self.push_abo_erfolg,
config.abschnitt("mail"),
# Ein leerer Eintrag in der config.ini ist
# vorhanden, aber leer - die Vorgabe von
# text() greift dann nicht.
(config.text("push", "schluessel")
or os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "push_vapid.json")),
self.dry_run),
]
# Meldungen, die nicht aus einer Automatik kommen: die Probe aus den
# Einstellungen, spaeter vielleicht ein Skript. Derselbe Weg, dieselbe
# Zustellung - nur ohne Umweg ueber eine Regel.
self.meldungen = queue.Queue()
try:
self.mqtt.subscribe(BENACHRICHTIGUNG_TOPIC)
except Exception as fehler:
logger.warning("Meldungs-Topic nicht abonnierbar: %r", fehler)
self.regelwerk = None
# --- Aufbau ----------------------------------------------------------
@@ -774,10 +805,83 @@ class Runner:
def _mqtt_nachricht(self, client, userdata, nachricht):
# Der Client laeuft schon, waehrend __init__ noch die Transporte baut.
if nachricht.topic.startswith("benachrichtigung/"):
# Nicht hier zustellen: Dieser Rueckruf laeuft im MQTT-Faden, und
# eine Datenbankverbindung gehoert einem Faden. Der naechste Takt
# holt es ab.
schlange = getattr(self, "meldungen", None)
if schlange is not None:
schlange.put((nachricht.topic.rsplit("/", 1)[-1], nachricht.payload))
return
for transport in getattr(self, "transporte", []):
if isinstance(transport, MQTTTransport):
transport.nachricht(nachricht.topic, nachricht.payload)
# --- Benachrichtigungen ----------------------------------------------
def benachrichtigung(self):
"""Der Transport fuer die Meldungen, oder None."""
for transport in self.transporte:
if isinstance(transport, BenachrichtigungTransport):
return transport
return None
def meldungen_abarbeiten(self):
"""
Was ueber benachrichtigung/# hereinkam, zustellen.
Nutzlast ist JSON ({"titel": ..., "text": ...}); ein blosser Text
geht auch durch und wird zum Textkoerper.
"""
transport = self.benachrichtigung()
while True:
try:
kanal, rohtext = self.meldungen.get_nowait()
except queue.Empty:
return
if transport is None:
continue
try:
text = rohtext.decode("utf-8", "replace") if isinstance(rohtext, bytes) else str(rohtext)
try:
daten = json.loads(text)
if not isinstance(daten, dict):
raise ValueError
except ValueError:
daten = {"text": text}
if kanal == "mail":
transport.mail_senden(daten.get("betreff") or daten.get("titel") or "Smarthome",
daten.get("text") or "")
else:
transport.push(daten.get("titel") or "Smarthome", daten.get("text") or "")
except Exception as fehler:
logger.warning("Meldung (%s) nicht zustellbar: %r", kanal, fehler)
def push_abos(self):
"""Die angemeldeten Geraete. Fehlt die Tabelle, gibt es eben keine."""
try:
with self.db.cursor() as c:
c.execute("SELECT id, endpoint, p256dh, auth, name FROM push_abos")
return list(c.fetchall())
except Exception as fehler:
logger.debug("push_abos nicht lesbar: %r", fehler)
return []
def push_abo_weg(self, abo_id, grund):
try:
with self.db.cursor() as c:
c.execute("DELETE FROM push_abos WHERE id = %s", (abo_id,))
except Exception as fehler:
logger.warning("Push-Abo %s nicht loeschbar: %r", abo_id, fehler)
def push_abo_erfolg(self, abo_id):
try:
with self.db.cursor() as c:
c.execute("UPDATE push_abos SET zuletzt = NOW(), fehler = '' WHERE id = %s",
(abo_id,))
except Exception as fehler:
logger.debug("Push-Abo %s nicht fortschreibbar: %r", abo_id, fehler)
def transport_fuer(self, actor_url):
for transport in self.transporte:
if transport.passt(actor_url):
@@ -1311,6 +1415,7 @@ class Runner:
while True:
jetzt = time.monotonic()
self.werte_einsammeln(auch_geraete=nur_einmal)
self.meldungen_abarbeiten()
self.durchlauf(fenster)
self.ergebnisse_verbuchen()
self.protokoll_saeubern()
+29
View File
@@ -85,3 +85,32 @@ dry_run = false
# DEBUG, INFO, WARNING, ERROR
log_level = INFO
# ============================================================================
# BENACHRICHTIGUNGEN - E-Mail
# ============================================================================
# Fuer das Kommando "E-Mail" des Geraets "Benachrichtigungen". Ohne Server
# und Empfaenger meldet die Automatik einen Fehler ins Protokoll, statt still
# nichts zu tun.
#
# port 587 beginnt im Klartext und wechselt mit STARTTLS (der Normalfall)
# port 465 spricht von Anfang an verschluesselt
#
# Mehrere Empfaenger mit Komma trennen. "von" ist der Absender; viele Anbieter
# verlangen, dass er gleich dem Anmeldenamen ist.
[mail]
server =
port = 587
benutzer =
passwort =
von =
an =
# ============================================================================
# BENACHRICHTIGUNGEN - Web Push
# ============================================================================
# Das Schluesselpaar erzeugt einmalig SolarManager/vapid_erzeugen.py. Der
# Pfad muss nur gesetzt werden, wenn die Datei woanders liegt als
# SolarManager/push_vapid.json.
[push]
schluessel =
+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