Die native Auswahlliste zeichnete Chrome hell auf hell. Welcher Effekt und welches Preset gerade laufen, liest room.php?action=wled direkt von der Lampe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
16 KiB
PHP
338 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* Raum-Modal: alles, was in einem Raum haengt, auf einer Seite.
|
||
*
|
||
* Loest das frühere Thermostat-Modal ab. Der Grundriss zeigt Raeume, also
|
||
* sollte ein Klick darauf den Raum oeffnen und nicht nur seine Heizung - die
|
||
* Rollladen im selben Zimmer waren bisher gar nicht erreichbar.
|
||
*
|
||
* Welche Geraete zu einem Raum gehoeren, sagt die Spalte actors.room_id
|
||
* (Einstellungen -> Geraete).
|
||
*
|
||
* GET ?room=12 Inhalt des Modals, 12 = rooms.id
|
||
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
|
||
* POST ?action=temp {"room": 12, "temp": 21} - Solltemperatur
|
||
* POST ?action=jalousie {"actor": 5} - Stand einer Jalousie
|
||
* POST ?action=my {"actor": 5} - aktuellen Stand als
|
||
* my-Position speichern
|
||
*
|
||
* Frueher hiess der Raum "EG_Bad" und wurde am Unterstrich zerlegt. Das hielt
|
||
* weder ein Leerzeichen im Namen aus (erst nachgeruestet) noch eine
|
||
* Umbenennung - die feste Nummer haelt beides.
|
||
*/
|
||
|
||
/** Ein Raum nach seiner festen Nummer, oder null. */
|
||
function raumNachNummer($nr)
|
||
{
|
||
foreach (allRooms() as $r) {
|
||
if ($r["nr"] === intval($nr)) {
|
||
return $r;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Ein "Zu" an einer Jalousie faehrt herunter, wartet und neigt danach - das
|
||
// dauert in der Summe ueber eine Minute. Die Vorgabe von PHP reicht dafuer
|
||
// nicht; ajax/tahoma.php macht es an derselben Stelle genauso.
|
||
set_time_limit(180);
|
||
|
||
require_once("../helper.php");
|
||
require_once("../restricted/automations.php");
|
||
require_once("../restricted/commands.php");
|
||
require_once("../restricted/roomControls.php");
|
||
require_once("./phpMQTT.php");
|
||
|
||
if (!checkLogin()) {
|
||
http_response_code(403);
|
||
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);
|
||
header("Content-Type: application/json; charset=utf-8");
|
||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Die Antwort abschicken, aber weiterarbeiten.
|
||
*
|
||
* Nach einem Fahrbefehl muss noch jemand abwarten, wo die Jalousie stehen
|
||
* bleibt, und das aufschreiben - sonst steht in actor_states bis zum
|
||
* naechsten Poll des Runners der alte Wert. Das dem Browser zu ueberlassen
|
||
* ging schief: wer das Modal gleich nach dem Fahrbefehl zumachte, nahm den
|
||
* einzigen Zuschauer mit.
|
||
*
|
||
* Dieselbe Bauart wie in ajax/tahoma.php: Content-Length setzen, ausliefern,
|
||
* die Verbindung schliessen, weiterlaufen. Ohne FPM bleibt nur, die Puffer zu
|
||
* leeren - dann hat der Browser seine Daten zwar, wartet aber trotzdem.
|
||
*/
|
||
function raumAntwortUndWeiter($data)
|
||
{
|
||
http_response_code(200);
|
||
header("Content-Type: application/json; charset=utf-8");
|
||
$text = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||
header("Content-Length: " . strlen($text));
|
||
echo $text;
|
||
// Der Aufrufer ist weg, das Skript soll trotzdem zu Ende kommen.
|
||
ignore_user_abort(true);
|
||
if (function_exists("fastcgi_finish_request")) {
|
||
fastcgi_finish_request();
|
||
return;
|
||
}
|
||
while (ob_get_level() > 0) {
|
||
ob_end_flush();
|
||
}
|
||
flush();
|
||
}
|
||
|
||
// --- Schaltende Aufrufe ---------------------------------------------------
|
||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||
$body = json_decode(file_get_contents("php://input"), true);
|
||
if (!is_array($body)) {
|
||
raumAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||
}
|
||
try {
|
||
if (($_GET["action"] ?? "") === "command") {
|
||
$id = intval($body["command_id"] ?? 0);
|
||
$text = executeCommand($id,
|
||
is_array($body["params"] ?? null) ? $body["params"] : [],
|
||
!empty($body["probe"]));
|
||
// Bei einem Tahoma-Geraet ist mit dem Absenden noch nichts
|
||
// festgehalten: die Box meldet die neue Stellung erst, wenn die
|
||
// Fahrt zu Ende ist. Also Antwort raus und in Ruhe nachsehen.
|
||
$url = empty($body["probe"]) ? tahomaUrlZuKommando($id) : null;
|
||
if ($url === null) {
|
||
raumAntwort(["ok" => $text]);
|
||
}
|
||
raumAntwortUndWeiter(["ok" => $text]);
|
||
tahomaNachlesen($url);
|
||
exit;
|
||
}
|
||
if (($_GET["action"] ?? "") === "temp") {
|
||
// Derselbe Weg wie im alten Thermostat-Modal: <Zweig>/changeSetTemp.
|
||
// Der Zweig steht am Raum (rooms.thermostat) und wird nicht mehr
|
||
// aus Etage und Name zusammengesetzt - ein Thermostat in einem
|
||
// anderen Haus meldet vielleicht unter einem ganz anderen.
|
||
$raum = raumNachNummer($body["room"] ?? 0);
|
||
if (!$raum || empty($raum["thermostat"])) {
|
||
raumAntwort(["error" => "Dieser Raum hat kein Thermostat."], 400);
|
||
}
|
||
$temp = intval($body["temp"] ?? 0);
|
||
if ($temp < 10 || $temp > 30) {
|
||
raumAntwort(["error" => "Temperatur außerhalb 10–30 °C."], 400);
|
||
}
|
||
$mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomTempSetter_" . getmypid());
|
||
if (!$mqtt->connect(true, null, "", "")) {
|
||
raumAntwort(["error" => "Keine Verbindung zum MQTT-Broker."], 500);
|
||
}
|
||
$mqtt->publish(rtrim($raum["thermostat"], "/") . "/changeSetTemp",
|
||
strval($temp), 0, false);
|
||
$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"),
|
||
"myPosition" => $zahl("core:Memorized1PositionState"),
|
||
"myNeigung" => $zahl("core:Memorized1OrientationState"),
|
||
]);
|
||
}
|
||
// Den Zustand einer WLED-Lampe live lesen: an/aus, Helligkeit, Farbe,
|
||
// Effekt und Preset. In actor_states stehen nur die ersten drei, und
|
||
// ohne Effekt und Preset koennte das Raum-Modal nicht zeigen, was
|
||
// gerade laeuft.
|
||
if (($_GET["action"] ?? "") === "wled") {
|
||
$db = commandDb();
|
||
$stmt = $db->prepare("SELECT url FROM actors WHERE id = ?");
|
||
$id = intval($body["actor"] ?? 0);
|
||
$stmt->bind_param("i", $id);
|
||
$stmt->execute();
|
||
$zeile = $stmt->get_result()->fetch_assoc();
|
||
$stmt->close();
|
||
if (!$zeile || strpos(strval($zeile["url"]), "wled://") !== 0) {
|
||
raumAntwort(["error" => "Keine WLED-Lampe mit dieser Nummer."], 404);
|
||
}
|
||
$ch = curl_init("http://" . rtrim(substr($zeile["url"], strlen("wled://")), "/") . "/json/state");
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
|
||
$stand = json_decode(strval(curl_exec($ch)), true);
|
||
curl_close($ch);
|
||
if (!is_array($stand)) {
|
||
raumAntwort(["error" => "Die Lampe antwortet nicht."], 503);
|
||
}
|
||
$segment = $stand["seg"][0] ?? [];
|
||
$farbe = $segment["col"][0] ?? null;
|
||
raumAntwort([
|
||
"an" => !empty($stand["on"]),
|
||
"bri" => isset($stand["bri"]) ? intval($stand["bri"]) : null,
|
||
"farbe" => is_array($farbe) && count($farbe) >= 3 ? array_map("intval", array_slice($farbe, 0, 3)) : null,
|
||
"effect" => isset($segment["fx"]) ? intval($segment["fx"]) : null,
|
||
"preset" => isset($stand["ps"]) ? intval($stand["ps"]) : null,
|
||
]);
|
||
}
|
||
// Den Stand, auf dem die Jalousie gerade steht, als my-Position in den
|
||
// Motor schreiben. Die Box kennt dafuer setMemorized1Position und bei
|
||
// Raffstores zusaetzlich setMemorized1Orientation; die Geraetesuche
|
||
// uebernimmt beide nicht als Kommando, deshalb geht es hier direkt.
|
||
// Gespeichert wird, was die Box meldet - nicht, was im Browser steht:
|
||
// der koennte einer Fahrt hinterherhinken.
|
||
if (($_GET["action"] ?? "") === "my") {
|
||
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
|
||
if ($url === null) {
|
||
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
|
||
}
|
||
$vorher = tahomaZustaende($url);
|
||
if ($vorher === null) {
|
||
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
|
||
}
|
||
if (($vorher["core:MovingState"] ?? null) === true) {
|
||
raumAntwort(["error" => "Die Jalousie fährt noch – bitte warten, bis sie steht."], 409);
|
||
}
|
||
if (!isset($vorher["core:Memorized1PositionState"]) || !is_numeric($vorher["core:ClosureState"] ?? null)) {
|
||
raumAntwort(["error" => "Dieses Gerät kann keine my-Position speichern."], 400);
|
||
}
|
||
$position = intval($vorher["core:ClosureState"]);
|
||
$neigung = null;
|
||
tahomaKommando($url, "setMemorized1Position", [$position]);
|
||
$text = "my-Position " . $position . " %";
|
||
if (isset($vorher["core:Memorized1OrientationState"]) && is_numeric($vorher["core:SlateOrientationState"] ?? null)) {
|
||
$neigung = intval($vorher["core:SlateOrientationState"]);
|
||
tahomaKommando($url, "setMemorized1Orientation", [$neigung]);
|
||
$text .= ", Lamellen " . $neigung . " %";
|
||
}
|
||
// Die Box braucht einen Moment, bis sie den neuen Wert meldet.
|
||
sleep(2);
|
||
$nachher = tahomaZustaende($url);
|
||
if ($nachher !== null) {
|
||
tahomaZustaendeSchreiben($url, $nachher);
|
||
}
|
||
raumAntwort([
|
||
"ok" => $text . " gespeichert",
|
||
"myPosition" => $position,
|
||
"myNeigung" => $neigung,
|
||
]);
|
||
}
|
||
raumAntwort(["error" => "Unbekannte Aktion."], 400);
|
||
} catch (InvalidArgumentException $e) {
|
||
raumAntwort(["error" => $e->getMessage()], 400);
|
||
} catch (Throwable $e) {
|
||
raumAntwort(["error" => $e->getMessage()], 500);
|
||
}
|
||
}
|
||
|
||
|
||
// --- Inhalt des Modals ----------------------------------------------------
|
||
$raum = raumNachNummer($_GET["room"] ?? 0);
|
||
if (!$raum) {
|
||
echo "<p class='text-body-secondary mb-0'>Diesen Raum gibt es nicht (mehr).</p>";
|
||
exit;
|
||
}
|
||
// Derselbe Schluessel wie actors.room_id im Katalog - als Text, siehe raumListe().
|
||
$raumKey = strval($raum["nr"]);
|
||
|
||
// Alles im Raum, sortiert nach Bedienform. Das Thermostat selbst wird nicht
|
||
// als Karte gezeichnet: es wird zum Schieberegler im Heizungs-Reiter und
|
||
// braucht daneben keine zweite Bedienung - "Solltemperatur" stand sonst
|
||
// zweimal im Modal.
|
||
//
|
||
// Ob es einen Heizungs-Reiter gibt, entscheidet genau das: haengt in diesem
|
||
// Raum ein Thermostat? Frueher stand hier "hat der Raum eine Kachel" - das
|
||
// stimmte, solange nur Raeume mit Thermostat eine bekamen. Seit auch Kueche
|
||
// und Technik eine haben, bekamen sie einen Heizungsregler fuer eine
|
||
// Heizung, die es dort nicht gibt.
|
||
$katalog = deviceCatalog();
|
||
$hatThermostat = false;
|
||
$gruppen = [];
|
||
foreach ($katalog as $d) {
|
||
if ($d["room"] !== $raumKey) {
|
||
continue;
|
||
}
|
||
$form = bedienform($d);
|
||
if ($form === "heizung") {
|
||
$hatThermostat = true;
|
||
continue;
|
||
}
|
||
$gruppen[$form][] = $d;
|
||
}
|
||
|
||
// Die Heizung ist eine Gruppe wie die anderen, nur baut sie sich nicht aus
|
||
// einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
|
||
$fertig = [];
|
||
if ($hatThermostat) {
|
||
// Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, daneben - und + und
|
||
// die Schnellwahl. Alle Zahlen kommen live ueber MQTT (der Browser hoert
|
||
// ohnehin auf den Thermostat-Zweig) und werden von js/solar/raumfenster.js
|
||
// eingetragen. Das Thermostat nimmt ganze Grad von 10 bis 30 °C, siehe
|
||
// action=temp.
|
||
$fertig["heizung"] = "<div class='rf-heiz' data-rf-heiz-teil data-min='10' data-max='30'>"
|
||
. "<div class='rf-ring'><svg viewBox='0 0 170 170'>"
|
||
. "<defs><linearGradient id='rfHeizVerlauf' gradientUnits='userSpaceOnUse' gradientTransform='rotate(-135 85 85)' x1='15' y1='0' x2='155' y2='0'>"
|
||
. "<stop offset='0' stop-color='#4b9fd6'/><stop offset='.55' stop-color='#e8a33a'/><stop offset='1' stop-color='#e5624f'/>"
|
||
. "</linearGradient></defs>"
|
||
. "<circle class='rf-ring-spur' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
|
||
. "<circle class='rf-ring-ist' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
|
||
. "<circle class='rf-ring-griff' cx='85' cy='85' r='70' transform='rotate(135 85 85)' data-rf-ringzug/>"
|
||
. "<circle class='rf-ring-soll' cx='155' cy='85' r='10' data-rf-ringzug tabindex='0' role='slider'"
|
||
. " aria-label='Solltemperatur' aria-valuemin='10' aria-valuemax='30'/></svg>"
|
||
. "<div class='rf-ring-mitte'><small>im Raum</small><b data-rf-heiz='ist'>–</b><span data-rf-heiz='soll'>Soll –</span></div></div>"
|
||
. "<div class='rf-heiz-rechts'><h3>Solltemperatur</h3>"
|
||
. "<div class='rf-stepper'><button type='button' data-rf-schritt='-1' aria-label='1 Grad kälter'>−</button>"
|
||
. "<output data-rf-heiz='wert'>–</output>"
|
||
. "<button type='button' data-rf-schritt='1' aria-label='1 Grad wärmer'>+</button></div>"
|
||
. "<div class='rf-chips'>"
|
||
. "<button type='button' class='rf-chip' data-rf-soll='10' aria-pressed='false'>Minimum <span>10°</span></button>"
|
||
. "<button type='button' class='rf-chip' data-rf-soll='17' aria-pressed='false'>Eco <span>17°</span></button>"
|
||
. "<button type='button' class='rf-chip' data-rf-soll='21' aria-pressed='false'>Komfort <span>21°</span></button>"
|
||
. "<button type='button' class='rf-chip' data-rf-soll='23' aria-pressed='false'>Warm <span>23°</span></button></div>"
|
||
. "<div class='rf-hinweis' data-rf-heiz='hinweis' hidden><i class='bi bi-info-circle'></i><span></span></div>"
|
||
. "<div class='rf-pillen'><span class='rf-pille'><i class='bi bi-droplet'></i><span data-rf-heiz='feuchte'>–</span></span>"
|
||
. "<span class='rf-pille' data-rf-heiz='puffer' hidden><i class='bi bi-fire'></i>Pufferbetrieb</span></div>"
|
||
. "</div></div>";
|
||
}
|
||
|
||
// Der Thermostat-Zweig reist mit: das Modal fuellt Ist-Werte und Heizsymbol
|
||
// aus dem laufenden MQTT-Strom und muss wissen, wo es nachsehen soll.
|
||
echo "<div class='rf' id='roomView' data-room='" . $raum["nr"] . "'"
|
||
. " data-thermostat='" . htmlspecialchars((string)$raum["thermostat"], ENT_QUOTES) . "'>";
|
||
|
||
if (!$gruppen && !$fertig) {
|
||
echo "<p class='rf-leer'>Diesem Raum ist noch nichts zugeordnet. "
|
||
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
|
||
}
|
||
|
||
echo zeichneGruppen($gruppen, $fertig);
|
||
|
||
echo "<div id='roomFeedback' class='rf-meldung' aria-live='polite'>Änderungen gelten sofort</div>";
|
||
echo "</div>";
|