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"]; +}