Files
Smart-Dashboard/restricted/commands.php
T
adminandClaude Opus 5 c9a9e16b21 Verweise auf den Runner nachgezogen
Die Pfade in commands.php und homeMesh_automations.sql zeigten noch ins
Web-Verzeichnis. Dazu die Beschreibung der Zeit-Operatoren im Kopf von
automations.php: "um 16:30" gilt jetzt ab dieser Minute und noch
catchup_minutes lang, damit ein Neustart oder ein langsamer Durchlauf den
Termin nicht mehr verschluckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:14 +02:00

426 lines
16 KiB
PHP

<?php
/**
* Ein Kommando aus `actor_commands` ausfuehren.
*
* Das Gegenstueck zu SolarManager/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 SolarManager/autoActions/transports.py - beide Versender
* brauchen es, und beide muessen es wissen.
*/
const NEIGUNG_DIREKT_MAX = 30;
/** So heissen die beiden Parameter einer Jalousie im Geraetemodell. */
const NEIGUNG_PARAMETER = "Neigung";
const POSITION_PARAMETER = "Position";
/**
* So lange wird hoechstens auf das Ende einer Fahrt gewartet.
*
* Gemessen: eine Neigung von 100 % auf 0 % dauert gut fuenfzehn Sekunden,
* eine volle Fahrt von oben nach unten rund sechzig. Die Grenze ist die
* Notbremse, nicht die uebliche Dauer.
*/
const JALOUSIE_WARTE_SEKUNDEN = 120;
/**
* So lange gilt ein "faehrt nicht" direkt nach dem Absenden als noch nicht
* aussagekraeftig.
*
* Die Box meldet core:MovingState traege: kurz nach einem Kommando steht
* dort noch der alte Wert. Ohne diesen Vorlauf haelt die Warteschleife die
* Fahrt fuer beendet, bevor sie begonnen hat - und das naechste Kommando
* schneidet sie ab.
*/
const JALOUSIE_VORLAUF_SEKUNDEN = 8;
/** 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 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)" : "");
}