Etagen, Raeume, Kachelpositionen und Kachelwerte standen als Tabelle in restricted/rooms.php, die Werte zusaetzlich ueberschreibbar in solarLog.kachelWerte. Fuer ein anderes Haus haette man den Quelltext umschreiben muessen. Jetzt liegen sie in homeMesh.floors und homeMesh.rooms (homeMesh_grundriss.sql). rooms.php liest sie und gibt sie in der bisherigen Form weiter; Startseite, Menue, Editor und Einstellungen sind unveraendert - vorher und nachher als HTML verglichen, gleich bis auf Livewerte und die zwei neuen Felder nr und thermostat in homeRooms. - Vorgabe einer Kachel ist jetzt allein das Thermostat (rooms.thermostat): Soll, Ist, Feuchte - sonst nichts. Was bisher im Code als Vorgabe stand, gilt damit als selbst gesetzt. - Grundrissbild je Etage (floors.bild) statt assets/img/<code>.png. - Standard-Etage (floors.standard) statt "OG" an vier Stellen. - meshDb() in eigener Datei, sonst Ring zwischen rooms.php und automations.php. - solarLog_kacheln.sql entfaellt; die Tabelle kachelWerte liest niemand mehr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
739 lines
31 KiB
JavaScript
739 lines
31 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 () {
|
||
// 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.
|
||
//
|
||
// "leistung_kw" ist dasselbe fuer Quellen, die schon Kilowatt melden -
|
||
// die Wallboxen im SolarManager. Umgerechnet wird hier in Watt, damit
|
||
// es eine Regel fuer beide bleibt.
|
||
if (wert.format === "leistung" || wert.format === "leistung_kw") {
|
||
const zahl = Number(text) * (wert.format === "leistung_kw" ? 1000 : 1);
|
||
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");
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
// Die Etage, mit der die Seite oeffnet - standardEtage() in rooms.php, von
|
||
// home.php in die Seite geschrieben. Stand frueher fest auf "OG".
|
||
var currentFloor = (typeof homeStandardEtage !== "undefined") ? homeStandardEtage : "";
|
||
|
||
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;
|
||
}
|