Files
Smart-Dashboard/js/solar/solarMQTT.js
T
adminandClaude Opus 5 eca5c8f041 Bewaesserungs-Modal: Zonenkarten statt Tabelle
Das Modal ist 500 px breit (modal-dialog ohne Groessenklasse). Die Tabelle
mit fuenf Spalten samt Zahlenfeld war darin schon eng; jede weitere Angabe
haette sie gesprengt. Statt der Tabelle bekommt jede Zone eine eigene Karte
mit Zustandsschild, Steuerzeile und den Zeiten darunter, die Automatik eine
eigene Karte darueber - dort sitzt auch die kombinierte Automatik, die zwei
Zonen bedient.

Die Automatik-Karte zeigt zusaetzlich, was auto_watering.py je Zone vorhat,
und begruendet es: "heute geplant - nur 0,2 mm Regen in 2 Tagen". Die Zahlen
stammen aus dem RainStatus-Topic, das die Seite ohnehin schon abonniert hat,
bisher aber nur fuer das Uhr-Symbol im SVG benutzte. Die genaue Regel steht
im Titel der Zeile, weil sich die Schwellen je Zone deutlich unterscheiden.
Die letzte Bewaesserung bleibt in der Zonenkarte, sie ist eine Eigenschaft
der Zone; die Automatik nennt sie nur als Begruendung.

Nebenbei sind die drei getrennten Zuordnungstabellen (wateringZones,
wateringIndicators, wateringHistoryRows) zu einer verschmolzen, die Ventil,
Automatik-Zone, Beschriftung und die SVG-Anzeige-Ids traegt. Damit wird aus
updateWateringStatus eine Schleife statt dreier, und die doppelt vorhandene
Knopflogik faellt weg. Alle 18 Zuordnungen und die Historienzeilen sind
gegen den vorherigen Stand geprueft, ohne Abweichung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:47:50 +02:00

1125 lines
47 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.
This file contains Unicode characters that might be confused with other characters. 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);
// 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 + '<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 = {};
// Eine Tabelle fuer alles: Ventil, Automatik-Zone des Skripts, Beschriftung
// und die Anzeige-Ids im Realtime-SVG. Vorher standen dieselben Zuordnungen
// in drei getrennten Tabellen.
const wateringZones = {
troughs: {
topic: "Gartenwasser/vorn", valve: 1, auto: "troege", label: "Tröge",
statusId: "wateringTroughStatus", iconId: "wateringTroughIcon"
},
frontGarden: {
topic: "Gartenwasser/vorn", valve: 6, auto: "garten_vorn", label: "Vorn",
statusId: "wateringFrontStatus", iconId: "wateringFrontIcon"
},
raisedBeds: {
topic: "Gartenwasser/hinten", valve: 6, auto: "hochbeete", 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]))
: "--"
};
}
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);
}
// Zeitraeume und Regenmengen in Klartext. "2 T." war zu kryptisch.
function wateringTage(tage) {
const zahl = Number(tage);
const text = Number.isInteger(zahl) ? String(zahl) : String(zahl).replace(".", ",");
return zahl === 1 ? text + " Tag" : text + " Tagen";
}
function wateringMm(wert) {
return String(wert).replace(".", ",") + " mm";
}
// Sagt in einem Satz, was die Automatik fuer die Zone vorhat und warum.
// Speist sich aus derselben RainStatus-Nachricht wie die Anzeige im SVG.
// Der Titel nennt zusaetzlich die Regel, nach der entschieden wird - die
// Schwellenwerte unterscheiden sich je Zone deutlich.
function wateringPlanText(autoZoneKey) {
const status = wateringJson("Gartenwasser/AutoWatering/" + autoZoneKey + "/RainStatus");
if (!status) return { text: "keine Planungsdaten", titel: "" };
const regel = [];
if (status.threshold_mm !== undefined && status.threshold_days !== undefined) {
regel.push("Bewässert wird, wenn in " + wateringTage(status.threshold_days)
+ " weniger als " + wateringMm(status.threshold_mm) + " Regen fallen.");
}
if (status.max_dry_days) {
regel.push("Ebenso, wenn seit " + wateringTage(status.max_dry_days) + " nicht bewässert wurde.");
}
const titel = regel.join(" ");
if (status.watered_recently) {
const zeitpunkt = status.last_watering
? " zuletzt " + String(status.last_watering).replace(/[.]\d{4} /, ". ")
: "";
return { text: "erledigt" + zeitpunkt, titel: titel };
}
const regen = status.rain_mm !== undefined && status.threshold_days !== undefined
? wateringMm(status.rain_mm) + " Regen in " + wateringTage(status.threshold_days)
: "";
if (!status.would_water) {
return { text: "kein Bedarf" + (regen ? " " + regen : ""), titel: titel };
}
if (status.forced_dry) {
const trocken = status.max_dry_days
? " seit " + wateringTage(status.max_dry_days) + " nicht bewässert"
: " lange nicht bewässert";
return { text: "heute geplant" + trocken, titel: titel };
}
return { text: "heute geplant" + (regen ? " nur " + regen : ""), titel: titel };
}
// Alle Zonen stammen aus demselben Lauf des Skripts - der erste gefundene
// Zeitstempel steht daher fuer alle. Die Sekunden schneidet slice(0, 16) ab.
function wateringPlanUpdated() {
for (const [, zone] of wateringZoneList) {
const status = wateringJson("Gartenwasser/AutoWatering/" + zone.auto + "/RainStatus");
if (status && status.updated) return "Planung vom " + String(status.updated).slice(0, 16);
}
return "";
}
function updateWateringStatus() {
wateringZoneList.forEach(([key, zone]) => {
const state = wateringLabel(zone.topic, zone.valve);
const paused = wateringIsPaused(zone.topic, zone.valve);
const planned = state === "aus" && wateringAutoPlanned(zone.auto);
// 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, planned);
// 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, planned);
const history = wateringHistory(zone.topic, zone.valve);
setzeZelle(`[data-watering-last='${key}']`, history.dateTime);
setzeZelle(`[data-watering-duration='${key}']`, history.duration);
const plan = wateringPlanText(zone.auto);
setzeZelle(`[data-watering-plan='${key}']`, plan.text, plan.titel);
});
document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton);
setzeZelle("[data-watering-plan-updated]", wateringPlanUpdated());
}
// 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, von der Automatik geplant blau, sonst grau.
function setzeSchild(selektor, state, paused, planned) {
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 = planned ? "heute geplant" : "aus";
farbe = planned ? "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, 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(" ");
}