Files
Smart-Dashboard/ajax/room.php
T
adminandClaude Opus 5 d5d2d8f336 Raum-Modal im neuen Stil: Reiter-Kacheln, Beschattung mit my, Heizring, Licht
- Beschattung: Auf/Stop/my/Zu, Hoehe und Lamellen in 25-%-Stufen, Ziehen im
  Fensterbild, neue Behang-Grafik ueber die ganze Kartenhoehe, Alle im Raum
- my-Position: aktuellen Stand per setMemorized1Position/-Orientation in den
  Motor schreiben (room.php?action=my), gespeicherte Werte werden angezeigt
- Heizung: Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, -/+ und Schnellwahl
- Licht: Birne als Schalter, Helligkeit, Farbfelder, Effekt/Preset eingeklappt
- Logik aus homeMQTT.js nach js/solar/raumfenster.js; bindModalSlider entfaellt

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

305 lines
14 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 1030 °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 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>";