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 genau in dieser Minute. Der uebliche Fall - einmal taeglich * zu einem festen Zeitpunkt. * 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"], ]; } /** 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; } /** * Alle Geraete mit ihren Messwerten und Kommandos in einem Rutsch. * * Der Editor holt den Katalog einmal und baut daraus jede Zeile - frueher * kostete jede neue Bedingung zwei zusaetzliche Anfragen (Geraeteliste und * Details), und die Dropdowns fuellten sich asynchron nach. */ function deviceCatalog() { $db = meshDb(); $devices = []; $res = $db->query("SELECT id, name, type, url FROM actors ORDER BY name"); while ($row = $res->fetch_assoc()) { $devices[intval($row["id"])] = [ "id" => intval($row["id"]), "name" => $row["name"], "type" => $row["type"], "url" => $row["url"], "states" => [], "commands" => [], ]; } $res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value, s.url, s.possible_values, t.type FROM actor_states s LEFT JOIN state_types t ON s.state_type = t.id ORDER BY s.actor_id, s.id"); while ($row = $res->fetch_assoc()) { $id = intval($row["actor_id"]); if (!isset($devices[$id])) { continue; } $options = json_decode($row["possible_values"], true); if (!is_array($options)) { $options = []; } $type = $row["type"] ?: "string"; $devices[$id]["states"][] = [ "id" => intval($row["id"]), "name" => $row["state_name"], "type" => $type, "input" => inputForType($type, count($options) > 0), "operators" => operatorChoices($type), "options" => array_values($options), "unit" => $row["unit"], "value" => $row["current_value"], "url" => $row["url"], ]; } $res = $db->query("SELECT id, actor_id, command_name FROM actor_commands ORDER BY actor_id, id"); $commands = []; while ($row = $res->fetch_assoc()) { $id = intval($row["actor_id"]); if (!isset($devices[$id])) { continue; } $commands[intval($row["id"])] = $id; $devices[$id]["commands"][] = [ "id" => intval($row["id"]), "name" => $row["command_name"], "params" => [], ]; } if ($commands) { $res = $db->query("SELECT p.id, p.command_id, p.parameter_name, p.min_value, p.max_value, p.possible_values, p.url, t.type FROM command_parameters p LEFT JOIN state_types t ON p.parameter_type = t.id ORDER BY p.command_id, p.id"); while ($row = $res->fetch_assoc()) { $cmdId = intval($row["command_id"]); if (!isset($commands[$cmdId])) { continue; } $options = json_decode($row["possible_values"], true); if (!is_array($options)) { $options = []; } $type = $row["type"] ?: "string"; // Das passende Kommando im Geraet suchen. Wenige Kommandos je // Geraet, deshalb reicht die lineare Suche. foreach ($devices[$commands[$cmdId]]["commands"] as &$cmd) { if ($cmd["id"] === $cmdId) { $cmd["params"][] = [ "id" => intval($row["id"]), "name" => $row["parameter_name"], "type" => $type, "input" => inputForType($type, count($options) > 0), "options" => array_values($options), "min" => $row["min_value"] === null ? null : floatval($row["min_value"]), "max" => $row["max_value"] === null ? null : floatval($row["max_value"]), "url" => $row["url"], ]; break; } } unset($cmd); } } return array_values($devices); } /** Leere Automatik, wie sie der Editor fuer "Neu anlegen" bekommt. */ function emptyAutomation($floor) { return [ "id" => 0, "name" => "", // 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."); } $stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?, window_from = ?, window_to = ?, weekdays = ?, on_vacation = ?, on_holiday = ?, force_once = ?, lockout_secs = ? 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(); $list = []; foreach ($ids as $id) { $auto = loadAutomation($id); if (!$auto) { continue; } $auto["conditionText"] = describeConditions($auto["conditions"], $namesState); $auto["actionText"] = describeActions($auto["actions"], $namesCommand); $list[] = $auto; } return $list; } /** id => "Geraet: Messwert" fuer alle Messwerte. */ function stateNames() { $db = meshDb(); $res = $db->query("SELECT s.id, s.state_name, s.unit, a.name AS actor_name, t.type FROM actor_states s JOIN actors a ON a.id = s.actor_id LEFT JOIN state_types t ON s.state_type = t.id"); $names = []; while ($row = $res->fetch_assoc()) { $names[intval($row["id"])] = [ "text" => $row["actor_name"] . ": " . $row["state_name"], "unit" => $row["unit"], "type" => $row["type"] ?: "string", ]; } return $names; } /** id => "Geraet: Kommando" fuer alle Kommandos. */ function commandNames() { $db = meshDb(); $res = $db->query("SELECT c.id, c.command_name, a.name AS actor_name FROM actor_commands c JOIN actors a ON a.id = c.actor_id"); $names = []; while ($row = $res->fetch_assoc()) { $names[intval($row["id"])] = $row["actor_name"] . ": " . $row["command_name"]; } return $names; } /** * Die Bedingungen als Satz, so geklammert wie sie im Editor gezeichnet sind: * "(A und B) oder C". Denselben Text zeigt der Editor unter den Bloecken und * die Uebersicht in der Spalte "Ausloeser" - was man liest, ist damit genau * das, was der Runner rechnet. */ function describeConditions($conditions, $names) { $groups = []; foreach ($conditions as $c) { $state = $names[$c["state_id"]] ?? ["text" => "Messwert " . $c["state_id"], "unit" => null, "type" => ""]; $text = $state["text"] . " " . operatorLabel($c["operator"], $state["type"]) . " " . $c["value"]; if ($state["unit"]) { $text .= " " . $state["unit"]; } $groups[$c["group"]][] = $text; } if (!$groups) { return ""; } $parts = []; foreach ($groups as $group) { $text = implode(" und ", $group); if (count($groups) > 1 && count($group) > 1) { $text = "(" . $text . ")"; } $parts[] = $text; } return implode(" oder ", $parts); } function describeActions($actions, $names) { $parts = []; foreach ($actions as $a) { $text = $names[$a["command_id"]] ?? ("Kommando " . $a["command_id"]); $values = array_values($a["params"]); if ($values) { $text .= " (" . implode(", ", $values) . ")"; } $parts[] = $text; } return implode(", ", $parts); }