Nach dem Discovery-Lauf stehen 51 Geraete in der Ausloeser-Liste und 40 in der Aktions-Liste, flach und alphabetisch. Dreizehn Thermostate und elf Jalousien sind darin vierundzwanzig gleich aussehende Zeilen. Die Liste ist jetzt nach Geraeteart gruppiert (optgroup, das koennen auch die Handy-Browser nativ). Arten mit nur einem Geraet bekommen keine eigene Ueberschrift - fuenfzehn Gruppen mit je einem Eintrag waeren unuebersichtlicher als die flache Liste -, sie sammeln sich am Ende unter "Einzelne". Dazu ein Namensputz fuer die Anzeige, ohne die Datenbank anzufassen: das u_-Praefix aus den Tahoma-Beschriftungen faellt weg, ebenso eine angehaengte einstellige Kanalnummer, und Unterstriche werden zu Leerzeichen. Aus "u_Wozi Terrassentuer" wird "Wozi Terrassentuer", aus "Power_EG_EM_0" wird "Power EG EM". Bewusst nur die einstellige Endung - "go-eCharger_270003" traegt seine Seriennummer und behaelt sie. Beschriftungen, die danach doppelt waeren, bekommen die Geraeteart dazu: die Tahoma-Box nennt sowohl den Rollladen als auch den Sonnenenergie-Sensor "Terasse", in der Liste untereinander waren sie nicht zu unterscheiden. Daraus wird "Terasse (Rollladen)" und "Terasse (Licht)". Die Beschriftung entsteht an einer Stelle und wird von Editor und Uebersicht geteilt - sonst stuende in der Auswahlliste "Wozi Terrassentuer" und im Satz darunter weiter "u_Wozi Terrassentuer". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
777 lines
28 KiB
PHP
777 lines
28 KiB
PHP
<?php
|
||
/**
|
||
* Modell-Schicht fuer die Automatiken (AutoActions).
|
||
*
|
||
* Hier liegt alles, was Editor und Uebersicht gemeinsam brauchen: die
|
||
* Verbindung zur homeMesh-Datenbank, der Geraetekatalog, Laden und Speichern
|
||
* einer Automatik und die Beschreibung in Alltagssprache. Die Ajax-Endpunkte
|
||
* sind damit reine Verteiler.
|
||
*
|
||
* Tabellen siehe homeMesh_automations.sql. Kurzfassung: eine Automatik hat
|
||
* Bedingungen (Verweis auf actor_states) und Aktionen (Verweis auf
|
||
* actor_commands, Werte je command_parameters). Bedingungen mit derselben
|
||
* group_no sind mit UND verknuepft, verschiedene Gruppen mit ODER -
|
||
* ausgewertet wird any(all(gruppe)).
|
||
*/
|
||
|
||
require_once(__DIR__ . "/mysql.php");
|
||
|
||
/** Verbindung zur Geraete- und Automatik-Datenbank. */
|
||
function meshDb()
|
||
{
|
||
static $db = null;
|
||
if ($db === null) {
|
||
$db = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_MeshUser"],
|
||
$GLOBALS["mysql_MeshPass"], $GLOBALS["mysql_MeshDB"]);
|
||
$db->set_charset("utf8mb4");
|
||
}
|
||
return $db;
|
||
}
|
||
|
||
/**
|
||
* Operatoren, die zu einem Datentyp passen. Immer ASCII - die huebschen
|
||
* Zeichen macht erst die Anzeige daraus. Frueher schickte der Editor sein
|
||
* innerHTML ("≠", ">") 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"],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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));
|
||
}
|
||
|
||
/**
|
||
* 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 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) {
|
||
$devices[intval($row["id"])] = [
|
||
"id" => intval($row["id"]),
|
||
"name" => $row["name"],
|
||
"label" => $labels[intval($row["id"])],
|
||
"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 = 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 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 = 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.");
|
||
}
|
||
|
||
$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();
|
||
$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);
|
||
}
|