Geraete hingen ueber den Text "Etage/Raum" an ihrem Raum, das Raum-Modal zerlegte "EG_Bad" am Unterstrich, und das Thermostat-Topic wurde aus Etage und Name zusammengesetzt. Ein umbenannter Raum haette still alle Geraete, seinen Temperaturregler und sein Heizsymbol verloren. - actors.room (Text) -> actors.room_id, Fremdschluessel auf rooms, beim Loeschen eines Raums SET NULL. Alle 59 Zuordnungen uebernommen. - automations.floor: ENUM(UG,EG,OG,AG) -> Verweis auf floors. Eine neue Etage kann damit auch Automatiken tragen. - room.php nimmt ?room=<Nummer>; Solltemperatur geht an rooms.thermostat + /changeSetTemp. - homeMQTT.js: Heizsymbol, Regler und Ist-Werte ueber mqttZweig(thermostat) statt mqttData.Raumtemp[Etage][Name]. - Raumvorschlaege in den Einstellungen: Messwerte unter dem Thermostat- Zweig eines Raums statt fest Raumtemp/<Etage>/<Name>. - Schluessel in raumListe(), Katalog und Maske ist die Raumnummer als Text. Geprueft gegen den Stand davor: Startseite gleich bis auf das Klickziel, jede der 25 Kacheln oeffnet ihren eigenen Raum; Geraeteliste, Vorschlaege, Editor-Katalog und die eigenen Geraete je Kachel gleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
10 KiB
PHP
244 lines
10 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
|
||
*
|
||
* 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"),
|
||
]);
|
||
}
|
||
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) {
|
||
$karte = "<div class='card mb-2 raum-geraet'><div class='card-body py-2'>"
|
||
. "<div class='d-flex justify-content-between align-items-baseline'>"
|
||
. "<label class='form-label mb-0 small' for='modal-slider'>Solltemperatur</label>"
|
||
. "<span id='modal-slider-label' class='fw-semibold text-nowrap ms-2' style='color:#297195'>–</span>"
|
||
. "</div>"
|
||
. "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>"
|
||
// Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank -
|
||
// der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von
|
||
// vorhin waere neben dem Regler nur irrefuehrend.
|
||
. "<div class='d-flex flex-wrap gap-3 small text-body-secondary'>"
|
||
. "<span><i class='bi bi-thermometer-half me-1'></i><span data-mqtt='Temp[degC]'>–</span> °C</span>"
|
||
. "<span><i class='bi bi-droplet me-1'></i><span data-mqtt='rHum[%]'>–</span> %rF</span>"
|
||
. "<span data-mqtt-heizung class='d-none'><i class='bi bi-fire text-danger me-1'></i>heizt</span>"
|
||
. "</div></div></div>";
|
||
$fertig["heizung"] = $karte;
|
||
}
|
||
|
||
// 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='p-1' id='roomView' data-room='" . $raum["nr"] . "'"
|
||
. " data-thermostat='" . htmlspecialchars((string)$raum["thermostat"], ENT_QUOTES) . "'>";
|
||
|
||
if (!$gruppen && !$fertig) {
|
||
echo "<p class='text-body-secondary mb-0'>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='small mt-2'></div>";
|
||
echo "</div>";
|