Files
Smart-Dashboard/restricted/kacheln.php
T
adminandClaude Opus 5 060336c891 Kachelwerte koennen das Vorzeichen umdrehen
Ein Zaehler zaehlt in der Richtung, in der er eingebaut ist - einer der
beiden Shelly-Zaehler meldet deshalb mit umgekehrtem Vorzeichen, und auf der
Kachel stand der Verbrauch als Minusleistung.

Neu gibt es dafuer den Schalter "negativ" an jedem Kachelwert. Er sitzt in
der Anzeige und nicht am Zaehler: Automatiken und Statistik lesen dieselbe
Nachricht und rechnen mit dem Vorzeichen, das dort steht - wer es an der
Quelle drehte, verschoebe die Rechnung an einer Stelle, an der niemand
danach sucht.

Gedreht wird vor dem Formatieren, also auch vor der Skalierung zwischen W
und kW und vor den Nachkommastellen. Aus -0 wird wieder 0. Was keine Zahl
ist, bleibt unangetastet und die Kachel zeigt weiter ihren Strich.

In der eingeklappten Kurzfassung steht das Minus vor dem Namen, damit man
ohne Aufklappen sieht, welcher Wert gedreht ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:41:18 +02:00

433 lines
15 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)
* 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.
*
* 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;
/**
* 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()
{
$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;
}
}
// 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 - 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;
}