", "<"]; 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"], ]; } /** * 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 - ein neuer Raum ist dort eine * Zeile und taucht hier automatisch auf. Der Schluessel ist "Etage/Raum", * weil "Bad" allein auf drei Etagen vorkommt. * * 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" => $raum["floor"] . "/" . $raum["mqtt"], "label" => $raum["floor"] . " · " . $raum["mqtt"], "floor" => $raum["floor"], "room" => $raum["mqtt"], ]; } return $liste; } /** Anzeigename eines gespeicherten Raumschluessels. */ function raumBeschriftung($key) { if (!$key) { return "Nicht zugeordnet"; } foreach (raumListe() as $raum) { if ($raum["key"] === $key) { return $raum["label"]; } } // Raum aus rooms.php entfernt - der Eintrag am Geraet bleibt stehen, // damit eine Umbenennung dort keine Zuordnung stillschweigend loescht. return $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) { return ["floor" => "Ohne Raum", "room" => ""]; } foreach (raumListe() as $raum) { if ($raum["key"] === $key) { return ["floor" => $raum["floor"], "room" => $raum["room"]]; } } return ["floor" => "Unbekannter Raum", "room" => $key]; } /** * Raumvorschlaege aus den MQTT-Topics. * * Die Thermostate tragen ihren Raum im Topic: "Raumtemp/OG/Bad/Temp[degC]". * Das ist keine Rateaufgabe, sondern dieselbe Schreibweise wie in rooms.php, * und deckt dreizehn Geraete ohne Zutun ab. Bei allen anderen wird bewusst * nicht geraten: "Bad Links" sagt nichts darueber, auf welcher der drei * Etagen dieses Bad liegt. */ function raumVorschlaege() { $bekannt = []; foreach (raumListe() as $raum) { $bekannt[strtolower($raum["key"])] = $raum["key"]; } $res = meshDb()->query("SELECT actor_id, url FROM actor_states WHERE url LIKE 'Raumtemp/%/%/%'"); $vorschlag = []; while ($row = $res->fetch_assoc()) { $teile = explode("/", $row["url"]); if (count($teile) < 3) { continue; } $key = strtolower($teile[1] . "/" . $teile[2]); if (isset($bekannt[$key])) { $vorschlag[intval($row["actor_id"])] = $bekannt[$key]; } } 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 = ? 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 : $key; $stmt->bind_param("si", $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 = []; $res = $db->query("SELECT id, name, type, url, 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"], "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) { $wa = explode("/", (string)$a["room"], 2); $wb = explode("/", (string)$b["room"], 2); // Geraete ohne Raum nach oben: sie sind das, was Arbeit macht. $ra = ($a["room"] === null || $a["room"] === "") ? -1 : ($reihenfolge[$wa[0]] ?? 98); $rb = ($b["room"] === null || $b["room"] === "") ? -1 : ($reihenfolge[$wb[0]] ?? 98); if ($ra !== $rb) { return $ra <=> $rb; } $raum = strcoll($wa[1] ?? "", $wb[1] ?? ""); return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]); }); 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, 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, "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"]), "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; } $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 = ?, changed = NOW() WHERE id = ?"); $stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce, $oncePerDay, $lockout, $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) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); $stmt->bind_param("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce, $oncePerDay, $lockout); $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); }