Statt einer eigenen Vorlage mit nachgebauter Verdrahtung: ?action=anzeige ist der Eintrag solar mit "anzeige" => true. header.php laesst Kopfleiste und Menue weg und setzt die schmale Leiste (neu: Diagramme), footer.php den Fuss; die Realtime-Karte fuellt einen Bildschirm, Diagramme folgen darunter. Damit gehen am Display auch die Bedienfenster (Heizstab, Wallboxen, Bewaesserung) und die Diagramme - alles aus solarMQTT.js. anzeige.js bleibt fuer Leerlauf, Verbindungspunkt, Uhr und Neuladen; restricted/anzeige.php, "eigen" in index.php und klickFilter entfallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
780 lines
32 KiB
JavaScript
780 lines
32 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); });
|
||
});
|
||
|
||
// Anzeige-Modus (festes Display, js/solar/anzeige.js): immer quer.
|
||
const anzeigeModus = document.body.classList.contains("anzeige");
|
||
energiefluss = new Energiefluss(behaelter, {
|
||
hochkantUnter: anzeigeModus ? 0 : undefined,
|
||
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, { hochkantUnter: anzeigeModus ? 0 : undefined });
|
||
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;
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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(" ");
|
||
}
|