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. */ 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 } } // 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 switchTab(newtab){ const newContent = document.getElementById(newtab); const newTabBtn = document.getElementById(newtab+"-tab"); document.querySelectorAll("#actions-tab .nav-link").forEach(function(btn){ removeClass(btn,"active"); }); document.querySelectorAll("#actions-tabContent .tab-pane").forEach(function(pane){ removeClass(pane,"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(); // 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); 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; }