Files
Smart-Dashboard/restricted/kacheln.php
T
adminandClaude Opus 5 07bca5104e 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>
2026-09-08 22:45:24 +02:00

340 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}