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()