Files
Smart-Dashboard/ajax/AutoAction.php
T
adminandClaude Opus 5 d3c1cb4682 Aussengelaende wird die vierte Etage AG
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>
2026-09-04 17:55:11 +02:00

333 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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>&nbsp;&nbsp;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>&nbsp;&nbsp;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>&nbsp;&nbsp;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>