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,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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user