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