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(" "); }