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:
@@ -73,31 +73,6 @@ async function getData(chart, url, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schieberegler im Modal mit seiner Beschriftung verbinden.
|
||||
* label(wert) liefert den anzuzeigenden Text.
|
||||
*/
|
||||
function bindModalSlider(slider, span, label) {
|
||||
slider.oninput = function () {
|
||||
const min = Number(slider.min);
|
||||
const max = Number(slider.max);
|
||||
const percent = (Number(slider.value) - min) * 100 / (max - min);
|
||||
slider.style.setProperty("--background-size", `${percent}%`);
|
||||
|
||||
// Beschriftung mitlaufen lassen, an den Raendern etwas gebremst
|
||||
let margin = percent;
|
||||
if (margin < 10) {
|
||||
margin = margin - 0.4 * margin;
|
||||
} else if (margin < 85) {
|
||||
margin = margin - 4;
|
||||
} else {
|
||||
margin = margin - 4 - 0.5 * (margin - 85);
|
||||
}
|
||||
span.setAttribute('style', 'margin-left:' + margin + '%;');
|
||||
span.innerHTML = label(slider.value);
|
||||
};
|
||||
}
|
||||
|
||||
/** Meteoblue-Meteogramm einhängen, sofern die Seite einen Platz dafür hat. */
|
||||
// Mindestbreite, in der das Meteogramm von meteoblue geladen wird. Der
|
||||
// Anbieter legt sein Bild einmal beim Laden fest und zeichnet es bei einer
|
||||
|
||||
+10
-409
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
/*
|
||||
* 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() : " ") + "</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));
|
||||
}
|
||||
|
||||
// Effekt und Preset: Chips oder - bei langen Listen - eine Auswahl.
|
||||
karte.querySelectorAll(".rf-effekte .rf-chips[data-cmd]").forEach(gruppe => {
|
||||
gruppe.querySelectorAll("[data-wert]").forEach(k => k.addEventListener("click", () => {
|
||||
gruppe.querySelectorAll("[data-wert]").forEach(x => x.setAttribute("aria-pressed", String(x === k)));
|
||||
const params = {};
|
||||
params[gruppe.dataset.param] = k.dataset.wert;
|
||||
rfKommando(Number(gruppe.dataset.cmd), params, gruppe);
|
||||
}));
|
||||
});
|
||||
karte.querySelectorAll(".rf-effekte select[data-cmd]").forEach(auswahl => auswahl.addEventListener("change", () => {
|
||||
const params = {};
|
||||
params[auswahl.dataset.param] = auswahl.value;
|
||||
rfKommando(Number(auswahl.dataset.cmd), params, auswahl);
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user