Files
adminandClaude Opus 5 df48432133 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>
2026-09-21 08:37:31 +02:00

173 lines
5.8 KiB
PHP

<?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;
}