diff --git a/ajax/AutoAction.php b/ajax/AutoAction.php index 96365dc..40cb677 100644 --- a/ajax/AutoAction.php +++ b/ajax/AutoAction.php @@ -228,6 +228,12 @@ if ($action === "list") { if ($a["once_per_day"]) { $wann .= ", nur einmal am Tag"; } + // Vor der Sperre, weil es auch zeitlich davor liegt: erst muss + // die Bedingung stehen bleiben, dann wird ausgeloest. + if (!empty($a["hold_secs"])) { + $wann .= ", erst nach " + . intdiv($a["hold_secs"], 60) . " Min. ununterbrochen"; + } if ($a["lockout_secs"] > 0) { $wann .= ", frühestens alle " . ($a["lockout_secs"] >= 60 @@ -306,6 +312,7 @@ $nutzdaten = json_encode([ "automation" => $auto, "devices" => deviceCatalog(), "lockouts" => lockoutChoices(), + "haltezeiten" => haltezeitChoices(), "ferien" => kalendertagChoices("Ferien", "in den Ferien"), "feiertage" => kalendertagChoices("Feiertage", "an Feiertagen"), "tagvorlagen" => tagVorlagen(), @@ -458,6 +465,18 @@ $nutzdaten = json_encode([ + +
+
+ Auslösen + +
+
diff --git a/doku/automatiken.md b/doku/automatiken.md index d199eeb..9d4d0b2 100644 --- a/doku/automatiken.md +++ b/doku/automatiken.md @@ -59,6 +59,7 @@ erDiagram tinyint force_once "am Fensterende notfalls doch" tinyint once_per_day "höchstens einmal am Tag" int lockout_secs "Sperrzeit nach einem Lauf" + int hold_secs "Haltezeit vor dem Auslösen" tinyint cond_met "Laufzustand: war die Bedingung zuletzt wahr?" datetime last_run "Laufzustand: wann zuletzt ausgelöst" timestamp changed "Signal an den Runner" @@ -72,13 +73,13 @@ erDiagram } ``` -Drei Felder sind **nachgerüstet** und stehen deshalb nicht in der +Vier Felder sind **nachgerüstet** und stehen deshalb nicht in der Schema-Datei, sondern in eigenen Skripten im SolarManager-Repo (`autoActions/`): `next_day` (`vorabend.sql`), `once_per_day` und die Dreiwertigkeit von `on_vacation`/`on_holiday` (`rahmen_erweitern.sql`), -sowie der Datentyp `elapsed` samt gerechnetem Gerät „Automatiken“ -(`automatik_ausloeser.sql`). Wer die Datenbank neu aufsetzt, spielt sie nach -`homeMesh_automations.sql` ein. +`hold_secs` (`haltezeit.sql`), sowie der Datentyp `elapsed` samt gerechnetem +Gerät „Automatiken“ (`automatik_ausloeser.sql`). Wer die Datenbank neu +aufsetzt, spielt sie nach `homeMesh_automations.sql` ein. Drei Spalten in `automations` sind **kein Regelwerk, sondern Laufzustand**: `cond_met`, `last_run` und `changed`. Sie werden vom Runner geschrieben. @@ -169,6 +170,7 @@ sonst steht in der Übersicht etwas anderes als im Editor. | `next_day` | Der Rahmen gilt für **morgen** (Vorabend-Form) | Uhrzeit und Fenster bleiben bei heute | | `window_from`/`window_to` | Zeitfenster; `von > bis` heißt über Mitternacht | | | `lockout_secs` | Sperrzeit nach einem Lauf (0 / 60 / 900) | | +| `hold_secs` | Haltezeit: so lange muss die Bedingung **ununterbrochen** erfüllt sein (0 = sofort) | Zählt im Speicher des Runners — ein Neustart fängt die Zeit von vorn an | | `once_per_day` | höchstens ein Lauf je Kalendertag | | | `force_once` | am Ende des Fensters notfalls doch ausführen | greift nur, wenn im Fenster gar nichts lief | @@ -282,7 +284,9 @@ flowchart TB C -- nein --> Z1["Fenster zu:
force_once prüfen,
Flanke zurücksetzen"] C -- ja --> D{"Bedingung erfüllt?
any(all(gruppe))"} D -- nein --> E["cond_met = 0"] - D -- ja --> F{"cond_met schon 1?"} + D -- ja --> HZ{"hold_secs erfüllt?
lange genug am Stück"} + HZ -- nein --> G + HZ -- ja --> F{"cond_met schon 1?"} F -- ja --> G["nichts tun
keine neue Flanke"] F -- nein --> H{"once_per_day
und lief heute?"} H -- ja --> G @@ -297,6 +301,40 @@ flowchart TB `cond_met` hält fest, ob die Bedingung beim letzten Durchlauf schon erfüllt war. Ohne das würde „Temperatur über 22 °C“ in jedem Takt erneut feuern. +### Haltezeit: erst, wenn es dabei bleibt + +Manches zeigt sich nicht in einem einzelnen Messwert, sondern erst in seiner +Dauer. „Der Wasserzähler läuft“ ist jedes Händewaschen; „läuft seit einer +halben Stunde ohne Pause“ ist ein offener Hahn. `hold_secs` schiebt deshalb +die Flanke nach hinten: Erst wenn die Bedingung so viele Sekunden **am +Stück** erfüllt war, gilt sie als erfüllt. + +``` +Bedingung ──┐ ┌──────────────────────────────────┐ + └───┘ └──── + ↑ ↑ ↑ + angefangen nach 30 min: Bedingung weg, + (Zeit läuft) ausgelöst Zeit verfällt +``` + +Entscheidend ist, dass `cond_met` bis dahin auf 0 bleibt: Die Flanke wird +**aufgeschoben, nicht verbraucht**. Alles danach — Tagessperre, Sperrzeit, +Protokoll — bleibt unverändert. + +Was man dazu wissen sollte: + +* **Ein Aussetzer setzt zurück.** Auch ein einzelner Takt genügt; genau das + ist mit „ununterbrochen“ gemeint. +* **Gezählt wird im Speicher** (`erfuellt_seit`), nicht in der Datenbank. Es + ist Laufzustand wie `war_aktiv` — und nach einem Neustart weiß niemand, ob + die Bedingung zwischendurch angelegen hat. Die Zeit beginnt dann von vorn. +* **Die Messrate ist die Untergrenze der Genauigkeit.** Ein Zähler, der alle + fünf Minuten meldet, macht die Haltezeit auf fünf Minuten genau. Mit einem + punktgenauen Zeit-Auslöser („um 16:30“, nur im Nachholfenster wahr) ist sie + gar nicht sinnvoll zu kombinieren — sie würde nie reif. +* **Das Zeitfenster schneidet sie ab.** Geht das Fenster zu, verfällt eine + angefangene Haltezeit: Sie soll innerhalb des Fensters voll gelaufen sein. + ### Die drei Zeitformen | Schreibweise | Operator | Bedeutung | Gedacht für | @@ -339,6 +377,7 @@ heute um 06:00 wahr, an einem Tag, an dem der Auslöser gar nicht lief. | Mittel | Wogegen | Verhalten | |---|---|---| +| `hold_secs` | Zustände, die erst durch ihre Dauer auffallen (Wasser läuft, Fenster steht offen) | Die Flanke wird **aufgeschoben**, bis die Bedingung lange genug am Stück steht. Der Gegenspieler zur Sperrzeit: die bremst die Wiederholung, diese den ersten Lauf | | `lockout_secs` | Messwerte, die um die Schwelle pendeln (22,1 / 21,9 / 22,1) | Die Flanke wird **verworfen**, nicht aufgehoben — ein Rollladen, der eine Viertelstunde später doch losfährt, wäre unangenehmer als einer, der gar nicht fährt | | `once_per_day` | Dinge, die man hinterher von Hand anders stellt | Nach dem ersten Lauf ist bis Mitternacht Ruhe. Die Sperrzeit taugt dafür nicht: sie zählt Sekunden und verfehlte am nächsten Morgen den Termin | | `force_once` | verpasste Fenster | Beim Zugehen des Fensters, aber nur, wenn darin nichts lief (zusätzlich gegen `last_run` geprüft, damit ein Neustart nicht doppelt auslöst) | @@ -392,6 +431,7 @@ räumt der Runner selbst weg. | Automatik läuft gar nicht, ohne Protokolleintrag | Rahmen: falscher Wochentag, Ferien/Feiertag auf „nie“, Fenster zu eng — oder die Bedingung war nie *neu* erfüllt (`cond_met` stand schon auf 1) | | Läuft einmal und dann nicht mehr am selben Tag | `once_per_day` | | Läuft kurz nacheinander nicht erneut | `lockout_secs` | +| Läuft gar nicht, obwohl die Bedingung sichtbar zutrifft | `hold_secs`: sie war noch nicht lange genug am Stück erfüllt, oder ein Aussetzer hat die Zeit zurückgesetzt | | Kette bleibt stehen | Auslöser pausiert oder an dem Tag nicht dran | | Änderung im Editor wirkt nicht | Regelwerk-Signatur: erst beim nächsten `reload_seconds`-Fenster (30 s) | | `error` im Protokoll | Gerät nicht erreichbar, Kommando oder Transport gibt es nicht mehr | @@ -407,3 +447,5 @@ räumt der Runner selbst weg. | … eine neue Geräteart anschließen | eine Klasse in `SolarManager/autoActions/transports.py` (Lesen + Senden), Erkennung an der Aktor-URL | | … verstehen, warum etwas lief | `automation_log`, dann die Zeitleiste des Tages (→ [zeitleiste.md](zeitleiste.md)) | | … das Nachholfenster ändern | `catchup_minutes` in `config.ini` des Runners | +| … eine weitere Stufe für Sperre oder Haltezeit | `lockoutChoices()` / `haltezeitChoices()` in `restricted/automations.php` — eine Zeile, der Rest zieht nach | +| … auf Dauerzustände reagieren (Wasser läuft, Tür steht offen) | Haltezeit im Rahmen, dazu eine Aktion des Geräts „Benachrichtigungen“ (→ [benachrichtigungen.md](benachrichtigungen.md)) | diff --git a/js/solar/autoActionFuncs.js b/js/solar/autoActionFuncs.js index 414ef8d..4279a00 100644 --- a/js/solar/autoActionFuncs.js +++ b/js/solar/autoActionFuncs.js @@ -26,6 +26,7 @@ let autoModel = null; let autoDevices = []; let autoLockouts = []; +let autoHaltezeiten = []; let autoFerien = []; let autoFeiertage = []; let autoTagVorlagen = []; @@ -1125,6 +1126,9 @@ function rahmenKurzText() { teile.push(autoModel.window_from === "00:00" && autoModel.window_to === "23:59" ? "rund um die Uhr" : autoModel.window_from + "–" + autoModel.window_to); + if (autoModel.hold_secs > 0) { + teile.push("erst nach " + Math.floor(autoModel.hold_secs / 60) + " Min."); + } if (autoModel.lockout_secs > 0) { teile.push("Sperre " + (autoModel.lockout_secs >= 60 ? Math.floor(autoModel.lockout_secs / 60) + " Min." @@ -1143,6 +1147,7 @@ function rahmenIstVorgabe() { && autoModel.window_from === "00:00" && autoModel.window_to === "23:59" && !autoModel.once_per_day + && !autoModel.hold_secs && !autoModel.force_once; } @@ -1173,6 +1178,9 @@ function fuelleFormular() { const sperre = document.getElementById("lockoutSecs"); optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })), autoModel.lockout_secs); + optionListe(document.getElementById("holdSecs"), + autoHaltezeiten.map(o => ({ value: o.value, text: o.label })), + autoModel.hold_secs); document.getElementById("autoName").value = autoModel.name; // Kennt die Liste die Etage nicht, bleibt eine Auswahlliste sonst leer // stehen und schickt beim Speichern "" mit. @@ -1207,6 +1215,7 @@ function leseFormular() { autoModel.once_per_day = document.getElementById("oncePerDay").checked; autoModel.next_day = document.getElementById("nextDay").checked; autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value); + autoModel.hold_secs = Number(document.getElementById("holdSecs").value); } /** @@ -1214,7 +1223,8 @@ function leseFormular() { * Ohne das stuende dort "rund um die Uhr", waehrend darunter 08:45 steht. */ function rahmenFelderVerdrahten() { - ["tSpanFrom", "tSpanTo", "lockoutSecs", "runOnce", "oncePerDay", "nextDay"].forEach(id => { + ["tSpanFrom", "tSpanTo", "lockoutSecs", "holdSecs", + "runOnce", "oncePerDay", "nextDay"].forEach(id => { const feld = document.getElementById(id); if (feld) feld.addEventListener("change", () => { leseFormular(); renderRahmen(); }); }); @@ -1242,6 +1252,7 @@ function loadAutomatic() { const daten = JSON.parse(document.getElementById("autoActionData").textContent); autoDevices = daten.devices; autoLockouts = daten.lockouts; + autoHaltezeiten = daten.haltezeiten || []; autoFerien = daten.ferien || []; autoFeiertage = daten.feiertage || []; autoTagVorlagen = daten.tagvorlagen || []; @@ -1263,6 +1274,7 @@ function loadAutomatic() { force_once: a.force_once, once_per_day: a.once_per_day, lockout_secs: a.lockout_secs, + hold_secs: a.hold_secs || 0, groups: [], actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) })) }; @@ -1352,6 +1364,7 @@ function submitAutoAction() { force_once: autoModel.force_once, once_per_day: autoModel.once_per_day, lockout_secs: autoModel.lockout_secs, + hold_secs: autoModel.hold_secs, conditions: [], actions: autoModel.actions }; diff --git a/restricted/automations.php b/restricted/automations.php index 1bda626..ce3aae8 100644 --- a/restricted/automations.php +++ b/restricted/automations.php @@ -211,6 +211,31 @@ function lockoutChoices() ]; } +/** + * Wie lange die Bedingung anliegen muss, bevor ausgeloest wird. + * + * Der Gegenspieler zur Sperrzeit: die begrenzt, wie oft ausgeloest wird, + * diese, wie lange etwas anliegen muss, bevor es ueberhaupt dazu kommt. + * "Der Wasserzaehler laeuft" ist jedes Haendewaschen - "laeuft seit einer + * halben Stunde ohne Pause" ist ein offener Hahn. + * + * Faellt die Bedingung zwischendurch aus, faengt die Zeit von vorn an; genau + * das ist mit "ununterbrochen" gemeint. Gezaehlt wird im Runner. + * + * 0 ist die Vorgabe und heisst: wie bisher, sofort. + */ +function haltezeitChoices() +{ + return [ + ["value" => 0, "label" => "sofort"], + ["value" => 300, "label" => "nach 5 Min. ununterbrochen"], + ["value" => 900, "label" => "nach 15 Min. ununterbrochen"], + ["value" => 1800, "label" => "nach 30 Min. ununterbrochen"], + ["value" => 3600, "label" => "nach 1 Std. ununterbrochen"], + ["value" => 10800, "label" => "nach 3 Std. ununterbrochen"], + ]; +} + /** * Auswahlwerte aus possible_values in eine einheitliche Form bringen. * @@ -805,6 +830,7 @@ function emptyAutomation($floor) "force_once" => false, "once_per_day" => false, "lockout_secs" => 60, + "hold_secs" => 0, "last_run" => null, "conditions" => [], "actions" => [], @@ -842,6 +868,8 @@ function loadAutomation($id) // rahmen_erweitern.sql legt die Spalte erst an. "once_per_day" => (bool)($row["once_per_day"] ?? 0), "lockout_secs" => intval($row["lockout_secs"]), + // ?? 0 wie oben: haltezeit.sql legt die Spalte erst an. + "hold_secs" => intval($row["hold_secs"] ?? 0), "last_run" => $row["last_run"], "conditions" => [], "actions" => [], @@ -936,6 +964,10 @@ function saveAutomation($data) if (!in_array($lockout, array_column(lockoutChoices(), "value"), true)) { $lockout = 60; } + $halten = intval($data["hold_secs"] ?? 0); + if (!in_array($halten, array_column(haltezeitChoices(), "value"), true)) { + $halten = 0; + } $db->begin_transaction(); try { @@ -961,11 +993,12 @@ function saveAutomation($data) $stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?, window_from = ?, window_to = ?, weekdays = ?, on_vacation = ?, on_holiday = ?, next_day = ?, force_once = ?, - once_per_day = ?, lockout_secs = ?, changed = NOW() + once_per_day = ?, lockout_secs = ?, hold_secs = ?, + changed = NOW() WHERE id = ?"); - $stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, + $stmt->bind_param("ssissiiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce, - $oncePerDay, $lockout, $id); + $oncePerDay, $lockout, $halten, $id); $stmt->execute(); $stmt->close(); $db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id); @@ -974,11 +1007,11 @@ function saveAutomation($data) $stmt = $db->prepare("INSERT INTO automations (name, floor, enabled, window_from, window_to, weekdays, on_vacation, on_holiday, next_day, force_once, - once_per_day, lockout_secs) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); - $stmt->bind_param("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, + once_per_day, lockout_secs, hold_secs) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"); + $stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce, - $oncePerDay, $lockout); + $oncePerDay, $lockout, $halten); $stmt->execute(); $id = $db->insert_id; $stmt->close();