/* ==========================================================================
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" },
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" ? "fertig" : 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" ? "voll" : 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");
const eigen = $("zeitEigen");
if (document.activeElement !== eigen && !bekannt) 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());
}));
$("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 });
};
})();