Files
Smart-Dashboard/js/solar/ladefenster.js
T
adminandClaude Opus 5 5f42309410 Ladefenster: eigene Abfahrtszeit lesbar und als ganzer Knopf antippbar
Das Zeitfeld im Chip war zu schmal (von 16:00 blieb die 1) und oeffnete
den Waehler nur ueber das Uhrsymbol von Chromium. Jetzt zeigt der Knopf die
Zeit und oeffnet den Waehler per showPicker(); das Feld liegt unsichtbar
darunter. Car-Zustand Complete heisst beendet, nicht voll.

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

268 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" }, // 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<small>" + zustand + "</small>";
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 + " %<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");
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 });
};
})();