diff --git a/ajax/push.php b/ajax/push.php new file mode 100644 index 0000000..46489a9 --- /dev/null +++ b/ajax/push.php @@ -0,0 +1,76 @@ + "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); diff --git a/homeMesh_push.sql b/homeMesh_push.sql new file mode 100644 index 0000000..2466719 --- /dev/null +++ b/homeMesh_push.sql @@ -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; diff --git a/index.php b/index.php index 40bbe30..bb9941b 100644 --- a/index.php +++ b/index.php @@ -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, diff --git a/js/solar/benachrichtigungen.js b/js/solar/benachrichtigungen.js new file mode 100644 index 0000000..f891c09 --- /dev/null +++ b/js/solar/benachrichtigungen.js @@ -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; +} diff --git a/js/solar/settings.js b/js/solar/settings.js index aaf321d..f15d0be 100644 --- a/js/solar/settings.js +++ b/js/solar/settings.js @@ -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 = '
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.
"; + return; + } + if (!pushStand.schluessel) { + ziel.innerHTML = '
Auf der NAS fehlt das ' + + "Schlüsselpaar. Einmalig python3 vapid_erzeugen.py im SolarManager " + + "ausführen, dann diese Seite neu laden.
"; + return; + } + + const an = !!pushDiesesGeraet; + const blockiert = Notification.permission === "denied"; + const zeilen = pushStand.abos.map(a => { + const stand = a.fehler + ? '' + textSchuetzen(a.fehler) + "" + : a.zuletzt ? "zuletzt zugestellt " + textSchuetzen(a.zuletzt) + : "noch nichts zugestellt"; + return '
' + + '' + + "" + textSchuetzen(a.name || "Gerät") + "" + + '' + textSchuetzen(a.browser) + "" + + '' + stand + "" + + '
'; + }).join(""); + + ziel.innerHTML = + '
' + + '" + + '' + + '' + + "
" + + (blockiert && !an + ? '
Der Browser hat Benachrichtigungen für ' + + "diese Seite blockiert. Das lässt sich nur in seinen eigenen Website-Einstellungen " + + "wieder erlauben.
" + : "") + + (zeilen + ? '
' + zeilen + "
" + : '

Noch kein Gerät angemeldet.

'); + + 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 = '
' + textSchuetzen(fehler.message) + "
"; + 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(); +}); diff --git a/restricted/deviceDiscovery/device_discovery.py b/restricted/deviceDiscovery/device_discovery.py index 3993744..c22e106 100644 --- a/restricted/deviceDiscovery/device_discovery.py +++ b/restricted/deviceDiscovery/device_discovery.py @@ -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) diff --git a/restricted/deviceDiscovery/modules/benachrichtigung_module.py b/restricted/deviceDiscovery/modules/benachrichtigung_module.py new file mode 100644 index 0000000..a0cb7ef --- /dev/null +++ b/restricted/deviceDiscovery/modules/benachrichtigung_module.py @@ -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, [] diff --git a/restricted/push.php b/restricted/push.php new file mode 100644 index 0000000..344f060 --- /dev/null +++ b/restricted/push.php @@ -0,0 +1,172 @@ +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; +} diff --git a/restricted/settings.php b/restricted/settings.php index 9911346..1102c63 100644 --- a/restricted/settings.php +++ b/restricted/settings.php @@ -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 { + + + +
+
+
+
+

Benachrichtigungen

+
+ +
+
+
+

+ 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. +

+
+

Wird geladen …

+
+
+
+
+
+ diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..0c4f045 --- /dev/null +++ b/sw.js @@ -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); + })); +});