Files
Smart-Dashboard/restricted/commands.php
T
adminandClaude Opus 5 98a1da6cd5 Raum-Modal: Heizung, Rollladen und Licht auf einer Seite
Der Grundriss zeigt Raeume, ein Klick darauf zeigte aber nur den
Temperaturregler. Die Rollladen im selben Zimmer waren von dort gar nicht
erreichbar, obwohl die Karte genau sagt, wo sie haengen.

Das Modal zeigt jetzt alles, was dem Raum zugeordnet ist: die
Solltemperatur wie bisher, dazu je Geraet seine Kommandos. Kommandos ohne
Parameter werden Knoepfe nebeneinander - "Auf", "Zu", "Stop" will man
antippen und nicht erst einstellen -, Kommandos mit Parametern bekommen
ihre Eingabefelder und einen Ausloeser. Geraete ohne Kommandos zeigen ihre
Messwerte. Die Reihenfolge der Knoepfe kommt nicht mehr aus dem
Geraete-Suchlauf: die haeufig gebrauchten stehen vorn.

Ausgefuehrt wird ueber restricted/commands.php - das Gegenstueck zu
transports.py, nur fuer den Browser. Dieselbe Zuordnung "welche URL gehoert
zu welchem Weg", nur eben in PHP:

    mqtt://   Nutzlast auf das Topic des Parameters
    wled://   JSON-Vorlage mit Platzhaltern an /json/state
    http://   Abfrageargumente an die Geraete-URL
    Tahoma    exec/apply, erkannt an der Box-Kennung in der URL

Zwei Umsetzungen derselben Sache sind nicht schoen. Die Alternative waere,
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; der Kommentar sagt das an
beiden Stellen.

Der Endpunkt kennt einen Probelauf: mit "probe" beschreibt er, was er
schicken wuerde, ohne es zu schicken. Damit sind alle vier Wege geprueft,
ohne einen Rollladen in Bewegung zu setzen - Tahoma mit und ohne Parameter,
WLED mit gefuellter Vorlage, Shelly Gen1 und MQTT.

ajax/roomtemp.php entfaellt, es ging darin ausschliesslich um den Regler,
den das Raum-Modal jetzt enthaelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:43:21 +02:00

252 lines
8.8 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;
/** 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"];
}
/**
* 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 = [];
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"];
}
$rumpf = [
"label" => "Raumbedienung",
"actions" => [[
"deviceURL" => $cmd["actor_url"],
"commands" => [["name" => $cmd["command_url"], "parameters" => $parameter]],
]],
];
$ziel = "https://gateway-" . $GLOBALS["tahoma_PIN"]
. ":8443/enduser-mobile-web/1/enduserAPI/exec/apply";
if ($probe) {
return "Tahoma: " . json_encode($rumpf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$ch = curl_init($ziel);
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));
}
return $cmd["actor_name"] . ": " . $cmd["command_name"];
}