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 = '
python3 vapid_erzeugen.py im SolarManager "
+ + "ausführen, dann diese Seite neu laden.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 = '+ 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 …
+