Files
Smart-Dashboard/js/solar/ladefenster.js
T
adminandClaude Opus 5 b0bac3d3ba Ladefenster neu: Zustand zuerst, Modus als Karten, Aenderungen sofort
Nach dem abgestimmten Entwurf: Ladestand als Ring mit Ziel, Leistung,
Sonnenanteil und fertig um; ein grosser Knopf, der sich nach der gemeldeten
Freigabe (frc) richtet - "Laden stoppen", "Ladung freigeben" (zurueck zum
Modus statt dauerhaft gesperrt), "Jetzt laden", ohne Auto ausgegraut.
Modus als Karten (Sofort, Sonne, Geplant), Planung nur bei "Geplant": mit
Ladestand des Autos als Ziel-Ladestand, sonst als zusaetzliche Prozent;
Abfahrt per Schnellwahl. Ladestrom als Segmente mit kW.

Jede Aenderung geht sofort als JSON an carSteuerung.php (frc, modus, strom,
plan) statt ueber "Save changes"; die Logik steht in js/solar/ladefenster.js.
Am 800er-Display zweispaltig ohne Scrollen.

Nebenbei: die Uebersicht las die Restladezeit aus carRemChrg, das der
Manager nicht mehr schickt - jetzt evRemChrgTime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:40:29 +02:00

260 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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<small>" + zustand + "</small>";
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 + " %<small>" + (plusWh > 0 ? "+ " + komma(plusWh / 1000) + " kWh" : "schon erreicht") + "</small>";
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 });
};
})();