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>
This commit is contained in:
2026-09-15 22:21:19 +02:00
co-authored by Claude Opus 5
parent fed0d35e03
commit d5d2d8f336
8 changed files with 1201 additions and 1016 deletions
+10 -409
View File
@@ -180,6 +180,7 @@ const homeSVG = {
// mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum
// bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort
// dazu, nicht hier.
if (typeof raumfensterMqtt === "function") raumfensterMqtt();
homeRooms.forEach(function (room) {
const el = (part) => document.getElementById(room.floor + "_" + part);
@@ -318,10 +319,16 @@ function switchFloor(floor){
// Ersetzung bleibt fuer die alten Namen ("Kueche"), bis sie jemand
// unter Einstellungen -> Grundriss umbenennt.
const titel = eintrag
? eintrag.floor + " · " + eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö")
? eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö")
: "Raum";
const modalTitel = document.getElementById("modal-title");
modalTitel.textContent = titel;
if (eintrag) {
const etage = document.createElement("span");
etage.className = "rf-etage";
etage.textContent = eintrag.floor;
modalTitel.append(" ", etage);
}
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht der Handler eines vorher
@@ -346,414 +353,8 @@ function switchFloor(floor){
.then(a => a.text())
.then(html => {
koerper.innerHTML = html;
const slider = document.getElementById("modal-slider");
if (slider) {
bindModalSlider(slider, document.getElementById("modal-slider-label"),
wert => wert + " °C");
const stand = mqttZweig(eintrag?.thermostat)?.["Set Temp[degC]"];
slider.value = typeof stand !== "undefined" ? stand : 10;
// Nur "input": das zeichnet die Anzeige, schickt aber nichts.
slider.dispatchEvent(new Event("input"));
// "change" kommt beim Loslassen - wie bei den Rollladenreglern.
slider.addEventListener("change", () => sendeSolltemperatur(raum));
}
raumBedienungAktivieren(koerper);
// Jede Jalousie im Raum einmal an der Box nachfragen. Der Wert im
// Modal kommt aus actor_states und ist damit hoechstens so frisch
// wie der letzte Poll des Runners - und wer das Modal waehrend einer
// Fahrt aufmacht, saehe sonst den Stand von davor und nie wieder
// etwas anderes: gezeichnet wird nur beim Oeffnen.
koerper.querySelectorAll(".raum-geraet").forEach(k => jalousieVerfolgen(k, null));
raumMesswerteAktualisieren(eintrag?.thermostat);
// Verdrahten, zeichnen, Jalousien nachfragen: js/solar/raumfenster.js.
raumfensterStarten(koerper.querySelector("#roomView") || koerper, raum, eintrag?.thermostat);
})
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; });
}
/**
* Die Bedienelemente des Raum-Modals mit ihren Kommandos verbinden.
*
* Welches Geraet welches Element bekommt, entscheidet der Server
* (restricted/roomControls.php); hier haengen nur die Zuhoerer an den
* data-Attributen, die er dabei setzt.
*/
function raumBedienungAktivieren(koerper) {
koerper.querySelectorAll("button[data-cmd]").forEach(k => {
k.addEventListener("click", () => schalte(k));
});
// Schieberegler: waehrend des Ziehens nur die Anzeige, geschickt wird
// erst beim Loslassen. Sonst bekaeme eine Jalousie bei jedem Pixel ein
// neues Ziel und kaeme aus dem Anfahren nicht mehr heraus.
koerper.querySelectorAll(".raum-jalousie").forEach(lamellenZeichnen);
koerper.querySelectorAll(".raum-regler").forEach(regler => {
const schieber = regler.querySelector("input[type=range]");
reglerAnzeigen(regler);
schieber.addEventListener("input", () => reglerAnzeigen(regler));
schieber.addEventListener("change", () => {
const params = {};
params[regler.dataset.param] = schieber.value;
// Der Hoehenregler nimmt die Neigung mit: eine Hoehenfahrt rastet die
// Lamellen um, und der Server faehrt sie nur dann wieder an, wenn der
// Befehl ein Neigungsziel dabei hat (Kugelschreiber-Mechanik, siehe
// sendeTahoma() in restricted/commands.php). Welcher Parameter das
// ist, sagt der Server ueber data-mit-param.
if (regler.dataset.mitParam) {
const daneben = regler.closest(".raum-jalousie")
.querySelector("[data-rolle='" + regler.dataset.mitRolle + "'] input[type=range]");
if (daneben) params[regler.dataset.mitParam] = daneben.value;
}
raumKommando(Number(regler.dataset.cmd), params, regler);
});
});
// Kippschalter: An und Aus sind zwei getrennte Kommandos. Kennt das
// Geraet nur "umschalten", tut es auch das - dann stimmt die Stellung
// hinterher, weil wir sie selbst gesetzt haben.
koerper.querySelectorAll(".raum-schalter").forEach(schalter => {
const kasten = schalter.querySelector("input[type=checkbox]");
const beschriftung = schalter.querySelector("label");
kasten.addEventListener("change", () => {
const ziel = kasten.checked ? schalter.dataset.ein : schalter.dataset.aus;
const cmd = ziel || schalter.dataset.um;
beschriftung.textContent = kasten.checked ? "An" : "Aus";
if (!cmd) return;
raumKommando(Number(cmd), {}, schalter);
});
});
// Farbe: der Browser bringt seinen Farbwaehler mit, das Geraet will drei
// Zahlen 0-255. Zerlegt wird deshalb erst hier.
koerper.querySelectorAll(".raum-farbe").forEach(feld => {
const eingabe = feld.querySelector("input[type=color]");
eingabe.addEventListener("change", () => {
const hex = eingabe.value;
const params = {};
params[feld.dataset.red] = parseInt(hex.substr(1, 2), 16);
params[feld.dataset.green] = parseInt(hex.substr(3, 2), 16);
params[feld.dataset.blue] = parseInt(hex.substr(5, 2), 16);
raumKommando(Number(feld.dataset.cmd), params, feld);
});
});
// Auswahllisten, die ohne zusaetzlichen Knopf schalten - Effekt, Preset.
koerper.querySelectorAll("select.raum-sofort").forEach(auswahl => {
auswahl.addEventListener("change", () => {
const params = {};
params[auswahl.dataset.param] = auswahl.value;
raumKommando(Number(auswahl.dataset.cmd), params, auswahl);
});
});
}
/**
* Anzeige, gefuellte Bahn und Lamellenbild eines Reglers nachziehen.
*
* Was 0 % und 100 % bedeuten, steht bei den senkrechten Reglern als Wort
* an ihren Enden ("oben"/"unten", "auf"/"zu"); eine Zahl steht dort nicht
* mehr - das Bild daneben zeigt beim Ziehen mehr als sie.
*/
function reglerAnzeigen(regler) {
const schieber = regler.querySelector("input[type=range]");
const anzeige = regler.querySelector(".raum-regler-wert");
const wert = Number(schieber.value);
if (anzeige) {
anzeige.textContent = wert + (schieber.dataset.einheit || "");
}
// Der gefuellte Anteil der Bahn - dieselbe Eigenschaft, aus der auch
// bindModalSlider() in common.js den Temperaturregler faerbt.
const min = Number(schieber.min);
const max = Number(schieber.max);
schieber.style.setProperty("--background-size",
((wert - min) * 100 / (max - min)) + "%");
lamellenZeichnen(regler.closest(".raum-jalousie"));
}
/** Der Stand eines der beiden Regler einer Jalousie, als Anteil 0..1. */
function reglerAnteil(jalousie, rolle, ersatz) {
const schieber = jalousie.querySelector("[data-rolle=" + rolle + "] input[type=range]");
if (!schieber) return ersatz;
const min = Number(schieber.min);
return (Number(schieber.value) - min) / (Number(schieber.max) - min);
}
/**
* Das Fenster zeichnen, wie die Jalousie davor gerade steht.
*
* Zwei Zahlen in einem Bild. Die Hoehe schneidet ab - gezeichnet wird nur
* der Teil des Fensters, den die Jalousie bedeckt, mit der Endschiene an
* seiner Unterkante. Die Neigung bestimmt, wie dick eine Lamelle von vorn
* erscheint: w * sin(Winkel). Bei "auf" sieht man nur ihre Kante und viel
* Luft dazwischen, bei "zu" deckt sie den Abstand zur naechsten
* vollstaendig ab - genau das, was das Licht abhaelt. Von Mitte zu Mitte
* sind es 10 Einheiten, die Lamelle ist 11 tief; ab etwa 65 % schliesst
* das Bild, so wie es die Jalousie auch tut.
*
* Ein Rollladen ohne Neigung bekommt volle Lamellenhoehe - er ist ja ein
* geschlossener Panzer.
*/
function lamellenZeichnen(jalousie) {
if (!jalousie) return;
const bild = jalousie.querySelector(".raum-lamellen");
if (!bild) return;
const hoch = bild.viewBox.baseVal.height;
const abdeckung = hoch * reglerAnteil(jalousie, "position", 1);
const kippung = reglerAnteil(jalousie, "neigung", 1);
const dicke = Math.max(1.6, 11 * Math.sin(kippung * Math.PI / 2));
bild.querySelector(".raum-abdeckung").setAttribute("height", abdeckung);
bild.querySelectorAll(".raum-lamelle").forEach(lamelle => {
const mitte = Number(lamelle.dataset.mitte);
lamelle.setAttribute("height", dicke);
lamelle.setAttribute("y", mitte - dicke / 2);
});
// Die Endschiene sitzt auf der Unterkante und bleibt im Bild, auch wenn
// die Jalousie ganz oben steht.
const schiene = bild.querySelector(".raum-endschiene");
const dick = schiene.height.baseVal.value;
schiene.setAttribute("y", Math.max(0, Math.min(hoch - dick, abdeckung - dick)));
}
/**
* Die Ist-Werte im Heizungsteil aus dem laufenden MQTT-Strom fuellen.
*
* Der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert aus der
* Datenbank waere neben dem Regler nur ein Stand von vorhin.
*/
function raumMesswerteAktualisieren(thermostat) {
const werte = mqttZweig(thermostat);
if (!werte) return;
document.querySelectorAll("#roomView [data-mqtt]").forEach(feld => {
const wert = werte[feld.dataset.mqtt];
if (typeof wert !== "undefined") feld.textContent = wert;
});
const heizt = document.querySelector("#roomView [data-mqtt-heizung]");
if (heizt) heizt.classList.toggle("d-none", String(werte["Heating"]) !== "true");
}
/** Ein Kommando des angeklickten Knopfes ausfuehren. */
function schalte(knopf) {
const zeile = knopf.closest("[data-cmdrow]");
const params = {};
if (zeile) {
zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
}
raumKommando(Number(knopf.dataset.cmd), params, knopf);
}
/**
* Ein Kommando schicken und die Karte so lange sperren.
*
* Ein "Zu" an einer Jalousie dauert ueber eine Minute (die Mechanik will
* zweimal geschwenkt werden, siehe restricted/commands.php). Ohne sichtbare
* Sperre tippt man in der Zeit dreimal nach.
*/
function raumKommando(cmdId, params, element) {
const karte = element.closest(".raum-geraet") || element;
karte.classList.add("raum-warte");
// Die Verfolgung laeuft neben dem Auftrag her, nicht nach ihm: ein "Zu"
// faehrt herunter, wartet und neigt danach - die Antwort kaeme also erst,
// wenn die Jalousie schon steht. Beides nebeneinander geht, seit
// ajax/room.php die Sitzung freigibt. Der Auftrag geht mit, weil nur er
// weiss, wann das Manoever zu Ende ist.
const auftrag = raumRequest("command", { command_id: cmdId, params: params });
jalousieVerfolgen(karte, auftrag);
auftrag.then(antwort => {
karte.classList.remove("raum-warte");
raumMeldung(antwort);
});
}
// 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;
// Wie lange nach dem Ende des Auftrags noch nachgesehen wird. Eine
// Neigungsfahrt setzt core:MovingState nie (siehe warteAufJalousie() in
// restricted/commands.php), dauert aber gemessen bis zu fuenfzehn Sekunden
// von Anschlag zu Anschlag - ohne diesen Nachlauf stuende der Wert von
// vor dem letzten Schwenk in der Anzeige.
const JALOUSIE_NACHLAUF_MS = 14000;
/**
* Nach einem Fahrbefehl abwarten, bis die Jalousie steht, und dann ihren
* wirklichen Stand zeigen.
*
* 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.
*
* Waehrend der Fahrt wird bewusst nichts angezeigt. An der Anlage
* gemessen: die Hoehe (core:ClosureState) steht die ganze Fahrt ueber auf
* dem Wert von vor dem Kommando und springt erst am Ende um - wer sie
* anzeigte, holte den Regler vom eingestellten Ziel auf den alten Stand
* zurueck, nur um ihn zwanzig Sekunden spaeter wieder vorzuschieben. Die
* Neigung meldet zwar Zwischenwerte, aber ausgerechnet die der
* Kugelschreiber-Mechanik: bei "Neigung 80" liefe der Regler 62 -> 28 ->
* 38 -> 79, ueber einen Umweg, den niemand angefordert hat.
*/
function jalousieVerfolgen(karte, auftrag) {
if (!karte || !karte.querySelector(".raum-jalousie")) return;
const actor = Number(karte.dataset.actor);
if (!actor) return;
// Ohne Auftrag - beim Oeffnen des Modals - gibt es keine Traegheit
// abzuwarten und nichts nachlaufen zu lassen: was die Box beim ersten
// Blick meldet, gilt. Faehrt sie gerade, wird bis zum Stillstand
// weitergesehen.
const vorlaufNoetig = !!auftrag;
// 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;
// Der zuletzt im Stillstand gelesene Stand. "core:MovingState ist false"
// heisst noch nicht "steht": die Box meldet das Ende der Fahrt, bevor die
// Werte darauf nachgezogen haben. Fertig ist es erst, wenn zwei Abfragen
// hintereinander dasselbe sagen.
let letzterStand = null;
// Solange der Auftrag laeuft, ist nichts entschieden: die
// Kugelschreiber-Mechanik schickt zwei Befehle mit einer Wartezeit
// dazwischen, und zwischen ihnen steht die Jalousie still. Wer da
// aufhoert, schreibt die Zwischenstellung fest - gemessen "Neigung 0",
// wo 62 gemeint war.
let ruheAb = 0;
if (auftrag) {
ruheAb = Infinity;
Promise.resolve(auftrag).then(
function () { ruheAb = Date.now() + JALOUSIE_NACHLAUF_MS; },
function () { ruheAb = Date.now(); }
);
}
function aufhoeren() {
delete karte.dataset.jalousieLaeuft;
delete karte.dataset.jalousieBis;
}
function runde() {
raumRequest("jalousie", { actor: actor }).then(stand => {
// Ein Fehler soll auffallen. Ohne die Meldung endet die Schleife
// still, und die Anzeige bleibt einfach stehen - nicht zu
// unterscheiden von "die Verfolgung gibt es gar nicht".
if (stand && stand.error) raumMeldung(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;
}
const abgelesen = stand.faehrt
? null
: String(stand.position) + "/" + String(stand.neigung);
const steht = abgelesen !== null && abgelesen === letzterStand;
letzterStand = abgelesen;
if (stand.faehrt) {
gefahren = true;
} else if (Date.now() >= ruheAb) {
// Erst wenn der Auftrag durch ist. Zwischen den beiden Befehlen der
// Kugelschreiber-Mechanik steht die Jalousie still und meldet
// "Neigung 0" - anzeigen hiesse, den Umweg vorzufuehren.
jalousieAnzeigen(karte, stand);
}
if (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.
// Beim blossen Aufmachen eines Raums, in dem sich nichts geruehrt
// hat, gibt es nichts abzuwarten - dann bleibt es bei einer Abfrage
// je Jalousie.
if (!vorlaufNoetig && !gefahren && !stand.faehrt) {
aufhoeren();
return;
}
const fertig = steht
&& Date.now() >= ruheAb
&& (gefahren || !vorlaufNoetig
|| Date.now() - beginn >= JALOUSIE_VORLAUF_MS);
if (fertig) {
aufhoeren();
return;
}
setTimeout(() => runde(false), JALOUSIE_TAKT_MS);
});
}
// Beim Oeffnen sofort nachsehen, nach einem Kommando erst im Takt: da
// stuende die Box ohnehin noch auf dem alten Wert.
setTimeout(runde, auftrag ? JALOUSIE_TAKT_MS : 0);
}
/**
* Die gemeldete Stellung in die beiden Regler und das Bild uebernehmen.
* Aufgerufen nur im Stillstand, siehe jalousieVerfolgen().
*/
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.
*
* Das Modal bleibt danach offen: frueher schloss es sich, weil der
* Speichern-Knopf das Ende der Bedienung war. Jetzt ist das Verschieben
* des Reglers die Bedienung, und wer dreimal nachjustiert, will nicht
* dreimal neu aufmachen.
*/
function sendeSolltemperatur(raum) {
const slider = document.getElementById("modal-slider");
if (!slider) return;
const karte = slider.closest(".raum-geraet");
if (karte) karte.classList.add("raum-warte");
raumRequest("temp", { room: raum, temp: Number(slider.value) })
.then(antwort => {
if (karte) karte.classList.remove("raum-warte");
raumMeldung(antwort);
});
}
function raumRequest(action, body) {
return fetch("./ajax/room.php?action=" + action, {
method: "POST",
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
body: JSON.stringify(body)
})
.then(a => a.json())
.catch(fehler => ({ error: fehler.message }));
}
/** Rueckmeldung im Modal statt als alert - man schaltet oft mehrmals. */
function raumMeldung(antwort) {
const ziel = document.getElementById("roomFeedback");
if (!ziel) return;
ziel.className = "small mt-2 " + (antwort.error ? "text-danger" : "text-body-secondary");
ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok;
}