From 4b47372015cf5175bc8a14cfd4ea33da461dc265 Mon Sep 17 00:00:00 2001 From: Moirtz Wagner Date: Mon, 31 Aug 2026 16:16:48 +0200 Subject: [PATCH] Geraete den Raeumen zuordnen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), nicht in Geraetearten. Ein Raummodell gibt es schon - restricted/rooms.php, die einzige Quelle fuer die Home-Ansicht -, nur fehlte die Zuordnung Geraet -> Raum. Die neue Spalte actors.room haelt sie als "Etage/Raum" (OG/Bad), dazu "aussen" fuer Carport, Garage und Regensensor. Etage und Raum zusammen, weil "Bad" allein auf drei Etagen vorkommt. Discovery fasst die Spalte nicht an - der Upsert setzt nur type und name -, die Zuordnung ueberlebt also jeden Suchlauf. Zugeordnet wird ueber das Haus-Symbol in der Karte "Automatismen". Wo der Raum aus dem MQTT-Topic hervorgeht ("Raumtemp/OG/Bad/Temp[degC]"), schlaegt die Ansicht ihn vor und uebernimmt ihn auf Knopfdruck - das sind die dreizehn Thermostate, ohne jedes Raten. Bei allen anderen wird bewusst nicht geraten: "Bad Links" sagt nichts darueber, auf welcher der drei Etagen dieses Bad liegt. Noch nicht zugeordnete Geraete stehen in der Tabelle oben. Die Geraeteauswahl im Editor gruppiert danach nach Raum, in der Reihenfolge aus rooms.php. Solange niemand zugeordnet hat, bleibt es bei der Gruppierung nach Geraeteart - eine einzige Gruppe "Nicht zugeordnet" waere ein Rueckschritt. Und weil auch nach der ersten Zuordnung noch viel ohne Raum ist, wird dieser Rest weiter nach Geraeteart unterteilt ("Ohne Raum · Aussenjalousie (11)"). Anders als bei den Gerätearten behalten Raeume mit nur einem Geraet ihre Ueberschrift: "OG · Bad" ist auch mit einem einzigen Thermostat genau die Zeile, die man sucht - und mehr als eines haengt selten in einem Raum. Die dreizehn Thermostate sind bereits zugeordnet, weil das ihre eigenen Topics hergeben; alles andere steht auf "-" und will von Hand vergeben werden. Co-Authored-By: Claude Opus 5 --- ajax/AutoAction.php | 74 ++++++++++++++++++++ homeMesh_DB-layout.sql | 3 +- js/solar/autoActionFuncs.js | 130 +++++++++++++++++++++++++++++++----- restricted/automations.php | 112 ++++++++++++++++++++++++++++++- restricted/home.php | 6 ++ 5 files changed, 306 insertions(+), 19 deletions(-) diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index 39ec23c..d55b855 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -9,9 +9,11 @@ * GET ?action=editor&id=N Editor fuer eine vorhandene Automatik * GET ?action=editor&floor=OG Editor fuer eine neue Automatik * GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen" + * GET ?action=rooms Zuordnung Geraet -> Raum zum Bearbeiten * POST ?action=save JSON-Rumpf, legt an oder ueberschreibt * POST ?action=delete {"id": N} * POST ?action=toggle {"id": N} - pausieren / fortsetzen + * POST ?action=saveRooms {"": "OG/Bad", ...} */ require_once("../helper.php"); @@ -49,6 +51,8 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { jsonAntwort(["ok" => true]); case "toggle": jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]); + case "saveRooms": + jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]); default: jsonAntwort(["error" => "Unbekannte Aktion."], 400); } @@ -126,6 +130,73 @@ if ($action === "list") { exit; } +// --- Geraete den Raeumen zuordnen --------------------------------------- +if ($action === "rooms") { + $labels = geraeteBeschriftungen(); + $raeume = geraeteRaeume(); + $vorschlag = raumVorschlaege(); + $auswahl = raumListe(); + + $res = meshDb()->query("SELECT id, type FROM actors"); + $geraete = []; + while ($row = $res->fetch_assoc()) { + $id = intval($row["id"]); + $geraete[] = [ + "id" => $id, + "label" => $labels[$id] ?? "?", + "type" => $row["type"], + "room" => $raeume[$id] ?? null, + "tipp" => $vorschlag[$id] ?? null, + ]; + } + // Unzugeordnete nach oben: das ist die Arbeit, die noch zu tun ist. + usort($geraete, function ($a, $b) { + $offen = (int)!empty($b["room"]) - (int)!empty($a["room"]); + return $offen !== 0 ? $offen : strcoll($a["label"], $b["label"]); + }); + + $offen = count(array_filter($geraete, function ($g) { return empty($g["room"]); })); + $tipps = count(array_filter($geraete, function ($g) { + return empty($g["room"]) && !empty($g["tipp"]); + })); + + echo "
"; + echo "

Die Zuordnung ordnet die Geräteliste im Editor " + . "nach Räumen statt nach Geräteart. Sie wird von Hand vergeben und bleibt bei " + . "einem neuen Geräte-Suchlauf erhalten.

"; + if ($tipps) { + echo "
" + . "" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic." + . "
"; + } + echo "
"; + echo "" + . "" + . ""; + foreach ($geraete as $g) { + echo ""; + echo ""; + echo ""; + echo ""; + } + echo "
GerätArtRaum
" . htmlspecialchars($g["label"]) . "" + . htmlspecialchars($g["type"]) . "
"; + echo "

" . $offen . " von " . count($geraete) + . " Geräten noch ohne Raum.

"; + echo "
"; + exit; +} + // --- Editor -------------------------------------------------------------- $id = intval($_GET["id"] ?? 0); $auto = $id > 0 ? loadAutomation($id) : null; @@ -141,6 +212,9 @@ $nutzdaten = json_encode([ "automation" => $auto, "devices" => deviceCatalog(), "lockouts" => lockoutChoices(), + // Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und + // Etage fuer Etage liest sich besser als alphabetisch. + "rooms" => raumListe(), ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?> diff --git a/homeMesh_DB-layout.sql b/homeMesh_DB-layout.sql index d2e6329..12d860a 100644 --- a/homeMesh_DB-layout.sql +++ b/homeMesh_DB-layout.sql @@ -31,7 +31,8 @@ CREATE TABLE `actors` ( `id` int(11) NOT NULL, `type` varchar(50) NOT NULL COMMENT 'Gerätetyp z.B. RollerShutter', `name` varchar(70) NOT NULL COMMENT 'Name des Geräts', - `url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL' + `url` varchar(100) NOT NULL COMMENT 'Tahoma Device URL', + `room` varchar(60) DEFAULT NULL COMMENT 'Raum aus restricted/rooms.php als "Etage/Raum" (z.B. OG/Bad) oder "aussen". Wird von Hand vergeben und deshalb vom Discovery nicht angefasst - der Upsert setzt nur type und name.' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; -- diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index 3208c21..cce2519 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -20,6 +20,7 @@ let autoModel = null; let autoDevices = []; let autoLockouts = []; +let autoRooms = []; // --- Katalog-Helfer ------------------------------------------------------ @@ -81,30 +82,74 @@ function option(e, gewaehlt) { } /** - * Geraeteliste nach Geraeteart gruppiert. + * Geraeteliste gruppiert - nach Raum, wo zugeordnet, sonst nach Geraeteart. * - * Bei ueber fuenfzig Geraeten ist die flache Liste der Engpass: dreizehn - * Thermostate und elf Jalousien stehen sonst als vierundzwanzig gleich - * aussehende Zeilen untereinander. Arten mit nur einem Geraet bekommen - * keine eigene Ueberschrift - fuenfzehn Gruppen mit je einem Eintrag waeren - * unuebersichtlicher als die flache Liste. Sie sammeln sich am Ende. + * Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll..."), nicht + * in Geraetearten. Der Raum steht aber nur da, wo er von Hand vergeben wurde + * (siehe Zuordnung ueber das Zahnrad in der Karte "Automatismen"); solange + * das niemand getan hat, bleibt die Gruppierung nach Art - besser als eine + * einzige Gruppe "Nicht zugeordnet". */ function geraeteGruppen(geraete) { - const nachArt = new Map(); + if (!geraete.some(d => d.room)) { + return gruppiere(geraete, d => d.type, null, "Einzelne", true); + } + const gruppen = gruppiere(geraete, d => d.roomLabel, raumReihenfolge(), + "Nicht zugeordnet", false); + // Was noch keinen Raum hat, bleibt sonst ein einziger Eimer - solange erst + // ein Teil zugeordnet ist, waeren das die meisten Geraete. Innerhalb des + // Eimers gilt deshalb weiter die Ordnung nach Geraeteart. + const letzte = gruppen[gruppen.length - 1]; + if (!letzte || !letzte.titel.startsWith("Nicht zugeordnet")) return gruppen; + gruppen.pop(); + return gruppen.concat( + gruppiere(letzte.geraete, d => d.type, null, "Einzelne", true) + .map(g => ({ titel: "Ohne Raum · " + g.titel, geraete: g.geraete }))); +} + +/** Reihenfolge der Raum-Ueberschriften, wie in rooms.php notiert. */ +function raumReihenfolge() { + return autoRooms.map(r => r.label).concat(["Nicht zugeordnet"]); +} + +/** + * Nach einem Merkmal gruppieren. + * + * `einzelneSammeln` entscheidet, was mit Gruppen aus einem einzigen Geraet + * passiert. Bei Gerätearten sollen sie zusammenfallen - fuenfzehn + * Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als die + * flache Liste. Bei Raeumen nicht: "OG · Bad" ist auch mit einem einzigen + * Geraet die Ueberschrift, die man sucht, und in der Wohnung haengt oft nur + * ein Thermostat je Raum. + */ +function gruppiere(geraete, merkmal, reihenfolge, restTitel, einzelneSammeln) { + const nach = new Map(); geraete.forEach(d => { - if (!nachArt.has(d.type)) nachArt.set(d.type, []); - nachArt.get(d.type).push(d); + const schluessel = merkmal(d) || restTitel; + if (!nach.has(schluessel)) nach.set(schluessel, []); + nach.get(schluessel).push(d); + }); + const titel = Array.from(nach.keys()); + titel.sort((a, b) => { + if (reihenfolge) { + const ia = reihenfolge.indexOf(a), ib = reihenfolge.indexOf(b); + if (ia !== ib) return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib); + } + return a.localeCompare(b, "de"); }); const gruppen = []; - const einzelne = []; - Array.from(nachArt.keys()).sort((a, b) => a.localeCompare(b, "de")).forEach(art => { - const liste = nachArt.get(art); - if (liste.length > 1) gruppen.push({ titel: art + " (" + liste.length + ")", geraete: liste }); - else einzelne.push(liste[0]); + const rest = []; + titel.forEach(t => { + const liste = nach.get(t); + if (!einzelneSammeln || liste.length > 1 || t === restTitel) { + gruppen.push({ titel: t + " (" + liste.length + ")", geraete: liste }); + } else { + rest.push(liste[0]); + } }); - if (einzelne.length) { - einzelne.sort((a, b) => a.label.localeCompare(b.label, "de")); - gruppen.push({ titel: "Einzelne", geraete: einzelne }); + if (rest.length) { + rest.sort((a, b) => a.label.localeCompare(b.label, "de")); + gruppen.push({ titel: restTitel + " (" + rest.length + ")", geraete: rest }); } return gruppen; } @@ -545,6 +590,7 @@ function loadAutomatic() { const daten = JSON.parse(document.getElementById("autoActionData").textContent); autoDevices = daten.devices; autoLockouts = daten.lockouts; + autoRooms = daten.rooms || []; const a = daten.automation; autoModel = { @@ -687,6 +733,56 @@ function refreshAutomations(floor) { }); } +/** + * Zuordnung Geraet -> Raum. Eigenes Modal, weil es kein Teil einer einzelnen + * Automatik ist, sondern einmal fuer alle gilt. + */ +function openRoomModal() { + document.getElementById("modal-title").innerHTML = "Geräte den Räumen zuordnen"; + const alt = document.getElementById("modalSaveBtn"); + const btn = alt.cloneNode(true); + alt.replaceWith(btn); + btn.addEventListener("click", speichereRaeume); + + const dialog = document.querySelector("#modalEV .modal-dialog"); + dialog.classList.add("modal-lg"); + document.getElementById("modalEV").addEventListener("hidden.bs.modal", + () => dialog.classList.remove("modal-lg"), { once: true }); + + const koerper = document.getElementById("modal-body"); + koerper.innerHTML = loadingHTML("Wird geladen..."); + modalEV.show(); + + fetch("./ajax/AutoAction.php?action=rooms", { + method: "GET", headers: { "Requested-With-Ajax": "ajax" } + }) + .then(a => a.text()) + .then(html => { + koerper.innerHTML = html; + const tipps = document.getElementById("btnRoomTipps"); + if (tipps) { + tipps.addEventListener("click", () => { + koerper.querySelectorAll("select[data-tipp]").forEach(s => { + if (!s.value) s.value = s.dataset.tipp; + }); + tipps.disabled = true; + }); + } + }) + .catch(fehler => { koerper.innerHTML = "Konnte die Zuordnung nicht laden: " + fehler.message; }); +} + +function speichereRaeume() { + const rooms = {}; + document.querySelectorAll("#modal-body select[data-actor]").forEach(s => { + rooms[s.dataset.actor] = s.value; + }); + automationRequest("saveRooms", { rooms: rooms }).then(antwort => { + if (antwort.error) alert(antwort.error); + else modalEV.hide(); + }); +} + function toggleAutomation(id) { automationRequest("toggle", { id: id }).then(antwort => { if (antwort.error) alert(antwort.error); diff --git a/restricted/automations.php b/restricted/automations.php index 905b8f7..ff937fb 100644 --- a/restricted/automations.php +++ b/restricted/automations.php @@ -15,6 +15,11 @@ */ require_once(__DIR__ . "/mysql.php"); +require_once(__DIR__ . "/rooms.php"); + +// Geraete, die zu keinem Raum der Wohnung gehoeren - Carport, Garage, +// Regensensor. Kein Raum in rooms.php, aber auch nicht "unzugeordnet". +const RAUM_AUSSEN = "aussen"; /** Verbindung zur Geraete- und Automatik-Datenbank. */ function meshDb() @@ -210,6 +215,109 @@ function anzeigeName($name) return trim(preg_replace('/\s+/', ' ', $name)); } +/** + * Waehlbare Raeume: die Raeume der Home-Ansicht plus "Aussen". + * + * Einzige Quelle bleibt restricted/rooms.php - ein neuer Raum ist dort eine + * Zeile und taucht hier automatisch auf. Der Schluessel ist "Etage/Raum", + * weil "Bad" allein auf drei Etagen vorkommt. + */ +function raumListe() +{ + $liste = []; + foreach ($GLOBALS["rooms"] as $raum) { + $liste[] = [ + "key" => $raum["floor"] . "/" . $raum["mqtt"], + "label" => $raum["floor"] . " · " . $raum["mqtt"], + ]; + } + $liste[] = ["key" => RAUM_AUSSEN, "label" => "Außen"]; + return $liste; +} + +/** Anzeigename eines gespeicherten Raumschluessels. */ +function raumBeschriftung($key) +{ + if (!$key) { + return "Nicht zugeordnet"; + } + foreach (raumListe() as $raum) { + if ($raum["key"] === $key) { + return $raum["label"]; + } + } + // Raum aus rooms.php entfernt - der Eintrag am Geraet bleibt stehen, + // damit eine Umbenennung dort keine Zuordnung stillschweigend loescht. + return $key . " (unbekannt)"; +} + +/** + * Raumvorschlaege aus den MQTT-Topics. + * + * Die Thermostate tragen ihren Raum im Topic: "Raumtemp/OG/Bad/Temp[degC]". + * Das ist keine Rateaufgabe, sondern dieselbe Schreibweise wie in rooms.php, + * und deckt dreizehn Geraete ohne Zutun ab. Bei allen anderen wird bewusst + * nicht geraten: "Bad Links" sagt nichts darueber, auf welcher der drei + * Etagen dieses Bad liegt. + */ +function raumVorschlaege() +{ + $bekannt = []; + foreach (raumListe() as $raum) { + $bekannt[strtolower($raum["key"])] = $raum["key"]; + } + $res = meshDb()->query("SELECT actor_id, url FROM actor_states + WHERE url LIKE 'Raumtemp/%/%/%'"); + $vorschlag = []; + while ($row = $res->fetch_assoc()) { + $teile = explode("/", $row["url"]); + if (count($teile) < 3) { + continue; + } + $key = strtolower($teile[1] . "/" . $teile[2]); + if (isset($bekannt[$key])) { + $vorschlag[intval($row["actor_id"])] = $bekannt[$key]; + } + } + return $vorschlag; +} + +/** Raum je Geraet, wie gespeichert. */ +function geraeteRaeume() +{ + $res = meshDb()->query("SELECT id, room FROM actors"); + $raeume = []; + while ($row = $res->fetch_assoc()) { + $raeume[intval($row["id"])] = $row["room"]; + } + return $raeume; +} + +/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */ +function saveRooms($zuordnung) +{ + $db = meshDb(); + $erlaubt = array_column(raumListe(), "key"); + $stmt = $db->prepare("UPDATE actors SET room = ? WHERE id = ?"); + $anzahl = 0; + foreach ($zuordnung as $actorId => $key) { + $actorId = intval($actorId); + $key = strval($key); + if ($actorId <= 0) { + continue; + } + if ($key !== "" && !in_array($key, $erlaubt, true)) { + throw new InvalidArgumentException("Unbekannter Raum: " . $key); + } + $wert = $key === "" ? null : $key; + $stmt->bind_param("si", $wert, $actorId); + $stmt->execute(); + $anzahl++; + } + $stmt->close(); + return $anzahl; +} + /** * id => Beschriftung fuer alle Geraete, geputzt und eindeutig. * @@ -256,7 +364,7 @@ function deviceCatalog() $devices = []; $labels = geraeteBeschriftungen(); - $res = $db->query("SELECT id, name, type, url FROM actors"); + $res = $db->query("SELECT id, name, type, url, room FROM actors"); $zeilen = []; while ($row = $res->fetch_assoc()) { $zeilen[] = $row; @@ -271,6 +379,8 @@ function deviceCatalog() "label" => $labels[intval($row["id"])], "type" => $row["type"], "url" => $row["url"], + "room" => $row["room"], + "roomLabel" => raumBeschriftung($row["room"]), "states" => [], "commands" => [], ]; diff --git a/restricted/home.php b/restricted/home.php index fdb7aaf..b0e9305 100644 --- a/restricted/home.php +++ b/restricted/home.php @@ -133,6 +133,12 @@

Automatismen

+ +