set_charset("utf8mb4"); } return $db; } /** Kommando mit Geraet und Parametern laden. */ function loadCommand($commandId) { $db = commandDb(); $stmt = $db->prepare("SELECT c.id, c.command_name, c.command_url, a.name AS actor_name, a.url AS actor_url FROM actor_commands c JOIN actors a ON a.id = c.actor_id WHERE c.id = ?"); $stmt->bind_param("i", $commandId); $stmt->execute(); $cmd = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$cmd) { return null; } $stmt = $db->prepare("SELECT id, parameter_name, url FROM command_parameters WHERE command_id = ? ORDER BY id"); $stmt->bind_param("i", $commandId); $stmt->execute(); $res = $stmt->get_result(); $cmd["params"] = []; while ($row = $res->fetch_assoc()) { $cmd["params"][] = $row; } $stmt->close(); return $cmd; } /** * Kommando ausfuehren. $werte ist [parameter_id => Wert]. * * Mit $probe = true wird nichts geschickt, nur beschrieben, was geschickt * wuerde - zum Nachsehen, ohne einen Rollladen in Bewegung zu setzen. * * Zurueck kommt ein Text, der den Vorgang beschreibt. */ function executeCommand($commandId, $werte, $probe = false) { $cmd = loadCommand($commandId); if (!$cmd) { throw new InvalidArgumentException("Kommando " . $commandId . " gibt es nicht."); } $url = $cmd["actor_url"]; if (strpos($url, "mqtt://") === 0) { return sendeMqtt($cmd, $werte, $probe); } if (strpos($url, "wled://") === 0) { return sendeWled($cmd, $werte, $probe); } if ($GLOBALS["tahoma_PIN"] && strpos($url, "://" . $GLOBALS["tahoma_PIN"] . "/") !== false) { return sendeTahoma($cmd, $werte, $probe); } if (strpos($url, "http") === 0) { return sendeHttp($cmd, $werte, $probe); } throw new RuntimeException("Für " . $cmd["actor_name"] . " gibt es keinen Weg zum Gerät."); } /** Werte in der Reihenfolge der Parameter, fehlende als leer. */ function parameterWerte($cmd, $werte) { $liste = []; foreach ($cmd["params"] as $p) { $liste[] = [ "name" => $p["parameter_name"], "url" => $p["url"], "wert" => strval($werte[$p["id"]] ?? $werte[strval($p["id"])] ?? ""), ]; } return $liste; } /** MQTT: je Parameter eine Nachricht auf dessen Topic. */ function sendeMqtt($cmd, $werte, $probe) { $params = parameterWerte($cmd, $werte); $nachrichten = []; if (!$params) { // Kommando ohne Parameter: das Kommando selbst ist die Nutzlast. $nachrichten[] = [$cmd["command_url"], ""]; } else { foreach ($params as $p) { $nachrichten[] = [$p["url"] ?: $cmd["command_url"], $p["wert"]]; } } foreach ($nachrichten as $n) { if (!$n[0]) { throw new RuntimeException("Kommando ohne Topic."); } } if ($probe) { return "MQTT: " . implode(", ", array_map(function ($n) { return $n[0] . " <- " . $n[1]; }, $nachrichten)); } $mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomControl_" . getmypid()); if (!$mqtt->connect(true, null, "", "")) { throw new RuntimeException("Keine Verbindung zum MQTT-Broker."); } foreach ($nachrichten as $n) { $mqtt->publish($n[0], $n[1], 0, false); } $mqtt->close(); return $cmd["actor_name"] . ": " . $cmd["command_name"]; } /** WLED: die JSON-Vorlage aus command_url fuellen und am Stueck schicken. */ function sendeWled($cmd, $werte, $probe) { $vorlage = $cmd["command_url"]; foreach (parameterWerte($cmd, $werte) as $p) { $vorlage = str_replace("%" . $p["name"] . "%", $p["wert"], $vorlage); } $rumpf = json_decode($vorlage, true); if ($rumpf === null) { throw new RuntimeException("WLED-Vorlage ergibt kein gültiges JSON: " . $vorlage); } $ziel = "http://" . rtrim(substr($cmd["actor_url"], strlen("wled://")), "/") . "/json/state"; if ($probe) { return "WLED: " . $ziel . " <- " . json_encode($rumpf, JSON_UNESCAPED_UNICODE); } $ch = curl_init($ziel); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($rumpf)); curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code >= 400 || $code === 0) { throw new RuntimeException("WLED antwortete mit " . $code); } return $cmd["actor_name"] . ": " . $cmd["command_name"]; } /** HTTP: command_url und Parameter als Abfrageargumente anhaengen. */ function sendeHttp($cmd, $werte, $probe) { $argumente = []; foreach (explode("&", strval($cmd["command_url"])) as $teil) { if (strpos($teil, "=") !== false) { list($k, $v) = explode("=", $teil, 2); $argumente[$k] = $v; } } foreach (parameterWerte($cmd, $werte) as $p) { if ($p["url"]) { $argumente[$p["url"]] = $p["wert"]; } } if (!$argumente) { throw new RuntimeException("Kommando ohne URL und ohne Parameter."); } $ziel = $cmd["actor_url"] . (strpos($cmd["actor_url"], "?") === false ? "?" : "&") . http_build_query($argumente); if ($probe) { return "HTTP: " . $ziel; } $ch = curl_init($ziel); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code >= 400 || $code === 0) { throw new RuntimeException("Gerät antwortete mit " . $code); } return $cmd["actor_name"] . ": " . $cmd["command_name"]; } /** Ein Kommando an die Tahoma-Box schicken. */ function tahomaKommando($deviceUrl, $name, $parameter) { $rumpf = [ "label" => "Raumbedienung", "actions" => [[ "deviceURL" => $deviceUrl, "commands" => [["name" => $name, "parameters" => $parameter]], ]], ]; $ch = curl_init("https://gateway-" . $GLOBALS["tahoma_PIN"] . ":8443/enduser-mobile-web/1/enduserAPI/exec/apply"); curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json", "Authorization: Bearer " . $GLOBALS["tahoma_token"]]); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($rumpf)); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $antwort = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code >= 400 || $code === 0) { throw new RuntimeException("Tahoma antwortete mit " . $code . ": " . substr(strval($antwort), 0, 120)); } } /** Einen Zustandswert eines Tahoma-Geraets lesen, null wenn unbekannt. */ function tahomaZustand($deviceUrl, $feld) { $ch = curl_init("https://gateway-" . $GLOBALS["tahoma_PIN"] . ":8443/enduser-mobile-web/1/enduserAPI/setup/devices/" . rawurlencode($deviceUrl) . "/states"); curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $GLOBALS["tahoma_token"]]); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $antwort = json_decode(curl_exec($ch), true); curl_close($ch); if (!is_array($antwort)) { return null; } foreach ($antwort as $zustand) { if (($zustand["name"] ?? "") === $feld) { return $zustand["value"]; } } return null; } /** * Wartet, bis die Jalousie ihre Fahrt beendet hat und die Zielwerte zeigt. * * Zwei Auskuenfte zusammen, weil einzeln keine traegt: * * core:MovingState taugt fuer die lange Fahrt hoch und runter, wird * bei kurzen Neigungsfahrten aber nie gesetzt. * die Zustandswerte sind die eigentliche Wahrheit, brauchen aber * einen Vorlauf, weil sie kurz nach dem Kommando * noch den alten Stand zeigen. * * Fertig ist die Fahrt, wenn nichts mehr faehrt, die Ziele erreicht sind und * entweder ein "faehrt" gesehen wurde oder der Vorlauf um ist. * * $schliessungZiel darf null sein, wenn das Kommando die Position nicht * anfasst. */ function warteAufJalousie($deviceUrl, $neigungZiel, $schliessungZiel = null) { $start = time(); $gestartet = false; while (time() - $start < JALOUSIE_WARTE_SEKUNDEN) { sleep(2); $faehrt = tahomaZustand($deviceUrl, "core:MovingState"); $neigung = tahomaZustand($deviceUrl, "core:SlateOrientationState"); $schliessung = tahomaZustand($deviceUrl, "core:ClosureState"); if ($faehrt === true) { $gestartet = true; continue; } // null heisst "nicht gelesen". intval() daraus waere 0 - und damit // ausgerechnet beim Ziel 0 ein falsches Erfolgssignal. $neigungOk = $neigung !== null && intval($neigung) === $neigungZiel; $schliessungOk = $schliessungZiel === null || ($schliessung !== null && intval($schliessung) === $schliessungZiel); if ($neigungOk && $schliessungOk && ($gestartet || time() - $start >= JALOUSIE_VORLAUF_SEKUNDEN)) { return true; } } return false; } /** * Hat das Geraet ein Kommando, das Position und Neigung zusammen setzt? * Nur solche Geraete sind Jalousien mit der Kugelschreiber-Mechanik. */ function jalousieKombiKommando($actorUrl) { $db = commandDb(); $stmt = $db->prepare("SELECT c.command_url FROM actor_commands c JOIN actors a ON a.id = c.actor_id WHERE a.url = ? AND c.command_url = 'setClosureAndOrientation'"); $stmt->bind_param("s", $actorUrl); $stmt->execute(); $treffer = $stmt->get_result()->fetch_assoc(); $stmt->close(); return $treffer ? $treffer["command_url"] : null; } /** * Tahoma: exec/apply an die Box - dieselbe Form wie in ajax/tahoma.php, nur * ohne dessen Sonderbehandlung fuer "faehrt gerade". */ function sendeTahoma($cmd, $werte, $probe) { $befehl = $cmd["command_url"]; $parameter = []; $neigungIndex = null; $positionIndex = null; foreach (parameterWerte($cmd, $werte) as $i => $p) { // Die Box erwartet Zahlen als Zahlen, Text als Text. $parameter[] = is_numeric($p["wert"]) ? $p["wert"] + 0 : $p["wert"]; if ($p["name"] === NEIGUNG_PARAMETER) { $neigungIndex = $i; } elseif ($p["name"] === POSITION_PARAMETER) { $positionIndex = $i; } } // "Zu" allein macht diese Jalousien nicht dicht: sie faehrt herunter, die // Lamellen bleiben durch die Mechanik aber bei etwa 30 % offen stehen. // Gemeint ist "ganz unten, Lamellen geschlossen" - also dasselbe wie // Position 100 mit Neigung 100, und damit ein Fall fuer die Regel unten. if ($befehl === "down" && jalousieKombiKommando($cmd["actor_url"])) { $befehl = "setClosureAndOrientation"; $parameter = [100, 100]; $positionIndex = 0; $neigungIndex = 1; } // Kugelschreiber-Mechanik: ueber NEIGUNG_DIREKT_MAX hinaus rastet sie nur // um, wenn sie vorher einmal auf 0 % war. Geschickt wird deshalb derselbe // Befehl zweimal - erst mit Neigung 0, dann mit dem gewuenschten Wert. // Die Position bleibt dabei stehen, die Jalousie faehrt also nur einmal. $umweg = $neigungIndex !== null && is_numeric($parameter[$neigungIndex]) && $parameter[$neigungIndex] > NEIGUNG_DIREKT_MAX; $vorstufe = $parameter; if ($umweg) { $vorstufe[$neigungIndex] = 0; } if ($probe) { $text = "Tahoma: " . $befehl . " " . json_encode($parameter); return $umweg ? $text . " (zuerst " . json_encode($vorstufe) . ", dann warten)" : $text; } if ($umweg) { tahomaKommando($cmd["actor_url"], $befehl, $vorstufe); warteAufJalousie($cmd["actor_url"], 0, $positionIndex === null ? null : intval($parameter[$positionIndex])); } tahomaKommando($cmd["actor_url"], $befehl, $parameter); return $cmd["actor_name"] . ": " . $cmd["command_name"] . ($umweg ? " (über Neigung 0 gefahren)" : ""); }