Heizstab- und Bewaesserungsfenster im neuen Look, Bewaesserungszeilen enger

Beide Fenster lesen den Zustand live aus MQTT und uebernehmen jede Wahl
sofort (bedienfenster.js). submitFormAjax entfaellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 21:26:16 +02:00
co-authored by Claude Opus 5
parent bf5fdfefa4
commit 6e805678d4
9 changed files with 466 additions and 311 deletions
+249
View File
@@ -0,0 +1,249 @@
/* ==========================================================================
Bedienfenster Heizstab und Bewässerung - im selben Stil wie das
Ladefenster (ladefenster.js), mit denselben Bausteinen aus solar.css.
heizfensterStarten(wurzel, url) Gerüst: ajax/heater.php
wasserfensterStarten(wurzel, url) Gerüst: ajax/watering.php
Beide lesen jede Sekunde aus mqttData und schicken jede Änderung sofort.
Was eben geschickt wurde, gilt HALTEN ms lang lokal, bis das Gerät den
neuen Stand meldet. Der Takt endet, wenn das Fenster zugeht.
========================================================================== */
(function () {
"use strict";
const HALTEN = 8000;
const S = ""; // schmales Leerzeichen vor Einheiten
const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
const text = t => { const v = window.mqttData && mqttData[t]; return v === undefined ? undefined : String(v); };
const zahl = t => { const v = Number(text(t)); return isFinite(v) ? v : null; };
/** Gemeinsames Gerüst: lokaler Stand, Meldungszeile, Takt bis zum Schließen. */
function geruest(wurzel, meldungsFeld, grundText) {
const lokal = {};
const g = {
merke(name, wert) { lokal[name] = { wert, bis: Date.now() + HALTEN }; },
gilt(name, gemeldet) { return lokal[name] && lokal[name].bis > Date.now() ? lokal[name].wert : gemeldet; },
meldung(inhalt, art) {
const m = meldungsFeld();
m.textContent = inhalt;
m.className = "lade-meldung" + (art ? " " + art : "");
clearTimeout(g.uhr);
if (art === "gut") g.uhr = setTimeout(() => { m.textContent = grundText; m.className = "lade-meldung"; }, 2200);
},
takt(zeichnen) {
zeichnen();
const uhr = setInterval(zeichnen, 1000);
const fenster = wurzel.closest(".modal");
if (fenster) fenster.addEventListener("hidden.bs.modal", () => clearInterval(uhr), { once: true });
},
};
return g;
}
function setze(el, inhalt) {
if (el && el.textContent !== inhalt) el.textContent = inhalt;
}
/** Temperatur als Farbe, wie der Speicher in der Übersicht. */
function tempFarbe(t) {
const f = Math.max(0, Math.min(1, (t - 20) / 60));
const kalt = [35, 137, 186], heiss = [186, 59, 35];
return "rgb(" + kalt.map((k, i) => Math.round(k + (heiss[i] - k) * f)).join(",") + ")";
}
/* ======================================================== Heizstab */
window.heizfensterStarten = function (wurzel, url) {
const $ = name => wurzel.querySelector('[data-heiz="' + name + '"]');
const g = geruest(wurzel, () => $("meldung"), "Änderungen gelten sofort");
let ziehen = false;
async function senden(stufe) {
g.meldung("wird geschickt …");
try {
const antwort = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "setMode=" + stufe,
});
const inhalt = (await antwort.text()).trim();
if (!antwort.ok || inhalt) throw new Error(inhalt || "HTTP " + antwort.status);
g.meldung("✓ an den Heizstab geschickt", "gut");
} catch (e) {
g.meldung("Nicht übernommen: " + e.message, "fehler");
}
zeichnen();
}
function stand() {
const w = zahl("solarManager/pHeat") || 0;
const gemeldetFest = text("solarManager/heatMode") !== undefined && text("solarManager/heatMode") !== "eco";
const modus = g.gilt("modus", gemeldetFest ? "fest" : "auto");
const stufe = g.gilt("stufe", Math.max(1, Math.min(60, Math.round(w / 100) || 30)));
return {
w, modus, stufe,
temps: ["t_buffT", "t_buffM", "t_buffB"].map(t => zahl("solarManager/" + t)),
};
}
function zeichnen() {
const s = stand();
const heizt = s.w >= 20;
// Speicher
s.temps.forEach((t, i) => {
const schicht = $("t" + i);
if (t !== null) schicht.style.fill = tempFarbe(t);
setze($("tt" + i), t !== null ? Math.round(t) + "°" : "");
});
$("spirale").classList.toggle("an", heizt);
const namen = ["oben", "Mitte", "unten"];
setze($("temps"), s.temps.map((t, i) => t !== null ? namen[i] + " " + komma(t) : null).filter(Boolean).join(" · ") + S + "°C");
$("leistung").innerHTML = komma(s.w / 1000, 2) + S + "kW<small>" + (heizt ? "heizt" : "aus") + "</small>";
const pille = $("pille");
setze(pille, s.modus === "auto" ? "Automatik" : "fest");
pille.className = "lade-pille" + (heizt ? " an" : "");
setze($("erklaerung"), s.modus === "auto"
? "Heizt nur mit dem Überschuss der Anlage ohne Sonne bleibt der Heizstab aus."
: "Heizt dauerhaft mit " + komma(s.stufe / 10) + S + "kW reicht die Sonne nicht, kommt der Strom aus dem Netz.");
wurzel.querySelectorAll("[data-heiz-modus]").forEach(b => b.setAttribute("aria-checked", b.dataset.heizModus === s.modus ? "true" : "false"));
$("fest").hidden = s.modus !== "fest";
const regler = $("regler");
if (!ziehen) regler.value = s.stufe;
const wert = Number(regler.value);
regler.style.setProperty("--wert", ((wert - 1) / 59 * 100).toFixed(1) + "%");
$("reglerAusgabe").innerHTML = komma(wert / 10) + S + "kW";
wurzel.querySelectorAll("[data-heiz-stufe]").forEach(c => c.setAttribute("aria-pressed", Number(c.dataset.heizStufe) === wert ? "true" : "false"));
}
wurzel.querySelectorAll("[data-heiz-modus]").forEach(b => b.addEventListener("click", () => {
const modus = b.dataset.heizModus;
g.merke("modus", modus);
senden(modus === "auto" ? 0 : Number($("regler").value));
}));
$("regler").addEventListener("input", () => { ziehen = true; g.merke("stufe", Number($("regler").value)); zeichnen(); });
$("regler").addEventListener("change", () => {
ziehen = false;
g.merke("modus", "fest");
senden(Number($("regler").value));
});
wurzel.querySelectorAll("[data-heiz-stufe]").forEach(c => c.addEventListener("click", () => {
const stufe = Number(c.dataset.heizStufe);
g.merke("stufe", stufe);
g.merke("modus", "fest");
senden(stufe);
}));
g.takt(zeichnen);
};
/* ===================================================== Bewässerung */
window.wasserfensterStarten = function (wurzel, url) {
const g = geruest(wurzel, () => wurzel.querySelector('[data-w="meldung"]'), "Befehle gehen sofort an die Ventilsteuerung");
const bilder = window.ENERGIEFLUSS_ZONENBILDER || {};
const dauer = {}; // Zone -> gewählte Minuten
wurzel.querySelectorAll("[data-bild]").forEach(b => {
if (bilder[b.dataset.bild]) b.innerHTML = '<svg viewBox="0 0 24 24">' + bilder[b.dataset.bild] + "</svg>";
});
wurzel.querySelectorAll("[data-zone]").forEach(k => { dauer[k.dataset.zone] = Number(k.dataset.dauer); });
const zeitText = sek => sek < 60 ? Math.round(sek) + S + "s" : Math.ceil(sek / 60) + S + "min";
const labelText = l => String(l).replace(/^(\d+)m$/, "$1" + S + "min").replace(/^(\d+)s$/, "$1" + S + "s");
async function senden(topic, payload) {
g.meldung("wird geschickt …");
try {
const antwort = await fetch(url, {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ topic, payload: typeof payload === "string" ? payload : JSON.stringify(payload) }),
});
const d = await antwort.json().catch(() => ({}));
if (!antwort.ok || !d.success) throw new Error(d.message || "HTTP " + antwort.status);
g.meldung("✓ an die Ventilsteuerung geschickt", "gut");
} catch (e) {
g.meldung("Nicht übernommen: " + e.message, "fehler");
}
zeichnen();
}
function zeichnen() {
wurzel.querySelectorAll("[data-zone]").forEach(karte => {
const key = karte.dataset.zone;
const zone = wateringZones[key];
if (!zone) return;
const z = wateringZonenZustand(zone);
const gemeldetLaeuft = z.state !== "aus" && z.state !== "--";
const laeuft = g.gilt("laeuft-" + key, gemeldetLaeuft);
const timer = wateringValue(wateringJson(zone.topic + "/Timers"), zone.valve);
const rest = timer !== null ? Math.abs(wateringSeconds(timer)) : null;
const $ = name => karte.querySelector('[data-w="' + name + '"]');
let zustand;
if (laeuft && gemeldetLaeuft) zustand = (z.paused ? "pausiert" : "läuft") + (rest ? " · noch " + zeitText(rest) : "");
else if (laeuft) zustand = "startet …";
else if (z.bewaessert) zustand = "heute " + labelText(z.heute.label) + " gegossen";
else if (z.state === "--") zustand = "Zustand unbekannt";
else zustand = "heute noch nicht gegossen";
setze($("zustand"), zustand);
const pille = $("pille");
setze(pille, laeuft ? (z.paused ? "pausiert" : "läuft") : z.bewaessert ? "gegossen" : "");
pille.className = "lade-pille" + (laeuft ? (z.paused ? " warn" : " an") : z.bewaessert ? " blau" : "");
karte.classList.toggle("laeuft", laeuft);
const knopf = $("knopf");
knopf.className = "lade-start wasser-knopf" + (laeuft ? " laeuft" : "");
setze($("knopfText"), laeuft ? "Stoppen" : "Gießen · " + dauer[key] + S + "min");
knopf.dataset.aktion = laeuft ? "stopp" : "start";
karte.querySelectorAll("[data-min]").forEach(c => c.setAttribute("aria-pressed", Number(c.dataset.min) === dauer[key] ? "true" : "false"));
const verlauf = wateringHistory(zone.topic, zone.valve);
const vortag = wateringDayTotal(zone.topic, "LastWateringDay", "wateringDurationDaySecs", zone.valve, false);
const teile = [];
if (verlauf.dateTime !== "--") teile.push("zuletzt " + verlauf.dateTime + (verlauf.duration !== "--" ? " · " + labelText(verlauf.duration) : ""));
if (vortag.label !== "--" && vortag.sekunden > 0) teile.push("letzter Gießtag " + labelText(vortag.label));
setze($("info"), teile.join(" · "));
});
wurzel.querySelectorAll("[data-ablauf]").forEach(c => {
const modi = JSON.parse(c.dataset.ablauf);
const aktiv = g.gilt("ablauf-" + c.textContent, Object.entries(modi).every(([t, m]) => wateringAutoActive(t, m)));
c.setAttribute("aria-pressed", aktiv ? "true" : "false");
});
}
wurzel.querySelectorAll("[data-zone]").forEach(karte => {
const key = karte.dataset.zone;
const zone = wateringZones[key];
karte.querySelectorAll("[data-min]").forEach(c => c.addEventListener("click", () => {
dauer[key] = Number(c.dataset.min);
zeichnen();
}));
karte.querySelector('[data-w="knopf"]').addEventListener("click", e => {
const start = e.currentTarget.dataset.aktion === "start";
g.merke("laeuft-" + key, start);
senden(zone.topic + "/set", start
? { valve: zone.valve, set: "open", timer: dauer[key] }
: { valve: zone.valve, set: "close" });
});
});
wurzel.querySelectorAll("[data-ablauf]").forEach(c => c.addEventListener("click", async () => {
const modi = JSON.parse(c.dataset.ablauf);
const aktiv = c.getAttribute("aria-pressed") === "true";
g.merke("ablauf-" + c.textContent, !aktiv);
for (const [topic, modus] of Object.entries(modi)) {
await senden(topic + "/auto", aktiv ? "Stop" : modus);
}
}));
g.takt(zeichnen);
};
})();
-40
View File
@@ -73,46 +73,6 @@ async function getData(chart, url, options = {}) {
}
}
/**
* Formular des Modals per POST abschicken ("Save changes", heute nur noch
* der Heizstab). Das Ladefenster schreibt selbst, siehe ladefenster.js.
*/
function submitFormAjax(event) {
let xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
setTimeout(function () { modalEV.hide(); }, 700);
if (xmlhttp.responseText) {
alert(xmlhttp.responseText);
}
}
};
let post = "";
const form = document.getElementById('modalEV').querySelector('form');
for (const element of Array.from(form.elements)) {
if (!element.name) continue;
if (element.type == "radio" || element.type == "checkbox") {
if (element.checked) {
post += element.name + "=" + encodeURIComponent(element.value) + "&";
}
} else if (element.type == "button") {
if (!element.classList.contains("accordion-button")) {
post += element.name + "=" + encodeURIComponent(element.innerHTML) + "&";
}
} else {
post += element.name + "=" + encodeURIComponent(element.value) + "&";
}
}
xmlhttp.open("POST", event.currentTarget.contentURL, true);
xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
document.getElementById('modalEV').querySelector('.modal-body').innerHTML = loadingHTML("Änderungen werden übernommen...");
xmlhttp.send(post);
return false;
}
/**
* Schieberegler im Modal mit seiner Beschriftung verbinden.
* label(wert) liefert den anzuzeigenden Text.
+3 -1
View File
@@ -381,7 +381,7 @@
wert.remove(); neben.remove();
symbol.setAttribute("transform", `translate(0 ${-r * 0.5}) scale(1.4)`);
k.zonen.forEach((z, i) => {
const zy = -r * 0.1 + i * 25;
const zy = -r * 0.06 + i * 20;
// Eigenes Symbol (ZONENBILDER) oder ein Zeichen aus Bootstrap Icons.
// updateWateringIndicator() färbt es über color und schreibt den
// Tooltip in das <title>.
@@ -622,4 +622,6 @@
}
window.Energiefluss = Energiefluss;
// Auch die Bedienfenster (bedienfenster.js) zeigen die Zonen mit diesen Symbolen.
window.ENERGIEFLUSS_ZONENBILDER = ZONENBILDER;
})();
+11 -157
View File
@@ -443,9 +443,9 @@ var chartSettings = {
function openModal(type) {
// Das Ladefenster und die Bewaesserung uebernehmen jede Aenderung sofort -
// "Save changes" braucht nur noch der Heizstab.
document.getElementById("modalSaveBtn").style.display = type === "heater" ? "" : "none";
// 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";
@@ -471,8 +471,6 @@ var chartSettings = {
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',
@@ -485,25 +483,17 @@ var chartSettings = {
.then(html => {
modalBodyElement.innerHTML = html;
var slider = document.getElementById("modal-slider");
var span = document.getElementById("modal-slider-label");
// Aufbau und Logik der Fenster: ladefenster.js und bedienfenster.js.
switch(type){
case "CarEG":
case "CarOG":
// Alles Weitere in js/solar/ladefenster.js.
ladefensterStarten(modalBodyElement.querySelector(".lade"), contentURL);
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 "heater":
heizfensterStarten(modalBodyElement.querySelector(".heiz"), contentURL);
break;
case "watering":
initializeWateringModal();
wasserfensterStarten(modalBodyElement.querySelector(".wasser"), contentURL);
break;
}
@@ -513,146 +503,10 @@ var chartSettings = {
});
}
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 Zonensymbole im Bewaesserungskreis; das Fenster aktualisiert sich
// selbst (bedienfenster.js).
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);
wateringSymboleSetzen(svgElement);
}
window.setInterval(updateWateringStatus, 1000);