Files
Smart-Dashboard/restricted/automations.php
T
adminandClaude Opus 5 229910bcb8 Haltezeit frei in Minuten eingeben statt aus Stufen waehlen
Wonach man sucht, haengt am Messwert: eine offene Tuer faellt nach zwei
Minuten auf, ein laufender Wasserhahn erst nach Stunden. Eine Stufenliste
traf damit immer nur die Haelfte der Faelle.

Das Feld nimmt Minuten, das Modell rechnet in Sekunden weiter. Die Kopfzeile
des Rahmens rechnet beim Tippen mit - aus "210" wird dort "erst nach 3,5
Std.", also steht die lesbare Fassung neben der eingebbaren.

haltezeitPruefen() ersetzt die Whitelist: nicht unter null, nicht ueber
einen Tag, auf ganze Minuten gerundet. Sekunden gewinnen hier nichts, die
Messwerte kommen viel seltener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:21:24 +02:00

1470 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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__ . "/meshdb.php");
require_once(__DIR__ . "/rooms.php");
/**
* 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 heissen die Operatoren "um", "ab" und "vor":
*
* um 16:30 ab dieser Minute, und noch ein paar Minuten danach
* (catchup_minutes im Runner, Vorgabe fuenf). Der uebliche
* Fall - einmal taeglich zu einem festen Zeitpunkt. Das
* Fenster kostet nichts, weil ohnehin nur die steigende
* Flanke zaehlt; es sorgt nur dafuer, dass ein Neustart oder
* ein langsamer Durchlauf den Termin nicht verschluckt.
* ab 16:30 ab da bis Mitternacht wahr. Ausgeloest wird trotzdem nur
* einmal, weil nur die steigende Flanke zaehlt; dafuer kostet
* ein verpasster Takt nicht den ganzen Tag. Fuer alles, was
* laufen muss, auch wenn der Termin selbst verpasst wurde,
* ist das die richtige Wahl - etwa die Bewaesserung.
* vor 16:30 bis dahin wahr, danach nicht mehr.
*
* Beim Sonnenauf- und -untergang ist der Wert ein Versatz, der davor oder
* 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)
{
switch ($type) {
case "integer":
case "float":
return ["=", "!=", ">", "<"];
case "time":
case "date":
case "datetime":
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:
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;
}
/**
* Die aktuellen Werte zu einer Liste von Messwerten.
*
* Der Editor zeigt neben jeder Bedingung, was der Messwert gerade sagt, und
* holt sich das alle paar Sekunden nach. Bewusst aus actor_states und nicht
* aus MQTT: ueber den Broker kommt nur ein Teil der Geraete: die Jalousien
* haengen an der Tahoma-Box, die gerechneten Werte an gar nichts. Der Runner
* schreibt dagegen jeden Messwert zurueck, egal ueber welchen Weg er ihn
* geholt hat - eine Tabelle, ein Zugriff, alle Geraetearten.
*
* Die Ids kommen aus dem Browser und werden deshalb zu Zahlen gemacht, bevor
* sie in die Abfrage gehen.
*/
function stateValues(array $ids)
{
$ids = array_values(array_unique(array_filter(array_map("intval", $ids))));
if (!$ids) {
return [];
}
$db = meshDb();
$liste = implode(",", $ids);
$res = $db->query("SELECT id, current_value, unit FROM actor_states
WHERE id IN (" . $liste . ")");
$werte = [];
while ($row = $res->fetch_assoc()) {
$werte[intval($row["id"])] = [
"value" => $row["current_value"],
"unit" => $row["unit"],
];
}
return $werte;
}
/**
* Ferien und Feiertage sind dreiwertig, nicht ja/nein.
*
* Zwei Zustaende gab es immer: "egal" (der Tag aendert nichts) und "nie"
* (an solchen Tagen laeuft die Automatik nicht - so ist "werktags" gebaut).
* "Zusaetzlich" ist der dritte und zaehlt wie ein passender Wochentag.
*
* Ohne ihn liess sich "an Wochenenden und Feiertagen" gar nicht schreiben:
* die Wochentagsmaske kennt nur Samstag und Sonntag, und ein Feiertag am
* Dienstag ist eben ein Dienstag. Jetzt genuegt Sa+So angehakt und
* "Feiertage: zusaetzlich".
*
* Ein Verbot schlaegt eine Erweiterung: wer in den Ferien nie laeuft und an
* Feiertagen zusaetzlich, laeuft an einem Feiertag in den Ferien nicht.
* Andersherum liesse sich "nie" nicht mehr verlassen. Der Runner haelt
* dieselbe Reihenfolge ein (tag_passt in autoaction_runner.py).
*/
function kalendertagChoices($was, $wann)
{
// Drei kurze Worte auf einer Achse, von eng nach weit: nie - egal -
// immer. Sie stehen als Schalterreihe neben "Ferien" bzw. "Feiertage",
// die Erklaerung haengt als title daran und der ganze Rahmen steht
// darunter noch einmal als Satz.
//
// Vorher stand die Erklaerung *im* Etikett ("zusaetzlich, auch am
// falschen Wochentag"). Ein Auswahlfeld, dessen Eintraege Saetze sind,
// liest sich wie eine Bedienungsanleitung und zeigt trotzdem nur den
// gewaehlten Satz - man sieht nie, was es sonst noch gibt.
return [
["value" => 0, "label" => "nie",
"title" => "Läuft " . $wann . " nicht."],
["value" => 1, "label" => "egal",
"title" => $was . " ändern nichts."],
["value" => 2, "label" => "immer",
"title" => "Läuft " . $wann . ", auch wenn kein Wochentag angehakt ist."],
];
}
/**
* Vorlagen fuer die Tagesauswahl - die drei Faelle, die man wirklich meint.
*
* "Werktags" heisst nicht nur Mo-Fr, sondern auch "nicht an Feiertagen", und
* "Wochenende" heisst fast immer "Wochenende und Feiertage". Wer das aus
* Wochentagsmaske und zwei Dreifachschaltern von Hand zusammensetzt, vergisst
* den zweiten Teil - deshalb steht er hier mit drin.
*/
function tagVorlagen()
{
return [
["label" => "täglich", "weekdays" => 127, "on_vacation" => 1, "on_holiday" => 1,
"title" => "Jeden Tag, Feiertage eingeschlossen."],
["label" => "werktags", "weekdays" => 31, "on_vacation" => 1, "on_holiday" => 0,
"title" => "Montag bis Freitag, aber nicht an Feiertagen."],
["label" => "Wochenende", "weekdays" => 96, "on_vacation" => 1, "on_holiday" => 2,
"title" => "Samstag und Sonntag, dazu alle Feiertage."],
];
}
/**
* Ein Kalenderwert aus dem Editor - alles Krumme wird zu "egal".
*
* is_numeric davor, und zwar mit Absicht: intval("") ist 0, und 0 heisst hier
* "nie". Eine leere oder verunglueckte Angabe wuerde eine Automatik sonst
* stillschweigend an Feiertagen abschalten - eine Regelaenderung, die niemand
* angeordnet hat und die man erst am naechsten Feiertag bemerkt. "Egal" ist
* die harmlose Vorgabe: der Tag aendert dann nichts.
*/
function kalendertagWert($wert)
{
if (!is_numeric($wert)) {
return 1;
}
$zahl = intval($wert);
return in_array($zahl, [0, 1, 2], true) ? $zahl : 1;
}
/**
* 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 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.
*/
/**
* Eine eingegebene Haltezeit auf etwas Brauchbares zurechtstutzen.
*
* Anders als die Sperre ist sie frei einstellbar: Wonach man sucht, haengt am
* Messwert - eine offene Tuer faellt nach zwei Minuten auf, ein laufender
* Wasserhahn erst nach Stunden. Eine Stufenliste traefe immer nur die Haelfte
* der Faelle.
*
* Grenzen gibt es trotzdem. Unter null ist keine Zeit; oberhalb eines Tages
* waere es keine Haltezeit mehr, sondern ein Dauerzustand, den man besser
* anders abfragt - und das Zeitfenster schneidet sie ohnehin ab. Gerechnet
* wird in ganzen Minuten, weil der Editor Minuten anbietet und Sekunden hier
* nichts gewinnen: die Messwerte kommen viel seltener.
*/
function haltezeitPruefen($sekunden)
{
$sekunden = intval($sekunden);
if ($sekunden < 0) {
$sekunden = 0;
}
if ($sekunden > 86400) {
$sekunden = 86400;
}
return intdiv($sekunden, 60) * 60;
}
/**
* Eine Haltezeit in Worten - "45 Min.", "1 Std.", "3,5 Std."
*
* Minuten bis eine Stunde, darueber Stunden: "210 Min." muss man umrechnen,
* "3,5 Std." nicht. Die Nachkommastelle faellt weg, wenn sie null ist.
*/
function haltezeitKurz($sekunden)
{
$sekunden = intval($sekunden);
if ($sekunden < 3600) {
return intdiv($sekunden, 60) . " Min.";
}
$zahl = number_format($sekunden / 3600, 1, ",", "");
return rtrim(rtrim($zahl, "0"), ",") . " Std.";
}
/**
* Auswahlwerte aus possible_values in eine einheitliche Form bringen.
*
* In der Datenbank stehen zwei Schreibweisen. Eine schlichte Liste
* (["open","close"]) und eine Liste aus Ein-Schluessel-Objekten
* ([{"0":"Unknown"},{"1":"Idle"}]) - letztere ueberall dort, wo das Geraet
* eine Zahl schickt und einen Zustand meint.
*
* Was davon der gespeicherte Wert ist, haengt an der Seite:
*
* Messwert: der Runner uebersetzt die Zahl schon beim Lesen in den
* Klartext, eine Bedingung vergleicht also "Charging".
* Parameter: das Geraet will die Zahl - gespeichert wird der Schluessel,
* angezeigt der Klartext.
*/
function optionList($json, $klartextAlsWert)
{
$roh = json_decode($json, true);
if (!is_array($roh)) {
return [];
}
$liste = [];
foreach ($roh as $eintrag) {
if (is_array($eintrag)) {
foreach ($eintrag as $wert => $bezeichnung) {
$liste[] = [
"value" => $klartextAlsWert ? strval($bezeichnung) : strval($wert),
"label" => strval($bezeichnung),
];
}
} else {
$liste[] = ["value" => strval($eintrag), "label" => strval($eintrag)];
}
}
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":
case "elapsed":
return "time";
case "date":
return "date";
case "datetime":
return "datetime-local";
default:
return "text";
}
}
/**
* Anzeigeform eines Operators. Bei Zeitangaben liest sich "um"/"ab"/"vor"
* verstaendlicher als "="/">="/"<", und beim Sonnenstand steht das
* Vorzeichen des Versatzes dahinter: "ab + 00:30" heisst ab einer halben
* Stunde nach Sonnenaufgang.
*/
function operatorLabel($op, $type = "")
{
if ($type === "time" || $type === "datetime") {
if ($op === "=") return "um";
if ($op === ">=") return "ab";
if ($op === "<") return "vor";
}
if ($type === "date") {
if ($op === "=") return "am";
if ($op === ">=") return "ab";
if ($op === "<") return "vor";
}
if ($type === "deltatime") {
$beschriftung = ["+" => "+", "-" => "-", ">=+" => "ab +", ">=-" => "ab -",
"<+" => "vor +", "<-" => "vor -"];
if (isset($beschriftung[$op])) {
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;
}
/**
* Technische Zutaten aus einem Geraetenamen nehmen, ohne ihn in der
* Datenbank anzufassen.
*
* Die Discovery vergibt Namen, wie das Geraet sie meldet: "u_Wozi
* Terrassentuer" (das u_ stammt aus der Tahoma-Beschriftung),
* "Power_EG_EM_0" oder "Handtuchtrockner_OG_Relay_0" (die angehaengte Ziffer
* ist der Kanal, und es gibt nur einen). In der Auswahlliste stoert das nur.
*
* Bewusst nur die einstellige Endung: "go-eCharger_270003" traegt seine
* Seriennummer, die bleibt stehen.
*/
function anzeigeName($name)
{
$name = preg_replace('/^u_/', '', $name);
$name = preg_replace('/_\d$/', '', $name);
$name = str_replace('_', ' ', $name);
return trim(preg_replace('/\s+/', ' ', $name));
}
/**
* Waehlbare Raeume: die Raeume der Home-Ansicht.
*
* Einzige Quelle bleibt restricted/rooms.php (dahinter homeMesh.rooms) - ein
* neuer Raum taucht hier automatisch auf. Der Schluessel ist die feste
* Raumnummer als Text, so wie actors.room_id sie traegt. Frueher war es
* "Etage/Raum" - ein umbenannter Raum verlor damit still alle Geraete. Als
* Text, weil er im Browser mit dem Wert einer Auswahlliste verglichen wird,
* und der ist immer einer.
*
* Frueher stand hier ein Pseudo-Raum "aussen" fuer alles, was zu keinem Raum
* der Wohnung gehoert - Carport, Pergola, Garage, die Wechselrichter. Das war
* ein einziger Topf mit sechzehn Geraeten. Inzwischen ist das Aussengelaende
* die Etage AG mit eigenen Raeumen, und dieser Sonderfall ist keiner mehr.
*
* Etage und Raum stehen zusaetzlich einzeln daneben: die Geraeteauswahl im
* Editor gruppiert nach der einen und sortiert nach dem anderen, und der
* Schluessel soll dafuer nicht erst im JavaScript wieder auseinandergenommen
* werden. Die Reihenfolge dieser Liste ist zugleich die Reihenfolge der
* Ueberschriften dort - erst OG, dann EG, dann UG, wie in rooms.php notiert.
*/
function raumListe()
{
$liste = [];
foreach (allRooms() as $raum) {
$liste[] = [
"key" => strval($raum["nr"]),
"label" => $raum["floor"] . " · " . $raum["mqtt"],
"floor" => $raum["floor"],
"room" => $raum["mqtt"],
];
}
return $liste;
}
/** Anzeigename eines gespeicherten Raumschluessels. */
function raumBeschriftung($key)
{
if ($key === null || $key === "") {
return "Nicht zugeordnet";
}
foreach (raumListe() as $raum) {
if ($raum["key"] === strval($key)) {
return $raum["label"];
}
}
// Kann nur noch an der Datenbank vorbei entstehen: fk_actors_room setzt
// die Zuordnung beim Loeschen eines Raums auf NULL.
return "Raum " . $key . " (unbekannt)";
}
/**
* Etage und Raumname zu einem gespeicherten Raumschluessel.
*
* Die Geraeteauswahl im Editor braucht beides getrennt: die Etage wird zur
* Ueberschrift, der Raum zur Sortierung darunter. Geraete ohne Zuordnung
* bekommen ihre eigene Ueberschrift ganz am Ende; ein Raum, den es in
* rooms.php nicht mehr gibt, ebenso - er verschwindet nicht still zwischen
* den Unzugeordneten, sondern faellt auf.
*/
function raumEinordnung($key)
{
if ($key === null || $key === "") {
return ["floor" => "Ohne Raum", "room" => ""];
}
foreach (raumListe() as $raum) {
if ($raum["key"] === strval($key)) {
return ["floor" => $raum["floor"], "room" => $raum["room"]];
}
}
return ["floor" => "Unbekannter Raum", "room" => strval($key)];
}
/**
* Raumvorschlaege aus den MQTT-Topics.
*
* Ein Thermostat meldet unter dem Zweig, der am Raum eingetragen ist
* (rooms.thermostat, etwa "Raumtemp/OG/Bad"). Ein Messwert darunter gehoert
* also zu diesem Raum - das ist keine Rateaufgabe und deckt die Thermostate
* ohne Zutun ab. Bei allen anderen wird bewusst nicht geraten: "Bad Links"
* sagt nichts darueber, auf welcher Etage dieses Bad liegt.
*/
function raumVorschlaege()
{
$zweige = [];
foreach (allRooms() as $raum) {
if (!empty($raum["thermostat"])) {
$zweige[strtolower(rtrim($raum["thermostat"], "/")) . "/"] = strval($raum["nr"]);
}
}
if (!$zweige) {
return [];
}
$res = meshDb()->query("SELECT actor_id, url FROM actor_states WHERE url LIKE '%/%'");
$vorschlag = [];
while ($row = $res->fetch_assoc()) {
$url = strtolower($row["url"]);
foreach ($zweige as $zweig => $key) {
if (strpos($url, $zweig) === 0) {
$vorschlag[intval($row["actor_id"])] = $key;
break;
}
}
}
return $vorschlag;
}
/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
function saveRooms($zuordnung)
{
$db = meshDb();
$erlaubt = array_column(raumListe(), "key");
$stmt = $db->prepare("UPDATE actors SET room_id = ? WHERE id = ?");
$anzahl = 0;
foreach ($zuordnung as $actorId => $key) {
$actorId = intval($actorId);
$key = strval($key);
if ($actorId <= 0) {
continue;
}
if ($key !== "" && !in_array($key, $erlaubt, true)) {
throw new InvalidArgumentException("Unbekannter Raum: " . $key);
}
$wert = $key === "" ? null : intval($key);
$stmt->bind_param("ii", $wert, $actorId);
$stmt->execute();
$anzahl++;
}
$stmt->close();
return $anzahl;
}
/**
* Die Geraeteliste fuer die Einstellungsseite.
*
* Enthaelt alles, was man ueber ein Geraet wissen will, ohne es anzuklicken:
* Beschriftung, Art, Raum, ein Vorschlag fuer den Raum (aus dem MQTT-Topic),
* wie viele Messwerte und Kommandos daran haengen, wann zuletzt etwas davon
* gemeldet wurde - und in welchen Automatiken es steckt.
*
* Das Letzte ist der Grund, warum die Liste hier steht und nicht in der Maske:
* ein Geraet, auf das eine Automatik zeigt, darf nicht verschwinden, und wer
* das entscheidet, muss die Verweise kennen.
*/
function geraeteListe()
{
$db = meshDb();
$labels = geraeteBeschriftungen();
$tipps = raumVorschlaege();
$geraete = [];
// room_id als Text: derselbe Schluessel wie in raumListe(), siehe dort.
$res = $db->query("SELECT id, name, type, url, CAST(room_id AS CHAR) AS room FROM actors");
while ($row = $res->fetch_assoc()) {
$id = intval($row["id"]);
$wo = raumEinordnung($row["room"]);
$geraete[$id] = [
"id" => $id,
"name" => $row["name"],
"label" => $labels[$id] ?? $row["name"],
"type" => $row["type"],
"url" => $row["url"],
"room" => $row["room"],
"roomLabel" => raumBeschriftung($row["room"]),
"floor" => $wo["floor"],
"raumName" => $wo["room"],
"tipp" => $tipps[$id] ?? null,
"messwerte" => 0,
"kommandos" => 0,
"gesehen" => null,
"benutzt" => [],
];
}
// Messwerte: Anzahl und juengste Meldung. Letztere sagt, ob ein Geraet
// noch lebt - ein Eintrag von vor einem halben Jahr ist eine Karteileiche.
$res = $db->query("SELECT actor_id, COUNT(*) AS n, MAX(last_updated) AS gesehen
FROM actor_states GROUP BY actor_id");
while ($row = $res->fetch_assoc()) {
$id = intval($row["actor_id"]);
if (isset($geraete[$id])) {
$geraete[$id]["messwerte"] = intval($row["n"]);
$geraete[$id]["gesehen"] = $row["gesehen"];
}
}
$res = $db->query("SELECT actor_id, COUNT(*) AS n FROM actor_commands GROUP BY actor_id");
while ($row = $res->fetch_assoc()) {
$id = intval($row["actor_id"]);
if (isset($geraete[$id])) {
$geraete[$id]["kommandos"] = intval($row["n"]);
}
}
// Verweise aus den Automatiken - ueber Messwerte (Bedingungen) und ueber
// Kommandos (Aktionen).
$sql = "SELECT s.actor_id AS actor_id, a.id AS aid, a.name AS aname
FROM automation_conditions c
JOIN actor_states s ON s.id = c.state_id
JOIN automations a ON a.id = c.automation_id
UNION
SELECT k.actor_id AS actor_id, a.id AS aid, a.name AS aname
FROM automation_actions x
JOIN actor_commands k ON k.id = x.command_id
JOIN automations a ON a.id = x.automation_id";
$res = $db->query($sql);
while ($res && $row = $res->fetch_assoc()) {
$id = intval($row["actor_id"]);
if (isset($geraete[$id])) {
$geraete[$id]["benutzt"][intval($row["aid"])] = $row["aname"];
}
}
foreach ($geraete as $id => $g) {
$geraete[$id]["benutzt"] = array_values($g["benutzt"]);
}
// Nach Etage, Raum und Beschriftung - dieselbe Ordnung wie ueberall.
$reihenfolge = array_flip($GLOBALS["floors"]);
$liste = array_values($geraete);
usort($liste, function ($a, $b) use ($reihenfolge) {
// Geraete ohne Raum nach oben: sie sind das, was Arbeit macht.
$ra = ($a["room"] === null || $a["room"] === "") ? -1 : ($reihenfolge[$a["floor"]] ?? 98);
$rb = ($b["room"] === null || $b["room"] === "") ? -1 : ($reihenfolge[$b["floor"]] ?? 98);
if ($ra !== $rb) {
return $ra <=> $rb;
}
$raum = strcoll($a["raumName"], $b["raumName"]);
return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]);
});
// raumName war nur fuer die Sortierung da; die Maske liest roomLabel.
foreach ($liste as $i => $g) {
unset($liste[$i]["raumName"]);
}
return $liste;
}
/**
* Ein Geraet samt Messwerten, Kommandos und Parametern loeschen.
*
* Gedacht fuer Karteileichen: der Suchlauf loescht nichts (clear_tables steht
* bewusst auf false, weil die Automatiken auf die Ids zeigen), also bleibt ein
* abgebautes Geraet fuer immer in der Liste stehen.
*
* Zeigt eine Automatik darauf, wird nicht geloescht, sondern abgelehnt - mit
* den Namen der Automatiken. Sie stillschweigend mitzuloeschen waere ein
* stiller Datenverlust, sie kaputt zurueckzulassen ebenfalls. Wer das Geraet
* wirklich loswerden will, raeumt erst die Automatik auf.
*
* Steht das Geraet noch im Netz, findet der naechste Suchlauf es wieder - das
* ist kein Fehler, sondern der Zweck des Suchlaufs.
*/
function geraetLoeschen($id)
{
$id = intval($id);
if ($id <= 0) {
throw new InvalidArgumentException("Kein Gerät angegeben.");
}
$db = meshDb();
$res = $db->query("SELECT name FROM actors WHERE id = " . $id);
$geraet = $res ? $res->fetch_assoc() : null;
if (!$geraet) {
throw new InvalidArgumentException("Dieses Gerät gibt es nicht (mehr).");
}
foreach (geraeteListe() as $g) {
if ($g["id"] === $id && $g["benutzt"]) {
throw new InvalidArgumentException(
"Das Gerät steckt in " . count($g["benutzt"]) . " Automatik(en): "
. implode(", ", $g["benutzt"]) . ". Erst dort entfernen.");
}
}
$db->begin_transaction();
try {
// Von innen nach aussen: Parameter haengen an Kommandos, Kommandos und
// Messwerte am Geraet.
$db->query("DELETE p FROM command_parameters p
JOIN actor_commands k ON k.id = p.command_id
WHERE k.actor_id = " . $id);
$db->query("DELETE FROM actor_commands WHERE actor_id = " . $id);
$db->query("DELETE FROM actor_states WHERE actor_id = " . $id);
$db->query("DELETE FROM actors WHERE id = " . $id);
$db->commit();
} catch (Throwable $e) {
$db->rollback();
throw $e;
}
return $geraet["name"];
}
/**
* id => Beschriftung fuer alle Geraete, geputzt und eindeutig.
*
* Eine Stelle fuer Editor und Uebersicht, damit in der Auswahlliste nicht
* "Wozi Terrassentuer" steht und im Satz darunter "u_Wozi Terrassentuer".
*/
function geraeteBeschriftungen()
{
static $labels = null;
if ($labels !== null) {
return $labels;
}
$res = meshDb()->query("SELECT id, name, type FROM actors");
$zeilen = [];
$haeufigkeit = [];
while ($row = $res->fetch_assoc()) {
$row["label"] = anzeigeName($row["name"]);
$haeufigkeit[$row["label"]] = ($haeufigkeit[$row["label"]] ?? 0) + 1;
$zeilen[] = $row;
}
// Die Tahoma-Box vergibt Beschriftungen doppelt: "Terasse" ist einmal der
// Rollladen und einmal der Sonnenenergie-Sensor. Untereinander in der
// Liste waeren sie nicht auseinanderzuhalten, deshalb kommt bei
// Doppelungen die Geraeteart dazu.
$labels = [];
foreach ($zeilen as $row) {
$labels[intval($row["id"])] = $haeufigkeit[$row["label"]] > 1
? $row["label"] . " (" . $row["type"] . ")"
: $row["label"];
}
return $labels;
}
/**
* 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 = [];
$labels = geraeteBeschriftungen();
$res = $db->query("SELECT id, name, type, url, CAST(room_id AS CHAR) AS room FROM actors");
$zeilen = [];
while ($row = $res->fetch_assoc()) {
$zeilen[] = $row;
}
usort($zeilen, function ($a, $b) use ($labels) {
return strcoll($labels[intval($a["id"])], $labels[intval($b["id"])]);
});
foreach ($zeilen as $row) {
$wo = raumEinordnung($row["room"]);
$devices[intval($row["id"])] = [
"id" => intval($row["id"]),
"name" => $row["name"],
"label" => $labels[intval($row["id"])],
"type" => $row["type"],
"url" => $row["url"],
"room" => $row["room"],
"roomLabel" => raumBeschriftung($row["room"]),
// Etage und Raum getrennt: die Auswahlliste im Editor gruppiert
// nach der Etage und sortiert darin nach dem Raum.
"floorLabel" => $wo["floor"],
"roomName" => $wo["room"],
"states" => [],
"commands" => [],
];
}
/*
* value_path kommt mit, seit die Einstellungsseite denselben Katalog fuer
* die Home-Kacheln benutzt: bei Geraeten, die ihre Werte als JSON in einer
* einzigen Nachricht schicken (der Regensensor etwa), unterscheidet erst
* dieser Schluessel die sechs Messwerte unter demselben Topic.
*/
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
s.url, s.value_path, 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 = optionList($row["possible_values"], true);
$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" => $options,
"unit" => $row["unit"],
"value" => $row["current_value"],
"url" => $row["url"],
// Schluessel in einer JSON-Nachricht, sonst leer - siehe oben.
"pfad" => $row["value_path"],
];
}
$res = $db->query("SELECT id, actor_id, command_name, command_url 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"],
// Das Raum-Modal erkennt an der URL, was ein Kommando tut:
// "setClosure" ist eine Position, "up"/"down" faehrt, ein
// WLED-"{"on":true}" schaltet ein. Am Namen ginge das nicht -
// der kommt aus dem Geraetesuchlauf und heisst mal "An", mal
// "turn_on".
"url" => $row["command_url"],
"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 = optionList($row["possible_values"], false);
$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" => $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" => "",
// Aufgerufen wird der Editor immer aus einem Etagenreiter. Fehlt die
// Angabe trotzdem, gilt dieselbe Etage wie auf der Startseite.
"floor" => in_array($floor, $GLOBALS["floors"], true) ? $floor : standardEtage(),
"enabled" => true,
"window_from" => "00:00",
"window_to" => "23:59",
"weekdays" => 127,
"on_vacation" => 1,
"on_holiday" => 1,
"next_day" => false,
"force_once" => false,
"once_per_day" => false,
"lockout_secs" => 60,
"hold_secs" => 0,
"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" => intval($row["on_vacation"]),
"on_holiday" => intval($row["on_holiday"]),
// Vorabend: der Rahmen gilt fuer morgen. Siehe vorabend.sql im
// SolarManager; ?? 0 wie bei once_per_day, falls die Spalte fehlt.
"next_day" => (bool)($row["next_day"] ?? 0),
"force_once" => (bool)$row["force_once"],
// ?? 0, damit der Editor auch vor der Migration noch aufgeht:
// 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" => [],
];
$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, $GLOBALS["floors"], true)) {
// Nicht stillschweigend auf "" zurueckfallen: die Automatik wuerde
// dann in keinem der drei Reiter mehr auftauchen.
throw new InvalidArgumentException("Bitte eine Etage wählen.");
}
$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.");
}
pruefeKreis($data["id"] ?? 0, $conditions);
$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 = kalendertagWert($data["on_vacation"] ?? 1);
$onHoliday = kalendertagWert($data["on_holiday"] ?? 1);
$nextDay = !empty($data["next_day"]) ? 1 : 0;
$forceOnce = !empty($data["force_once"]) ? 1 : 0;
$oncePerDay = !empty($data["once_per_day"]) ? 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;
}
$halten = haltezeitPruefen($data["hold_secs"] ?? 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.");
}
// changed wird ausdruecklich mitgeschrieben. Es haengt zwar an
// ON UPDATE, das greift aber nur, wenn sich hier oben wirklich
// eine Spalte aendert - wer nur eine Bedingung verstellt, laesst
// Name, Stockwerk und Fenster ja unberuehrt. Der Runner sieht am
// Zeitstempel, dass er sein Regelwerk neu laden muss.
$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 = ?, hold_secs = ?,
changed = NOW()
WHERE id = ?");
$stmt->bind_param("ssissiiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
$oncePerDay, $lockout, $halten, $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, next_day, force_once,
once_per_day, lockout_secs, hold_secs)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
$oncePerDay, $lockout, $halten);
$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;
}
/**
* 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();
$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();
$labelsParam = parameterLabels();
$kanten = automationTriggers();
$list = [];
foreach ($ids as $id) {
$auto = loadAutomation($id);
if (!$auto) {
continue;
}
$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;
}
/** id => "Geraet: Messwert" fuer alle Messwerte. */
function stateNames()
{
$db = meshDb();
$labels = geraeteBeschriftungen();
$res = $db->query("SELECT s.id, s.state_name, s.unit, s.actor_id, t.type
FROM actor_states s
LEFT JOIN state_types t ON s.state_type = t.id");
$names = [];
while ($row = $res->fetch_assoc()) {
$names[intval($row["id"])] = [
"text" => ($labels[intval($row["actor_id"])] ?? "?") . ": " . $row["state_name"],
"unit" => $row["unit"],
"type" => $row["type"] ?: "string",
];
}
return $names;
}
/**
* id => [gespeicherter Wert => Bezeichnung] fuer alle Kommando-Parameter,
* die eine Auswahl haben. Gespeichert wird bei Parametern die Zahl, die das
* Geraet erwartet - in der Uebersicht soll aber "Blink" stehen und nicht "1".
*/
function parameterLabels()
{
$db = meshDb();
$res = $db->query("SELECT id, possible_values FROM command_parameters
WHERE possible_values <> '' AND possible_values <> '[]'");
$labels = [];
while ($row = $res->fetch_assoc()) {
foreach (optionList($row["possible_values"], false) as $option) {
$labels[intval($row["id"])][$option["value"]] = $option["label"];
}
}
return $labels;
}
/** id => "Geraet: Kommando" fuer alle Kommandos. */
function commandNames()
{
$db = meshDb();
$labels = geraeteBeschriftungen();
$res = $db->query("SELECT id, command_name, actor_id FROM actor_commands");
$names = [];
while ($row = $res->fetch_assoc()) {
$names[intval($row["id"])] = ($labels[intval($row["actor_id"])] ?? "?")
. ": " . $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, $labels = [])
{
$parts = [];
foreach ($actions as $a) {
$text = $names[$a["command_id"]] ?? ("Kommando " . $a["command_id"]);
$values = [];
foreach ($a["params"] as $parameterId => $wert) {
// Wo es eine Auswahl gibt, die Bezeichnung nennen: "Blink" sagt
// mehr als die 1, die das Geraet tatsaechlich bekommt.
$values[] = $labels[intval($parameterId)][$wert] ?? $wert;
}
if ($values) {
$text .= " (" . implode(", ", $values) . ")";
}
$parts[] = $text;
}
return implode(", ", $parts);
}