From 98a1da6cd5ee70832ea71c925c0257a6f4e89b4a Mon Sep 17 00:00:00 2001 From: Moirtz Wagner Date: Mon, 31 Aug 2026 16:43:21 +0200 Subject: [PATCH] Raum-Modal: Heizung, Rollladen und Licht auf einer Seite Der Grundriss zeigt Raeume, ein Klick darauf zeigte aber nur den Temperaturregler. Die Rollladen im selben Zimmer waren von dort gar nicht erreichbar, obwohl die Karte genau sagt, wo sie haengen. Das Modal zeigt jetzt alles, was dem Raum zugeordnet ist: die Solltemperatur wie bisher, dazu je Geraet seine Kommandos. Kommandos ohne Parameter werden Knoepfe nebeneinander - "Auf", "Zu", "Stop" will man antippen und nicht erst einstellen -, Kommandos mit Parametern bekommen ihre Eingabefelder und einen Ausloeser. Geraete ohne Kommandos zeigen ihre Messwerte. Die Reihenfolge der Knoepfe kommt nicht mehr aus dem Geraete-Suchlauf: die haeufig gebrauchten stehen vorn. Ausgefuehrt wird ueber restricted/commands.php - das Gegenstueck zu transports.py, nur fuer den Browser. Dieselbe Zuordnung "welche URL gehoert zu welchem Weg", nur eben in PHP: mqtt:// Nutzlast auf das Topic des Parameters wled:// JSON-Vorlage mit Platzhaltern an /json/state http:// Abfrageargumente an die Geraete-URL Tahoma exec/apply, erkannt an der Box-Kennung in der URL Zwei Umsetzungen derselben Sache sind nicht schoen. Die Alternative waere, den Runner um eine HTTP-Schnittstelle zu erweitern - dann haengt die Bedienung daran, dass er laeuft - oder das Web-UI Python aufrufen zu lassen. Beides waere teurer als diese Datei; der Kommentar sagt das an beiden Stellen. Der Endpunkt kennt einen Probelauf: mit "probe" beschreibt er, was er schicken wuerde, ohne es zu schicken. Damit sind alle vier Wege geprueft, ohne einen Rollladen in Bewegung zu setzen - Tahoma mit und ohne Parameter, WLED mit gefuellter Vorlage, Shelly Gen1 und MQTT. ajax/roomtemp.php entfaellt, es ging darin ausschliesslich um den Regler, den das Raum-Modal jetzt enthaelt. Co-Authored-By: Claude Opus 5 --- ajax/room.php | 191 ++++++++++++++++++++++++++++++ ajax/roomtemp.php | 48 -------- css/solar.css | 9 ++ js/solar/homeMQTT.js | 122 ++++++++++++++----- restricted/commands.php | 251 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 543 insertions(+), 78 deletions(-) create mode 100644 ajax/room.php delete mode 100644 ajax/roomtemp.php create mode 100644 restricted/commands.php diff --git a/ajax/room.php b/ajax/room.php new file mode 100644 index 0000000..1fbb0ca --- /dev/null +++ b/ajax/room.php @@ -0,0 +1,191 @@ + "Konnte die Anfrage nicht lesen."], 400); + } + try { + if (($_GET["action"] ?? "") === "command") { + $text = executeCommand(intval($body["command_id"] ?? 0), + is_array($body["params"] ?? null) ? $body["params"] : [], + !empty($body["probe"])); + raumAntwort(["ok" => $text]); + } + if (($_GET["action"] ?? "") === "temp") { + // Bewusst derselbe Weg wie im alten Thermostat-Modal: das Topic + // Raumtemp///changeSetTemp. + $raum = preg_replace('/[^A-Za-z0-9_]/', '', strval($body["room"] ?? "")); + $temp = intval($body["temp"] ?? 0); + if ($temp < 10 || $temp > 30) { + raumAntwort(["error" => "Temperatur außerhalb 10–30 °C."], 400); + } + $mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomTempSetter_" . getmypid()); + if (!$mqtt->connect(true, null, "", "")) { + raumAntwort(["error" => "Keine Verbindung zum MQTT-Broker."], 500); + } + $mqtt->publish("Raumtemp/" . str_replace("_", "/", $raum) . "/changeSetTemp", + strval($temp), 0, false); + $mqtt->close(); + raumAntwort(["ok" => "Solltemperatur " . $temp . " °C"]); + } + raumAntwort(["error" => "Unbekannte Aktion."], 400); + } catch (InvalidArgumentException $e) { + raumAntwort(["error" => $e->getMessage()], 400); + } catch (Throwable $e) { + raumAntwort(["error" => $e->getMessage()], 500); + } +} + +// --- Inhalt des Modals ---------------------------------------------------- +$schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? "")); +$teile = explode("_", $schluessel, 2); +$etage = $teile[0] ?? ""; +$name = $teile[1] ?? ""; +$raumKey = $etage . "/" . $name; + +// Hat der Raum ein Thermostat? Nur dann gibt es etwas zu heizen. +$hatThermostat = false; +foreach (allRooms() as $raum) { + if ($raum["floor"] === $etage && $raum["mqtt"] === $name) { + $hatThermostat = isset($raum["x"]); // Kachel = Thermostat vorhanden + } +} + +$katalog = deviceCatalog(); +$geraete = array_values(array_filter($katalog, function ($d) use ($raumKey) { + return $d["room"] === $raumKey && count($d["commands"]) > 0; +})); +$messwerte = array_values(array_filter($katalog, function ($d) use ($raumKey) { + return $d["room"] === $raumKey && count($d["states"]) > 0 && count($d["commands"]) === 0; +})); + +echo "
"; + +if ($hatThermostat) { + echo "
"; + echo ""; + echo ""; + echo "
" + . "10 °C30 °C
"; + echo "
"; +} + +if (!$geraete && !$messwerte && !$hatThermostat) { + echo "

Diesem Raum ist noch nichts zugeordnet. " + . "Das geht über das Haus-Symbol in der Karte „Automatismen“.

"; +} + +foreach ($geraete as $d) { + echo "
"; + echo "
" . htmlspecialchars($d["label"]) + . " " . htmlspecialchars($d["type"]) . "
"; + + // Kommandos ohne Parameter werden Knoepfe nebeneinander - "Auf", "Zu", + // "Stop" will man antippen und nicht erst einstellen. Die Reihenfolge in + // der Datenbank ist die des Geraete-Suchlaufs und damit Zufall; die + // haeufig gebrauchten kommen deshalb nach vorne. + $knoepfe = array_values(array_filter($d["commands"], function ($c) { return !count($c["params"]); })); + $vorne = ["Auf", "Zu", "Stop", "An", "Aus", "turn_on", "turn_off", "toggle"]; + usort($knoepfe, function ($a, $b) use ($vorne) { + $ia = array_search($a["name"], $vorne, true); + $ib = array_search($b["name"], $vorne, true); + $ia = $ia === false ? 99 : $ia; + $ib = $ib === false ? 99 : $ib; + return $ia === $ib ? strcoll($a["name"], $b["name"]) : $ia - $ib; + }); + if ($knoepfe) { + echo "
"; + foreach ($knoepfe as $c) { + echo ""; + } + echo "
"; + } + + foreach ($d["commands"] as $c) { + if (!count($c["params"])) { + continue; + } + echo "
"; + echo "" . htmlspecialchars($c["name"]) . ""; + foreach ($c["params"] as $p) { + if (count($c["params"]) > 1) { + echo "" . htmlspecialchars($p["name"]) . ""; + } + if ($p["options"]) { + echo ""; + } else { + echo ""; + } + } + echo ""; + echo "
"; + } + echo "
"; +} + +// Geraete ohne Kommandos: nur anzeigen, was sie melden. +foreach ($messwerte as $d) { + echo "
"; + echo "
" . htmlspecialchars($d["label"]) + . " " . htmlspecialchars($d["type"]) . "
"; + echo "
"; + $teile = []; + foreach (array_slice($d["states"], 0, 8) as $s) { + $teile[] = htmlspecialchars($s["name"]) . ": " . htmlspecialchars(strval($s["value"])) + . ($s["unit"] ? " " . htmlspecialchars($s["unit"]) : ""); + } + echo implode(" · ", $teile); + if (count($d["states"]) > 8) { + echo " · … " . (count($d["states"]) - 8) . " weitere"; + } + echo "
"; +} + +echo "
"; +echo "
"; diff --git a/ajax/roomtemp.php b/ajax/roomtemp.php deleted file mode 100644 index 4eb29e6..0000000 --- a/ajax/roomtemp.php +++ /dev/null @@ -1,48 +0,0 @@ - 9 && $temp < 31){ - if ($mqtt->connect(true, NULL, $username, $password)) { - $mqtt->publish($topic, $temp, 0, false); - $mqtt->close(); - } - } - $close = 1; - } - } else { - $close = 1; -} -if (!$close) { - echo << -
-
-
- - - -
- 10 °C - 30 °C -
-
-
-
-ENDE; -} diff --git a/css/solar.css b/css/solar.css index a935481..b13d00d 100644 --- a/css/solar.css +++ b/css/solar.css @@ -102,3 +102,12 @@ input[type='range']::-webkit-slider-runnable-track { width: auto; } } + +/* Raum-Modal. Ein Kommando mit mehreren Parametern ("Position+Neigung") + traegt Beschriftung, zwei Zahlenfelder und den Ausloeseknopf in einer + input-group - auf einem Handy schrumpfen die Felder dabei auf Nullbreite. + Eine Mindestbreite laesst die Zeile stattdessen umbrechen. */ +.input-group.raum-cmd > .form-control, +.input-group.raum-cmd > .form-select { + min-width: 5rem; +} diff --git a/js/solar/homeMQTT.js b/js/solar/homeMQTT.js index 70a5e03..967bcd8 100644 --- a/js/solar/homeMQTT.js +++ b/js/solar/homeMQTT.js @@ -151,7 +151,7 @@ function switchFloor(floor){ var mqttData = {}; function openHeaterSettings(heater){ - openModal(heater) + openRoomView(heater); } document.addEventListener('readystatechange', function () { @@ -162,36 +162,98 @@ function switchFloor(floor){ }); - function openModal(heater) { - var contentURL = "./ajax/roomtemp.php?heater="+heater; - let heaterMQTT = heater.toString().split("_"); - document.getElementById("modal-title").innerHTML = "Thermostat "+heater.replace("_"," ").replace("ue","ü").replace("ae","ä").replace("oe","ö"); - modalBodyElement = document.getElementById("modal-body"); - modalBodyElement.innerHTML = loadingHTML("Wird geladen..."); - document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax); - document.getElementById("modalSaveBtn").contentURL = contentURL; - modalEV.show(); - fetch(contentURL, { - method: 'GET', - headers: { - 'X-Requested-From-Modal': 'a', - 'Requested-With-Ajax': 'ajax' - } - }) - .then(response => response.text()) - .then(html => { - modalBodyElement.innerHTML = html; - var slider = document.getElementById("modal-slider"); - var span = document.getElementById("modal-slider-label"); - bindModalSlider(slider, span, value => value + " °C"); + /** + * Alles, was in einem Raum haengt, auf einer Seite: Heizung, Rollladen, + * Licht. Der Grundriss zeigt Raeume, also sollte ein Klick darauf den Raum + * oeffnen - vorher kam nur der Temperaturregler, die Rollladen im selben + * Zimmer waren von hier gar nicht erreichbar. + * + * Welche Geraete dazugehoeren, sagt die Raumzuordnung (actors.room). + */ + function openRoomView(raum) { + const teile = raum.toString().split("_"); + document.getElementById("modal-title").innerHTML = + teile[0] + " · " + teile.slice(1).join(" ") + .replace("ue", "ü").replace("ae", "ä").replace("oe", "ö"); - if(typeof(mqttData["Raumtemp"][heaterMQTT[0]][heaterMQTT[1]]["Set Temp[degC]"])!= "undefined") - slider.setAttribute("value",mqttData["Raumtemp"][heaterMQTT[0]][heaterMQTT[1]]["Set Temp[degC]"]); - else - slider.setAttribute("value",10); - slider.dispatchEvent(new Event('input')); + // Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne + // Zuhoerer stellt sicher, dass nicht auch der Handler eines vorher + // geoeffneten Modals mitfeuert. + const alt = document.getElementById("modalSaveBtn"); + const btn = alt.cloneNode(true); + alt.replaceWith(btn); + btn.addEventListener("click", () => sendeSolltemperatur(raum)); + + const koerper = document.getElementById("modal-body"); + koerper.innerHTML = loadingHTML("Wird geladen..."); + modalEV.show(); + + fetch("./ajax/room.php?room=" + encodeURIComponent(raum), { + method: "GET", + headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" } + }) + .then(a => a.text()) + .then(html => { + koerper.innerHTML = html; + + const slider = document.getElementById("modal-slider"); + if (slider) { + bindModalSlider(slider, document.getElementById("modal-slider-label"), + wert => wert + " °C"); + const stand = mqttData["Raumtemp"]?.[teile[0]]?.[teile[1]]?.["Set Temp[degC]"]; + slider.value = typeof stand !== "undefined" ? stand : 10; + slider.dispatchEvent(new Event("input")); + } + // Ohne Thermostat gibt es nichts zu speichern - dann waere der Knopf + // eine Falle. + btn.hidden = !slider; + + koerper.querySelectorAll("button[data-cmd]").forEach(k => { + k.addEventListener("click", () => schalte(k)); + }); }) - .catch(error => { - modalBodyElement.innerHTML += error.message; + .catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; }); + } + + /** Ein Kommando des angeklickten Knopfes ausfuehren. */ + function schalte(knopf) { + const zeile = knopf.closest("[data-cmdrow]"); + const params = {}; + if (zeile) { + zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; }); + } + knopf.disabled = true; + raumRequest("command", { command_id: Number(knopf.dataset.cmd), params: params }) + .then(antwort => { + knopf.disabled = false; + raumMeldung(antwort); }); } + + function sendeSolltemperatur(raum) { + const slider = document.getElementById("modal-slider"); + if (!slider) return; + raumRequest("temp", { room: raum, temp: Number(slider.value) }) + .then(antwort => { + raumMeldung(antwort); + if (!antwort.error) setTimeout(() => modalEV.hide(), 700); + }); + } + + function raumRequest(action, body) { + return fetch("./ajax/room.php?action=" + action, { + method: "POST", + headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" }, + body: JSON.stringify(body) + }) + .then(a => a.json()) + .catch(fehler => ({ error: fehler.message })); + } + + /** Rueckmeldung im Modal statt als alert - man schaltet oft mehrmals. */ + function raumMeldung(antwort) { + const ziel = document.getElementById("roomFeedback"); + if (!ziel) return; + ziel.className = "small mt-2 " + (antwort.error ? "text-danger" : "text-body-secondary"); + ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok; + } diff --git a/restricted/commands.php b/restricted/commands.php new file mode 100644 index 0000000..ee0b145 --- /dev/null +++ b/restricted/commands.php @@ -0,0 +1,251 @@ +set_charset("utf8mb4"); + } + return $db; +} + +/** Kommando mit Geraet und Parametern laden. */ +function loadCommand($commandId) +{ + $db = commandDb(); + $stmt = $db->prepare("SELECT c.id, c.command_name, c.command_url, + a.name AS actor_name, a.url AS actor_url + FROM actor_commands c JOIN actors a ON a.id = c.actor_id + WHERE c.id = ?"); + $stmt->bind_param("i", $commandId); + $stmt->execute(); + $cmd = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$cmd) { + return null; + } + $stmt = $db->prepare("SELECT id, parameter_name, url FROM command_parameters + WHERE command_id = ? ORDER BY id"); + $stmt->bind_param("i", $commandId); + $stmt->execute(); + $res = $stmt->get_result(); + $cmd["params"] = []; + while ($row = $res->fetch_assoc()) { + $cmd["params"][] = $row; + } + $stmt->close(); + return $cmd; +} + +/** + * Kommando ausfuehren. $werte ist [parameter_id => Wert]. + * + * Mit $probe = true wird nichts geschickt, nur beschrieben, was geschickt + * wuerde - zum Nachsehen, ohne einen Rollladen in Bewegung zu setzen. + * + * Zurueck kommt ein Text, der den Vorgang beschreibt. + */ +function executeCommand($commandId, $werte, $probe = false) +{ + $cmd = loadCommand($commandId); + if (!$cmd) { + throw new InvalidArgumentException("Kommando " . $commandId . " gibt es nicht."); + } + $url = $cmd["actor_url"]; + + if (strpos($url, "mqtt://") === 0) { + return sendeMqtt($cmd, $werte, $probe); + } + if (strpos($url, "wled://") === 0) { + return sendeWled($cmd, $werte, $probe); + } + if ($GLOBALS["tahoma_PIN"] && strpos($url, "://" . $GLOBALS["tahoma_PIN"] . "/") !== false) { + return sendeTahoma($cmd, $werte, $probe); + } + if (strpos($url, "http") === 0) { + return sendeHttp($cmd, $werte, $probe); + } + throw new RuntimeException("Für " . $cmd["actor_name"] . " gibt es keinen Weg zum Gerät."); +} + +/** Werte in der Reihenfolge der Parameter, fehlende als leer. */ +function parameterWerte($cmd, $werte) +{ + $liste = []; + foreach ($cmd["params"] as $p) { + $liste[] = [ + "name" => $p["parameter_name"], + "url" => $p["url"], + "wert" => strval($werte[$p["id"]] ?? $werte[strval($p["id"])] ?? ""), + ]; + } + return $liste; +} + +/** MQTT: je Parameter eine Nachricht auf dessen Topic. */ +function sendeMqtt($cmd, $werte, $probe) +{ + $params = parameterWerte($cmd, $werte); + $nachrichten = []; + if (!$params) { + // Kommando ohne Parameter: das Kommando selbst ist die Nutzlast. + $nachrichten[] = [$cmd["command_url"], ""]; + } else { + foreach ($params as $p) { + $nachrichten[] = [$p["url"] ?: $cmd["command_url"], $p["wert"]]; + } + } + foreach ($nachrichten as $n) { + if (!$n[0]) { + throw new RuntimeException("Kommando ohne Topic."); + } + } + if ($probe) { + return "MQTT: " . implode(", ", array_map(function ($n) { + return $n[0] . " <- " . $n[1]; + }, $nachrichten)); + } + $mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomControl_" . getmypid()); + if (!$mqtt->connect(true, null, "", "")) { + throw new RuntimeException("Keine Verbindung zum MQTT-Broker."); + } + foreach ($nachrichten as $n) { + $mqtt->publish($n[0], $n[1], 0, false); + } + $mqtt->close(); + return $cmd["actor_name"] . ": " . $cmd["command_name"]; +} + +/** WLED: die JSON-Vorlage aus command_url fuellen und am Stueck schicken. */ +function sendeWled($cmd, $werte, $probe) +{ + $vorlage = $cmd["command_url"]; + foreach (parameterWerte($cmd, $werte) as $p) { + $vorlage = str_replace("%" . $p["name"] . "%", $p["wert"], $vorlage); + } + $rumpf = json_decode($vorlage, true); + if ($rumpf === null) { + throw new RuntimeException("WLED-Vorlage ergibt kein gültiges JSON: " . $vorlage); + } + $ziel = "http://" . rtrim(substr($cmd["actor_url"], strlen("wled://")), "/") . "/json/state"; + if ($probe) { + return "WLED: " . $ziel . " <- " . json_encode($rumpf, JSON_UNESCAPED_UNICODE); + } + $ch = curl_init($ziel); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($rumpf)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 400 || $code === 0) { + throw new RuntimeException("WLED antwortete mit " . $code); + } + return $cmd["actor_name"] . ": " . $cmd["command_name"]; +} + +/** HTTP: command_url und Parameter als Abfrageargumente anhaengen. */ +function sendeHttp($cmd, $werte, $probe) +{ + $argumente = []; + foreach (explode("&", strval($cmd["command_url"])) as $teil) { + if (strpos($teil, "=") !== false) { + list($k, $v) = explode("=", $teil, 2); + $argumente[$k] = $v; + } + } + foreach (parameterWerte($cmd, $werte) as $p) { + if ($p["url"]) { + $argumente[$p["url"]] = $p["wert"]; + } + } + if (!$argumente) { + throw new RuntimeException("Kommando ohne URL und ohne Parameter."); + } + $ziel = $cmd["actor_url"] . (strpos($cmd["actor_url"], "?") === false ? "?" : "&") + . http_build_query($argumente); + if ($probe) { + return "HTTP: " . $ziel; + } + $ch = curl_init($ziel); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 400 || $code === 0) { + throw new RuntimeException("Gerät antwortete mit " . $code); + } + return $cmd["actor_name"] . ": " . $cmd["command_name"]; +} + +/** + * Tahoma: exec/apply an die Box - dieselbe Form wie in ajax/tahoma.php, nur + * ohne dessen Sonderbehandlung fuer "faehrt gerade". + */ +function sendeTahoma($cmd, $werte, $probe) +{ + $parameter = []; + foreach (parameterWerte($cmd, $werte) as $p) { + // Die Box erwartet Zahlen als Zahlen, Text als Text. + $parameter[] = is_numeric($p["wert"]) ? $p["wert"] + 0 : $p["wert"]; + } + $rumpf = [ + "label" => "Raumbedienung", + "actions" => [[ + "deviceURL" => $cmd["actor_url"], + "commands" => [["name" => $cmd["command_url"], "parameters" => $parameter]], + ]], + ]; + $ziel = "https://gateway-" . $GLOBALS["tahoma_PIN"] + . ":8443/enduser-mobile-web/1/enduserAPI/exec/apply"; + if ($probe) { + return "Tahoma: " . json_encode($rumpf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + $ch = curl_init($ziel); + curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", + "Authorization: Bearer " . $GLOBALS["tahoma_token"]]); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($rumpf)); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + $antwort = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 400 || $code === 0) { + throw new RuntimeException("Tahoma antwortete mit " . $code . ": " . substr(strval($antwort), 0, 120)); + } + return $cmd["actor_name"] . ": " . $cmd["command_name"]; +}