From 07bca5104ed8a730ec987e5922801fdba0587ac4 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 8 Sep 2026 22:45:24 +0200 Subject: [PATCH] Einstellungsseite: Reiter, Kachelwerte und Geraeteliste Die Einstellungen bestanden aus einer einzigen langen Spalte mit den Preistabellen. Sie bekommt Reiter, und darin zwei neue Flaechen: was die Kacheln der Startseite anzeigen, und welche Geraete es ueberhaupt gibt. Reiter gab es vorher dreimal in drei Ausfuehrungen - die Etagen ueber den Automatismen mit eigenem switchTab(), die Geraetegruppen im Raum-Modal mit eigener CSS-Klasse, und die Einstellungsseite noch einmal anders. Jetzt baut restricted/reiter.php alle drei; umgeschaltet wird von Bootstrap, den offenen Reiter merkt reiterMerken() in der Adresse. Kachelwerte: was auf einer Kachel steht, stand fest in rooms.php. Die neue Tabelle kachelWerte (solarLog_kacheln.sql) ueberschreibt das je Raum, die Maske waehlt die Messwerte aus demselben Katalog wie der Automatik-Editor - nach Etage sortiert, die Geraete des eigenen Raums oben. Fehlt die Tabelle, zeigt jede Kachel weiter ihre Vorgabe. Geraete: die Zuordnung Geraet -> Raum war ein Modal, erreichbar ueber ein Zahnrad neben den Automatismen. Sie ist eine Einstellung und steht jetzt dort, zusammen mit dem, was fehlte: seit wann ein Geraet nichts mehr meldet, in welchen Automatiken es steckt, und ein Loeschknopf. Letzterer ist noetig, weil der Suchlauf nichts loescht - er legt an und frischt auf, damit die Automatiken ihre Zustands-Ids behalten; ein abgebautes Geraet bliebe sonst fuer immer stehen. Geloescht wird nur, was in keiner Automatik vorkommt. Co-Authored-By: Claude Opus 5 --- ajax/AutoAction.php | 71 ---- ajax/settings.php | 72 +++- css/solar.css | 271 +++++++++++- js/solar/autoActionFuncs.js | 52 --- js/solar/common.js | 33 ++ js/solar/homeMQTT.js | 13 - js/solar/settings.js | 801 ++++++++++++++++++++++++++++++++++++ restricted/automations.php | 176 +++++++- restricted/home.php | 48 ++- restricted/kacheln.php | 339 +++++++++++++++ restricted/reiter.php | 128 ++++++ restricted/roomControls.php | 45 +- restricted/rooms.php | 101 ++++- restricted/settings.php | 108 ++++- solarLog_kacheln.sql | 26 ++ 15 files changed, 2058 insertions(+), 226 deletions(-) create mode 100644 restricted/kacheln.php create mode 100644 restricted/reiter.php create mode 100644 solarLog_kacheln.sql diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index e3064d8..e08c9c7 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -9,11 +9,9 @@ * 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"); @@ -51,8 +49,6 @@ 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); } @@ -130,73 +126,6 @@ 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; diff --git a/ajax/settings.php b/ajax/settings.php index 2977bc9..8282b9f 100644 --- a/ajax/settings.php +++ b/ajax/settings.php @@ -4,14 +4,22 @@ * * GET ?action=list&reihe=strom Zeitraeume einer Preistabelle * POST ?action=save {"reihe": "strom", "zeilen": [ ... ]} + * GET ?action=kacheln Anzeigewerte aller Home-Kacheln + * POST ?action=kacheln-save {"floor": "OG", "room": "bad", + * "werte": [ ... ] oder null} + * GET ?action=geraete Geraete mit Raum, Verwendung und Alter + * POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}} + * POST ?action=geraet-loeschen {"id": 12} * * Ein reiner Verteiler: geprueft und geschrieben wird in - * restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber - * einen Zeitraum haben. + * restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt + * dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben. */ require_once("../helper.php"); require_once("../restricted/costs.php"); +require_once("../restricted/kacheln.php"); +require_once("../restricted/automations.php"); if (!checkLogin()) { http_response_code(403); @@ -43,6 +51,41 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { "gespeichert" => $anzahl, "zeilen" => preiseLaden($body["reihe"]), ]); + + case "kacheln-save": + /* + * Fehlt "werte" oder steht dort null, faellt die Kachel auf + * ihre Vorgabe zurueck - dann kommt diese zurueck, damit die + * Maske zeigt, was jetzt gilt, statt zu leeren Zeilen. + */ + $floor = $body["floor"] ?? ""; + $room = $body["room"] ?? ""; + $werte = array_key_exists("werte", $body) ? $body["werte"] : null; + $gespeichert = kachelWerteSpeichern($floor, $room, $werte); + + $vorgabe = []; + foreach (allRooms() as $r) { + if ($r["floor"] === $floor && $r["id"] === $room) { + $vorgabe = kachelVorgabe($r); + } + } + jsonAntwort([ + "eigen" => $gespeichert !== null, + "werte" => $gespeichert !== null ? $gespeichert : $vorgabe, + ]); + case "geraete-raeume": + jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]); + + case "geraet-loeschen": + /* + * Loeschen ist endgueltig - abgelehnt wird es, wenn eine + * Automatik auf das Geraet zeigt (siehe geraetLoeschen()). + * Zurueck kommt die neue Liste, damit die Maske nicht raten + * muss, was jetzt gilt. + */ + $name = geraetLoeschen($body["id"] ?? 0); + jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]); + default: jsonAntwort(["error" => "Unbekannte Aktion."], 400); } @@ -53,6 +96,31 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { } } +if ($action === "kacheln") { + try { + jsonAntwort([ + "kacheln" => kachelListe(), + "symbole" => kachelSymbole(), + // Auswahlliste je Geraet - dieselbe Quelle wie im Automatik-Editor. + "katalog" => kachelKatalog(), + // Fuer die Eingabe von Hand, wenn etwas nicht im Katalog steht. + "topics" => kachelTopicVorschlaege(), + "tabelleDa" => kachelTabelleDa(), + "max" => KACHEL_MAX, + ]); + } catch (Throwable $e) { + jsonAntwort(["error" => "Kacheln nicht abrufbar: " . $e->getMessage()], 500); + } +} + +if ($action === "geraete") { + try { + jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]); + } catch (Throwable $e) { + jsonAntwort(["error" => "Geräte nicht abrufbar: " . $e->getMessage()], 500); + } +} + if ($action === "list") { try { jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]); diff --git a/css/solar.css b/css/solar.css index b6fd285..7e3862e 100644 --- a/css/solar.css +++ b/css/solar.css @@ -691,21 +691,6 @@ input[type='range']::-webkit-slider-runnable-track { Beschriftungen nicht nebeneinander - dann scrollt die Leiste seitwaerts, statt die Namen umzubrechen. Die durchgehende Linie unter den Reitern darf dabei nicht mitscrollen, sie gehoert an den unteren Rand. */ -.raum-tabs { - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: none; - flex-wrap: nowrap; -} -.raum-tabs::-webkit-scrollbar { - display: none; -} -.raum-tabs .nav-link { - padding: 0.3rem 0.7rem; - font-size: 0.8rem; - white-space: nowrap; - border-bottom-color: transparent; -} /* Waehrend ein Kommando unterwegs ist. Ein "Zu" an einer Jalousie dauert ueber eine Minute; ohne sichtbare Sperre tippt man in der Zeit nach. */ @@ -1022,6 +1007,71 @@ input[type='range']::-webkit-slider-runnable-track { --skoda-blech: 128, 138, 148; /* alles Unbeteiligte am Fahrzeug */ } +/* --- Reiter -------------------------------------------------------------- + Eine Bauart fuer die ganze Seite: Einstellungen, Raum-Modal, Automatiken. + Gebaut werden sie von restricted/reiter.php, umgeschaltet von Bootstrap. + + Statt der Ordnerlaschen von Bootstrap eine Unterstreichung: die Laschen + brauchen einen Rahmen um die Flaeche darunter, damit sie nicht in der Luft + haengen - und der Rahmen ist genau das, was diese Oberflaeche sonst + nirgends hat. Der aktive Reiter traegt die Akzentfarbe. + + Jede Regel nennt .nav-tabs UND .reiter, obwohl .reiter allein die Leiste + schon trifft. Das ist noetig, nicht schmueckend: adminlte.min.css wird nach + dieser Datei geladen und bringt eigene Regeln fuer .nav-tabs .nav-link mit. + Bei gleicher Spezifitaet gewaenne die spaetere - der aktive Reiter waere + weiss statt bernstein. */ +.nav-tabs.reiter { + border-bottom: 1px solid var(--ton-linie, rgba(138, 148, 158, .2)); + gap: .1rem; +} +.nav-tabs.reiter .nav-link { + border: 0; + border-bottom: 2px solid transparent; + border-radius: .35rem .35rem 0 0; + padding: .45rem .9rem; + color: var(--ton-schrift-2, #9aa6ad); + background: transparent; + transition: color .18s, background .18s, border-color .18s; +} +@media (hover: hover) { + .nav-tabs.reiter .nav-link:hover { + background: rgba(138, 148, 158, .08); + color: var(--ton-schrift, #e8ede9); + border-bottom-color: var(--ton-linie-satt, rgba(138, 148, 158, .38)); + } +} +.nav-tabs.reiter .nav-link.active { + background: transparent; + color: var(--ton-sonne, #f0b429); + border-bottom-color: var(--ton-sonne, #f0b429); +} +.nav-tabs.reiter .nav-link:focus-visible { + outline: 2px solid var(--ton-sonne, #f0b429); + outline-offset: -2px; +} +.nav-tabs.reiter .nav-link i { margin-right: .5rem; } + +/* Schmale Fassung fuer enge Stellen (Raum-Modal): kleinere Reiter, die + waagerecht scrollen statt umzubrechen - eine Zeile bleibt eine Zeile. */ +.nav-tabs.reiter-schmal { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} +.nav-tabs.reiter-schmal::-webkit-scrollbar { display: none; } +.nav-tabs.reiter-schmal .nav-link { + padding: .3rem .7rem; + font-size: .8rem; + white-space: nowrap; +} +.nav-tabs.reiter-schmal .nav-link i { margin-right: .3rem; } + +@media (prefers-reduced-motion: reduce) { + .nav-tabs.reiter .nav-link { transition: none; } +} + /* --- Aufbau der Fahrzeugkachel ------------------------------------------- Links das Bild, rechts die Zahlen dazu. Die Kachel bricht um, sobald die Zahlenspalte unter 24rem faellt; das Bild behaelt dann seine Breite und @@ -1816,6 +1866,197 @@ input[type='range']::-webkit-slider-runnable-track { .skoda-alter { font-size: .85rem; } +/* === Einstellungen: Werte auf den Home-Kacheln =========================== + Eine Zeile je Raum, aufklappbar. Die Zeile zeigt im zugeklappten Zustand + schon, was auf der Kachel steht - sonst muesste man zwanzig Raeume + aufklappen, um einen zu finden. */ + +.kachel-etage { margin-bottom: 1.25rem; } +.kachel-etage:last-child { margin-bottom: 0; } +.kachel-etage-name { + font-size: .7rem; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--ton-schrift-3, #6f7a81); + margin-bottom: .35rem; +} + +.kachel-zeile { + border: 1px solid var(--ton-linie, rgba(138, 148, 158, .2)); + border-radius: .5rem; + margin-bottom: .4rem; + overflow: hidden; +} +.kachel-zeile.offen { border-color: var(--ton-linie-satt, rgba(138, 148, 158, .38)); } + +.kachel-kopf { + display: flex; + align-items: center; + gap: .6rem; + width: 100%; + padding: .5rem .75rem; + background: none; + border: 0; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +@media (hover: hover) { + .kachel-kopf:hover { background: rgba(138, 148, 158, .08); } +} +.kachel-kopf .pfeil { + color: var(--ton-schrift-3, #6f7a81); + transition: transform .18s; + flex: 0 0 auto; +} +.kachel-zeile.offen .kachel-kopf .pfeil { transform: rotate(90deg); } +.kachel-kopf .name { min-width: 8rem; } +.kachel-kopf .werte { + display: flex; + flex-wrap: wrap; + gap: .3rem; + flex: 1 1 auto; +} +.kachel-chip { + font-size: .72rem; + padding: .1rem .45rem; + border: 1px solid var(--ton-linie, rgba(138, 148, 158, .2)); + border-radius: 1rem; + color: var(--ton-schrift-2, #9aa6ad); + white-space: nowrap; +} +.kachel-chip.leer { font-style: italic; } + +.kachel-editor { + padding: .25rem .75rem .75rem; + border-top: 1px solid var(--ton-linie, rgba(138, 148, 158, .2)); +} + +/* Eine Wertzeile. Auf breiten Schirmen nebeneinander, auf dem Handy + untereinander - die Felder sind zu kurz, um sie zu quetschen. */ +.kachel-wert { + display: grid; + grid-template-columns: 7.5rem minmax(10rem, 1fr) 7rem 5.5rem 4rem auto; + gap: .4rem; + align-items: center; + padding: .5rem 0; + border-bottom: 1px dashed var(--ton-linie, rgba(138, 148, 158, .2)); +} +.kachel-wert:last-of-type { border-bottom: 0; } +.kachel-wert .kw-schalter { + display: flex; + align-items: center; + gap: .5rem; + white-space: nowrap; +} +.kachel-wert .kw-schalter label { + display: inline-flex; + align-items: center; + gap: .25rem; + font-size: .78rem; + color: var(--ton-schrift-2, #9aa6ad); + cursor: pointer; +} + +@media (max-width: 991.98px) { + .kachel-wert { + grid-template-columns: 1fr 1fr; + } + .kachel-wert .kw-topic { grid-column: 1 / -1; } +} + +.kachel-fuss { + display: flex; + align-items: center; + gap: .75rem; + margin-top: .75rem; +} + +/* === Geraetetafel (Einstellungen) ======================================== + Sechzig Zeilen, in denen die meiste Arbeit an einer Handvoll haengt: den + Geraeten ohne Raum und denen, die seit Monaten schweigen. Beide sind + markiert, damit man sie nicht suchen muss - der Rest bleibt ruhig. */ +.geraete-sieb { + display: flex; + align-items: center; + gap: .9rem; + margin-bottom: .75rem; +} +.geraete-sieb input[type="search"] { max-width: 22rem; } +.geraete-sieb .form-check { flex: none; } + +.geraete-tafel { + max-height: calc(100vh - 22rem); + min-height: 14rem; + overflow-y: auto; + border: 1px solid var(--bs-border-color, #2c3034); + border-radius: .375rem; +} +/* Der Kopf bleibt beim Scrollen stehen - sonst raet man ab Zeile zwanzig, + welche Spalte der Raum war. */ +.geraete-tafel thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--ton-flaeche-2, #2b3237); + font-size: .72rem; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--ton-schrift-3, #6f7a81); + white-space: nowrap; +} +.geraete-tafel td { vertical-align: middle; } +.geraete-tafel .name { line-height: 1.25; } +.geraete-tafel .name .neben { + display: block; + font-size: .72rem; + color: var(--ton-schrift-3, #6f7a81); +} +.geraete-tafel .raum { min-width: 11rem; } +.geraete-tafel .raum select { min-width: 9rem; } +.geraete-tafel .alter { color: var(--ton-schrift-2, #9aa4ab); white-space: nowrap; } +.geraete-tafel .alter.lange { color: var(--ton-sonne, #f0b429); } +/* Ohne Raum: eine Kante links statt einer eingefaerbten Zeile - Letzteres + sieht bei zwanzig offenen Geraeten nach Fehler aus, ist aber nur Arbeit. + Als ::before und nicht als border, weil Bootstrap Tabellenzellen im + Hover mit einem inset-Schatten fuellt und den Rand damit ueberdeckt. */ +.geraete-tafel tr.ohne-raum td:first-child { position: relative; } +.geraete-tafel tr.ohne-raum td:first-child::before { + content: ""; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--ton-sonne, #f0b429); +} + +/* Der Papierkorb haelt sich zurueck, bis man ihn braucht. */ +.geraete-tafel .geraet-weg { + color: var(--ton-schrift-3, #6f7a81); + border-color: transparent; + line-height: 1; +} +.geraete-tafel .geraet-weg:hover:not(:disabled), +.geraete-tafel .geraet-weg:focus-visible:not(:disabled) { + color: var(--ton-schlecht, #e06060); + border-color: var(--ton-schlecht, #e06060); +} +.geraete-tafel .geraet-weg:disabled { opacity: .25; } + +.geraete-fuss { + display: flex; + justify-content: space-between; + align-items: center; + gap: .75rem; + margin-top: .5rem; +} + +@media (max-width: 575.98px) { + .geraete-sieb { flex-wrap: wrap; } + .geraete-sieb input[type="search"] { max-width: none; } + .geraete-tafel { max-height: calc(100vh - 18rem); } +} + /* === Protokollseite ====================================================== Ein Log liest sich nur in einer Schrift mit fester Breite: die Zeitstempel stehen dann untereinander, und das Auge findet die Stelle, an der etwas diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index 121c1c5..dff484d 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -800,58 +800,6 @@ 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); - // Das Raum-Modal versteckt ihn; hier wird er gebraucht. - btn.hidden = false; - 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/js/solar/common.js b/js/solar/common.js index 63c24df..1f18161 100644 --- a/js/solar/common.js +++ b/js/solar/common.js @@ -215,3 +215,36 @@ function updateWeatherCards(value) { document.getElementById("windGust").innerHTML = value("maxgust"); document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10; } + + +/** + * Offenen Reiter in der Adresse merken. + * + * Gilt fuer jede Leiste, die restricted/reiter.php mit "merken" gebaut hat - + * erkennbar an data-reiter-merken. Ohne das landet man nach jedem Neuladen + * wieder im ersten Reiter, obwohl man gerade woanders gearbeitet hat. + * + * In der Adresse steht nur das Kuerzel des Reiters (#kacheln), nicht der Name + * der Leiste: es ist eine Adresse, die man auch von Hand tippt. Zwei merkende + * Leisten auf einer Seite waeren damit nicht zu unterscheiden - dann muesste + * hier der Name davor, und die Adresse waere haesslicher als noetig. + */ +function reiterMerken() { + if (typeof bootstrap === "undefined") return; + + document.querySelectorAll("[data-reiter-merken]").forEach(function (leiste) { + const knoepfe = leiste.querySelectorAll("[data-reiter]"); + const gewuenscht = location.hash.replace("#", ""); + + knoepfe.forEach(function (knopf) { + knopf.addEventListener("shown.bs.tab", function () { + history.replaceState(null, "", "#" + knopf.dataset.reiter); + }); + if (gewuenscht && knopf.dataset.reiter === gewuenscht) { + new bootstrap.Tab(knopf).show(); + } + }); + }); +} + +document.addEventListener("DOMContentLoaded", reiterMerken); diff --git a/js/solar/homeMQTT.js b/js/solar/homeMQTT.js index 31e3d89..0b953fb 100644 --- a/js/solar/homeMQTT.js +++ b/js/solar/homeMQTT.js @@ -203,19 +203,6 @@ function removeClass(el, classNameToRemove){ // Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die // home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier // sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht. -function switchTab(newtab){ - const newContent = document.getElementById(newtab); - const newTabBtn = document.getElementById(newtab+"-tab"); - document.querySelectorAll("#actions-tab .nav-link").forEach(function(btn){ - removeClass(btn,"active"); - }); - document.querySelectorAll("#actions-tabContent .tab-pane").forEach(function(pane){ - removeClass(pane,"active show"); - }); - addClass(newTabBtn,"active"); - addClass(newContent,"active show"); -} - function switchFloor(floor){ if(currentFloor == floor) diff --git a/js/solar/settings.js b/js/solar/settings.js index 51be503..90e86a8 100644 --- a/js/solar/settings.js +++ b/js/solar/settings.js @@ -252,3 +252,804 @@ document.addEventListener("DOMContentLoaded", function () { preisKarteBinden(karte); }); }); + +/* ========================================================================== + Reiter "Home-Kacheln": welche Messwerte auf den Kacheln stehen. + + Dieselbe Arbeitsteilung wie bei den Preisen: das Modell ist die Wahrheit, + jede Eingabe schreibt hinein, gezeichnet wird daraus neu. Anders als dort + kommt es nicht mit der Seite, sondern per Abruf - die Liste haengt an den + Raeumen und aendert sich seltener als ein Preis, aber sie ist laenger. + + Eine Kachel ist entweder "Standard" (keine Zeile in der Datenbank, es gilt + restricted/rooms.php) oder "selbst gesetzt". Der Unterschied steht in der + Zeile, denn er entscheidet, was beim naechsten Umbau von rooms.php + passiert: Standardkacheln ziehen mit, selbst gesetzte nicht. + ========================================================================== */ + +let kachelModell = null; // { kacheln, symbole, topics, max, tabelleDa } + +/* + * Der Katalog ist die Liste der Messwerte, die der Geraetesuchlauf kennt - + * dieselbe, aus der der Automatik-Editor seine Bedingungen baut. Ein Wert + * daraus bringt Topic, JSON-Pfad und Einheit mit; von Hand einzutippen ist + * dann nichts mehr. + * + * Gespeichert wird trotzdem nur Topic und Pfad. Der Katalog ist eine + * Eingabehilfe und keine zweite Wahrheit: verschwindet ein Geraet aus dem + * Suchlauf, zeigt die Kachel weiter, was sie zeigt. + */ + +/** Der Katalogeintrag zu einem Wert - oder null, wenn er dort nicht steht. */ +function katalogTreffer(wert) { + if (!kachelModell || !kachelModell.katalog || wert.wechselrichter) return null; + const pfad = wert.pfad || ""; + for (let gi = 0; gi < kachelModell.katalog.length; gi++) { + const werte = kachelModell.katalog[gi].werte; + for (let wi = 0; wi < werte.length; wi++) { + if (werte[wi].topic === wert.topic && (werte[wi].pfad || "") === pfad) { + return { gi: gi, wi: wi, geraet: kachelModell.katalog[gi], wert: werte[wi] }; + } + } + } + return null; +} + +/** + * Welche Eingabeart eine Zeile zeigt. + * + * Beim ersten Zeichnen geraten: steht der Wert im Katalog, ist es eine + * Auswahl. Sobald der Benutzer die Art umstellt, gilt seine Wahl (_quelle) - + * sonst spraenge die Zeile beim naechsten Neuzeichnen zurueck. + */ +function kachelQuelle(wert) { + if (wert._quelle) return wert._quelle; + if (wert.wechselrichter) return "wr"; + return katalogTreffer(wert) ? "sensor" : "topic"; +} + +/** + * Kurzform eines Wertes fuer die Zeile: das Wesentliche, nicht alles. + * + * Der Geraetename steht nur dabei, wenn das Geraet in einem anderen Raum haengt + * als die Kachel - im eigenen Raum wiederholte er nur, wo man ohnehin steht, + * und aus drei Kuerzeln wuerden drei Zeilen. + */ +function kachelWertKurz(wert, kachel) { + if (wert.wechselrichter) { + const n = wert.wechselrichter.length; + return "Wechselrichter (" + n + ")"; + } + const treffer = katalogTreffer(wert); + const einheit = wert.format === "leistung" ? "W/kW" : (wert.einheit || ""); + if (treffer) { + const fremd = !kachel || treffer.geraet.raum !== kachel.name; + return (fremd ? treffer.geraet.label + " · " : "") + treffer.wert.name + + (einheit ? " " + einheit : ""); + } + const topic = wert.topic || ""; + const teile = topic.split("/"); + let text = teile[teile.length - 1] || topic; + if (wert.pfad) text += " · " + wert.pfad; + return einheit ? text + " " + einheit : text; +} + +/** Eine Kachel im Modell finden. */ +function kachelSuchen(floor, room) { + return (kachelModell.kacheln || []).find(k => k.floor === floor && k.room === room); +} + +/* --- Zeichnen ------------------------------------------------------------ */ + +function zeichneKacheln() { + const ziel = document.getElementById("kachelListe"); + if (!ziel || !kachelModell) return; + + if (!kachelModell.tabelleDa) { + ziel.innerHTML = '
Die Tabelle ' + + 'kachelWerte fehlt noch. Sie steht in ' + + 'solarLog_kacheln.sql – bis dahin zeigen alle Kacheln ' + + 'ihre Vorgabe, und Speichern ist nicht möglich.
'; + return; + } + + // Vorschlagsliste einmal fuer alle Topic-Felder. + let html = '' + + (kachelModell.topics || []).map(t => '"; + + let etage = null; + (kachelModell.kacheln || []).forEach(function (k) { + if (k.floor !== etage) { + if (etage !== null) html += ""; + etage = k.floor; + html += '
' + + textSchuetzen(k.floorLabel) + "
"; + } + html += kachelZeile(k); + }); + if (etage !== null) html += "
"; + + ziel.innerHTML = html; +} + +/** Eine Raumzeile: Name, Kurzfassung der Werte, darunter der Editor. */ +function kachelZeile(k) { + const werte = k._entwurf || k.werte; + const kurz = werte.length + ? werte.map(w => '' + textSchuetzen(kachelWertKurz(w, k)) + + "").join("") + : 'ohne Messwert'; + + let html = '
' + + '"; + + if (k._offen) { + html += '
' + + werte.map((w, i) => kachelWertZeile(w, i, k)).join(""); + if (werte.length < (kachelModell.max || 3)) { + html += ''; + } + html += '
' + + '' + + '" + + '' + + "
"; + } + return html + "
"; +} + +/** + * Eine Wertdefinition als Formularzeile. + * + * Die Eingabeart entscheidet, welche Felder sichtbar sind: eine Auswahl aus + * dem Katalog braucht kein Topic, ein Wechselrichter keinen JSON-Pfad. Alles + * gleichzeitig anzubieten waere eine Einladung, es auch auszufuellen - + * gelesen wird immer nur eines davon. + */ +function kachelWertZeile(wert, i, kachel) { + const quelle = kachelQuelle(wert); + const katalog = (kachelModell && kachelModell.katalog) || []; + const symbole = kachelModell.symbole || {}; + const symbolOptionen = Object.keys(symbole).map(function (key) { + return '"; + }).join(""); + + // Eingabeart. "Messwert" steht nur zur Wahl, wenn es einen Katalog gibt - + // ohne Mesh-Datenbank waere es eine leere Liste. + let felder = '
' + + '
"; + + if (quelle === "sensor") { + const treffer = katalogTreffer(wert); + // Ohne Treffer beim ersten Geraet des eigenen Raums beginnen, sonst beim + // ersten ueberhaupt - ein neuer Wert soll nicht im Nachbargeschoss starten. + const ersterEigener = katalog.findIndex( + (g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name); + const gi = treffer ? treffer.gi : Math.max(0, ersterEigener); + const wi = treffer ? treffer.wi : -1; + + /* + * Die Geraete des eigenen Raums stehen oben in einer eigenen Gruppe - das + * ist fast immer das Gesuchte. Der Rest folgt nach Etagen, denn manche + * Kachel zeigt bewusst etwas Fremdes: der Carport die Wallbox, die + * Technik den Netzanschluss. Ganz wegzulassen waere deshalb zu streng. + * + * Der Wert einer Option bleibt der Platz im vollstaendigen Katalog, nicht + * in der gefilterten Liste - sonst zeigte die Auswahl nach dem Umsortieren + * auf ein anderes Geraet. + */ + const eigen = (g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name; + const option = (g, index) => '"; + + let geraete = ""; + const eigene = katalog.map((g, i2) => [g, i2]).filter(([g]) => eigen(g)); + if (eigene.length) { + geraete += '' + + eigene.map(([g, i2]) => option(g, i2)).join("") + ""; + } + let etage = null; + katalog.forEach(function (g, index) { + if (eigen(g)) return; + if (g.floor !== etage) { + if (etage !== null) geraete += ""; + etage = g.floor; + geraete += ''; + } + geraete += option(g, index); + }); + if (etage !== null) geraete += ""; + + const messwerte = (katalog[gi] || { werte: [] }).werte.map(function (w, index) { + // Der aktuelle Wert steht dabei: er beantwortet, ob man den richtigen + // Messwert erwischt hat, ohne die Kachel erst speichern zu muessen. + const jetzt = (w.wert === null || w.wert === undefined || w.wert === "") + ? "" : " · " + w.wert + (w.einheit ? " " + w.einheit : ""); + return '"; + }).join(""); + + felder += '
" + + '
"; + } else if (quelle === "wr") { + felder += '
' + + '
'; + } else { + felder += '
' + + '
'; + } + + return '
' + felder + + + '
' + + + '
' + + + '
" + + + '
' + + '" + + '" + + '' + + "
"; +} + +/** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */ +function textSchuetzen(s) { + const d = document.createElement("div"); + d.textContent = String(s === undefined || s === null ? "" : s); + return d.innerHTML.replace(/"/g, """); +} + +/* --- Bedienung ----------------------------------------------------------- */ + +function kachelHinweis(zeile, text, klasse) { + const feld = zeile.querySelector(".kachel-hinweis"); + if (feld) { + feld.textContent = text; + feld.className = "kachel-hinweis small " + (klasse || "text-secondary"); + } +} + +/** Aus den Feldern einer Zeile die Wertdefinition zusammensetzen. */ +function kachelWertLesen(feldZeile) { + const hol = (name) => { + const el = feldZeile.querySelector('[data-feld="' + name + '"]'); + if (!el) return undefined; + return el.type === "checkbox" ? el.checked : el.value; + }; + const wert = {}; + const quelle = hol("quelle") || "topic"; + wert._quelle = quelle; // die Wahl des Benutzers, nicht gespeichert + + if (quelle === "wr") { + wert.wechselrichter = String(hol("wechselrichter") || "") + .split(",").map(t => t.trim()).filter(Boolean); + wert.feld = hol("feld") || "p_AC"; + } else if (quelle === "sensor") { + // Topic und Pfad kommen aus dem Katalog, nicht aus einem Textfeld. + const g = ((kachelModell.katalog || [])[parseInt(hol("geraet"), 10)] || { werte: [] }); + const w = g.werte[parseInt(hol("messwert"), 10)]; + if (w) { + wert.topic = w.topic; + if (w.pfad) wert.pfad = w.pfad; + } + } else { + wert.topic = (hol("topic") || "").trim(); + const pfad = (hol("pfad") || "").trim(); + if (pfad) wert.pfad = pfad; + } + if (hol("format")) { + wert.format = "leistung"; + } else { + const einheit = (hol("einheit") || "").trim(); + if (einheit) wert.einheit = einheit; + const stellen = hol("stellen"); + if (stellen !== "" && stellen !== undefined) wert.stellen = parseInt(stellen, 10); + } + const icon = hol("icon"); + if (icon) wert.icon = icon; + if (hol("gross")) wert.gross = true; + return wert; +} + +/** Den Entwurf einer Kachel aus allen sichtbaren Feldern nachziehen. */ +function kachelEntwurfLesen(zeile, k) { + k._entwurf = Array.from(zeile.querySelectorAll(".kachel-wert")).map(kachelWertLesen); +} + +async function kachelSenden(zeile, k, werte) { + kachelHinweis(zeile, "Wird gespeichert …", "text-secondary"); + try { + const antwort = await fetch("ajax/settings.php?action=kacheln-save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ floor: k.floor, room: k.room, werte: werte }), + }); + const d = await antwort.json(); + if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); + k.werte = d.werte; + k.eigen = d.eigen; + delete k._entwurf; + zeichneKacheln(); + // Nach dem Neuzeichnen ist die Zeile eine andere - der Hinweis gehoert + // an die neue. + const neu = document.querySelector('.kachel-zeile[data-floor="' + k.floor + + '"][data-room="' + k.room + '"]'); + if (neu && k._offen) kachelHinweis(neu, "Gespeichert.", "text-success"); + } catch (e) { + kachelHinweis(zeile, e.message, "text-danger"); + } +} + +async function kachelnHolen() { + const ziel = document.getElementById("kachelListe"); + if (!ziel) return; + try { + const antwort = await fetch("ajax/settings.php?action=kacheln", { cache: "no-store" }); + const d = await antwort.json(); + if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); + kachelModell = d; + zeichneKacheln(); + } catch (e) { + ziel.innerHTML = '
Kacheln nicht abrufbar: ' + + textSchuetzen(e.message) + "
"; + } +} + +function kachelnBinden() { + const liste = document.getElementById("kachelListe"); + if (!liste) return; + + liste.addEventListener("click", function (ereignis) { + const zeile = ereignis.target.closest(".kachel-zeile"); + if (!zeile || !kachelModell) return; + const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room); + if (!k) return; + + if (ereignis.target.closest(".kachel-kopf")) { + // Nur eine Kachel gleichzeitig offen: zwei Editoren nebeneinander + // fuellen den Bildschirm, und die Zeile darunter waere nicht mehr zu + // sehen. Ein Wechsel verwirft ungespeicherte Aenderungen nicht - der + // Entwurf bleibt am Modell haengen. + const wechsel = !k._offen; + kachelModell.kacheln.forEach(a => { a._offen = false; }); + k._offen = wechsel; + if (wechsel && !k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte)); + zeichneKacheln(); + return; + } + if (ereignis.target.closest(".kachel-plus")) { + kachelEntwurfLesen(zeile, k); + k._entwurf.push((kachelModell.katalog || []).length + ? { _quelle: "sensor", einheit: "" } // erst waehlen, dann feilen + : { topic: "", einheit: "" }); + zeichneKacheln(); + return; + } + if (ereignis.target.closest(".kachel-weg")) { + kachelEntwurfLesen(zeile, k); + k._entwurf.splice(parseInt(ereignis.target.closest(".kachel-wert").dataset.i, 10), 1); + zeichneKacheln(); + return; + } + if (ereignis.target.closest(".kachel-speichern")) { + kachelEntwurfLesen(zeile, k); + kachelSenden(zeile, k, k._entwurf); + return; + } + if (ereignis.target.closest(".kachel-standard")) { + kachelSenden(zeile, k, null); + } + }); + + // Die Quelle und das Leistungsformat blenden Felder um - dafuer muss neu + // gezeichnet werden. Alles andere schreibt nur in den Entwurf. + liste.addEventListener("change", function (ereignis) { + const feld = ereignis.target.closest("[data-feld]"); + const zeile = ereignis.target.closest(".kachel-zeile"); + if (!feld || !zeile || !kachelModell) return; + const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room); + if (!k) return; + const name = feld.dataset.feld; + + kachelEntwurfLesen(zeile, k); + + /* + * Ein neu gewaehlter Messwert bringt seine Einheit mit - sonst stuende an + * einer Temperatur weiter "W". Beim Wechsel des Geraets faellt die alte + * Auswahl weg, dann ist erst einmal keiner gewaehlt. + */ + if (name === "geraet" || name === "messwert") { + const i = parseInt(feld.closest(".kachel-wert").dataset.i, 10); + const wert = k._entwurf[i]; + const treffer = wert && katalogTreffer(wert); + if (treffer) { + wert.einheit = treffer.wert.einheit; + } else if (name === "geraet") { + delete wert.topic; + delete wert.pfad; + } + } + + // Diese drei blenden Felder um; alles andere schreibt nur in den Entwurf. + if (name === "quelle" || name === "format" || name === "geraet" || name === "messwert") { + zeichneKacheln(); + } + }); + + const neuLaden = document.getElementById("kachelNeuLaden"); + if (neuLaden) neuLaden.addEventListener("click", kachelnHolen); +} + +document.addEventListener("DOMContentLoaded", function () { + // Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer + // jede Leiste der Seite, nicht nur fuer diese. + kachelnBinden(); + kachelnHolen(); +}); + + +/* ========================================================================== + Reiter "Geräte": Raumzuordnung, Karteileichen. + + Die Zuordnung war ein Modal auf der Startseite - erreichbar über ein + Zahnradsymbol neben den Automatismen, also genau dort, wo man sie nicht + sucht. Sie ist eine Einstellung und steht deshalb jetzt hier, zusammen mit + dem, was ohne sie fehlte: seit wann ein Gerät nichts mehr meldet, in + welchen Automatiken es steckt und der Knopf, mit dem ein abgebautes Gerät + verschwindet. + + Letzterer ist nötig, weil der Suchlauf nichts löscht - er legt an und + aktualisiert, damit die Automatiken ihre Zustands-Ids behalten. Ein Gerät, + das aus dem Haus fliegt, bleibt also stehen, bis es hier jemand entfernt. + ========================================================================== */ + +let geraeteModell = null; // { geraete, raeume } +const geraeteSieb = { text: "", nurOffen: false }; + +/* --- Ableitungen --------------------------------------------------------- */ + +/** + * Wie alt die jüngste Meldung eines Geräts ist, in Worten. + * + * "alt" heißt: lange genug her, um aufzufallen. Ein Thermostat meldet + * stündlich - drei Tage Stille sind schon viel; eine Jalousie meldet nur, + * wenn sie fährt. Deshalb faellt nur auf, was über einen Monat still ist. + * + * Geräte ganz ohne Messwerte - die Somfy-Screens etwa, die nur Befehle + * entgegennehmen - haben nichts zu melden. Bei ihnen steht ein Strich und + * keine Warnung: sonst stünde die halbe Spalte in Alarmfarbe, ohne dass + * irgendetwas fehlt. + */ +function geraetAlter(g) { + const zeitpunkt = g.gesehen; + if (!g.messwerte) return { text: "–", alt: false }; + if (!zeitpunkt) return { text: "nie", alt: true }; + const d = new Date(String(zeitpunkt).replace(" ", "T")); + if (isNaN(d.getTime())) return { text: "–", alt: false }; + const tage = Math.floor((Date.now() - d.getTime()) / 86400000); + if (tage <= 0) return { text: "heute", alt: false }; + if (tage === 1) return { text: "gestern", alt: false }; + if (tage < 31) return { text: "vor " + tage + " Tagen", alt: tage > 30 }; + const monate = Math.round(tage / 30); + return { text: "vor " + monate + " Monaten", alt: true }; +} + +/** Ein Gerät im Modell finden. */ +function geraetSuchen(id) { + return (geraeteModell.geraete || []).find(g => g.id === id); +} + +/** Was das Sieb übrig lässt. */ +function geraeteGefiltert() { + const suche = geraeteSieb.text.trim().toLowerCase(); + return (geraeteModell.geraete || []).filter(function (g) { + if (geraeteSieb.nurOffen && g.room) return false; + if (!suche) return true; + return (g.label + " " + g.type + " " + (g.roomLabel || "")).toLowerCase().indexOf(suche) >= 0; + }); +} + +/* --- Zeichnen ------------------------------------------------------------ */ + +function zeichneGeraete() { + const ziel = document.getElementById("geraeteListe"); + if (!ziel || !geraeteModell) return; + + const alle = geraeteModell.geraete || []; + const sichtbar = geraeteGefiltert(); + const ohneRaum = alle.filter(g => !g.room).length; + const tipps = alle.filter(g => !g.room && g.tipp).length; + + let html = '

Die Zuordnung ordnet die Geräteliste ' + + 'im Automatik-Editor und die Auswahl der Kachelwerte nach Räumen. Ein neuer ' + + 'Suchlauf legt Geräte an und frischt sie auf, löscht aber keine – ' + + 'abgebaute Geräte verschwinden nur hier.

'; + + // Sieb: bei über sechzig Geräten ist Scrollen keine Suche. + html += '
' + + '' + + '
' + + '" + + '' + + "
"; + + if (tipps) { + // Viele Geräte tragen ihren Raum im Topic (Raumtemp/OG/Bad/Temp[degC]). + // Das von Hand nachzuklicken ist stumpfe Arbeit für den Rechner. + html += '
' + tipps + " Gerät(e) ohne Raum verraten ihn" + + ' im MQTT-Topic.
'; + } + + html += '
' + + '' + + "" + + '' + + "" + + '' + + '' + + '' + + ""; + + html += sichtbar.length + ? sichtbar.map(g => geraetZeile(g)).join("") + : ''; + + html += "
GerätArtRaumGemeldetAutomatiken
Nichts gefunden.
" + + '
' + + alle.length + " Geräte, " + ohneRaum + " ohne Raum" + + (sichtbar.length !== alle.length ? " · " + sichtbar.length + " angezeigt" : "") + + '
'; + + ziel.innerHTML = html; +} + +/** Eine Zeile der Tafel. */ +function geraetZeile(g) { + const alter = geraetAlter(g); + const benutzt = g.benutzt || []; + + // Die Raumauswahl nach Etagen gruppiert - dieselbe Reihenfolge wie überall, + // sie kommt schon sortiert aus raumListe(). + let auswahl = '"; + let etage = null; + (geraeteModell.raeume || []).forEach(function (rm) { + if (rm.floor !== etage) { + if (etage !== null) auswahl += ""; + etage = rm.floor; + auswahl += ''; + } + auswahl += '"; + }); + if (etage !== null) auswahl += ""; + + // Der Papierkorb steht sechzigmal untereinander - in Rot waere die Tafel + // eine Warnleuchte. Er faerbt sich erst, wenn man ihn anfasst. + const weg = benutzt.length + ? '' + : ''; + + return '" + + '' + textSchuetzen(g.label) + /* + * Unter der Beschriftung steht genau eine Zusatzzeile - schmal wird die + * Art dort gezeigt, weil ihre Spalte weg ist; breit die Anzahl der + * Messwerte und Kommandos, die vor dem Loeschen interessiert. Nie beides: + * auf dem Handy braeuchte das drei Zeilen und erschluege den Namen. + */ + + '' + + '' + textSchuetzen(g.type) + "" + + '' + g.messwerte + " Messwerte · " + + g.kommandos + " Kommandos" + + '' + + textSchuetzen(g.type) + "" + + '" + + '' + + alter.text + "" + + '' + + (benutzt.length + ? '' + + benutzt.length + "" + : '') + + "" + + '' + weg + ""; +} + +/** Kurze Rückmeldung im Fuß der Tafel; sie verblasst von selbst. */ +function geraeteStand(text, klasse) { + const ziel = document.querySelector("#geraeteListe .geraete-stand"); + if (!ziel) return; + ziel.className = "geraete-stand " + (klasse || "text-body-secondary"); + ziel.textContent = text; + clearTimeout(geraeteStand._uhr); + if (klasse !== "text-danger") { + geraeteStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000); + } +} + +/* --- Sprechen mit dem Endpunkt ------------------------------------------- */ + +async function geraeteFrage(action, koerper) { + const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(koerper), + } : { cache: "no-store" }); + const d = await antwort.json(); + if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); + return d; +} + +/** + * Eine oder mehrere Zuordnungen speichern. + * + * Gespeichert wird sofort beim Umstellen und ohne die Tafel neu zu zeichnen: + * wer sich durch sechzig Geräte arbeitet, will nicht nach jedem Klick seinen + * Platz in der Liste wiederfinden. Nur die Zeilenfarbe wandert mit. + */ +async function geraeteRaeumeSenden(zuordnung) { + const ids = Object.keys(zuordnung); + try { + await geraeteFrage("geraete-raeume", { rooms: zuordnung }); + ids.forEach(function (id) { + const g = geraetSuchen(parseInt(id, 10)); + if (g) { + g.room = zuordnung[id] || null; + const raum = (geraeteModell.raeume || []).find(r => r.key === g.room); + g.roomLabel = raum ? raum.label : null; + } + const zeile = document.querySelector('#geraeteListe tr[data-id="' + id + '"]'); + if (zeile) zeile.classList.toggle("ohne-raum", !zuordnung[id]); + }); + geraeteStand(ids.length > 1 ? ids.length + " Zuordnungen gespeichert." : "Gespeichert.", + "text-success"); + } catch (e) { + geraeteStand(e.message, "text-danger"); + zeichneGeraete(); // Anzeige zurück auf das, was wirklich gilt + } +} + +/** Ein Gerät entfernen - endgültig, deshalb mit Nachfrage. */ +async function geraetLoeschenFragen(id) { + const g = geraetSuchen(id); + if (!g) return; + if (!confirm("Gerät \"" + g.label + "\" mit " + g.messwerte + " Messwerten und " + + g.kommandos + " Kommandos wirklich löschen?\n\n" + + "Ein neuer Suchlauf legt es wieder an, wenn es noch antwortet.")) { + return; + } + try { + const d = await geraeteFrage("geraet-loeschen", { id: id }); + geraeteModell.geraete = d.geraete; + zeichneGeraete(); + geraeteStand("„" + d.geloescht + "“ entfernt.", "text-success"); + } catch (e) { + geraeteStand(e.message, "text-danger"); + } +} + +async function geraeteHolen() { + const ziel = document.getElementById("geraeteListe"); + if (!ziel) return; + try { + geraeteModell = await geraeteFrage("geraete"); + zeichneGeraete(); + } catch (e) { + ziel.innerHTML = '
Geräte nicht abrufbar: ' + + textSchuetzen(e.message) + "
"; + } +} + +/* --- Bedienung ----------------------------------------------------------- */ + +function geraeteBinden() { + const liste = document.getElementById("geraeteListe"); + if (!liste) return; + + liste.addEventListener("change", function (ereignis) { + if (ereignis.target.id === "geraeteNurOffen") { + geraeteSieb.nurOffen = ereignis.target.checked; + zeichneGeraete(); + // Der Schalter ist nach dem Neuzeichnen ein anderes Element - ohne das + // hier verlöre die Tastatur nach dem Umlegen ihren Platz. + const schalter = document.getElementById("geraeteNurOffen"); + if (schalter) schalter.focus(); + return; + } + const wahl = ereignis.target.closest("select[data-actor]"); + if (wahl) { + const zuordnung = {}; + zuordnung[wahl.dataset.actor] = wahl.value; + geraeteRaeumeSenden(zuordnung); + } + }); + + liste.addEventListener("input", function (ereignis) { + if (ereignis.target.id !== "geraeteSuche") return; + geraeteSieb.text = ereignis.target.value; + zeichneGeraete(); + // Nach dem Neuzeichnen ist das Feld ein anderes - der Cursor gehört zurück. + const feld = document.getElementById("geraeteSuche"); + if (feld) { + feld.focus(); + feld.setSelectionRange(feld.value.length, feld.value.length); + } + }); + + liste.addEventListener("click", function (ereignis) { + if (ereignis.target.closest("#geraeteTipps")) { + // Alle offenen Vorschläge auf einmal - eine Anfrage, ein Neuzeichnen. + const zuordnung = {}; + liste.querySelectorAll("select[data-tipp]").forEach(function (s) { + if (!s.value) { + s.value = s.dataset.tipp; + zuordnung[s.dataset.actor] = s.value; + } + }); + if (Object.keys(zuordnung).length) { + geraeteRaeumeSenden(zuordnung).then(zeichneGeraete); + } + return; + } + const weg = ereignis.target.closest(".geraet-weg"); + if (weg) { + geraetLoeschenFragen(parseInt(weg.closest("tr").dataset.id, 10)); + } + }); + + const neuLaden = document.getElementById("geraeteNeuLaden"); + if (neuLaden) neuLaden.addEventListener("click", geraeteHolen); +} + +document.addEventListener("DOMContentLoaded", function () { + geraeteBinden(); + geraeteHolen(); +}); diff --git a/restricted/automations.php b/restricted/automations.php index 2b389c7..107716c 100644 --- a/restricted/automations.php +++ b/restricted/automations.php @@ -316,17 +316,6 @@ function raumVorschlaege() 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) { @@ -352,6 +341,161 @@ function saveRooms($zuordnung) return $anzahl; } +/** + * Die Geraeteliste fuer die Einstellungsseite. + * + * Enthaelt alles, was man ueber ein Geraet wissen will, ohne es anzuklicken: + * Beschriftung, Art, Raum, ein Vorschlag fuer den Raum (aus dem MQTT-Topic), + * wie viele Messwerte und Kommandos daran haengen, wann zuletzt etwas davon + * gemeldet wurde - und in welchen Automatiken es steckt. + * + * Das Letzte ist der Grund, warum die Liste hier steht und nicht in der Maske: + * ein Geraet, auf das eine Automatik zeigt, darf nicht verschwinden, und wer + * das entscheidet, muss die Verweise kennen. + */ +function geraeteListe() +{ + $db = meshDb(); + $labels = geraeteBeschriftungen(); + $tipps = raumVorschlaege(); + + $geraete = []; + $res = $db->query("SELECT id, name, type, url, room FROM actors"); + while ($row = $res->fetch_assoc()) { + $id = intval($row["id"]); + $wo = raumEinordnung($row["room"]); + $geraete[$id] = [ + "id" => $id, + "name" => $row["name"], + "label" => $labels[$id] ?? $row["name"], + "type" => $row["type"], + "url" => $row["url"], + "room" => $row["room"], + "roomLabel" => raumBeschriftung($row["room"]), + "floor" => $wo["floor"], + "tipp" => $tipps[$id] ?? null, + "messwerte" => 0, + "kommandos" => 0, + "gesehen" => null, + "benutzt" => [], + ]; + } + + // Messwerte: Anzahl und juengste Meldung. Letztere sagt, ob ein Geraet + // noch lebt - ein Eintrag von vor einem halben Jahr ist eine Karteileiche. + $res = $db->query("SELECT actor_id, COUNT(*) AS n, MAX(last_updated) AS gesehen + FROM actor_states GROUP BY actor_id"); + while ($row = $res->fetch_assoc()) { + $id = intval($row["actor_id"]); + if (isset($geraete[$id])) { + $geraete[$id]["messwerte"] = intval($row["n"]); + $geraete[$id]["gesehen"] = $row["gesehen"]; + } + } + + $res = $db->query("SELECT actor_id, COUNT(*) AS n FROM actor_commands GROUP BY actor_id"); + while ($row = $res->fetch_assoc()) { + $id = intval($row["actor_id"]); + if (isset($geraete[$id])) { + $geraete[$id]["kommandos"] = intval($row["n"]); + } + } + + // Verweise aus den Automatiken - ueber Messwerte (Bedingungen) und ueber + // Kommandos (Aktionen). + $sql = "SELECT s.actor_id AS actor_id, a.id AS aid, a.name AS aname + FROM automation_conditions c + JOIN actor_states s ON s.id = c.state_id + JOIN automations a ON a.id = c.automation_id + UNION + SELECT k.actor_id AS actor_id, a.id AS aid, a.name AS aname + FROM automation_actions x + JOIN actor_commands k ON k.id = x.command_id + JOIN automations a ON a.id = x.automation_id"; + $res = $db->query($sql); + while ($res && $row = $res->fetch_assoc()) { + $id = intval($row["actor_id"]); + if (isset($geraete[$id])) { + $geraete[$id]["benutzt"][intval($row["aid"])] = $row["aname"]; + } + } + foreach ($geraete as $id => $g) { + $geraete[$id]["benutzt"] = array_values($g["benutzt"]); + } + + // Nach Etage, Raum und Beschriftung - dieselbe Ordnung wie ueberall. + $reihenfolge = array_flip($GLOBALS["floors"]); + $liste = array_values($geraete); + usort($liste, function ($a, $b) use ($reihenfolge) { + $wa = explode("/", (string)$a["room"], 2); + $wb = explode("/", (string)$b["room"], 2); + // Geraete ohne Raum nach oben: sie sind das, was Arbeit macht. + $ra = ($a["room"] === null || $a["room"] === "") ? -1 : ($reihenfolge[$wa[0]] ?? 98); + $rb = ($b["room"] === null || $b["room"] === "") ? -1 : ($reihenfolge[$wb[0]] ?? 98); + if ($ra !== $rb) { + return $ra <=> $rb; + } + $raum = strcoll($wa[1] ?? "", $wb[1] ?? ""); + return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]); + }); + return $liste; +} + +/** + * Ein Geraet samt Messwerten, Kommandos und Parametern loeschen. + * + * Gedacht fuer Karteileichen: der Suchlauf loescht nichts (clear_tables steht + * bewusst auf false, weil die Automatiken auf die Ids zeigen), also bleibt ein + * abgebautes Geraet fuer immer in der Liste stehen. + * + * Zeigt eine Automatik darauf, wird nicht geloescht, sondern abgelehnt - mit + * den Namen der Automatiken. Sie stillschweigend mitzuloeschen waere ein + * stiller Datenverlust, sie kaputt zurueckzulassen ebenfalls. Wer das Geraet + * wirklich loswerden will, raeumt erst die Automatik auf. + * + * Steht das Geraet noch im Netz, findet der naechste Suchlauf es wieder - das + * ist kein Fehler, sondern der Zweck des Suchlaufs. + */ +function geraetLoeschen($id) +{ + $id = intval($id); + if ($id <= 0) { + throw new InvalidArgumentException("Kein Gerät angegeben."); + } + $db = meshDb(); + + $res = $db->query("SELECT name FROM actors WHERE id = " . $id); + $geraet = $res ? $res->fetch_assoc() : null; + if (!$geraet) { + throw new InvalidArgumentException("Dieses Gerät gibt es nicht (mehr)."); + } + + foreach (geraeteListe() as $g) { + if ($g["id"] === $id && $g["benutzt"]) { + throw new InvalidArgumentException( + "Das Gerät steckt in " . count($g["benutzt"]) . " Automatik(en): " + . implode(", ", $g["benutzt"]) . ". Erst dort entfernen."); + } + } + + $db->begin_transaction(); + try { + // Von innen nach aussen: Parameter haengen an Kommandos, Kommandos und + // Messwerte am Geraet. + $db->query("DELETE p FROM command_parameters p + JOIN actor_commands k ON k.id = p.command_id + WHERE k.actor_id = " . $id); + $db->query("DELETE FROM actor_commands WHERE actor_id = " . $id); + $db->query("DELETE FROM actor_states WHERE actor_id = " . $id); + $db->query("DELETE FROM actors WHERE id = " . $id); + $db->commit(); + } catch (Throwable $e) { + $db->rollback(); + throw $e; + } + return $geraet["name"]; +} + /** * id => Beschriftung fuer alle Geraete, geputzt und eindeutig. * @@ -425,8 +569,14 @@ function deviceCatalog() ]; } + /* + * value_path kommt mit, seit die Einstellungsseite denselben Katalog fuer + * die Home-Kacheln benutzt: bei Geraeten, die ihre Werte als JSON in einer + * einzigen Nachricht schicken (der Regensensor etwa), unterscheidet erst + * dieser Schluessel die sechs Messwerte unter demselben Topic. + */ $res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value, - s.url, s.possible_values, t.type + s.url, s.value_path, s.possible_values, t.type FROM actor_states s LEFT JOIN state_types t ON s.state_type = t.id ORDER BY s.actor_id, s.id"); @@ -447,6 +597,8 @@ function deviceCatalog() "unit" => $row["unit"], "value" => $row["current_value"], "url" => $row["url"], + // Schluessel in einer JSON-Nachricht, sonst leer - siehe oben. + "pfad" => $row["value_path"], ]; } diff --git a/restricted/home.php b/restricted/home.php index dd0ea94..a8c7fd2 100644 --- a/restricted/home.php +++ b/restricted/home.php @@ -1,4 +1,7 @@ - +
@@ -185,12 +188,6 @@

Automatismen

- - @@ -198,28 +195,35 @@
- + $etage, "titel" => $etage, + "titel2" => floorLabel($etage)]; + }, $floors); + $autoOpt = ["name" => "actions", "aktiv" => $_GET["floor"], + "klasse" => "mb-2"]; + $autoOpt["aktiv"] = reiterAktiv($autoReiter, $autoOpt); +?> +
-
" - id="actions-" role="tabpanel" aria-labelledby="actions--tab"> +
Wird geladen...
-
+
diff --git a/restricted/kacheln.php b/restricted/kacheln.php new file mode 100644 index 0000000..e013db4 --- /dev/null +++ b/restricted/kacheln.php @@ -0,0 +1,339 @@ + "ohne", + "ev-station" => "Ladesäule", + "cloud-rain" => "Regen", + "sun" => "Sonne", + ]; +} + +/** + * Alle Kacheln mit ihrem aktuellen Stand, für die Einstellungsseite. + * + * Je Kachel steht dabei, ob die Werte die Vorgabe sind oder von Hand gesetzt - + * die Maske zeigt "Standard wiederherstellen" nur dort, wo es etwas + * herzustellen gibt. + */ +function kachelListe() +{ + $ueber = kachelUeberschreibungen(); + $liste = []; + foreach (allRooms() as $room) { + if (!isset($room["x"])) { + continue; // kein Platz im Grundriss, keine Kachel + } + $key = $room["floor"] . "|" . $room["id"]; + $liste[] = [ + "floor" => $room["floor"], + "floorLabel" => floorLabel($room["floor"]), + "room" => $room["id"], + "name" => $room["mqtt"], + "eigen" => isset($ueber[$key]), + "werte" => $ueber[$key] ?? kachelVorgabe($room), + "vorgabe" => kachelVorgabe($room), + ]; + } + return $liste; +} + +/** + * Die Messwerte, die sich auswählen lassen - nach Gerät gruppiert. + * + * Quelle ist derselbe Katalog, aus dem der Automatik-Editor seine Bedingungen + * baut (deviceCatalog() in restricted/automations.php). Damit steht in beiden + * Masken dasselbe zur Auswahl, und ein neu erkanntes Gerät taucht ohne Zutun + * auch hier auf. + * + * Nicht alles daraus taugt für eine Kachel: die Tahoma-Geräte melden ihre + * Zustände über die Box, nicht über den Broker (ihre "url" ist dann ein + * Zustandsname wie core:OpenClosedState). Eine Kachel liest aber ausschließlich + * MQTT - solche Werte blieben ewig auf "–" stehen und sind deshalb hier nicht + * dabei. Übrig bleibt, was ein Topic hat. + * + * Der Katalog darf fehlen: ohne Mesh-Datenbank gibt es eben keine Auswahl, + * und die Maske fällt auf die Eingabe von Hand zurück. + */ +function kachelKatalog() +{ + $datei = __DIR__ . "/automations.php"; + if (!file_exists($datei)) { + return []; + } + require_once($datei); + + try { + $katalog = deviceCatalog(); + } catch (Throwable $e) { + return []; + } + + $geraete = []; + foreach ($katalog as $g) { + $werte = []; + foreach ($g["states"] as $s) { + $topic = (string)($s["url"] ?? ""); + // Ein Topic hat Schrägstriche und kein Schema davor; "core:..." + // und "io://..." kommen von der Tahoma-Box. + if ($topic === "" || strpos($topic, "/") === false + || preg_match('/^[a-z]+:/', $topic)) { + continue; + } + $werte[] = [ + "name" => $s["name"], + "einheit" => (string)($s["unit"] ?? ""), + "topic" => $topic, + "pfad" => (string)($s["pfad"] ?? ""), + "wert" => $s["value"], + ]; + } + if (!$werte) { + continue; + } + /* + * Etage und Raum auch als Kuerzel, nicht nur ausgeschrieben: die + * Maske stellt die Geraete des eigenen Raums nach oben und muss dafuer + * genau vergleichen koennen. actors.room steht als "EG/Technik" da - + * dieselben Kuerzel, die auch rooms.php benutzt. + */ + $wo = explode("/", (string)($g["room"] ?? ""), 2); + $geraete[] = [ + "label" => $g["label"], + "floor" => $g["floorLabel"], + "floorKey" => $wo[0] ?? "", + "raum" => $g["roomName"], + "raumKey" => $wo[1] ?? "", + "werte" => $werte, + ]; + } + + /* + * Nach Etage sortieren, darin nach Raum und Geraet. deviceCatalog() + * liefert alphabetisch nach Geraetenamen - in einer nach Etagen + * gruppierten Liste stuende dieselbe Etage dann mehrfach da, jedes Mal + * mit ein, zwei Geraeten darin. + */ + $reihenfolge = array_flip($GLOBALS["floors"]); + usort($geraete, function ($a, $b) use ($reihenfolge) { + $fa = $reihenfolge[$a["floorKey"]] ?? 99; + $fb = $reihenfolge[$b["floorKey"]] ?? 99; + if ($fa !== $fb) { + return $fa <=> $fb; + } + $raum = strcoll($a["raum"], $b["raum"]); + return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]); + }); + return $geraete; +} + +/** + * Topics für die Vorschlagsliste der Eingabe von Hand. + * + * Alles aus dem Katalog plus das, was die Kacheln heute zeigen - denn nicht + * jedes Topic am Broker ist irgendwo registriert. Die beiden Shelly-Zähler in + * den Technikräumen etwa stehen in keinem Katalog, sind aber auf zwei Kacheln + * in Gebrauch. + */ +function kachelTopicVorschlaege() +{ + $topics = []; + + foreach (allRooms() as $room) { + foreach (kachelVorgabe($room) as $wert) { + if (!empty($wert["topic"])) { + $topics[$wert["topic"]] = true; + } + } + } + foreach (kachelUeberschreibungen() as $werte) { + foreach ($werte as $wert) { + if (!empty($wert["topic"])) { + $topics[$wert["topic"]] = true; + } + } + } + foreach (kachelKatalog() as $g) { + foreach ($g["werte"] as $w) { + $topics[$w["topic"]] = true; + } + } + + $liste = array_keys($topics); + sort($liste, SORT_NATURAL | SORT_FLAG_CASE); + return $liste; +} + +/** + * Eine Wertdefinition prüfen und auf das Nötige eindampfen. + * + * Geworfen wird bei allem, was die Anzeige nicht darstellen könnte. Was nur + * überflüssig ist - eine Einheit neben format "leistung" etwa - fällt still + * weg, damit in der Datenbank keine Angaben stehen, die niemand liest. + */ +function kachelWertPruefen($wert, $nummer) +{ + if (!is_array($wert)) { + throw new InvalidArgumentException("Wert " . $nummer . " ist unlesbar."); + } + $sauber = []; + + $wechselrichter = array_values(array_filter(array_map( + "trim", (array)($wert["wechselrichter"] ?? [])))); + $topic = trim((string)($wert["topic"] ?? "")); + + if ($wechselrichter) { + $sauber["wechselrichter"] = $wechselrichter; + $feld = trim((string)($wert["feld"] ?? "p_AC")); + $sauber["feld"] = $feld !== "" ? $feld : "p_AC"; + } elseif ($topic !== "") { + if (mb_strlen($topic) > 120) { + throw new InvalidArgumentException("Wert " . $nummer . ": das Topic ist zu lang."); + } + $sauber["topic"] = $topic; + $pfad = trim((string)($wert["pfad"] ?? "")); + if ($pfad !== "") { + $sauber["pfad"] = $pfad; + } + } else { + throw new InvalidArgumentException( + "Wert " . $nummer . " braucht ein Topic oder einen Wechselrichter."); + } + + // Entweder das Format rechnet die Einheit selbst aus, oder sie steht da. + if (($wert["format"] ?? "") === "leistung") { + $sauber["format"] = "leistung"; + } else { + $einheit = trim((string)($wert["einheit"] ?? "")); + if (mb_strlen($einheit) > 8) { + throw new InvalidArgumentException("Wert " . $nummer . ": die Einheit ist zu lang."); + } + if ($einheit !== "") { + $sauber["einheit"] = $einheit; + } + $stellen = $wert["stellen"] ?? null; + if ($stellen !== null && $stellen !== "") { + $stellen = intval($stellen); + if ($stellen < 0 || $stellen > 3) { + throw new InvalidArgumentException( + "Wert " . $nummer . ": Nachkommastellen gehen von 0 bis 3."); + } + $sauber["stellen"] = $stellen; + } + } + + $icon = (string)($wert["icon"] ?? ""); + if ($icon !== "") { + if (!array_key_exists($icon, kachelSymbole())) { + throw new InvalidArgumentException("Wert " . $nummer . ": unbekanntes Symbol."); + } + $sauber["icon"] = $icon; + } + if (!empty($wert["gross"])) { + $sauber["gross"] = true; + } + return $sauber; +} + +/** + * Die Werte einer Kachel speichern. + * + * $werte === null setzt auf die Vorgabe zurück - die Zeile verschwindet, statt + * die Vorgabe hineinzukopieren. Sonst stünde in der Datenbank eine Kopie, die + * nicht mitzöge, wenn sich rooms.php ändert. + * + * Eine leere Liste ist etwas anderes: die Kachel zeigt dann bewusst keinen + * Messwert, sondern nur ihren Namen. + */ +function kachelWerteSpeichern($floor, $room, $werte) +{ + if (!kachelTabelleDa()) { + throw new RuntimeException( + "Die Tabelle kachelWerte fehlt. Sie steht in solarLog_kacheln.sql."); + } + $treffer = null; + foreach (allRooms() as $r) { + if ($r["floor"] === $floor && $r["id"] === $room && isset($r["x"])) { + $treffer = $r; + } + } + if (!$treffer) { + throw new InvalidArgumentException("Diese Kachel gibt es nicht."); + } + + $db = solarDb(); + if ($werte === null) { + $stmt = $db->prepare("DELETE FROM kachelWerte WHERE floor = ? AND room = ?"); + $stmt->bind_param("ss", $floor, $room); + $stmt->execute(); + return null; + } + + if (!is_array($werte)) { + throw new InvalidArgumentException("Die Werteliste ist unlesbar."); + } + if (count($werte) > KACHEL_MAX) { + throw new InvalidArgumentException( + "Mehr als " . KACHEL_MAX . " Werte passen nicht auf eine Kachel."); + } + $sauber = []; + foreach (array_values($werte) as $i => $wert) { + $sauber[] = kachelWertPruefen($wert, $i + 1); + } + + // Zwei grosse Zahlen nebeneinander gibt es nicht: die Kachel hat genau + // eine Zeile, die aus der Entfernung lesbar ist. + $grosse = count(array_filter($sauber, function ($w) { return !empty($w["gross"]); })); + if ($grosse > 1) { + throw new InvalidArgumentException("Nur ein Wert darf gross stehen."); + } + + $json = json_encode($sauber, JSON_UNESCAPED_UNICODE); + $stmt = $db->prepare("INSERT INTO kachelWerte (floor, room, werte) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE werte = VALUES(werte)"); + $stmt->bind_param("sss", $floor, $room, $json); + if (!$stmt->execute()) { + throw new RuntimeException($stmt->error); + } + return $sauber; +} diff --git a/restricted/reiter.php b/restricted/reiter.php new file mode 100644 index 0000000..21fc441 --- /dev/null +++ b/restricted/reiter.php @@ -0,0 +1,128 @@ +"; + foreach ($reiter as $r) { + $offen = $r["id"] === $aktiv; + $html .= ""; + } + return $html . ""; +} + +/** Anfang einer Fläche - der Inhalt steht danach im Template. */ +function reiterFlaecheAuf($id, $opt = []) +{ + $offen = ($opt["aktiv"] ?? null) === $id; + return "
"; +} + +/** Ende einer Fläche. Eigene Funktion, damit im Template kein nacktes
steht. */ +function reiterFlaecheZu() +{ + return "
"; +} + +/** + * Leiste und Flächen am Stück - für Aufrufer, die den Inhalt schon als Text + * haben (siehe zeichneGruppen() im Raum-Modal). + */ +function reiterBlock($reiter, $opt = []) +{ + $opt["aktiv"] = reiterAktiv($reiter, $opt); + $html = reiterLeiste($reiter, $opt) . "
"; + foreach ($reiter as $r) { + $html .= reiterFlaecheAuf($r["id"], $opt) . ($r["inhalt"] ?? "") . reiterFlaecheZu(); + } + return $html . "
"; +} diff --git a/restricted/roomControls.php b/restricted/roomControls.php index ca3c03c..d69ab22 100644 --- a/restricted/roomControls.php +++ b/restricted/roomControls.php @@ -19,6 +19,10 @@ * die hier gesetzt werden. */ +// Die Geraetegruppen werden als Reiter gezeichnet - eine Bauart fuer die +// ganze Seite, siehe restricted/reiter.php. +require_once(__DIR__ . "/reiter.php"); + /** * Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein * Schaltkommando ist. @@ -209,38 +213,29 @@ function zeichneGruppen($gruppen, $fertig = []) return ""; } - $html = ""; - if (count($vorhanden) > 1) { - $html .= ""; - } - - $html .= "
"; - $erster = true; + // Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet + // wird, steht in restricted/reiter.php - hier nur, was drinsteht. + $reiter = []; foreach ($vorhanden as $form => $titel) { - $html .= "
"; + $inhalt = ""; if (isset($fertig[$form])) { - $html .= $fertig[$form]; + $inhalt = $fertig[$form]; } else { foreach ($gruppen[$form] as $d) { - $html .= zeichneGeraet($d, $form); + $inhalt .= zeichneGeraet($d, $form); } } - $html .= "
"; - $erster = false; + $reiter[] = [ + "id" => $form, + "titel" => $titel[0], + "symbol" => $titel[1], + "inhalt" => $inhalt, + ]; } - return $html . "
"; + // "schmal", weil das Modal eng ist: kleinere Reiter, die waagerecht + // scrollen statt umzubrechen. + return reiterBlock($reiter, ["name" => "raumtab", "schmal" => true, + "klasse" => "mb-2"]); } // =========================================================================== diff --git a/restricted/rooms.php b/restricted/rooms.php index ab11236..45c404e 100644 --- a/restricted/rooms.php +++ b/restricted/rooms.php @@ -2,6 +2,11 @@ /* * Einzige Quelle für die Räume der Home-Ansicht. * + * Die Werte auf den Kacheln sind hier die Vorgabe; überschrieben werden sie + * auf der Einstellungsseite (Reiter "Home-Kacheln"). Was dort gesetzt wurde, + * steht in der Tabelle kachelWerte und hat Vorrang - alles Übrige zeigt + * weiterhin, was hier steht. + * * Aus dieser Tabelle entstehen sowohl die SVG-Kacheln in home.php als auch * die Aktualisierungsschleife in js/solar/homeMQTT.js (die Tabelle wird dafür * als JSON in die Seite geschrieben). Ein neuer oder umbenannter Raum ist @@ -12,7 +17,9 @@ * id Kürzel in den SVG-Element-IDs: _w0_, __heater * x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen * ohne Kachel - * werte Was auf der Kachel steht: bis zu drei Messwerte, jeder mit + * werte Was auf der Kachel steht, sofern auf der Einstellungsseite nichts + * anderes gewählt wurde (Tabelle kachelWerte, siehe + * restricted/kacheln.php): bis zu drei Messwerte, jeder mit * topic (oder wechselrichter, siehe Carport), optional pfad * (Schlüssel in einer JSON-Nachricht) und entweder format * ("leistung" skaliert selbst zwischen W und kW) oder dem Paar @@ -33,6 +40,10 @@ * ist die linke obere Ecke der 24 x 19 grossen Kachel, nicht ihre Mitte. */ +// solarDb() für die Kachelwerte. costs.php definiert nur Funktionen und +// verbindet sich erst, wenn eine davon gerufen wird. +require_once(__DIR__ . "/costs.php"); + // Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage. // // AG ist das Außengelände - eine Etage ohne Grundriss. Was draußen hängt, @@ -239,27 +250,97 @@ function roomsWithTile() } /** - * Die Messwerte einer Kachel, fertig ausgeschrieben. + * Was eine Kachel ohne Zutun zeigt. * * Ohne eigene Angabe sind es die drei Thermostatwerte, die dort schon immer * standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein - * darunter. Nur diese Räume bekommen auch die Symbole für "heizt" und - * "Pufferbetrieb" - anderswo gibt es nichts zu heizen. + * darunter. */ -function mitWerten($room) +function kachelVorgabe($room) { if (isset($room["werte"])) { - $room["heizung"] = false; - $room["werte"] = array_slice($room["werte"], 0, 3); - return $room; + return array_slice($room["werte"], 0, 3); } $zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/"; - $room["heizung"] = true; - $room["werte"] = [ + return [ ["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0], ["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true], ["topic" => $zweig . "rHum[%]", "einheit" => "%rF"], ]; +} + +/** + * Gibt es die Tabelle mit den selbst gewählten Kachelwerten? + * + * Sie darf fehlen (solarLog_kacheln.sql legt sie an). Dann gilt überall die + * Vorgabe - die Startseite bleibt nicht mit einem SQL-Fehler stehen, nur weil + * eine Einstellung noch nie benutzt wurde. + */ +function kachelTabelleDa() +{ + static $da = null; + if ($da === null) { + /* + * Auch der Ausfall der Datenbank fuehrt hier nur zur Vorgabe. Die + * Kacheln holen ihre Zahlen vom Broker, nicht aus MySQL - die + * Startseite lief bisher ohne Datenbank, und das soll so bleiben. + * Ohne dieses try stuende dort im Ernstfall ein Fatal Error statt des + * Hauses. + */ + try { + $res = solarDb()->query("SHOW TABLES LIKE 'kachelWerte'"); + $da = $res && $res->num_rows > 0; + } catch (Throwable $e) { + $da = false; + } + } + return $da; +} + +/** + * Die von Hand gesetzten Werte, nach "Etage|Raum" abgelegt. + * + * Eine Abfrage je Seitenaufbau, nicht eine je Kachel - die Startseite zeichnet + * zwanzig davon. + */ +function kachelUeberschreibungen() +{ + static $werte = null; + if ($werte !== null) { + return $werte; + } + $werte = []; + if (!kachelTabelleDa()) { + return $werte; + } + try { + $res = solarDb()->query("SELECT floor, room, werte FROM kachelWerte"); + while ($res && $row = $res->fetch_assoc()) { + $liste = json_decode($row["werte"], true); + if (is_array($liste)) { + $werte[$row["floor"] . "|" . $row["room"]] = array_slice($liste, 0, 3); + } + } + } catch (Throwable $e) { + $werte = []; // siehe kachelTabelleDa(): dann eben die Vorgabe + } + return $werte; +} + +/** + * Die Messwerte einer Kachel, fertig ausgeschrieben. + * + * Zuerst das, was auf der Einstellungsseite gewählt wurde; sonst die Vorgabe. + * + * Die Symbole für "heizt" und "Pufferbetrieb" hängen am Raum, nicht an der + * Anzeige: ein Thermostatraum bleibt einer, auch wenn auf seiner Kachel + * inzwischen etwas anderes steht. Anderswo gibt es nichts zu heizen. + */ +function mitWerten($room) +{ + $room["heizung"] = !isset($room["werte"]); + $eigen = kachelUeberschreibungen()[$room["floor"] . "|" . $room["id"]] ?? null; + $room["werte"] = $eigen !== null ? $eigen : kachelVorgabe($room); return $room; } diff --git a/restricted/settings.php b/restricted/settings.php index e9bf174..abc75cb 100644 --- a/restricted/settings.php +++ b/restricted/settings.php @@ -2,10 +2,15 @@ /** * Einstellungsseite: Parameter, die von Hand gepflegt werden. * - * Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist - * als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein - * weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die - * vorhandenen etwas davon merken. + * Gegliedert in Reiter, einer je Thema - Preise und die Anzeigewerte der + * Home-Kacheln. Die Liste $reiter unten ist die einzige Stelle, die davon + * weiss: ein neues Thema ist ein Eintrag dort plus der Inhalt, alles Uebrige + * (Navigation, Umschalten, Merken des Reiters in der Adresse) ergibt sich + * daraus. Vorher stand jedes Thema als Karte untereinander; bei drei Themen + * waere die Seite ein Scrollband geworden. + * + * Innerhalb eines Reiters bleibt es bei Karten - eine je Tabelle bzw. je + * Etage. * * Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier * eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst @@ -15,6 +20,22 @@ * die Felder dort muessen dieselben sein. */ require_once(__DIR__ . "/costs.php"); +require_once(__DIR__ . "/kacheln.php"); +require_once(__DIR__ . "/reiter.php"); + +/* + * Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene, + * solange die Adresse nichts anderes sagt (#kacheln). + */ +$reiter = [ + ["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"], + ["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"], + ["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"], +]; +// "merken": der offene Reiter steht in der Adresse und uebersteht ein +// Neuladen - siehe reiterMerken() in js/solar/common.js. +$reiterOpt = ["name" => "einstellung", "merken" => true, "klasse" => "mb-3"]; +$reiterOpt["aktiv"] = reiterAktiv($reiter, $reiterOpt); $reihen = preisReihen(); $daten = []; @@ -50,6 +71,13 @@ try {
Die Einstellungen sind nicht erreichbar:
+ + + + +
+ $reihe): ?>
@@ -94,6 +122,78 @@ try {
+ + + + + +
+
+
+
+

Werte auf den Kacheln

+
+ +
+
+
+

+ Jede Kachel im Grundriss zeigt bis zu drei Messwerte. Ohne eigene + Angabe sind das die Werte aus restricted/rooms.php – + bei Räumen mit Thermostat Soll, Ist und Feuchte. Was hier gesetzt + wird, gilt stattdessen; „Standard“ nimmt es wieder zurück. +

+
+

Wird geladen …

+
+
+
+
+
+ + + + + +
+
+
+
+

Geräte und Räume

+
+ +
+
+
+

+ Der Raum ordnet die Geräteliste im Automatik-Editor und bestimmt, + welche Messwerte bei einer Home-Kachel oben stehen. Er wird von Hand + vergeben und bleibt bei einem neuen Suchlauf erhalten – der Suchlauf + legt nur an und aktualisiert, er löscht nichts. Abgebaute Geräte + bleiben deshalb stehen, bis sie hier entfernt werden. +

+
+

Wird geladen …

+
+
+
+
+
+ + + diff --git a/solarLog_kacheln.sql b/solarLog_kacheln.sql new file mode 100644 index 0000000..5d0ddc5 --- /dev/null +++ b/solarLog_kacheln.sql @@ -0,0 +1,26 @@ +-- Anzeigewerte der Home-Kacheln. +-- +-- Eine Zeile je Kachel, und nur fuer die Kacheln, an denen etwas geaendert +-- wurde. Steht keine Zeile da, gilt die Vorgabe aus restricted/rooms.php: bei +-- Thermostatraeumen die drei Werte Soll, Ist und Feuchte, sonst die dort +-- eingetragene Liste. Die Tabelle ist damit reine Ueberschreibung - sie muss +-- weder vollstaendig sein noch mitwachsen, wenn ein Raum hinzukommt. +-- +-- Die Werte stehen als JSON in einer Spalte und nicht in Einzelspalten. Eine +-- Wertdefinition ist heterogen: mal ein Topic mit Pfad in einer +-- JSON-Nachricht, mal eine Liste von Wechselrichternamen, dazu Einheit, +-- Nachkommastellen, Symbol und ob die Zahl gross steht. In Spalten gegossen +-- waeren das ein Dutzend Felder, von denen je nach Quelle die Haelfte leer +-- bleibt - und jede neue Anzeigeoption waere eine Schemaaenderung. So ist es +-- genau die Form, die restricted/rooms.php ohnehin verarbeitet. +-- +-- Geprueft wird beim Speichern in restricted/kacheln.php, nicht hier: die +-- Datenbank kann JSON zwar halten, aber nicht wissen, welche Symbole es gibt. + +CREATE TABLE IF NOT EXISTS `kachelWerte` ( + `floor` varchar(4) NOT NULL COMMENT 'Etage, wie in rooms.php', + `room` varchar(40) NOT NULL COMMENT 'Kuerzel des Raums (rooms.id)', + `werte` text NOT NULL COMMENT 'JSON-Liste der Wertdefinitionen, hoechstens drei', + `aktualisiert` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`floor`, `room`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;