Files
Smart-Dashboard/js/solar/raumfenster.js
T
adminandClaude Opus 5 0eb01403c5 Raum-Modal Licht: Preset als Chips, Effekt als durchsuchbarer Picker, WLED-Zustand live
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>
2026-09-15 22:27:40 +02:00

706 lines
30 KiB
JavaScript
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.
/*
* Raum-Modal: Beschattung, Heizung, Licht und Schalter eines Raums.
*
* Das Markup kommt fertig aus ajax/room.php (Bedienformen in
* restricted/roomControls.php); hier haengen die Zuhoerer an den
* data-Attributen, und hier wird gezeichnet, was sich beim Bedienen aendert:
* Zustand der Reiter, Behang-Bild, Heizring, Lampen.
*
* Wie im Lade-, Heiz- und Bewaesserungsfenster gilt jede Wahl sofort - es
* gibt keinen Speichern-Knopf.
*
* Aufgerufen von openRoomView() in js/solar/homeMQTT.js; die Ist-Werte der
* Heizung kommen ueber raumfensterMqtt() aus dem laufenden MQTT-Strom.
*/
const RF_S = " "; // schmales Leerzeichen vor Einheiten
let raumfenster = null; // {wurzel, raum, thermostat}
function raumfensterStarten(wurzel, raum, thermostat) {
raumfenster = { wurzel: wurzel, raum: raum, thermostat: thermostat };
wurzel.querySelectorAll("[data-rf-reiter]").forEach(k => k.addEventListener("click", () => rfReiter(wurzel, k)));
wurzel.querySelectorAll(".rf-behang").forEach(rfBehangStarten);
wurzel.querySelectorAll(".rf-alle [data-alle]").forEach(k => k.addEventListener("click", () => rfAlle(k)));
wurzel.querySelectorAll(".rf-lampe").forEach(rfLampeStarten);
wurzel.querySelectorAll(".rf-schalter").forEach(k => k.addEventListener("click", () => rfSchalten(k, k)));
// Mehr-Menue und "Sonstiges": Kommandos so, wie sie in der Datenbank stehen.
wurzel.querySelectorAll("button[data-cmd]").forEach(k => k.addEventListener("click", () => rfSchalte(k)));
const heiz = wurzel.querySelector("[data-rf-heiz-teil]");
if (heiz) rfHeizungStarten(heiz);
rfStatus();
raumfensterMqtt();
// 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.
wurzel.querySelectorAll(".rf-behang").forEach(k => rfJalousieVerfolgen(k, null));
}
/* --- Reiter ---------------------------------------------------------------- */
function rfReiter(wurzel, knopf) {
wurzel.querySelectorAll("[data-rf-reiter]").forEach(k => k.setAttribute("aria-selected", String(k === knopf)));
wurzel.querySelectorAll("[data-rf-flaeche]").forEach(f => { f.hidden = f.dataset.rfFlaeche !== knopf.dataset.rfReiter; });
}
/** Die Zeile unter jedem Reiter: was in dieser Gruppe gerade los ist. */
function rfStatus() {
if (!raumfenster) return;
const w = raumfenster.wurzel;
const setze = (form, text) => { const el = w.querySelector("[data-rf-status='" + form + "']"); if (el) el.textContent = text; };
const behaenge = [...w.querySelectorAll(".rf-behang")];
const bekannt = behaenge.filter(k => k.dataset.pos !== "");
const zu = bekannt.filter(k => Number(k.dataset.pos) >= 95).length;
setze("beschattung", !bekannt.length ? behaenge.length + " Geräte"
: zu === 0 ? "alle offen" : zu === bekannt.length ? "alle zu" : zu + " von " + bekannt.length + " zu");
const an = w.querySelectorAll(".rf-lampe[data-an='1']").length;
setze("licht", an ? an + " an" : "alles aus");
const schalter = w.querySelectorAll(".rf-schalter[aria-pressed='true']").length;
setze("schalter", schalter ? schalter + " an" : "alles aus");
const zahl = (sel, wort) => { const n = w.querySelectorAll(sel).length; return n + " " + wort; };
setze("sonstiges", zahl("[data-rf-flaeche='sonstiges'] .rf-karte", "Geräte"));
setze("messwerte", zahl("[data-rf-flaeche='messwerte'] .rf-karte", "Geräte"));
// "heizung" setzt rfHeizungZeichnen() mit der Ist-Temperatur.
}
/* --- Kommandos --------------------------------------------------------------- */
function rfRequest(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 unten im Fenster; nach ein paar Sekunden wieder der Hinweis. */
function rfMeldung(antwort) {
const ziel = document.getElementById("roomFeedback");
if (!ziel) return;
clearTimeout(rfMeldung.takt);
ziel.classList.toggle("fehler", !!antwort.error);
ziel.classList.toggle("gut", !antwort.error);
ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok;
rfMeldung.takt = setTimeout(() => {
ziel.classList.remove("fehler", "gut");
ziel.textContent = "Änderungen gelten sofort";
}, antwort.error ? 8000 : 4000);
}
/**
* Ein Kommando schicken. Eine Jalousie zeigt dabei "fährt…" und wird bis zum
* Stillstand verfolgt; alles andere ist kurz gesperrt, damit man nicht
* nachtippt.
*/
function rfKommando(cmdId, params, element) {
const karte = element.closest(".raum-geraet") || element;
const behang = karte.classList.contains("rf-behang");
if (behang) {
karte.dataset.faehrt = "1";
rfBehangZeichnen(karte);
} else {
karte.classList.add("raum-warte");
}
const auftrag = rfRequest("command", { command_id: cmdId, params: params });
if (behang) rfJalousieVerfolgen(karte, auftrag);
return auftrag.then(antwort => {
karte.classList.remove("raum-warte");
if (antwort.error && behang) {
delete karte.dataset.faehrt;
rfBehangZeichnen(karte);
}
rfMeldung(antwort);
return antwort;
});
}
/** Ein Knopf mit data-cmd; Parameter stehen, wenn noetig, in seiner Zeile. */
function rfSchalte(knopf) {
const zeile = knopf.closest("[data-cmdrow]");
const params = {};
if (zeile) zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
rfKommando(Number(knopf.dataset.cmd), params, knopf);
}
/* --- Beschattung -------------------------------------------------------------- */
function rfLamellenWort(n) {
n = Number(n);
return n <= 12 ? "offen" : n >= 88 ? "zu" : Math.abs(n - 50) <= 12 ? "halb" : n + RF_S + "%";
}
function rfStandText(pos, neig) {
if (pos === "") return "";
const p = Number(pos);
let text = p <= 2 ? "offen" : p >= 98 ? "zu" : p + RF_S + "% zu";
if (neig !== "" && p > 2) text += " · Lamellen " + rfLamellenWort(neig);
return text;
}
function rfBehangZeichnen(karte) {
const d = karte.dataset;
const pos = d.pos, neig = d.neig;
const teil = karte.querySelector(".rf-behang-teil");
if (teil) teil.style.height = (pos === "" ? 0 : Number(pos)) + "%";
const lamellen = karte.querySelector(".rf-lamellen");
if (lamellen) lamellen.style.setProperty("--kipp", 0.2 + 0.8 * (neig === "" ? 0 : Number(neig)) / 100);
const faehrt = d.faehrt === "1";
karte.classList.toggle("rf-bewegt", faehrt);
const pille = karte.querySelector("[data-rf-stand]");
if (pille) {
pille.textContent = faehrt ? "fährt…" : rfStandText(pos, neig);
pille.classList.toggle("faehrt", faehrt);
}
const aufMy = d.myPos !== "" && pos !== "" && Math.abs(Number(pos) - Number(d.myPos)) <= 2
&& (d.myNeig === "" || neig === "" || Math.abs(Number(neig) - Number(d.myNeig)) <= 2);
const myKnopf = karte.querySelector("[data-rf-fahrt='my']");
if (myKnopf) myKnopf.classList.toggle("aktiv", aufMy && !faehrt);
karte.querySelectorAll("[data-rf-pos]").forEach(k =>
k.setAttribute("aria-checked", String(pos !== "" && !aufMy && Math.abs(Number(k.dataset.rfPos) - Number(pos)) <= 2)));
karte.querySelectorAll("[data-rf-neig]").forEach(k =>
k.setAttribute("aria-checked", String(neig !== "" && Math.abs(Number(k.dataset.rfNeig) - Number(neig)) <= 2)));
const my = karte.querySelector("[data-rf-my]");
if (my) {
const myText = (p, n) => p + RF_S + "%" + (n !== "" && n !== null && typeof n !== "undefined" ? " · Lamellen " + rfLamellenWort(n) : "");
if (d.myFrage === "1") {
my.className = "rf-my-zeile frage";
my.innerHTML = "<span>my von <b>" + myText(d.myPos, d.myNeig) + "</b> auf <b>" + myText(pos, neig) + "</b> ändern?</span>"
+ "<span class='rf-my-knoepfe'><button type='button' class='rf-chip ja' data-rf-my-tun='ja'>Speichern</button>"
+ "<button type='button' class='rf-chip' data-rf-my-tun='nein'>Abbrechen</button></span>";
} else {
my.className = "rf-my-zeile";
my.innerHTML = "<i class='bi bi-star-fill'></i><span>my-Position " + myText(d.myPos, d.myNeig) + "</span>"
+ (aufMy || faehrt || pos === "" || d.mySpeichert === "1" ? ""
: "<button type='button' class='rf-link' data-rf-my-tun='frage'>aktuellen Stand als my speichern</button>")
+ (d.mySpeichert === "1" ? "<span class='rf-link'>wird gespeichert…</span>" : "");
}
}
rfStatus();
}
function rfBehangStarten(karte) {
const d = karte.dataset;
rfBehangZeichnen(karte);
karte.querySelectorAll("[data-rf-fahrt]").forEach(k => k.addEventListener("click", () => {
const cmd = d[k.dataset.rfFahrt];
if (!cmd) return;
rfKommando(Number(cmd), {}, karte);
}));
const hoehe = function (wert) {
const params = {};
params[d.hoeheParam] = wert;
// Die Hoehe 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 (siehe sendeTahoma() in restricted/commands.php).
if (d.hoeheMit) params[d.hoeheMit] = d.neig === "" ? 0 : Number(d.neig);
d.pos = String(wert);
rfKommando(Number(d.hoeheCmd), params, karte);
};
karte.querySelectorAll("[data-rf-pos]").forEach(k => k.addEventListener("click", () => hoehe(Number(k.dataset.rfPos))));
karte.querySelectorAll("[data-rf-neig]").forEach(k => k.addEventListener("click", () => {
const params = {};
params[d.neigParam] = Number(k.dataset.rfNeig);
d.neig = k.dataset.rfNeig;
rfKommando(Number(d.neigCmd), params, karte);
}));
// Im Fensterbild ziehen: die Hoehe fein einstellen. Waehrend des Ziehens
// nur das Bild, geschickt wird beim Loslassen - sonst bekaeme die Jalousie
// bei jedem Pixel ein neues Ziel.
const fenster = karte.querySelector("[data-rf-zieh]");
if (fenster && d.hoeheCmd) {
let zug = null;
const wert = e => {
const r = fenster.querySelector(".rf-glas").getBoundingClientRect();
return Math.max(0, Math.min(100, (e.clientY - r.top) / r.height * 100));
};
fenster.addEventListener("pointerdown", e => {
zug = e.pointerId;
d.zieht = "1";
fenster.setPointerCapture(e.pointerId);
karte.querySelector(".rf-behang-teil").style.height = wert(e) + "%";
e.preventDefault();
});
fenster.addEventListener("pointermove", e => {
if (zug !== e.pointerId) return;
const w = wert(e);
karte.querySelector(".rf-behang-teil").style.height = w + "%";
const pille = karte.querySelector("[data-rf-stand]");
if (pille) pille.textContent = rfStandText(String(Math.round(w)), d.neig);
});
const ende = e => {
if (zug !== e.pointerId) return;
zug = null;
delete d.zieht;
hoehe(Math.round(wert(e) / 5) * 5);
};
fenster.addEventListener("pointerup", ende);
fenster.addEventListener("pointercancel", () => { zug = null; delete d.zieht; rfBehangZeichnen(karte); });
}
// my-Position speichern: erst nachfragen, dann schreibt der Server den
// Stand, den die Box meldet, in den Motor.
karte.addEventListener("click", e => {
const k = e.target.closest("[data-rf-my-tun]");
if (!k) return;
const tun = k.dataset.rfMyTun;
if (tun === "frage") d.myFrage = "1";
if (tun === "nein") delete d.myFrage;
if (tun === "ja") {
delete d.myFrage;
d.mySpeichert = "1";
rfRequest("my", { actor: Number(d.actor) }).then(antwort => {
delete d.mySpeichert;
if (!antwort.error) {
d.myPos = String(antwort.myPosition);
if (antwort.myNeigung !== null && typeof antwort.myNeigung !== "undefined") d.myNeig = String(antwort.myNeigung);
}
rfMeldung(antwort);
rfBehangZeichnen(karte);
});
}
rfBehangZeichnen(karte);
});
}
/** "Alle im Raum": jedem Behang sein eigenes Kommando. */
function rfAlle(knopf) {
const was = knopf.dataset.alle;
knopf.closest("[data-rf-flaeche]").querySelectorAll(".rf-behang").forEach(karte => {
const d = karte.dataset;
if (was === "sicht") {
if (!d.hoeheMit) return;
const params = {};
params[d.hoeheParam] = 100;
params[d.hoeheMit] = 50;
d.pos = "100";
d.neig = "50";
rfKommando(Number(d.hoeheCmd), params, karte);
return;
}
if (d[was]) rfKommando(Number(d[was]), {}, karte);
});
}
// 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 RF_JALOUSIE_TAKT_MS = 2000;
const RF_JALOUSIE_VORLAUF_MS = 8000;
const RF_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 RF_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. Hier wird genau dieses eine Geraet
* gefragt; ajax/room.php haelt den frischen Wert gleich in der Tabelle fest.
*
* Waehrend der Fahrt wird kein Zwischenstand 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, und die Neigung
* meldet ausgerechnet die Umwege der Kugelschreiber-Mechanik.
*/
function rfJalousieVerfolgen(karte, auftrag) {
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.
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() + RF_JALOUSIE_MAX_MS);
if (karte.dataset.jalousieLaeuft) return;
karte.dataset.jalousieLaeuft = "1";
const beginn = Date.now();
let gefahren = false;
// Fertig ist es erst, wenn zwei Abfragen im Stillstand dasselbe sagen: die
// Box meldet das Ende der Fahrt, bevor die Werte nachgezogen haben.
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.
let ruheAb = 0;
if (auftrag) {
ruheAb = Infinity;
Promise.resolve(auftrag).then(
function () { ruheAb = Date.now() + RF_JALOUSIE_NACHLAUF_MS; },
function () { ruheAb = Date.now(); }
);
}
function aufhoeren() {
delete karte.dataset.jalousieLaeuft;
delete karte.dataset.jalousieBis;
delete karte.dataset.faehrt;
rfBehangZeichnen(karte);
}
function runde() {
rfRequest("jalousie", { actor: actor }).then(stand => {
// Ein Fehler soll auffallen, sonst bliebe die Anzeige still stehen.
if (stand && stand.error) rfMeldung(stand);
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) {
rfJalousieAnzeigen(karte, stand);
}
if (Date.now() > Number(karte.dataset.jalousieBis)) {
aufhoeren();
return;
}
// Beim blossen Aufmachen eines Raums, in dem sich nichts geruehrt hat,
// bleibt es bei einer Abfrage je Jalousie.
if (!vorlaufNoetig && !gefahren && !stand.faehrt) {
aufhoeren();
return;
}
if (!vorlaufNoetig && stand.faehrt) {
karte.dataset.faehrt = "1";
rfBehangZeichnen(karte);
}
const fertig = steht && Date.now() >= ruheAb
&& (gefahren || !vorlaufNoetig || Date.now() - beginn >= RF_JALOUSIE_VORLAUF_MS);
if (fertig) {
aufhoeren();
return;
}
setTimeout(runde, RF_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 ? RF_JALOUSIE_TAKT_MS : 0);
}
/** Die gemeldete Stellung uebernehmen - nur im Stillstand, siehe oben. */
function rfJalousieAnzeigen(karte, stand) {
const d = karte.dataset;
// Wer gerade im Bild zieht, bestimmt.
if (d.zieht) return;
const setze = (feld, wert) => { if (wert !== null && typeof wert !== "undefined") d[feld] = String(wert); };
setze("pos", stand.position);
if (d.neigCmd) setze("neig", stand.neigung);
if (d.myPos !== undefined && karte.querySelector("[data-rf-my]")) {
setze("myPos", stand.myPosition);
if (d.neigCmd) setze("myNeig", stand.myNeigung);
}
rfBehangZeichnen(karte);
}
/* --- Heizung ------------------------------------------------------------------ */
const RF_RING_R = 70;
const RF_RING_UMFANG = 2 * Math.PI * RF_RING_R;
const RF_RING_BOGEN = RF_RING_UMFANG * 270 / 360;
const RF_HEIZ_HALTEN_MS = 8000; // so lange gilt die eigene Wahl vor dem MQTT-Stand
function rfHeizungStarten(teil) {
const min = Number(teil.dataset.min), max = Number(teil.dataset.max);
const zustand = { soll: null, eigen: 0, senden: null };
teil._rf = zustand;
teil.querySelector(".rf-ring-spur").setAttribute("stroke-dasharray", RF_RING_BOGEN + " " + RF_RING_UMFANG);
teil.querySelector(".rf-ring-griff").setAttribute("stroke-dasharray", RF_RING_BOGEN + " " + RF_RING_UMFANG);
const waehlen = function (wert, sofort) {
wert = Math.max(min, Math.min(max, Math.round(wert)));
zustand.soll = wert;
zustand.eigen = Date.now();
rfHeizungZeichnen(teil);
clearTimeout(zustand.senden);
// Mehrmals - oder + tippen ergibt einen Befehl, nicht fuenf.
zustand.senden = setTimeout(() => {
teil.classList.add("raum-warte");
rfRequest("temp", { room: raumfenster.raum, temp: wert }).then(antwort => {
teil.classList.remove("raum-warte");
rfMeldung(antwort);
});
}, sofort ? 0 : 700);
};
teil.querySelectorAll("[data-rf-schritt]").forEach(k => k.addEventListener("click", () => {
if (zustand.soll === null) return;
waehlen(zustand.soll + Number(k.dataset.rfSchritt), false);
}));
teil.querySelectorAll("[data-rf-soll]").forEach(k => k.addEventListener("click", () => waehlen(Number(k.dataset.rfSoll), true)));
// Den Soll-Punkt auf dem Ring ziehen oder auf den Ring tippen. Waehrend des
// Ziehens wird nur gezeichnet, geschickt wird beim Loslassen.
const svg = teil.querySelector(".rf-ring svg");
const ausZeiger = e => {
const r = svg.getBoundingClientRect();
const grad = Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2)) * 180 / Math.PI;
let a = (grad - 135 + 720) % 360;
if (a > 270) a = a > 315 ? 0 : 270;
return min + a / 270 * (max - min);
};
let zug = null;
svg.addEventListener("pointerdown", e => {
if (!e.target.closest("[data-rf-ringzug]")) return;
zug = e.pointerId;
svg.setPointerCapture(e.pointerId);
teil.classList.add("zieht");
zustand.soll = Math.round(ausZeiger(e));
zustand.eigen = Date.now();
rfHeizungZeichnen(teil);
e.preventDefault();
});
svg.addEventListener("pointermove", e => {
if (zug !== e.pointerId) return;
zustand.soll = Math.round(ausZeiger(e));
zustand.eigen = Date.now();
rfHeizungZeichnen(teil);
});
svg.addEventListener("pointerup", e => {
if (zug !== e.pointerId) return;
zug = null;
teil.classList.remove("zieht");
waehlen(zustand.soll, true);
});
svg.addEventListener("pointercancel", () => { zug = null; teil.classList.remove("zieht"); });
const punkt = teil.querySelector(".rf-ring-soll");
punkt.addEventListener("keydown", e => {
const schritt = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }[e.key];
if (!schritt || zustand.soll === null) return;
e.preventDefault();
waehlen(zustand.soll + schritt, false);
});
}
/** Ist, Soll und Hinweis in den Heizungsteil schreiben. */
function rfHeizungZeichnen(teil) {
const z = teil._rf;
const werte = (raumfenster && typeof mqttZweig === "function") ? mqttZweig(raumfenster.thermostat) : null;
const zahl = v => (v === null || v === "" || typeof v === "undefined" || isNaN(Number(v))) ? null : Number(v);
const ist = werte ? zahl(werte["Temp[degC]"]) : null;
const gemeldet = werte ? zahl(werte["Set Temp[degC]"]) : null;
// Die eigene Wahl gilt, bis das Thermostat sie bestaetigt hat.
if (gemeldet !== null && (z.soll === null || Date.now() - z.eigen > RF_HEIZ_HALTEN_MS)) z.soll = gemeldet;
const soll = z.soll;
const min = Number(teil.dataset.min), max = Number(teil.dataset.max);
const anteil = v => Math.max(0, Math.min(1, (v - min) / (max - min)));
const komma = (v, stellen) => v.toFixed(stellen).replace(".", ",");
const feld = name => teil.querySelector("[data-rf-heiz='" + name + "']");
teil.querySelector(".rf-ring-ist").setAttribute("stroke-dasharray",
(ist === null ? 0 : RF_RING_BOGEN * anteil(ist)) + " " + RF_RING_UMFANG);
const punkt = teil.querySelector(".rf-ring-soll");
punkt.style.transform = "rotate(" + (135 + 270 * anteil(soll === null ? min : soll)) + "deg)";
punkt.style.visibility = soll === null ? "hidden" : "";
if (soll !== null) punkt.setAttribute("aria-valuenow", soll);
feld("ist").innerHTML = ist === null ? "–" : komma(ist, 1) + "<small>" + RF_S + "°C</small>";
feld("soll").textContent = "Soll " + (soll === null ? "–" : komma(soll, 0) + RF_S + "°C");
const chip = soll === null ? null : teil.querySelector("[data-rf-soll='" + soll + "']");
feld("wert").innerHTML = soll === null ? "–"
: soll + RF_S + "°C" + "<small>" + (chip ? chip.firstChild.textContent.trim() : "&nbsp;") + "</small>";
teil.querySelectorAll("[data-rf-soll]").forEach(k => k.setAttribute("aria-pressed", String(k === chip)));
const heizt = werte && String(werte["Heating"]) === "true";
const hinweis = feld("hinweis");
let text = "";
if (ist !== null && soll !== null) {
if (heizt) text = ist < soll ? "Heizt – noch " + komma(soll - ist, 1) + RF_S + "°C bis zum Soll." : "Heizt gerade.";
else if (ist > soll + 0.2) text = "Heizt nicht – der Raum ist " + komma(ist - soll, 1) + RF_S + "°C wärmer als das Soll.";
else text = "Heizt nicht – das Soll ist erreicht.";
}
hinweis.hidden = !text;
hinweis.classList.toggle("an", !!heizt);
hinweis.querySelector("span").textContent = text;
const feuchte = werte ? zahl(werte["rHum[%]"]) : null;
feld("feuchte").textContent = feuchte === null ? "–" : Math.round(feuchte) + RF_S + "% Luftfeuchte";
feld("puffer").hidden = !(werte && werte["mode"] == "Overheating");
const status = raumfenster && raumfenster.wurzel.querySelector("[data-rf-status='heizung']");
if (status) status.textContent = ist === null ? "–" : komma(ist, 1) + RF_S + "°C" + (heizt ? " · heizt" : "");
}
/** Aufgerufen bei jeder MQTT-Nachricht (homeSVG.updateValuesMQTT). */
function raumfensterMqtt() {
if (!raumfenster || !document.body.contains(raumfenster.wurzel)) return;
const teil = raumfenster.wurzel.querySelector("[data-rf-heiz-teil]");
if (teil && !teil.classList.contains("zieht")) rfHeizungZeichnen(teil);
}
/* --- Licht und Schalter --------------------------------------------------------- */
/** An/Aus schicken - kennt das Geraet nur "umschalten", tut es auch das. */
function rfSchalten(element, karte) {
const an = karte.classList.contains("rf-schalter") ? karte.getAttribute("aria-pressed") !== "true" : karte.dataset.an !== "1";
const cmd = (an ? karte.dataset.ein : karte.dataset.aus) || karte.dataset.um;
if (!cmd) return;
if (karte.classList.contains("rf-schalter")) {
karte.setAttribute("aria-pressed", String(an));
karte.querySelector(".rf-st").textContent = an ? "an" : "aus";
} else {
karte.dataset.an = an ? "1" : "";
rfLampeZeichnen(karte);
}
rfStatus();
rfKommando(Number(cmd), {}, element);
}
function rfLampeZeichnen(karte) {
const an = karte.dataset.an === "1";
karte.classList.toggle("aus", !an);
karte.querySelector("[data-rf-birne]").setAttribute("aria-pressed", String(an));
const hell = karte.querySelector(".rf-hell");
let text = an ? "an" : "aus";
if (hell) {
const min = Number(hell.min), max = Number(hell.max);
const prozent = Math.round((Number(hell.value) - min) / (max - min) * 100);
hell.style.setProperty("--wert", prozent + "%");
if (an) text += " · " + prozent + RF_S + "%";
}
karte.querySelector("[data-rf-lampe-stand]").textContent = text;
}
function rfLampeStarten(karte) {
rfLampeZeichnen(karte);
karte.querySelector("[data-rf-birne]").addEventListener("click", e => rfSchalten(e.currentTarget, karte));
const hell = karte.querySelector(".rf-hell");
if (hell) {
hell.addEventListener("input", () => rfLampeZeichnen(karte));
hell.addEventListener("change", () => {
const params = {};
params[hell.dataset.param] = hell.value;
rfKommando(Number(hell.dataset.cmd), params, hell);
});
}
// Farbe: das Geraet will drei Zahlen 0-255, zerlegt wird deshalb hier.
const farben = karte.querySelector(".rf-farben");
if (farben) {
const setzen = hex => {
karte.style.setProperty("--farbe", hex);
farben.querySelectorAll("[data-farbe]").forEach(k => k.setAttribute("aria-pressed", String(k.dataset.farbe === hex)));
const params = {};
params[farben.dataset.red] = parseInt(hex.substr(1, 2), 16);
params[farben.dataset.green] = parseInt(hex.substr(3, 2), 16);
params[farben.dataset.blue] = parseInt(hex.substr(5, 2), 16);
rfKommando(Number(farben.dataset.cmd), params, farben);
};
farben.querySelectorAll("[data-farbe]").forEach(k => k.addEventListener("click", () => setzen(k.dataset.farbe)));
const eigen = farben.querySelector("input[type=color]");
eigen.addEventListener("input", () => karte.style.setProperty("--farbe", eigen.value));
eigen.addEventListener("change", () => setzen(eigen.value));
}
// Preset, Effekt und andere Listen: Chips, lange Listen im aufklappbaren
// Picker mit Suche.
karte.querySelectorAll(".rf-liste").forEach(liste => {
const knopf = liste.querySelector(".rf-picker-knopf");
const picker = liste.querySelector(".rf-picker");
const suche = liste.querySelector(".rf-suche");
const auf = offen => {
if (!picker) return;
picker.hidden = !offen;
knopf.setAttribute("aria-expanded", String(offen));
if (offen) {
suche.value = "";
liste.querySelectorAll("[data-wert]").forEach(k => { k.hidden = false; });
const gewaehlt = liste.querySelector("[data-wert][aria-pressed='true']");
if (gewaehlt) gewaehlt.scrollIntoView({ block: "nearest" });
suche.focus({ preventScroll: true });
}
};
if (knopf) knopf.addEventListener("click", () => auf(picker.hidden));
if (suche) {
suche.addEventListener("input", () => {
const text = suche.value.trim().toLowerCase();
liste.querySelectorAll("[data-wert]").forEach(k => { k.hidden = text !== "" && !k.textContent.toLowerCase().includes(text); });
});
suche.addEventListener("keydown", e => {
if (e.key === "Escape") { e.stopPropagation(); auf(false); knopf.focus(); }
if (e.key === "Enter") {
const erster = [...liste.querySelectorAll("[data-wert]")].find(k => !k.hidden);
if (erster) erster.click();
}
});
}
liste.querySelectorAll("[data-wert]").forEach(k => k.addEventListener("click", () => {
rfListeSetzen(liste, k.dataset.wert);
auf(false);
const params = {};
params[liste.dataset.param] = k.dataset.wert;
rfKommando(Number(liste.dataset.cmd), params, liste);
}));
});
if (karte.dataset.wled) rfWledLesen(karte);
}
/** Einen Eintrag einer Liste als gewaehlt zeigen; null = keiner. */
function rfListeSetzen(liste, wert) {
let name = null;
liste.querySelectorAll("[data-wert]").forEach(k => {
const ja = wert !== null && k.dataset.wert === String(wert);
k.setAttribute("aria-pressed", String(ja));
if (ja) name = k.textContent;
});
const anzeige = liste.querySelector("[data-rf-picker-wert]");
if (anzeige) anzeige.textContent = name === null ? "–" : name;
}
/**
* Den wirklichen Zustand einer WLED-Lampe holen. In der Datenbank stehen nur
* an/aus, Helligkeit und Farbe vom letzten Poll; welcher Effekt und welches
* Preset laufen, weiss nur die Lampe selbst.
*/
function rfWledLesen(karte) {
rfRequest("wled", { actor: Number(karte.dataset.actor) }).then(stand => {
if (!stand || stand.error || !document.body.contains(karte)) return;
karte.dataset.an = stand.an ? "1" : "";
const hell = karte.querySelector(".rf-hell");
if (hell && stand.bri !== null && document.activeElement !== hell) hell.value = stand.bri;
if (stand.farbe && (stand.farbe[0] || stand.farbe[1] || stand.farbe[2])) {
const hex = "#" + stand.farbe.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
karte.style.setProperty("--farbe", hex);
const eigen = karte.querySelector(".rf-farbe-eigen input");
if (eigen) eigen.value = hex;
karte.querySelectorAll("[data-farbe]").forEach(k => k.setAttribute("aria-pressed", String(k.dataset.farbe === hex)));
}
karte.querySelectorAll(".rf-liste").forEach(liste => {
const wert = stand[liste.dataset.feld];
// WLED meldet ps = -1, wenn kein Preset aktiv ist.
if (typeof wert !== "undefined") rfListeSetzen(liste, wert === null || wert < 0 ? null : wert);
});
rfLampeZeichnen(karte);
rfStatus();
});
}