Files
Smart-Dashboard/js/solar/homeMQTT.js
T
adminandClaude Opus 5 060336c891 Kachelwerte koennen das Vorzeichen umdrehen
Ein Zaehler zaehlt in der Richtung, in der er eingebaut ist - einer der
beiden Shelly-Zaehler meldet deshalb mit umgekehrtem Vorzeichen, und auf der
Kachel stand der Verbrauch als Minusleistung.

Neu gibt es dafuer den Schalter "negativ" an jedem Kachelwert. Er sitzt in
der Anzeige und nicht am Zaehler: Automatiken und Statistik lesen dieselbe
Nachricht und rechnen mit dem Vorzeichen, das dort steht - wer es an der
Quelle drehte, verschoebe die Rechnung an einer Stelle, an der niemand
danach sucht.

Gedreht wird vor dem Formatieren, also auch vor der Skalierung zwischen W
und kW und vor den Nachkommastellen. Aus -0 wird wieder 0. Was keine Zahl
ist, bleibt unangetastet und die Kachel zeigt weiter ihren Strich.

In der eingeklappten Kurzfassung steht das Minus vor dem Namen, damit man
ohne Aufklappen sieht, welcher Wert gedreht ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:41:18 +02:00

733 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.
const homeMQTT = {
getMQTT: function () {
const id = Math.random().toString(36).substring(7);
const topic = "#";
const connection = "wss://mqtt.nas.el-wa.org:443"
mqttsolarTreeDone = false;
// const connection = "ws://username:password@37.97.203.138:8083" // Works
// const connection = "wss://public:public@public.cloud.shiftr.io" // Works
const client = mqtt.connect(connection, {
rejectUnauthorized: false,
});
client.on("message", messageReceived);
client.on("connect", function () {
// Welche Zweige gebraucht werden, sagt die Raumtabelle: seit eine
// Kachel jeden Messwert zeigen kann, reicht Raumtemp allein nicht
// mehr. homeTopics kommt aus restricted/rooms.php.
homeTopics.forEach(function (topic) { client.subscribe(topic); });
});
client.on("error", function (error) {
//alert("MQTT Error: " + error);
});
client.on('end', function () {
setTimeout(getMQTT, 5000);
alert("MQTT Disconnected, try to reconnect in 5 secs.");
})
function getNestedProp(obj, path) {
return path.split('/').reduce((acc, key) => acc && acc[key], obj);
}
function setNestedProp(obj, path, value) {
var schema = obj; // a moving reference to internal objects within obj
var pList = path.split('/');
var len = pList.length;
for (var i = 0; i < len - 1; i++) {
var elem = pList[i];
if (!schema[elem]) schema[elem] = {}
schema = schema[elem];
}
schema[pList[len - 1]] = value;
}
function messageReceived(topic, message) {
setNestedProp(mqttData, topic, message);
setTimeout(function () { homeSVG.updateValuesMQTT(mqttData) }, 200); //give the object tree some time to build up and receive all values
}
}
}
const homeSVG = {
updateCnt: 99,
fillElementArray: function () {
},
/**
* Eine Nachricht aus dem Baum holen. mqttData ist nach den Teilen des
* Topics verschachtelt, siehe setNestedProp() weiter oben.
*/
ausTopic: function (mqttData, topic) {
return topic.split("/").reduce(function (zweig, teil) {
return zweig && zweig[teil];
}, mqttData);
},
/**
* Die Leistung benannter Wechselrichter, aufsummiert.
*
* Der SolarManager legt sie unter solarManager/inverters0, /inverters1
* und so fort ab - die Nummer ist eine Position in einer Liste und sagt
* nichts darueber, welches Dach gemeint ist. Deshalb wird ueber
* invertersN/name gesucht: aendert sich die Reihenfolge, findet die
* Kachel ihren Wechselrichter trotzdem, statt stillschweigend das
* falsche Dach anzuzeigen.
*/
wechselrichterWert: function (mqttData, wert) {
const zweig = mqttData["solarManager"];
if (!zweig) return null;
const gesucht = wert.wechselrichter;
const feld = wert.feld || "p_AC";
let summe = 0;
const gefunden = [];
const vorhanden = [];
for (const schluessel in zweig) {
if (!schluessel.startsWith("inverters")) continue;
const wr = zweig[schluessel];
if (!wr || typeof wr.name === "undefined") continue;
const name = String(wr.name);
vorhanden.push(name);
if (gesucht.indexOf(name) === -1) continue;
if (typeof wr[feld] === "undefined") continue;
summe += Number(wr[feld]);
gefunden.push(name);
}
if (!gefunden.length) {
// Ein Name, den es nicht gibt, ergaebe sonst nur einen Strich auf
// der Kachel und keinen Hinweis, woran es liegt.
if (vorhanden.length && !homeSVG.gemeldet[gesucht.join("|")]) {
homeSVG.gemeldet[gesucht.join("|")] = true;
console.warn("Kachel: kein Wechselrichter namens " + gesucht.join(", ")
+ ". Vorhanden sind: " + vorhanden.join(", "));
}
return null;
}
return summe;
},
/** Merkt sich gemeldete Namen, damit die Konsole nicht zulaeuft. */
gemeldet: {},
/**
* Einen Kachelwert lesen und hinschreiben.
*
* Woher der Wert kommt, sagt entweder "topic" oder "wechselrichter".
* "pfad" ist gesetzt, wenn das Geraet mehrere Werte in einer einzigen
* JSON-Nachricht schickt - die beiden Shelly-Zaehler tun das.
*
* Wie er dasteht, sagt entweder "format" oder das Paar aus "einheit" und
* "stellen": "stellen" schneidet Nachkommastellen ab, ohne Angabe bleibt
* der Text so, wie das Geraet ihn meldet.
*
* "negativ" dreht das Vorzeichen. Ein Zaehler zaehlt in der Richtung, in
* der er eingebaut ist - einer der beiden meldet den Bezug deshalb mit
* Minus. Das gehoert an die Anzeige und nicht an den Zaehler: die
* Automatiken und die Statistik lesen dieselbe Nachricht und rechnen mit
* dem Vorzeichen, das dort steht.
*/
kachelWert: function (mqttData, wert) {
const roh = wert.wechselrichter
? homeSVG.wechselrichterWert(mqttData, wert)
: homeSVG.ausTopic(mqttData, wert.topic);
if (roh === null || typeof roh === "undefined") return null;
let text = String(roh);
if (wert.pfad) {
try {
const daten = JSON.parse(text);
if (!(wert.pfad in daten)) return null;
text = String(daten[wert.pfad]);
} catch (fehler) {
return null; // noch keine vollstaendige Nachricht
}
}
if (wert.negativ) {
const zahl = Number(text);
if (text === "" || isNaN(zahl)) return null;
// "+ 0" macht aus -0 wieder 0: "-0 W" hat noch niemanden erfreut.
text = String(-zahl + 0);
}
// Leistungen skalieren mit: unter tausend Watt in W, darueber in kW.
// powerToString() aus common.js macht genau das und wird auf der
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
// gerundet dasteht.
if (wert.format === "leistung") {
const zahl = Number(text);
if (text === "" || isNaN(zahl)) return null;
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
return powerToString(zahl).replace(" ", "");
}
if (typeof wert.stellen === "number" && text !== "" && !isNaN(Number(text))) {
text = Number(text).toFixed(wert.stellen);
}
// Schmales Leerzeichen zwischen Zahl und Einheit, wie es sich gehoert.
return wert.einheit ? text + "\u2009" + wert.einheit : text;
},
updateValuesMQTT: function (mqttData) {
// homeRooms liefert restricted/rooms.php (in home.php eingebettet),
// mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum
// bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort
// dazu, nicht hier.
homeRooms.forEach(function (room) {
const el = (part) => document.getElementById(room.floor + "_" + part);
room.werte.forEach(function (wert, i) {
const text = homeSVG.kachelWert(mqttData, wert);
const feld = el("w" + i + "_" + room.id);
if (text !== null && feld) feld.innerHTML = text;
});
// Heizsymbol und Pufferbetrieb gibt es nur, wo ein Thermostat
// haengt - anderswo sind die Elemente gar nicht gezeichnet.
if (!room.heizung) return;
const values = mqttData["Raumtemp"]?.[room.floor]?.[room.mqtt];
if (!values) return;
if (typeof (values["Heating"]) != "undefined")
el(room.id + "_heater").setAttribute("display", values["Heating"] == "false" ? "none" : "");
if (typeof (values["mode"]) != "undefined")
el(room.id + "_buffer").setAttribute("display", values["mode"] == "Overheating" ? "" : "none");
});
}
}
var currentFloor = "OG";
function addClass(el, classNameToAdd){
el.className += ' ' + classNameToAdd;
}
function removeClass(el, classNameToRemove){
var elClass = ' ' + el.className + ' ';
while(elClass.indexOf(' ' + classNameToRemove + ' ') !== -1){
elClass = elClass.replace(' ' + classNameToRemove + ' ', '');
}
el.className = elClass;
}
// Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die
// home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
// sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht.
function switchFloor(floor){
if(currentFloor == floor)
return;
const targetIn = document.getElementById(floor+'_Info');
const targetOut = document.getElementById(currentFloor+'_Info');
var blendIn = new KeyframeEffect(
targetIn, [{opacity: '0'},{opacity: '100'}],
{
duration: 500,
easing: "ease-in-out",
fill: "forwards",
iterations: 1,
}
);
var blendOut = new KeyframeEffect(
targetOut, [{opacity: '100'},{opacity: '0'}],
{
duration: 500,
easing: "ease-in-out",
fill: "forwards",
iterations: 1,
}
);
var inAnim = new Animation(blendIn,document.timeline);
var outAnim = new Animation(blendOut,document.timeline);
targetIn.setAttribute("display","");
outAnim.onfinish= (event) => {
targetOut.setAttribute("display","none");
};
inAnim.play();
outAnim.play();
// Welche Etage gerade zu sehen ist, stand den Schaltern bisher nicht an -
// alle vier sahen immer gleich aus. classList und nicht die Helfer
// addClass/removeClass weiter oben: deren "el.className +=" wirkt auf
// SVG-Elementen nicht, dort ist className schreibgeschuetzt.
document.querySelectorAll(".floorBtn").forEach(function (schalter) {
schalter.classList.toggle("active", schalter.id === floor);
});
currentFloor = floor;
}
var modalEV = new bootstrap.Modal(document.getElementById('modalEV'), {
keyboard: false
});
var offcanvas = new bootstrap.Offcanvas(document.getElementById('offcanvas'), {
keyboard: false
});
var mqttData = {};
function openHeaterSettings(heater){
openRoomView(heater);
}
document.addEventListener('readystatechange', function () {
if (event.target.readyState === "complete") {
homeMQTT.getMQTT();
mountMeteogram();
}
});
/**
* Alles, was in einem Raum haengt, auf einer Seite: Heizung, Rollladen,
* Licht. Der Grundriss zeigt Raeume, also sollte ein Klick darauf den Raum
* oeffnen - vorher kam nur der Temperaturregler, die Rollladen im selben
* Zimmer waren von hier gar nicht erreichbar.
*
* Welche Geraete dazugehoeren, sagt die Raumzuordnung (actors.room).
*/
function openRoomView(raum) {
const teile = raum.toString().split("_");
document.getElementById("modal-title").innerHTML =
teile[0] + " · " + teile.slice(1).join(" ")
.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö");
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht der Handler eines vorher
// geoeffneten Modals mitfeuert; im Raum-Modal bleibt er weg. Hier wird
// nichts gesammelt und dann abgeschickt - jeder Rollladen, jeder
// Schalter und jede Lampe wirkt sofort, und die Solltemperatur tut es
// jetzt auch. Ein Knopf, der nur fuer einen der Reiter gilt, waere in
// allen anderen eine Falle.
const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true);
alt.replaceWith(btn);
btn.hidden = true;
const koerper = document.getElementById("modal-body");
koerper.innerHTML = loadingHTML("Wird geladen...");
modalEV.show();
fetch("./ajax/room.php?room=" + encodeURIComponent(raum), {
method: "GET",
headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" }
})
.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 = mqttData["Raumtemp"]?.[teile[0]]?.[teile[1]]?.["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(teile[0], teile[1]);
})
.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(etage, raum) {
const werte = mqttData["Raumtemp"]?.[etage]?.[raum];
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;
}