Geraete hingen ueber den Text "Etage/Raum" an ihrem Raum, das Raum-Modal zerlegte "EG_Bad" am Unterstrich, und das Thermostat-Topic wurde aus Etage und Name zusammengesetzt. Ein umbenannter Raum haette still alle Geraete, seinen Temperaturregler und sein Heizsymbol verloren. - actors.room (Text) -> actors.room_id, Fremdschluessel auf rooms, beim Loeschen eines Raums SET NULL. Alle 59 Zuordnungen uebernommen. - automations.floor: ENUM(UG,EG,OG,AG) -> Verweis auf floors. Eine neue Etage kann damit auch Automatiken tragen. - room.php nimmt ?room=<Nummer>; Solltemperatur geht an rooms.thermostat + /changeSetTemp. - homeMQTT.js: Heizsymbol, Regler und Ist-Werte ueber mqttZweig(thermostat) statt mqttData.Raumtemp[Etage][Name]. - Raumvorschlaege in den Einstellungen: Messwerte unter dem Thermostat- Zweig eines Raums statt fest Raumtemp/<Etage>/<Name>. - Schluessel in raumListe(), Katalog und Maske ist die Raumnummer als Text. Geprueft gegen den Stand davor: Startseite gleich bis auf das Klickziel, jede der 25 Kacheln oeffnet ihren eigenen Raum; Geraeteliste, Vorschlaege, Editor-Katalog und die eigenen Geraete je Kachel gleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
427 lines
15 KiB
PHP
427 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* Modell-Schicht für die Anzeigewerte der Home-Kacheln.
|
||
*
|
||
* Gespeichert wird in homeMesh.rooms.werte. Steht dort NULL, gilt die Vorgabe
|
||
* (kachelVorgabe() in rooms.php: mit Thermostat Soll, Ist und Feuchte, sonst
|
||
* nichts). Bis September 2026 lagen die Werte getrennt in solarLog.kachelWerte
|
||
* und überschrieben eine Tabelle im Quelltext - beides gibt es nicht mehr.
|
||
*
|
||
* Die Form einer Wertdefinition ist überall dieselbe, 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, die Quelle
|
||
* liefert Watt; "leistung_kw" dasselbe fuer Quellen, die
|
||
* schon in Kilowatt melden (die Wallboxen im SolarManager)
|
||
* einheit sonst die Einheit, die hinter der Zahl steht
|
||
* stellen Nachkommastellen (0-3)
|
||
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt
|
||
* herum eingebaut sind
|
||
* gross die Zahl steht gross auf der Kachel
|
||
* icon Symbol vor der Zahl: der Name eines Bootstrap-Icons ohne
|
||
* das "bi-" davor ("thermometer-half"). Erlaubt ist jedes,
|
||
* das im Stylesheet steht - siehe bootstrapIcons() in
|
||
* rooms.php; kachelSymbole() ist nur die Vorschlagsliste.
|
||
*/
|
||
|
||
/*
|
||
* Geladen wird nicht hier, sondern in rooms.php: kachelVorgabe() und das
|
||
* Lesen der Werte 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, meshDb()
|
||
|
||
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
|
||
const KACHEL_MAX = 3;
|
||
|
||
/**
|
||
* Die Formate, die selbst zwischen W und kW skalieren - je nachdem, in welcher
|
||
* Einheit die Quelle meldet. Die meisten Zähler schicken Watt; die Wallboxen
|
||
* im SolarManager (evPower, evPowerOG) schicken Kilowatt, und als Watt gelesen
|
||
* stand bei 1 kW Ladeleistung "1 W" auf der Kachel.
|
||
*/
|
||
const KACHEL_LEISTUNG = ["leistung", "leistung_kw"];
|
||
|
||
/**
|
||
* Vorschläge für das Symbol vor einer Zahl.
|
||
*
|
||
* Erlaubt ist jedes Bootstrap-Icon (bootstrapIcons() in rooms.php liest sie
|
||
* aus dem Stylesheet, es sind gut zweitausend). Diese Liste ist deshalb keine
|
||
* Beschränkung, sondern eine Abkürzung: sie steht in der Maske obenan, damit
|
||
* man für die üblichen Messwerte nicht erst einen Namen suchen muss.
|
||
*
|
||
* Gruppiert nach dem, was auf den Kacheln steht - nicht nach dem, wie die
|
||
* Symbole heißen.
|
||
*/
|
||
function kachelSymbole()
|
||
{
|
||
return [
|
||
"Klima" => [
|
||
"thermometer-half" => "Temperatur",
|
||
"thermometer-sun" => "Temperatur draußen",
|
||
"thermometer-snow" => "Kälte",
|
||
"droplet-half" => "Feuchte",
|
||
"moisture" => "Luftfeuchte",
|
||
"fan" => "Lüftung",
|
||
"fire" => "Heizung",
|
||
"wind" => "Wind",
|
||
],
|
||
"Strom" => [
|
||
"lightning-charge-fill" => "Leistung",
|
||
"plug-fill" => "Verbrauch",
|
||
"sun" => "Erzeugung",
|
||
"battery-half" => "Speicher",
|
||
"battery-charging" => "Ladung",
|
||
"ev-station" => "Wallbox",
|
||
"car-front" => "Fahrzeug",
|
||
"speedometer2" => "Zähler",
|
||
"graph-up-arrow" => "Ertrag",
|
||
"arrow-down-up" => "Bezug und Einspeisung",
|
||
],
|
||
"Wasser und Wetter" => [
|
||
"cloud-rain" => "Regen",
|
||
"cloud-sun" => "Wetter",
|
||
"snow" => "Schnee",
|
||
"water" => "Wasserstand",
|
||
"bucket" => "Menge",
|
||
"tree" => "Garten",
|
||
],
|
||
"Haus" => [
|
||
"lightbulb" => "Licht",
|
||
"door-open" => "Tür",
|
||
"window" => "Fenster",
|
||
"house-door" => "Haus",
|
||
"clock-history" => "Zeit",
|
||
"wifi" => "Verbindung",
|
||
],
|
||
];
|
||
}
|
||
|
||
/** Die Vorschläge flach, wie die Maske sie braucht: Name => Beschriftung. */
|
||
function kachelSymboleFlach()
|
||
{
|
||
$flach = [];
|
||
foreach (kachelSymbole() as $gruppe) {
|
||
foreach ($gruppe as $name => $titel) {
|
||
$flach[$name] = $titel;
|
||
}
|
||
}
|
||
return $flach;
|
||
}
|
||
|
||
/**
|
||
* Alle Symbolnamen für die Vorschlagsliste der Maske.
|
||
*
|
||
* Die Vorschläge zuerst und mit ihrer Beschriftung, danach der ganze Rest
|
||
* alphabetisch: so steht das Übliche oben, und wer etwas anderes will, tippt
|
||
* los und bekommt es angeboten.
|
||
*/
|
||
function kachelIconNamen()
|
||
{
|
||
$vorschlag = kachelSymboleFlach();
|
||
$liste = [];
|
||
foreach ($vorschlag as $name => $titel) {
|
||
if (isset(bootstrapIcons()[$name])) {
|
||
$liste[] = ["name" => $name, "titel" => $titel];
|
||
}
|
||
}
|
||
foreach (array_keys(bootstrapIcons()) as $name) {
|
||
if (!isset($vorschlag[$name])) {
|
||
$liste[] = ["name" => $name, "titel" => ""];
|
||
}
|
||
}
|
||
return $liste;
|
||
}
|
||
|
||
/**
|
||
* 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()
|
||
{
|
||
$liste = [];
|
||
foreach (allRooms() as $room) {
|
||
if (!isset($room["x"])) {
|
||
continue; // kein Platz im Grundriss, keine Kachel
|
||
}
|
||
$liste[] = [
|
||
"floor" => $room["floor"],
|
||
"floorLabel" => floorLabel($room["floor"]),
|
||
"room" => $room["id"],
|
||
// Die feste Raumnummer, als Text wie in raumListe(): die Maske
|
||
// stellt damit die Geraete des eigenen Raums nach oben.
|
||
"nr" => strval($room["nr"]),
|
||
"name" => $room["mqtt"],
|
||
"eigen" => isset($room["werte"]),
|
||
"werte" => $room["werte"] ?? 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 Schluessel, nicht nur ausgeschrieben: die
|
||
* Maske stellt die Geraete des eigenen Raums nach oben und muss dafuer
|
||
* genau vergleichen koennen. Der Raum ist die feste Raumnummer
|
||
* (actors.room_id), dieselbe wie "nr" in kachelListe(); die Etage das
|
||
* Kuerzel, das deviceCatalog() schon als floorLabel fuehrt.
|
||
*/
|
||
$hatRaum = ($g["room"] ?? null) !== null && $g["room"] !== "";
|
||
$geraete[] = [
|
||
"label" => $g["label"],
|
||
"floor" => $g["floorLabel"],
|
||
"floorKey" => $hatRaum ? $g["floorLabel"] : "",
|
||
"raum" => $g["roomName"],
|
||
"raumKey" => $hatRaum ? strval($g["room"]) : "",
|
||
"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 (array_merge(kachelVorgabe($room), $room["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 (in_array($wert["format"] ?? "", KACHEL_LEISTUNG, true)) {
|
||
$sauber["format"] = $wert["format"];
|
||
} 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;
|
||
}
|
||
}
|
||
|
||
// Jedes Bootstrap-Icon darf es sein; geprüft wird gegen dieselbe Liste,
|
||
// aus der die Anzeige später das Zeichen holt. Ein Tippfehler faellt so
|
||
// beim Speichern auf und nicht erst als leere Stelle auf der Kachel.
|
||
$icon = trim((string)($wert["icon"] ?? ""));
|
||
if ($icon !== "") {
|
||
if (!isset(bootstrapIcons()[$icon])) {
|
||
throw new InvalidArgumentException(
|
||
"Wert " . $nummer . ": das Symbol \"" . $icon . "\" gibt es nicht. "
|
||
. "Die Namen stehen auf icons.getbootstrap.com, ohne das \"bi-\" davor.");
|
||
}
|
||
$sauber["icon"] = $icon;
|
||
}
|
||
if (!empty($wert["negativ"])) {
|
||
$sauber["negativ"] = true;
|
||
}
|
||
if (!empty($wert["gross"])) {
|
||
$sauber["gross"] = true;
|
||
}
|
||
return $sauber;
|
||
}
|
||
|
||
/**
|
||
* Die Werte einer Kachel speichern.
|
||
*
|
||
* $werte === null setzt auf die Vorgabe zurück - in der Spalte steht dann
|
||
* NULL, statt die Vorgabe hineinzukopieren. Sonst stünde dort eine Kopie, die
|
||
* nicht mitzöge, wenn sich das Thermostat des Raums ändert.
|
||
*
|
||
* Eine leere Liste ist etwas anderes: die Kachel zeigt dann bewusst keinen
|
||
* Messwert, sondern nur ihren Namen.
|
||
*/
|
||
function kachelWerteSpeichern($floor, $room, $werte)
|
||
{
|
||
$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 = meshDb();
|
||
if ($werte === null) {
|
||
$stmt = $db->prepare("UPDATE rooms SET werte = NULL WHERE id = ?");
|
||
$stmt->bind_param("i", $treffer["nr"]);
|
||
$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("UPDATE rooms SET werte = ? WHERE id = ?");
|
||
$stmt->bind_param("si", $json, $treffer["nr"]);
|
||
if (!$stmt->execute()) {
|
||
throw new RuntimeException($stmt->error);
|
||
}
|
||
return $sauber;
|
||
}
|