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:
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* Web-Push-Abos: anmelden, abmelden, auflisten, Probe schicken.
|
||||
*
|
||||
* GET ?action=stand {schluessel, abos: [...]}
|
||||
* POST ?action=anmelden {"abo": {endpoint, keys:{p256dh, auth}}, "name": "..."}
|
||||
* POST ?action=abmelden {"endpoint": "..."} - dieses Gerät
|
||||
* POST ?action=entfernen {"id": 3} - ein anderes Gerät
|
||||
* POST ?action=probe {"titel": "...", "text": "..."}
|
||||
*
|
||||
* Verschickt wird nirgends hier: Das macht der Runner auf der NAS, der die
|
||||
* Verschlüsselung und den privaten Schlüssel hat. Die Probe geht deshalb als
|
||||
* MQTT-Nachricht an ihn - denselben Weg nimmt auch eine Automatik.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/push.php");
|
||||
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Nicht angemeldet."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function pushAntwort($daten, $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
echo json_encode($daten, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $_GET["action"] ?? "stand";
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$body = json_decode(file_get_contents("php://input"), true);
|
||||
if (!is_array($body)) {
|
||||
pushAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||||
}
|
||||
try {
|
||||
switch ($action) {
|
||||
case "anmelden":
|
||||
pushAboSpeichern($body["abo"] ?? [], $body["name"] ?? "",
|
||||
$_SERVER["HTTP_USER_AGENT"] ?? "");
|
||||
pushAntwort(["ok" => true, "abos" => pushAbos()]);
|
||||
|
||||
case "abmelden":
|
||||
$weg = pushAboEntfernenNachEndpoint(strval($body["endpoint"] ?? ""));
|
||||
pushAntwort(["ok" => true, "entfernt" => $weg, "abos" => pushAbos()]);
|
||||
|
||||
case "entfernen":
|
||||
$weg = pushAboEntfernen($body["id"] ?? 0);
|
||||
pushAntwort(["ok" => true, "entfernt" => $weg, "abos" => pushAbos()]);
|
||||
|
||||
case "probe":
|
||||
$titel = trim(strval($body["titel"] ?? "")) ?: "Probe";
|
||||
$text = trim(strval($body["text"] ?? "")) ?: "Wenn das ankommt, stimmt der Weg.";
|
||||
pushProbe($titel, $text);
|
||||
pushAntwort(["ok" => true]);
|
||||
}
|
||||
pushAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
pushAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
pushAntwort(["error" => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "stand") {
|
||||
pushAntwort([
|
||||
"schluessel" => pushSchluessel(),
|
||||
"abos" => pushAbos(),
|
||||
]);
|
||||
}
|
||||
pushAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Web-Push-Abos fuer die Benachrichtigungen der Automatiken
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Ein Abo ist ein Geraet, das Meldungen bekommen moechte: Browser oder
|
||||
-- Startbildschirm-Symbol auf einem Handy. Der Browser erzeugt es beim ersten
|
||||
-- "Benachrichtigungen erlauben" und liefert drei Teile:
|
||||
--
|
||||
-- endpoint die Adresse beim Push-Dienst des Browserherstellers (Google,
|
||||
-- Mozilla, Apple). Sie ist eindeutig und zugleich das Geheimnis -
|
||||
-- wer sie hat, kann diesem Geraet etwas schicken.
|
||||
-- p256dh oeffentlicher Schluessel des Geraets
|
||||
-- auth Geheimnis fuer die Verschluesselung
|
||||
--
|
||||
-- Verschluesselt wird gegen p256dh und auth, unterschrieben mit dem
|
||||
-- VAPID-Schluessel aus push_vapid.json (SolarManager). Der Push-Dienst sieht
|
||||
-- nur ein Paket, das er nicht lesen kann.
|
||||
--
|
||||
-- Geloescht wird automatisch: meldet der Dienst 404 oder 410, gibt es das
|
||||
-- Geraet nicht mehr (App entfernt, Browserdaten geloescht) - der Runner
|
||||
-- raeumt die Zeile dann selbst weg.
|
||||
--
|
||||
-- Einspielen:
|
||||
-- mysql -h nas.fritz.box -P 3310 -u homeMesh -p homeMesh < homeMesh_push.sql
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `push_abos` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`endpoint` varchar(500) NOT NULL COMMENT 'Adresse beim Push-Dienst; zugleich der Schluessel der Zeile',
|
||||
`p256dh` varchar(255) NOT NULL COMMENT 'oeffentlicher Schluessel des Geraets',
|
||||
`auth` varchar(64) NOT NULL COMMENT 'Geheimnis fuer die Verschluesselung',
|
||||
`name` varchar(80) NOT NULL DEFAULT '' COMMENT 'Klarname, den der Nutzer vergibt ("Handy Moritz")',
|
||||
`browser` varchar(120) NOT NULL DEFAULT '' COMMENT 'erkannt aus dem User-Agent, nur zum Wiedererkennen',
|
||||
`angelegt` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`zuletzt` datetime DEFAULT NULL COMMENT 'wann zuletzt erfolgreich zugestellt',
|
||||
`fehler` varchar(255) NOT NULL DEFAULT '' COMMENT 'letzter Fehler, leer = alles gut',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_endpoint` (`endpoint`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -115,7 +115,8 @@ if (isset($_GET["addUser"])) {
|
||||
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/settings.js", "js/solar/grundriss.js",
|
||||
"js/solar/anlageKatalog.js", "js/solar/energieflussKatalog.js",
|
||||
"js/solar/energiefluss.js", "js/solar/energieflussEinstellungen.js"]],
|
||||
"js/solar/energiefluss.js", "js/solar/energieflussEinstellungen.js",
|
||||
"js/solar/benachrichtigungen.js"]],
|
||||
"logs" => ["template" => "logs.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/logs.js"]],
|
||||
"einfuehrung" => ["template" => "einfuehrung.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Benachrichtigungen auf diesem Gerät ein- und ausschalten.
|
||||
*
|
||||
* Web Push braucht drei Dinge, und alle drei gibt es hier schon: eine
|
||||
* sichere Verbindung, einen Service Worker (sw.js im Wurzelverzeichnis) und
|
||||
* ein Schlüsselpaar (VAPID, liegt auf der NAS). Eine eigene App braucht es
|
||||
* nicht - unter iOS muss die Seite allerdings einmal über "Zum
|
||||
* Home-Bildschirm" abgelegt werden, sonst verweigert Safari die Erlaubnis.
|
||||
*
|
||||
* Das Abo gehört dem Browser, nicht dem Konto: Jedes Gerät meldet sich
|
||||
* einzeln an. Gespeichert wird es in homeMesh.push_abos (ajax/push.php),
|
||||
* verschickt vom Runner auf der NAS.
|
||||
*/
|
||||
|
||||
const PUSH_ENDPUNKT = "ajax/push.php";
|
||||
|
||||
let pushStand = { schluessel: "", abos: [] };
|
||||
|
||||
/** base64url aus der Datenbank in den Bytesalat, den der Browser will. */
|
||||
function pushSchluesselBytes(text) {
|
||||
const voll = (text + "=".repeat((4 - text.length % 4) % 4)).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const roh = atob(voll);
|
||||
return Uint8Array.from(roh, z => z.charCodeAt(0));
|
||||
}
|
||||
|
||||
/** Was der Browser über sich verrät, auf das Nötige gekürzt. */
|
||||
function pushBrowserName() {
|
||||
const ua = navigator.userAgent;
|
||||
const name = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera"
|
||||
: /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari"
|
||||
: /Firefox\//.test(ua) ? "Firefox" : "Browser";
|
||||
const os = /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS"
|
||||
: /Windows/.test(ua) ? "Windows" : /Mac OS/.test(ua) ? "Mac" : "";
|
||||
return os ? name + " · " + os : name;
|
||||
}
|
||||
|
||||
function pushMoeglich() {
|
||||
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
async function pushAnfrage(action, body) {
|
||||
const antwort = await fetch(PUSH_ENDPUNKT + "?action=" + action, body ? {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
|
||||
body: JSON.stringify(body),
|
||||
} : { cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Das Abo dieses Browsers, oder null. */
|
||||
async function pushAbo() {
|
||||
if (!pushMoeglich()) return null;
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return reg ? await reg.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf diesem Gerät anmelden.
|
||||
*
|
||||
* Die Reihenfolge ist wichtig: erst den Service Worker, dann die Erlaubnis,
|
||||
* dann das Abo. Fragt man zuerst nach der Erlaubnis und der Service Worker
|
||||
* scheitert danach, hat der Nutzer eine Erlaubnis vergeben, die zu nichts
|
||||
* führt - und der Browser fragt kein zweites Mal.
|
||||
*/
|
||||
async function pushAnmelden(name) {
|
||||
if (!pushMoeglich()) {
|
||||
throw new Error("Dieser Browser kann keine Benachrichtigungen empfangen.");
|
||||
}
|
||||
if (!pushStand.schluessel) {
|
||||
throw new Error("Auf der NAS fehlt der Schlüssel (push_vapid.json).");
|
||||
}
|
||||
const reg = await navigator.serviceWorker.register("sw.js");
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const erlaubnis = await Notification.requestPermission();
|
||||
if (erlaubnis !== "granted") {
|
||||
throw new Error(erlaubnis === "denied"
|
||||
? "Benachrichtigungen sind für diese Seite blockiert – im Browser unter „Website-Einstellungen“ wieder erlauben."
|
||||
: "Ohne Erlaubnis geht es nicht.");
|
||||
}
|
||||
|
||||
const abo = await reg.pushManager.getSubscription()
|
||||
|| await reg.pushManager.subscribe({
|
||||
// Ohne diese Zusage weigern sich die Browser: Jede Nachricht muss den
|
||||
// Nutzer erreichen, stille Datenübertragung ist nicht erlaubt.
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: pushSchluesselBytes(pushStand.schluessel),
|
||||
});
|
||||
|
||||
const d = await pushAnfrage("anmelden", {
|
||||
abo: abo.toJSON(),
|
||||
name: name || pushBrowserName(),
|
||||
});
|
||||
pushStand.abos = d.abos;
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Auf diesem Gerät abmelden - Abo im Browser und Zeile in der Datenbank. */
|
||||
async function pushAbmelden() {
|
||||
const abo = await pushAbo();
|
||||
if (!abo) return;
|
||||
const endpoint = abo.endpoint;
|
||||
await abo.unsubscribe();
|
||||
const d = await pushAnfrage("abmelden", { endpoint: endpoint });
|
||||
pushStand.abos = d.abos;
|
||||
}
|
||||
|
||||
/** Ein fremdes Gerät aus der Liste werfen (Handy verkauft, Browser weg). */
|
||||
async function pushEntfernen(id) {
|
||||
const d = await pushAnfrage("entfernen", { id: id });
|
||||
pushStand.abos = d.abos;
|
||||
}
|
||||
|
||||
/** Probemeldung über den Runner an alle angemeldeten Geräte. */
|
||||
async function pushProbe() {
|
||||
await pushAnfrage("probe", { titel: "Probe", text: "Wenn das ankommt, stimmt der Weg." });
|
||||
}
|
||||
|
||||
async function pushStandLaden() {
|
||||
pushStand = await pushAnfrage("stand");
|
||||
return pushStand;
|
||||
}
|
||||
@@ -1432,7 +1432,139 @@ function skodaBinden() {
|
||||
if (neuLaden) neuLaden.addEventListener("click", skodaHolen);
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
Reiter "Meldungen": Web-Push-Abos dieses und der anderen Geräte.
|
||||
|
||||
Das Abo gehört dem Browser. Deshalb steht hier zweierlei nebeneinander:
|
||||
dieses Gerät (an- und abmelden) und die Liste aller angemeldeten Geräte
|
||||
(entfernen, wenn eines weg ist). Verschickt wird nichts von hier - die
|
||||
Probe geht über MQTT an den Runner, genau wie eine Automatik.
|
||||
========================================================================== */
|
||||
|
||||
let pushDiesesGeraet = null; // Abo dieses Browsers, oder null
|
||||
|
||||
async function pushZeichnen() {
|
||||
const ziel = document.getElementById("pushListe");
|
||||
if (!ziel) return;
|
||||
|
||||
if (!pushMoeglich()) {
|
||||
ziel.innerHTML = '<div class="alert alert-secondary mb-0">Dieser Browser kann keine '
|
||||
+ "Benachrichtigungen empfangen. Auf dem iPhone geht es erst, wenn die Seite über "
|
||||
+ "„Teilen ▸ Zum Home-Bildschirm“ abgelegt und von dort geöffnet wird.</div>";
|
||||
return;
|
||||
}
|
||||
if (!pushStand.schluessel) {
|
||||
ziel.innerHTML = '<div class="alert alert-warning mb-0">Auf der NAS fehlt das '
|
||||
+ "Schlüsselpaar. Einmalig <code>python3 vapid_erzeugen.py</code> im SolarManager "
|
||||
+ "ausführen, dann diese Seite neu laden.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
const an = !!pushDiesesGeraet;
|
||||
const blockiert = Notification.permission === "denied";
|
||||
const zeilen = pushStand.abos.map(a => {
|
||||
const stand = a.fehler
|
||||
? '<span class="text-danger">' + textSchuetzen(a.fehler) + "</span>"
|
||||
: a.zuletzt ? "zuletzt zugestellt " + textSchuetzen(a.zuletzt)
|
||||
: "noch nichts zugestellt";
|
||||
return '<div class="push-abo border-top py-2 d-flex align-items-center gap-2 flex-wrap">'
|
||||
+ '<i class="bi bi-phone text-body-secondary"></i>'
|
||||
+ "<b>" + textSchuetzen(a.name || "Gerät") + "</b>"
|
||||
+ '<span class="small text-body-secondary">' + textSchuetzen(a.browser) + "</span>"
|
||||
+ '<span class="small text-body-secondary ms-auto">' + stand + "</span>"
|
||||
+ '<button type="button" class="btn btn-outline-danger btn-sm push-weg" data-id="' + a.id
|
||||
+ '" title="Dieses Gerät entfernen"><i class="bi bi-trash3"></i></button></div>';
|
||||
}).join("");
|
||||
|
||||
ziel.innerHTML =
|
||||
'<div class="d-flex flex-wrap align-items-center gap-2 mb-3">'
|
||||
+ '<button type="button" class="btn ' + (an ? "btn-outline-secondary" : "btn-primary")
|
||||
+ '" id="pushSchalter"' + (blockiert && !an ? " disabled" : "") + ">"
|
||||
+ '<i class="bi ' + (an ? "bi-bell-slash" : "bi-bell") + ' me-1"></i>'
|
||||
+ (an ? "Auf diesem Gerät abmelden" : "Auf diesem Gerät anmelden") + "</button>"
|
||||
+ '<button type="button" class="btn btn-outline-primary" id="pushProbe"'
|
||||
+ (pushStand.abos.length ? "" : " disabled") + '><i class="bi bi-send me-1"></i>Probe schicken</button>'
|
||||
+ '<span class="small" id="pushMeldung"></span>'
|
||||
+ "</div>"
|
||||
+ (blockiert && !an
|
||||
? '<div class="alert alert-warning py-2 small">Der Browser hat Benachrichtigungen für '
|
||||
+ "diese Seite blockiert. Das lässt sich nur in seinen eigenen Website-Einstellungen "
|
||||
+ "wieder erlauben.</div>"
|
||||
: "")
|
||||
+ (zeilen
|
||||
? '<div class="push-abos">' + zeilen + "</div>"
|
||||
: '<p class="text-secondary small mb-0">Noch kein Gerät angemeldet.</p>');
|
||||
|
||||
document.getElementById("pushSchalter").addEventListener("click", pushSchalten);
|
||||
document.getElementById("pushProbe").addEventListener("click", pushProbeSchicken);
|
||||
ziel.querySelectorAll(".push-weg").forEach(k => k.addEventListener("click", async () => {
|
||||
pushMeldung("wird entfernt …");
|
||||
try {
|
||||
await pushEntfernen(Number(k.dataset.id));
|
||||
pushDiesesGeraet = await pushAbo();
|
||||
await pushZeichnen();
|
||||
} catch (fehler) {
|
||||
pushMeldung(fehler.message, "text-danger");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function pushMeldung(text, klasse) {
|
||||
const el = document.getElementById("pushMeldung");
|
||||
if (!el) return;
|
||||
el.className = "small " + (klasse || "text-body-secondary");
|
||||
el.textContent = text || "";
|
||||
}
|
||||
|
||||
async function pushSchalten() {
|
||||
const an = !!pushDiesesGeraet;
|
||||
pushMeldung(an ? "wird abgemeldet …" : "wird angemeldet …");
|
||||
try {
|
||||
if (an) await pushAbmelden();
|
||||
else await pushAnmelden();
|
||||
pushDiesesGeraet = await pushAbo();
|
||||
await pushZeichnen();
|
||||
pushMeldung(an ? "Abgemeldet." : "Angemeldet – die Probe kommt jetzt hier an.",
|
||||
an ? "text-body-secondary" : "text-success");
|
||||
} catch (fehler) {
|
||||
pushMeldung(fehler.message, "text-danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function pushProbeSchicken() {
|
||||
pushMeldung("Probe unterwegs …");
|
||||
try {
|
||||
await pushProbe();
|
||||
pushMeldung("Probe geschickt – sie geht über den Runner auf der NAS.", "text-success");
|
||||
} catch (fehler) {
|
||||
pushMeldung(fehler.message, "text-danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function pushLaden() {
|
||||
try {
|
||||
await pushStandLaden();
|
||||
pushDiesesGeraet = await pushAbo();
|
||||
} catch (fehler) {
|
||||
const ziel = document.getElementById("pushListe");
|
||||
if (ziel) ziel.innerHTML = '<div class="alert alert-danger mb-0">' + textSchuetzen(fehler.message) + "</div>";
|
||||
return;
|
||||
}
|
||||
await pushZeichnen();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
skodaBinden();
|
||||
skodaHolen();
|
||||
});
|
||||
|
||||
function pushBinden() {
|
||||
const neu = document.getElementById("pushNeuLaden");
|
||||
if (neu) neu.addEventListener("click", pushLaden);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
pushBinden();
|
||||
pushLaden();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Service Worker - nur für Benachrichtigungen.
|
||||
*
|
||||
* Er liegt bewusst im Wurzelverzeichnis der Anwendung: Ein Service Worker
|
||||
* gilt nur für Adressen unterhalb seines eigenen Ortes, und die Abos sollen
|
||||
* für die ganze Seite gelten, nicht nur für /js/.
|
||||
*
|
||||
* Was er NICHT tut: Seiten zwischenspeichern. Das Dashboard lebt von
|
||||
* aktuellen Werten, und ein Cache, der eine alte Fassung ausliefert, wäre
|
||||
* hier kein Vorteil, sondern eine Fehlerquelle. Deshalb gibt es kein
|
||||
* fetch-Ereignis - der Browser holt alles wie ohne Service Worker.
|
||||
*
|
||||
* Geschickt wird vom Runner (BenachrichtigungTransport in
|
||||
* SolarManager/autoActions/transports.py), verschlüsselt und mit dem
|
||||
* VAPID-Schlüssel unterschrieben.
|
||||
*/
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener("push", (ereignis) => {
|
||||
let daten = { titel: "Smart controller", text: "", url: "index.php" };
|
||||
try {
|
||||
if (ereignis.data) Object.assign(daten, ereignis.data.json());
|
||||
} catch (fehler) {
|
||||
// Kein JSON: dann ist die Nutzlast der Text.
|
||||
if (ereignis.data) daten.text = ereignis.data.text();
|
||||
}
|
||||
ereignis.waitUntil(self.registration.showNotification(daten.titel, {
|
||||
body: daten.text,
|
||||
icon: "assets/img/favicon.png",
|
||||
badge: "assets/img/favicon.png",
|
||||
// Gleiche Kennung ersetzt die vorherige Meldung, statt sie zu stapeln -
|
||||
// eine Automatik, die dreimal meldet, soll nicht dreimal im Schirm stehen.
|
||||
tag: daten.kennung || undefined,
|
||||
timestamp: Date.now(),
|
||||
data: { url: daten.url },
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (ereignis) => {
|
||||
ereignis.notification.close();
|
||||
const ziel = new URL((ereignis.notification.data && ereignis.notification.data.url) || "index.php",
|
||||
self.registration.scope).href;
|
||||
ereignis.waitUntil(self.clients.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((fenster) => {
|
||||
// Ist die Seite schon offen, wird sie nach vorn geholt statt ein
|
||||
// zweites Mal geöffnet.
|
||||
for (const f of fenster) {
|
||||
if (f.url.startsWith(self.registration.scope) && "focus" in f) return f.focus();
|
||||
}
|
||||
return self.clients.openWindow(ziel);
|
||||
}));
|
||||
});
|
||||
Reference in New Issue
Block a user