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 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:45:24 +02:00
co-authored by Claude Opus 5
parent 1b58579c22
commit 07bca5104e
15 changed files with 2058 additions and 226 deletions
-71
View File
@@ -9,11 +9,9 @@
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik * GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik * GET ?action=editor&floor=OG Editor fuer eine neue Automatik
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen" * 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=save JSON-Rumpf, legt an oder ueberschreibt
* POST ?action=delete {"id": N} * POST ?action=delete {"id": N}
* POST ?action=toggle {"id": N} - pausieren / fortsetzen * POST ?action=toggle {"id": N} - pausieren / fortsetzen
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
*/ */
require_once("../helper.php"); require_once("../helper.php");
@@ -51,8 +49,6 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
jsonAntwort(["ok" => true]); jsonAntwort(["ok" => true]);
case "toggle": case "toggle":
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]); jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
case "saveRooms":
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
default: default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400); jsonAntwort(["error" => "Unbekannte Aktion."], 400);
} }
@@ -130,73 +126,6 @@ if ($action === "list") {
exit; 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 "<div class='p-2'>";
echo "<p class='small text-body-secondary'>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.</p>";
if ($tipps) {
echo "<div class='alert alert-secondary py-2 small d-flex justify-content-between align-items-center'>"
. "<span>" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic.</span>"
. "<button type='button' class='btn btn-outline-secondary btn-sm' id='btnRoomTipps'>"
. "Vorschläge übernehmen</button></div>";
}
echo "<div class='table-responsive' style='max-height:60vh;overflow-y:auto'>";
echo "<table class='table table-sm table-hover align-middle'><thead><tr>"
. "<th>Gerät</th><th class='d-none d-sm-table-cell'>Art</th><th>Raum</th>"
. "</tr></thead><tbody>";
foreach ($geraete as $g) {
echo "<tr" . (empty($g["room"]) ? " class='table-active'" : "") . ">";
echo "<td>" . htmlspecialchars($g["label"]) . "</td>";
echo "<td class='d-none d-sm-table-cell small text-body-secondary'>"
. htmlspecialchars($g["type"]) . "</td>";
echo "<td><select class='form-select form-select-sm' data-actor='" . $g["id"] . "'"
. ($g["tipp"] ? " data-tipp='" . htmlspecialchars($g["tipp"], ENT_QUOTES) . "'" : "")
. ">";
echo "<option value=''" . (empty($g["room"]) ? " selected" : "") . "></option>";
foreach ($auswahl as $raum) {
echo "<option value='" . htmlspecialchars($raum["key"], ENT_QUOTES) . "'"
. ($g["room"] === $raum["key"] ? " selected" : "") . ">"
. htmlspecialchars($raum["label"]) . "</option>";
}
echo "</select></td></tr>";
}
echo "</tbody></table></div>";
echo "<p class='small text-body-secondary mb-0'>" . $offen . " von " . count($geraete)
. " Geräten noch ohne Raum.</p>";
echo "</div>";
exit;
}
// --- Editor -------------------------------------------------------------- // --- Editor --------------------------------------------------------------
$id = intval($_GET["id"] ?? 0); $id = intval($_GET["id"] ?? 0);
$auto = $id > 0 ? loadAutomation($id) : null; $auto = $id > 0 ? loadAutomation($id) : null;
+70 -2
View File
@@ -4,14 +4,22 @@
* *
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle * GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]} * 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 * Ein reiner Verteiler: geprueft und geschrieben wird in
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber * restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
* einen Zeitraum haben. * dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
*/ */
require_once("../helper.php"); require_once("../helper.php");
require_once("../restricted/costs.php"); require_once("../restricted/costs.php");
require_once("../restricted/kacheln.php");
require_once("../restricted/automations.php");
if (!checkLogin()) { if (!checkLogin()) {
http_response_code(403); http_response_code(403);
@@ -43,6 +51,41 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
"gespeichert" => $anzahl, "gespeichert" => $anzahl,
"zeilen" => preiseLaden($body["reihe"]), "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: default:
jsonAntwort(["error" => "Unbekannte Aktion."], 400); 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") { if ($action === "list") {
try { try {
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]); jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
+256 -15
View File
@@ -691,21 +691,6 @@ input[type='range']::-webkit-slider-runnable-track {
Beschriftungen nicht nebeneinander - dann scrollt die Leiste seitwaerts, Beschriftungen nicht nebeneinander - dann scrollt die Leiste seitwaerts,
statt die Namen umzubrechen. Die durchgehende Linie unter den Reitern statt die Namen umzubrechen. Die durchgehende Linie unter den Reitern
darf dabei nicht mitscrollen, sie gehoert an den unteren Rand. */ 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 /* Waehrend ein Kommando unterwegs ist. Ein "Zu" an einer Jalousie dauert
ueber eine Minute; ohne sichtbare Sperre tippt man in der Zeit nach. */ 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 */ --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 ------------------------------------------- /* --- Aufbau der Fahrzeugkachel -------------------------------------------
Links das Bild, rechts die Zahlen dazu. Die Kachel bricht um, sobald die Links das Bild, rechts die Zahlen dazu. Die Kachel bricht um, sobald die
Zahlenspalte unter 24rem faellt; das Bild behaelt dann seine Breite und 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; } .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 ====================================================== /* === Protokollseite ======================================================
Ein Log liest sich nur in einer Schrift mit fester Breite: die Zeitstempel 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 stehen dann untereinander, und das Auge findet die Stelle, an der etwas
-52
View File
@@ -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) { function toggleAutomation(id) {
automationRequest("toggle", { id: id }).then(antwort => { automationRequest("toggle", { id: id }).then(antwort => {
if (antwort.error) alert(antwort.error); if (antwort.error) alert(antwort.error);
+33
View File
@@ -215,3 +215,36 @@ function updateWeatherCards(value) {
document.getElementById("windGust").innerHTML = value("maxgust"); document.getElementById("windGust").innerHTML = value("maxgust");
document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10; 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);
-13
View File
@@ -203,19 +203,6 @@ function removeClass(el, classNameToRemove){
// Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die // 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 // home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
// sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht. // 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){ function switchFloor(floor){
if(currentFloor == floor) if(currentFloor == floor)
+801
View File
@@ -252,3 +252,804 @@ document.addEventListener("DOMContentLoaded", function () {
preisKarteBinden(karte); 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 = '<div class="alert alert-warning mb-0">Die Tabelle '
+ '<code>kachelWerte</code> fehlt noch. Sie steht in '
+ '<code>solarLog_kacheln.sql</code> bis dahin zeigen alle Kacheln '
+ 'ihre Vorgabe, und Speichern ist nicht möglich.</div>';
return;
}
// Vorschlagsliste einmal fuer alle Topic-Felder.
let html = '<datalist id="kachelTopics">'
+ (kachelModell.topics || []).map(t => '<option value="' + textSchuetzen(t) + '">').join("")
+ "</datalist>";
let etage = null;
(kachelModell.kacheln || []).forEach(function (k) {
if (k.floor !== etage) {
if (etage !== null) html += "</div>";
etage = k.floor;
html += '<div class="kachel-etage"><div class="kachel-etage-name">'
+ textSchuetzen(k.floorLabel) + "</div>";
}
html += kachelZeile(k);
});
if (etage !== null) html += "</div>";
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 => '<span class="kachel-chip">' + textSchuetzen(kachelWertKurz(w, k))
+ "</span>").join("")
: '<span class="kachel-chip leer">ohne Messwert</span>';
let html = '<div class="kachel-zeile' + (k._offen ? " offen" : "") + '"'
+ ' data-floor="' + k.floor + '" data-room="' + k.room + '">'
+ '<button type="button" class="kachel-kopf">'
+ '<i class="bi bi-chevron-right pfeil"></i>'
+ '<span class="name">' + textSchuetzen(k.name) + "</span>"
+ '<span class="werte">' + kurz + "</span>"
+ (k.eigen ? '<span class="badge text-bg-secondary">selbst gesetzt</span>' : "")
+ "</button>";
if (k._offen) {
html += '<div class="kachel-editor">'
+ werte.map((w, i) => kachelWertZeile(w, i, k)).join("");
if (werte.length < (kachelModell.max || 3)) {
html += '<button type="button" class="btn btn-sm btn-outline-secondary kachel-plus">'
+ '<i class="bi bi-plus-lg me-1"></i>Wert hinzufügen</button>';
}
html += '<div class="kachel-fuss">'
+ '<button type="button" class="btn btn-primary btn-sm kachel-speichern">Speichern</button>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm kachel-standard"'
+ (k.eigen ? "" : " disabled") + ">Standard</button>"
+ '<span class="kachel-hinweis small"></span>'
+ "</div></div>";
}
return html + "</div>";
}
/**
* 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 '<option value="' + key + '"' + ((wert.icon || "") === key ? " selected" : "")
+ ">" + textSchuetzen(symbole[key]) + "</option>";
}).join("");
// Eingabeart. "Messwert" steht nur zur Wahl, wenn es einen Katalog gibt -
// ohne Mesh-Datenbank waere es eine leere Liste.
let felder = '<div class="kw-quelle">'
+ '<select class="form-select form-select-sm" data-feld="quelle">'
+ (katalog.length ? '<option value="sensor"' + (quelle === "sensor" ? " selected" : "")
+ ">Messwert</option>" : "")
+ '<option value="topic"' + (quelle === "topic" ? " selected" : "") + ">Topic</option>"
+ '<option value="wr"' + (quelle === "wr" ? " selected" : "") + ">Wechselrichter</option>"
+ "</select></div>";
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) => '<option value="' + index + '"'
+ (index === gi ? " selected" : "") + ">"
+ textSchuetzen(g.label + (g.raum && !eigen(g) ? " " + g.raum : "")) + "</option>";
let geraete = "";
const eigene = katalog.map((g, i2) => [g, i2]).filter(([g]) => eigen(g));
if (eigene.length) {
geraete += '<optgroup label="Dieser Raum">'
+ eigene.map(([g, i2]) => option(g, i2)).join("") + "</optgroup>";
}
let etage = null;
katalog.forEach(function (g, index) {
if (eigen(g)) return;
if (g.floor !== etage) {
if (etage !== null) geraete += "</optgroup>";
etage = g.floor;
geraete += '<optgroup label="' + textSchuetzen(etage || "ohne Raum") + '">';
}
geraete += option(g, index);
});
if (etage !== null) geraete += "</optgroup>";
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 '<option value="' + index + '"' + (index === wi ? " selected" : "") + ">"
+ textSchuetzen(w.name + jetzt) + "</option>";
}).join("");
felder += '<div class="kw-topic"><select class="form-select form-select-sm"'
+ ' data-feld="geraet">' + geraete + "</select></div>"
+ '<div class="kw-pfad"><select class="form-select form-select-sm" data-feld="messwert">'
+ (wi < 0 ? '<option value="-1" selected> wählen </option>' : "")
+ messwerte + "</select></div>";
} else if (quelle === "wr") {
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="wechselrichter" placeholder="Namen, mit Komma getrennt"'
+ ' value="' + textSchuetzen((wert.wechselrichter || []).join(", ")) + '"></div>'
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="feld" placeholder="Feld" value="' + textSchuetzen(wert.feld || "p_AC") + '"></div>';
} else {
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="topic" list="kachelTopics" placeholder="MQTT-Topic"'
+ ' value="' + textSchuetzen(wert.topic || "") + '"></div>'
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="pfad" placeholder="JSON-Pfad" value="' + textSchuetzen(wert.pfad || "") + '"></div>';
}
return '<div class="kachel-wert" data-i="' + i + '">' + felder
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
+ (wert.format === "leistung" ? " disabled" : "")
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
+ ' data-feld="stellen" min="0" max="3" placeholder="0"'
+ (wert.format === "leistung" ? " disabled" : "")
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
+ '<div class="kw-symbol"><select class="form-select form-select-sm" data-feld="icon">'
+ symbolOptionen + "</select></div>"
+ '<div class="kw-schalter">'
+ '<label title="Leistung: skaliert selbst zwischen W und kW">'
+ '<input type="checkbox" class="form-check-input" data-feld="format"'
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>"
+ '<label title="Die Zahl steht gross auf der Kachel">'
+ '<input type="checkbox" class="form-check-input" data-feld="gross"'
+ (wert.gross ? " checked" : "") + "> groß</label>"
+ '<button type="button" class="btn btn-sm btn-outline-danger kachel-weg"'
+ ' title="Wert entfernen"><i class="bi bi-trash"></i></button>'
+ "</div></div>";
}
/** 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, "&quot;");
}
/* --- 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 = '<div class="alert alert-danger mb-0">Kacheln nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
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 = '<p class="small text-body-secondary">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.</p>';
// Sieb: bei über sechzig Geräten ist Scrollen keine Suche.
html += '<div class="geraete-sieb">'
+ '<input type="search" class="form-control form-control-sm" id="geraeteSuche"'
+ ' placeholder="Gerät, Art oder Raum suchen" value="' + textSchuetzen(geraeteSieb.text) + '">'
+ '<div class="form-check form-switch mb-0">'
+ '<input class="form-check-input" type="checkbox" id="geraeteNurOffen"'
+ (geraeteSieb.nurOffen ? " checked" : "") + ">"
+ '<label class="form-check-label small" for="geraeteNurOffen">nur ohne Raum</label>'
+ "</div></div>";
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 += '<div class="alert alert-secondary py-2 small d-flex justify-content-between'
+ ' align-items-center gap-2 mb-2"><span>' + tipps + " Gerät(e) ohne Raum verraten ihn"
+ ' im MQTT-Topic.</span><button type="button" class="btn btn-outline-secondary'
+ ' btn-sm text-nowrap" id="geraeteTipps">Vorschläge übernehmen</button></div>';
}
html += '<div class="geraete-tafel table-responsive">'
+ '<table class="table table-sm table-hover align-middle mb-0"><thead><tr>'
+ "<th>Gerät</th>"
+ '<th class="d-none d-md-table-cell">Art</th>'
+ "<th>Raum</th>"
+ '<th class="d-none d-sm-table-cell">Gemeldet</th>'
+ '<th class="d-none d-lg-table-cell">Automatiken</th>'
+ '<th class="text-end"></th>'
+ "</tr></thead><tbody>";
html += sichtbar.length
? sichtbar.map(g => geraetZeile(g)).join("")
: '<tr><td colspan="6" class="text-body-secondary py-3">Nichts gefunden.</td></tr>';
html += "</tbody></table></div>"
+ '<div class="geraete-fuss small"><span class="text-body-secondary">'
+ alle.length + " Geräte, " + ohneRaum + " ohne Raum"
+ (sichtbar.length !== alle.length ? " · " + sichtbar.length + " angezeigt" : "")
+ '</span><span class="geraete-stand"></span></div>';
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 = '<option value=""' + (g.room ? "" : " selected") + "></option>";
let etage = null;
(geraeteModell.raeume || []).forEach(function (rm) {
if (rm.floor !== etage) {
if (etage !== null) auswahl += "</optgroup>";
etage = rm.floor;
auswahl += '<optgroup label="' + textSchuetzen(rm.floor) + '">';
}
auswahl += '<option value="' + textSchuetzen(rm.key) + '"'
+ (g.room === rm.key ? " selected" : "") + ">"
+ textSchuetzen(rm.room) + "</option>";
});
if (etage !== null) auswahl += "</optgroup>";
// Der Papierkorb steht sechzigmal untereinander - in Rot waere die Tafel
// eine Warnleuchte. Er faerbt sich erst, wenn man ihn anfasst.
const weg = benutzt.length
? '<button type="button" class="btn btn-sm geraet-weg" disabled'
+ ' title="Steckt in einer Automatik"><i class="bi bi-trash3"></i></button>'
: '<button type="button" class="btn btn-sm geraet-weg"'
+ ' title="Gerät entfernen"><i class="bi bi-trash3"></i></button>';
return '<tr data-id="' + g.id + '"' + (g.room ? "" : ' class="ohne-raum"') + ">"
+ '<td class="name">' + 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.
*/
+ '<span class="neben">'
+ '<span class="d-md-none">' + textSchuetzen(g.type) + "</span>"
+ '<span class="d-none d-md-inline">' + g.messwerte + " Messwerte · "
+ g.kommandos + " Kommandos</span></span></td>"
+ '<td class="d-none d-md-table-cell small text-body-secondary">'
+ textSchuetzen(g.type) + "</td>"
+ '<td class="raum"><select class="form-select form-select-sm" data-actor="' + g.id + '"'
+ (g.tipp ? ' data-tipp="' + textSchuetzen(g.tipp) + '"' : "") + ">"
+ auswahl + "</select></td>"
+ '<td class="d-none d-sm-table-cell small alter' + (alter.alt ? " lange" : "") + '">'
+ alter.text + "</td>"
+ '<td class="d-none d-lg-table-cell small">'
+ (benutzt.length
? '<span class="badge text-bg-secondary" title="' + textSchuetzen(benutzt.join(", ")) + '">'
+ benutzt.length + "</span>"
: '<span class="text-body-secondary"></span>')
+ "</td>"
+ '<td class="text-end">' + weg + "</td></tr>";
}
/** 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 = '<div class="alert alert-danger mb-0">Geräte nicht abrufbar: '
+ textSchuetzen(e.message) + "</div>";
}
}
/* --- 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();
});
+164 -12
View File
@@ -316,17 +316,6 @@ function raumVorschlaege()
return $vorschlag; 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|""]. */ /** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
function saveRooms($zuordnung) function saveRooms($zuordnung)
{ {
@@ -352,6 +341,161 @@ function saveRooms($zuordnung)
return $anzahl; 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. * 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, $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 FROM actor_states s
LEFT JOIN state_types t ON s.state_type = t.id LEFT JOIN state_types t ON s.state_type = t.id
ORDER BY s.actor_id, s.id"); ORDER BY s.actor_id, s.id");
@@ -447,6 +597,8 @@ function deviceCatalog()
"unit" => $row["unit"], "unit" => $row["unit"],
"value" => $row["current_value"], "value" => $row["current_value"],
"url" => $row["url"], "url" => $row["url"],
// Schluessel in einer JSON-Nachricht, sonst leer - siehe oben.
"pfad" => $row["value_path"],
]; ];
} }
+26 -22
View File
@@ -1,4 +1,7 @@
<?php require_once(__DIR__ . "/rooms.php"); ?> <?php
require_once(__DIR__ . "/rooms.php");
require_once(__DIR__ . "/reiter.php");
?>
<!--begin::App Main--> <!--begin::App Main-->
<main class="app-main"> <main class="app-main">
<!--begin::App Content Header--> <!--begin::App Content Header-->
@@ -185,12 +188,6 @@
<div class="card-header pb-0 pt-1"> <div class="card-header pb-0 pt-1">
<h3 class="card-title">Automatismen</h3> <h3 class="card-title">Automatismen</h3>
<div class="card-tools"> <div class="card-tools">
<!-- Zuordnung Geraet -> Raum: sie ordnet die Geraeteliste im
Editor nach Raeumen statt nach Geraeteart. -->
<button type="button" class="btn btn-tool" title="Geräte den Räumen zuordnen"
onclick="openRoomModal()">
<i class="bi bi-house-gear"></i>
</button>
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove"> <button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
@@ -198,28 +195,35 @@
</div> </div>
<div class="card-body pb-1"> <div class="card-body pb-1">
<div class="card-header p-0 pt-1"> <?php
<ul class="nav nav-tabs" id="actions-tab" role="tablist"> /*
<?php foreach ($floors as $etage): $offen = $_GET["floor"] == $etage; ?> * Eine Etage, ein Reiter. Gezeichnet von reiterLeiste() aus
<li class="nav-item"> * restricted/reiter.php - dieselbe Bauart wie im Raum-Modal und
<a class="nav-link <?php if ($offen) echo "active"; ?>" id="actions-<?= $etage ?>-tab" * auf der Einstellungsseite. Frueher stand hier ein eigenes
href="#actions-<?= $etage ?>" onclick="switchTab('actions-<?= $etage ?>')" * Markup samt eigenem switchTab() in homeMQTT.js; umgeschaltet
title="<?= htmlspecialchars(floorLabel($etage)) ?>" * wird jetzt von Bootstrap wie ueberall sonst.
aria-controls="actions-<?= $etage ?>" aria-selected="<?= $offen ? "true" : "false" ?>"><?= $etage ?></a> *
</li> * Der Name "actions" haelt die Element-Ids, an denen
<?php endforeach; ?> * autoActionFuncs.js haengt: actions-OG, actions-OG-list.
</ul> */
</div> $autoReiter = array_map(function ($etage) {
return ["id" => $etage, "titel" => $etage,
"titel2" => floorLabel($etage)];
}, $floors);
$autoOpt = ["name" => "actions", "aktiv" => $_GET["floor"],
"klasse" => "mb-2"];
$autoOpt["aktiv"] = reiterAktiv($autoReiter, $autoOpt);
?>
<?= reiterLeiste($autoReiter, $autoOpt) ?>
<div class="tab-content" id="actions-tabContent"> <div class="tab-content" id="actions-tabContent">
<?php foreach ($floors as $etage): ?> <?php foreach ($floors as $etage): ?>
<div class="tab-pane fade <?php if ($_GET["floor"] == $etage) echo "active show"; ?>" <?= reiterFlaecheAuf($etage, $autoOpt) ?>
id="actions-<?= $etage ?>" role="tabpanel" aria-labelledby="actions-<?= $etage ?>-tab">
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von <!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
refreshAutomations() in js/solar/autoActionFuncs.js --> refreshAutomations() in js/solar/autoActionFuncs.js -->
<div id="actions-<?= $etage ?>-list">Wird geladen...</div> <div id="actions-<?= $etage ?>-list">Wird geladen...</div>
<button type="button" class="btn btn-success" title="Neue Automatik" <button type="button" class="btn btn-success" title="Neue Automatik"
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button> onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
</div> <?= reiterFlaecheZu() ?>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
+339
View File
@@ -0,0 +1,339 @@
<?php
/**
* Modell-Schicht für die Anzeigewerte der Home-Kacheln.
*
* Was auf einer Kachel steht, stand bisher fest in restricted/rooms.php. Das
* ist weiterhin die Vorgabe - hier kommt nur die Möglichkeit dazu, sie zu
* überschreiben, ohne die Datei anzufassen. Eine Kachel ohne Zeile in
* kachelWerte zeigt also genau das, was sie immer gezeigt hat.
*
* Die Form einer Wertdefinition ist dieselbe wie in rooms.php, damit zwischen
* Maske, Datenbank und Anzeige nichts übersetzt werden muss:
*
* topic MQTT-Topic, aus dem der Wert kommt
* pfad optional: Schlüssel in einer JSON-Nachricht
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
* feld bei Wechselrichtern das Feld, meist p_AC
* format "leistung" skaliert selbst zwischen W und kW
* einheit sonst die Einheit, die hinter der Zahl steht
* stellen Nachkommastellen (0-3)
* gross die Zahl steht gross auf der Kachel
* icon Symbol vor der Zahl, siehe kachelIcon() in rooms.php
*
* Die Tabelle darf fehlen. Ist sie nicht angelegt (solarLog_kacheln.sql), so
* verhält sich alles wie vorher, statt dass die Startseite mit einem
* SQL-Fehler stehenbleibt - die Kacheln sind keine Einstellung, ohne die man
* das Haus nicht mehr sieht.
*/
/*
* Geladen wird nicht hier, sondern in rooms.php: kachelVorgabe() und
* kachelUeberschreibungen() stehen dort, weil die Startseite sie braucht und
* diese Datei nicht. So bleibt der Weg zur Anzeige kurz - und es gibt keine
* Ringabhängigkeit zwischen Raum-Modell und Maske.
*/
require_once(__DIR__ . "/rooms.php"); // Räume, Vorgaben, Überschreibungen
require_once(__DIR__ . "/costs.php"); // solarDb()
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
const KACHEL_MAX = 3;
/** Symbole, die vor einer Zahl stehen dürfen. Schlüssel wie in kachelIcon(). */
function kachelSymbole()
{
return [
"" => "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;
}
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* Reiter - eine Bauart für die ganze Seite.
*
* Vorher gab es drei: die Etagenreiter über den Automatiken (Home) mit einem
* eigenen switchTab() in JavaScript, die Gerätegruppen im Raum-Modal als
* Bootstrap-Reiter mit eigener CSS-Klasse, und die Einstellungsseite noch
* einmal anders. Drei Markups, zwei Verhaltensweisen, ein Sonderaussehen -
* und jede Änderung am Erscheinungsbild wäre dreimal zu machen gewesen.
*
* Hier steht nur noch, wie ein Reiter aussieht und heißt. Umgeschaltet wird
* von Bootstrap (data-bs-toggle="tab"); eigenes JavaScript dafür gibt es
* nicht mehr. Wer sich den offenen Reiter merken will, setzt "merken" - das
* übernimmt reiterMerken() in js/solar/common.js für alle Leisten der Seite.
*
* Ein Reiter ist:
*
* id Kürzel, taucht in den Element-Ids und in der Adresse auf
* titel Beschriftung
* symbol optional ein Bootstrap-Icon ("bi-currency-euro")
* titel2 optional der lange Name als Sprechblase (Etagenkürzel!)
* inhalt nur für reiterBlock(): der fertige Inhalt der Fläche
*
* Aufrufarten - je nachdem, ob der Inhalt schon als Text vorliegt:
*
* reiterBlock($reiter, $opt) Leiste und Flächen am Stück
* reiterLeiste($reiter, $opt) nur die Leiste ...
* reiterFlaecheAuf($id, $opt) ... und die Flächen einzeln, wenn der
* reiterFlaecheZu() Inhalt im Template steht
*/
/**
* Die Kennung eines Reiters. Der Name der Leiste steht davor, damit zwei
* Leisten auf derselben Seite sich nicht dieselben Ids teilen.
*/
function reiterId($opt, $id, $anhang = "")
{
return ($opt["name"] ?? "reiter") . "-" . $id . $anhang;
}
/**
* Welcher Reiter offen ist: der ausdrücklich genannte, sonst der erste.
*
* Als eigene Funktion, weil Leiste und Flächen dieselbe Antwort brauchen -
* und weil "der erste" bei den Flächen sonst noch einmal ausgerechnet würde.
*/
function reiterAktiv($reiter, $opt)
{
$ids = array_column($reiter, "id");
$aktiv = $opt["aktiv"] ?? null;
return ($aktiv !== null && in_array($aktiv, $ids, true)) ? $aktiv : ($ids[0] ?? null);
}
/**
* Die Leiste.
*
* $opt:
* name Namensraum der Ids; sollte gesetzt sein, sonst "reiter"
* aktiv id des offenen Reiters (Vorgabe: der erste)
* merken true: der offene Reiter steht in der Adresse und übersteht ein
* Neuladen. Nur eine Leiste je Seite sollte das tun.
* schmal true: kleinere Reiter, die waagerecht scrollen statt umzubrechen
* - für enge Stellen wie das Raum-Modal
* klasse zusätzliche Klassen für die Leiste
*/
function reiterLeiste($reiter, $opt = [])
{
if (count($reiter) < 2 && empty($opt["immer"])) {
// Ein einzelner Reiter beschriftet nur sich selbst. Das Raum-Modal
// hat oft nur eine Gerätegruppe - dort ist die Leiste dann weg.
return "";
}
$aktiv = reiterAktiv($reiter, $opt);
$klassen = "nav nav-tabs reiter"
. (!empty($opt["schmal"]) ? " reiter-schmal" : "")
. (isset($opt["klasse"]) ? " " . $opt["klasse"] : "");
$html = "<ul class='" . $klassen . "' role='tablist'"
. (!empty($opt["merken"]) ? " data-reiter-merken='" . htmlspecialchars($opt["name"] ?? "reiter") . "'" : "")
. ">";
foreach ($reiter as $r) {
$offen = $r["id"] === $aktiv;
$html .= "<li class='nav-item' role='presentation'>"
. "<button class='nav-link" . ($offen ? " active" : "") . "' type='button' role='tab'"
. " id='" . reiterId($opt, $r["id"], "-tab") . "'"
. " data-bs-toggle='tab' data-bs-target='#" . reiterId($opt, $r["id"]) . "'"
. " data-reiter='" . htmlspecialchars($r["id"]) . "'"
. " aria-controls='" . reiterId($opt, $r["id"]) . "'"
. " aria-selected='" . ($offen ? "true" : "false") . "'"
. (isset($r["titel2"]) ? " title='" . htmlspecialchars($r["titel2"]) . "'" : "")
. ">"
// Der Abstand zum Text kommt aus dem Stylesheet, nicht aus
// einer Hilfsklasse: in der schmalen Fassung ist er kleiner.
. (isset($r["symbol"]) ? "<i class='bi " . htmlspecialchars($r["symbol"]) . "'></i>" : "")
. htmlspecialchars($r["titel"])
. "</button></li>";
}
return $html . "</ul>";
}
/** Anfang einer Fläche - der Inhalt steht danach im Template. */
function reiterFlaecheAuf($id, $opt = [])
{
$offen = ($opt["aktiv"] ?? null) === $id;
return "<div class='tab-pane fade" . ($offen ? " show active" : "") . "'"
. " id='" . reiterId($opt, $id) . "' role='tabpanel'"
. " aria-labelledby='" . reiterId($opt, $id, "-tab") . "'>";
}
/** Ende einer Fläche. Eigene Funktion, damit im Template kein nacktes </div> steht. */
function reiterFlaecheZu()
{
return "</div>";
}
/**
* 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) . "<div class='tab-content'>";
foreach ($reiter as $r) {
$html .= reiterFlaecheAuf($r["id"], $opt) . ($r["inhalt"] ?? "") . reiterFlaecheZu();
}
return $html . "</div>";
}
+20 -25
View File
@@ -19,6 +19,10 @@
* die hier gesetzt werden. * 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 * Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
* Schaltkommando ist. * Schaltkommando ist.
@@ -209,38 +213,29 @@ function zeichneGruppen($gruppen, $fertig = [])
return ""; return "";
} }
$html = ""; // Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet
if (count($vorhanden) > 1) { // wird, steht in restricted/reiter.php - hier nur, was drinsteht.
$html .= "<ul class='nav nav-tabs flex-nowrap mb-2 raum-tabs' role='tablist'>"; $reiter = [];
$erster = true;
foreach ($vorhanden as $form => $titel) {
$html .= "<li class='nav-item' role='presentation'>"
. "<button class='nav-link" . ($erster ? " active" : "") . "' type='button'"
. " data-bs-toggle='tab' data-bs-target='#raumtab-" . $form . "' role='tab'>"
. "<i class='bi " . $titel[1] . "'></i>"
. "<span class='ms-1'>" . htmlspecialchars($titel[0]) . "</span>"
. "</button></li>";
$erster = false;
}
$html .= "</ul>";
}
$html .= "<div class='tab-content'>";
$erster = true;
foreach ($vorhanden as $form => $titel) { foreach ($vorhanden as $form => $titel) {
$html .= "<div class='tab-pane fade" . ($erster ? " show active" : "") . "'" $inhalt = "";
. " id='raumtab-" . $form . "' role='tabpanel'>";
if (isset($fertig[$form])) { if (isset($fertig[$form])) {
$html .= $fertig[$form]; $inhalt = $fertig[$form];
} else { } else {
foreach ($gruppen[$form] as $d) { foreach ($gruppen[$form] as $d) {
$html .= zeichneGeraet($d, $form); $inhalt .= zeichneGeraet($d, $form);
} }
} }
$html .= "</div>"; $reiter[] = [
$erster = false; "id" => $form,
"titel" => $titel[0],
"symbol" => $titel[1],
"inhalt" => $inhalt,
];
} }
return $html . "</div>"; // "schmal", weil das Modal eng ist: kleinere Reiter, die waagerecht
// scrollen statt umzubrechen.
return reiterBlock($reiter, ["name" => "raumtab", "schmal" => true,
"klasse" => "mb-2"]);
} }
// =========================================================================== // ===========================================================================
+91 -10
View File
@@ -2,6 +2,11 @@
/* /*
* Einzige Quelle für die Räume der Home-Ansicht. * 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 * 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 * die Aktualisierungsschleife in js/solar/homeMQTT.js (die Tabelle wird dafür
* als JSON in die Seite geschrieben). Ein neuer oder umbenannter Raum ist * als JSON in die Seite geschrieben). Ein neuer oder umbenannter Raum ist
@@ -12,7 +17,9 @@
* id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater * id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen * x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
* ohne Kachel * 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 * topic (oder wechselrichter, siehe Carport), optional pfad
* (Schlüssel in einer JSON-Nachricht) und entweder format * (Schlüssel in einer JSON-Nachricht) und entweder format
* ("leistung" skaliert selbst zwischen W und kW) oder dem Paar * ("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. * 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. // 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, // 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 * Ohne eigene Angabe sind es die drei Thermostatwerte, die dort schon immer
* standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein * standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein
* darunter. Nur diese Räume bekommen auch die Symbole für "heizt" und * darunter.
* "Pufferbetrieb" - anderswo gibt es nichts zu heizen.
*/ */
function mitWerten($room) function kachelVorgabe($room)
{ {
if (isset($room["werte"])) { if (isset($room["werte"])) {
$room["heizung"] = false; return array_slice($room["werte"], 0, 3);
$room["werte"] = array_slice($room["werte"], 0, 3);
return $room;
} }
$zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/"; $zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/";
$room["heizung"] = true; return [
$room["werte"] = [
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0], ["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true], ["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
["topic" => $zweig . "rHum[%]", "einheit" => "%rF"], ["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; return $room;
} }
+104 -4
View File
@@ -2,10 +2,15 @@
/** /**
* Einstellungsseite: Parameter, die von Hand gepflegt werden. * Einstellungsseite: Parameter, die von Hand gepflegt werden.
* *
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist * Gegliedert in Reiter, einer je Thema - Preise und die Anzeigewerte der
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein * Home-Kacheln. Die Liste $reiter unten ist die einzige Stelle, die davon
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die * weiss: ein neues Thema ist ein Eintrag dort plus der Inhalt, alles Uebrige
* vorhandenen etwas davon merken. * (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 * Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst * eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
@@ -15,6 +20,22 @@
* die Felder dort muessen dieselben sein. * die Felder dort muessen dieselben sein.
*/ */
require_once(__DIR__ . "/costs.php"); 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(); $reihen = preisReihen();
$daten = []; $daten = [];
@@ -50,6 +71,13 @@ try {
<?php if ($fehler): ?> <?php if ($fehler): ?>
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div> <div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
<?php endif; ?> <?php endif; ?>
<!-- Reiter. Welcher offen ist, merkt sich die Adresse (#kacheln) -
ein Neuladen nach dem Speichern landet damit wieder dort. -->
<?= reiterLeiste($reiter, $reiterOpt) ?>
<div class="tab-content">
<?= reiterFlaecheAuf("preise", $reiterOpt) ?>
<?php foreach ($reihen as $key => $reihe): ?> <?php foreach ($reihen as $key => $reihe): ?>
<?php $breiten = preisSpalten($reihe["felder"]); ?> <?php $breiten = preisSpalten($reihe["felder"]); ?>
<div class="row"> <div class="row">
@@ -94,6 +122,78 @@ try {
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?= reiterFlaecheZu() ?><!-- /Preise -->
<!-- ================================================ Home-Kacheln ==== -->
<!-- Der Inhalt kommt aus js/solar/settings.js: eine Karte je Etage, darin
je Raum eine Zeile mit ihren Werten. Gezeichnet wird aus dem Modell,
das ajax/settings.php?action=kacheln liefert - dieselbe Arbeits-
teilung wie bei den Preisen. -->
<?= reiterFlaecheAuf("kacheln", $reiterOpt) ?>
<div class="row">
<div class="col-12 col-xxl-9">
<div class="card mb-4" id="kachelKarte">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi bi-grid-1x2 me-2"></i>Werte auf den Kacheln</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" id="kachelNeuLaden"
title="Neu laden">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
Jede Kachel im Grundriss zeigt bis zu drei Messwerte. Ohne eigene
Angabe sind das die Werte aus <code>restricted/rooms.php</code>
bei Räumen mit Thermostat Soll, Ist und Feuchte. Was hier gesetzt
wird, gilt stattdessen; „Standard“ nimmt es wieder zurück.
</p>
<div id="kachelListe">
<p class="text-secondary small mb-0">Wird geladen …</p>
</div>
</div>
</div>
</div>
</div>
<?= reiterFlaecheZu() ?><!-- /Home-Kacheln -->
<!-- ===================================================== Geräte ==== -->
<!-- Die Zuordnung Gerät -> Raum lag vorher als Modal auf der Home-Seite.
Sie gehört hierher: sie wird selten geändert, sie ist eine
Einstellung, und hier ist Platz für das, was daneben gehört - wie
lange ein Gerät nichts mehr gemeldet hat, in welchen Automatiken es
steckt, und der Weg, eine Karteileiche loszuwerden. -->
<?= reiterFlaecheAuf("geraete", $reiterOpt) ?>
<div class="row">
<div class="col-12 col-xxl-10">
<div class="card mb-4" id="geraeteKarte">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi bi-house-gear me-2"></i>Geräte und Räume</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" id="geraeteNeuLaden" title="Neu laden">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
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.
</p>
<div id="geraeteListe">
<p class="text-secondary small mb-0">Wird geladen …</p>
</div>
</div>
</div>
</div>
</div>
<?= reiterFlaecheZu() ?><!-- /Geräte -->
</div><!-- /tab-content -->
</div> </div>
</div> </div>
<!--end::App Content--> <!--end::App Content-->
+26
View File
@@ -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;