/* * 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 = "my von " + myText(d.myPos, d.myNeig) + " auf " + myText(pos, neig) + " ändern?" + "" + ""; } else { my.className = "rf-my-zeile"; my.innerHTML = "my-Position " + myText(d.myPos, d.myNeig) + "" + (aufMy || faehrt || pos === "" || d.mySpeichert === "1" ? "" : "") + (d.mySpeichert === "1" ? "wird gespeichert…" : ""); } } 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) + "" + RF_S + "°C"; 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" + "" + (chip ? chip.firstChild.textContent.trim() : " ") + ""; 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(); }); }