Automatiken als Ausloeser: Editor, Uebersicht, Loeschen

Gegenstueck zur Runner-Seite. Der Editor selbst braucht nichts: das
gerechnete Geraet "Automatiken" fuehrt jede Automatik als Messwert, und
der Geraetewaehler listet ohnehin Geraete und deren Messwerte. Neu sind
nur die Operatoren des Datentyps `elapsed` ("+", "ab +", "vor +") und
sein Eingabefeld.

Drei Stellen wissen trotzdem davon:

pruefeKreis() lehnt beim Speichern ab, was sich mittelbar selbst
ausloesen wuerde. Im Betrieb waere ein Kreis kaum zu bemerken - die
Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im
Protokoll sieht das aus wie eine Automatik, die halt oft laeuft.

deleteAutomation() fragt vorher. fk_cond_state steht auf ON DELETE
CASCADE, mit dem Messwert verschwaende also still die Bedingung des
Nachfolgers: 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. Bei der letzten Bedingung faengt der Runner das
ab, bei einer von zweien niemand. Zur Wahl stehen Abhaengen (Bedingung
raus, Nachfolger pausiert) und Mitloeschen; automatisch mitzuloeschen
waere die falsche Vorgabe.

Die Uebersicht rueckt Nachfolger unter ihren Ausloeser ein und zeigt sie
eingeklappt. Das ist nur die Darstellung - jeder Nachfolger bleibt eine
vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen und
eigener Zeile im Protokoll. Sobald "gruppiert" auch "gehoert dazu"
hiesse, waere zu klaeren, wem die Pause gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:51:42 +02:00
co-authored by Claude Opus 5
parent c6a879b424
commit b607d4a064
4 changed files with 474 additions and 11 deletions
+34 -3
View File
@@ -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.
+118 -5
View File
@@ -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") {
. "<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>";
/*
* 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 "<tr" . $klasse . ">";
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
$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 = "<button type='button' class='btn btn-link btn-sm p-0 me-1 text-decoration-none'"
. " title='Abhängige Automatiken zeigen'"
. " data-gruppe='" . $id . "'"
. " onclick='toggleAutomationGroup(" . $id . ", this)'>"
. "<i class='bi bi-chevron-right'></i> " . $kinder . "</button>";
}
$einzug = $tiefe > 0
? "<span class='text-body-secondary me-1'>"
. str_repeat("&nbsp;&nbsp;&nbsp;", $tiefe)
. "<i class='bi bi-arrow-return-right'></i></span> "
: "";
echo "<tr data-auto='" . $id . "'" . $klasse . ">";
echo "<td class='text-nowrap'>" . $einzug . $griff
. 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>";
+120 -2
View File
@@ -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 => "<li>" + textSicher(f.name)
+ (f.enabled ? "" : " <span class='text-body-secondary'>(pausiert)</span>")
+ "</li>")
.join("");
const wieViele = folgen.length === 1
? "Eine Automatik wird" : folgen.length + " Automatiken werden";
const huelle = document.createElement("div");
huelle.innerHTML =
"<div class='modal fade' id='modalAutoLoeschen' tabindex='-1'>"
+ "<div class='modal-dialog modal-dialog-centered'><div class='modal-content'>"
+ "<div class='modal-header'><h5 class='modal-title'>„" + textSicher(name) + "“ löschen</h5>"
+ "<button type='button' class='btn-close' data-bs-dismiss='modal'></button></div>"
+ "<div class='modal-body'><p>" + wieViele + " von dieser hier ausgelöst:</p>"
+ "<ul>" + liste + "</ul>"
+ "<p class='mb-0 text-body-secondary small'>Beim Abhängen verlieren sie nur diese "
+ "eine Bedingung. Damit sie danach nicht mit einer schwächeren Regel weiterlaufen, "
+ "werden sie pausiert.</p></div>"
+ "<div class='modal-footer'>"
+ "<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Abbrechen</button>"
+ "<button type='button' class='btn btn-warning' id='btnAutoAbhaengen'>Abhängen und pausieren</button>"
+ "<button type='button' class='btn btn-danger' id='btnAutoMit'>Alle löschen</button>"
+ "</div></div></div></div>";
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));
+202 -1
View File
@@ -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;