Neuer Reiter mit Plan-Editor: Etagen anlegen, umbenennen, sortieren, loeschen und als Standard markieren; Grundrissbild hochladen; Raeume mit Name, Kuerzel und Thermostat-Zweig anlegen und bearbeiten; Kacheln im Plan ziehen oder einen Raum ohne Kachel mit "Platzieren" und einem Klick setzen. Loest tools/kachelpositionen.php ab - Raster, Geisterkachel und die Umrechnung Mitte -> linke obere Ecke sind von dort uebernommen. - restricted/grundriss.php: alle Pruefungen und Schreibzugriffe. Eine Etage mit Raeumen oder Automatiken laesst sich nicht loeschen, die Ablehnung nennt die Namen. Raeume loeschen laesst ihre Geraete ohne Raum. - Grundrissbilder nach tiles/grundriss/ (beschreibbar, nicht im Git), nur PNG/JPEG/WebP, am Inhalt geprueft; SVG bewusst nicht. Prüfsumme im Dateinamen, alte hochgeladene Bilder werden weggeraeumt. - ajax/settings.php: ?action=grundriss und sieben POST-Aktionen, jede antwortet mit dem ganzen neuen Stand. - "Werte" springt in den Reiter Home-Kacheln und oeffnet die Kachel; Home-Kacheln und Geraete laden nach einer Grundriss-Aenderung nach. - Startseite ohne Etage mit platziertem Raum: Hinweis auf die Einstellungen statt einer leeren Zeichenflaeche. - README: Grundriss als Inhalt statt Code, neue Dateien und Endpunkte, Reihenfolge der Schema-Dateien fuer ein neues Haus. Backend mit einer Probe-Etage durchgespielt (auch Fehlerfaelle, Upload, Aufraeumen); Oberflaeche auf einer Probeseite mit echten Daten und Zeiger-Ereignissen geprueft: Ziehen, Antippen, Platzieren, neuer Raum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 lines
11 KiB
PHP
318 lines
11 KiB
PHP
<?php
|
|
/*
|
|
* Etagen und Räume der Home-Ansicht.
|
|
*
|
|
* Bis September 2026 standen sie hier als Tabelle im Quelltext - für ein
|
|
* anderes Haus hätte man die Datei umschreiben müssen. Jetzt stehen sie in
|
|
* homeMesh (floors, rooms; Aufbau in homeMesh_grundriss.sql) und werden unter
|
|
* Einstellungen -> Grundriss gepflegt. Diese Datei liest sie und reicht sie in
|
|
* der Form weiter, die Startseite, Menü, Automatik-Editor und Einstellungen
|
|
* seit jeher erwarten.
|
|
*
|
|
* Ein Raum sieht deshalb aus wie früher, mit zwei Feldern mehr:
|
|
*
|
|
* nr feste Nummer (rooms.id) - bleibt, wenn der Raum umbenannt wird
|
|
* floor Etage; dieselben Kürzel wie in floors.code
|
|
* mqtt Name des Raums (rooms.name). Der Schlüssel heißt aus
|
|
* Gewohnheit weiter "mqtt": er stand früher zugleich im Topic
|
|
* Raumtemp/<floor>/<mqtt>/..., und zwanzig Stellen lesen ihn so.
|
|
* id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater
|
|
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
|
|
* ohne Kachel
|
|
* thermostat Zweig der Thermostatwerte (Raumtemp/EG/Bad) oder null
|
|
* werte die auf der Einstellungsseite gesetzten Kachelwerte - fehlt,
|
|
* solange die Vorgabe gilt (siehe kachelVorgabe())
|
|
*
|
|
* Ein Raum ohne x/y ist trotzdem ein Raum: Geräte lassen sich ihm zuordnen
|
|
* (actors.room_id), er erscheint nur nicht als Kachel im Grundriss.
|
|
*
|
|
* Ohne Datenbank oder ohne die beiden Tabellen gibt es keine Etagen - die
|
|
* Startseite zeigt dann einen Hinweis statt eines Fatal Errors.
|
|
*/
|
|
|
|
require_once(__DIR__ . "/meshdb.php");
|
|
|
|
/**
|
|
* Etagen und Räume aus homeMesh, einmal je Anfrage.
|
|
*
|
|
* Gibt [etagen, raeume] zurück: etagen ist code => Zeile aus floors in der
|
|
* eingestellten Reihenfolge, raeume die Liste im Format oben.
|
|
*
|
|
* $neuLaden nach einer Änderung in derselben Anfrage (restricted/grundriss.php)
|
|
* - sonst antwortete der Endpunkt mit dem Stand von davor.
|
|
*/
|
|
function grundriss($neuLaden = false)
|
|
{
|
|
static $stand = null;
|
|
if ($stand !== null && !$neuLaden) {
|
|
return $stand;
|
|
}
|
|
unset($GLOBALS["grundrissFehler"]);
|
|
$etagen = [];
|
|
$raeume = [];
|
|
try {
|
|
$db = meshDb();
|
|
$res = $db->query("SELECT code, label, position, bild, standard FROM floors
|
|
ORDER BY position, code");
|
|
while ($res && $z = $res->fetch_assoc()) {
|
|
$z["standard"] = (bool)$z["standard"];
|
|
$etagen[$z["code"]] = $z;
|
|
}
|
|
$res = $db->query("SELECT r.id, r.floor, r.name, r.kuerzel, r.x, r.y, r.thermostat, r.werte
|
|
FROM rooms r JOIN floors f ON f.code = r.floor
|
|
ORDER BY f.position, r.position, r.id");
|
|
while ($res && $z = $res->fetch_assoc()) {
|
|
$raum = [
|
|
"nr" => intval($z["id"]),
|
|
"floor" => $z["floor"],
|
|
"mqtt" => $z["name"],
|
|
"id" => $z["kuerzel"],
|
|
"thermostat" => $z["thermostat"] !== null && $z["thermostat"] !== ""
|
|
? $z["thermostat"] : null,
|
|
];
|
|
// Nur mit Position eine Kachel - isset($room["x"]) ist die Frage,
|
|
// die überall gestellt wird.
|
|
if ($z["x"] !== null && $z["y"] !== null) {
|
|
$raum["x"] = intval($z["x"]);
|
|
$raum["y"] = intval($z["y"]);
|
|
}
|
|
if ($z["werte"] !== null) {
|
|
$liste = json_decode($z["werte"], true);
|
|
if (is_array($liste)) {
|
|
$raum["werte"] = array_slice(array_values($liste), 0, 3);
|
|
}
|
|
}
|
|
$raeume[] = $raum;
|
|
}
|
|
} catch (Throwable $e) {
|
|
// Siehe oben: lieber ein leeres Haus mit Hinweis als ein Abbruch.
|
|
$etagen = [];
|
|
$raeume = [];
|
|
$GLOBALS["grundrissFehler"] = $e->getMessage();
|
|
}
|
|
$stand = [$etagen, $raeume];
|
|
return $stand;
|
|
}
|
|
|
|
// Die drei globalen Namen, die seit jeher gelesen werden: Reihenfolge der
|
|
// Etagen, ihre Namen und die Raumliste.
|
|
// Über $GLOBALS und nicht als schlichte Zuweisung: wird diese Datei zuerst aus
|
|
// einer Funktion heraus eingebunden, landeten die Namen sonst in deren
|
|
// lokalem Gültigkeitsbereich.
|
|
$GLOBALS["rooms"] = grundriss()[1];
|
|
$GLOBALS["floors"] = array_keys(grundriss()[0]);
|
|
$GLOBALS["floorLabels"] = array_column(grundriss()[0], "label", "code");
|
|
|
|
/**
|
|
* Räume einer Etage, die eine Kachel im Grundriss haben.
|
|
*
|
|
* Alles, was zeichnet oder aktualisiert, geht hierüber - ein Raum ohne
|
|
* Position hätte keine Stelle im SVG und keine Elemente, die sich füllen
|
|
* liessen.
|
|
*/
|
|
function roomsOnFloor($floor)
|
|
{
|
|
return array_values(array_filter(grundriss()[1], function ($room) use ($floor) {
|
|
return $room["floor"] == $floor && isset($room["x"]);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Ließen sich Etagen und Räume lesen? Nein heißt: Datenbank weg oder
|
|
* homeMesh_grundriss.sql noch nicht eingespielt - nicht "noch nichts
|
|
* angelegt", das ist ein leeres, aber gültiges Haus.
|
|
*/
|
|
function grundrissDa()
|
|
{
|
|
grundriss();
|
|
return !isset($GLOBALS["grundrissFehler"]);
|
|
}
|
|
|
|
/** Alle Räume, auch die ohne Kachel - für die Geräte-Zuordnung. */
|
|
function allRooms()
|
|
{
|
|
return grundriss()[1];
|
|
}
|
|
|
|
/** Ausgeschriebener Name einer Etage; unbekannte bleiben ihr Kürzel. */
|
|
function floorLabel($floor)
|
|
{
|
|
return grundriss()[0][$floor]["label"] ?? $floor;
|
|
}
|
|
|
|
/**
|
|
* Die Etage, die öffnet, wenn keine gewählt ist.
|
|
*
|
|
* Stand früher als "OG" an vier Stellen. Ist keine als Standard markiert,
|
|
* gilt die erste mit Grundriss, sonst die erste überhaupt.
|
|
*/
|
|
function standardEtage()
|
|
{
|
|
$etagen = grundriss()[0];
|
|
foreach ($etagen as $code => $e) {
|
|
if ($e["standard"]) {
|
|
return $code;
|
|
}
|
|
}
|
|
$mitPlan = floorsWithPlan();
|
|
if ($mitPlan) {
|
|
return $mitPlan[0];
|
|
}
|
|
return array_key_first($etagen) ?? "";
|
|
}
|
|
|
|
/**
|
|
* Der Grundriss einer Etage als Pfad relativ zum Web-Root, oder null.
|
|
*
|
|
* Mitgelieferte Bilder liegen unter assets/img, hochgeladene unter
|
|
* tiles/grundriss - der Webserver darf nur dort schreiben.
|
|
*/
|
|
function etageBild($floor)
|
|
{
|
|
return grundriss()[0][$floor]["bild"] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Alle Bootstrap-Icons als Name => Codepunkt.
|
|
*
|
|
* Die Zahlen standen früher als Handabschrift hier - drei Stück, und jedes
|
|
* weitere Symbol war eine Codeänderung. Sie stehen aber ohnehin schon im
|
|
* Haus: css/bootstrap-icons.min.css führt sie unter .bi-<name>::before, das
|
|
* ist die Quelle, aus der auch der Browser liest. Also wird sie gelesen,
|
|
* statt sie abzuschreiben; damit steht jedes der gut zweitausend Symbole zur
|
|
* Verfügung, und ein Wechsel der Icon-Version zieht von selbst mit.
|
|
*
|
|
* Einmal je Anfrage - das Auslesen kostet rund eine Millisekunde, und die
|
|
* Startseite fragt für jede Kachel nach.
|
|
*/
|
|
function bootstrapIcons()
|
|
{
|
|
static $icons = null;
|
|
if ($icons !== null) {
|
|
return $icons;
|
|
}
|
|
$icons = [];
|
|
$css = @file_get_contents(__DIR__ . "/../css/bootstrap-icons.min.css");
|
|
if ($css === false) {
|
|
return $icons; // ohne Datei eben ohne Symbole
|
|
}
|
|
// Im CSS steht content:"\f5a2" - der Backslash gehoert zum Inhalt, im
|
|
// Muster ist er deshalb doppelt.
|
|
if (preg_match_all('/\.bi-([a-z0-9-]+)::before\{content:"\\\\([0-9a-f]+)"\}/',
|
|
$css, $treffer, PREG_SET_ORDER)) {
|
|
foreach ($treffer as $t) {
|
|
$icons[$t[1]] = hexdec($t[2]);
|
|
}
|
|
}
|
|
return $icons;
|
|
}
|
|
|
|
/**
|
|
* Das Zeichen eines Bootstrap-Icons für die Kacheln.
|
|
*
|
|
* Die Icons kommen als Schrift, nicht als Pfad: css/bootstrap-icons.min.css
|
|
* ist ohnehin eingebunden (restricted/header.php), und SVG-Text darf dieselbe
|
|
* Schriftfamilie benutzen wie HTML. Ein eingebettetes <path> je Symbol wäre
|
|
* dieselbe Wirkung mit mehr Zeilen.
|
|
*
|
|
* Ein unbekannter Name gibt einen leeren Text - die Kachel zeigt dann die
|
|
* Zahl ohne Symbol, statt ein leeres Kästchen anzuzeigen.
|
|
*/
|
|
function kachelIcon($name)
|
|
{
|
|
$icons = bootstrapIcons();
|
|
return isset($icons[$name]) ? mb_chr($icons[$name], "UTF-8") : "";
|
|
}
|
|
|
|
/**
|
|
* Etagen mit Grundriss - die, die im Menü und im SVG auftauchen.
|
|
*
|
|
* Eine Etage ohne einen einzigen platzierten Raum führte auf eine leere
|
|
* Zeichenfläche. Sobald ihr erster Raum ein x/y bekommt, erscheint sie von
|
|
* selbst - es gibt keine zweite Liste, die man nachziehen müsste.
|
|
*
|
|
* Für die Zuordnung und die Automatiken zählt dagegen $floors: dort ist jede
|
|
* Etage von Anfang an dabei.
|
|
*/
|
|
function floorsWithPlan()
|
|
{
|
|
return array_values(array_filter($GLOBALS["floors"], function ($floor) {
|
|
return count(roomsOnFloor($floor)) > 0;
|
|
}));
|
|
}
|
|
|
|
/** Nur die Räume mit Kachel, für die Aktualisierungsschleife im Browser. */
|
|
function roomsWithTile()
|
|
{
|
|
return array_values(array_map("mitWerten", array_filter(grundriss()[1], function ($room) {
|
|
return isset($room["x"]);
|
|
})));
|
|
}
|
|
|
|
/**
|
|
* Was eine Kachel ohne Zutun zeigt.
|
|
*
|
|
* Mit Thermostat die drei Werte, die dort schon immer standen: Solltemperatur
|
|
* klein darüber, Isttemperatur gross, Feuchte klein darunter. Ohne Thermostat
|
|
* nichts - die Kachel trägt dann ihren Namen.
|
|
*/
|
|
function kachelVorgabe($room)
|
|
{
|
|
if (empty($room["thermostat"])) {
|
|
return [];
|
|
}
|
|
$zweig = rtrim($room["thermostat"], "/") . "/";
|
|
return [
|
|
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
|
|
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
|
|
["topic" => $zweig . "rHum[%]", "einheit" => "%rF"],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Die Messwerte einer Kachel, fertig ausgeschrieben.
|
|
*
|
|
* Zuerst das, was auf der Einstellungsseite gewählt wurde; sonst die Vorgabe.
|
|
*
|
|
* Die Symbole für "heizt" und "Pufferbetrieb" hängen am Thermostat, nicht an
|
|
* der Anzeige: ein Thermostatraum bleibt einer, auch wenn auf seiner Kachel
|
|
* inzwischen etwas anderes steht. Anderswo gibt es nichts zu heizen.
|
|
*/
|
|
function mitWerten($room)
|
|
{
|
|
$room["heizung"] = !empty($room["thermostat"]);
|
|
$room["werte"] = $room["werte"] ?? kachelVorgabe($room);
|
|
return $room;
|
|
}
|
|
|
|
/**
|
|
* Was der Browser beim Broker anmelden muss, damit die Kacheln laufen.
|
|
*
|
|
* Nicht die einzelnen Topics, sondern ihre Zweige: aus vierzig Anmeldungen
|
|
* für Raumtemp werden so vier insgesamt. Der Zweig bringt ausserdem mit,
|
|
* was die Kachel nebenbei braucht - "Heating" und "mode" stehen in keiner
|
|
* Werteliste, das Heizsymbol lebt aber davon.
|
|
*/
|
|
function tileTopics()
|
|
{
|
|
$zweige = [];
|
|
foreach (roomsWithTile() as $room) {
|
|
foreach ($room["werte"] as $wert) {
|
|
// Ein Wert nennt entweder sein Topic oder einen Wechselrichter,
|
|
// den homeMQTT.js unter solarManager/invertersN sucht. Ohne diese
|
|
// Unterscheidung entstand aus dem fehlenden Topic ein "/#" - eine
|
|
// Anmeldung auf einen leeren Zweig.
|
|
$zweig = isset($wert["topic"])
|
|
? explode("/", $wert["topic"])[0]
|
|
: "solarManager";
|
|
$zweige[$zweig . "/#"] = true;
|
|
}
|
|
// Das Heizsymbol braucht den Thermostatzweig, auch wenn kein Wert der
|
|
// Kachel daraus stammt.
|
|
if (!empty($room["thermostat"])) {
|
|
$zweige[explode("/", $room["thermostat"])[0] . "/#"] = true;
|
|
}
|
|
}
|
|
return array_keys($zweige);
|
|
}
|