Vor W, kW, kWh, V, mV, A, % und °C steht jetzt ueberall U+2009 wie in der Uebersicht von Hand begonnen (65 Stellen, nur in Anzeigetexten; CSS-Werte und freistehende Achseneinheiten bleiben). Dazu "Wirkungsgrad 96 %" mit Leerzeichen und die Heizstab-Temperaturen mit " / " wie Sonne und Netz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
268 lines
13 KiB
JavaScript
268 lines
13 KiB
JavaScript
/* ==========================================================================
|
||
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 });
|
||
};
|
||
})();
|