Im Raum-Modal wirkt jedes Bedienelement sofort - der Rollladen faehrt, der Schalter schaltet, die Lampe leuchtet. Nur die Solltemperatur wartete auf "Save changes". Sie schickt jetzt beim Loslassen des Reglers, wie die Rollladenregler daneben, und der Knopf ist im Raum-Modal weg. Einer, der nur fuer einen der Reiter gilt, waere in allen anderen eine Falle. Das Modal bleibt nach dem Schicken offen; frueher schloss es sich, weil der Knopf das Ende der Bedienung war. Wer dreimal nachjustiert, will nicht dreimal neu aufmachen. Die Heizungskarte traegt dafuer dieselbe Sperre wie die Geraetekarten - waehrend das Kommando unterwegs ist, ist sie gesperrt. Dabei einen Fehler mitgenommen: der Speichern-Knopf gehoert allen Modals gemeinsam, und wer ihn versteckt, versteckt ihn fuer alle. Ein Raum ohne Thermostat liess ihn schon bisher verschwinden - danach hatten der AutoAction-Editor und die Raumzuordnung bis zum Neuladen keinen Speichern-Knopf mehr. Beide holen ihn jetzt wieder hervor. Nachgemessen: im Raum-Modal versteckt, im Editor und in der Raumzuordnung sichtbar. Die Fahrknoepfe sind von 34 auf 50 Pixel gewachsen - sie sind das, was man im Vorbeigehen antippt, die Regler daneben braucht man seltener. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
17 KiB
JavaScript
440 lines
17 KiB
JavaScript
|
|
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 () {
|
|
client.subscribe("Raumtemp/#");
|
|
});
|
|
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 () {
|
|
|
|
},
|
|
updateValuesMQTT: function (mqttData) {
|
|
// homeRooms liefert restricted/rooms.php (in home.php eingebettet).
|
|
// Jeder Raum bekommt die gleiche Behandlung - Raeume kommen dort dazu,
|
|
// nicht hier.
|
|
homeRooms.forEach(function (room) {
|
|
const values = mqttData["Raumtemp"]?.[room.floor]?.[room.mqtt];
|
|
if (!values) return; // Topic noch nicht empfangen
|
|
const el = (part) => document.getElementById(room.floor + "_" + part);
|
|
|
|
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");
|
|
if (typeof (values["Set Temp[degC]"]) != "undefined")
|
|
el("pres_" + room.id).innerHTML = Math.floor(values["Set Temp[degC]"]) + "\u2009\u00b0C";
|
|
if (typeof (values["Temp[degC]"]) != "undefined")
|
|
el("tmp_" + room.id).innerHTML = values["Temp[degC]"] + "\u2009\u00b0C";
|
|
if (typeof (values["rHum[%]"]) != "undefined")
|
|
el("hum_" + room.id).innerHTML = values["rHum[%]"] + "\u2009%rF";
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function switchTab(newtab){
|
|
newContent = document.getElementById(newtab);
|
|
newTabBtn = document.getElementById(newtab+"-tab");
|
|
removeClass(document.getElementById("actions-OG-tab"),"active");
|
|
removeClass(document.getElementById("actions-EG-tab"),"active");
|
|
removeClass(document.getElementById("actions-UG-tab"),"active");
|
|
|
|
removeClass(document.getElementById("actions-OG"),"active show");
|
|
removeClass(document.getElementById("actions-EG"),"active show");
|
|
removeClass(document.getElementById("actions-UG"),"active show");
|
|
addClass(newTabBtn,"active");
|
|
addClass(newContent,"active show");
|
|
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
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);
|
|
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;
|
|
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");
|
|
raumRequest("command", { command_id: cmdId, params: params })
|
|
.then(antwort => {
|
|
karte.classList.remove("raum-warte");
|
|
raumMeldung(antwort);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|