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).");
}
}