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>
340 lines
12 KiB
PHP
340 lines
12 KiB
PHP
<?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;
|
||
}
|