diff --git a/README.md b/README.md index c3591c0..c9aab0c 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,9 @@ Antwort selbst. Bedingungen in UND-/ODER-Gruppen, Aktionen mit Parametern, Klartext-Vorschau. Er bekommt Gerätekatalog und Datensatz in einem Dokument von `AutoAction.php?action=editor` — früher kamen je Bedingung zwei weitere -Anfragen dazu, und die Auswahlfelder füllten sich asynchron. +Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die +Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage +vor dem Löschen (`frageNachfolger`). **`settings.js`** — drei Reiter in einer Datei, deutlich getrennt: Preistabellen, Home-Kacheln, Geräte. Jeder Abschnitt hat sein Modell (`kachelModell`, @@ -334,7 +336,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben. | Endpunkt | Aufruf | liefert / tut | |---|---|---| | `room.php` | `?room=EG_Bad`, `POST ?action=command\|temp` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden | -| `AutoAction.php` | `?action=editor\|list`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht | +| `AutoAction.php` | `?action=editor\|list\|followers`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht | | `settings.php` | `?action=list\|kacheln\|geraete`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen` | Einstellungsseite | | `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl | | `energyHistory.php` | `?series=prod\|cons&range=month\|year\|decade` | Verbrauchs-/Erzeugungsverlauf (Rohdaten oder Stundenarchiv) | @@ -360,7 +362,7 @@ Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum, |---|---|---| | `rooms.php` | Räume, Etagen, Kachelwerte, Symbole | `allRooms()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `kachelUeberschreibungen()`, `bootstrapIcons()`, `kachelIcon()` | | `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` | -| `automations.php` | Automatiken, Gerätekatalog, Verbindung zu `homeMesh` | `meshDb()`, `deviceCatalog()`, `loadAutomation()`, `saveAutomation()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` | +| `automations.php` | Automatiken, Gerätekatalog, Verbindung zu `homeMesh` | `meshDb()`, `deviceCatalog()`, `loadAutomation()`, `saveAutomation()`, `deleteAutomation()`, `automationFollowers()`, `pruefeKreis()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` | | `kacheln.php` | Anzeigewerte der Home-Kacheln (Maskenseite) | `kachelListe()`, `kachelKatalog()`, `kachelWertPruefen()`, `kachelWerteSpeichern()`, `kachelSymbole()` | | `roomControls.php` | Welches Gerät welches Bedienelement bekommt | `bedienform()`, `zeichneBeschattung()`, `zeichneLicht()`, `zeichneMesswerte()` | | `commands.php` | Ein Kommando abschicken (MQTT/WLED/HTTP/Tahoma) | `executeCommand()`, `sendeMqtt()`, `sendeWled()`, `sendeHttp()`, `sendeTahoma()` | @@ -530,6 +532,35 @@ auf einen `actor_states`-Eintrag, eine Aktion auf ein `actor_commands` samt Parametern. Deshalb dürfen Zustands-Ids nicht wandern — siehe die Geräte-Erkennung im nächsten Abschnitt. +**Eine Automatik kann eine andere auslösen.** Dafür steht im Editor auffallend +wenig Code: das gerechnete Gerät „Automatiken" führt jede Automatik als +Messwert, ihr Wert ist der Zeitpunkt der letzten Auslösung — für den +Gerätewähler ist es damit ein Gerät wie jedes andere. Die Messwerte legt der +Runner an und pflegt sie, `restricted/automations.php` liest sie nur +(`ausloeserStates()`, `automationTriggers()`). + +Drei Stellen wissen trotzdem davon: + +* `pruefeKreis()` lehnt beim Speichern ab, was sich mittelbar selbst auslösen + würde. Im Betrieb wäre ein Kreis kaum zu bemerken — die Sperrzeit begrenzt + ihn auf eine Auslösung je `lockout_secs`, und im Protokoll sieht das aus wie + eine Automatik, die halt oft läuft. +* `deleteAutomation()` fragt vorher. `fk_cond_state` steht auf + `ON DELETE CASCADE`, mit dem Messwert verschwände also still die Bedingung + des Nachfolgers: aus *zehn Minuten nach dem Wecker UND es ist hell* würde + ein bloßes *es ist hell*. Zur Wahl stehen Abhängen (Bedingung raus, + Nachfolger pausiert) und Mitlöschen. +* Die Übersicht rückt Nachfolger unter ihren Auslöser ein und zeigt sie + eingeklappt (`toggleAutomationGroup()`). Das ist **nur die Darstellung** — + jeder Nachfolger bleibt eine vollwertige Automatik mit eigener Pause, + eigenen Rahmenbedingungen und eigener Zeile im Protokoll. Sobald + „gruppiert" auch „gehört dazu" hieße, wäre zu klären, wem die Pause gehört. + +Wie der Runner das auswertet — Datentyp `elapsed`, topologische Reihenfolge, +was beim Pausieren geschieht — steht in +[`autoActions/README.md`](../../homes/wagner/SolarManager/autoActions/README.md) +im SolarManager-Repo. + ### 4. Die Geräte-Erkennung `restricted/deviceDiscovery/device_discovery.py` wird **von Hand** gestartet. diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index e08c9c7..4d2c332 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -10,7 +10,8 @@ * GET ?action=editor&floor=OG Editor fuer eine neue Automatik * GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen" * POST ?action=save JSON-Rumpf, legt an oder ueberschreibt - * POST ?action=delete {"id": N} + * GET ?action=followers&id=N JSON: wer haengt an dieser Automatik? + * POST ?action=delete {"id": N, "mode": "abhaengen"|"mitloeschen"} * POST ?action=toggle {"id": N} - pausieren / fortsetzen */ @@ -45,7 +46,12 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $id = saveAutomation($body); jsonAntwort(["id" => $id]); case "delete": - deleteAutomation(intval($body["id"] ?? 0)); + // Der Modus kommt aus der Rueckfrage im Browser. Unbekanntes + // faellt auf das schonendere "abhaengen" zurueck - lieber eine + // pausierte Automatik zu viel als eine geloeschte zu viel. + $modus = ($body["mode"] ?? "") === "mitloeschen" + ? "mitloeschen" : "abhaengen"; + deleteAutomation(intval($body["id"] ?? 0), $modus); jsonAntwort(["ok" => true]); case "toggle": jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]); @@ -59,6 +65,13 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { } } +// --- Wer haengt an dieser Automatik? ------------------------------------- +// Wird vor dem Loeschen gefragt, damit die Rueckfrage im Browser die Namen +// nennen kann und nicht nur eine Zahl. +if ($action === "followers") { + jsonAntwort(["followers" => automationFollowers(intval($_GET["id"] ?? 0))]); +} + // --- Uebersichtstabelle -------------------------------------------------- if ($action === "list") { $tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; @@ -74,7 +87,77 @@ if ($action === "list") { . "Aktion" . "Wann" . "Zuletzt"; + + /* + * Eine Automatik, die von einer anderen ausgeloest wird, steht unter + * ihr statt alphabetisch irgendwo dazwischen - sonst liest man + * "Rollladen Magdalena morgens" und sieht nicht, woran es haengt. + * + * Eingeklappt, weil die Uebersicht sonst mit jeder Verkettung laenger + * wird, ohne mehr zu sagen: der Ausloeser ist das, was man sucht. Der + * Zaehler am Pfeil verraet, dass da noch etwas ist. + * + * Gruppiert wird nur die Darstellung. Jeder Nachfolger bleibt eine + * vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen + * und eigener Zeile im Protokoll - sonst waere zu klaeren, wem die + * Pause gehoert und was das Protokoll zeigt. + */ + $nachFolgern = []; foreach ($list as $a) { + foreach ($a["triggers"] as $davor) { + $nachFolgern[intval($davor)][] = intval($a["id"]); + } + } + $vorhanden = []; + foreach ($list as $a) { + $vorhanden[intval($a["id"])] = $a; + } + // Wessen Ausloeser nicht in dieser Etage steht, faengt selbst oben an. + $wurzeln = []; + foreach ($list as $a) { + $hatVater = false; + foreach ($a["triggers"] as $davor) { + if (isset($vorhanden[intval($davor)])) { + $hatVater = true; + } + } + if (!$hatVater) { + $wurzeln[] = intval($a["id"]); + } + } + $reihe = []; + $einordnen = function ($id, $tiefe, $vater) use (&$einordnen, &$reihe, $nachFolgern, $vorhanden) { + if (!isset($vorhanden[$id]) || isset($reihe[$id])) { + return; + } + $reihe[$id] = ["auto" => $vorhanden[$id], "tiefe" => $tiefe, + "vater" => $vater, "kinder" => 0]; + foreach ($nachFolgern[$id] ?? [] as $kind) { + $einordnen($kind, $tiefe + 1, $id); + } + }; + foreach ($wurzeln as $id) { + $einordnen($id, 0, 0); + } + // Was durch einen Kreis nicht erreicht wurde, kommt trotzdem mit. + foreach ($list as $a) { + $einordnen(intval($a["id"]), 0, 0); + } + // Gezaehlt wird erst jetzt, und nur was wirklich eingeordnet wurde: + // wer zwei Ausloeser hat, steht nur unter dem ersten. Aus der rohen + // Nachfolgerliste gezaehlt verspraeche der zweite Pfeil eine Zeile, + // die er nicht aufklappen kann. + foreach ($reihe as $eintrag) { + if ($eintrag["vater"]) { + $reihe[$eintrag["vater"]]["kinder"]++; + } + } + + foreach ($reihe as $eintrag) { + $a = $eintrag["auto"]; + $tiefe = $eintrag["tiefe"]; + $kinder = $eintrag["kinder"]; + $vater = $eintrag["vater"]; // Nur die aktiven Tage nennen. Sieben Haekchen untereinander // haben die Zeile frueher unnoetig hoch gemacht. $aktiveTage = []; @@ -101,13 +184,43 @@ if ($action === "list") { } $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 ""; - echo "" . htmlspecialchars($a["name"]) . ""; + $klassen = []; + if (!$a["enabled"]) { + $klassen[] = "opacity-50"; + } + if ($tiefe > 0) { + // Eingeklappt starten: die Zeile gehoert dem Ausloeser, und + // der steht direkt darueber. + $klassen[] = "auto-folge"; + $klassen[] = "auto-von-" . $vater; + $klassen[] = "d-none"; + } + $klasse = $klassen ? " class='" . implode(" ", $klassen) . "'" : ""; + + // Der Pfeil klappt die Nachfolger auf. Er steht nur da, wo es + // welche gibt - ein Pfeil ohne Inhalt waere eine Einladung ins + // Leere. + $griff = ""; + if ($kinder > 0) { + $griff = ""; + } + $einzug = $tiefe > 0 + ? "" + . str_repeat("   ", $tiefe) + . " " + : ""; + + echo ""; + echo "" . $einzug . $griff + . htmlspecialchars($a["name"]) . ""; echo "" . htmlspecialchars($a["conditionText"]) . ""; echo "" . htmlspecialchars($a["actionText"]) . ""; echo "" . htmlspecialchars($wann) . ""; diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index dff484d..292017b 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -15,6 +15,12 @@ * * Der Geraetekatalog kommt zusammen mit dem Datensatz im selben Dokument * (siehe ajax/AutoAction.php), es gibt also keine Nachladerei je Zeile. + * + * Eine Automatik kann eine andere ausloesen. Dafuer steht hier auffallend + * wenig: das gerechnete Geraet "Automatiken" fuehrt jede Automatik als + * Messwert, und damit ist es fuer den Editor ein Geraet wie jedes andere - + * er listet ohnehin Geraete und deren Messwerte. Nur die Uebersicht weiss + * davon (toggleAutomationGroup) und das Loeschen (deleteAutomation). */ let autoModel = null; @@ -800,6 +806,43 @@ function refreshAutomations(floor) { }); } +/** + * Die von einer Automatik ausgeloesten Zeilen zeigen oder verbergen. + * + * Sie stehen schon in der Tabelle, nur mit d-none - das spart eine zweite + * Anfrage und haelt die Reihenfolge stabil, wenn zwischendurch etwas + * pausiert wird. + */ +function toggleAutomationGroup(id, knopf) { + const zeilen = Array.from(document.querySelectorAll(".auto-von-" + id)); + if (!zeilen.length) return; + const aufklappen = zeilen[0].classList.contains("d-none"); + if (aufklappen) { + zeilen.forEach(zeile => zeile.classList.remove("d-none")); + } else { + // Beim Zuklappen muessen auch die Enkel verschwinden. Sonst blieben sie + // sichtbar stehen, ohne dass noch zu sehen waere, woran sie haengen. + automationGruppeVerbergen(id); + } + pfeilStellen(knopf, aufklappen); +} + +function automationGruppeVerbergen(id) { + document.querySelectorAll(".auto-von-" + id).forEach(zeile => { + zeile.classList.add("d-none"); + const griff = zeile.querySelector("button[data-gruppe]"); + if (griff) { + pfeilStellen(griff, false); + automationGruppeVerbergen(Number(griff.dataset.gruppe)); + } + }); +} + +function pfeilStellen(knopf, offen) { + const pfeil = knopf && knopf.querySelector("i"); + if (pfeil) pfeil.className = offen ? "bi bi-chevron-down" : "bi bi-chevron-right"; +} + function toggleAutomation(id) { automationRequest("toggle", { id: id }).then(antwort => { if (antwort.error) alert(antwort.error); @@ -807,12 +850,87 @@ function toggleAutomation(id) { }); } +/** + * Loeschen - und vorher fragen, was mit den abhaengigen Automatiken wird. + * + * Ohne diese Frage verschwaende mit dem Ausloeser still die Bedingung des + * Nachfolgers (fk_cond_state steht auf ON DELETE CASCADE). Aus "zehn Minuten + * nach dem Wecker UND es ist hell" wuerde dann ein blosses "es ist hell" - + * eine andere Regel, die niemand angeordnet hat. + * + * Drei Antworten, deshalb kein confirm(): mitloeschen, abhaengen, abbrechen. + */ function deleteAutomation(id, name) { - if (!confirm("Automatik \"" + name + "\" wirklich löschen?")) return; - automationRequest("delete", { id: id }).then(antwort => { + fetch("./ajax/AutoAction.php?action=followers&id=" + id, { + headers: { "Requested-With-Ajax": "ajax" } + }) + .then(antwort => antwort.json()) + .then(daten => { + const folgen = (daten && daten.followers) || []; + if (!folgen.length) { + if (confirm("Automatik \"" + name + "\" wirklich löschen?")) { + automationLoeschen(id, "abhaengen"); + } + return; + } + frageNachfolger(id, name, folgen); + }) + .catch(() => { + // Auch ohne Auskunft loeschbar - dann eben mit der schonenden Variante. + if (confirm("Automatik \"" + name + "\" wirklich löschen?")) { + automationLoeschen(id, "abhaengen"); + } + }); +} + +function automationLoeschen(id, modus) { + automationRequest("delete", { id: id, mode: modus }).then(antwort => { if (antwort.error) alert(antwort.error); else refreshAutomations(null); }); } +/** Der Dialog mit den drei Antworten. Wird bei Bedarf gebaut. */ +function frageNachfolger(id, name, folgen) { + const alt = document.getElementById("modalAutoLoeschen"); + if (alt) alt.remove(); + + const liste = folgen + .map(f => "
  • " + textSicher(f.name) + + (f.enabled ? "" : " (pausiert)") + + "
  • ") + .join(""); + const wieViele = folgen.length === 1 + ? "Eine Automatik wird" : folgen.length + " Automatiken werden"; + + const huelle = document.createElement("div"); + huelle.innerHTML = + ""; + const knoten = huelle.firstChild; + document.body.appendChild(knoten); + + const dialog = new bootstrap.Modal(knoten); + const waehlen = modus => { + dialog.hide(); + automationLoeschen(id, modus); + }; + knoten.querySelector("#btnAutoAbhaengen").addEventListener("click", () => waehlen("abhaengen")); + knoten.querySelector("#btnAutoMit").addEventListener("click", () => waehlen("mitloeschen")); + knoten.addEventListener("hidden.bs.modal", () => knoten.remove()); + dialog.show(); +} + document.addEventListener("DOMContentLoaded", () => refreshAutomations(null)); diff --git a/restricted/automations.php b/restricted/automations.php index 107716c..d200754 100644 --- a/restricted/automations.php +++ b/restricted/automations.php @@ -54,6 +54,11 @@ function meshDb() * danach liegen kann - deshalb dieselben drei Faelle noch einmal mal zwei: * "+ 00:30" eine halbe Stunde nach Sonnenaufgang, "ab - 00:30" ab einer * halben Stunde davor, "vor + 00:30" bis eine halbe Stunde danach. + * + * Dieselbe Schreibweise traegt die Verkettung. Das gerechnete Geraet + * "Automatiken" fuehrt jede Automatik als Messwert, ihr Wert ist der + * Zeitpunkt der letzten Ausloesung (Datentyp `elapsed`) - "Wecker Magdalena + * + 00:10" heisst zehn Minuten danach. Ein Minus gibt es dort nicht. */ function operatorsForType($type) { @@ -67,6 +72,10 @@ function operatorsForType($type) return ["=", ">=", "<"]; case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz return ["+", "-", ">=+", ">=-", "<+", "<-"]; + case "elapsed": // andere Automatik: Wert ist ein Versatz danach + // Nur die drei Formen "danach". Ein Minus gibt es nicht: vor dem + // Auslöser kann nichts liegen, das erst der Auslöser anstößt. + return ["+", ">=+", "<+"]; case "bool": return ["="]; // JA/NEIN steht im Wertfeld, nicht im Operator default: @@ -157,6 +166,7 @@ function inputForType($type, $hasOptions) return "number"; case "time": case "deltatime": + case "elapsed": return "time"; case "date": return "date"; @@ -192,6 +202,14 @@ function operatorLabel($op, $type = "") return $beschriftung[$op]; } } + if ($type === "elapsed") { + // Dieselben Wörter wie beim Sonnenstand, damit ein Versatz überall + // gleich gelesen wird: "Wecker + 00:10" sind zehn Minuten danach. + $beschriftung = ["+" => "+", ">=+" => "ab +", "<+" => "vor +"]; + if (isset($beschriftung[$op])) { + return $beschriftung[$op]; + } + } return $op === "!=" ? "≠" : $op; } @@ -785,6 +803,7 @@ function saveAutomation($data) if (!$actions) { throw new InvalidArgumentException("Ohne Aktion haette die Automatik nichts zu tun."); } + pruefeKreis($data["id"] ?? 0, $conditions); $id = intval($data["id"] ?? 0); $enabled = !empty($data["enabled"]) ? 1 : 0; @@ -925,9 +944,186 @@ function normalizeTime($value, $fallback) return $fallback; } -function deleteAutomation($id) +/** + * Welcher Messwert des gerechneten Geraets "Automatiken" auf welche Automatik + * zeigt: state_id => automation_id. + * + * Die Zeilen legt der Runner an und fuehrt sie nach (ausloeser_nachfuehren()), + * hier werden sie nur gelesen. Gefuehrt wird nach der Kennung in der URL + * ("auto:15") und nicht nach dem Namen - wer umbenennt, soll die abhaengigen + * Bedingungen behalten. + */ +function ausloeserStates() { + static $karte = null; + if ($karte !== null) { + return $karte; + } + $karte = []; $db = meshDb(); + $res = $db->query("SELECT s.id, s.url FROM actor_states s + JOIN actors a ON a.id = s.actor_id + WHERE a.url = 'Automatik'"); + while ($row = $res->fetch_assoc()) { + if (preg_match('/^auto:(\d+)$/', strval($row["url"]), $t)) { + $karte[intval($row["id"])] = intval($t[1]); + } + } + return $karte; +} + +/** automation_id => Liste der Automatiken, die sie ausloesen. */ +function automationTriggers() +{ + $states = ausloeserStates(); + if (!$states) { + return []; + } + $db = meshDb(); + $res = $db->query("SELECT automation_id, state_id FROM automation_conditions"); + $karte = []; + while ($row = $res->fetch_assoc()) { + $wer = intval($row["automation_id"]); + $davor = $states[intval($row["state_id"])] ?? null; + if ($davor !== null && $davor !== $wer) { + $karte[$wer][$davor] = true; + } + } + return array_map("array_keys", $karte); +} + +/** Die Automatiken, die diese hier als Ausloeser benutzen. */ +function automationFollowers($id) +{ + $id = intval($id); + $liste = []; + foreach (automationTriggers() as $wer => $davor) { + if (in_array($id, $davor, true)) { + $liste[] = $wer; + } + } + if (!$liste) { + return []; + } + $db = meshDb(); + $platz = implode(",", array_fill(0, count($liste), "?")); + $stmt = $db->prepare("SELECT id, name, floor, enabled FROM automations + WHERE id IN ($platz) ORDER BY name"); + $stmt->bind_param(str_repeat("i", count($liste)), ...$liste); + $stmt->execute(); + $res = $stmt->get_result(); + $nachfolger = []; + while ($row = $res->fetch_assoc()) { + $nachfolger[] = [ + "id" => intval($row["id"]), + "name" => $row["name"], + "floor" => $row["floor"], + "enabled" => (bool)$row["enabled"], + ]; + } + $stmt->close(); + return $nachfolger; +} + +/** + * Wuerde diese Automatik sich mittelbar selbst ausloesen? + * + * Ein Kreis (A loest B loest A) ist kein Fehler, den man im Betrieb bemerkt: + * die Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im + * Protokoll sieht das aus wie eine Automatik, die halt oft laeuft. Deshalb + * wird er hier abgelehnt und nicht dort geheilt. Der Runner meldet einen + * Kreis trotzdem - falls doch einer an der Datenbank vorbei entsteht. + */ +function pruefeKreis($id, $conditions) +{ + $id = intval($id); + if ($id <= 0) { + return; // eine neue Automatik kennt noch niemand + } + $states = ausloeserStates(); + $davor = []; + foreach ($conditions as $c) { + $wer = $states[intval($c["state_id"] ?? 0)] ?? null; + if ($wer !== null) { + $davor[] = $wer; + } + } + if (!$davor) { + return; + } + if (in_array($id, $davor, true)) { + throw new InvalidArgumentException( + "Eine Automatik kann sich nicht selbst auslösen."); + } + $kanten = automationTriggers(); + $kanten[$id] = $davor; + $offen = $davor; + $gesehen = []; + while ($offen) { + $aktuell = array_pop($offen); + if ($aktuell === $id) { + throw new InvalidArgumentException( + "Das ergäbe einen Kreis: die Automatik würde sich mittelbar " + . "selbst auslösen."); + } + if (isset($gesehen[$aktuell])) { + continue; + } + $gesehen[$aktuell] = true; + foreach ($kanten[$aktuell] ?? [] as $weiter) { + $offen[] = $weiter; + } + } +} + +/** + * Loeschen - und was mit den Automatiken geschieht, die davon abhaengen. + * + * Nichts zu tun waere die schlechteste Wahl: `fk_cond_state` steht auf + * ON DELETE CASCADE, mit dem Messwert verschwaende also still die Bedingung + * des Nachfolgers. Bei der LETZTEN Bedingung faengt der Runner das ab (eine + * Automatik ohne Bedingung loest nie aus), bei einer von zweien niemand: aus + * "zehn Minuten nach dem Wecker UND es ist hell" wuerde ein blosses "es ist + * hell", und der Rollladen fuehre ab morgen jeden Tag bei Sonnenaufgang hoch. + * + * abhaengen die Bedingung wird entfernt und der Nachfolger pausiert. + * Eine pausierte Automatik mit sichtbarer Luecke ist besser + * als eine stille Regelaenderung. + * mitloeschen der Nachfolger geht mit, und dessen Nachfolger auch. + */ +function deleteAutomation($id, $modus = "abhaengen", &$gesehen = []) +{ + $id = intval($id); + if (isset($gesehen[$id])) { + return; // Schutz gegen einen Kreis, den es nicht geben sollte + } + $gesehen[$id] = true; + + $db = meshDb(); + $nachfolger = automationFollowers($id); + $eigeneStates = array_keys(ausloeserStates(), $id, true); + + if ($nachfolger && $modus === "mitloeschen") { + foreach ($nachfolger as $n) { + deleteAutomation($n["id"], "mitloeschen", $gesehen); + } + } elseif ($nachfolger && $eigeneStates) { + $platz = implode(",", array_fill(0, count($eigeneStates), "?")); + foreach ($nachfolger as $n) { + $args = array_merge([$n["id"]], $eigeneStates); + $stmt = $db->prepare("DELETE FROM automation_conditions + WHERE automation_id = ? AND state_id IN ($platz)"); + $stmt->bind_param(str_repeat("i", count($args)), ...$args); + $stmt->execute(); + $stmt->close(); + $stmt = $db->prepare("UPDATE automations SET enabled = 0, changed = NOW() + WHERE id = ?"); + $stmt->bind_param("i", $n["id"]); + $stmt->execute(); + $stmt->close(); + } + } + $stmt = $db->prepare("DELETE FROM automations WHERE id = ?"); $stmt->bind_param("i", $id); $stmt->execute(); @@ -970,6 +1166,7 @@ function listAutomations($floor) $namesState = stateNames(); $namesCommand = commandNames(); $labelsParam = parameterLabels(); + $kanten = automationTriggers(); $list = []; foreach ($ids as $id) { @@ -979,6 +1176,10 @@ function listAutomations($floor) } $auto["conditionText"] = describeConditions($auto["conditions"], $namesState); $auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam); + // Woran diese Automatik haengt - die Uebersicht rueckt Nachfolger + // unter ihren Ausloeser ein, statt sie alphabetisch dazwischen zu + // streuen. + $auto["triggers"] = $kanten[$id] ?? []; $list[] = $auto; } return $list;