Files
Smart-Dashboard/js/solar/solarMQTT.js
T
adminandClaude Opus 5 6b8556567b Dashboard: Doppelungen zusammengelegt, Lade- und Renderlast gesenkt
Zusammenlegung
- Die sechs Endpunkte getConsData_/getProdData_{month,year,decade}.php sind
  ajax/energyHistory.php?series=&range= gewichen, der gemeinsame Aufbau der
  Chart-Daten steht in ajax/chartData.php. Die erzeugte SQL wurde gegen alle
  sechs Originale abgeglichen.
- Die 13 Raeume der Home-Ansicht stehen nur noch in restricted/rooms.php.
  home.php rendert die SVG-Kacheln daraus, homeMQTT.js bekommt dieselbe
  Tabelle als JSON (346 -> 222 bzw. 428 -> 197 Zeilen).
- Gemeinsame JS-Helfer in js/solar/common.js; geteilte Bausteine fuer
  Wetterkacheln, Chart-Skripte und das Formular der Ladesteuerung.
- Seitenvorlagen sind jetzt .php und werden eingebunden, statt per
  file_get_contents zusammengesetzt zu werden.

Fehlerbehebungen
- ?action=weather fuehrte auf eine leere Seite, es gibt jetzt weather.php.
- homeMQTT.js griff auf ein #meteogram zu, das home.php nie hatte, und brach
  damit den readystatechange-Handler ab.
- Die Heizungsseite abonnierte weatherStation/#, zeigte die Werte aber nie an.

Ladelast
- Chart.js, MQTT und Meteogramm werden nur noch auf den Seiten geladen, die
  sie brauchen; die Seitentabelle dafuer steht in index.php.
- mqtt.js durch die minifizierte Fassung ersetzt (859 -> 359 KB). MQTT.js
  5.14.1, byte-identisch mit dem signierten npm-Tarball.
- Rund 3,1 MB ungenutzte Vendor-Reste und AdminLTE-Demobilder entfernt.
- Solar 1796 -> 1240 KB, Home 1796 -> 873 KB, Historie 1796 -> 843 KB.

Speicher im Browser
- Chart.defaults.devicePixelRatio auf 1.5 gedeckelt. Die Zeichenpuffer der
  Diagramme waren mit 20-45 MB der groesste Posten der Seite.
- Das Meteogramm-iframe laedt erst beim Hinscrollen.

Realtime-SVG
- Filterbereiche auf das noetige Mass verkleinert und den wirkungslosen
  feOffset-Durchgang entfernt: 60 % weniger gerasterte Filterflaeche bei
  pixelgleichem Ergebnis.
- Die Fluss-Animation laeuft ueber transform statt stroke-dashoffset, mit
  Punkten statt Strichmuster; sie ruht, wo kein Fluss anliegt.
- Der Wertupdate schreibt nur noch bei echter Aenderung und benutzt
  textContent statt innerHTML: 9 statt 86 Elemente pro Update, davon keines
  mit Filter. describeArc rundet auf volle Grad.
- waterInfo auf scale(0.90) wie die uebrigen Info-Gruppen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:28 +02:00

1018 lines
42 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/hinten/status");
client.subscribe("Gartenwasser/hinten/Timers");
client.subscribe("Gartenwasser/hinten/LastWatering");
client.subscribe("Gartenwasser/AutoWatering/#");
});
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("/RainStatus"))) {
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);
const sichtbar = Math.abs(width) >= 0.5;
flow.style.setProperty("--w", (sichtbar ? Math.abs(width) : 0) + "px");
flow.style.animationPlayState = sichtbar ? "running" : "paused";
flow.style.willChange = sichtbar ? "transform" : "auto";
}
const solarSVG = {
updateCnt: 99,
/** Cache verwerfen - noetig, falls das SVG neu gezeichnet wird. */
fillElementArray: function () {
for (const id in svgElements) delete svgElements[id];
},
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 + '<tspan x="0" dy="1.2em">' + mqttData["solarManager/inverters" + i+"/name"] + ":</tspan>"
tttext = tttext + '<tspan x="160">' + "--" + " °C</tspan>"
tttext = tttext + '<tspan x="215">' + "--W" + "</tspan>"
} else {
tttext = tttext + '<tspan x="0" dy="1.2em">' + mqttData["solarManager/inverters" + i+"/name"] + ":</tspan>"
tttext = tttext + '<tspan x="160">' + Math.round(mqttData["solarManager/inverters" + i+"/temp"]) + " °C</tspan>"
tttext = tttext + '<tspan x="215">' + powerToString(mqttData["solarManager/inverters" + i+"/p_AC"]) + "</tspan>"
}
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");
}
if (plugev != "no car") {
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" });
getStats("Stats-Year","ajax/getStats.php?type=ThisYear");
solarSVG.fillElementArray();
mountMeteogram();
}
});
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();
document.getElementById("modal-slider").value = Math.round(energy * 100 / 14000);
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();
document.getElementById("modal-slider").value = Math.round(energy * 100 / 14000);
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 = {};
const wateringZones = {
troughs: { topic: "Gartenwasser/vorn", valve: 1 },
frontGarden: { topic: "Gartenwasser/vorn", valve: 6 },
raisedBeds: { topic: "Gartenwasser/hinten", valve: 6 }
};
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];
const state = wateringLabel(zone.topic, zone.valve);
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");
};
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]))
: "--"
};
}
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;
}
// Liest den von auto_watering.py stündlich veröffentlichten Regen-Status
// (retained, siehe Gartenwasser/AutoWatering/<zone>/RainStatus) und sagt,
// ob für die Zone heute noch eine automatische Bewässerung aussteht.
function wateringAutoPlanned(autoZoneKey) {
const status = wateringJson("Gartenwasser/AutoWatering/" + autoZoneKey + "/RainStatus");
return !!(status && status.would_water && !status.watered_recently);
}
// Konstante Tabellen - stehen ausserhalb, damit der Sekundentakt sie nicht
// jedes Mal neu aufbaut.
const wateringIndicators = [
["wateringFrontStatus", "wateringFrontIcon", "Vorn", "Gartenwasser/vorn", 6, "garten_vorn"],
["wateringTroughStatus", "wateringTroughIcon", "Tröge", "Gartenwasser/vorn", 1, "troege"],
["wateringBedsStatus", "wateringBedsIcon", "Beete", "Gartenwasser/hinten", 6, "hochbeete"]
];
const wateringHistoryRows = [
["troughs", "Gartenwasser/vorn", 1],
["frontGarden", "Gartenwasser/vorn", 6],
["raisedBeds", "Gartenwasser/hinten", 6]
];
function updateWateringStatus() {
// Die Anzeigen liegen im Realtime-SVG und bleiben bestehen -> svgElement.
wateringIndicators.forEach(([statusId, iconId, name, topic, valve, autoZoneKey]) => {
const status = svgElement(statusId);
const icon = svgElement(iconId);
if (!status || !icon) return;
const state = wateringLabel(topic, valve);
const planned = state === "aus" && wateringAutoPlanned(autoZoneKey);
updateWateringIndicator(status, icon, name, state, wateringIsPaused(topic, valve), planned);
});
document.querySelectorAll("[data-watering-toggle]").forEach((button) => {
const zone = wateringZones[button.dataset.wateringZone];
const state = wateringLabel(zone.topic, zone.valve);
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");
});
document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton);
// Die Modal-Elemente werden bei jedem Oeffnen neu erzeugt -> nicht cachen.
wateringHistoryRows.forEach(([zone, topic, valve]) => {
const history = wateringHistory(topic, valve);
const dateElement = document.querySelector(`[data-watering-last='${zone}']`);
const durationElement = document.querySelector(`[data-watering-duration='${zone}']`);
if (dateElement) dateElement.textContent = history.dateTime;
if (durationElement) durationElement.textContent = history.duration;
});
}
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, planned) {
// "Aus" bleibt bei nicht geplant stehen; bei geplant ersetzt die
// Stoppuhr (U+23F1, 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" ? (planned ? "⏱" : "Aus") : state;
if (status.textContent !== text) status.textContent = text;
// Geplant-Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water").
const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : planned ? "#3A6292" : "#6c757d";
if (icon.getAttribute("color") !== color) {
icon.setAttribute("color", color);
icon.setAttribute("fill", color);
icon.style.color = color;
}
const titel = name + ": " + (state === "aus" && planned ? "Aus (Automatik heute noch geplant)" : 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(" ");
}