Der senkrechte Positionsregler liess sich am Handy kaum anfassen. Gebaut
war er mit writing-mode: vertical-rl - dem neueren Weg, aber einem, bei
dem der Browser das Ziehen als Wischen liest und die Seite blaettert,
statt den Griff mitzunehmen.
Jetzt liegt ein gewoehnlicher waagerechter Regler um 90 Grad gedreht in
seinem Kasten. Er bewegt sich in seinen eigenen Koordinaten waagerecht,
wird von jedem Browser unterstuetzt, traegt dieselbe Gestaltung wie die
liegenden Regler, und touch-action: none haelt die Seite still, solange
der Finger auf ihm liegt. Die Trefferflaeche ist 1,6 rem breit statt der
sichtbaren 0,375 rem Bahn. Nachgemessen: oben angeklickt 8 %, unten 93 %.
Die Neigung steht jetzt ebenfalls senkrecht, daneben das Lamellenbild -
Regler und Bild bilden ein Paar, das Bild sagt, was der Regler bedeutet.
Beide Spalten sind mit Namen und den Worten an ihren Enden beschriftet
("oben"/"unten", "auf"/"zu"); die Zahl darunter ist das, was sich beim
Ziehen aendert.
Die Regler im Raum-Modal haben ein eigenes Aussehen bekommen: Bootstraps
weisse Bahn mit blauem Griff stach auf den dunklen Karten heraus, und in
einem Raum mit drei Jalousien waren das sechs helle Balken. Bahn und Griff
tragen jetzt dasselbe Blaugruen wie der Temperaturregler, der ungefuellte
Teil ein Grau, das in beiden Themes traegt. Die Regler in den Modals fuer
Autoladung und Heizstab bleiben unberuehrt.
Die Regel fuer die Bahn steht zweimal da, einmal fuer -webkit- und einmal
fuer -moz-: als Liste notiert wirft der Browser beim unbekannten Selektor
die ganze Regel weg, und dann bliebe die Bahn ungestaltet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
400 lines
15 KiB
JavaScript
400 lines
15 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 auch der Handler eines vorher
|
|
// geoeffneten Modals mitfeuert.
|
|
const alt = document.getElementById("modalSaveBtn");
|
|
const btn = alt.cloneNode(true);
|
|
alt.replaceWith(btn);
|
|
btn.addEventListener("click", () => sendeSolltemperatur(raum));
|
|
|
|
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;
|
|
slider.dispatchEvent(new Event("input"));
|
|
}
|
|
// Ohne Thermostat gibt es nichts zu speichern - dann waere der Knopf
|
|
// eine Falle.
|
|
btn.hidden = !slider;
|
|
|
|
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-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"); die Anzeige braucht deshalb
|
|
* nur noch die Zahl, und die ist beim Ziehen das Interessante.
|
|
*/
|
|
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-neigung-block"), wert, min, max);
|
|
}
|
|
|
|
/**
|
|
* Die Lamellen zur Neigung zeichnen.
|
|
*
|
|
* Gezeichnet wird die Hoehe, die eine gekippte Lamelle von vorn zeigt:
|
|
* w * sin(Winkel). Bei 0 % steht sie waagerecht und man sieht nur ihre
|
|
* Kante, bei 100 % steht sie quer und deckt den Abstand zur naechsten
|
|
* vollstaendig ab - genau das, was das Licht abhaelt. Der Abstand von
|
|
* Mitte zu Mitte ist 10, die Lamelle 11 tief: ab etwa 65 % schliesst
|
|
* das Bild, so wie es die Jalousie auch tut.
|
|
*/
|
|
function lamellenZeichnen(block, wert, min, max) {
|
|
if (!block) return;
|
|
const anteil = (wert - min) / (max - min);
|
|
const hoehe = Math.max(1.6, 11 * Math.sin(anteil * Math.PI / 2));
|
|
block.querySelectorAll(".raum-lamelle").forEach(lamelle => {
|
|
const mitte = Number(lamelle.dataset.mitte);
|
|
lamelle.setAttribute("height", hoehe);
|
|
lamelle.setAttribute("y", mitte - hoehe / 2);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
}
|
|
|
|
function sendeSolltemperatur(raum) {
|
|
const slider = document.getElementById("modal-slider");
|
|
if (!slider) return;
|
|
raumRequest("temp", { room: raum, temp: Number(slider.value) })
|
|
.then(antwort => {
|
|
raumMeldung(antwort);
|
|
if (!antwort.error) setTimeout(() => modalEV.hide(), 700);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|