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 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 16:43:21 +02:00
co-authored by Claude Opus 5
parent bed1884b04
commit 98a1da6cd5
5 changed files with 543 additions and 78 deletions
+191
View File
@@ -0,0 +1,191 @@
<?php
/**
* Raum-Modal: alles, was in einem Raum haengt, auf einer Seite.
*
* Loest das frühere Thermostat-Modal ab. Der Grundriss zeigt Raeume, also
* sollte ein Klick darauf den Raum oeffnen und nicht nur seine Heizung - die
* Rollladen im selben Zimmer waren bisher gar nicht erreichbar.
*
* Welche Geraete zu einem Raum gehoeren, sagt die Spalte actors.room (siehe
* den Zuordnungsdialog in der Karte "Automatismen").
*
* GET ?room=EG_Bad Inhalt des Modals
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
* POST ?action=temp {"room": "EG_Bad", "temp": 21} - Solltemperatur
*/
require_once("../helper.php");
require_once("../restricted/automations.php");
require_once("../restricted/commands.php");
require_once("./phpMQTT.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
function raumAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
// --- Schaltende Aufrufe ---------------------------------------------------
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$body = json_decode(file_get_contents("php://input"), true);
if (!is_array($body)) {
raumAntwort(["error" => "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/<Etage>/<Raum>/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 1030 °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 "<div class='p-2' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>";
if ($hatThermostat) {
echo "<div class='card mb-2'><div class='card-body py-2'>";
echo "<label class='form-label mb-0'>Solltemperatur "
. "<span id='modal-slider-label' style='font-size:15px;color:#297195;display:inline-block'></span></label>";
echo "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>";
echo "<div class='d-flex justify-content-between small text-body-secondary'>"
. "<span>10 °C</span><span>30 °C</span></div>";
echo "</div></div>";
}
if (!$geraete && !$messwerte && !$hatThermostat) {
echo "<p class='text-body-secondary mb-0'>Diesem Raum ist noch nichts zugeordnet. "
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
}
foreach ($geraete as $d) {
echo "<div class='card mb-2'><div class='card-body py-2'>";
echo "<div class='fw-bold small mb-2'>" . htmlspecialchars($d["label"])
. " <span class='fw-normal text-body-secondary'>" . htmlspecialchars($d["type"]) . "</span></div>";
// 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 "<div class='d-flex flex-wrap gap-1 mb-2'>";
foreach ($knoepfe as $c) {
echo "<button type='button' class='btn btn-outline-primary btn-sm' data-cmd='" . $c["id"] . "'>"
. htmlspecialchars($c["name"]) . "</button>";
}
echo "</div>";
}
foreach ($d["commands"] as $c) {
if (!count($c["params"])) {
continue;
}
echo "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . $c["id"] . "'>";
echo "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>";
foreach ($c["params"] as $p) {
if (count($c["params"]) > 1) {
echo "<span class='input-group-text'>" . htmlspecialchars($p["name"]) . "</span>";
}
if ($p["options"]) {
echo "<select class='form-select' data-param='" . $p["id"] . "'>";
foreach ($p["options"] as $o) {
echo "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
}
echo "</select>";
} else {
echo "<input type='" . ($p["input"] === "number" ? "number" : "text")
. "' class='form-control' data-param='" . $p["id"] . "'"
. ($p["min"] !== null ? " min='" . $p["min"] . "'" : "")
. ($p["max"] !== null ? " max='" . $p["max"] . "'" : "")
. " value='" . ($p["min"] !== null ? intval($p["min"]) : 0) . "'>";
}
}
echo "<button type='button' class='btn btn-outline-primary' data-cmd='" . $c["id"] . "'>"
. "<i class='bi bi-play-fill'></i></button>";
echo "</div>";
}
echo "</div></div>";
}
// Geraete ohne Kommandos: nur anzeigen, was sie melden.
foreach ($messwerte as $d) {
echo "<div class='card mb-2'><div class='card-body py-2'>";
echo "<div class='fw-bold small mb-1'>" . htmlspecialchars($d["label"])
. " <span class='fw-normal text-body-secondary'>" . htmlspecialchars($d["type"]) . "</span></div>";
echo "<div class='small text-body-secondary'>";
$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 "</div></div></div>";
}
echo "<div id='roomFeedback' class='small mt-2'></div>";
echo "</div>";