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:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user