Ladestand ins Fahrzeugbild, Jahresstatistik als Vergleichstabelle

Drei Teile, die zusammengehoeren: die Fahrzeugkachel zeigt den Ladestand
jetzt dort, wo er im Auto auch sitzt, die Jahresstatistik wird zur
Tabelle, und beide holen ihr Aussehen aus einer gemeinsamen Farbwelt.

Fahrzeugkachel. Die Kachel "Batterie und Ladung" ist aufgeloest; ihr
Inhalt steckt in der Draufsicht. Die Akkuplatte im Fahrzeugboden fuellt
sich von hinten nach vorn, traegt die Zielmarke und nennt Prozent und
Reichweite in ihrer Mitte - auf dem Goldenen Schnitt der Plattenhoehe,
damit sie nicht am Klimaknopf klebt. Beim Laden wandern Streifen im
gefuellten Teil nach vorn, und vom Anschluss fliesst eine Linie in die
Platte. Was sich nicht zeichnen laesst - Ladeleistung, Restzeit,
Kilometerstand, Klima, Herkunft des Ladestroms - steht als Kennzahl
daneben, dazu die gefahrenen Kilometer aus Woche, Monat und Jahr
(?was=strecke, gerechnet aus dem Zaehlerstand statt aus erkannten
Fahrten - faellt eine Zeile aus, fehlt die Fahrt in der Liste, im
Zaehlerstand aber nicht).

Am Fahrzeug selbst: kein Panoramadach, das hat unseres nicht. Reling und
Spoiler neu gezeichnet, weil sie mit der Dachplatte bisher ein
geschlossenes Rechteck bildeten und das wie ein Fensterrahmen aussah. Die
Blende am Bug ist schmaler und heller - von oben sieht man von einer
senkrechten Flaeche nur einen Streifen, und fast schwarz las sie sich wie
ein Loch. Die Ladeklappe ist gebaut wie der Klimaknopf; den Ladezustand
traegt jetzt die Farbe statt einer zweiten Form.

Jahresvergleich. getStats.php liefert kein fertiges HTML mehr, sondern
JSON, das sich selbst beschreibt: Einheit, Nachkommastellen, Richtung und
Zugehoerigkeit stehen als Metadaten je Kennzahl, nicht mehr als zwei
Arrays, die ueber den Spaltenindex mit der Abfrage synchron gehalten
werden mussten. Zwei Abfragen holen alles - Monatswerte, daraus die
ganzen Jahre und die Summe seit Aufzeichnungsbeginn, dazu der gleiche
Zeitraum jedes Jahres bis zum heutigen Kalendertag. Verhaeltniszahlen
sind auf jeder Ebene neu aus den Summen gerechnet; ein Jahresmittel ist
nicht das Mittel der Monatsmittel.

Gezeichnet wird das von einem Baustein, den sich Historien- und
Solarseite teilen: dort drei Jahresspalten mit verschiebbarem Fenster bis
zurueck zum ersten Jahr, hier eine Spalte ohne Werkzeuge. Zeilen, die
sich aufteilen, stehen als "davon" darunter; was bereits in einer anderen
Zeile steckt, als "darin" samt Namen der Zeile - der Beitrag der Batterie
und die Solarladung des Autos duerfen nicht zum Ertrag addiert werden.
Zusammengefasste Zeilen sind zunaechst zu, damit die Tabelle mit zehn
Zeilen aufmacht statt mit siebzehn.

Farbwelt. Statt Regel fuer Regel zu ueberschreiben, ist die Palette
ausgetauscht: ein paar Variablen am Wurzelelement, an die Bootstrap und
AdminLTE ohnehin gehen. Anthrazit als Grund, eine Spur hellere Flaeche
fuer Karten, Haarlinien statt Rahmen und Schatten, Bernstein als einzige
Akzentfarbe - auf dieser Seite die Farbe der eigenen Anlage. Gruen, Rot
und Gelb bleiben den Zustaenden vorbehalten.

Erklaerungen, die bisher nur im title standen, sind antippbar: am Finger
gibt es kein Ueberfahren, und ein title erscheint dort nie.

Die beiden neuen Abfragen sind gegen solarLog gelaufen, die Monatswerte
decken sich mit einer unabhaengigen Zerlegung. grundpreisImJahr() ruft
jetzt grundpreisImZeitraum() auf - dieselbe Rechnung, nur auch fuer einen
Teil des Jahres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 20:08:51 +02:00
co-authored by Claude Opus 5
parent 22ecc1b50f
commit 0cb0d4c3ae
13 changed files with 2691 additions and 587 deletions
+383 -79
View File
@@ -15,6 +15,11 @@
* Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
* auszugeben.
*
* Der Ladestand hat keine eigene Anzeige mehr: er steckt in der Akkuplatte der
* Fahrzeuggrafik - Füllhöhe, Zielmarke, Prozent und Reichweite setzt
* zeichneAkku(). Was sich nicht zeichnen lässt, steht in den Kennzahlen
* daneben (zeichneKennzahlen).
*/
var mqttData = {};
@@ -86,6 +91,70 @@ function dauer(minuten) {
return Math.floor(m / 60) + " h " + (m % 60) + " min";
}
/* --------------------------------------------------------------- Hinweise */
/*
* Erklärungen zu einer Zahl - woher sie stammt, was sie nicht abdeckt.
*
* Im title stehen sie am Zeiger gut, auf einem Berührungsgerät aber nirgends:
* Überfahren gibt es dort nicht, und der title erscheint nie. Das übliche
* Mittel dagegen ist, aus der Erklärung etwas Anfassbares zu machen - einen
* Auslöser, der Fokus annehmen kann, und ein Fenster, das beim nächsten
* Tippen daneben oder mit Esc wieder verschwindet. Genau das leistet ein
* Popover mit den Auslösern "focus hover": tippen öffnet, danebentippen
* schliesst, für Maus und Tastatur bleibt es beim Überfahren und Tabben.
*
* Damit das gefunden wird, trägt der Auslöser sichtbar ein Zeichen - die
* Klasse hat-hinweis setzt es. Eine Erklärung, die niemand vermutet, ist so
* gut wie keine.
*/
/** Erklärung an ein Element hängen; ohne Text wird sie wieder entfernt. */
function setzeHinweis(el, text) {
if (!el) return;
// Ohne Bootstrap bleibt der title. Weniger gut, aber nicht nichts.
if (typeof bootstrap === "undefined" || !bootstrap.Popover) {
if (text) el.title = text; else el.removeAttribute("title");
return;
}
const alt = bootstrap.Popover.getInstance(el);
if (alt) alt.dispose();
el.removeAttribute("title"); // sonst zeigt der Browser beides
if (!text) {
el.classList.remove("hat-hinweis");
["tabindex", "role", "aria-label"].forEach(a => el.removeAttribute(a));
return;
}
el.classList.add("hat-hinweis");
el.setAttribute("tabindex", "0"); // ohne Fokus kein Antippen auf iOS
el.setAttribute("role", "button");
el.setAttribute("aria-label", "Erklärung anzeigen");
new bootstrap.Popover(el, {
content: text,
trigger: "focus hover",
placement: "top",
container: "body", // sonst schneidet die Kachel ab
customClass: "skoda-erklaerung"
});
}
/**
* Erklärungen unterhalb eines Knotens auflösen, bevor er neu gebaut wird.
* Ein offenes Fenster gehört zu seinem Auslöser - verschwindet der aus dem
* Dokument, bliebe es sonst am Bildschirm stehen.
*/
function loeseHinweise(wurzel) {
if (!wurzel || typeof bootstrap === "undefined" || !bootstrap.Popover) return;
wurzel.querySelectorAll(".hat-hinweis").forEach(function (el) {
const p = bootstrap.Popover.getInstance(el);
if (p) p.dispose();
});
}
async function hole(was, extra = "") {
const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" });
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
@@ -115,7 +184,6 @@ const ZUSTANDSWORT = {
const TEILE = [
{ id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" },
{ id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" },
{ id: "teil-sunroof", feld: "sunroof", name: "Panoramadach", zu: "CLOSED" },
{ id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" },
{ id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" },
{ id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" }
@@ -201,6 +269,17 @@ function zeichneTeile(stand) {
/* -------------------------------------------------------------- Live-Block */
/*
* Der Live-Block ist die Fahrzeugkachel: die Draufsicht mit dem Ladestand in
* der Akkuplatte und die Kennzahlen daneben.
*
* Er arbeitet in drei Schritten. liveWerte() sammelt aus MQTT und Tabelle
* einen Satz fertiger Werte, jede zeichne*-Funktion zeigt genau einen Bereich
* davon, und zeichneLive() ruft sie der Reihe nach auf. Keine der
* Zeichenfunktionen holt sich selbst etwas - sie bekommen alles übergeben und
* bleiben dadurch einzeln prüfbar und für sich verwendbar.
*/
let livePlanung = null;
function planeLiveAnzeige() {
if (livePlanung) return; // höchstens fünfmal je Sekunde
@@ -209,108 +288,289 @@ function planeLiveAnzeige() {
let letzterStand = null; // letzte Zeile aus der Tabelle
function zeichneLive() {
/*
* Maße der Akkuplatte in Einheiten der viewBox. Sie stehen genau einmal hier
* und einmal im Markup (restricted/skoda.php); wer die Platte verschiebt,
* ändert beides.
*/
const AKKU = { oben: 108, hoehe: 270 };
/* Zustand der Platte. Die Klasse sitzt am <svg>, nicht an der Gruppe: der
Farbverlauf steht in <defs> und muss dieselbe Farbe erben. */
const AKKU_KLASSEN = ["akku-laedt", "akku-niedrig", "akku-unbekannt"];
/* Ladestand, unter dem die Platte rot wird. Reichweitenangst beginnt nicht
bei null, sondern dort, wo eine Fahrt zu planen ist. */
const AKKU_NIEDRIG = 15;
const LADEART = { AC: "Wechselstrom", DC: "Gleichstrom" };
const KLIMA = {
OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: ""
};
/* Zustände, in denen die Klimatisierung tatsächlich arbeitet - nur dann hebt
sich ihre Kachel hervor. */
const KLIMA_LAEUFT = ["COOLING", "HEATING", "HEATING_AUXILIARY", "VENTILATION"];
/* Das Zeichen der Klimakachel sagt, was gerade geschieht, statt nur "Klima" zu
bebildern: Schneeflocke fürs Kühlen, Sonne fürs Heizen, Wind fürs Lüften.
Der Lüfter steht für alles Übrige - aus, fertig, unbekannt. */
const KLIMA_ZEICHEN = {
COOLING: "bi-snow",
HEATING: "bi-thermometer-sun",
HEATING_AUXILIARY: "bi-fire",
VENTILATION: "bi-wind",
COMPLETED: "bi-check2-circle"
};
const KLIMA_ZEICHEN_SONST = "bi-fan";
const LADEZUSTAND = {
CHARGING: ["lädt", "text-bg-success"],
CONSERVING: ["hält Ladung", "text-bg-secondary"],
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
DISCHARGING: ["entlädt", "text-bg-warning"]
};
/* ------------------------------------------------------- kleine Werkzeuge */
/** Zahl aus einem Tabellenfeld; leer, null und "" ergeben null statt 0. */
function feldZahl(wert) {
if (wert === null || wert === undefined || wert === "") return null;
const z = Number(wert);
return Number.isFinite(z) ? z : null;
}
/** Text eines Elements setzen, sofern die Seite es überhaupt hat. */
function setzeText(id, text) {
const el = $(id);
if (el) el.textContent = text;
}
/** Eine Klasse an einem Element setzen oder entfernen. */
function schalteKlasse(id, klasse, an) {
const el = $(id);
if (el) el.classList.toggle(klasse, !!an);
}
/** Genau eine Klasse aus einer Gruppe setzen, die übrigen entfernen. */
function setzeAusGruppe(el, gruppe, aktiv) {
if (!el) return;
gruppe.forEach(k => el.classList.toggle(k, k === aktiv));
}
/** "bis 14:20 Uhr" - der Zeitpunkt hinter der gemeldeten Restladezeit. */
function fertigUm(minuten) {
const m = Number(minuten);
if (!Number.isFinite(m) || m <= 0) return "";
const ende = new Date(Date.now() + m * 60000);
return "bis " + ende.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + " Uhr";
}
/** y-Kante eines Ladestands auf der Platte: 0 % unten, 100 % oben. */
function akkuKante(prozent) {
const p = Math.max(0, Math.min(100, Number(prozent) || 0));
return AKKU.oben + AKKU.hoehe * (1 - p / 100);
}
/** Ein Rechteck vom Boden der Platte bis zum Ladestand aufziehen. */
function setzeFuellstand(el, prozent) {
if (!el) return;
const y = akkuKante(prozent);
el.setAttribute("y", y.toFixed(1));
el.setAttribute("height", (AKKU.oben + AKKU.hoehe - y).toFixed(1));
}
/* ------------------------------------------------------------- Sammelstelle */
/**
* Alles, was die Fahrzeugkachel zeigt, an einer Stelle zusammengetragen.
* MQTT hat überall Vorrang, die Tabelle ist der Rückfall - sie kann eine
* Stunde alt sein, MQTT ist von eben.
*/
function liveWerte() {
const stand = letzterStand;
// Ladestand: MQTT hat Vorrang, die Tabelle ist der Rückfall.
const soc = mqZahl("evSOC") ?? (stand ? Number(stand.soc) : null);
const ziel = stand && stand.target_soc ? Number(stand.target_soc) : null;
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
const laedt = zustand === "CHARGING";
// Ring: der Umfang ist 2*pi*r, der Rest ergibt sich aus dem Ladestand.
const ring = $("skodaRing");
if (ring) {
const umfang = 2 * Math.PI * 82;
ring.style.strokeDasharray = umfang;
ring.style.strokeDashoffset = umfang * (1 - (soc || 0) / 100);
ring.style.stroke = laedt ? "#4caf7d" : (soc !== null && soc < 15 ? "#e06060" : "#7aa7c7");
}
const socText = $("skodaSocText");
if (socText) socText.textContent = soc === null ? "" : nf(soc);
// Zielmarke auf dem Ring
const zielLinie = $("skodaZiel");
if (zielLinie) {
zielLinie.style.display = ziel ? "" : "none";
if (ziel) zielLinie.setAttribute("transform", "rotate(" + (ziel * 3.6) + " 100 100)");
}
$("skodaZielText").textContent = ziel ? nf(ziel) + " %" : "";
// Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter
// gemeint sein, ein Akku trägt keine 1500 Kilometer.
let km = mqZahl("evRange");
if (km === null && stand && stand.range_m !== null && stand.range_m !== undefined) {
const roh = Number(stand.range_m);
km = roh > 1500 ? roh / 1000 : roh;
if (km === null && stand) {
const roh = feldZahl(stand.range_m);
if (roh !== null) km = roh > 1500 ? roh / 1000 : roh;
}
$("skodaReichweite").textContent = km === null ? "" : nf(km) + " km";
// Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am
// Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert.
const wbKw = mqZahl("evPower");
const carKw = stand && stand.chg_kw !== null ? Number(stand.chg_kw) : null;
const kw = laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null);
$("skodaLeistung").textContent = kw ? nf(kw, 1) + " kW" : "";
const carKw = stand ? feldZahl(stand.chg_kw) : null;
const restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null);
$("skodaRestzeit").textContent = laedt ? dauer(restMin) : "";
const pvW = mqZahl("P_PV");
$("skodaKm").textContent = stand && stand.odo_km ? nf(stand.odo_km) + " km" : "";
const klima = stand ? stand.ac_state : null;
const KLIMA = { OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "" };
$("skodaKlima").textContent = klima ? (KLIMA[klima] || klima) : "";
// Ladezustand als Kennzeichen
const ZUSTAND = {
CHARGING: ["lädt", "text-bg-success"],
CONSERVING: ["hält Ladung", "text-bg-secondary"],
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
DISCHARGING: ["entlädt", "text-bg-warning"]
return {
zustand: zustand,
laedt: laedt,
soc: mqZahl("evSOC") ?? (stand ? feldZahl(stand.soc) : null),
ziel: stand ? feldZahl(stand.target_soc) : null,
km: km,
kw: laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null),
pvKw: pvW === null ? null : Math.max(0, pvW / 1000),
restMin: mqZahl("evRemChrgTime") ?? (stand ? feldZahl(stand.chg_rem_min) : null),
ladeart: stand ? stand.chg_type : null,
odo: stand ? feldZahl(stand.odo_km) : null,
klima: stand ? stand.ac_state : null,
// null heißt: das Fahrzeug meldet den Zustand des Schlosses selbst nicht.
verriegelt: (stand && stand.locked !== null && stand.locked !== undefined)
? Number(stand.locked) === 1 : null
};
const kz = $("skodaLadeZustand");
const eintrag = ZUSTAND[zustand] || [zustand || "", "text-bg-secondary"];
kz.textContent = eintrag[0];
kz.className = "badge " + eintrag[1];
}
/* --------------------------------------------------------- Zeichenschritte */
/**
* Die Akkuplatte im Fahrzeugboden - die Ladestandsanzeige der Seite.
*
* Gefüllt wird von hinten nach vorn, also im Bild von unten nach oben. Die
* Farbe kommt aus einer einzigen CSS-Variablen, die an der Zustandsklasse des
* <svg> hängt; hier wird nur die Klasse gewechselt.
*/
function zeichneAkku(w) {
const bild = $("skodaAuto");
if (!bild) return;
const bekannt = w.soc !== null && Number.isFinite(w.soc);
setzeAusGruppe(bild, AKKU_KLASSEN, !bekannt ? "akku-unbekannt"
: (w.laedt ? "akku-laedt" : (w.soc < AKKU_NIEDRIG ? "akku-niedrig" : null)));
const stand = bekannt ? w.soc : 0;
setzeFuellstand($("akkuFuellung"), stand);
setzeFuellstand($("akkuPegelFeld"), stand); // Schnittkante für die Strömung
// Die helle Kante sitzt auf der Füllung. Bei leerer Platte hat sie nichts
// zu begrenzen und bliebe sonst als Strich am Boden liegen.
const kante = $("akkuKante");
if (kante) {
kante.setAttribute("y", (akkuKante(stand) - 1.25).toFixed(1));
kante.style.display = (bekannt && stand > 0) ? "" : "none";
}
// Zielmarke. Gezeichnet ist sie auf 100 Prozent; verschoben wird die ganze
// Gruppe, damit Linie und Beschriftung beieinanderbleiben.
const marke = $("akkuZielMarke");
if (marke) {
marke.style.display = w.ziel ? "" : "none";
if (w.ziel) {
marke.style.transform = "translateY(" + (akkuKante(w.ziel) - AKKU.oben).toFixed(1) + "px)";
setzeText("akkuZielText", "Ziel " + nf(w.ziel) + " %");
}
}
setzeText("akkuText", bekannt ? nf(w.soc) : "");
setzeText("akkuNeben", w.km === null ? "" : nf(w.km) + " km");
// Der Titel der Gruppe ist Sprechblase und Vorlesetext zugleich - er nennt
// in Worten, was die Platte zeigt.
setzeText("akkuTitel", "Antriebsbatterie: "
+ (bekannt ? nf(w.soc) + " Prozent" : "Ladestand unbekannt")
+ (w.ziel ? ", Ladeziel " + nf(w.ziel) + " Prozent" : "")
+ (w.km === null ? "" : ", Reichweite " + nf(w.km) + " Kilometer")
+ (w.laedt ? ", lädt" : ""));
// Der Ladeanschluss pulst, der Weg zur Platte fließt - beides hängt an
// derselben Zustandsklasse wie die Füllung.
schalteKlasse("teil-plug", "laedt", w.laedt);
}
/**
* Der Klimaknopf im Fahrzeugbild. Sein Lüfterrad dreht sich, solange die
* Klimatisierung arbeitet - so zeigt der Knopf nicht nur, was er kann,
* sondern auch, ob es gerade geschieht.
*/
function zeichneKlimaknopf(w) {
schalteKlasse("skodaAuto", "klima-laeuft", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
}
/** Die Kacheln neben dem Fahrzeug: alles, was sich nicht zeichnen lässt. */
function zeichneKennzahlen(w) {
setzeText("skodaLeistung", w.kw ? nf(w.kw, 1) + " kW" : "");
setzeText("skodaLadeart", (w.laedt && w.ladeart && LADEART[w.ladeart]) || "");
schalteKlasse("skodaKachelLeistung", "aktiv", w.laedt && w.kw);
setzeText("skodaRestzeit", w.laedt ? dauer(w.restMin) : "");
setzeText("skodaFertig", w.laedt ? fertigUm(w.restMin) : "");
schalteKlasse("skodaKachelRestzeit", "aktiv", w.laedt && w.restMin > 0);
setzeText("skodaKm", w.odo ? nf(w.odo) + " km" : "");
setzeText("skodaKlima", w.klima ? (KLIMA[w.klima] || w.klima) : "");
schalteKlasse("skodaKachelKlima", "aktiv", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
const klimaZeichen = $("skodaKlimaZeichen");
if (klimaZeichen) {
klimaZeichen.className = "bi " + (KLIMA_ZEICHEN[w.klima] || KLIMA_ZEICHEN_SONST);
}
}
/** Die beiden Kennzeichen in der Kopfzeile: Schloss und Ladezustand. */
function zeichneAbzeichen(w) {
const zustand = $("skodaLadeZustand");
if (zustand) {
const eintrag = LADEZUSTAND[w.zustand] || [w.zustand || "", "text-bg-secondary"];
zustand.textContent = eintrag[0];
zustand.className = "badge " + eintrag[1];
}
// Verriegelung. null heißt: das Fahrzeug meldet UNKNOWN.
const schloss = $("skodaSchloss");
const zu = stand ? stand.locked : null;
if (zu === null || zu === undefined) {
if (!schloss) return;
if (w.verriegelt === null) {
schloss.textContent = "Schloss unbekannt";
schloss.className = "badge text-bg-secondary";
} else if (Number(zu) === 1) {
} else if (w.verriegelt) {
schloss.textContent = "verriegelt";
schloss.className = "badge text-bg-success";
} else {
schloss.textContent = "offen";
schloss.className = "badge text-bg-warning";
}
}
// Ladeanschluss pulst beim Laden
const stecker = $("teil-plug");
if (stecker) stecker.classList.toggle("laedt", laedt);
// Herkunft des Ladestroms: was die PV im Augenblick liefert, gegen das,
// was die Wallbox zieht. Mehr als die Ladeleistung kann die PV nicht
// beitragen, auch wenn gerade mehr Sonne da ist.
/**
* Herkunft des Ladestroms: was die Anlage im Augenblick liefert, gegen das,
* was die Wallbox zieht. Ohne Ladung hat der Balken nichts zu teilen und
* bleibt weg.
*/
function zeichneLadequelle(w) {
const box = $("skodaQuelleBox");
const pvW = mqZahl("P_PV");
if (laedt && kw && pvW !== null) {
const pvKw = Math.max(0, Math.min(kw, pvW / 1000));
const anteil = Math.round(100 * pvKw / kw);
$("skodaAnteilPV").style.width = anteil + "%";
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
$("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz";
box.style.display = "";
} else {
box.style.display = "none";
}
if (!box) return;
const zeigen = w.laedt && w.kw > 0 && w.pvKw !== null;
box.hidden = !zeigen;
if (!zeigen) return;
// Mehr als die Ladeleistung kann die Anlage nicht beitragen, auch wenn
// gerade mehr Sonne da ist - der Überschuss geht ins Haus oder ins Netz.
const ausPv = Math.max(0, Math.min(w.kw, w.pvKw));
const anteil = Math.round(100 * ausPv / w.kw);
$("skodaAnteilPV").style.width = anteil + "%";
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
setzeText("skodaQuelleText", nf(w.kw, 1) + " kW gesamt");
setzeText("skodaQuellePV", nf(ausPv, 1) + " kW");
setzeText("skodaQuelleNetz", nf(w.kw - ausPv, 1) + " kW");
}
/** Die Fahrzeugkachel neu zeichnen. */
function zeichneLive() {
const w = liveWerte();
zeichneAkku(w);
zeichneKlimaknopf(w);
zeichneKennzahlen(w);
zeichneAbzeichen(w);
zeichneLadequelle(w);
}
/* ------------------------------------------------------------- Diagramme */
@@ -691,7 +951,7 @@ function zeigeVerbrauchsbasis() {
if (!letzteKapazitaet) {
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
feld.textContent = "";
feld.removeAttribute("title");
setzeHinweis(feld, null);
if (schalter) schalter.style.display = "none";
setzeEinheit("prozent", false);
return;
@@ -703,7 +963,7 @@ function zeigeVerbrauchsbasis() {
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
// Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten.
feld.title = "Aus " + k.n + " Ladungen des letzten Jahres: "
setzeHinweis(feld, "Aus " + k.n + " Ladungen des letzten Jahres: "
+ nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, "
+ nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad "
+ nf(k.wirkungsgrad * 100, 1) + " %, "
@@ -711,7 +971,7 @@ function zeigeVerbrauchsbasis() {
? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem "
+ "Ladegerät, das Fahrzeug meldet dahinter."
: "angenommen, weil noch zu wenige Ladungen die Ladeleistung des "
+ "Fahrzeugs mitgeliefert haben.");
+ "Fahrzeugs mitgeliefert haben."));
}
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
@@ -757,6 +1017,47 @@ async function ladeLive() {
}
}
/*
* Fahrleistung. Die Namen der Zeiträume stehen hier, ihre Länge in Tagen
* kommt vom Server mit - so muss nur eine Seite geändert werden, wenn ein
* Zeitraum hinzukommt.
*/
const STRECKE_ZEITRAEUME = { woche: "7 Tage", monat: "30 Tage", jahr: "12 Monate" };
async function ladeStrecke() {
const feld = $("skodaFahrleistung");
if (!feld) return;
loeseHinweise(feld);
try {
const d = await hole("strecke");
feld.innerHTML = Object.keys(STRECKE_ZEITRAEUME).map(function (name) {
const p = d.strecke ? d.strecke[name] : null;
const km = (p && p.km !== null && p.km !== undefined) ? nf(p.km) + " km" : "";
/*
* Reicht die Aufzeichnung nicht über den ganzen Zeitraum, ist die Zahl
* kein Jahreswert, sondern nur das, was aufgezeichnet ist. Sie deshalb
* wegzulassen wäre falsch - sie kommentarlos als Jahr auszugeben aber
* auch. Also gestrichelt unterstrichen, mit dem Anfang im Titel.
*/
const knapp = p && p.km !== null && !p.vollstaendig;
return '<div class="posten' + (knapp ? " knapp" : "") + '">'
+ '<span class="v">' + km + '</span>'
+ '<span class="k">' + STRECKE_ZEITRAEUME[name] + "</span></div>";
}).join("");
// Die knappen Zeiträume tragen ihre Erklärung selbst - antippbar.
feld.querySelectorAll(".posten.knapp").forEach(function (posten) {
setzeHinweis(posten, "Aufgezeichnet wird erst seit " + zeitpunkt(d.seit, true)
+ ". Der Wert deckt deshalb nicht den ganzen Zeitraum ab, sondern nur "
+ "das, was seither aufgezeichnet ist.");
});
} catch (e) {
feld.innerHTML = '<div class="posten"><span class="v"></span>'
+ '<span class="k">nicht abrufbar</span></div>';
}
}
async function ladeLadungen() {
try {
const d = await hole("ladungen", "&tage=180");
@@ -1137,6 +1438,7 @@ function verdrahteBefehle(bereich) {
function alleLaden() {
ladeLive();
ladeStrecke();
ladeLadungen();
ladeKurve();
ladeGesundheit();
@@ -1161,7 +1463,9 @@ document.addEventListener("readystatechange", function () {
// Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters:
// beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt.
setInterval(ladeLive, 60000);
setInterval(function () { ladeLadungen(); ladeKurve(); ladeFahrten(); }, 300000);
setInterval(function () {
ladeLadungen(); ladeKurve(); ladeFahrten(); ladeStrecke();
}, 300000);
setInterval(ladeGesundheit, 900000);
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass