Was draussen haengt, stand als Pseudo-Raum "aussen" in automations.php:
"Geraete, die zu keinem Raum der Wohnung gehoeren - Carport, Garage,
Regensensor". Das waren inzwischen sechzehn Geraete in einem einzigen Topf,
der groesste ueberhaupt - in der Geraeteauswahl des Editors standen die fuenf
Carport-Wechselrichter, das Sonnensegel und das Garagentor unsortiert
nebeneinander.
Jetzt ist das Aussengelaende eine Etage wie OG, EG und UG, mit eigenen
Raeumen: Carport, Veranda, Pergola, Terrasse, Garage und Garten. Die Namen
stammen von den Geraeten selbst - die Hoymiles heissen seit jeher "Carport
1-6" und "Veranda OG2-4". Die achtzehn Geraete sind verteilt (die beiden
Ventilsteuerungen hatten noch gar keinen Raum und stehen jetzt im Garten,
zusammen mit dem Regensensor, der ihre Automatiken ausloest); "Ohne Raum"
faellt damit von acht auf sechs, und die sind es zu Recht.
AG ist eine Etage ohne Grundriss. Ihre Raeume haben kein x/y, es gibt ja
kein Bild, auf das eine Kachel zeigen koennte - ein Raum ohne Position war
schon immer vorgesehen. Das Menue und das SVG richten sich deshalb nach der
neuen floorsWithPlan(), die Zuordnung und die Automatiken nach $floors.
Bekommt der erste AG-Raum eine Kachel, erscheint die Etage von selbst auch
im Menue; es gibt keine zweite Liste, die man nachziehen muesste.
Die Etagen standen bisher an sechs Stellen noch einmal im Code. Jetzt gibt
es genau eine Liste, $floors in rooms.php, und alles andere leitet sich ab:
* automations.php prueft gegen $floors statt gegen eine eigene Whitelist
* ajax/AutoAction.php baut seine Etagen-Auswahl daraus
* home.php baut Reiter und Inhaltsbereiche in einer Schleife statt
dreimal fast dasselbe HTML
* header.php baut die Menuepunkte daraus
* switchTab() in homeMQTT.js liest die Reiter aus der Seite, statt OG, EG
und UG sechsmal aufzuzaehlen
* refreshAutomations() ebenso - eine feste Liste haette die vierte Etage
still ausgelassen, die Tabelle waere ohne Fehlermeldung leer geblieben
Dazu die ausgeschriebenen Namen in $floorLabels: das Kuerzel steht in URLs,
SVG-Ids und im MQTT-Baum, "Aussengelaende" nur als Titel am Menuepunkt und
am Reiter.
In der Datenbank ist das Enum um 'AG' erweitert; header.php laedt rooms.php
jetzt selbst, weil es vor home.php eingebunden wird.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
15 KiB
PHP
333 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* Endpunkt fuer die Automatiken: Editor, Uebersicht, Speichern, Loeschen.
|
||
*
|
||
* Alles laeuft ueber ?action=... , damit Formular und Uebersicht dieselbe
|
||
* Modell-Schicht (restricted/automations.php) benutzen und nicht zwei
|
||
* Wahrheiten ueber denselben Datensatz entstehen.
|
||
*
|
||
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
||
* GET ?action=rooms Zuordnung Geraet -> Raum zum Bearbeiten
|
||
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
||
* POST ?action=delete {"id": N}
|
||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
|
||
*/
|
||
|
||
require_once("../helper.php");
|
||
require_once("../restricted/automations.php");
|
||
|
||
if (!checkLogin()) {
|
||
http_response_code(403);
|
||
exit;
|
||
}
|
||
|
||
function jsonAntwort($data, $code = 200)
|
||
{
|
||
http_response_code($code);
|
||
header("Content-Type: application/json; charset=utf-8");
|
||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
$action = $_GET["action"] ?? "editor";
|
||
$floor = in_array($_GET["floor"] ?? "", $floors, true) ? $_GET["floor"] : "";
|
||
|
||
// --- Schreibende Aufrufe -------------------------------------------------
|
||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||
$body = json_decode(file_get_contents("php://input"), true);
|
||
if (!is_array($body)) {
|
||
jsonAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||
}
|
||
try {
|
||
switch ($action) {
|
||
case "save":
|
||
$id = saveAutomation($body);
|
||
jsonAntwort(["id" => $id]);
|
||
case "delete":
|
||
deleteAutomation(intval($body["id"] ?? 0));
|
||
jsonAntwort(["ok" => true]);
|
||
case "toggle":
|
||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||
case "saveRooms":
|
||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||
default:
|
||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||
}
|
||
} catch (InvalidArgumentException $e) {
|
||
jsonAntwort(["error" => $e->getMessage()], 400);
|
||
} catch (Throwable $e) {
|
||
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
|
||
}
|
||
}
|
||
|
||
// --- Uebersichtstabelle --------------------------------------------------
|
||
if ($action === "list") {
|
||
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
$list = listAutomations($floor);
|
||
if (!$list) {
|
||
echo "<p class='text-body-secondary mb-2'>Für diese Etage ist noch nichts eingerichtet.</p>";
|
||
} else {
|
||
echo "<div class='table-responsive'><table class='table table-hover align-middle'>";
|
||
// Je schmaler der Schirm, desto weniger Spalten: auf dem Handy bleiben
|
||
// Name, Ausloeser und die Knoepfe. Sonst muss man seitwaerts scrollen,
|
||
// um eine Automatik ueberhaupt pausieren zu koennen.
|
||
echo "<thead><tr><th>Name</th><th>Auslöser</th>"
|
||
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
|
||
. "<th class='d-none d-md-table-cell'>Wann</th>"
|
||
. "<th class='d-none d-md-table-cell'>Zuletzt</th><th></th></tr></thead><tbody>";
|
||
foreach ($list as $a) {
|
||
// Nur die aktiven Tage nennen. Sieben Haekchen untereinander
|
||
// haben die Zeile frueher unnoetig hoch gemacht.
|
||
$aktiveTage = [];
|
||
for ($i = 0; $i < 7; $i++) {
|
||
if ($a["weekdays"] & (1 << $i)) {
|
||
$aktiveTage[] = $tage[$i];
|
||
}
|
||
}
|
||
$wann = count($aktiveTage) === 7 ? "täglich" : implode(" ", $aktiveTage);
|
||
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
|
||
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
|
||
}
|
||
if (!$a["on_holiday"]) {
|
||
$wann .= ", nicht an Feiertagen";
|
||
}
|
||
if (!$a["on_vacation"]) {
|
||
$wann .= ", nicht in den Ferien";
|
||
}
|
||
if ($a["lockout_secs"] > 0) {
|
||
$wann .= ", frühestens alle "
|
||
. ($a["lockout_secs"] >= 60
|
||
? intdiv($a["lockout_secs"], 60) . " Min."
|
||
: $a["lockout_secs"] . " Sek.");
|
||
}
|
||
|
||
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "–";
|
||
$klasse = $a["enabled"] ? "" : " class='opacity-50'";
|
||
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
|
||
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
|
||
$id = intval($a["id"]);
|
||
|
||
echo "<tr" . $klasse . ">";
|
||
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
|
||
echo "<td>" . htmlspecialchars($a["conditionText"]) . "</td>";
|
||
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
|
||
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
|
||
echo "<td class='text-nowrap d-none d-md-table-cell'>" . $zuletzt . "</td>";
|
||
echo "<td class='text-nowrap'>"
|
||
. "<button type='button' class='btn btn-info btn-sm' title='" . $titel . "'"
|
||
. " onclick='toggleAutomation(" . $id . ")'><i class='bi " . $pause . "'></i></button> "
|
||
. "<button type='button' class='btn btn-warning btn-sm' title='Bearbeiten'"
|
||
. " onclick=\"openAutoActionModal('?action=editor&id=" . $id . "')\"><i class='bi bi-pencil-square'></i></button> "
|
||
. "<button type='button' class='btn btn-danger btn-sm' title='Löschen'"
|
||
. " onclick='deleteAutomation(" . $id . ", \"" . htmlspecialchars($a["name"], ENT_QUOTES) . "\")'><i class='bi bi-trash3'></i></button>"
|
||
. "</td></tr>";
|
||
}
|
||
echo "</tbody></table></div>";
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// --- Geraete den Raeumen zuordnen ---------------------------------------
|
||
if ($action === "rooms") {
|
||
$labels = geraeteBeschriftungen();
|
||
$raeume = geraeteRaeume();
|
||
$vorschlag = raumVorschlaege();
|
||
$auswahl = raumListe();
|
||
|
||
$res = meshDb()->query("SELECT id, type FROM actors");
|
||
$geraete = [];
|
||
while ($row = $res->fetch_assoc()) {
|
||
$id = intval($row["id"]);
|
||
$geraete[] = [
|
||
"id" => $id,
|
||
"label" => $labels[$id] ?? "?",
|
||
"type" => $row["type"],
|
||
"room" => $raeume[$id] ?? null,
|
||
"tipp" => $vorschlag[$id] ?? null,
|
||
];
|
||
}
|
||
// Unzugeordnete nach oben: das ist die Arbeit, die noch zu tun ist.
|
||
usort($geraete, function ($a, $b) {
|
||
$offen = (int)!empty($b["room"]) - (int)!empty($a["room"]);
|
||
return $offen !== 0 ? $offen : strcoll($a["label"], $b["label"]);
|
||
});
|
||
|
||
$offen = count(array_filter($geraete, function ($g) { return empty($g["room"]); }));
|
||
$tipps = count(array_filter($geraete, function ($g) {
|
||
return empty($g["room"]) && !empty($g["tipp"]);
|
||
}));
|
||
|
||
echo "<div class='p-2'>";
|
||
echo "<p class='small text-body-secondary'>Die Zuordnung ordnet die Geräteliste im Editor "
|
||
. "nach Räumen statt nach Geräteart. Sie wird von Hand vergeben und bleibt bei "
|
||
. "einem neuen Geräte-Suchlauf erhalten.</p>";
|
||
if ($tipps) {
|
||
echo "<div class='alert alert-secondary py-2 small d-flex justify-content-between align-items-center'>"
|
||
. "<span>" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic.</span>"
|
||
. "<button type='button' class='btn btn-outline-secondary btn-sm' id='btnRoomTipps'>"
|
||
. "Vorschläge übernehmen</button></div>";
|
||
}
|
||
echo "<div class='table-responsive' style='max-height:60vh;overflow-y:auto'>";
|
||
echo "<table class='table table-sm table-hover align-middle'><thead><tr>"
|
||
. "<th>Gerät</th><th class='d-none d-sm-table-cell'>Art</th><th>Raum</th>"
|
||
. "</tr></thead><tbody>";
|
||
foreach ($geraete as $g) {
|
||
echo "<tr" . (empty($g["room"]) ? " class='table-active'" : "") . ">";
|
||
echo "<td>" . htmlspecialchars($g["label"]) . "</td>";
|
||
echo "<td class='d-none d-sm-table-cell small text-body-secondary'>"
|
||
. htmlspecialchars($g["type"]) . "</td>";
|
||
echo "<td><select class='form-select form-select-sm' data-actor='" . $g["id"] . "'"
|
||
. ($g["tipp"] ? " data-tipp='" . htmlspecialchars($g["tipp"], ENT_QUOTES) . "'" : "")
|
||
. ">";
|
||
echo "<option value=''" . (empty($g["room"]) ? " selected" : "") . ">–</option>";
|
||
foreach ($auswahl as $raum) {
|
||
echo "<option value='" . htmlspecialchars($raum["key"], ENT_QUOTES) . "'"
|
||
. ($g["room"] === $raum["key"] ? " selected" : "") . ">"
|
||
. htmlspecialchars($raum["label"]) . "</option>";
|
||
}
|
||
echo "</select></td></tr>";
|
||
}
|
||
echo "</tbody></table></div>";
|
||
echo "<p class='small text-body-secondary mb-0'>" . $offen . " von " . count($geraete)
|
||
. " Geräten noch ohne Raum.</p>";
|
||
echo "</div>";
|
||
exit;
|
||
}
|
||
|
||
// --- Editor --------------------------------------------------------------
|
||
$id = intval($_GET["id"] ?? 0);
|
||
$auto = $id > 0 ? loadAutomation($id) : null;
|
||
if (!$auto) {
|
||
$auto = emptyAutomation($floor);
|
||
}
|
||
|
||
// Geraetekatalog und Datensatz reisen im selben Dokument mit. Frueher holte
|
||
// der Editor die Geraeteliste und je Zeile noch einmal deren Details nach -
|
||
// zwei zusaetzliche Anfragen pro Bedingung, und die Dropdowns fuellten sich
|
||
// asynchron, weshalb ein gespeicherter Wert nicht zuverlaessig ankam.
|
||
$nutzdaten = json_encode([
|
||
"automation" => $auto,
|
||
"devices" => deviceCatalog(),
|
||
"lockouts" => lockoutChoices(),
|
||
// Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und
|
||
// Etage fuer Etage liest sich besser als alphabetisch.
|
||
"rooms" => raumListe(),
|
||
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||
|
||
?>
|
||
<script type="application/json" id="autoActionData"><?= $nutzdaten ?></script>
|
||
<form id="autoActionForm" class="p-2">
|
||
<div class="row g-2 mb-2">
|
||
<div class="col-8">
|
||
<div class="form-floating">
|
||
<input type="text" class="form-control" id="autoName" placeholder="Name" maxlength="100">
|
||
<label for="autoName">Name der Automatik</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-4">
|
||
<div class="form-floating">
|
||
<!-- Ohne Etage waere die Automatik in keinem der drei Reiter zu
|
||
sehen und nur noch in der Datenbank auffindbar. Reihenfolge wie
|
||
die Reiter, damit der Rueckfall auf den ersten Eintrag dieselbe
|
||
Etage trifft wie die Startseite. -->
|
||
<select class="form-select" id="autoFloor">
|
||
<?php foreach ($floors as $etage): ?>
|
||
<option value="<?= $etage ?>" title="<?= htmlspecialchars(floorLabel($etage)) ?>"><?= $etage ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<label for="autoFloor">Etage</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="accordion" id="actionAccordion">
|
||
<div class="accordion-item">
|
||
<h2 class="accordion-header">
|
||
<button class="accordion-button" type="button" data-bs-toggle="collapse"
|
||
data-bs-target="#collapseOne" aria-expanded="true">
|
||
<i class="bi bi-lightning-fill"></i> Auslöser
|
||
</button>
|
||
</h2>
|
||
<div id="collapseOne" class="accordion-collapse collapse show" data-bs-parent="#actionAccordion">
|
||
<div class="accordion-body">
|
||
<!-- Ein Block ist eine UND-Gruppe, zwischen den Bloecken steht ODER.
|
||
Die Klammerung ist damit gezeichnet und nicht bloss vereinbart. -->
|
||
<div id="condBlocks"></div>
|
||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" id="btnAddGroup">
|
||
<i class="bi bi-plus-lg"></i> Alternative (oder)
|
||
</button>
|
||
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="condSummary"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="accordion-item">
|
||
<h2 class="accordion-header">
|
||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||
data-bs-target="#collapseTwo" aria-expanded="false">
|
||
<i class="bi bi-calendar-check-fill"></i> Bedingungen
|
||
</button>
|
||
</h2>
|
||
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
|
||
<div class="accordion-body">
|
||
<div class="input-group mb-3">
|
||
<span class="input-group-text">Aktiver Zeitraum</span>
|
||
<div class="form-floating">
|
||
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
|
||
<label for="tSpanFrom">Von</label>
|
||
</div>
|
||
<div class="form-floating">
|
||
<input type="time" class="form-control" id="tSpanTo" value="23:59">
|
||
<label for="tSpanTo">Bis</label>
|
||
</div>
|
||
</div>
|
||
Aktive Wochentage:
|
||
<div class="input-group mb-3" id="weekdayGroup"></div>
|
||
<div class="input-group mb-3">
|
||
<div class="input-group-text">
|
||
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFerien">
|
||
<label class="form-check-label" for="actFerien">In den Ferien ausführen</label>
|
||
</div>
|
||
<div class="input-group-text">
|
||
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFeier">
|
||
<label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
|
||
</div>
|
||
</div>
|
||
<div class="input-group mb-3">
|
||
<span class="input-group-text">Sperrzeit nach dem Auslösen</span>
|
||
<select class="form-select" id="lockoutSecs"></select>
|
||
</div>
|
||
<div class="input-group mb-0">
|
||
<div class="input-group-text">
|
||
<input class="form-check-input mt-0 me-2" type="checkbox" id="runOnce">
|
||
<label class="form-check-label" for="runOnce">
|
||
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="accordion-item">
|
||
<h2 class="accordion-header">
|
||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||
data-bs-target="#collapseThree" aria-expanded="false">
|
||
<i class="bi bi-play-fill"></i> Aktionen
|
||
</button>
|
||
</h2>
|
||
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
|
||
<div class="accordion-body">
|
||
<div id="actionList"></div>
|
||
<button class="btn btn-outline-success btn-sm mt-2" type="button" id="btnAddActor">
|
||
<i class="bi bi-plus-lg"></i> Aktion
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|