Files
Smart-Dashboard/restricted/automations.php
T
adminandClaude Opus 5 5f643b9844 AutoAction-Runner: Automatiken ausfuehren
Bisher konnte man Automatiken nur anlegen - ausgefuehrt hat sie niemand.
restricted/autoActions/autoaction_runner.py holt das nach.

Dauerlaeufer statt Cronjob, aus zwei Gruenden: Schwellwert-Ausloeser sollen
greifen, wenn die MQTT-Nachricht hereinkommt, und actor_states.current_value
wird sonst von niemandem fortgeschrieben - beim Discovery einmal gesetzt und
danach nie wieder. Ein zustandsloser Lauf haette gar nichts, womit er
vergleichen koennte. Der Runner pflegt den Wert nebenbei mit, wovon auch der
Editor profitiert: er zeigt neben jedem Messwert den aktuellen Stand.

Ausgeloest wird nur auf der steigenden Flanke (automations.cond_met), sonst
wuerde "Temperatur ueber 22 Grad" bei jedem Takt erneut feuern. Aus
demselben Grund heissen Zeit-Ausloeser jetzt "ab 16:30" statt "gleich
16:30": ein Gleichheitsvergleich waere nur in einer einzigen Minute wahr,
und ein Ausfall in genau dieser Minute kostet den ganzen Tag. Dieselbe
Ueberlegung steht hinter den breiten Zeitfenstern in auto_watering.py.

Der Weg zum Geraet haengt an der URL des Aktors: mqtt:// abonniert und
publiziert, http:// pollt und haengt Parameter an, io:// spricht mit der
Tahoma-Box, Logic rechnet Uhrzeit, Datum und Sonnenzeiten. Alle vier stehen
in transports.py; eine fuenfte Geraeteart ist eine weitere Klasse mit
passt(), zustaende_lesen() und senden().

fetch_calendar.py fuellt calendar_days aus openholidaysapi.org, damit "in
den Ferien" und "an Feiertagen" eine Grundlage haben. Einmal jaehrlich per
Cron.

Die Uebersicht blendet auf schmalen Schirmen Spalten aus, statt sie
wegzuschieben - auf dem Handy waren die Knoepfe zum Pausieren und Loeschen
sonst nur per Seitwaertsscrollen erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 19:21:07 +02:00

604 lines
21 KiB
PHP

<?php
/**
* Modell-Schicht fuer die Automatiken (AutoActions).
*
* Hier liegt alles, was Editor und Uebersicht gemeinsam brauchen: die
* Verbindung zur homeMesh-Datenbank, der Geraetekatalog, Laden und Speichern
* einer Automatik und die Beschreibung in Alltagssprache. Die Ajax-Endpunkte
* sind damit reine Verteiler.
*
* Tabellen siehe homeMesh_automations.sql. Kurzfassung: eine Automatik hat
* Bedingungen (Verweis auf actor_states) und Aktionen (Verweis auf
* actor_commands, Werte je command_parameters). Bedingungen mit derselben
* group_no sind mit UND verknuepft, verschiedene Gruppen mit ODER -
* ausgewertet wird any(all(gruppe)).
*/
require_once(__DIR__ . "/mysql.php");
/** Verbindung zur Geraete- und Automatik-Datenbank. */
function meshDb()
{
static $db = null;
if ($db === null) {
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_MeshUser"],
$GLOBALS["mysql_MeshPass"], $GLOBALS["mysql_MeshDB"]);
$db->set_charset("utf8mb4");
}
return $db;
}
/**
* Operatoren, die zu einem Datentyp passen. Immer ASCII - die huebschen
* Zeichen macht erst die Anzeige daraus. Frueher schickte der Editor sein
* innerHTML ("≠", "&gt;") an den Server, wo eine Whitelist mit "!=" stand:
* jede Bedingung "ungleich" wurde still zu "gleich".
*
* Bei Zeit und Datum gibt es bewusst kein "gleich", sondern "ab" und "vor".
* "Uhrzeit = 16:30" waere nur in genau der einen Minute wahr - faellt der
* Runner in dieser Minute aus, ist die Automatik fuer den Tag verloren. "ab
* 16:30" bleibt bis Mitternacht wahr; ausgeloest wird trotzdem nur einmal,
* weil nur die steigende Flanke zaehlt. Dieselbe Ueberlegung steht hinter den
* breiten Zeitfenstern in auto_watering.py.
*/
function operatorsForType($type)
{
switch ($type) {
case "integer":
case "float":
return ["=", "!=", ">", "<"];
case "time":
case "date":
case "datetime":
return [">=", "<"];
case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz
return ["+", "-"];
case "bool":
return ["="]; // JA/NEIN steht im Wertfeld, nicht im Operator
default:
return ["=", "!="];
}
}
/** Operatoren mit ihrer Beschriftung, so wie der Editor sie anbietet. */
function operatorChoices($type)
{
$liste = [];
foreach (operatorsForType($type) as $op) {
$liste[] = ["value" => $op, "label" => operatorLabel($op, $type)];
}
return $liste;
}
/** Wie das Wertfeld im Editor aussieht. */
function inputForType($type, $hasOptions)
{
if ($type === "bool") {
return "bool";
}
if ($hasOptions) {
return "select";
}
switch ($type) {
case "integer":
case "float":
return "number";
case "time":
case "deltatime":
return "time";
case "date":
return "date";
case "datetime":
return "datetime-local";
default:
return "text";
}
}
/**
* Anzeigeform eines Operators. Bei Zeitangaben liest sich "ab"/"vor"
* verstaendlicher als ">="/"<", und bei Sonnenauf-/-untergang steht der
* Versatz dahinter: "ab + 00:30" heisst eine halbe Stunde danach.
*/
function operatorLabel($op, $type = "")
{
if ($type === "time" || $type === "date" || $type === "datetime") {
if ($op === ">=") return "ab";
if ($op === "<") return "vor";
}
if ($type === "deltatime") {
if ($op === "+") return "ab +";
if ($op === "-") return "ab -";
}
return $op === "!=" ? "≠" : $op;
}
/**
* Alle Geraete mit ihren Messwerten und Kommandos in einem Rutsch.
*
* Der Editor holt den Katalog einmal und baut daraus jede Zeile - frueher
* kostete jede neue Bedingung zwei zusaetzliche Anfragen (Geraeteliste und
* Details), und die Dropdowns fuellten sich asynchron nach.
*/
function deviceCatalog()
{
$db = meshDb();
$devices = [];
$res = $db->query("SELECT id, name, type, url FROM actors ORDER BY name");
while ($row = $res->fetch_assoc()) {
$devices[intval($row["id"])] = [
"id" => intval($row["id"]),
"name" => $row["name"],
"type" => $row["type"],
"url" => $row["url"],
"states" => [],
"commands" => [],
];
}
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
s.url, s.possible_values, t.type
FROM actor_states s
LEFT JOIN state_types t ON s.state_type = t.id
ORDER BY s.actor_id, s.id");
while ($row = $res->fetch_assoc()) {
$id = intval($row["actor_id"]);
if (!isset($devices[$id])) {
continue;
}
$options = json_decode($row["possible_values"], true);
if (!is_array($options)) {
$options = [];
}
$type = $row["type"] ?: "string";
$devices[$id]["states"][] = [
"id" => intval($row["id"]),
"name" => $row["state_name"],
"type" => $type,
"input" => inputForType($type, count($options) > 0),
"operators" => operatorChoices($type),
"options" => array_values($options),
"unit" => $row["unit"],
"value" => $row["current_value"],
"url" => $row["url"],
];
}
$res = $db->query("SELECT id, actor_id, command_name FROM actor_commands
ORDER BY actor_id, id");
$commands = [];
while ($row = $res->fetch_assoc()) {
$id = intval($row["actor_id"]);
if (!isset($devices[$id])) {
continue;
}
$commands[intval($row["id"])] = $id;
$devices[$id]["commands"][] = [
"id" => intval($row["id"]),
"name" => $row["command_name"],
"params" => [],
];
}
if ($commands) {
$res = $db->query("SELECT p.id, p.command_id, p.parameter_name, p.min_value,
p.max_value, p.possible_values, p.url, t.type
FROM command_parameters p
LEFT JOIN state_types t ON p.parameter_type = t.id
ORDER BY p.command_id, p.id");
while ($row = $res->fetch_assoc()) {
$cmdId = intval($row["command_id"]);
if (!isset($commands[$cmdId])) {
continue;
}
$options = json_decode($row["possible_values"], true);
if (!is_array($options)) {
$options = [];
}
$type = $row["type"] ?: "string";
// Das passende Kommando im Geraet suchen. Wenige Kommandos je
// Geraet, deshalb reicht die lineare Suche.
foreach ($devices[$commands[$cmdId]]["commands"] as &$cmd) {
if ($cmd["id"] === $cmdId) {
$cmd["params"][] = [
"id" => intval($row["id"]),
"name" => $row["parameter_name"],
"type" => $type,
"input" => inputForType($type, count($options) > 0),
"options" => array_values($options),
"min" => $row["min_value"] === null ? null : floatval($row["min_value"]),
"max" => $row["max_value"] === null ? null : floatval($row["max_value"]),
"url" => $row["url"],
];
break;
}
}
unset($cmd);
}
}
return array_values($devices);
}
/** Leere Automatik, wie sie der Editor fuer "Neu anlegen" bekommt. */
function emptyAutomation($floor)
{
return [
"id" => 0,
"name" => "",
"floor" => $floor,
"enabled" => true,
"window_from" => "00:00",
"window_to" => "23:59",
"weekdays" => 127,
"on_vacation" => true,
"on_holiday" => true,
"force_once" => false,
"last_run" => null,
"conditions" => [],
"actions" => [],
];
}
/** Eine Automatik mit Bedingungen und Aktionen laden. */
function loadAutomation($id)
{
$db = meshDb();
$stmt = $db->prepare("SELECT * FROM automations WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$row) {
return null;
}
$auto = [
"id" => intval($row["id"]),
"name" => $row["name"],
"floor" => $row["floor"],
"enabled" => (bool)$row["enabled"],
"window_from" => substr($row["window_from"], 0, 5),
"window_to" => substr($row["window_to"], 0, 5),
"weekdays" => intval($row["weekdays"]),
"on_vacation" => (bool)$row["on_vacation"],
"on_holiday" => (bool)$row["on_holiday"],
"force_once" => (bool)$row["force_once"],
"last_run" => $row["last_run"],
"conditions" => [],
"actions" => [],
];
$stmt = $db->prepare("SELECT id, group_no, state_id, operator, value
FROM automation_conditions WHERE automation_id = ?
ORDER BY group_no, position, id");
$stmt->bind_param("i", $id);
$stmt->execute();
$res = $stmt->get_result();
while ($c = $res->fetch_assoc()) {
$auto["conditions"][] = [
"group" => intval($c["group_no"]),
"state_id" => intval($c["state_id"]),
"operator" => $c["operator"],
"value" => $c["value"],
];
}
$stmt->close();
$stmt = $db->prepare("SELECT a.id, a.command_id, p.parameter_id, p.value
FROM automation_actions a
LEFT JOIN automation_action_params p ON p.action_id = a.id
WHERE a.automation_id = ?
ORDER BY a.position, a.id, p.parameter_id");
$stmt->bind_param("i", $id);
$stmt->execute();
$res = $stmt->get_result();
$actions = [];
while ($a = $res->fetch_assoc()) {
$aid = intval($a["id"]);
if (!isset($actions[$aid])) {
$actions[$aid] = ["command_id" => intval($a["command_id"]), "params" => []];
}
if ($a["parameter_id"] !== null) {
$actions[$aid]["params"][strval($a["parameter_id"])] = $a["value"];
}
}
$stmt->close();
$auto["actions"] = array_values($actions);
return $auto;
}
/**
* Eine Automatik anlegen oder ueberschreiben. Gibt die ID zurueck.
*
* Bedingungen und Aktionen werden komplett neu geschrieben statt einzeln
* abgeglichen: sie haben keine Identitaet, die der Benutzer kennt, und der
* Editor schickt ohnehin immer den vollstaendigen Stand. Alles laeuft in
* einer Transaktion, damit eine halb ersetzte Regel nie sichtbar wird - und
* schon gar nicht ausgefuehrt.
*/
function saveAutomation($data)
{
$db = meshDb();
$name = trim(strval($data["name"] ?? ""));
if ($name === "") {
throw new InvalidArgumentException("Bitte einen Namen vergeben.");
}
$floor = strval($data["floor"] ?? "");
if (!in_array($floor, ["", "UG", "EG", "OG"], true)) {
$floor = "";
}
$conditions = is_array($data["conditions"] ?? null) ? $data["conditions"] : [];
$actions = is_array($data["actions"] ?? null) ? $data["actions"] : [];
if (!$conditions) {
throw new InvalidArgumentException("Ohne Ausloeser wuerde die Automatik nie starten.");
}
if (!$actions) {
throw new InvalidArgumentException("Ohne Aktion haette die Automatik nichts zu tun.");
}
$id = intval($data["id"] ?? 0);
$enabled = !empty($data["enabled"]) ? 1 : 0;
$windowFrom = normalizeTime($data["window_from"] ?? "00:00", "00:00:00");
$windowTo = normalizeTime($data["window_to"] ?? "23:59", "23:59:00");
$weekdays = intval($data["weekdays"] ?? 127) & 127;
$onVacation = !empty($data["on_vacation"]) ? 1 : 0;
$onHoliday = !empty($data["on_holiday"]) ? 1 : 0;
$forceOnce = !empty($data["force_once"]) ? 1 : 0;
$db->begin_transaction();
try {
if ($id > 0) {
// Erst nachsehen, ob es die Automatik noch gibt. Ohne das liefe
// das UPDATE ins Leere und erst der Fremdschluessel der
// Bedingungen wuerde meckern - mit einer Meldung, die niemandem
// weiterhilft.
$stmt = $db->prepare("SELECT id FROM automations WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$exists = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$exists) {
throw new RuntimeException("Automatik " . $id . " gibt es nicht mehr.");
}
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
window_from = ?, window_to = ?, weekdays = ?,
on_vacation = ?, on_holiday = ?, force_once = ?
WHERE id = ?");
$stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce, $id);
$stmt->execute();
$stmt->close();
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
$db->query("DELETE FROM automation_actions WHERE automation_id = " . $id);
} else {
$stmt = $db->prepare("INSERT INTO automations
(name, floor, enabled, window_from, window_to, weekdays,
on_vacation, on_holiday, force_once)
VALUES (?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $forceOnce);
$stmt->execute();
$id = $db->insert_id;
$stmt->close();
}
// Gruppennummern luecken lassen waere erlaubt, aber die Uebersicht
// liest sich besser, wenn sie bei 0 anfangen und dicht sind.
$groupMap = [];
foreach ($conditions as $c) {
$g = intval($c["group"] ?? 0);
if (!isset($groupMap[$g])) {
$groupMap[$g] = count($groupMap);
}
}
$stmt = $db->prepare("INSERT INTO automation_conditions
(automation_id, group_no, position, state_id, operator, value)
VALUES (?,?,?,?,?,?)");
$positions = [];
foreach ($conditions as $c) {
$group = $groupMap[intval($c["group"] ?? 0)];
$stateId = intval($c["state_id"] ?? 0);
$operator = strval($c["operator"] ?? "=");
$value = strval($c["value"] ?? "");
if ($stateId <= 0) {
throw new InvalidArgumentException("Eine Bedingung hat keinen Messwert.");
}
if (!in_array($operator, ["=", "!=", ">", "<", ">=", "<=", "+", "-"], true)) {
throw new InvalidArgumentException("Unbekannter Operator: " . $operator);
}
$positions[$group] = ($positions[$group] ?? -1) + 1;
$position = $positions[$group];
$stmt->bind_param("iiiiss", $id, $group, $position, $stateId, $operator, $value);
$stmt->execute();
}
$stmt->close();
$stmtAction = $db->prepare("INSERT INTO automation_actions
(automation_id, position, command_id) VALUES (?,?,?)");
$stmtParam = $db->prepare("INSERT INTO automation_action_params
(action_id, parameter_id, value) VALUES (?,?,?)");
$position = 0;
foreach ($actions as $a) {
$commandId = intval($a["command_id"] ?? 0);
if ($commandId <= 0) {
throw new InvalidArgumentException("Eine Aktion hat kein Kommando.");
}
$stmtAction->bind_param("iii", $id, $position, $commandId);
$stmtAction->execute();
$actionId = $db->insert_id;
$position++;
$params = is_array($a["params"] ?? null) ? $a["params"] : [];
foreach ($params as $paramId => $value) {
$paramId = intval($paramId);
$value = strval($value);
if ($paramId <= 0) {
continue;
}
$stmtParam->bind_param("iis", $actionId, $paramId, $value);
$stmtParam->execute();
}
}
$stmtAction->close();
$stmtParam->close();
$db->commit();
} catch (Throwable $e) {
$db->rollback();
throw $e;
}
return $id;
}
/** "16:30" oder "16:30:00" auf das Spaltenformat bringen. */
function normalizeTime($value, $fallback)
{
if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/', trim(strval($value)), $t)) {
return $t[1] . ":" . $t[2] . ":" . (isset($t[4]) ? $t[4] : "00");
}
return $fallback;
}
function deleteAutomation($id)
{
$db = meshDb();
$stmt = $db->prepare("DELETE FROM automations WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$stmt->close();
}
/** Pausieren oder wieder aufnehmen. Gibt den neuen Zustand zurueck. */
function toggleAutomation($id)
{
$db = meshDb();
$stmt = $db->prepare("UPDATE automations SET enabled = 1 - enabled WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$stmt->close();
$stmt = $db->prepare("SELECT enabled FROM automations WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return $row ? (bool)$row["enabled"] : false;
}
/**
* Alle Automatiken einer Etage, angereichert um lesbare Beschreibungen fuer
* die Uebersichtstabelle.
*/
function listAutomations($floor)
{
$db = meshDb();
$stmt = $db->prepare("SELECT id FROM automations WHERE floor = ? ORDER BY name");
$stmt->bind_param("s", $floor);
$stmt->execute();
$res = $stmt->get_result();
$ids = [];
while ($row = $res->fetch_assoc()) {
$ids[] = intval($row["id"]);
}
$stmt->close();
$namesState = stateNames();
$namesCommand = commandNames();
$list = [];
foreach ($ids as $id) {
$auto = loadAutomation($id);
if (!$auto) {
continue;
}
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
$auto["actionText"] = describeActions($auto["actions"], $namesCommand);
$list[] = $auto;
}
return $list;
}
/** id => "Geraet: Messwert" fuer alle Messwerte. */
function stateNames()
{
$db = meshDb();
$res = $db->query("SELECT s.id, s.state_name, s.unit, a.name AS actor_name, t.type
FROM actor_states s
JOIN actors a ON a.id = s.actor_id
LEFT JOIN state_types t ON s.state_type = t.id");
$names = [];
while ($row = $res->fetch_assoc()) {
$names[intval($row["id"])] = [
"text" => $row["actor_name"] . ": " . $row["state_name"],
"unit" => $row["unit"],
"type" => $row["type"] ?: "string",
];
}
return $names;
}
/** id => "Geraet: Kommando" fuer alle Kommandos. */
function commandNames()
{
$db = meshDb();
$res = $db->query("SELECT c.id, c.command_name, a.name AS actor_name
FROM actor_commands c JOIN actors a ON a.id = c.actor_id");
$names = [];
while ($row = $res->fetch_assoc()) {
$names[intval($row["id"])] = $row["actor_name"] . ": " . $row["command_name"];
}
return $names;
}
/**
* Die Bedingungen als Satz, so geklammert wie sie im Editor gezeichnet sind:
* "(A und B) oder C". Denselben Text zeigt der Editor unter den Bloecken und
* die Uebersicht in der Spalte "Ausloeser" - was man liest, ist damit genau
* das, was der Runner rechnet.
*/
function describeConditions($conditions, $names)
{
$groups = [];
foreach ($conditions as $c) {
$state = $names[$c["state_id"]]
?? ["text" => "Messwert " . $c["state_id"], "unit" => null, "type" => ""];
$text = $state["text"] . " " . operatorLabel($c["operator"], $state["type"]) . " " . $c["value"];
if ($state["unit"]) {
$text .= " " . $state["unit"];
}
$groups[$c["group"]][] = $text;
}
if (!$groups) {
return "";
}
$parts = [];
foreach ($groups as $group) {
$text = implode(" und ", $group);
if (count($groups) > 1 && count($group) > 1) {
$text = "(" . $text . ")";
}
$parts[] = $text;
}
return implode(" oder ", $parts);
}
function describeActions($actions, $names)
{
$parts = [];
foreach ($actions as $a) {
$text = $names[$a["command_id"]] ?? ("Kommando " . $a["command_id"]);
$values = array_values($a["params"]);
if ($values) {
$text .= " (" . implode(", ", $values) . ")";
}
$parts[] = $text;
}
return implode(", ", $parts);
}