let solarMqttClient; let solarSvgUpdatePending = false; const solarMQTT = { 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, }); solarMqttClient = client; client.on("message", messageReceived); client.on("connect", function () { client.subscribe("solarManager/#"); client.subscribe("wattpilot/properties/lmo/state"); client.subscribe("wattpilot/properties/ftt/state"); client.subscribe("wattpilot/properties/fte/state"); client.subscribe("wattpilot/properties/amp/state"); client.subscribe("wattpilot/properties/car/state"); client.subscribe("go-eCharger/270003/amp"); client.subscribe("go-eCharger/270003/ate"); client.subscribe("go-eCharger/270003/lmo"); client.subscribe("go-eCharger/270003/att"); client.subscribe("go-eCharger/270003/car"); client.subscribe("weatherStation/#"); client.subscribe("Gartenwasser/vorn/status"); client.subscribe("Gartenwasser/vorn/Timers"); client.subscribe("Gartenwasser/vorn/LastWatering"); client.subscribe("Gartenwasser/vorn/WateringToday"); client.subscribe("Gartenwasser/vorn/LastWateringDay"); client.subscribe("Gartenwasser/hinten/status"); client.subscribe("Gartenwasser/hinten/Timers"); client.subscribe("Gartenwasser/hinten/LastWatering"); client.subscribe("Gartenwasser/hinten/WateringToday"); client.subscribe("Gartenwasser/hinten/LastWateringDay"); }); 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) { mqttData[topic] = message; if (topic === "Gartenwasser/vorn/Timers" || topic === "Gartenwasser/hinten/Timers") { wateringTimersLastSeen[topic] = Date.now(); } if (topic.startsWith("Gartenwasser/") && (topic.endsWith("/status") || topic.endsWith("/Timers") || topic.endsWith("/LastWatering") || topic.endsWith("/WateringToday") || topic.endsWith("/LastWateringDay"))) { updateWateringStatus(); } if (topic == "solarManager/P_Load") { if (!solarSvgUpdatePending) { solarSvgUpdatePending = true; setTimeout(function () { solarSvgUpdatePending = false; solarSVG.updateValuesMQTT(mqttData); }, 1000); } }else if(topic == "weatherStation/windDeg"){ setTimeout(function () { updateWeatherCards(name => mqttData["weatherStation/" + name]) }, 200); //give the object tree some time to build up and receive all values } } }, publish: function (topic, payload, callback) { if (!solarMqttClient || !solarMqttClient.connected) { throw new Error("MQTT ist noch nicht verbunden."); } solarMqttClient.publish(topic, typeof payload === "string" ? payload : JSON.stringify(payload), callback); } } // Die Knoten im eingebetteten Realtime-SVG bleiben ueber die Lebensdauer der // Seite bestehen. updateValuesMQTT fasst 104 davon an - bis zu einmal pro // Sekunde -, deshalb werden sie einmal nachgeschlagen und dann behalten. const svgElements = {}; function svgElement(id) { let node = svgElements[id]; if (node === undefined) { node = svgElements[id] = document.getElementById(id); } return node; } // Nur schreiben, wenn sich der Wert wirklich geaendert hat. Jeder Schreibzugriff // macht Style und Paint des Elements ungueltig - bei einem gefilterten Element // wird dabei sein Filter neu gerastert. Gemessen waren rund 71 % der Zugriffe // pro Update reine Wiederholungen desselben Werts; die entfallen damit. const letzterWert = new Map(); function schreibe(schluessel, wert, anwenden) { const s = String(wert); if (letzterWert.get(schluessel) === s) return; letzterWert.set(schluessel, s); anwenden(s); } // textContent statt innerHTML: setzt einen Textknoten, ohne den HTML-Parser zu bemuehen. function setText(id, wert) { schreibe("t:" + id, wert, s => { svgElement(id).textContent = s; }); } function setHTML(id, markup) { schreibe("h:" + id, markup, s => { svgElement(id).innerHTML = s; }); } function setAttr(id, name, wert) { schreibe("a:" + id + ":" + name, wert, s => { svgElement(id).setAttribute(name, s); }); } function setDisplay(id, wert) { schreibe("d:" + id, wert, s => { svgElement(id).style.display = s; }); } // Punktgroesse einer Flusslinie setzen und die Animation anhalten, wenn kein // Fluss anliegt. --w ist der Durchmesser, die CSS-Regel macht daraus den Radius // aller Punkte der Gruppe. will-change nur solange die Gruppe laeuft: es fordert // eine eigene Compositor-Ebene an, und die kostet Speicher. function setFlowWidth(id, width) { const flow = svgElement(id); // Eine negative Breite heisst "kein Fluss in dieser Richtung" - die // Aufrufer rechnen 55*(1-exp(x)), was bei umgekehrtem Vorzeichen negativ // wird. Frueher zeichnete eine negative stroke-width schlicht nichts. // Math.max haelt das bei; Math.abs wuerde stattdessen einen Fluss in der // falschen Richtung mit voller Staerke anzeigen. const breite = Math.max(0, width); const sichtbar = breite >= 0.5; flow.style.setProperty("--w", breite + "px"); flow.style.animationPlayState = sichtbar ? "running" : "paused"; flow.style.willChange = sichtbar ? "transform" : "auto"; } const solarSVG = { updateCnt: 99, /** * Beide Zwischenspeicher verwerfen - noetig, falls das SVG neu gezeichnet * wird. Nur die Knoten zu vergessen reicht nicht: letzterWert wuerde dann * jeden Schreibzugriff als "unveraendert" ueberspringen und das frische * SVG behielte seine Platzhalter. */ fillElementArray: function () { for (const id in svgElements) delete svgElements[id]; letzterWert.clear(); }, updateValuesMQTT: function (mqttData) { const angleFactor = 15000 / 288; const sixkWangleFactor = 6000 / 288; var cons = Number(mqttData["solarManager/P_Load"]); var pvn = JSON.parse(mqttData["solarManager/P_PVn"]); var pv = Number(mqttData["solarManager/P_PV"]); var pv2 = Number(pvn[1]); var pv1 = Number(pvn[0]); var pv3 = pv - (pv1 + pv2); var pbatt = Number(mqttData["solarManager/P_Akku"]); var limbatt = Number(mqttData["solarManager/crgMaxPct"]); var grid = Number(mqttData["solarManager/P_Grid"]); var soc = Number(mqttData["solarManager/SOC"]); var og = Number(mqttData["solarManager/og"]); var eg = Number(mqttData["solarManager/eg"]); var ug = Number(mqttData["solarManager/ug"]); var evsoc = Number(mqttData["solarManager/evSOC"]); var evlock = Number(mqttData["solarManager/evLock"]); var evMode = mqttData["solarManager/evMode"]; var evPower = Number(mqttData["solarManager/evPower"]); var plugev = Number(mqttData["solarManager/evPlug"]); var fuelev = Number(mqttData["solarManager/evFuel"]); var heatOG = Number(mqttData["solarManager/heatOG"]); var heatEG = Number(mqttData["solarManager/heatEG"]); var puffO = Number(mqttData["solarManager/t_buffT"]); var puffM = Number(mqttData["solarManager/t_buffM"]); var puffU = Number(mqttData["solarManager/t_buffB"]); var heatMode = mqttData["solarManager/heatMode"]; var carRemChrg = Number(mqttData["solarManager/carRemChrg"]); var aut = Number(mqttData["solarManager/autarky"]); var Pheat = Number(mqttData["solarManager/pHeat"]); var waterHeight = Number(mqttData["solarManager/waterHeight"]); var waterTemp = Number(mqttData["solarManager/waterTemp"]); var p_wr = Number(mqttData["solarManager/P_WR"]); var eff = Number(mqttData["solarManager/eff"]); var i_l1evu = Number(mqttData["solarManager/i_l1evu"]); var i_l2evu = Number(mqttData["solarManager/i_l2evu"]); var i_l3evu = Number(mqttData["solarManager/i_l3evu"]); var evPowerOG = Number(mqttData["solarManager/evPowerOG"]); var evPlugOG = Number(mqttData["solarManager/evPlugOG"]); var evModeOG = mqttData["solarManager/evModeOG"]; var common = -cons - ug - eg + og ; if(common < 0){ common = 0; } setAttr('consumerArc', "d", describeArc(100, 100, 95, 0, Math.round(-cons / angleFactor))); setAttr("ogArc", "d", describeArc(100, 100, 95, 0, Math.round(-og / angleFactor))); setText("consumerText", powerToString(-cons)); setText("consumerTextAllg", "Gemein: "+powerToString(common)); if (evPower > 0) { setDisplay("evCharge", ""); if (carRemChrg > -1) { var today = new Date(); today.setMinutes(today.getMinutes() + carRemChrg); var h = today.getHours(); var m = today.getMinutes(); if (h < 10) h = "0" + h; if (m < 10) m = "0" + m; setText("evRemText", h + ":" + m); } } else { setText("evRemText", ""); } setText("ogText", powerToString(-og)); setText("egText", powerToString(eg)); setText("ugText", powerToString(ug)); setText("pvText", powerToString(pv)); setText("pv1txt", powerToString(pv1)); setText("pv2txt", powerToString(pv2)); setText("pv3txt", powerToString(pv3)); setText("il1txt", Math.round(i_l1evu * 10) / 10 + " A"); setText("il2txt", Math.round(i_l2evu * 10) / 10 + " A"); setText("il3txt", Math.round(i_l3evu * 10) / 10 + " A"); setText("batText", powerToString(pbatt)); setText("heatText", powerToString(Pheat)); setText("evText", powerToString(evPower * 1000)); setText("evTextOG", powerToString(evPowerOG * 1000)); setText("genText", "Autarkie: " + Math.round(aut) + " %"); setText("genInfoText", "Wirkungsgrad: " + Math.round(eff) + " %");// powerToString(p_wr); setText("gridText", powerToString(grid)); for (i = 0; i < pvn.length; i++) { setText("det_pv" + i + "P", powerToString(pvn[i])); } tttext = "" var i = 0; while (mqttData["solarManager/inverters" + i]) { if (mqttData["solarManager/inverters" + i+"/error"] > 0) { tttext = tttext + '' + mqttData["solarManager/inverters" + i+"/name"] + ":" tttext = tttext + '' + "--" + " °C" tttext = tttext + '' + "-- W" + "" } else { tttext = tttext + '' + mqttData["solarManager/inverters" + i+"/name"] + ":" tttext = tttext + '' + Math.round(mqttData["solarManager/inverters" + i+"/temp"]) + " °C" tttext = tttext + '' + powerToString(mqttData["solarManager/inverters" + i+"/p_AC"]) + "" } i++; } setHTML("invList", tttext); setAttr("egArc", "d", describeArc(100, 100, 95, 0, Math.round(eg / angleFactor))); setAttr("ugArc", "d", describeArc(100, 100, 95, 0, Math.round(ug / angleFactor))); setAttr("pvArc", "d", describeArc(100, 100, 95, 0, Math.round(pv1 / angleFactor))); setAttr("pv2Arc", "d", describeArc(100, 100, 95, Math.round(pv1 / angleFactor), Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor))); setAttr("pv3Arc", "d", describeArc(100, 100, 95, Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor), Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor) + Math.round(pv3 / angleFactor))); if (pbatt < 0) { if (limbatt < 100) { setDisplay("charge", "none"); setDisplay("batLim", ""); setText("batLimText", Math.round(limbatt) + "%"); } else { setDisplay("charge", ""); setDisplay("batLim", "none"); } setAttr("batArc", "d", describeArc(100, 100, 95, 0, Math.round(-pbatt / angleFactor))); setAttr("battani", "class", "stream-rev"); setFlowWidth("battani", 55 * (1 - Math.exp(pbatt / 2000))); } else { setDisplay("charge", "none"); setDisplay("batLim", "none"); setAttr("batArc", "d", describeArc(100, 100, 95, 0, Math.round(pbatt / angleFactor))); setAttr("battani", "class", "stream"); setFlowWidth("battani", 55 * (1 - Math.exp(-pbatt / 2000))); } setAttr("batChargeState", "y", (100 - soc) + "%"); //setAttr("heatChargeState", "y", 100 - ((((puffO + puffM) / 2) - 30) * 1.66) + "%"); setAttr('tmpH', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffO)); setAttr('tmpM', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffM)); setAttr('tmpL', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffU)); setText("batSOC", soc + " %"); setAttr("evArc", "d", describeArc(100, 100, 95, 0, Math.round(evPower * 2000 / angleFactor))); setAttr("evChargeState", "y", (100 - evsoc) + "%"); setText("evSOC", evsoc + " %"); setText("evFuel", fuelev + " %"); setAttr("heatArc", "d", describeArc(100, 100, 95, 0, Math.round(Pheat / sixkWangleFactor))); setText("heatUp", Math.round(puffO * 10) / 10 + " °C"); setText("heatDown", Math.round(puffM * 10) / 10 + " °C"); if (evlock == false) { setDisplay("evLock", "none"); } else { setDisplay("evLock", ""); } if (heatMode == "eco") { setDisplay("heatEco", ""); setDisplay("heatDefault", "none"); } else { setDisplay("heatEco", "none"); setDisplay("heatDefault", ""); } if (evMode == "Eco") { setDisplay("evEco", ""); setDisplay("evNextTrip", "none"); setDisplay("evDefault", "none"); } else if (evMode == "Next Trip") { setDisplay("evEco", "none"); setDisplay("evNextTrip", ""); setDisplay("evDefault", "none"); } else { setDisplay("evEco", "none"); setDisplay("evNextTrip", "none"); setDisplay("evDefault", ""); } if (heatOG) { setDisplay("heatOG", ""); } else { setDisplay("heatOG", "none"); } if (heatEG) { setDisplay("heatEG", ""); } else { setDisplay("heatEG", "none"); } // solarManager/evPlug kommt als 1 oder 0, wie evPlugOG. Frueher kam // hier der Text der Wallbox an ("no car", "charging", ...); Number() // machte daraus NaN, und NaN != "no car" ist immer wahr - das // Steckersymbol stand dauerhaft da. Den Text weiter zu verstehen // kostet eine Zeile und haelt die Anzeige richtig, egal ob der // Manager schon neu gestartet ist oder nicht. const plugText = mqttData["solarManager/evPlug"]; if (isNaN(plugev) ? (plugText !== undefined && plugText !== "no car") : plugev > 0) { setDisplay("evPlug", ""); } else { setDisplay("evPlug", "none"); } if (evModeOG == "Eco") { setDisplay("evEcoOG", ""); setDisplay("evNextTripOG", "none"); setDisplay("evDefaultOG", "none"); } else if (evModeOG == "Next Trip") { setDisplay("evEcoOG", "none"); setDisplay("evNextTripOG", ""); setDisplay("evDefaultOG", "none"); } else { setDisplay("evEcoOG", "none"); setDisplay("evNextTripOG", "none"); setDisplay("evDefaultOG", ""); } if (evPlugOG > 0) { setDisplay("evPlugOG", ""); } else { setDisplay("evPlugOG", "none"); } if (grid < 0) { setAttr("gridArc", "d", describeArc(100, 100, 95, 0, -grid / angleFactor)); setAttr("consGridani", "class", "stream-rev"); setFlowWidth("consGridani", 55 * (1 - Math.exp(grid / 2000))); } else { setAttr("gridArc", "d", describeArc(100, 100, 95, 0, grid / angleFactor)); setAttr("consGridani", "class", "stream"); setFlowWidth("consGridani", 55 * (1 - Math.exp(-grid / 2000))); } setFlowWidth("consEVani", 55 * (1 - Math.exp(-evPower))); setFlowWidth("consEVOGani", 55 * (1 - Math.exp(-evPowerOG))); setFlowWidth("consOGani", 55 * (1 - Math.exp(og / 2000))); setFlowWidth("consUGani", 55 * (1 - Math.exp( -ug / 2000))); setFlowWidth("consHeatAni", 55 * (1 - Math.exp(-Pheat / 2000))); setFlowWidth("consAni", 55 * (1 - Math.exp(cons / 2000))); setFlowWidth("consEGani", 55 * (1 - Math.exp(-eg / 2000))); setFlowWidth("pvani", 55 * (1 - Math.exp(-pv / 2000))); } } var chartSettings = { type: 'line', options: { animation: true, plugins: { annotation: { common: { type: 'box', drawTime: 'beforeDatasetsDraw', yScaleID: 'y-axis-0', backgroundColor: 'rgba(255, 255, 255, 0.05)', init: true }, annotations: [] }, tooltip: { position: 'nearest', pointStyle: "circle", boxWidth: 4, usePointStyle: true, callbacks: { footer: function (tooltipItems){return ""}, }, }, legend: { position: "bottom", labels: { pointStyleWidth: 10, usePointStyle: true, pointStyle: "line", } }, }, responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: 'index', }, scales: { x: { adapters: { date: { locale: "DE-de" } }, ticks: { }, type: 'timestack', }, y: { stacked: true, display: true, min: 0, position: 'left', ticks: { callback: value => `${value / 1000} kW`, }, /*title: { display: true, text: "Leistung" }*/ }, y1: { stacked: false, display: true, position: 'right', min: 0, max: 100, ticks: { callback: value => `${value} %`, }, /*title: { display: true, text: "Ladestand" },*/ data:{} } } } }; var forecastChartSettings = { type: 'bar', options: { animation: true, plugins: { tooltip: { position: 'nearest', pointStyle: "circle", boxWidth: 4, usePointStyle: true, callbacks: { footer: function (tooltipItems){return ""}, }, }, legend: { position: "bottom", labels: { pointStyleWidth: 10, usePointStyle: true, pointStyle: "line", } }, }, responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: 'index', }, scales: { x: { adapters: { date: { locale: "DE-de" } }, ticks: { }, type: 'timestack', timestack:{ right_floating_tick_thres: 0.3, format_style: {month: 'long'}, } }, y: { stacked: false, display: true, position: 'left', ticks: { callback: value => `${value / 1000} kW`, }, /*title: { display: true, text: "Leistung" }*/ }, } } }; var modalEV = new bootstrap.Modal(document.getElementById('modalEV'), { keyboard: false }); var chartData = {}; var mqttData = {}; var timeFrom = -24; var timeTo = 12; const dayRange = () => ({ FROM: timeFrom, TO: timeTo }); const consChart = new Chart( document.querySelector('#consumption-chart'), Object.assign({}, chartSettings) ); const prodChart = new Chart( document.querySelector('#production-chart'), Object.assign({}, chartSettings) ); const foreChart = new Chart( document.querySelector('#forecast-chart'), Object.assign({}, forecastChartSettings) ); function updateCharts(){ getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, autoUpdate: false, updateMode: "none" }); getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, autoUpdate: false, updateMode: "none" }); } function prevDay(){ timeFrom -= 24; timeTo -= 24; var now = new Date(); now.setDate(now.getDate()+1+(timeFrom/24)); var day = ("0" + now.getDate()).slice(-2); var month = ("0" + (now.getMonth() + 1)).slice(-2); var selected = now.getFullYear()+"-"+(month)+"-"+(day); document.getElementById("DatePickerCons").value = selected; document.getElementById("DatePickerProd").value = selected; updateCharts(); } function changeDay(e){ const date1 = new Date(e.target.value); const date2 = new Date(); const difference = Math.floor((date1.getTime() - date2.getTime()) / (1000*60*60*24)) *24; timeFrom = difference; timeTo = difference+36; updateCharts(); //.then((ret) => enableEvents()); } function nextDay(){ timeFrom += 24; timeTo += 24; var now = new Date(); now.setDate(now.getDate()+1+(timeFrom/24)); var day = ("0" + now.getDate()).slice(-2); var month = ("0" + (now.getMonth() + 1)).slice(-2); var selected = now.getFullYear()+"-"+(month)+"-"+(day); document.getElementById("DatePickerCons").value = selected; document.getElementById("DatePickerProd").value = selected; updateCharts(); } document.addEventListener('readystatechange', function () { if (event.target.readyState === "complete") { solarMQTT.getMQTT(); getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, updateMode: "none" }); getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, updateMode: "none" }); getData(foreChart, 'ajax/getForecastData.php', { params: dayRange, updateMode: "none" }); // Dieselbe Statistik wie auf der Historienseite, nur das laufende Jahr: // hier ist sie Beiwerk, dort der Zweck der Seite. baueJahresstatistik(document.getElementById("Stats-Year"), { spalten: 1, werkzeuge: false, fussnoten: false }); solarSVG.fillElementArray(); mountMeteogram(); } }); // Setzt den Schieberegler aus den Wattstunden, die die Wallbox meldet. function setzeLadeRegler(wattstunden) { const regler = document.getElementById("modal-slider"); const whProProzent = Number(regler.dataset.whProProzent); if (whProProzent > 0) { regler.value = Math.round(Number(String(wattstunden)) / whProProzent); } } function openModal(type) { document.getElementById("modalSaveBtn").style.display = type === "watering" ? "none" : ""; switch(type){ case "CarEG": document.getElementById("modal-title").innerHTML = "Autoladung EG"; contentURL = "ajax/carEG.php"; break; case "CarOG": document.getElementById("modal-title").innerHTML = "Autoladung OG"; contentURL = "ajax/carOG.php"; break; case "heater": document.getElementById("modal-title").innerHTML = "Steuerung Heizstab"; contentURL = "ajax/heater.php"; break; case "watering": document.getElementById("modal-title").innerHTML = "Gartenbewässerung"; contentURL = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php"; break; default: document.getElementById("modal-title").innerHTML = "Fehler"; contentURL = ""; break; } modalBodyElement = document.getElementById('modal-body'); modalBodyElement.innerHTML = loadingHTML("Wird geladen..."); document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax); document.getElementById("modalSaveBtn").contentURL = contentURL; modalEV.show(); fetch(contentURL, { method: 'GET', headers: { 'X-Requested-From-Modal': 'a', 'Requested-With-Ajax': 'ajax' } }) .then(response => response.text()) .then(html => { modalBodyElement.innerHTML = html; var slider = document.getElementById("modal-slider"); var span = document.getElementById("modal-slider-label"); switch(type){ case "CarEG": bindModalSlider(slider, span, value => value + "%"); document.getElementById("evStart/Stop").addEventListener("click", submitFormAjax); document.getElementById("evStart/Stop").contentURL = contentURL; mode = new TextDecoder().decode(mqttData["wattpilot/properties/lmo/state"]); time = String(mqttData["wattpilot/properties/ftt/state"]); energy = mqttData["wattpilot/properties/fte/state"]; amp = new TextDecoder().decode(mqttData["wattpilot/properties/amp/state"]); car = new TextDecoder().decode(mqttData["wattpilot/properties/car/state"]); charge = Number(mqttData["solarManager/p_l1ev"])+Number(mqttData["solarManager/p_l2ev"])+Number(mqttData["solarManager/p_l3ev"]) if (charge < 0.2) { document.getElementById("evStart/Stop").innerHTML = 'Laden starten' } else { document.getElementById("evStart/Stop").innerHTML = 'Laden stoppen' } if (mode == "Awattar") { document.getElementById("EV_Eco").click(); } else if (mode == "Default") { document.getElementById("EV_Default").click(); } else if (mode == "AutomaticStop") { document.getElementById("EV_NextTrip").click(); } else { alert(mode); } document.getElementById("EV_NextTripTime").value = time.toHHMM(); // Wattstunden je Prozent liefert das Formular mit (carForm.php); // die Akkugroesse steht in restricted/config.php. Frueher standen // hier feste 14000 Wh - fuer beide Autos dasselbe, und fuer keines // von beiden richtig. setzeLadeRegler(energy); document.getElementById("modal-slider").dispatchEvent(new Event('input')); if (amp == "16") { document.getElementById("EV_16A").click(); } else if (amp == "10") { document.getElementById("EV_10A").click(); } else if (amp == "6") { document.getElementById("EV_6A").click(); } break; case "CarOG": bindModalSlider(slider, span, value => value + "%"); document.getElementById("evStart/Stop").addEventListener("click", submitFormAjax); document.getElementById("evStart/Stop").contentURL = contentURL; mode = mqttData["go-eCharger/270003/lmo"]; time = String(mqttData["go-eCharger/270003/att"]); energy = mqttData["go-eCharger/270003/ate"]; amp = mqttData["go-eCharger/270003/amp"]; car = mqttData["go-eCharger/270003/car"]; if (car != "2") { document.getElementById("evStart/Stop").innerHTML = 'Laden starten' } else { document.getElementById("evStart/Stop").innerHTML = 'Laden stoppen' } if (mode == "4") { document.getElementById("EV_Eco").click(); } else if (mode == "3") { document.getElementById("EV_Default").click(); } else if (mode == "5") { document.getElementById("EV_NextTrip").click(); } else { alert(mode); } document.getElementById("EV_NextTripTime").value = time.toHHMM(); // Wattstunden je Prozent liefert das Formular mit (carForm.php); // die Akkugroesse steht in restricted/config.php. Frueher standen // hier feste 14000 Wh - fuer beide Autos dasselbe, und fuer keines // von beiden richtig. setzeLadeRegler(energy); document.getElementById("modal-slider").dispatchEvent(new Event('input')); if (amp == "16") { document.getElementById("EV_16A").click(); } else if (amp == "10") { document.getElementById("EV_10A").click(); } else if (amp == "6") { document.getElementById("EV_6A").click(); } break; case "heater": bindModalSlider(slider, span, value => value == 0 ? "Automatik" : (value / 10) + " kW"); if(mqttData["solarManager/heatMode"] == "eco"){ document.getElementById("modal-slider").value = 0; }else{ document.getElementById("modal-slider").value = Math.round(mqttData["solarManager/pHeat"]/100); } document.getElementById("modal-slider").dispatchEvent(new Event('input')); break; case "watering": initializeWateringModal(); break; } }) .catch(error => { modalBodyElement.innerHTML = error.message; }); } const wateringTimersLastSeen = {}; // Eine Tabelle fuer alles: Ventil, Beschriftung und die Anzeige-Ids im // Realtime-SVG. Vorher standen dieselben Zuordnungen in drei getrennten // Tabellen. const wateringZones = { troughs: { topic: "Gartenwasser/vorn", valve: 1, label: "Tröge", statusId: "wateringTroughStatus", iconId: "wateringTroughIcon" }, frontGarden: { topic: "Gartenwasser/vorn", valve: 6, label: "Vorn", statusId: "wateringFrontStatus", iconId: "wateringFrontIcon" }, raisedBeds: { topic: "Gartenwasser/hinten", valve: 6, label: "Beete", statusId: "wateringBedsStatus", iconId: "wateringBedsIcon" } }; // Feste Liste - der Sekundentakt soll sie nicht jedes Mal neu aufbauen. const wateringZoneList = Object.entries(wateringZones); function wateringCommand(topic, command, directTopic = false) { const feedback = document.getElementById("watering-feedback"); const targetTopic = directTopic ? topic : topic + "/set"; const payload = typeof command === "string" ? command : JSON.stringify(command); const endpoint = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php"; const showFeedback = (message, type) => { if (!feedback) return; feedback.textContent = message; feedback.className = "alert alert-" + type + " mb-3"; feedback.style.whiteSpace = "pre-line"; }; fetch(endpoint, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ topic: targetTopic, payload: payload }) }).then(async (response) => { const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.message || "Unbekannter MQTT-Fehler."); showFeedback("Befehl gesendet.\nTopic: " + result.topic + "\nInhalt: " + result.payload, "success"); }).catch((error) => { showFeedback("Befehl konnte nicht gesendet werden: " + error.message + "\nTopic: " + targetTopic + "\nInhalt: " + payload, "danger"); }); } function initializeWateringModal() { document.querySelectorAll("[data-watering-toggle]").forEach((button) => { const updateButton = () => { const zone = wateringZones[button.dataset.wateringZone]; setzeZonenButton(button, wateringLabel(zone.topic, zone.valve)); }; button.addEventListener("click", () => { const zone = wateringZones[button.dataset.wateringZone]; if (wateringLabel(zone.topic, zone.valve) === "aus") { const timerInput = document.querySelector(`[data-watering-timer='${button.dataset.wateringZone}']`); const timer = Math.min(120, Math.max(1, Number(timerInput.value) || 30)); wateringCommand(zone.topic, { valve: zone.valve, set: "open", timer: timer }); } else { wateringCommand(zone.topic, { valve: zone.valve, set: "close" }); } }); updateButton(); }); document.querySelectorAll("[data-watering-auto-toggle]").forEach((button) => { button.addEventListener("click", () => { const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(","); const modes = button.dataset.wateringAuto.split(","); const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index])); topics.forEach((topic, index) => { wateringCommand(topic + "/auto", active ? "Stop" : modes[index], true); }); }); updateWateringAutoButton(button); }); updateWateringStatus(); } // Die Anzeige laeuft im Sekundentakt, die Nutzlasten aendern sich aber nur, // wenn eine neue MQTT-Nachricht kommt. messageReceived legt dabei jedes Mal // ein neues Buffer-Objekt ab - der Identitaetsvergleich reicht also als // Gueltigkeitspruefung, ein Invalidieren von Hand braucht es nicht. const wateringJsonCache = {}; const wateringDecoder = new TextDecoder(); function wateringJson(topic) { const value = mqttData[topic]; if (!value) return null; const cached = wateringJsonCache[topic]; if (cached && cached.raw === value) return cached.parsed; let parsed = null; try { parsed = JSON.parse(typeof value === "string" ? value : wateringDecoder.decode(value)); } catch (error) { parsed = null; } wateringJsonCache[topic] = { raw: value, parsed: parsed }; return parsed; } function wateringHistory(topic, valve) { const data = wateringJson(topic + "/LastWatering"); if (!data) return { dateTime: "--", duration: "--" }; return { dateTime: data["wateringDateTime" + valve] || "--", duration: data["wateringDurationSecs" + valve] !== undefined ? wateringTimeLabel(Number(data["wateringDurationSecs" + valve])) : "--" }; } // Tagesdatum im Format der Ventilsteuerung ("29.08.26"). function wateringDayStamp(date) { const zwei = (zahl) => String(zahl).padStart(2, "0"); return zwei(date.getDate()) + "." + zwei(date.getMonth() + 1) + "." + zwei(date.getFullYear() % 100); } // Tagessummen der Ventilsteuerung: WateringToday fuer den laufenden Tag, // LastWateringDay fuer den letzten Tag, an dem ueberhaupt bewaessert wurde. // Beide Nutzlasten tragen den zugehoerigen Tag als "wateringDay" mit. Bei // WateringToday wird der geprueft - die Nachricht ist retained, nach einem // Tageswechsel ohne Bewaesserung stuende sonst die Summe von gestern als // "heute" in der Karte. function wateringDayTotal(topic, subTopic, feld, valve, mussHeuteSein) { const data = wateringJson(topic + "/" + subTopic); if (!data) return { label: "--", day: "", sekunden: null }; const tag = data.wateringDay || ""; const sekunden = data[feld + valve]; if (sekunden === undefined) return { label: "--", day: tag, sekunden: null }; if (mussHeuteSein && tag !== wateringDayStamp(new Date())) { return { label: "--", day: tag, sekunden: null }; } return { label: wateringTimeLabel(Number(sekunden)), day: tag, sekunden: Number(sekunden) }; } function wateringAutoActive(topic, mode) { const timers = wateringJson(topic + "/Timers"); return !!(timers && timers.auto === mode); } function wateringValue(data, valve) { if (!data) return null; const candidates = [ data.timers && data.timers[valve], data.timers && data.timers[String(valve)], data[valve], data[String(valve)], data.valves && data.valves[valve], data.valves && data.valves[String(valve)], data.status && data.status[valve], data.status && data.status[String(valve)], data.valveState && data.valveState[valve], data.valveState && data.valveState[String(valve)], data.setValveState && data.setValveState[valve], data.setValveState && data.setValveState[String(valve)] ]; for (const candidate of candidates) { if (candidate !== undefined) return candidate; } return null; } function wateringState(value) { if (value && typeof value === "object") { return value.state ?? value.status ?? value.valveState ?? value.set; } return value; } function wateringSeconds(value) { if (value && typeof value === "object") { value = value.remaining ?? value.timer ?? value.seconds ?? value.time; } return Number(value); } function wateringLabel(topic, valve) { const timers = wateringJson(topic + "/Timers"); const status = wateringJson(topic + "/status"); const lastSeen = wateringTimersLastSeen[topic + "/Timers"]; if (!lastSeen || Date.now() - lastSeen > 3000) return "aus"; const remaining = wateringValue(timers, valve); if (remaining !== null) { const seconds = wateringSeconds(remaining); if (seconds !== 0) return wateringTimeLabel(Math.abs(seconds)); return "aus"; } const state = wateringState(wateringValue(status, valve)); if (state === true || state === 1 || state === "1" || state === "open" || state === "OPEN") return "an"; if (state === false || state === 0 || state === "0" || state === "close" || state === "closed" || state === "CLOSED") return "aus"; return "--"; } function wateringTimeLabel(seconds) { if (seconds < 60) return seconds + "s"; return Math.ceil(seconds / 60) + "m"; } function wateringIsPaused(topic, valve) { const timers = wateringJson(topic + "/Timers"); const remaining = wateringValue(timers, valve); return remaining !== null && wateringSeconds(remaining) < 0; } function updateWateringStatus() { wateringZoneList.forEach(([key, zone]) => { const state = wateringLabel(zone.topic, zone.valve); const paused = wateringIsPaused(zone.topic, zone.valve); const heute = wateringDayTotal(zone.topic, "WateringToday", "wateringDurationTodaySecs", zone.valve, true); // Die Regenautomatik laeuft zum Sonnenaufgang; wenn jemand auf die // Anzeige schaut, ist die Entscheidung laengst gefallen. Angezeigt wird // deshalb nicht mehr, was geplant ist, sondern was passiert ist. Der // Zaehler unterscheidet nicht, wer ausgeloest hat - eine Bewaesserung // von Hand zaehlt genauso, und das ist so gewollt. const bewaessert = state === "aus" && heute.sekunden > 0; // Die Anzeigen liegen im Realtime-SVG und bleiben bestehen -> svgElement. const status = svgElement(zone.statusId); const icon = svgElement(zone.iconId); if (status && icon) updateWateringIndicator(status, icon, zone.label, state, paused, bewaessert); // Die Modal-Elemente werden bei jedem Oeffnen neu erzeugt -> nicht cachen. setzeZonenButton(document.querySelector(`[data-watering-toggle][data-watering-zone='${key}']`), state); setzeSchild(`[data-watering-state='${key}']`, state, paused, bewaessert); const history = wateringHistory(zone.topic, zone.valve); setzeZelle(`[data-watering-last='${key}']`, history.dateTime); setzeZelle(`[data-watering-duration='${key}']`, history.duration); const vortag = wateringDayTotal(zone.topic, "LastWateringDay", "wateringDurationDaySecs", zone.valve, false); setzeZelle(`[data-watering-today='${key}']`, heute.label, heute.day && "Stand: " + heute.day); setzeZelle(`[data-watering-prevday='${key}']`, vortag.label, vortag.day && "Bewässerungstag: " + vortag.day); }); document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton); } // Der Knopf zeigt die naechste Aktion, nicht den Zustand: laeuft die Zone, // steht "Aus" darauf. function setzeZonenButton(button, state) { if (!button) return; const beschriftung = state === "aus" ? "Ein" : "Aus"; if (button.textContent !== beschriftung) button.textContent = beschriftung; button.classList.toggle("btn-success", state === "aus"); button.classList.toggle("btn-danger", state !== "aus"); } // Zustandsschild der Zonenkarte, Farben wie im SVG: laufend gruen, // pausiert gelb, heute schon bewaessert blau, sonst grau. function setzeSchild(selektor, state, paused, bewaessert) { const schild = document.querySelector(selektor); if (!schild) return; let text = "unbekannt"; let farbe = "bg-secondary"; if (state !== "--" && state !== "aus") { text = paused ? "pausiert " + state : "läuft " + state; farbe = paused ? "bg-warning text-dark" : "bg-success"; } else if (state === "aus") { text = bewaessert ? "heute bewässert" : "aus"; farbe = bewaessert ? "bg-primary" : "bg-secondary"; } if (schild.textContent !== text) schild.textContent = text; const klasse = "badge " + farbe; if (schild.className !== klasse) schild.className = klasse; } // Schreibt nur bei echter Aenderung - die Tabelle wird im Sekundentakt // aktualisiert, ihre Werte aendern sich aber hoechstens minuetlich. function setzeZelle(selektor, text, titel) { const element = document.querySelector(selektor); if (!element) return; if (element.textContent !== text) element.textContent = text; if (titel) { if (element.getAttribute("title") !== titel) element.setAttribute("title", titel); } else if (element.hasAttribute("title")) { element.removeAttribute("title"); } } function updateWateringAutoButton(button) { const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(","); const modes = button.dataset.wateringAuto.split(","); const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index])); if (!button.dataset.wateringLabel) { button.dataset.wateringLabel = button.textContent.replace(/^Automatik\s+/, ""); } button.textContent = (active ? "Aus: " : "Ein: ") + button.dataset.wateringLabel; button.classList.toggle("btn-outline-primary", !active); button.classList.toggle("btn-outline-danger", active); } function updateWateringIndicator(status, icon, name, state, paused, bewaessert) { // "Aus" bleibt stehen, solange die Zone heute noch kein Wasser hatte; // danach ersetzt das Haekchen (U+2713, ohne Emoji-Variationsselektor -> // einfarbig, folgt der Icon-Farbe) den Text, um Platz auf der textPath zu // sparen. Laeuft im Sekundentakt, aendert sich aber selten - ohne Pruefung // wuerde jede Sekunde derselbe Wert geschrieben und das Icon samt // Filter neu gezeichnet. const text = state === "aus" ? (bewaessert ? "✓" : "Aus") : state; if (status.textContent !== text) status.textContent = text; // Das Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water"). const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : bewaessert ? "#3A6292" : "#6c757d"; if (icon.getAttribute("color") !== color) { icon.setAttribute("color", color); icon.setAttribute("fill", color); icon.style.color = color; } const titel = name + ": " + (state === "aus" && bewaessert ? "Aus (heute bereits bewässert)" : state); const titelKnoten = icon.querySelector("title"); if (titelKnoten.textContent !== titel) titelKnoten.textContent = titel; } window.setInterval(updateWateringStatus, 1000); function polarToCartesian(centerX, centerY, radius, angleInDegrees) { var angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0; return { x: centerX + (radius * Math.cos(angleInRadians)), y: centerY + (radius * Math.sin(angleInRadians)) }; } function getRBGComponent(colRange, minCol, valRange, minVal, val) { return Math.round(((val - minVal) / valRange) * colRange + minCol) .toString(16) .toUpperCase() .padStart(2, '0'); } function assignColor(minCol, maxCol, minVal, maxVal, val) { var color = ""; var minR = parseInt(minCol.substring(1, 3), 16); var maxR = parseInt(maxCol.substring(1, 3), 16); var minG = parseInt(minCol.substring(3, 5), 16); var maxG = parseInt(maxCol.substring(3, 5), 16); var minB = parseInt(minCol.substring(5, 7), 16); var maxB = parseInt(maxCol.substring(5, 7), 16); var valsRange = maxVal - minVal; var rangeG = maxG - minG; var rangeR = maxR - minR; var rangeB = maxB - minB; if (val > maxVal) val = maxVal; else if (val < minVal) val = minVal; color = '#' + getRBGComponent(rangeR, minR, valsRange, minVal, val) + getRBGComponent(rangeG, minG, valsRange, minVal, val) + getRBGComponent(rangeB, minB, valsRange, minVal, val); return color; } function describeArc(x, y, radius, startAngle, endAngle) { if (endAngle > 288) { endAngle = 288; } // Auf volle Grad runden: bei Radius 95 ist ein Grad rund 1,7 Einheiten, // feiner sieht niemand. Dafuer bleibt das d-Attribut bei kleinen // Schwankungen unveraendert und setAttr unterdrueckt den Schreibvorgang - // sonst laesst jeder Zappler den Bogen samt Filter neu zeichnen. startAngle = Math.round(startAngle); endAngle = Math.round(endAngle); var start = polarToCartesian(x, y, radius, endAngle); var end = polarToCartesian(x, y, radius, startAngle); var largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1"; // zwei Nachkommastellen genuegen und halten das Attribut kurz var rnd = (v) => Math.round(v * 100) / 100; return [ "M", rnd(start.x), rnd(start.y), "A", radius, radius, 0, largeArcFlag, 0, rnd(end.x), rnd(end.y) ].join(" "); }