Files
adminandClaude Opus 5 62a8af3fc6 Raeume ueber ihre feste Nummer: actors.room_id, Raum-Modal, Thermostat-Zweig
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>
2026-09-15 10:58:30 +02:00

427 lines
15 KiB
PHP
Raw Permalink 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.
*
* 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;
}