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>
426 lines
16 KiB
PHP
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)" : "");
|
|
}
|