Die Kugelschreiber-Mechanik haengt nicht am Kommando, sondern an der Neigung selbst - "Position+Neigung" braucht den Umweg ueber 0 % genauso wie "Neigung". Erkannt wird er deshalb am Parameter und nicht am Kommandonamen: beide Kommandos nennen ihren Neigungsparameter "Neigung", das Geraetemodell liefert die Zuordnung also mit. Geprueft im Probelauf in beiden Versendern: Position+Neigung mit 20 % geht direkt raus, mit 90 % mit vorgeschaltetem Umweg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
12 KiB
PHP
348 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Ein Kommando aus `actor_commands` ausfuehren.
|
|
*
|
|
* Das Gegenstueck zu restricted/autoActions/transports.py, nur fuer den
|
|
* Browser: dort schickt der Runner die Kommandos einer Automatik, hier
|
|
* schickt sie der Benutzer aus dem Raum-Modal. Die Zuordnung "welche URL
|
|
* gehoert zu welchem Weg" ist dieselbe und steht in beiden Dateien am selben
|
|
* Ort beschrieben:
|
|
*
|
|
* mqtt://... Nutzlast auf das Topic des Parameters
|
|
* wled://... JSON-Vorlage mit Platzhaltern an /json/state
|
|
* http://... Abfrageargumente an die Geraete-URL
|
|
* Tahoma exec/apply an die Box, erkannt an der Box-Kennung
|
|
*
|
|
* Zwei Umsetzungen derselben Sache sind nicht schoen. Sie in eine zu legen
|
|
* hiesse aber, entweder den Runner um eine HTTP-Schnittstelle zu erweitern
|
|
* (dann haengt die Bedienung daran, dass er laeuft) oder das Web-UI Python
|
|
* aufrufen zu lassen. Beides waere teurer als diese Datei.
|
|
*/
|
|
|
|
require_once(__DIR__ . "/mysql.php");
|
|
require_once(__DIR__ . "/tahoma_EG.php");
|
|
require_once(__DIR__ . "/../ajax/phpMQTT.php");
|
|
|
|
const MQTT_BROKER = "localhost";
|
|
const MQTT_PORT = 1883;
|
|
|
|
/**
|
|
* Bis zu dieser Neigung fahren die Aussenjalousien direkt.
|
|
*
|
|
* Sie haben eine Kugelschreiber-Mechanik: beim Herunterfahren stehen die
|
|
* Lamellen bei etwa 30 %. In Richtung 0 % laesst sich von jeder Stellung aus
|
|
* direkt neigen; ueber 30 % hinaus muss die Mechanik erst einmal auf 0 %
|
|
* zurueck, sonst rastet sie nicht um. Ein "Neigung 80 %" ohne diesen Umweg
|
|
* bleibt wirkungslos.
|
|
*
|
|
* Das gilt fuer jedes Kommando, das die Neigung setzt - "Neigung" ebenso wie
|
|
* "Position+Neigung". Erkannt wird es deshalb am Parameter und nicht am
|
|
* Kommandonamen: beide heissen ihren Neigungsparameter "Neigung".
|
|
*
|
|
* Dasselbe steht in restricted/autoActions/transports.py - beide Versender
|
|
* brauchen es, und beide muessen es wissen.
|
|
*/
|
|
const NEIGUNG_DIREKT_MAX = 30;
|
|
|
|
/** So heisst der Neigungsparameter im Geraetemodell. */
|
|
const NEIGUNG_PARAMETER = "Neigung";
|
|
|
|
/**
|
|
* So lange wird hoechstens auf das Ende einer Neigungsfahrt gewartet.
|
|
* Gemessen braucht eine Jalousie von 100 % auf 0 % gut fuenfzehn Sekunden.
|
|
*/
|
|
const NEIGUNG_WARTE_SEKUNDEN = 25;
|
|
|
|
/** Verbindung zur Geraetedatenbank. */
|
|
function commandDb()
|
|
{
|
|
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;
|
|
}
|
|
|
|
/** 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 Neigung den Zielwert erreicht hat.
|
|
*
|
|
* Auf core:MovingState ist bei Neigungsfahrten kein Verlass - gemessen hat
|
|
* die Box waehrend einer solchen Fahrt kein einziges Mal "faehrt" gemeldet.
|
|
* Der Zustandswert selbst ist die verlaessliche Auskunft.
|
|
*/
|
|
function warteAufNeigung($deviceUrl, $ziel)
|
|
{
|
|
for ($i = 0; $i < NEIGUNG_WARTE_SEKUNDEN; $i++) {
|
|
sleep(1);
|
|
$wert = tahomaZustand($deviceUrl, "core:SlateOrientationState");
|
|
// null heisst "nicht gelesen". intval() daraus waere 0 - und damit
|
|
// ausgerechnet beim Ziel 0 ein falsches Erfolgssignal, das die
|
|
// Warteschleife sofort beendet.
|
|
if ($wert !== null && intval($wert) === $ziel) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
{
|
|
$parameter = [];
|
|
$neigungsziel = null;
|
|
foreach (parameterWerte($cmd, $werte) as $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 && is_numeric($p["wert"])) {
|
|
$neigungsziel = $p["wert"] + 0;
|
|
}
|
|
}
|
|
|
|
// Kugelschreiber-Mechanik: ueber NEIGUNG_DIREKT_MAX hinaus rastet die
|
|
// Mechanik nur um, wenn sie vorher einmal auf 0 % war. Der Umweg gehoert
|
|
// hierher und nicht in die Bedienung - sonst muesste ihn jeder kennen,
|
|
// der eine Jalousie anspricht.
|
|
$umweg = ($neigungsziel !== null && $neigungsziel > NEIGUNG_DIREKT_MAX);
|
|
|
|
if ($probe) {
|
|
$text = "Tahoma: " . $cmd["command_url"] . " " . json_encode($parameter);
|
|
return $umweg ? $text . " (mit Umweg über Neigung 0)" : $text;
|
|
}
|
|
|
|
if ($umweg) {
|
|
tahomaKommando($cmd["actor_url"], "setOrientation", [0]);
|
|
warteAufNeigung($cmd["actor_url"], 0);
|
|
}
|
|
tahomaKommando($cmd["actor_url"], $cmd["command_url"], $parameter);
|
|
return $cmd["actor_name"] . ": " . $cmd["command_name"]
|
|
. ($umweg ? " (über 0 % geneigt)" : "");
|
|
}
|