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 ("≠", ">") 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. Dieselbe * Ueberlegung steht hinter den breiten Zeitfenstern in * auto_watering.py. * 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. */ 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; } /** * 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": 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]; } } 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 plus "Aussen". * * 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. * * 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"], ]; } // Aussen zaehlt als eigene Etage mit genau einem Raum: Carport, Garage und // Regensensor liegen auf keinem Stockwerk, sollen aber ihre eigene // Ueberschrift bekommen und nicht unter "Ohne Raum" fallen. $liste[] = ["key" => RAUM_AUSSEN, "label" => "Außen", "floor" => "Außen", "room" => ""]; 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; } /** Raum je Geraet, wie gespeichert. */ function geraeteRaeume() { $res = meshDb()->query("SELECT id, room FROM actors"); $raeume = []; while ($row = $res->fetch_assoc()) { $raeume[intval($row["id"])] = $row["room"]; } return $raeume; } /** 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; } /** * 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" => [], ]; } $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 = 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"], ]; } $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, ["UG", "EG", "OG"], true) ? $floor : "OG", "enabled" => true, "window_from" => "00:00", "window_to" => "23:59", "weekdays" => 127, "on_vacation" => true, "on_holiday" => true, "force_once" => 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" => (bool)$row["on_vacation"], "on_holiday" => (bool)$row["on_holiday"], "force_once" => (bool)$row["force_once"], "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, ["UG", "EG", "OG"], 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."); } $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; // 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 = ?, force_once = ?, lockout_secs = ?, changed = NOW() WHERE id = ?"); $stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $weekdays, $onVacation, $onHoliday, $forceOnce, $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, force_once, lockout_secs) VALUES (?,?,?,?,?,?,?,?,?,?)"); $stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo, $weekdays, $onVacation, $onHoliday, $forceOnce, $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; } 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(); $labelsParam = parameterLabels(); $list = []; foreach ($ids as $id) { $auto = loadAutomation($id); if (!$auto) { continue; } $auto["conditionText"] = describeConditions($auto["conditions"], $namesState); $auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam); $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); }