Benachrichtigungen: Geraet, Abos und der Reiter Meldungen

Der Geraetesuchlauf legt "Benachrichtigungen" an (benachrichtigung_module.py),
ein Kommando je Kanal. Damit kann jede Automatik melden, ohne dass Editor,
Zeitleiste oder Datenbank etwas davon wissen muessten.

Fuer die Meldung aufs Handy braucht es keine App: Der Browser meldet sich
unter Einstellungen -> Meldungen selbst an (sw.js, ajax/push.php), das Abo
steht in homeMesh.push_abos, verschickt wird vom Runner. Auf dem iPhone geht
es erst ab der Ablage auf dem Home-Bildschirm - der Reiter sagt das auch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 08:37:31 +02:00
co-authored by Claude Opus 5
parent 061dfa3fc5
commit df48432133
10 changed files with 720 additions and 1 deletions
@@ -23,6 +23,7 @@ from modules.gartenwasser_module import GartenwasserModule
from modules.solarmanager_module import SolarManagerModule
from modules.wetterstation_module import WetterstationModule
from modules.logic_module import LogicModule #nicht Vergessen neue Module auch unten in die Liste "modules" einzufügen!!
from modules.benachrichtigung_module import BenachrichtigungModule
# Logging
logging.basicConfig(
@@ -153,6 +154,15 @@ class Config:
return False
return self._get_bool('logic', 'enable', False)
# BENACHRICHTIGUNGEN
@property
def benachrichtigung_enable(self) -> bool:
# Ohne eigenen Abschnitt trotzdem an: das Geraet haengt an keinem
# Netz, kostet keinen Suchlauf und schadet nicht.
if not self.config.has_section('benachrichtigung'):
return True
return self._get_bool('benachrichtigung', 'enable', True)
# GARTENWASSER
@property
def gartenwasser_enable(self) -> bool:
@@ -610,6 +620,7 @@ def main():
MQTTModule(config),
ShellyModule(config),
LogicModule(config),
BenachrichtigungModule(config),
GartenwasserModule(config),
SolarManagerModule(config),
WetterstationModule(config)
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
Benachrichtigungen als Geraet.
Ein gerechnetes Geraet wie "Zeitpunkt" (logic_module.py): Es haengt an keinem
Netzwerkanschluss, sondern buendelt Kommandos, die der Runner selbst
ausfuehrt. Damit kann jede Automatik melden, ohne dass Editor, Datenbank oder
Zeitleiste etwas davon wissen muessten - fuer sie ist es ein Geraet mit
Kommandos wie jedes andere.
Zwei Kanaele, beide ohne fremde App auf dem Handy:
Nachricht Web Push an alle angemeldeten Geraete. Der Browser meldet sich
unter Einstellungen -> Meldungen selbst an; verschickt wird
verschluesselt ueber den Push-Dienst des Browserherstellers.
E-Mail ueber den SMTP-Zugang aus der config.ini des Runners.
Die Kommandos tragen keine Empfaengerliste: Wer eine Meldung bekommt, steht
in der Datenbank (push_abos) bzw. in der Konfiguration. Ein Empfaengerfeld je
Automatik waere eine zweite Stelle, an der dieselbe Liste gepflegt werden
muesste - und sie liefe auseinander.
Der Text darf Platzhalter enthalten, die der Runner ersetzt:
{automatik} Name der Automatik, die gerade ausgeloest hat
{zeit} Uhrzeit der Ausloesung, "HH:MM"
{datum} Datum der Ausloesung, "TT.MM.JJJJ"
"""
import logging
from typing import List, Dict, Tuple
from modules.base_module import BaseModule
logger = logging.getLogger(__name__)
class BenachrichtigungModule(BaseModule):
"""Legt das Geraet "Benachrichtigungen" mit seinen Kanaelen an."""
def is_enabled(self) -> bool:
# Wie beim Logic-Modul: ohne eigenen Abschnitt in der config.ini
# trotzdem an - das Geraet kostet nichts und schadet nicht.
return getattr(self.config, "benachrichtigung_enable", True)
def discover(self) -> Tuple[List[Dict], List[Dict]]:
logger.info("\n" + "=" * 60)
logger.info("BENACHRICHTIGUNGEN WERDEN ERZEUGT")
logger.info("=" * 60)
kommandos = [
{
"command": "Nachricht aufs Handy",
"url": "push",
"parameters": [
{"name": "Titel", "type": "string", "url": "titel"},
{"name": "Text", "type": "string", "url": "text"},
],
},
{
"command": "E-Mail",
"url": "mail",
"parameters": [
{"name": "Betreff", "type": "string", "url": "betreff"},
{"name": "Text", "type": "string", "url": "text"},
],
},
]
aktoren = [{
"type": "Benachrichtigung",
"name": "Benachrichtigungen",
"url": "Benachrichtigung",
"commands": kommandos,
"states": [],
}]
return aktoren, []
+172
View File
@@ -0,0 +1,172 @@
<?php
/**
* Modell-Schicht für die Web-Push-Abos.
*
* Ein Abo ist ein Gerät, das Meldungen bekommen möchte. Erzeugt wird es im
* Browser (js/solar/benachrichtigungen.js), gespeichert hier, benutzt vom
* Runner (BenachrichtigungTransport in SolarManager/autoActions/transports.py).
*
* Die Schlüssel liegen in push_vapid.json neben skoda.conf - außerhalb des
* Web-Roots, weil der private Teil dort steht. Von hier wird nur der
* öffentliche gelesen; er gehört ohnehin in jeden Browser.
*
* Tabelle: homeMesh.push_abos, siehe homeMesh_push.sql.
*/
require_once(__DIR__ . "/meshdb.php");
/** Wo das Schlüsselpaar liegt. Erzeugt von SolarManager/vapid_erzeugen.py. */
defined("PUSH_SCHLUESSEL") || define("PUSH_SCHLUESSEL",
"/volume1/homes/wagner/SolarManager/push_vapid.json");
/**
* Der öffentliche VAPID-Schlüssel, oder "" wenn es noch keinen gibt.
*
* Ohne ihn kann der Browser kein Abo anlegen; die Maske sagt dann, was zu
* tun ist, statt einen leeren Knopf anzubieten.
*/
function pushSchluessel()
{
static $wert = null;
if ($wert !== null) {
return $wert;
}
$wert = "";
if (is_readable(PUSH_SCHLUESSEL)) {
$daten = json_decode(file_get_contents(PUSH_SCHLUESSEL), true);
if (is_array($daten) && !empty($daten["public_key"])) {
$wert = strval($daten["public_key"]);
}
}
return $wert;
}
/** Gibt es die Tabelle? Eine Installation ohne homeMesh_push.sql soll nicht scheitern. */
function pushTabelleDa()
{
static $da = null;
if ($da === null) {
$res = meshDb()->query("SHOW TABLES LIKE 'push_abos'");
$da = $res && $res->num_rows > 0;
}
return $da;
}
/**
* Alle Abos - ohne die Geheimnisse.
*
* Endpoint und Schlüssel bleiben auf dem Server: Wer den Endpoint hat, kann
* dem Gerät schreiben. Die Maske braucht nur Name, Browser und Stand.
*/
function pushAbos()
{
if (!pushTabelleDa()) {
return [];
}
$liste = [];
$res = meshDb()->query("SELECT id, name, browser, angelegt, zuletzt, fehler
FROM push_abos ORDER BY angelegt");
while ($z = $res->fetch_assoc()) {
$liste[] = [
"id" => intval($z["id"]),
"name" => $z["name"],
"browser" => $z["browser"],
"angelegt" => $z["angelegt"],
"zuletzt" => $z["zuletzt"],
"fehler" => $z["fehler"],
];
}
return $liste;
}
/**
* Ein Abo anlegen oder auffrischen.
*
* Der Endpoint ist der Schlüssel: Derselbe Browser meldet sich nach einem
* Neustart mit demselben Endpoint, manchmal aber mit neuen Schlüsseln -
* dann werden sie überschrieben, statt eine zweite Zeile anzulegen. Ein
* erneutes Anmelden setzt außerdem einen alten Fehler zurück.
*/
function pushAboSpeichern($abo, $name, $browser)
{
if (!pushTabelleDa()) {
throw new RuntimeException("Die Tabelle push_abos fehlt - homeMesh_push.sql einspielen.");
}
$endpoint = trim(strval($abo["endpoint"] ?? ""));
$p256dh = trim(strval($abo["keys"]["p256dh"] ?? ""));
$auth = trim(strval($abo["keys"]["auth"] ?? ""));
if ($endpoint === "" || $p256dh === "" || $auth === "") {
throw new InvalidArgumentException("Das Abo ist unvollständig.");
}
if (!preg_match('#^https://#', $endpoint) || strlen($endpoint) > 500) {
throw new InvalidArgumentException("Diese Adresse taugt nicht als Push-Ziel.");
}
$name = mb_substr(trim(strval($name)), 0, 80);
$browser = mb_substr(trim(strval($browser)), 0, 120);
$db = meshDb();
$stmt = $db->prepare("INSERT INTO push_abos (endpoint, p256dh, auth, name, browser)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE p256dh = VALUES(p256dh), auth = VALUES(auth),
name = VALUES(name), browser = VALUES(browser), fehler = ''");
$stmt->bind_param("sssss", $endpoint, $p256dh, $auth, $name, $browser);
$stmt->execute();
$stmt->close();
return true;
}
/** Ein Abo nach Endpoint entfernen - das tut der Browser beim Abmelden. */
function pushAboEntfernenNachEndpoint($endpoint)
{
if (!pushTabelleDa()) {
return 0;
}
$db = meshDb();
$stmt = $db->prepare("DELETE FROM push_abos WHERE endpoint = ?");
$stmt->bind_param("s", $endpoint);
$stmt->execute();
$weg = $stmt->affected_rows;
$stmt->close();
return $weg;
}
/** Ein Abo nach Nummer entfernen - das tut die Maske für ein fremdes Gerät. */
function pushAboEntfernen($id)
{
if (!pushTabelleDa()) {
return 0;
}
$db = meshDb();
$stmt = $db->prepare("DELETE FROM push_abos WHERE id = ?");
$id = intval($id);
$stmt->bind_param("i", $id);
$stmt->execute();
$weg = $stmt->affected_rows;
$stmt->close();
return $weg;
}
/**
* Eine Probemeldung an alle Abos schicken.
*
* Verschickt wird nicht hier, sondern vom Runner: Er hat die Pakete
* (pywebpush) und den privaten Schlüssel, und er ist ohnehin der, der im
* Betrieb meldet. Die Weboberfläche legt den Auftrag nur ins MQTT - dasselbe
* Topic, das auch eine Automatik benutzen würde.
*/
function pushProbe($titel, $text)
{
// MQTT_BROKER und MQTT_PORT stehen in commands.php - dort, wo auch die
// Kommandos der Geraete ihren Weg zum Broker finden.
require_once(__DIR__ . "/commands.php");
require_once(__DIR__ . "/../ajax/phpMQTT.php");
$mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "PushProbe_" . getmypid());
if (!$mqtt->connect(true, null, "", "")) {
throw new RuntimeException("Keine Verbindung zum MQTT-Broker.");
}
$mqtt->publish("benachrichtigung/push",
json_encode(["titel" => $titel, "text" => $text], JSON_UNESCAPED_UNICODE),
0, false);
$mqtt->close();
return true;
}
+34
View File
@@ -37,6 +37,7 @@ $reiter = [
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
["id" => "meldungen", "titel" => "Meldungen", "symbol" => "bi-bell"],
];
// "merken": der offene Reiter steht in der Adresse und uebersteht ein
// Neuladen - siehe reiterMerken() in js/solar/common.js.
@@ -319,6 +320,39 @@ try {
</div>
</div>
<?= reiterFlaecheZu() ?><!-- /Fahrzeug -->
<!-- Meldungen. Web Push braucht kein fremdes Konto und keine App: Der
Browser meldet sich selbst an, die NAS schickt verschlüsselt an den
Push-Dienst seines Herstellers. Jedes Gerät meldet sich einzeln an -
ein Abo gehört dem Browser, nicht dem Benutzer. -->
<?= reiterFlaecheAuf("meldungen", $reiterOpt) ?>
<div class="row">
<div class="col-12 col-xxl-8">
<div class="card mb-4" id="pushKarte">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi bi-bell me-2"></i>Benachrichtigungen</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" id="pushNeuLaden" title="Neu laden">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
Automatiken können Meldungen schicken als Nachricht auf dieses Gerät
oder als E-Mail. Für die Meldung auf dem Gerät braucht es keine App:
Der Browser meldet sich hier an und zeigt die Meldung auch, wenn die
Seite zu ist. Auf dem iPhone muss die Seite dafür einmal über
„Teilen ▸ Zum Home-Bildschirm“ abgelegt und von dort geöffnet werden.
</p>
<div id="pushListe">
<p class="text-secondary small mb-0">Wird geladen …</p>
</div>
</div>
</div>
</div>
</div>
<?= reiterFlaecheZu() ?><!-- /Meldungen -->
</div><!-- /tab-content -->
</div>