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 "
";
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 => "
Beim Abhängen verlieren sie nur diese "
+ + "eine Bedingung. Damit sie danach nicht mit einer schwächeren Regel weiterlaufen, "
+ + "werden sie pausiert.
"
+ + "
";
+ 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;