diff --git a/ajax/room.php b/ajax/room.php index 508fe1a..3ce2064 100644 --- a/ajax/room.php +++ b/ajax/room.php @@ -30,6 +30,14 @@ if (!checkLogin()) { exit; } +// Die Sitzung wird ab hier nur noch gelesen, und PHP haelt ihre Datei bis +// zum Ende der Anfrage exklusiv gesperrt. Ein "Zu" an einer Jalousie wartet +// weiter unten ueber eine Minute auf das Ende der Fahrt - solange stand jede +// weitere Anfrage desselben Browsers still, auch die, die waehrend der Fahrt +// nachsehen will, wie weit sie ist. Alle Schreibzugriffe auf $_SESSION +// stecken in checkLogin() und sind hier durch. +session_write_close(); + function raumAntwort($data, $code = 200) { http_response_code($code); @@ -71,6 +79,31 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $mqtt->close(); raumAntwort(["ok" => "Solltemperatur " . $temp . " °C"]); } + // Eine Jalousie waehrend ihrer Fahrt nachfuehren. actor_states wird + // sonst nur vom Runner fortgeschrieben, und der fragt die Box alle + // fuenf Minuten ab - so lange stand nach einem Fahrbefehl der alte + // Wert im Modal. Hier wird genau ein Geraet gelesen, das kostet die + // Box wenig, und der frische Stand landet gleich in der Tabelle. + if (($_GET["action"] ?? "") === "jalousie") { + $url = tahomaAktorUrl(intval($body["actor"] ?? 0)); + if ($url === null) { + raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404); + } + $zustaende = tahomaZustaende($url); + if ($zustaende === null) { + raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503); + } + tahomaZustaendeSchreiben($url, $zustaende); + $zahl = function ($feld) use ($zustaende) { + return isset($zustaende[$feld]) && is_numeric($zustaende[$feld]) + ? intval($zustaende[$feld]) : null; + }; + raumAntwort([ + "faehrt" => ($zustaende["core:MovingState"] ?? null) === true, + "position" => $zahl("core:ClosureState"), + "neigung" => $zahl("core:SlateOrientationState"), + ]); + } raumAntwort(["error" => "Unbekannte Aktion."], 400); } catch (InvalidArgumentException $e) { raumAntwort(["error" => $e->getMessage()], 400); diff --git a/js/solar/homeMQTT.js b/js/solar/homeMQTT.js index 6fefb04..548e80e 100644 --- a/js/solar/homeMQTT.js +++ b/js/solar/homeMQTT.js @@ -513,6 +513,11 @@ function switchFloor(floor){ function raumKommando(cmdId, params, element) { const karte = element.closest(".raum-geraet") || element; karte.classList.add("raum-warte"); + // Die Verfolgung beginnt sofort, nicht erst mit der Antwort: ein "Zu" + // faehrt herunter, wartet und neigt danach - der Aufruf kommt also erst + // zurueck, wenn die Jalousie schon steht. Beides nebeneinander geht, + // seit ajax/room.php die Sitzung freigibt. + jalousieVerfolgen(karte); raumRequest("command", { command_id: cmdId, params: params }) .then(antwort => { karte.classList.remove("raum-warte"); @@ -520,6 +525,83 @@ function switchFloor(floor){ }); } + // Takt und Grenzen der Verfolgung. Dieselben Zahlen wie die Konstanten + // JALOUSIE_* in restricted/commands.php: die Box meldet core:MovingState + // traege, kurz nach dem Kommando steht dort noch "faehrt nicht" - deshalb + // der Vorlauf, in dem ein Stillstand nichts bedeutet. + const JALOUSIE_TAKT_MS = 2000; + const JALOUSIE_VORLAUF_MS = 8000; + const JALOUSIE_MAX_MS = 120000; + + /** + * Eine Jalousie waehrend ihrer Fahrt nachfuehren. + * + * actor_states schreibt sonst nur der Runner fort, und der fragt die + * Tahoma-Box alle fuenf Minuten ab - so lange zeigte das Modal nach einem + * Fahrbefehl den Stand von davor. Hier wird genau dieses eine Geraet + * gefragt; ajax/room.php haelt den frischen Wert gleich in der Tabelle + * fest, damit auch das naechste Oeffnen und der Automatik-Editor ihn haben. + */ + function jalousieVerfolgen(karte) { + if (!karte || !karte.querySelector(".raum-jalousie")) return; + const actor = Number(karte.dataset.actor); + if (!actor) return; + + // Ein zweiter Fahrbefehl schiebt nur das Ende hinaus. Zwei Schleifen + // wuerden dieselbe Box doppelt so oft fragen, ohne mehr zu zeigen. + karte.dataset.jalousieBis = String(Date.now() + JALOUSIE_MAX_MS); + if (karte.dataset.jalousieLaeuft) return; + karte.dataset.jalousieLaeuft = "1"; + + const beginn = Date.now(); + let gefahren = false; + + function aufhoeren() { + delete karte.dataset.jalousieLaeuft; + delete karte.dataset.jalousieBis; + } + + function runde(letzte) { + raumRequest("jalousie", { actor: actor }).then(stand => { + // Modal zu, Box stumm, Geraet unbekannt - in jedem Fall aufhoeren + // statt zwei Minuten lang ins Leere zu fragen. + if (!stand || stand.error || !document.body.contains(karte)) { + aufhoeren(); + return; + } + jalousieAnzeigen(karte, stand); + if (stand.faehrt) gefahren = true; + if (letzte || Date.now() > Number(karte.dataset.jalousieBis)) { + aufhoeren(); + return; + } + // Steht sie still, ist die Fahrt vorbei - aber erst, wenn wir sie + // haben fahren sehen oder der Vorlauf um ist. Eine letzte Runde + // danach holt den Wert, den die Box beim Anhalten setzt. + const fertig = !stand.faehrt + && (gefahren || Date.now() - beginn >= JALOUSIE_VORLAUF_MS); + setTimeout(() => runde(fertig), JALOUSIE_TAKT_MS); + }); + } + setTimeout(() => runde(false), JALOUSIE_TAKT_MS); + } + + /** Die gemeldete Stellung in die beiden Regler und das Bild uebernehmen. */ + function jalousieAnzeigen(karte, stand) { + [["position", stand.position], ["neigung", stand.neigung]].forEach(paar => { + const wert = paar[1]; + if (wert === null || typeof wert === "undefined") return; + const regler = karte.querySelector(".raum-regler[data-rolle='" + paar[0] + "']"); + if (!regler) return; + const schieber = regler.querySelector("input[type=range]"); + // Wer gerade zieht, bestimmt. Sonst spraenge ihm der Schieber unter + // dem Daumen weg, waehrend die Jalousie noch das alte Ziel anfaehrt. + if (document.activeElement === schieber) return; + schieber.value = wert; + reglerAnzeigen(regler); + }); + } + /** * Die neue Solltemperatur schicken. * diff --git a/restricted/commands.php b/restricted/commands.php index fef8c71..657de9f 100644 --- a/restricted/commands.php +++ b/restricted/commands.php @@ -279,8 +279,18 @@ function tahomaKommando($deviceUrl, $name, $parameter) } } -/** Einen Zustandswert eines Tahoma-Geraets lesen, null wenn unbekannt. */ -function tahomaZustand($deviceUrl, $feld) +/** + * 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/" @@ -295,12 +305,87 @@ function tahomaZustand($deviceUrl, $feld) if (!is_array($antwort)) { return null; } + $werte = []; foreach ($antwort as $zustand) { - if (($zustand["name"] ?? "") === $feld) { - return $zustand["value"]; + if (isset($zustand["name"])) { + $werte[$zustand["name"]] = $zustand["value"] ?? null; } } - return 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"; + } + 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 (strval($zeile["current_value"]) === $text) { + continue; + } + $id = intval($zeile["id"]); + $schreib->bind_param("si", $text, $id); + $schreib->execute(); + $geaendert++; + } + $schreib->close(); + return $geaendert; } /** @@ -326,9 +411,13 @@ function warteAufJalousie($deviceUrl, $neigungZiel, $schliessungZiel = null) $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"); + $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; @@ -340,12 +429,44 @@ function warteAufJalousie($deviceUrl, $neigungZiel, $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; } +/** + * 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.