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

+ +