- 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>
1118 lines
46 KiB
JavaScript
1118 lines
46 KiB
JavaScript
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(" ");
|
||
}
|