Einstellungen -> Grundriss: Etagen, Raeume und Kacheln am Plan pflegen

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>
This commit is contained in:
2026-09-15 11:11:10 +02:00
co-authored by Claude Opus 5
parent 62a8af3fc6
commit 8d626abc6d
12 changed files with 1365 additions and 255 deletions
+447
View File
@@ -0,0 +1,447 @@
<?php
/**
* Modell-Schicht für den Reiter "Grundriss": Etagen, Räume, Kachelpositionen,
* Grundrissbilder.
*
* Gelesen wird der Grundriss in rooms.php - dort, wo ihn Startseite, Menü und
* Editor ohnehin holen. Hier steht nur, was ihn ändert, und jede Prüfung
* dazu. Die Maske (js/solar/grundriss.js) prüft vorab nichts, was hier nicht
* auch geprüft würde; sie zeigt nur die Meldung, die von hier kommt.
*
* Tabellen: homeMesh.floors und homeMesh.rooms, siehe homeMesh_grundriss.sql.
*/
require_once(__DIR__ . "/rooms.php"); // grundriss(), meshDb()
/** Zeichenfläche und Kachel - dieselben Maße wie in restricted/home.php. */
const PLAN_BREIT = 400;
const PLAN_HOCH = 300;
const KACHEL_BREIT = 24;
const KACHEL_HOCH = 19;
/**
* Wohin hochgeladene Grundrisse kommen, relativ zum Web-Root.
*
* tiles/ ist das eine Verzeichnis, in das der Webserver schreiben darf - dort
* liegt schon der Zwischenspeicher der Kartenkacheln. Es steht nicht im Git;
* ein Grundriss ist Inhalt dieses Hauses, nicht Teil der Anwendung.
*/
const GRUNDRISS_ORDNER = "tiles/grundriss";
/** Größte Bilddatei. Ein Rendering in 2048 x 1536 hat gut drei Megabyte. */
const GRUNDRISS_MAX_BYTES = 16 * 1024 * 1024;
/** Web-Root als Dateipfad. */
function webRoot()
{
return dirname(__DIR__);
}
/**
* Alles, was der Reiter zum Zeichnen braucht, in einer Antwort.
*
* Je Etage steht dabei, wie viele Räume und Automatiken an ihr hängen, je
* Raum, wie viele Geräte - danach richtet sich, ob und mit welcher Rückfrage
* gelöscht werden darf. Die Maske soll die Namen nicht erst nachfragen müssen.
*/
function grundrissStand()
{
$db = meshDb();
[$etagen, $raeume] = grundriss();
$automatiken = [];
$res = $db->query("SELECT floor, name FROM automations ORDER BY name");
while ($res && $z = $res->fetch_assoc()) {
$automatiken[$z["floor"]][] = $z["name"];
}
$geraete = [];
$res = $db->query("SELECT room_id, COUNT(*) AS n FROM actors
WHERE room_id IS NOT NULL GROUP BY room_id");
while ($res && $z = $res->fetch_assoc()) {
$geraete[intval($z["room_id"])] = intval($z["n"]);
}
$liste = [];
foreach ($etagen as $code => $e) {
$liste[] = [
"code" => $code,
"label" => $e["label"],
"bild" => $e["bild"],
"standard" => $e["standard"],
"raeume" => count(array_filter($raeume, fn($r) => $r["floor"] === $code)),
"automatiken" => $automatiken[$code] ?? [],
];
}
$raumListe = [];
foreach ($raeume as $r) {
$raumListe[] = [
"nr" => $r["nr"],
"floor" => $r["floor"],
"name" => $r["mqtt"],
"kuerzel" => $r["id"],
"x" => $r["x"] ?? null,
"y" => $r["y"] ?? null,
"thermostat" => $r["thermostat"],
"geraete" => $geraete[$r["nr"]] ?? 0,
];
}
return [
"etagen" => $liste,
"raeume" => $raumListe,
"zweige" => thermostatZweige(),
"standard" => standardEtage(),
"mass" => ["planBreit" => PLAN_BREIT, "planHoch" => PLAN_HOCH,
"kachelBreit" => KACHEL_BREIT, "kachelHoch" => KACHEL_HOCH],
];
}
/**
* Vorschläge für das Thermostat-Feld: die Zweige, unter denen gerade ein
* Thermostat meldet.
*
* Erkannt an einem Messwert, dessen Topic auf "/Temp[degC]" endet - so
* schreiben die ESP32-Thermostate. Ein anderes Haus mit anderen Thermostaten
* tippt den Zweig von Hand; die Liste ist eine Abkürzung, keine Grenze.
*/
function thermostatZweige()
{
$zweige = [];
$res = meshDb()->query("SELECT DISTINCT url FROM actor_states WHERE url LIKE '%/Temp[degC]'");
while ($res && $z = $res->fetch_row()) {
$zweige[] = substr($z[0], 0, -strlen("/Temp[degC]"));
}
sort($zweige, SORT_NATURAL | SORT_FLAG_CASE);
return $zweige;
}
/* --- Etagen -------------------------------------------------------------- */
/**
* Eine Etage anlegen oder ändern.
*
* Das Kürzel ist nach dem Anlegen fest: es steht in URLs, SVG-Ids und
* automations.floor. Ändern lassen sich Name und "Standard".
*
* Kürzel aus bis zu vier Großbuchstaben oder Ziffern - so passt es in den
* Etagenschalter im Grundriss und braucht in keiner URL eine Maskierung.
*/
function etageSpeichern($daten)
{
$db = meshDb();
$code = strtoupper(trim(strval($daten["code"] ?? "")));
$label = trim(strval($daten["label"] ?? ""));
$neu = !empty($daten["neu"]);
$standard = !empty($daten["standard"]);
if (!preg_match('/^[A-Z0-9]{1,4}$/', $code)) {
throw new InvalidArgumentException(
"Das Kürzel besteht aus 1 bis 4 Großbuchstaben oder Ziffern, z. B. EG.");
}
if ($label === "" || mb_strlen($label) > 40) {
throw new InvalidArgumentException("Der Name der Etage braucht 1 bis 40 Zeichen.");
}
$da = isset(grundriss()[0][$code]);
if ($neu && $da) {
throw new InvalidArgumentException("Eine Etage " . $code . " gibt es schon.");
}
if (!$neu && !$da) {
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
}
$db->begin_transaction();
try {
if ($standard) {
// Höchstens eine - der Schalter an der einen nimmt ihn allen anderen.
$db->query("UPDATE floors SET standard = 0");
}
$std = $standard ? 1 : 0;
if ($neu) {
$pos = intval($db->query("SELECT COALESCE(MAX(position), -1) + 1 FROM floors")->fetch_row()[0]);
$stmt = $db->prepare("INSERT INTO floors (code, label, position, standard) VALUES (?,?,?,?)");
$stmt->bind_param("ssii", $code, $label, $pos, $std);
} else {
$stmt = $db->prepare("UPDATE floors SET label = ?, standard = ? WHERE code = ?");
$stmt->bind_param("sis", $label, $std, $code);
}
$stmt->execute();
$stmt->close();
$db->commit();
} catch (Throwable $e) {
$db->rollback();
throw $e;
}
return $code;
}
/** Neue Reihenfolge der Etagen: die Kürzel in der gewünschten Folge. */
function etagenReihenfolge($codes)
{
if (!is_array($codes)) {
throw new InvalidArgumentException("Die Reihenfolge ist unlesbar.");
}
$vorhanden = array_keys(grundriss()[0]);
$codes = array_values(array_map("strval", $codes));
if (count($codes) !== count($vorhanden) || array_diff($vorhanden, $codes)) {
throw new InvalidArgumentException(
"Die Reihenfolge passt nicht zu den Etagen - bitte neu laden.");
}
$db = meshDb();
$stmt = $db->prepare("UPDATE floors SET position = ? WHERE code = ?");
foreach ($codes as $i => $code) {
$stmt->bind_param("is", $i, $code);
$stmt->execute();
}
$stmt->close();
}
/**
* Eine Etage löschen - nur, wenn nichts mehr an ihr hängt.
*
* Räume und Automatiken mitzulöschen wäre ein stiller Datenverlust. Die
* Ablehnung nennt, was im Weg steht; aufräumen muss man bewusst.
*/
function etageLoeschen($code)
{
$code = strval($code);
$etage = grundriss()[0][$code] ?? null;
if (!$etage) {
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
}
$raeume = array_column(array_filter(grundriss()[1], fn($r) => $r["floor"] === $code), "mqtt");
if ($raeume) {
throw new InvalidArgumentException(
"Auf " . $etage["label"] . " liegen noch " . count($raeume) . " Räume: "
. implode(", ", $raeume) . ". Erst die Räume löschen oder verschieben.");
}
$db = meshDb();
$stmt = $db->prepare("SELECT name FROM automations WHERE floor = ? ORDER BY name");
$stmt->bind_param("s", $code);
$stmt->execute();
$namen = array_column($stmt->get_result()->fetch_all(MYSQLI_ASSOC), "name");
$stmt->close();
if ($namen) {
throw new InvalidArgumentException(
"Unter " . $etage["label"] . " stehen noch " . count($namen) . " Automatiken: "
. implode(", ", $namen) . ". Erst dort löschen oder einer anderen Etage zuordnen.");
}
$stmt = $db->prepare("DELETE FROM floors WHERE code = ?");
$stmt->bind_param("s", $code);
$stmt->execute();
$stmt->close();
grundrissBildWegraeumen($etage["bild"]);
}
/**
* Ein hochgeladenes Grundrissbild übernehmen.
*
* Erlaubt sind PNG, JPEG und WebP - geprüft am Inhalt (getimagesize), nicht an
* der Endung. SVG bewusst nicht: es kann Skript enthalten, und hier läge es
* direkt abrufbar im Web-Root derselben Seite.
*
* Der Dateiname trägt einen Teil der Prüfsumme. Ein neues Bild hat damit eine
* neue Adresse, und kein Browser zeigt nach dem Hochladen noch das alte aus
* seinem Zwischenspeicher.
*/
function etageBildSpeichern($code, $datei)
{
$code = strval($code);
$etage = grundriss()[0][$code] ?? null;
if (!$etage) {
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
}
if (!is_array($datei) || ($datei["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$fehler = $datei["error"] ?? UPLOAD_ERR_NO_FILE;
throw new InvalidArgumentException($fehler === UPLOAD_ERR_INI_SIZE || $fehler === UPLOAD_ERR_FORM_SIZE
? "Das Bild ist zu groß." : "Es kam kein Bild an.");
}
if ($datei["size"] > GRUNDRISS_MAX_BYTES) {
throw new InvalidArgumentException("Das Bild ist größer als 16 MB.");
}
$info = @getimagesize($datei["tmp_name"]);
$endungen = [IMAGETYPE_PNG => "png", IMAGETYPE_JPEG => "jpg", IMAGETYPE_WEBP => "webp"];
if (!$info || !isset($endungen[$info[2]])) {
throw new InvalidArgumentException("Nur PNG, JPEG oder WebP.");
}
$ordner = webRoot() . "/" . GRUNDRISS_ORDNER;
if (!is_dir($ordner) && !@mkdir($ordner, 0775, true)) {
throw new RuntimeException("Der Ordner " . GRUNDRISS_ORDNER . " lässt sich nicht anlegen.");
}
$name = $code . "-" . substr(sha1_file($datei["tmp_name"]), 0, 10) . "." . $endungen[$info[2]];
$ziel = $ordner . "/" . $name;
if (!move_uploaded_file($datei["tmp_name"], $ziel)) {
throw new RuntimeException("Das Bild ließ sich nicht nach " . GRUNDRISS_ORDNER . " schreiben.");
}
@chmod($ziel, 0664);
$pfad = GRUNDRISS_ORDNER . "/" . $name;
$db = meshDb();
$stmt = $db->prepare("UPDATE floors SET bild = ? WHERE code = ?");
$stmt->bind_param("ss", $pfad, $code);
$stmt->execute();
$stmt->close();
if ($etage["bild"] !== $pfad) {
grundrissBildWegraeumen($etage["bild"]);
}
return ["bild" => $pfad, "breit" => $info[0], "hoch" => $info[1]];
}
/**
* Ein nicht mehr benutztes Bild löschen - aber nur ein hochgeladenes.
*
* Die mitgelieferten unter assets/img gehören zum Repository und bleiben.
* Benutzt eine andere Etage dasselbe Bild, bleibt es ebenfalls.
*/
function grundrissBildWegraeumen($pfad)
{
if (!$pfad || strpos($pfad, GRUNDRISS_ORDNER . "/") !== 0 || strpos($pfad, "..") !== false) {
return;
}
$stmt = meshDb()->prepare("SELECT COUNT(*) FROM floors WHERE bild = ?");
$stmt->bind_param("s", $pfad);
$stmt->execute();
$benutzt = intval($stmt->get_result()->fetch_row()[0]);
$stmt->close();
if ($benutzt === 0) {
@unlink(webRoot() . "/" . $pfad);
}
}
/* --- Räume --------------------------------------------------------------- */
/**
* Ein Kürzel aus einem Namen: klein, ohne Umlaute, nur a-z, 0-9 und _.
*
* Es steht in SVG-Element-Ids; Leerzeichen und Umlaute gehören dort nicht
* hinein.
*/
function raumKuerzelAus($name)
{
$k = mb_strtolower($name);
$k = strtr($k, ["ä" => "ae", "ö" => "oe", "ü" => "ue", "ß" => "ss"]);
$k = preg_replace('/[^a-z0-9]+/', "_", $k);
$k = trim($k, "_");
return substr($k !== "" ? $k : "raum", 0, 36);
}
/**
* Einen Raum anlegen oder ändern.
*
* Fehlt das Kürzel, entsteht es aus dem Namen - und wird durchnummeriert,
* falls es auf der Etage schon vergeben ist. Ein von Hand eingetragenes muss
* dagegen passen: wer es tippt, meint genau dieses.
*
* Wechselt ein Raum die Etage, behält er seine Kachel an derselben Stelle der
* Zeichenfläche - verschieben ist dann ein Zug im Plan.
*/
function raumSpeichern($daten)
{
$db = meshDb();
$nr = intval($daten["nr"] ?? 0);
$floor = strval($daten["floor"] ?? "");
$name = trim(strval($daten["name"] ?? ""));
$kuerzel = trim(strval($daten["kuerzel"] ?? ""));
$thermostat = trim(strval($daten["thermostat"] ?? ""), " /");
if (!isset(grundriss()[0][$floor])) {
throw new InvalidArgumentException("Bitte eine Etage wählen.");
}
if ($name === "" || mb_strlen($name) > 40) {
throw new InvalidArgumentException("Der Name des Raums braucht 1 bis 40 Zeichen.");
}
if ($thermostat !== "" && (mb_strlen($thermostat) > 120 || preg_match('/[#+\s]/', $thermostat))) {
throw new InvalidArgumentException(
"Der Thermostat-Zweig ist ein MQTT-Topic ohne Leerzeichen, # oder +, z. B. Raumtemp/EG/Bad.");
}
$andere = array_filter(grundriss()[1], fn($r) => $r["floor"] === $floor && $r["nr"] !== $nr);
foreach ($andere as $r) {
if (mb_strtolower($r["mqtt"]) === mb_strtolower($name)) {
throw new InvalidArgumentException("Auf dieser Etage gibt es schon einen Raum „" . $name . "“.");
}
}
$vergeben = array_column($andere, "id");
if ($kuerzel === "") {
$basis = raumKuerzelAus($name);
$kuerzel = $basis;
for ($i = 2; in_array($kuerzel, $vergeben, true); $i++) {
$kuerzel = $basis . "_" . $i;
}
} elseif (!preg_match('/^[a-z0-9_]{1,40}$/', $kuerzel)) {
throw new InvalidArgumentException("Das Kürzel besteht nur aus a-z, 0-9 und _.");
} elseif (in_array($kuerzel, $vergeben, true)) {
throw new InvalidArgumentException("Das Kürzel " . $kuerzel . " ist auf dieser Etage schon vergeben.");
}
$th = $thermostat === "" ? null : $thermostat;
if ($nr > 0) {
$alt = null;
foreach (grundriss()[1] as $r) {
if ($r["nr"] === $nr) {
$alt = $r;
}
}
if (!$alt) {
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
}
$stmt = $db->prepare("UPDATE rooms SET floor = ?, name = ?, kuerzel = ?, thermostat = ? WHERE id = ?");
$stmt->bind_param("ssssi", $floor, $name, $kuerzel, $th, $nr);
$stmt->execute();
$stmt->close();
return $nr;
}
$pos = intval($db->query("SELECT COALESCE(MAX(position), -1) + 1 FROM rooms")->fetch_row()[0]);
$stmt = $db->prepare("INSERT INTO rooms (floor, name, kuerzel, position, thermostat) VALUES (?,?,?,?,?)");
$stmt->bind_param("sssis", $floor, $name, $kuerzel, $pos, $th);
$stmt->execute();
$nr = $db->insert_id;
$stmt->close();
return $nr;
}
/**
* Die Kachel eines Raums setzen oder vom Plan nehmen ($x === null).
*
* x/y ist die linke obere Ecke, begrenzt auf die Zeichenfläche - eine Kachel,
* die halb über den Rand hängt, ließe sich im Plan nicht mehr greifen.
*/
function raumPosition($nr, $x, $y)
{
$nr = intval($nr);
$db = meshDb();
if ($x === null || $y === null) {
$stmt = $db->prepare("UPDATE rooms SET x = NULL, y = NULL WHERE id = ?");
$stmt->bind_param("i", $nr);
} else {
$x = max(0, min(PLAN_BREIT - KACHEL_BREIT, intval(round(floatval($x)))));
$y = max(0, min(PLAN_HOCH - KACHEL_HOCH, intval(round(floatval($y)))));
$stmt = $db->prepare("UPDATE rooms SET x = ?, y = ? WHERE id = ?");
$stmt->bind_param("iii", $x, $y, $nr);
}
$stmt->execute();
$geaendert = $stmt->affected_rows;
$stmt->close();
if ($geaendert === 0 && !array_filter(grundriss()[1], fn($r) => $r["nr"] === $nr)) {
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
}
return $x === null ? null : ["x" => $x, "y" => $y];
}
/**
* Einen Raum löschen. Seine Geräte bleiben und stehen danach ohne Raum da
* (fk_actors_room: ON DELETE SET NULL) - die Rückfrage in der Maske nennt,
* wie viele es sind.
*/
function raumLoeschen($nr)
{
$nr = intval($nr);
$stmt = meshDb()->prepare("DELETE FROM rooms WHERE id = ?");
$stmt->bind_param("i", $nr);
$stmt->execute();
$weg = $stmt->affected_rows;
$stmt->close();
if ($weg === 0) {
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
}
}
+18
View File
@@ -37,6 +37,23 @@ require_once(__DIR__ . "/reiter.php");
</div>
</div>
<div class="card-body pb-1">
<?php if (!floorsWithPlan()): ?>
<!-- Eine neue Installation: noch keine Etage mit platzierten
Raeumen. Statt einer leeren Zeichenflaeche der Weg dorthin,
wo sich das aendert. -->
<div class="alert alert-secondary mb-3">
<?php if (!grundrissDa()): ?>
Etagen und Räume lassen sich nicht lesen die Datenbank
<code>homeMesh</code> ist nicht erreichbar, oder
<code>homeMesh_grundriss.sql</code> ist noch nicht eingespielt.
<?php else: ?>
Noch kein Grundriss. Unter
<a href="?action=settings#grundriss">Einstellungen → Grundriss</a>
werden Etagen angelegt, ihre Grundrissbilder hochgeladen und die
Kacheln der Räume gesetzt.
<?php endif; ?>
</div>
<?php else: ?>
<div lass="img-overlay-wrap">
<svg viewBox="0 0 400 300" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
@@ -179,6 +196,7 @@ require_once(__DIR__ . "/reiter.php");
</g>
</svg>
</div>
<?php endif; ?>
</div>
</div>
<!-- /.card -->
+6 -2
View File
@@ -37,13 +37,17 @@ require_once(__DIR__ . "/meshdb.php");
*
* 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()
function grundriss($neuLaden = false)
{
static $stand = null;
if ($stand !== null) {
if ($stand !== null && !$neuLaden) {
return $stand;
}
unset($GLOBALS["grundrissFehler"]);
$etagen = [];
$raeume = [];
try {
+38 -3
View File
@@ -32,6 +32,7 @@ require_once(__DIR__ . "/skodaKeys.php");
*/
$reiter = [
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
["id" => "grundriss", "titel" => "Grundriss", "symbol" => "bi-house"],
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
@@ -128,6 +129,39 @@ try {
<?php endforeach; ?>
<?= reiterFlaecheZu() ?><!-- /Preise -->
<!-- ================================================== Grundriss ==== -->
<!-- Etagen, Räume und wo ihre Kacheln sitzen. Stand bis September 2026
als Tabelle in restricted/rooms.php; jetzt in homeMesh.floors und
homeMesh.rooms, gepflegt hier. Den Inhalt zeichnet
js/solar/grundriss.js aus ajax/settings.php?action=grundriss. -->
<?= reiterFlaecheAuf("grundriss", $reiterOpt) ?>
<div class="row">
<div class="col-12 col-xxl-11">
<div class="card mb-4" id="grundrissKarte">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi bi-house me-2"></i>Etagen und Räume</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" id="grundrissNeuLaden" title="Neu laden">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
Jede Etage hat ein Grundrissbild, darauf sitzen die Kacheln ihrer Räume.
Ein Raum ohne Kachel ist trotzdem ein Raum: Geräte lassen sich ihm
zuordnen, er erscheint nur nicht im Plan. Was auf einer Kachel steht,
wird unter „Home-Kacheln“ eingestellt.
</p>
<div id="grundrissFlaeche">
<p class="text-secondary small mb-0">Wird geladen …</p>
</div>
</div>
</div>
</div>
</div>
<?= reiterFlaecheZu() ?><!-- /Grundriss -->
<!-- ================================================ Home-Kacheln ==== -->
<!-- Der Inhalt kommt aus js/solar/settings.js: eine Karte je Etage, darin
je Raum eine Zeile mit ihren Werten. Gezeichnet wird aus dem Modell,
@@ -149,9 +183,10 @@ try {
<div class="card-body">
<p class="text-secondary small mb-3">
Jede Kachel im Grundriss zeigt bis zu drei Messwerte. Ohne eigene
Angabe sind das die Werte aus <code>restricted/rooms.php</code>
bei Räumen mit Thermostat Soll, Ist und Feuchte. Was hier gesetzt
wird, gilt stattdessen; „Standard“ nimmt es wieder zurück.
Angabe sind das bei Räumen mit Thermostat Soll, Ist und Feuchte,
sonst nur der Name. Was hier gesetzt wird, gilt stattdessen;
„Standard“ nimmt es wieder zurück. Wo Kacheln sitzen und welcher
Raum ein Thermostat hat, steht unter „Grundriss“.
</p>
<div id="kachelListe">
<p class="text-secondary small mb-0">Wird geladen …</p>