Dritte Ansicht der Realtime-Karte (auch per Klick auf die Batterie): Kennzahlen (Ladestand mit kWh und voll/leer um, Leistung, SOH, Zellspreizung, Temperaturen, Spannung, Durchsatz, Zustand), der Turm aus BMU und acht Modulen mit je 16 Zellbalken, das gewaehlte Modul mit Zellspannungen und Temperaturfuehlern, dazu Verlaeufe fuer Ladestand und Leistung (EnergyFlow) sowie Spreizung und Temperatur (byd) mit Zeitraum- wahl und Anzeige beim Ueberfahren. Neu: ajax/speicher.php. Nebenbei: Icon-Zeichen in energiefluss.js und im Katalog stehen als Escape (\uF...) statt als unsichtbare Zeichen im Quelltext. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
946 lines
38 KiB
JavaScript
946 lines
38 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";
|
||
}
|
||
|
||
// Die Energiefluss-Uebersicht (js/solar/energiefluss.js) und die Anlage-Ansicht
|
||
// (js/solar/anlage.js). Bis September 2026 stand hier updateValuesMQTT fuer das
|
||
// feste realtime.svg - jeder Kreis mit eigenen Zeilen. Was gezeigt wird, steht
|
||
// jetzt in den Katalogen (energieflussKatalog.js, anlageKatalog.js).
|
||
let energiefluss = null;
|
||
let anlage = null;
|
||
let speicher = null;
|
||
|
||
const solarSVG = {
|
||
updateCnt: 99,
|
||
/**
|
||
* Beide Zwischenspeicher verwerfen - noetig, wenn die Uebersicht neu
|
||
* gezeichnet wird (Wechsel quer/hochkant). Nur die Knoten zu vergessen
|
||
* reicht nicht: letzterWert wuerde dann jeden Schreibzugriff als
|
||
* "unveraendert" ueberspringen und die frischen Elemente behielten ihre
|
||
* Platzhalter.
|
||
*/
|
||
fillElementArray: function () {
|
||
for (const id in svgElements) delete svgElements[id];
|
||
letzterWert.clear();
|
||
},
|
||
updateValuesMQTT: function (mqttData) {
|
||
if (energiefluss) energiefluss.aktualisieren(mqttData);
|
||
// Auch verborgen: so schreibt die Anlage ihren Verlauf je Platte mit.
|
||
if (anlage) anlage.aktualisieren(mqttData);
|
||
if (speicher) speicher.aktualisieren(mqttData);
|
||
}
|
||
};
|
||
|
||
/** Energiefluss aufbauen und den Umschalter Uebersicht/Anlage verdrahten. */
|
||
function energieflussStarten() {
|
||
const behaelter = document.getElementById("energiefluss");
|
||
if (!behaelter || typeof Energiefluss === "undefined") return;
|
||
|
||
const zeigeAnsicht = function (name) {
|
||
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
|
||
const an = knopf.dataset.enfAnsicht === name;
|
||
knopf.classList.toggle("active", an);
|
||
knopf.setAttribute("aria-selected", an ? "true" : "false");
|
||
});
|
||
document.querySelectorAll("[data-enf-ansicht-flaeche]").forEach(function (flaeche) {
|
||
const an = flaeche.dataset.enfAnsichtFlaeche === name;
|
||
flaeche.hidden = !an;
|
||
if (an) {
|
||
flaeche.classList.remove("enf-rein");
|
||
void flaeche.offsetWidth;
|
||
flaeche.classList.add("enf-rein");
|
||
}
|
||
});
|
||
};
|
||
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
|
||
knopf.addEventListener("click", function () { zeigeAnsicht(knopf.dataset.enfAnsicht); });
|
||
});
|
||
|
||
energiefluss = new Energiefluss(behaelter, {
|
||
einstellungen: typeof energieflussEinstellungen !== "undefined" ? energieflussEinstellungen : {},
|
||
onKlick: function (knoten) {
|
||
if (knoten.klick.ansicht) zeigeAnsicht(knoten.klick.ansicht);
|
||
else if (knoten.klick.modal) openModal(knoten.klick.modal);
|
||
},
|
||
onGezeichnet: function () {
|
||
solarSVG.fillElementArray();
|
||
if (typeof updateWateringStatus === "function") updateWateringStatus();
|
||
},
|
||
});
|
||
energiefluss.zeichnen();
|
||
|
||
const anlageBehaelter = document.getElementById("anlage");
|
||
if (anlageBehaelter && typeof Anlage !== "undefined") {
|
||
anlage = new Anlage(anlageBehaelter);
|
||
anlage.zeichnen();
|
||
}
|
||
const speicherBehaelter = document.getElementById("speicher");
|
||
if (speicherBehaelter && typeof Speicher !== "undefined") {
|
||
speicher = new Speicher(speicherBehaelter);
|
||
speicher.zeichnen();
|
||
}
|
||
}
|
||
|
||
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 });
|
||
energieflussStarten();
|
||
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(" ");
|
||
}
|