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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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) {
|
foreach ($vorhanden as $form => $titel) {
|
||||||
$html .= "<li class='nav-item' role='presentation'>"
|
$inhalt = "";
|
||||||
. "<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) {
|
|
||||||
$html .= "<div class='tab-pane fade" . ($erster ? " show active" : "") . "'"
|
|
||||||
. " 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
@@ -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
@@ -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-->
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user