Files
Smart-Dashboard/js/solar/solarMQTT.js
T
adminandClaude Opus 5 deb81045d5 Skoda: Schluessel in den Einstellungen, Ladungen am Stueck, Wallbox in kW
- neuer Reiter "Fahrzeug": API-Schluessel eintragen und pruefen, ohne dass
  ein Wert je an den Browser zurueckgeht (restricted/skodaKeys.php)
- ajax/skoda.php trennt Ladungen am Zustand statt an 30 Minuten Pause und
  rahmt sie mit der Zeile davor und danach ein
- solarLog_skoda_ladepunkte.sql: Wallbox-Verlauf waehrend einer Ladung
- Kachelformat "leistung_kw" fuer Quellen, die schon Kilowatt melden
- Steckersymbol versteht evPlug als 1/0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:30:18 +02:00

1118 lines
46 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/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 + '<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");
}
// 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(" ");
}