Sperrzeit je Automatik in drei Stufen

Die steigende Flanke allein schuetzt nicht gegen einen Messwert, der um die
Schwelle pendelt: "Temperatur > 22" bei 22,1 / 21,9 / 22,1 Grad ist jedes
Mal eine echte Flanke, und ueber MQTT koennen die Werte im Sekundentakt
hereinkommen. Gemessen: fuenf Kommandos in einer halben Minute.

automations.lockout_secs sagt jetzt, wie lange nach einer Ausloesung nicht
wieder geschaltet wird. Der Editor bietet drei Stufen an - ohne, eine
Minute, eine Viertelstunde -, weil die passende Wahl am Geraet haengt und
nicht an einer Zahl: ein Rollladen soll nicht alle zwanzig Sekunden
losfahren, eine Lichtfarbe darf das. Gespeichert werden Sekunden, damit eine
vierte Stufe eine Zeile in lockoutChoices() ist und keine Wanderung durch
die Datenbank. Vorbelegt ist eine Minute.

Eine Flanke in der Sperrzeit wird verworfen, nicht aufgehoben. Ein
Rollladen, der eine Viertelstunde spaeter doch noch losfaehrt, weil vor
langer Zeit einmal eine Schwelle gestreift wurde, waere unangenehmer als
einer, der gar nicht faehrt - und der naechste echte Anlass nach Ablauf der
Sperre kommt ohnehin durch. Verworfene Flanken stehen auf DEBUG und nicht in
automation_log, sonst waere die Tabelle bei einem zappelnden Sensor voll
davon.

force_once bleibt unberuehrt: es greift nur, wenn im Fenster gar nichts
gelaufen ist - dann ist auch keine Sperre aktiv.

Nachgemessen am pendelnden Sensor: mit 60 s Sperre ein Kommando, ohne Sperre
fuenf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 13:26:18 +02:00
co-authored by Claude Opus 5
parent 25d3c47bd3
commit 27e5c0d331
6 changed files with 115 additions and 8 deletions
+11
View File
@@ -93,6 +93,12 @@ if ($action === "list") {
if (!$a["on_vacation"]) { if (!$a["on_vacation"]) {
$wann .= ", nicht in den Ferien"; $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"])) : ""; $zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "";
$klasse = $a["enabled"] ? "" : " class='opacity-50'"; $klasse = $a["enabled"] ? "" : " class='opacity-50'";
@@ -134,6 +140,7 @@ if (!$auto) {
$nutzdaten = json_encode([ $nutzdaten = json_encode([
"automation" => $auto, "automation" => $auto,
"devices" => deviceCatalog(), "devices" => deviceCatalog(),
"lockouts" => lockoutChoices(),
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
?> ?>
@@ -212,6 +219,10 @@ $nutzdaten = json_encode([
<label class="form-check-label" for="actFeier">An Feiertagen ausführen</label> <label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
</div> </div>
</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 mb-0">
<div class="input-group-text"> <div class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" id="runOnce"> <input class="form-check-input mt-0 me-2" type="checkbox" id="runOnce">
+1
View File
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS `automations` (
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren', `on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren', `on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren', `force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern', `cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
`last_run` datetime DEFAULT NULL COMMENT 'Zuletzt ausgeloest, NULL = noch nie', `last_run` datetime DEFAULT NULL COMMENT 'Zuletzt ausgeloest, NULL = noch nie',
`changed` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT 'Signal an den Runner, das Regelwerk neu zu laden', `changed` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT 'Signal an den Runner, das Regelwerk neu zu laden',
+8
View File
@@ -19,6 +19,7 @@
let autoModel = null; let autoModel = null;
let autoDevices = []; let autoDevices = [];
let autoLockouts = [];
// --- Katalog-Helfer ------------------------------------------------------ // --- Katalog-Helfer ------------------------------------------------------
@@ -368,6 +369,9 @@ function renderWeekdays() {
} }
function fuelleFormular() { function fuelleFormular() {
const sperre = document.getElementById("lockoutSecs");
optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })),
autoModel.lockout_secs);
document.getElementById("autoName").value = autoModel.name; document.getElementById("autoName").value = autoModel.name;
document.getElementById("autoFloor").value = autoModel.floor; document.getElementById("autoFloor").value = autoModel.floor;
document.getElementById("tSpanFrom").value = autoModel.window_from; document.getElementById("tSpanFrom").value = autoModel.window_from;
@@ -389,6 +393,7 @@ function leseFormular() {
autoModel.on_vacation = document.getElementById("actFerien").checked; autoModel.on_vacation = document.getElementById("actFerien").checked;
autoModel.on_holiday = document.getElementById("actFeier").checked; autoModel.on_holiday = document.getElementById("actFeier").checked;
autoModel.force_once = document.getElementById("runOnce").checked; autoModel.force_once = document.getElementById("runOnce").checked;
autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value);
} }
// --- Modal --------------------------------------------------------------- // --- Modal ---------------------------------------------------------------
@@ -396,6 +401,7 @@ function leseFormular() {
function loadAutomatic() { function loadAutomatic() {
const daten = JSON.parse(document.getElementById("autoActionData").textContent); const daten = JSON.parse(document.getElementById("autoActionData").textContent);
autoDevices = daten.devices; autoDevices = daten.devices;
autoLockouts = daten.lockouts;
const a = daten.automation; const a = daten.automation;
autoModel = { autoModel = {
@@ -409,6 +415,7 @@ function loadAutomatic() {
on_vacation: a.on_vacation, on_vacation: a.on_vacation,
on_holiday: a.on_holiday, on_holiday: a.on_holiday,
force_once: a.force_once, force_once: a.force_once,
lockout_secs: a.lockout_secs,
groups: [], groups: [],
actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) })) actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) }))
}; };
@@ -501,6 +508,7 @@ function submitAutoAction() {
on_vacation: autoModel.on_vacation, on_vacation: autoModel.on_vacation,
on_holiday: autoModel.on_holiday, on_holiday: autoModel.on_holiday,
force_once: autoModel.force_once, force_once: autoModel.force_once,
lockout_secs: autoModel.lockout_secs,
conditions: [], conditions: [],
actions: autoModel.actions actions: autoModel.actions
}; };
+29
View File
@@ -81,6 +81,35 @@ und `vor` verschiebt sich der wahre Bereich entsprechend mit.
`force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn `force_once` („am Ende des Zeitraums auf jeden Fall ausführen") greift, wenn
das Fenster zugeht und in diesem Fenster noch nichts passiert ist. das Fenster zugeht und in diesem Fenster noch nichts passiert ist.
## Sperrzeit
Die Flanke allein schützt nicht gegen einen Messwert, der um die Schwelle
**pendelt**: „Temperatur > 22" bei 22,1 / 21,9 / 22,1 °C ist jedes Mal eine
echte steigende Flanke, und über MQTT können die Werte im Sekundentakt
hereinkommen. `automations.lockout_secs` sagt, wie lange nach einer Auslösung
nicht wieder geschaltet wird. Der Editor bietet drei Stufen an:
| | | gedacht für |
|---|---|---|
| Ohne | 0 s | volle Geschwindigkeit, jede Flanke schaltet |
| Kurz | 60 s | Licht, Farbe, Dimmwert |
| Lang | 900 s | Rollläden, Ventile, alles mit Motor |
Gespeichert werden Sekunden, angeboten werden nur die drei Stufen — eine
vierte ist damit eine Zeile in `lockoutChoices()` und keine Wanderung durch
die Datenbank.
Eine Flanke innerhalb der Sperrzeit wird **verworfen, nicht aufgehoben**. Ein
Rollladen, der eine Viertelstunde später doch noch losfährt, weil vor langer
Zeit einmal eine Schwelle gestreift wurde, wäre unangenehmer als einer, der
gar nicht fährt — und der nächste echte Anlass nach Ablauf der Sperre kommt
ohnehin durch. Verworfene Flanken stehen im Log auf `DEBUG`, nicht in
`automation_log`; bei einem zappelnden Sensor wäre die Tabelle sonst voll
davon.
`force_once` ist von der Sperre nicht betroffen: es greift nur, wenn im
Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv.
## Transporte ## Transporte
Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`: Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
@@ -575,6 +575,10 @@ class Runner:
self.lief_im_fenster[automatik["id"]] = False self.lief_im_fenster[automatik["id"]] = False
erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte, jetzt) erfuellt = gruppen_erfuellt(automatik, self.regelwerk, self.werte, jetzt)
if erfuellt and not automatik["cond_met"]: if erfuellt and not automatik["cond_met"]:
if self.gesperrt(automatik, jetzt):
logger.debug("%s: Flanke faellt in die Sperrzeit, uebersprungen",
automatik["name"])
else:
self.ausloesen(automatik, "fired") self.ausloesen(automatik, "fired")
self.flanke_merken(automatik, erfuellt) self.flanke_merken(automatik, erfuellt)
else: else:
@@ -595,6 +599,30 @@ class Runner:
self.war_aktiv[automatik["id"]] = aktiv self.war_aktiv[automatik["id"]] = aktiv
@staticmethod
def gesperrt(automatik, jetzt):
"""
Liegt die letzte Ausloesung noch innerhalb der Sperrzeit?
Gegen Messwerte, die um die Schwelle pendeln: "Temperatur > 22" bei
22,1 / 21,9 / 22,1 Grad ist jedes Mal eine echte steigende Flanke, und
ueber MQTT koennen die Werte im Sekundentakt hereinkommen.
Die Flanke wird dabei verworfen und nicht aufgehoben. Ein Rollladen,
der eine Viertelstunde spaeter doch noch losfaehrt, weil vor langer
Zeit einmal eine Schwelle gestreift wurde, waere unangenehmer als
einer, der gar nicht faehrt. Der naechste echte Anlass nach Ablauf
der Sperre kommt ohnehin durch.
force_once ist davon nicht betroffen: es greift nur, wenn im Fenster
gar nichts gelaufen ist - dann ist auch keine Sperre aktiv.
"""
sperre = int(automatik.get("lockout_secs") or 0)
letzter = automatik.get("last_run")
if not sperre or not letzter:
return False
return (jetzt - letzter).total_seconds() < sperre
@staticmethod @staticmethod
def lief_heute(automatik, jetzt): def lief_heute(automatik, jetzt):
letzter = automatik.get("last_run") letzter = automatik.get("last_run")
+37 -7
View File
@@ -79,6 +79,27 @@ function operatorChoices($type)
return $liste; return $liste;
} }
/**
* Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet.
*
* Gedacht gegen Messwerte, die um die Schwelle pendeln - "Temperatur > 22"
* bei 22,1 / 21,9 / 22,1 Grad ist jedes Mal eine echte steigende Flanke, und
* ohne Sperre ginge jedes Mal ein Kommando raus. Wie schlimm das ist, haengt
* am Geraet: ein Rollladen soll nicht alle zwanzig Sekunden losfahren, eine
* Lichtfarbe darf das.
*
* Gespeichert werden Sekunden, angeboten werden nur diese Stufen - eine
* vierte ist damit eine Zeile und keine Wanderung durch die Datenbank.
*/
function lockoutChoices()
{
return [
["value" => 0, "label" => "Ohne jede Flanke schaltet"],
["value" => 60, "label" => "1 Minute z.B. Licht und Farbe"],
["value" => 900, "label" => "15 Minuten z.B. Rollläden und Ventile"],
];
}
/** Wie das Wertfeld im Editor aussieht. */ /** Wie das Wertfeld im Editor aussieht. */
function inputForType($type, $hasOptions) function inputForType($type, $hasOptions)
{ {
@@ -254,6 +275,7 @@ function emptyAutomation($floor)
"on_vacation" => true, "on_vacation" => true,
"on_holiday" => true, "on_holiday" => true,
"force_once" => false, "force_once" => false,
"lockout_secs" => 60,
"last_run" => null, "last_run" => null,
"conditions" => [], "conditions" => [],
"actions" => [], "actions" => [],
@@ -284,6 +306,7 @@ function loadAutomation($id)
"on_vacation" => (bool)$row["on_vacation"], "on_vacation" => (bool)$row["on_vacation"],
"on_holiday" => (bool)$row["on_holiday"], "on_holiday" => (bool)$row["on_holiday"],
"force_once" => (bool)$row["force_once"], "force_once" => (bool)$row["force_once"],
"lockout_secs" => intval($row["lockout_secs"]),
"last_run" => $row["last_run"], "last_run" => $row["last_run"],
"conditions" => [], "conditions" => [],
"actions" => [], "actions" => [],
@@ -367,6 +390,12 @@ function saveAutomation($data)
$onVacation = !empty($data["on_vacation"]) ? 1 : 0; $onVacation = !empty($data["on_vacation"]) ? 1 : 0;
$onHoliday = !empty($data["on_holiday"]) ? 1 : 0; $onHoliday = !empty($data["on_holiday"]) ? 1 : 0;
$forceOnce = !empty($data["force_once"]) ? 1 : 0; $forceOnce = !empty($data["force_once"]) ? 1 : 0;
// Nur die angebotenen Stufen, sonst die Vorgabe. Eine krumme Zahl kaeme
// hier nur aus einer selbstgebauten Anfrage.
$lockout = intval($data["lockout_secs"] ?? 60);
if (!in_array($lockout, array_column(lockoutChoices(), "value"), true)) {
$lockout = 60;
}
$db->begin_transaction(); $db->begin_transaction();
try { try {
@@ -386,10 +415,11 @@ function saveAutomation($data)
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?, $stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
window_from = ?, window_to = ?, weekdays = ?, window_from = ?, window_to = ?, weekdays = ?,
on_vacation = ?, on_holiday = ?, force_once = ? on_vacation = ?, on_holiday = ?, force_once = ?,
lockout_secs = ?
WHERE id = ?"); WHERE id = ?");
$stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $id); $weekdays, $onVacation, $onHoliday, $forceOnce, $lockout, $id);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id); $db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
@@ -397,10 +427,10 @@ function saveAutomation($data)
} else { } else {
$stmt = $db->prepare("INSERT INTO automations $stmt = $db->prepare("INSERT INTO automations
(name, floor, enabled, window_from, window_to, weekdays, (name, floor, enabled, window_from, window_to, weekdays,
on_vacation, on_holiday, force_once) on_vacation, on_holiday, force_once, lockout_secs)
VALUES (?,?,?,?,?,?,?,?,?)"); VALUES (?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce); $weekdays, $onVacation, $onHoliday, $forceOnce, $lockout);
$stmt->execute(); $stmt->execute();
$id = $db->insert_id; $id = $db->insert_id;
$stmt->close(); $stmt->close();