/* ========================================================================== Ladefenster der Wallboxen (Gerüst: ajax/carForm.php, Schreiben: ajax/carSteuerung.php). ladefensterStarten(wurzel, url) nach dem Laden des Fensters Liest jede Sekunde aus mqttData (von der Seite gefüllt) und schreibt jede Änderung sofort. Was eben geändert wurde, gilt HALTEN ms lang lokal - so springt die Anzeige nicht auf den alten Stand zurück, bevor die Wallbox den neuen meldet. Freigabe (frc) wird gelesen, nicht geraten: 0 = der Modus entscheidet, 1 = gesperrt, 2 = lädt sofort. Der große Knopf bietet jeweils den sinnvollen nächsten Schritt an. ========================================================================== */ (function () { "use strict"; const HALTEN = 8000; // Was die Wallboxen melden und wie es heißt. Geschrieben wird mit denselben // Schlüsseln über restricted/wallboxen.php. const WALLBOXEN = { eg: { // Wattpilot, über wattpilot_bruecke.py im SolarManager modus: "wattpilot/properties/lmo/state", modi: { Default: "sofort", Awattar: "sonne", AutomaticStop: "plan", 3: "sofort", 4: "sonne", 5: "plan" }, ampere: "wattpilot/properties/amp/state", energie: "wattpilot/properties/fte/state", zeit: "wattpilot/properties/ftt/state", frc: "wattpilot/properties/frc/state", frcWerte: { Neutral: 0, Off: 1, On: 2, 0: 0, 1: 1, 2: 2 }, auto: "wattpilot/properties/car/state", autoWerte: { Idle: "kein", Charging: "laedt", WaitCar: "wartet", Complete: "fertig" }, // Complete = Ladevorgang beendet, nicht Akku voll leistungKW: "solarManager/evPower", soc: "solarManager/evSOC", restMin: "solarManager/evRemChrgTime", }, og: { // go-e Charger, direkt am Broker modus: "go-eCharger/270003/lmo", modi: { 3: "sofort", 4: "sonne", 5: "plan" }, ampere: "go-eCharger/270003/amp", energie: "go-eCharger/270003/ate", zeit: "go-eCharger/270003/att", frc: "go-eCharger/270003/frc", frcWerte: { 0: 0, 1: 1, 2: 2 }, auto: "go-eCharger/270003/car", autoWerte: { 1: "kein", 2: "laedt", 3: "wartet", 4: "fertig" }, leistungKW: "solarManager/evPowerOG", soc: null, restMin: null, }, }; const KW_JE_A = { 6: 4.1, 10: 6.9, 16: 11 }; // dreiphasig, 230 V const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ","); const hhmm = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); 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; }; function sekundenZuZeit(s) { if (s === null) return null; const h = Math.floor(s / 3600) % 24, m = Math.floor(s % 3600 / 60); return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); } /** "07:00" -> "heute 07:00" oder "morgen 07:00", je nachdem, was als nächstes kommt. */ function naechstes(zeit) { const [h, m] = zeit.split(":").map(Number); const jetzt = new Date(), ziel = new Date(); ziel.setHours(h, m, 0, 0); return (ziel > jetzt ? "heute " : "morgen ") + zeit; } function inStunden(h) { const d = new Date(Date.now() + h * 3600000); return (d.getDate() !== new Date().getDate() ? "morgen " : "um ") + hhmm(d); } window.ladefensterStarten = function (wurzel, url) { const box = WALLBOXEN[wurzel.dataset.wallbox]; if (!box) return; const whProProzent = Number(wurzel.dataset.whProProzent); const $ = name => wurzel.querySelector('[data-lade="' + name + '"]'); const lokal = {}; // name -> {wert, bis} let ziehen = false; const merke = (name, wert) => { lokal[name] = { wert, bis: Date.now() + HALTEN }; }; const gilt = (name, gemeldet) => (lokal[name] && lokal[name].bis > Date.now()) ? lokal[name].wert : gemeldet; function meldung(textInhalt, art) { const m = $("meldung"); m.textContent = textInhalt; m.className = "lade-meldung" + (art ? " " + art : ""); clearTimeout(meldung.uhr); if (art === "gut") meldung.uhr = setTimeout(() => { m.textContent = "Änderungen gelten sofort"; m.className = "lade-meldung"; }, 2200); } async function senden(daten) { meldung("wird geschickt …"); try { const antwort = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(daten) }); const d = await antwort.json().catch(() => ({})); if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); meldung("✓ an die Wallbox geschickt", "gut"); } catch (e) { meldung("Nicht übernommen: " + e.message, "fehler"); } zeichnen(); } /* ------------------------------------------------ Stand aus MQTT */ function stand() { const soc = box.soc ? zahl(box.soc) : null; const energieWh = zahl(box.energie); const zeit = gilt("zeit", sekundenZuZeit(zahl(box.zeit)) || "07:00"); // Ziel: mit Ladestand als Prozent des Akkus, ohne als zusätzliche Prozent. const gemeldetZiel = energieWh === null ? 80 : soc !== null ? Math.min(100, Math.round((soc + energieWh / whProProzent) / 5) * 5) : Math.min(100, Math.max(5, Math.round(energieWh / whProProzent / 5) * 5)); return { soc, modus: gilt("modus", box.modi[text(box.modus)] || null), ampere: gilt("ampere", zahl(box.ampere)), frc: gilt("frc", box.frcWerte[text(box.frc)] ?? 0), auto: box.autoWerte[text(box.auto)] || null, kw: zahl(box.leistungKW) || 0, restMin: box.restMin ? zahl(box.restMin) : null, ziel: gilt("ziel", gemeldetZiel), zeit, netz: zahl("solarManager/P_Grid") || 0, }; } /* ------------------------------------------------------ Zeichnen */ function zeichnen() { const s = stand(); const laedt = s.kw >= 0.2 || s.auto === "laedt"; const mitSoc = s.soc !== null; // Ring const ist = mitSoc ? s.soc : 0; const ziel = s.modus === "plan" ? s.ziel : mitSoc ? 100 : 0; const zielBogen = mitSoc ? Math.max(0, ziel - ist) : (s.modus === "plan" ? s.ziel : 0); const istBogen = wurzel.querySelector(".lade-ist"); istBogen.style.strokeDasharray = ist + " 100"; // Ein Bogen der Länge 0 zeigt mit runden Enden einen Punkt. istBogen.style.opacity = ist > 0 ? "1" : "0"; const zb = wurzel.querySelector(".lade-ziel"); zb.style.strokeDasharray = zielBogen + " 100"; zb.style.strokeDashoffset = -ist; setze("soc", mitSoc ? Math.round(s.soc) + " %" : (Number(wurzel.dataset.akkuKwh) ? komma(Number(wurzel.dataset.akkuKwh)) + " kWh" : "–")); setze("ringUnten", s.modus === "plan" ? (mitSoc ? "Ziel " + s.ziel + " %" : "+ " + s.ziel + " %") : mitSoc ? "Ladestand" : "Akku"); // Zustand const zustand = laedt ? "lädt" : s.frc === 1 ? "gestoppt" : s.auto === "fertig" ? "beendet" : s.auto === "kein" ? "kein Auto" : "wartet"; $("leistung").innerHTML = komma(s.kw) + " kW" + zustand + ""; const pille = $("stecker"); const pilleText = s.auto === "kein" ? "kein Auto" : s.auto === "laedt" ? "lädt" : s.auto === "fertig" ? "beendet" : s.auto ? "angesteckt" : ""; setze("stecker", pilleText); pille.className = "lade-pille" + (s.auto && s.auto !== "kein" ? " an" : ""); const anteil = laedt && s.kw > 0 ? Math.max(0, Math.min(1, 1 - Math.max(0, s.netz) / (s.kw * 1000))) : null; setze("quelle", anteil !== null ? Math.round(anteil * 100) + " % aus Sonnenstrom" : ""); let fertig = ""; if (laedt && s.restMin > 0) fertig = "fertig " + inStunden(s.restMin / 60); else if (s.modus === "plan") fertig = "Ziel bis " + naechstes(s.zeit); setze("fertig", fertig); // Knopf const knopf = $("start"); let aktion, beschriftung, klasse = ""; if (laedt) { aktion = 1; beschriftung = "Laden stoppen"; klasse = "laeuft"; } else if (s.frc === 1) { aktion = 0; beschriftung = "Ladung freigeben"; klasse = "gesperrt"; } else if (s.frc === 2) { aktion = 0; beschriftung = "Zurück zur Automatik"; } else if (s.auto === "kein") { aktion = 2; beschriftung = "Kein Auto angesteckt"; klasse = "aus"; } else { aktion = 2; beschriftung = "Jetzt laden"; } knopf.dataset.aktion = aktion; knopf.disabled = klasse === "aus"; knopf.className = "lade-start " + klasse; setze("startText", beschriftung); // Erklärung const kwMax = KW_JE_A[s.ampere] || (s.ampere ? s.ampere * 0.69 : 11); let erkl = { sofort: "Lädt mit bis zu " + komma(kwMax) + " kW – reicht die Sonne nicht, kommt der Rest aus dem Netz.", sonne: "Lädt nur mit Überschuss. Scheint zu wenig Sonne, pausiert die Ladung.", plan: "Lädt bis " + naechstes(s.zeit) + (mitSoc ? " auf " + s.ziel + " %" : " " + s.ziel + " % nach") + " – bevorzugt mit Sonnenstrom, nötigenfalls aus dem Netz.", }[s.modus] || ""; if (s.frc === 1) erkl = "Gestoppt: die Wallbox lädt erst wieder nach „Ladung freigeben“ – dann entscheidet der Modus."; if (s.frc === 2) erkl = "Lädt sofort, unabhängig vom Modus. „Zurück zur Automatik“ überlässt es wieder dem Modus."; setze("erklaerung", erkl); // Modus, Strom wurzel.querySelectorAll("[data-modus]").forEach(b => b.setAttribute("aria-checked", b.dataset.modus === s.modus ? "true" : "false")); wurzel.querySelectorAll("[data-ampere]").forEach(b => b.setAttribute("aria-checked", Number(b.dataset.ampere) === s.ampere ? "true" : "false")); // Planung const planung = $("planung"); planung.hidden = s.modus !== "plan"; if (!planung.hidden) { const regler = $("ziel"); if (!ziehen) regler.value = s.ziel; const wert = Number(regler.value); regler.style.setProperty("--ist", (mitSoc ? s.soc : 0) + "%"); regler.style.setProperty("--wert", wert + "%"); setze("zielTitel", mitSoc ? "Ladestand bei Abfahrt" : "Zusätzlich laden"); setze("markeMitte", mitSoc ? "jetzt " + Math.round(s.soc) + " %" : ""); const plusWh = mitSoc ? Math.max(0, wert - s.soc) * whProProzent : wert * whProProzent; $("zielAusgabe").innerHTML = wert + " %" + (plusWh > 0 ? "+ " + komma(plusWh / 1000) + " kWh" : "schon erreicht") + ""; setze("abfahrtAusgabe", naechstes(s.zeit)); let bekannt = false; wurzel.querySelectorAll("[data-zeit]").forEach(c => { const an = c.dataset.zeit === s.zeit; bekannt = bekannt || an; c.setAttribute("aria-pressed", an ? "true" : "false"); }); $("chipEigen").setAttribute("aria-pressed", bekannt ? "false" : "true"); setze("chipEigenText", bekannt ? "andere" : s.zeit); const eigen = $("zeitEigen"); if (document.activeElement !== eigen) eigen.value = s.zeit; } return s; } function setze(name, inhalt) { const e = $(name); if (e && e.textContent !== inhalt) e.textContent = inhalt; } /* ------------------------------------------------------ Aktionen */ function planSchicken(s) { const ziel = Number($("ziel").value); let wh = s.soc !== null ? Math.max(0, ziel - s.soc) * whProProzent : ziel * whProProzent; wh = Math.max(100, Math.round(wh)); merke("modus", "plan"); merke("ziel", ziel); merke("zeit", s.zeit); senden({ aktion: "plan", wh, zeit: s.zeit }); } $("start").addEventListener("click", () => { const wert = Number($("start").dataset.aktion); merke("frc", wert); senden({ aktion: "frc", wert }); }); wurzel.querySelectorAll("[data-modus]").forEach(b => b.addEventListener("click", () => { const modus = b.dataset.modus; if (modus === "plan") { merke("modus", "plan"); planSchicken(stand()); return; } merke("modus", modus); senden({ aktion: "modus", modus }); })); wurzel.querySelectorAll("[data-ampere]").forEach(b => b.addEventListener("click", () => { const ampere = Number(b.dataset.ampere); merke("ampere", ampere); senden({ aktion: "strom", ampere }); })); $("ziel").addEventListener("input", () => { ziehen = true; merke("ziel", Number($("ziel").value)); zeichnen(); }); $("ziel").addEventListener("change", () => { ziehen = false; planSchicken(stand()); }); wurzel.querySelectorAll("[data-zeit]").forEach(c => c.addEventListener("click", () => { merke("zeit", c.dataset.zeit); planSchicken(stand()); })); $("chipEigen").addEventListener("click", e => { const feld = $("zeitEigen"); if (e.target === feld) return; // showPicker öffnet den Wähler des Browsers; ältere Browser bekommen // wenigstens den Fokus und damit die Eingabe per Tastatur. try { feld.showPicker(); } catch (fehler) { feld.focus(); } }); $("zeitEigen").addEventListener("change", e => { if (!/^\d\d:\d\d$/.test(e.target.value)) return; merke("zeit", e.target.value); planSchicken(stand()); }); zeichnen(); const takt = setInterval(zeichnen, 1000); const fenster = wurzel.closest(".modal"); if (fenster) fenster.addEventListener("hidden.bs.modal", () => clearInterval(takt), { once: true }); }; })();