Files
Smart-Dashboard/js/solar/solarMQTT.js
T
adminandClaude Opus 5 2c48d78880 Eigenes Meteogramm statt des eingebetteten von meteoblue
Das Widget von meteoblue war ein fremdes Dokument im iframe: es passte
farblich nicht, liess sich nicht aendern, wurde auf schmalen Schirmen als
Ganzes heruntergerechnet und konnte die eigenen Messwerte nicht zeigen.
Genau das kann das neue Modul - links vom Jetzt-Strich steht, was die
Wetterstation gemessen hat, rechts die Vorhersage.

Aufgebaut wie die uebrigen Anzeige-Module: meteogrammKatalog.js sagt, WAS
gezeigt wird, meteogramm.js nur, WIE. Der Renderer kennt kein Feld und keine
Spur beim Namen. Eine weitere Spur ist damit ein Objekt im Katalog und keine
Codeaenderung.

Ein einziges SVG ueber alle drei Felder, damit Zeitachse, Nachtstreifen,
Jetzt-Linie und Zeiger nur einmal existieren und ueberall gleich sitzen.

Zwei Entscheidungen, die man sonst sucht:

Das Wolkenband zeichnet je Schicht ein Rechteck mit waagerechtem
Farbverlauf. Ein Rechteck je Stunde war der erste Versuch und sah aus wie
Fernsehrauschen - zweihundert harte Kanten nebeneinander. Open-Meteo kennt
ohnehin nur drei Schichten statt eines Hoehenprofils.

Die Strahlung teilt sich das Feld mit der Temperatur, statt ein viertes zu
bekommen: gleicher Tagesrhythmus, gleiche Nacht-Schattierung, und ein
viertes Feld machte das Ganze am Handy hoeher als das externe Widget.

Die alte Einbindung bleibt vorerst und ist umschaltbar. mountMeteogram()
laeuft jetzt erst beim Umschalten - wer meteoblue nie ansieht, laedt es auch
nicht mehr.

Die Karte heisst "Wetter" statt "Forecast": darunter steht bereits eine
zweite Karte dieses Namens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:54:55 +02:00

631 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let solarMqttClient;
let solarSvgUpdatePending = false;
const solarMQTT = {
getMQTT: function () {
const id = Math.random().toString(36).substring(7);
const topic = "#";
const connection = "wss://mqtt.nas.el-wa.org:443"
mqttsolarTreeDone = false;
// const connection = "ws://username:password@37.97.203.138:8083" // Works
// const connection = "wss://public:public@public.cloud.shiftr.io" // Works
const client = mqtt.connect(connection, {
rejectUnauthorized: false,
});
solarMqttClient = client;
client.on("message", messageReceived);
client.on("connect", function () {
client.subscribe("solarManager/#");
client.subscribe("wattpilot/properties/lmo/state");
client.subscribe("wattpilot/properties/ftt/state");
client.subscribe("wattpilot/properties/fte/state");
client.subscribe("wattpilot/properties/amp/state");
client.subscribe("wattpilot/properties/car/state");
client.subscribe("go-eCharger/270003/amp");
client.subscribe("go-eCharger/270003/ate");
client.subscribe("go-eCharger/270003/lmo");
client.subscribe("go-eCharger/270003/att");
client.subscribe("go-eCharger/270003/car");
client.subscribe("weatherStation/#");
client.subscribe("Gartenwasser/vorn/status");
client.subscribe("Gartenwasser/vorn/Timers");
client.subscribe("Gartenwasser/vorn/LastWatering");
client.subscribe("Gartenwasser/vorn/WateringToday");
client.subscribe("Gartenwasser/vorn/LastWateringDay");
client.subscribe("Gartenwasser/hinten/status");
client.subscribe("Gartenwasser/hinten/Timers");
client.subscribe("Gartenwasser/hinten/LastWatering");
client.subscribe("Gartenwasser/hinten/WateringToday");
client.subscribe("Gartenwasser/hinten/LastWateringDay");
});
client.on("error", function (error) {
//alert("MQTT Error: " + error);
});
client.on('end', function () {
setTimeout(getMQTT, 5000);
alert("MQTT Disconnected, try to reconnect in 5 secs.");
})
function getNestedProp(obj, path) {
return path.split('/').reduce((acc, key) => acc && acc[key], obj);
}
function setNestedProp(obj, path, value) {
var schema = obj; // a moving reference to internal objects within obj
var pList = path.split('/');
var len = pList.length;
for (var i = 0; i < len - 1; i++) {
var elem = pList[i];
if (!schema[elem]) schema[elem] = {}
schema = schema[elem];
}
schema[pList[len - 1]] = value;
}
function messageReceived(topic, message) {
mqttData[topic] = message;
if (topic === "Gartenwasser/vorn/Timers" || topic === "Gartenwasser/hinten/Timers") {
wateringTimersLastSeen[topic] = Date.now();
}
if (topic.startsWith("Gartenwasser/") && (topic.endsWith("/status") || topic.endsWith("/Timers") || topic.endsWith("/LastWatering")
|| topic.endsWith("/WateringToday") || topic.endsWith("/LastWateringDay"))) {
updateWateringStatus();
}
if (topic == "solarManager/P_Load") {
if (!solarSvgUpdatePending) {
solarSvgUpdatePending = true;
setTimeout(function () {
solarSvgUpdatePending = false;
solarSVG.updateValuesMQTT(mqttData);
}, 1000);
}
}else if(topic == "weatherStation/windDeg"){
setTimeout(function () { updateWeatherCards(name => mqttData["weatherStation/" + name]) }, 200); //give the object tree some time to build up and receive all values
}
}
},
publish: function (topic, payload, callback) {
if (!solarMqttClient || !solarMqttClient.connected) {
throw new Error("MQTT ist noch nicht verbunden.");
}
solarMqttClient.publish(topic, typeof payload === "string" ? payload : JSON.stringify(payload), callback);
}
}
// Die Knoten im eingebetteten Realtime-SVG bleiben ueber die Lebensdauer der
// Seite bestehen. updateValuesMQTT fasst 104 davon an - bis zu einmal pro
// Sekunde -, deshalb werden sie einmal nachgeschlagen und dann behalten.
const svgElements = {};
function svgElement(id) {
let node = svgElements[id];
if (node === undefined) {
node = svgElements[id] = document.getElementById(id);
}
return node;
}
// Nur schreiben, wenn sich der Wert wirklich geaendert hat. Jeder Schreibzugriff
// macht Style und Paint des Elements ungueltig - bei einem gefilterten Element
// wird dabei sein Filter neu gerastert. Gemessen waren rund 71 % der Zugriffe
// pro Update reine Wiederholungen desselben Werts; die entfallen damit.
const letzterWert = new Map();
function schreibe(schluessel, wert, anwenden) {
const s = String(wert);
if (letzterWert.get(schluessel) === s) return;
letzterWert.set(schluessel, s);
anwenden(s);
}
// textContent statt innerHTML: setzt einen Textknoten, ohne den HTML-Parser zu bemuehen.
function setText(id, wert) { schreibe("t:" + id, wert, s => { svgElement(id).textContent = s; }); }
function setHTML(id, markup) { schreibe("h:" + id, markup, s => { svgElement(id).innerHTML = s; }); }
function setAttr(id, name, wert) { schreibe("a:" + id + ":" + name, wert, s => { svgElement(id).setAttribute(name, s); }); }
function setDisplay(id, wert) { schreibe("d:" + id, wert, s => { svgElement(id).style.display = s; }); }
// Punktgroesse einer Flusslinie setzen und die Animation anhalten, wenn kein
// Fluss anliegt. --w ist der Durchmesser, die CSS-Regel macht daraus den Radius
// aller Punkte der Gruppe. will-change nur solange die Gruppe laeuft: es fordert
// eine eigene Compositor-Ebene an, und die kostet Speicher.
function setFlowWidth(id, width) {
const flow = svgElement(id);
// Eine negative Breite heisst "kein Fluss in dieser Richtung" - die
// Aufrufer rechnen 55*(1-exp(x)), was bei umgekehrtem Vorzeichen negativ
// wird. Frueher zeichnete eine negative stroke-width schlicht nichts.
// Math.max haelt das bei; Math.abs wuerde stattdessen einen Fluss in der
// falschen Richtung mit voller Staerke anzeigen.
const breite = Math.max(0, width);
const sichtbar = breite >= 0.5;
flow.style.setProperty("--w", breite + "px");
flow.style.animationPlayState = sichtbar ? "running" : "paused";
flow.style.willChange = sichtbar ? "transform" : "auto";
}
// 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;
let meteogramm = 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);
// Nur die Live-Leiste des Meteogramms; die Tafel zeigt Stunden und wird
// davon nicht neu gezeichnet.
if (meteogramm) meteogramm.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();
}
}
/**
* Das Meteogramm aufsetzen und den Umschalter eigenes/meteoblue verdrahten.
*
* mountMeteogram() wird ausdruecklich NICHT beim Laden gerufen, sondern erst
* beim ersten Umschalten auf meteoblue. Das Widget ist ein vollstaendiges
* Fremd-Dokument in einem iframe - wer es nie ansieht, soll es auch nicht
* laden. Das ist nebenbei der groesste Ladezeitgewinn dieser Karte.
*/
function meteogrammStarten() {
const behaelter = document.getElementById("meteogramm");
if (!behaelter || typeof Meteogramm === "undefined") {
// Seite ohne eigenes Meteogramm: dann wie frueher das Fremd-Widget.
if (typeof mountMeteogram === "function") mountMeteogram();
return;
}
// Anzeige-Modus (festes Display, 800 x 480): dort ist fuer fuenf Tage
// kein Platz, zwei Tage bleiben lesbar.
const anzeigeModus = document.body.classList.contains("anzeige");
meteogramm = new Meteogramm(behaelter, {
kopf: document.querySelector(".mtg-kopf"),
tage: anzeigeModus ? 2 : undefined,
});
meteogramm.zeichnen();
let blueGeladen = false;
const zeigeQuelle = function (name) {
document.querySelectorAll("[data-mtg-quelle]").forEach(function (knopf) {
const an = knopf.dataset.mtgQuelle === name;
knopf.classList.toggle("active", an);
knopf.setAttribute("aria-selected", an ? "true" : "false");
});
document.querySelectorAll("[data-mtg-flaeche]").forEach(function (flaeche) {
flaeche.hidden = flaeche.dataset.mtgFlaeche !== name;
});
if (name === "blue" && !blueGeladen && typeof mountMeteogram === "function") {
blueGeladen = true;
mountMeteogram();
}
try { localStorage.setItem("mtgQuelle", name); } catch (e) { /* privates Fenster */ }
};
document.querySelectorAll("[data-mtg-quelle]").forEach(function (knopf) {
knopf.addEventListener("click", function () { zeigeQuelle(knopf.dataset.mtgQuelle); });
});
let gemerkt = null;
try { gemerkt = localStorage.getItem("mtgQuelle"); } catch (e) { /* egal */ }
if (gemerkt === "blue") zeigeQuelle("blue");
}
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();
meteogrammStarten();
}
});
function openModal(type) {
// Alle Fenster der Solar-Seite uebernehmen jede Aenderung sofort -
// "Save changes" braucht hier keines mehr.
document.getElementById("modalSaveBtn").style.display = "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...");
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;
// Aufbau und Logik der Fenster: ladefenster.js und bedienfenster.js.
switch(type){
case "CarEG":
case "CarOG":
ladefensterStarten(modalBodyElement.querySelector(".lade"), contentURL);
break;
case "heater":
heizfensterStarten(modalBodyElement.querySelector(".heiz"), contentURL);
break;
case "watering":
wasserfensterStarten(modalBodyElement.querySelector(".wasser"), contentURL);
break;
}
})
.catch(error => {
modalBodyElement.innerHTML = error.message;
});
}
// Die Zonensymbole im Bewaesserungskreis; das Fenster aktualisiert sich
// selbst (bedienfenster.js).
function updateWateringStatus() {
wateringSymboleSetzen(svgElement);
}
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(" ");
}