Raum-Modal: Bedienelemente statt Kommandoliste

Das Modal zeigte jedes Kommando so, wie es in der Datenbank steht: eine
Jalousie kam mit acht Rohbefehlen daher, darunter "Winken", die Farbe einer
Lampe als drei Zahlenfelder 0-255, ein Stromzaehler als abgeschnittene
Textzeile. Die Solltemperatur stand doppelt da - einmal als Schieberegler,
einmal als Zahlenfeld der Thermostat-Karte.

Jetzt entscheidet restricted/roomControls.php, welches Geraet welches
Bedienelement bekommt, und zwar an seinen Faehigkeiten statt am Typnamen:
hat es ein "setClosure"? ein An/Aus-Paar? drei Parameter rot/gruen/blau?
Damit bekommt auch ein neu gefundenes Geraet sein passendes Element, ohne
dass jemand einen Typnamen nachtraegt.

  Beschattung   Auf/Stop/Zu, darunter Position und Neigung als Schieber
                auf dem zuletzt gemeldeten Stand; "Winken" und
                "my-Position" im Mehr-Menue
  Licht         Kippschalter, Helligkeit, Farbwaehler des Browsers,
                Effekt und Preset als Auswahl
  Schalten      ein Kippschalter statt dreier Knoepfe
  Messwerte     Wertegitter, die sechs wichtigsten sichtbar - beim
                Wasserzaehler standen vorher MAC-Adresse und freier
                Speicher vorn und der Verbrauch hinter "12 weitere"
  Heizung       nur noch der Schieberegler, daneben Ist-Temperatur und
                Feuchte live aus dem MQTT-Strom

Ab zwei Geraeten einer Art ordnet eine Ueberschrift; darunter waere sie
nur Aufwand. Schieberegler schicken beim Loslassen, nicht beim Ziehen -
sonst bekaeme eine Jalousie bei jedem Pixel ein neues Ziel. Waehrend ein
Kommando unterwegs ist, sperrt sich die Karte sichtbar: ein "Zu" dauert
ueber eine Minute, und ohne Sperre tippt man in der Zeit nach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 21:18:46 +02:00
co-authored by Claude Opus 5
parent 131d7cc46d
commit 17f972d081
5 changed files with 780 additions and 91 deletions
+38 -83
View File
@@ -22,6 +22,7 @@ set_time_limit(180);
require_once("../helper.php");
require_once("../restricted/automations.php");
require_once("../restricted/commands.php");
require_once("../restricted/roomControls.php");
require_once("./phpMQTT.php");
if (!checkLogin()) {
@@ -75,6 +76,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
}
}
// --- Inhalt des Modals ----------------------------------------------------
$schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? ""));
$teile = explode("_", $schluessel, 2);
@@ -90,106 +92,59 @@ foreach (allRooms() as $raum) {
}
}
// Alles im Raum, sortiert nach Bedienform. Das Thermostat selbst faellt
// heraus: es wird oben zum Schieberegler und braucht daneben keine zweite
// Bedienung - "Solltemperatur" stand vorher zweimal im Modal.
$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;
}));
$gruppen = [];
foreach ($katalog as $d) {
if ($d["room"] !== $raumKey) {
continue;
}
$form = bedienform($d);
if ($form === "heizung") {
continue;
}
$gruppen[$form][] = $d;
}
echo "<div class='p-2' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>";
echo "<div class='p-1' 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 "<div class='d-flex justify-content-between align-items-baseline'>";
echo "<label class='form-label mb-0 small' for='modal-slider'>Solltemperatur</label>";
echo "<span id='modal-slider-label' class='fw-semibold text-nowrap ms-2' style='color:#297195'></span>";
echo "</div>";
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>";
// Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank - der
// Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von vorhin
// waere neben dem Regler nur irrefuehrend.
echo "<div class='d-flex flex-wrap gap-3 small text-body-secondary'>";
echo "<span><i class='bi bi-thermometer-half me-1'></i><span data-mqtt='Temp[degC]'></span> °C</span>";
echo "<span><i class='bi bi-droplet me-1'></i><span data-mqtt='rHum[%]'></span> %rF</span>";
echo "<span data-mqtt-heizung class='d-none'><i class='bi bi-fire text-danger me-1'></i>heizt</span>";
echo "</div>";
echo "</div></div>";
}
if (!$geraete && !$messwerte && !$hatThermostat) {
if (!$gruppen && !$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"])) {
foreach (raumGruppenTitel() as $form => $titel) {
if (empty($gruppen[$form])) {
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>";
// Eine einzelne Lampe braucht keine Ueberschrift "Licht" - der Name der
// Lampe steht direkt darunter. Ueberschriften ordnen erst ab zwei.
if (count($gruppen[$form]) > 1) {
echo gruppenTitel($titel[0], $titel[1]);
}
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>";
foreach ($gruppen[$form] as $d) {
echo zeichneGeraet($d, $form);
}
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>";
+88
View File
@@ -111,3 +111,91 @@ input[type='range']::-webkit-slider-runnable-track {
.input-group.raum-cmd > .form-select {
min-width: 5rem;
}
/* --------------------------------------------------------------------------
Raum-Modal: Bedienelemente
Erzeugt von restricted/roomControls.php, verdrahtet in js/solar/homeMQTT.js.
-------------------------------------------------------------------------- */
/* Ueberschrift einer Geraetegruppe (Beschattung, Licht, ...). Klein und
ruhig - sie ordnet, sie soll nicht mit den Geraeten um Aufmerksamkeit
ringen. */
.raum-gruppe {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.75rem 0 0.35rem;
}
.raum-gruppe:first-child {
margin-top: 0;
}
/* Waehrend ein Kommando unterwegs ist. Ein "Zu" an einer Jalousie dauert
ueber eine Minute; ohne sichtbare Sperre tippt man in der Zeit nach. */
.raum-geraet.raum-warte {
opacity: 0.55;
pointer-events: none;
}
.raum-geraet.raum-warte::after {
content: "";
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 0.9rem;
height: 0.9rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: raum-dreht 0.8s linear infinite;
}
.raum-geraet {
position: relative;
}
@keyframes raum-dreht {
to { transform: rotate(360deg); }
}
/* Schieberegler stehen dicht untereinander (Position und Neigung). Ohne den
Abstand kleben Beschriftung und Bahn aneinander. */
.raum-regler + .raum-regler {
margin-top: 0.35rem;
}
.raum-regler .form-range {
margin-bottom: 0;
}
/* Messwerte als Gitter statt als Textwurst: ein Stromzaehler hat sechzehn
Werte, die in einer Zeile niemand findet. auto-fill statt fester Spalten,
damit auf dem Handy zwei und auf dem Schirm vier nebeneinander passen. */
.raum-werte {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.4rem;
}
.raum-wert {
background: rgba(128, 128, 128, 0.08);
border-radius: 0.35rem;
padding: 0.3rem 0.45rem;
overflow: hidden;
}
.raum-wert-zahl {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.raum-wert-einheit {
font-size: 0.7rem;
font-weight: 400;
opacity: 0.7;
}
.raum-wert-name {
font-size: 0.68rem;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+99 -5
View File
@@ -208,11 +208,93 @@ function switchFloor(floor){
// eine Falle.
btn.hidden = !slider;
raumBedienungAktivieren(koerper);
raumMesswerteAktualisieren(teile[0], teile[1]);
})
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; });
}
/**
* Die Bedienelemente des Raum-Modals mit ihren Kommandos verbinden.
*
* Welches Geraet welches Element bekommt, entscheidet der Server
* (restricted/roomControls.php); hier haengen nur die Zuhoerer an den
* data-Attributen, die er dabei setzt.
*/
function raumBedienungAktivieren(koerper) {
koerper.querySelectorAll("button[data-cmd]").forEach(k => {
k.addEventListener("click", () => schalte(k));
});
})
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; });
// Schieberegler: waehrend des Ziehens nur die Beschriftung, geschickt
// wird erst beim Loslassen. Sonst bekaeme eine Jalousie bei jedem Pixel
// ein neues Ziel und kaeme aus dem Anfahren nicht mehr heraus.
koerper.querySelectorAll(".raum-regler").forEach(regler => {
const schieber = regler.querySelector("input[type=range]");
const anzeige = regler.querySelector(".raum-regler-wert");
const einheit = schieber.dataset.einheit || "";
schieber.addEventListener("input", () => { anzeige.textContent = schieber.value + einheit; });
schieber.addEventListener("change", () => {
const params = {};
params[regler.dataset.param] = schieber.value;
raumKommando(Number(regler.dataset.cmd), params, regler);
});
});
// Kippschalter: An und Aus sind zwei getrennte Kommandos. Kennt das
// Geraet nur "umschalten", tut es auch das - dann stimmt die Stellung
// hinterher, weil wir sie selbst gesetzt haben.
koerper.querySelectorAll(".raum-schalter").forEach(schalter => {
const kasten = schalter.querySelector("input[type=checkbox]");
const beschriftung = schalter.querySelector("label");
kasten.addEventListener("change", () => {
const ziel = kasten.checked ? schalter.dataset.ein : schalter.dataset.aus;
const cmd = ziel || schalter.dataset.um;
beschriftung.textContent = kasten.checked ? "An" : "Aus";
if (!cmd) return;
raumKommando(Number(cmd), {}, schalter);
});
});
// Farbe: der Browser bringt seinen Farbwaehler mit, das Geraet will drei
// Zahlen 0-255. Zerlegt wird deshalb erst hier.
koerper.querySelectorAll(".raum-farbe").forEach(feld => {
const eingabe = feld.querySelector("input[type=color]");
eingabe.addEventListener("change", () => {
const hex = eingabe.value;
const params = {};
params[feld.dataset.red] = parseInt(hex.substr(1, 2), 16);
params[feld.dataset.green] = parseInt(hex.substr(3, 2), 16);
params[feld.dataset.blue] = parseInt(hex.substr(5, 2), 16);
raumKommando(Number(feld.dataset.cmd), params, feld);
});
});
// Auswahllisten, die ohne zusaetzlichen Knopf schalten - Effekt, Preset.
koerper.querySelectorAll("select.raum-sofort").forEach(auswahl => {
auswahl.addEventListener("change", () => {
const params = {};
params[auswahl.dataset.param] = auswahl.value;
raumKommando(Number(auswahl.dataset.cmd), params, auswahl);
});
});
}
/**
* Die Ist-Werte im Heizungsteil aus dem laufenden MQTT-Strom fuellen.
*
* Der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert aus der
* Datenbank waere neben dem Regler nur ein Stand von vorhin.
*/
function raumMesswerteAktualisieren(etage, raum) {
const werte = mqttData["Raumtemp"]?.[etage]?.[raum];
if (!werte) return;
document.querySelectorAll("#roomView [data-mqtt]").forEach(feld => {
const wert = werte[feld.dataset.mqtt];
if (typeof wert !== "undefined") feld.textContent = wert;
});
const heizt = document.querySelector("#roomView [data-mqtt-heizung]");
if (heizt) heizt.classList.toggle("d-none", String(werte["Heating"]) !== "true");
}
/** Ein Kommando des angeklickten Knopfes ausfuehren. */
@@ -222,10 +304,22 @@ function switchFloor(floor){
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 })
raumKommando(Number(knopf.dataset.cmd), params, knopf);
}
/**
* Ein Kommando schicken und die Karte so lange sperren.
*
* Ein "Zu" an einer Jalousie dauert ueber eine Minute (die Mechanik will
* zweimal geschwenkt werden, siehe restricted/commands.php). Ohne sichtbare
* Sperre tippt man in der Zeit dreimal nach.
*/
function raumKommando(cmdId, params, element) {
const karte = element.closest(".raum-geraet") || element;
karte.classList.add("raum-warte");
raumRequest("command", { command_id: cmdId, params: params })
.then(antwort => {
knopf.disabled = false;
karte.classList.remove("raum-warte");
raumMeldung(antwort);
});
}
+7 -1
View File
@@ -411,7 +411,7 @@ function deviceCatalog()
];
}
$res = $db->query("SELECT id, actor_id, command_name FROM actor_commands
$res = $db->query("SELECT id, actor_id, command_name, command_url FROM actor_commands
ORDER BY actor_id, id");
$commands = [];
while ($row = $res->fetch_assoc()) {
@@ -423,6 +423,12 @@ function deviceCatalog()
$devices[$id]["commands"][] = [
"id" => intval($row["id"]),
"name" => $row["command_name"],
// Das Raum-Modal erkennt an der URL, was ein Kommando tut:
// "setClosure" ist eine Position, "up"/"down" faehrt, ein
// WLED-"{"on":true}" schaltet ein. Am Namen ginge das nicht -
// der kommt aus dem Geraetesuchlauf und heisst mal "An", mal
// "turn_on".
"url" => $row["command_url"],
"params" => [],
];
}
+546
View File
@@ -0,0 +1,546 @@
<?php
/**
* Bedienformen fuer das Raum-Modal.
*
* Die Geraetedatenbank kennt nur Kommandos mit Parametern - eine Jalousie hat
* acht davon, darunter "Winken". Stellt man die eins zu eins dar, steht im
* Raum-Modal eine Bedienungsanleitung statt einer Bedienung. Hier entscheidet
* sich deshalb, welches Geraet welches Bedienelement bekommt: ein Rollladen
* Auf/Stop/Zu mit zwei Schiebereglern, eine Lampe einen Kippschalter mit
* Helligkeit und Farbe, ein Zaehler ein Wertegitter.
*
* Erkannt wird an den Faehigkeiten, nicht am Typnamen: hat das Geraet ein
* "setClosure"? ein An/Aus-Paar? drei Parameter rot/gruen/blau? Damit bekommt
* auch ein neu gefundenes Geraet sein passendes Bedienelement, ohne dass hier
* jemand einen Typnamen nachtraegt.
*
* Das Gegenstueck im Browser ist raumBedienungAktivieren() in
* js/solar/homeMQTT.js - dort haengen die Zuhoerer an den data-Attributen,
* die hier gesetzt werden.
*/
/**
* Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
* Schaltkommando ist.
*
* Der Geraetesuchlauf vergibt die Namen so, wie das Geraet sie meldet: mal
* "An", mal "turn_on", mal "Anschalten". Deshalb zaehlt zuerst die URL.
*/
function schaltRichtung($cmd)
{
if (count($cmd["params"])) {
return null; // Ein Schalter hat nichts einzustellen.
}
$url = strtolower(trim($cmd["url"]));
$name = strtolower(trim($cmd["name"]));
if (strpos($url, "toggle") !== false || $name === "toggle" || $name === "umschalten") {
return "um";
}
if (in_array($url, ["on", "turn=on", '{"on":true}'], true)
|| in_array($name, ["an", "ein", "anschalten", "einschalten", "turn_on"], true)) {
return "ein";
}
if (in_array($url, ["off", "turn=off", '{"on":false}'], true)
|| in_array($name, ["aus", "ausschalten", "turn_off"], true)) {
return "aus";
}
return null;
}
/** Erstes Kommando, dessen URL in $urls steht. */
function kommandoMitUrl($d, $urls)
{
foreach ($d["commands"] as $c) {
if (in_array($c["url"], $urls, true)) {
return $c;
}
}
return null;
}
/** Erstes Kommando, das genau einen Parameter dieses Namens hat. */
function kommandoMitParameter($d, $name)
{
foreach ($d["commands"] as $c) {
if (count($c["params"]) === 1 && $c["params"][0]["name"] === $name) {
return $c;
}
}
return null;
}
/** Kommando mit Parametern rot/gruen/blau - eine Farbe zum Aussuchen. */
function farbKommando($d)
{
foreach ($d["commands"] as $c) {
$namen = array_map(function ($p) { return strtolower($p["name"]); }, $c["params"]);
sort($namen);
if ($namen === ["blue", "green", "red"]) {
return $c;
}
}
return null;
}
/** An/Aus/Um eines Geraets, sofern vorhanden. */
function schaltKommandos($d)
{
$gefunden = [];
foreach ($d["commands"] as $c) {
$richtung = schaltRichtung($c);
if ($richtung !== null && !isset($gefunden[$richtung])) {
$gefunden[$richtung] = $c;
}
}
return $gefunden;
}
/** Aktueller Wert eines Zustands - der erste Name, den es gibt. */
function zustandsWert($d, $namen)
{
foreach ($namen as $name) {
foreach ($d["states"] as $s) {
if (strcasecmp($s["name"], $name) === 0) {
return $s["value"];
}
}
}
return null;
}
/**
* Welche Bedienform passt zu diesem Geraet?
*
* Die Reihenfolge ist die Rangfolge: eine Jalousie hat auch ein "Auf" und
* "Zu", ist aber keine Lampe, und ein Thermostat mit Solltemperatur ist kein
* Schalter.
*/
function bedienform($d)
{
if (!count($d["commands"])) {
return "messwerte";
}
if (stripos($d["type"], "thermostat") !== false) {
return "heizung";
}
if (kommandoMitUrl($d, ["setClosure", "setClosureAndOrientation"])
|| kommandoMitUrl($d, ["up", "down"])) {
return "beschattung";
}
$schalter = schaltKommandos($d);
if ($schalter && (kommandoMitParameter($d, "brightness") || farbKommando($d))) {
return "licht";
}
if ($schalter) {
return "schalter";
}
return "sonstiges";
}
/** Ueberschriften der Gruppen, in der Reihenfolge, in der sie erscheinen. */
function raumGruppenTitel()
{
return [
"beschattung" => ["Beschattung", "bi-blinds"],
"licht" => ["Licht", "bi-lightbulb"],
"schalter" => ["Schalten", "bi-toggle-on"],
"sonstiges" => ["Weitere Geräte", "bi-sliders"],
"messwerte" => ["Messwerte", "bi-speedometer2"],
];
}
// ===========================================================================
// Bausteine
// ===========================================================================
function karteAuf($d, $status = "")
{
$html = "<div class='card mb-2 raum-geraet' data-actor='" . intval($d["id"]) . "'>"
. "<div class='card-body py-2'>"
. "<div class='d-flex justify-content-between align-items-center gap-2 mb-2'>"
. "<span class='fw-semibold small text-truncate'>" . htmlspecialchars($d["label"]) . "</span>";
if ($status !== "") {
$html .= "<span class='small text-body-secondary text-nowrap raum-status'>" . $status . "</span>";
}
return $html . "</div>";
}
function karteZu()
{
return "</div></div>";
}
/**
* Ein Schieberegler, der beim Loslassen schaltet.
*
* Waehrend des Ziehens zu schicken hiesse, bei jedem Pixel ein Kommando an
* die Box zu geben - eine Jalousie kaeme aus dem Anfahren nicht mehr heraus.
*/
function reglerZeile($titel, $cmd, $param, $wert, $einheit = "%")
{
$min = $param["min"] === null ? 0 : intval($param["min"]);
$max = $param["max"] === null ? 100 : intval($param["max"]);
$wert = $wert === null || $wert === "" ? $min : intval($wert);
$wert = max($min, min($max, $wert));
return "<div class='raum-regler' data-cmd='" . intval($cmd["id"]) . "'"
. " data-param='" . intval($param["id"]) . "'>"
. "<div class='d-flex justify-content-between small'>"
. "<span>" . htmlspecialchars($titel) . "</span>"
. "<span class='raum-regler-wert fw-semibold'>" . $wert . $einheit . "</span></div>"
. "<input type='range' class='form-range range-color-track' min='" . $min . "' max='" . $max . "'"
. " step='1' value='" . $wert . "' data-einheit='" . htmlspecialchars($einheit, ENT_QUOTES) . "'>"
. "</div>";
}
/** Kippschalter mit dem Stand, den das Geraet zuletzt gemeldet hat. */
function kippSchalter($d, $schalter, $an)
{
$id = "sw" . intval($d["id"]);
$html = "<div class='form-check form-switch raum-schalter'"
. " data-ein='" . (isset($schalter["ein"]) ? intval($schalter["ein"]["id"]) : "") . "'"
. " data-aus='" . (isset($schalter["aus"]) ? intval($schalter["aus"]["id"]) : "") . "'"
. " data-um='" . (isset($schalter["um"]) ? intval($schalter["um"]["id"]) : "") . "'>"
. "<input class='form-check-input' type='checkbox' role='switch' id='" . $id . "'"
. ($an ? " checked" : "") . ">"
. "<label class='form-check-label small' for='" . $id . "'>"
. ($an ? "An" : "Aus") . "</label></div>";
return $html;
}
/** Ein "wahr"-artiger Wert - die Geraete melden mal True, mal 1, mal "on". */
function istAn($wert)
{
if ($wert === null) {
return false;
}
return in_array(strtolower(trim(strval($wert))), ["1", "true", "on", "an", "ein", "yes"], true);
}
/**
* Nebensaechliche Kommandos in ein Mehr-Menue, damit sie nicht stoeren.
*
* Nur Kommandos ohne Parameter: ein Menuepunkt bietet nichts zum Einstellen,
* ein "Position+Neigung" von hier aus wuerde ohne Werte losgeschickt. Was
* Parameter braucht, hat oben seinen Schieberegler.
*/
function mehrMenue($cmds)
{
$cmds = array_values(array_filter($cmds, function ($c) { return !count($c["params"]); }));
if (!$cmds) {
return "";
}
$html = "<div class='dropdown'>"
. "<button class='btn btn-sm btn-link text-body-secondary p-0' type='button'"
. " data-bs-toggle='dropdown' aria-label='Weitere Befehle'>"
. "<i class='bi bi-three-dots'></i></button><ul class='dropdown-menu dropdown-menu-end'>";
foreach ($cmds as $c) {
$html .= "<li><button class='dropdown-item small' type='button' data-cmd='" . intval($c["id"]) . "'>"
. htmlspecialchars($c["name"]) . "</button></li>";
}
return $html . "</ul></div>";
}
// ===========================================================================
// Die Bedienformen
// ===========================================================================
/**
* Der zuletzt gemeldete Stand in Worten.
*
* "0 % zu" muss man erst uebersetzen, "offen" nicht. Die Prozentzahl steht
* ohnehin am Schieberegler darunter.
*/
function beschattungsStand($position, $neigung)
{
if ($position === null || $position === "") {
return "";
}
$p = intval($position);
$text = $p === 0 ? "offen" : ($p === 100 ? "geschlossen" : $p . " % zu");
if ($neigung !== null && $neigung !== "" && $p > 0) {
$text .= ", Neigung " . intval($neigung) . " %";
}
return htmlspecialchars($text);
}
/**
* Rollladen, Jalousie, Markise, Garagentor.
*
* Auf/Stop/Zu gross zum Antippen, darunter die Feinheiten. Die Schieber
* starten auf dem zuletzt gemeldeten Stand - so sieht man, wo das Geraet
* gerade steht, bevor man es bewegt.
*/
function zeichneBeschattung($d)
{
$auf = kommandoMitUrl($d, ["up"]);
$stop = kommandoMitUrl($d, ["stop"]);
$zu = kommandoMitUrl($d, ["down"]);
$position = kommandoMitParameter($d, "Position");
$neigung = kommandoMitParameter($d, "Neigung");
// Was das Geraet zuletzt gemeldet hat. Bei Somfy heisst die Lamelle
// "Lamellenausrichtung", die Hoehe schlicht "Position".
$standPos = zustandsWert($d, ["Position"]);
$standNeigung = zustandsWert($d, ["Lamellenausrichtung"]);
$status = beschattungsStand($standPos, $standNeigung);
$benutzt = array_filter([$auf, $stop, $zu, $position, $neigung]);
$benutzteIds = array_map(function ($c) { return $c["id"]; }, $benutzt);
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
$html = karteAuf($d, $status);
if ($auf || $stop || $zu) {
$html .= "<div class='d-flex align-items-center gap-2 mb-2'>";
$html .= "<div class='btn-group btn-group-sm flex-grow-1' role='group'>";
foreach ([[$auf, "bi-chevron-up", "Auf"], [$stop, "bi-stop-fill", "Stop"],
[$zu, "bi-chevron-down", "Zu"]] as $k) {
if (!$k[0]) {
continue;
}
$html .= "<button type='button' class='btn btn-outline-primary' data-cmd='" . intval($k[0]["id"]) . "'"
. " title='" . htmlspecialchars($k[2], ENT_QUOTES) . "'>"
. "<i class='bi " . $k[1] . "'></i><span class='d-none d-sm-inline ms-1'>"
. htmlspecialchars($k[2]) . "</span></button>";
}
$html .= "</div>" . mehrMenue($rest) . "</div>";
}
if ($position) {
$html .= reglerZeile("Position", $position, $position["params"][0], $standPos);
}
if ($neigung) {
$html .= reglerZeile("Neigung", $neigung, $neigung["params"][0], $standNeigung);
}
return $html . karteZu();
}
/**
* Lampe: Kippschalter, Helligkeit, Farbe, Effekt.
*
* Die Farbe kommt in der Datenbank als drei Parameter 0-255 an. Drei
* Zahlenfelder sind keine Farbwahl - deshalb ein Farbfeld, das der Browser
* aufmacht, und die Zerlegung passiert erst beim Absenden.
*/
function zeichneLicht($d)
{
$schalter = schaltKommandos($d);
$an = istAn(zustandsWert($d, ["An", "ison", "on", "state"]));
$helligkeit = kommandoMitParameter($d, "brightness");
$farbe = farbKommando($d);
$benutzteIds = [];
foreach ($schalter as $c) {
$benutzteIds[] = $c["id"];
}
foreach ([$helligkeit, $farbe] as $c) {
if ($c) {
$benutzteIds[] = $c["id"];
}
}
$html = karteAuf($d, "");
$html .= "<div class='d-flex justify-content-between align-items-center mb-2'>";
$html .= $schalter ? kippSchalter($d, $schalter, $an) : "<span></span>";
if ($farbe) {
// Der zuletzt gemeldete Farbwert steht als [r, g, b, w] in der
// Datenbank; alles andere faellt auf Weiss zurueck.
$roh = json_decode(strval(zustandsWert($d, ["Farbe", "color"])), true);
// Steht dort schwarz, leuchtet die Lampe ueber ihren Weisskanal - ein
// schwarzes Feld waere dann eine falsche Auskunft.
$hex = is_array($roh) && count($roh) >= 3
&& (intval($roh[0]) || intval($roh[1]) || intval($roh[2]))
? sprintf("#%02x%02x%02x", intval($roh[0]), intval($roh[1]), intval($roh[2]))
: "#ffffff";
$html .= "<span class='raum-farbe d-inline-flex align-items-center gap-1'"
. " data-cmd='" . intval($farbe["id"]) . "'";
foreach ($farbe["params"] as $p) {
$html .= " data-" . strtolower($p["name"]) . "='" . intval($p["id"]) . "'";
}
$html .= "><label class='small text-body-secondary' for='col" . intval($d["id"]) . "'>Farbe</label>"
. "<input type='color' class='form-control form-control-color form-control-sm'"
. " id='col" . intval($d["id"]) . "' value='" . $hex . "'></span>";
}
$html .= "</div>";
if ($helligkeit) {
$html .= reglerZeile("Helligkeit", $helligkeit, $helligkeit["params"][0],
zustandsWert($d, ["Helligkeit", "brightness", "bri"]), "");
}
// Effekt und Preset sind Listen - die gehoeren in eine Auswahl, nicht in
// ein Zahlenfeld. Sie schalten sofort, ohne zusaetzlichen Knopf.
foreach ($d["commands"] as $c) {
if (in_array($c["id"], $benutzteIds, true) || count($c["params"]) !== 1
|| !count($c["params"][0]["options"])) {
continue;
}
$benutzteIds[] = $c["id"];
$p = $c["params"][0];
$html .= "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . intval($c["id"]) . "'>"
. "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>"
. "<select class='form-select raum-sofort' data-param='" . intval($p["id"]) . "'"
. " data-cmd='" . intval($c["id"]) . "'>";
foreach ($p["options"] as $o) {
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
}
$html .= "</select></div>";
}
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
if ($rest) {
$html .= "<div class='d-flex justify-content-end'>" . mehrMenue($rest) . "</div>";
}
return $html . karteZu();
}
/** Alles, was nur an und aus kennt. */
function zeichneSchalter($d)
{
$schalter = schaltKommandos($d);
$an = istAn(zustandsWert($d, ["An", "ison", "on", "state", "Status"]));
$benutzteIds = array_map(function ($c) { return $c["id"]; }, array_values($schalter));
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
$html = karteAuf($d, "");
$html .= "<div class='d-flex justify-content-between align-items-center'>"
. kippSchalter($d, $schalter, $an) . mehrMenue($rest) . "</div>";
return $html . karteZu();
}
/**
* Geraete, die nur melden: ein Wertegitter statt einer Textwurst.
*
* Ein Stromzaehler hat sechzehn Werte. Alle sechzehn hinzuschreiben ist
* genauso unbrauchbar wie acht davon abzuschneiden - die ersten sechs
* stehen da, der Rest ist einen Klick entfernt.
*/
function zeichneMesswerte($d)
{
$html = karteAuf($d, htmlspecialchars($d["type"]));
$states = nachWichtigkeit($d["states"]);
$sichtbar = array_slice($states, 0, 6);
$weitere = array_slice($states, 6);
$html .= "<div class='raum-werte'>";
foreach ($sichtbar as $s) {
$html .= wertKachel($s);
}
$html .= "</div>";
if ($weitere) {
$html .= "<details class='mt-2'><summary class='small text-body-secondary'>"
. count($weitere) . " weitere Werte</summary>"
. "<div class='raum-werte mt-2'>";
foreach ($weitere as $s) {
$html .= wertKachel($s);
}
$html .= "</div></details>";
}
return $html . karteZu();
}
/**
* Messwerte so ordnen, dass die sechs sichtbaren die richtigen sind.
*
* Die Reihenfolge in der Datenbank ist die des Geraetesuchlaufs: beim
* Wasserzaehler standen MAC-Adresse, Firmware und freier Speicher vorn, der
* Verbrauch hinter "12 weitere Werte". Vorne gehoert hin, was gemessen wird -
* ein Wert mit Einheit -, hinten, was ueber das Geraet selbst Auskunft gibt.
*/
function nachWichtigkeit($states)
{
$diagnose = ["mac", "hostname", "fwversion", "uptime", "freemem", "rssi",
"error", "status", "ip", "ssid", "version"];
$rang = function ($s) use ($diagnose) {
$leer = $s["value"] === null || $s["value"] === "";
$technisch = in_array(strtolower($s["name"]), $diagnose, true);
return ($technisch ? 4 : 0) + ($leer ? 2 : 0) + ($s["unit"] ? 0 : 1);
};
usort($states, function ($a, $b) use ($rang) { return $rang($a) - $rang($b); });
return $states;
}
function wertKachel($s)
{
$wert = strval($s["value"]);
if ($wert === "") {
$wert = "";
}
// Messwerte kommen mit voller Rechengenauigkeit aus dem Geraet; auf einer
// Kachel liest niemand 231.10000000000002.
if (is_numeric($wert) && strpos($wert, ".") !== false) {
$wert = rtrim(rtrim(number_format(floatval($wert), 2, ",", ""), "0"), ",");
}
return "<div class='raum-wert'>"
. "<div class='raum-wert-zahl'>" . htmlspecialchars($wert)
. ($s["unit"] ? " <span class='raum-wert-einheit'>" . htmlspecialchars($s["unit"]) . "</span>" : "")
. "</div>"
. "<div class='raum-wert-name'>" . htmlspecialchars($s["name"]) . "</div></div>";
}
/** Was in keine Form passt: Kommandos so, wie sie in der Datenbank stehen. */
function zeichneSonstiges($d)
{
$html = karteAuf($d, htmlspecialchars($d["type"]));
$knoepfe = array_values(array_filter($d["commands"], function ($c) { return !count($c["params"]); }));
if ($knoepfe) {
$html .= "<div class='d-flex flex-wrap gap-1 mb-2'>";
foreach ($knoepfe as $c) {
$html .= "<button type='button' class='btn btn-outline-primary btn-sm' data-cmd='" . intval($c["id"]) . "'>"
. htmlspecialchars($c["name"]) . "</button>";
}
$html .= "</div>";
}
foreach ($d["commands"] as $c) {
if (!count($c["params"])) {
continue;
}
$html .= "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . intval($c["id"]) . "'>"
. "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>";
foreach ($c["params"] as $p) {
if (count($c["params"]) > 1) {
$html .= "<span class='input-group-text'>" . htmlspecialchars($p["name"]) . "</span>";
}
if ($p["options"]) {
$html .= "<select class='form-select' data-param='" . intval($p["id"]) . "'>";
foreach ($p["options"] as $o) {
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
}
$html .= "</select>";
} else {
$html .= "<input type='" . ($p["input"] === "number" ? "number" : "text")
. "' class='form-control' data-param='" . intval($p["id"]) . "'"
. ($p["min"] !== null ? " min='" . intval($p["min"]) . "'" : "")
. ($p["max"] !== null ? " max='" . intval($p["max"]) . "'" : "")
. " value='" . ($p["min"] !== null ? intval($p["min"]) : 0) . "'>";
}
}
$html .= "<button type='button' class='btn btn-outline-primary' data-cmd='" . intval($c["id"]) . "'>"
. "<i class='bi bi-play-fill'></i></button></div>";
}
return $html . karteZu();
}
/** Ein Geraet in seiner Bedienform zeichnen. */
function zeichneGeraet($d, $form)
{
switch ($form) {
case "beschattung": return zeichneBeschattung($d);
case "licht": return zeichneLicht($d);
case "schalter": return zeichneSchalter($d);
case "messwerte": return zeichneMesswerte($d);
default: return zeichneSonstiges($d);
}
}
/** Ueberschrift einer Gruppe. */
function gruppenTitel($titel, $symbol)
{
return "<h6 class='raum-gruppe text-body-secondary'>"
. "<i class='bi " . $symbol . " me-1'></i>" . htmlspecialchars($titel) . "</h6>";
}