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:
@@ -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, []
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user