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."); } /** * Die Tahoma-Geraete-URL hinter einem Kommando, null wenn es keine ist. * Damit der Aufrufer nach dem Schalten weiss, ob sich ein Nachlesen lohnt. */ function tahomaUrlZuKommando($commandId) { $cmd = loadCommand($commandId); $pin = strval($GLOBALS["tahoma_PIN"] ?? ""); if (!$cmd || $pin === "" || strpos(strval($cmd["actor_url"]), "://" . $pin . "/") === false) { return null; } return $cmd["actor_url"]; } /** 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)); } } /** * Alle Zustaende eines Tahoma-Geraets in einem Zug lesen. * * Die Box liefert auf eine Abfrage ohnehin die ganze Liste. Drei einzelne * Aufrufe fuer drei Felder waren dreimal derselbe Weg dorthin - und * ausgerechnet in der Warteschleife, die alle zwei Sekunden nachsieht. * * Rueckgabe: Feldname => Wert, oder null, wenn die Box nicht antwortet. Ein * leeres Array waere hier falsch: es hiesse "Geraet kennt keine Zustaende" * und liesse eine Warteschleife durchlaufen, statt sie warten zu lassen. */ function tahomaZustaende($deviceUrl) { $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; } $werte = []; foreach ($antwort as $zustand) { if (isset($zustand["name"])) { $werte[$zustand["name"]] = $zustand["value"] ?? null; } } return $werte; } /** Einen Zustandswert eines Tahoma-Geraets lesen, null wenn unbekannt. */ function tahomaZustand($deviceUrl, $feld) { $zustaende = tahomaZustaende($deviceUrl); return $zustaende === null ? null : ($zustaende[$feld] ?? null); } /** * Einen Zustandswert so in Text fassen, wie der Runner es tut. * * In actor_states.current_value schrieb bisher nur der Runner, und der macht * str() daraus - aus einem Wahrheitswert wird dort "True" bzw. "False", nicht * "1" und "". Das sieht aus PHP heraus schraeg aus, ist aber wichtig: sonst * stuenden fuer denselben Zustand zwei Schreibweisen in der Tabelle, je * nachdem wer zuletzt geschrieben hat. istAn() in roomControls.php kennt * beide, eine Bedingung im Automatik-Editor vergleicht aber stur. */ function tahomaWertText($wert) { if (is_bool($wert)) { return $wert ? "True" : "False"; } // Nicht jeder Zustand der Box ist eine Zahl oder ein Wort: manche sind // Listen oder Objekte. Als Messwert taugt das nicht, und strval() darauf // ist eine PHP-Warnung - die stuende dann mitten in der JSON-Antwort und // machte sie unlesbar, ohne dass irgendwo ein Fehler auftaucht. if (!is_scalar($wert)) { return null; } return strval($wert); } /** * Die frisch gelesenen Zustaende eines Tahoma-Geraets in actor_states * festhalten. * * Sonst schreibt dort nur der Runner, und der fragt die Box alle fuenf * Minuten ab (poll_tahoma in seiner config.ini) - nach einem Fahrbefehl * zeigte jedes Modal bis dahin den Stand von vor der Fahrt. Die Werte liegen * hier ohnehin schon vor, weil die Warteschleife sie gelesen hat. * * Geschrieben wird nur, was sich geaendert hat. Der Runner haelt einen * eigenen Merkzettel darueber, was er zuletzt geschrieben hat; er wird * unsere Zeile beim naechsten Poll hoechstens einmal ueberfluessig * ueberschreiben, denn er liest dieselbe Box. * * Rueckgabe: wie viele Zeilen geaendert wurden. */ function tahomaZustaendeSchreiben($deviceUrl, $zustaende) { if (!is_array($zustaende) || !$zustaende) { return 0; } $db = commandDb(); $stmt = $db->prepare("SELECT s.id, s.url, s.current_value FROM actor_states s JOIN actors a ON a.id = s.actor_id WHERE a.url = ? AND s.url IS NOT NULL AND s.url <> ''"); $stmt->bind_param("s", $deviceUrl); $stmt->execute(); $zeilen = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $stmt->close(); $geaendert = 0; $schreib = $db->prepare("UPDATE actor_states SET current_value = ? WHERE id = ?"); foreach ($zeilen as $zeile) { if (!array_key_exists($zeile["url"], $zustaende)) { continue; } $text = tahomaWertText($zustaende[$zeile["url"]]); if ($text === null || strval($zeile["current_value"]) === $text) { continue; } $id = intval($zeile["id"]); $schreib->bind_param("si", $text, $id); $schreib->execute(); $geaendert++; } $schreib->close(); return $geaendert; } /** * 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); $zustaende = tahomaZustaende($deviceUrl); if ($zustaende === null) { continue; } $faehrt = $zustaende["core:MovingState"] ?? null; $neigung = $zustaende["core:SlateOrientationState"] ?? null; $schliessung = $zustaende["core:ClosureState"] ?? null; 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)) { // Die Fahrt ist zu Ende und die Werte sind gerade gelesen - der // guenstigste Moment, sie festzuhalten. tahomaZustaendeSchreiben($deviceUrl, $zustaende); return true; } } return false; } /** * Nach einem Kommando abwarten, bis das Geraet steht, und seinen Stand in * actor_states festhalten. * * Gedacht fuer den Aufruf *nach* fastcgi_finish_request(): der Browser hat * seine Antwort dann laengst, und ob jemand noch zusieht, spielt keine * Rolle mehr. Genau daran scheiterte die Verfolgung im Modal - wer es * direkt nach dem Fahrbefehl zumachte, liess niemanden zurueck, der den * Endstand haette aufschreiben koennen. Bis zum naechsten Poll des Runners * stand dann der alte Wert in der Tabelle, fuenf Minuten lang. * * Gewartet wird nur auf core:MovingState, ohne Zielwerte - welche das * waeren, weiss hier niemand, und nicht jedes Kommando loest eine Fahrt * aus. Der Vorlauf faengt die Traegheit der Box ab: kurz nach dem Kommando * steht dort noch "faehrt nicht". * * Anders als die Warteschleife der Kugelschreiber-Mechanik laeuft das hier * erst, wenn das ganze Manoever durch ist - beide Schwenks sind geschickt, * die Fahrt, auf die gewartet wird, ist die letzte. */ function tahomaNachlesen($deviceUrl) { $start = time(); $gestartet = false; $zustaende = null; while (time() - $start < JALOUSIE_WARTE_SEKUNDEN) { sleep(2); $gelesen = tahomaZustaende($deviceUrl); if ($gelesen === null) { continue; } $zustaende = $gelesen; if (($zustaende["core:MovingState"] ?? null) === true) { $gestartet = true; continue; } if ($gestartet || time() - $start >= JALOUSIE_VORLAUF_SEKUNDEN) { break; } } return $zustaende === null ? 0 : tahomaZustaendeSchreiben($deviceUrl, $zustaende); } /** * Die Geraete-URL eines Aktors, null wenn es ihn nicht gibt oder er nicht an * der Tahoma-Box haengt. * * Die Pruefung auf die Box-Kennung ist dieselbe wie im Runner: am Schema * laesst sich ein Tahoma-Geraet nicht erkennen, dieselbe Box liefert io:// * fuer die Jalousien, rts:// fuer die Dachfenster und internal:// fuer die * Alarmanlage. Ohne sie wuerde eine Anfrage mit der Nummer einer Shelly die * Box nach einer URL fragen, die sie nicht kennt. */ function tahomaAktorUrl($actorId) { $pin = strval($GLOBALS["tahoma_PIN"] ?? ""); if ($pin === "") { return null; } $db = commandDb(); $stmt = $db->prepare("SELECT url FROM actors WHERE id = ?"); $id = intval($actorId); $stmt->bind_param("i", $id); $stmt->execute(); $zeile = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$zeile || strpos(strval($zeile["url"]), "://" . $pin . "/") === false) { return null; } return $zeile["url"]; } /** * 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)" : ""); }