Files
Smart-Dashboard/js/solar/skodaMQTT.js
T
adminandClaude Opus 5 0cb0d4c3ae 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>
2026-09-08 20:08:51 +02:00

1479 lines
55 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.
/*
* Škoda-Seite.
*
* Zwei Quellen, bewusst getrennt:
*
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand, Restladezeit und
* die Wallbox-Leistung kommen laufend über solarManager/# herein und
* aktualisieren die Anzeige sofort.
*
* Ajax Alles, was nur in der Tabelle skoda steht - Türen, Fenster, Licht,
* Klappen, Kilometerstand, Position, Klima, Ladeziel - sowie sämtliche
* Auswertungen. Beim Laden einmal, danach minütlich.
*
* Das Fahrzeug meldet Türen und Licht nur, wenn es sich meldet; zwischen zwei
* 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 = {};
const solarMQTT = {
getMQTT: function () {
const connection = "wss://mqtt.nas.el-wa.org:443";
const client = mqtt.connect(connection, { rejectUnauthorized: false });
client.on("message", function (topic, message) {
mqttData[topic] = message;
if (topic.startsWith("solarManager/ev") || topic === "solarManager/P_PV"
|| topic === "solarManager/P_Grid") {
planeLiveAnzeige();
}
});
client.on("connect", function () { client.subscribe("solarManager/#"); });
client.on("error", function () { /* stille Wiederverbindung durch mqtt.js */ });
}
};
/* ------------------------------------------------------------------ Hilfen */
const $ = (id) => document.getElementById(id);
/** MQTT-Nutzlast als Zahl. Fehlt das Thema noch, kommt null. */
function mqZahl(name) {
const v = mqttData["solarManager/" + name];
if (v === undefined || v === null) return null;
const z = Number(String(v));
return Number.isFinite(z) ? z : null;
}
/** MQTT-Nutzlast als Text. */
function mqText(name) {
const v = mqttData["solarManager/" + name];
return (v === undefined || v === null) ? null : String(v);
}
const nf = (n, k = 0) => (n === null || n === undefined || !Number.isFinite(Number(n)))
? "" : Number(n).toLocaleString("de-DE", { minimumFractionDigits: k, maximumFractionDigits: k });
/** Sekunden als "vor 3 min" / "vor 2 h 15 min" / "vor 4 Tagen". */
function seit(sekunden) {
if (sekunden === null || sekunden === undefined) return "";
const s = Math.max(0, Math.round(sekunden));
if (s < 90) return "vor " + s + " s";
const m = Math.round(s / 60);
if (m < 90) return "vor " + m + " min";
const h = Math.floor(s / 3600);
if (h < 36) return "vor " + h + " h " + Math.round((s - h * 3600) / 60) + " min";
return "vor " + Math.round(s / 86400) + " Tagen";
}
function zeitpunkt(iso, mitJahr = false) {
if (!iso) return "";
const d = new Date(String(iso).replace(" ", "T"));
if (isNaN(d)) return "";
return d.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: mitJahr ? "numeric" : undefined,
hour: "2-digit", minute: "2-digit"
});
}
/** Minuten als "1 h 35 min". */
function dauer(minuten) {
const m = Math.round(Number(minuten));
if (!Number.isFinite(m) || m <= 0) return "";
if (m < 60) return m + " min";
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);
return antwort.json();
}
function leer(text) {
return '<div class="leer">' + text + "</div>";
}
/* ------------------------------------------------------- Zustand des Autos */
/*
* Die Zustandswörter der API in Anzeige übersetzen. UNKNOWN ist ausdrücklich
* kein Nein: das Fahrzeug sagt dann, dass es den Zustand selbst nicht kennt,
* und die Anzeige darf daraus kein beruhigendes "geschlossen" machen.
*/
const OFFEN_WERTE = ["OPEN", "OPENED", "TRUNK_OPENED", "NO", "UNLOCKED"];
const ZUSTANDSWORT = {
OPEN: "offen", OPENED: "offen", TRUNK_OPENED: "Kofferraum offen",
CLOSED: "zu", LOCKED: "verriegelt", UNLOCKED: "entriegelt",
ON: "an", OFF: "aus", YES: "ja", NO: "nein",
UNKNOWN: "unbekannt", UNSUPPORTED: "nicht vorhanden"
};
const TEILE = [
{ id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" },
{ id: "teil-windows", feld: "windows", name: "Fenster", 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" }
];
/**
* Die Teile in der Fahrzeuggrafik mit ihren Einträgen koppeln - dieselbe
* Wirkung wie umgekehrt, nur von der Grafik aus angestossen. Die Gruppen
* stehen fest im Markup, also genügt einmaliges Verdrahten beim Seitenaufbau.
*/
function koppleFahrzeugTeile() {
document.querySelectorAll("#skodaAuto .teil[id]").forEach(function (knoten) {
const id = knoten.id;
knoten.addEventListener("pointerenter", function (e) {
if (e.pointerType === "mouse") hervorheben(id, true);
});
knoten.addEventListener("pointerleave", function (e) {
if (e.pointerType === "mouse") hervorheben(id, false);
});
knoten.addEventListener("click", () => hervorheben(id, "um"));
knoten.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hervorheben(id, "um"); }
});
});
}
/**
* Ein Fahrzeugteil und seinen Eintrag gemeinsam hervorheben.
*
* @param an true / false, oder "um" zum Umschalten (Tippen)
*/
function hervorheben(id, an) {
const knoten = $(id);
const chip = document.querySelector('.skoda-teile .chip[data-teil="' + id + '"]');
const soll = (an === "um") ? !(knoten && knoten.classList.contains("hervor")) : an;
if (knoten) knoten.classList.toggle("hervor", soll);
if (chip) chip.classList.toggle("hervor", soll);
}
function zeichneTeile(stand) {
const leiste = $("skodaTeile");
if (!leiste) return;
leiste.innerHTML = "";
TEILE.forEach(function (t) {
const wert = stand ? stand[t.feld] : null;
const knoten = $(t.id);
const anzeige = (wert === null || wert === undefined || wert === "")
? "unbekannt"
: (ZUSTANDSWORT[wert] || String(wert).toLowerCase());
const licht = t.feld === "lights" && wert === "ON";
const offen = !licht && wert && OFFEN_WERTE.indexOf(wert) >= 0;
if (knoten) {
knoten.classList.toggle("offen", !!offen);
knoten.classList.toggle("an", !!licht);
}
const chip = document.createElement("span");
chip.className = "chip" + (offen ? " offen" : "") + (licht ? " an" : "");
chip.textContent = t.name + ": " + anzeige;
chip.dataset.teil = t.id;
/*
* Eintrag und Fahrzeugteil zeigen aufeinander, damit auch ohne
* Farbunterscheidung klar ist, was gemeint ist.
*
* Mit Maus genügt das Überfahren. Auf einem Berührungsgerät gibt es das
* nicht - dort bleibt ein :hover nach dem Antippen sogar hängen. Deshalb
* schaltet ein Tippen die Hervorhebung um, und zwar auf beiden Seiten.
*/
chip.addEventListener("pointerenter", function (e) {
if (e.pointerType === "mouse") hervorheben(t.id, true);
});
chip.addEventListener("pointerleave", function (e) {
if (e.pointerType === "mouse") hervorheben(t.id, false);
});
chip.addEventListener("click", () => hervorheben(t.id, "um"));
leiste.appendChild(chip);
});
}
/* -------------------------------------------------------------- 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
livePlanung = setTimeout(function () { livePlanung = null; zeichneLive(); }, 200);
}
let letzterStand = null; // letzte Zeile aus der Tabelle
/*
* 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;
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
const laedt = zustand === "CHARGING";
// 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) {
const roh = feldZahl(stand.range_m);
if (roh !== null) km = roh > 1500 ? roh / 1000 : roh;
}
// 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 ? feldZahl(stand.chg_kw) : null;
const pvW = mqZahl("P_PV");
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
};
}
/* --------------------------------------------------------- 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];
}
const schloss = $("skodaSchloss");
if (!schloss) return;
if (w.verriegelt === null) {
schloss.textContent = "Schloss unbekannt";
schloss.className = "badge text-bg-secondary";
} else if (w.verriegelt) {
schloss.textContent = "verriegelt";
schloss.className = "badge text-bg-success";
} else {
schloss.textContent = "offen";
schloss.className = "badge text-bg-warning";
}
}
/**
* 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");
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 */
let kurveChart = null;
let gesundChart = null;
const achsenFarbe = () => getComputedStyle(document.body).getPropertyValue("--bs-secondary-color") || "#9aa0a6";
const netzFarbe = "rgba(128,138,148,0.18)";
function baueKurve() {
kurveChart = new Chart($("skodaKurve"), {
type: "line",
data: { datasets: [] },
options: {
maintainAspectRatio: false,
animation: false,
interaction: { mode: "index", intersect: false },
scales: {
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
// braeuchte einen Datums-Adapter, den es hier nicht gibt.
x: { type: "timestack", adapters: { date: { locale: "DE-de" } },
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
y: { title: { display: true, text: "kW", color: achsenFarbe() },
beginAtZero: true, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
y1: { position: "right", min: 0, max: 100,
title: { display: true, text: "Ladestand %", color: achsenFarbe() },
grid: { drawOnChartArea: false }, ticks: { color: achsenFarbe() } }
},
plugins: { legend: { labels: { color: achsenFarbe() } } }
}
});
}
function baueGesundheit() {
gesundChart = new Chart($("skodaGesundheit"), {
type: "scatter",
data: { datasets: [] },
options: {
maintainAspectRatio: false,
animation: false,
scales: {
x: { type: "timestack", adapters: { date: { locale: "DE-de" } },
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
y: { title: { display: true, text: "kWh / 100 %", color: achsenFarbe() },
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }
},
plugins: {
legend: { labels: { color: achsenFarbe() } },
tooltip: {
callbacks: {
label: (c) => {
const p = c.raw;
if (p.soc_von === undefined) return c.dataset.label + ": " + nf(c.parsed.y, 1);
return nf(c.parsed.y, 1) + " kWh/100 % (" + p.soc_von + " → " + p.soc_bis
+ " %, " + nf(p.wh / 1000, 2) + " kWh)";
}
}
}
}
}
});
}
/* ------------------------------------------------------------------ Karte */
/*
* Leaflet-Karte mit den Kacheln aus dem eigenen Zwischenspeicher.
*
* Die Kachel-URL zeigt bewusst auf ajax/tile.php und nicht unmittelbar auf
* tile.openstreetmap.org: so verlässt die Parkposition - also die
* Wohnadresse - das Haus nur beim allerersten Betrachten einer Gegend, und
* einmal geholte Kacheln bleiben auch ohne Internet verfügbar. Warum das so
* gebaut ist und was die Kachelrichtlinie verlangt, steht in tile.php.
*
* Die Fahrtlinien sind gestrichelt. Die API liefert nur die letzte bekannte
* PARKposition, also Start und Ziel einer Fahrt; die gefahrene Strecke kennt
* niemand. Eine durchgezogene Linie auf einer Straßenkarte würde behaupten,
* das Auto sei genau dort langgefahren.
*/
const KARTE_ZOOM_MIN = 6; // muss zu ZOOM_MIN/ZOOM_MAX in tile.php passen
const KARTE_ZOOM_MAX = 18;
let karte = null; // die Leaflet-Karte, einmal angelegt
let karteEbene = null; // Marker und Linien, wird bei jeder Auffrischung ersetzt
let karteAusgerichtet = false; // beim ersten Mal auf die Daten zoomen, danach nicht mehr
/*
* Verknüpfung zwischen der Fahrtenliste und der Karte: Nummer der Fahrt im
* Feld letzteFahrten -> ihre Linie samt der beiden Stilvarianten. Wird bei
* jedem Füllen der Karte neu aufgebaut, weil die Linien dabei verworfen
* werden.
*/
let fahrtLinien = {};
/** Nummer der gerade gewählten Fahrt, oder null. */
let gewaehlteFahrt = null;
/*
* Die drei Zustände einer Fahrtlinie.
*
* Blau kennzeichnet die jüngste Fahrt, damit man sie ohne Suchen findet.
* Bernstein ist die Auswahl und muss das Blau überstimmen können - sonst
* bliebe unklar, welche Linie zur angeklickten Zeile gehört, sobald die
* jüngste gewählt ist. Bernstein steht auf dieser Seite ohnehin schon für
* "sieh hier hin": in derselben Farbe leuchten offene Türen am Fahrzeug.
*/
const FAHRT_STIL = {
normal: { color: "#8a949c", weight: 2, opacity: 0.55 },
juengste: { color: "#4b8fd6", weight: 3, opacity: 0.95 },
gewaehlt: { color: "#e0904f", weight: 5, opacity: 1 }
};
/**
* Den Stil einer Linie setzen. Die Auswahl gewinnt vor dem Überfahren, das
* Überfahren vor dem Grundzustand.
*
* Ohne diese eine Stelle für alle drei Zustände würde ein Mausklick auf eine
* gewählte Linie sie beim Verlassen wieder zurücksetzen - der Behandler für
* mouseout weiss sonst nichts von der Auswahl.
*/
function stilAnwenden(nummer, ueberfahren) {
const eintrag = fahrtLinien[nummer];
if (!eintrag) return;
if (nummer === gewaehlteFahrt) {
eintrag.linie.setStyle(FAHRT_STIL.gewaehlt).bringToFront();
} else if (ueberfahren) {
eintrag.linie.setStyle(Object.assign({}, eintrag.grund,
{ weight: eintrag.grund.weight + 2, opacity: 1 }));
} else {
eintrag.linie.setStyle(eintrag.grund);
}
}
/**
* Die Karte anlegen. Wird einmal beim Seitenaufbau gerufen; danach kümmert
* sich karteFuellen() um den Inhalt.
*/
function karteAufbauen() {
const feld = document.getElementById("skodaKarte");
if (!feld || typeof L === "undefined") return;
karte = L.map(feld, {
minZoom: KARTE_ZOOM_MIN,
maxZoom: KARTE_ZOOM_MAX,
zoomControl: true,
// Das Mausrad gehört auf dieser langen Seite dem Scrollen. Zoomen geht
// mit den Schaltflächen, per Doppelklick oder mit Strg und Mausrad.
scrollWheelZoom: false
}).setView([51.0, 10.0], 6);
// Leaflets Vorspann enthält von Haus aus eine Flaggengrafik. Die Nennung
// der Bibliothek bleibt, das Bild kommt weg.
karte.attributionControl.setPrefix(
'<a href="https://leafletjs.com" target="_blank" rel="noopener noreferrer">Leaflet</a>');
L.tileLayer("ajax/tile.php?z={z}&x={x}&y={y}", {
minZoom: KARTE_ZOOM_MIN,
maxZoom: KARTE_ZOOM_MAX,
// Die Richtlinie verlangt sichtbare Nennung - nicht verstecken.
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" '
+ 'target="_blank" rel="noopener noreferrer">OpenStreetMap</a>-Mitwirkende'
}).addTo(karte);
karteEbene = L.layerGroup().addTo(karte);
const heim = document.getElementById("skodaKarteHeim");
if (heim) heim.addEventListener("click", karteAufStandort);
// Beim Anlegen steht das Kartenfeld noch im Seitenaufbau. Leaflet misst
// dann eine falsche Größe, lässt rechts einen leeren Streifen stehen und
// wählt beim Ausrichten eine zu grobe Zoomstufe. Ein Nachmessen, sobald das
// Layout steht, räumt beides aus.
setTimeout(() => karte && karte.invalidateSize(), 0);
}
/** Karte auf den aktuellen Standort zentrieren. */
function karteAufStandort() {
if (karte && letzterOrt && letzterOrt.lat) {
karte.setView([+letzterOrt.lat, +letzterOrt.lon], 15);
}
}
/**
* Marker und Linien neu setzen.
*
* Ausgerichtet wird nur beim ersten Mal. Sonst risse die minütliche
* Auffrischung dem Betrachter die Karte unter der Hand weg, sobald er
* irgendwo hingezoomt hat.
*/
function karteFuellen(fahrten, standort) {
if (!karte || !karteEbene) return;
karteEbene.clearLayers();
const punkte = [];
fahrtLinien = {};
// Fahrten von alt nach neu, damit die jüngste obenauf liegt. Die Nummer im
// Ursprungsfeld wird mitgeführt, damit die Fahrtenliste daneben ihre Zeile
// der passenden Linie zuordnen kann.
fahrten.slice().reverse().forEach(function (f, i, alle) {
if (!f.von_lat || !f.lat) return;
const nummer = fahrten.length - 1 - i;
const von = [+f.von_lat, +f.von_lon];
const nach = [+f.lat, +f.lon];
const jung = (i === alle.length - 1);
punkte.push(von, nach);
const grund = jung ? FAHRT_STIL.juengste : FAHRT_STIL.normal;
const linie = L.polyline([von, nach], Object.assign({
dashArray: "6 6" // gestrichelt: Luftlinie, keine Route
}, grund)).bindPopup(
"<b>" + textSicher(f.von || "?") + " &rarr; " + textSicher(f.nach || "?") + "</b><br>"
+ nf(f.km) + " km &middot; " + zeitpunkt(f.ankunft, true)
);
linie.on("mouseover", () => stilAnwenden(nummer, true));
linie.on("mouseout", () => stilAnwenden(nummer, false));
linie.on("click", () => waehleFahrt(nummer, false));
karteEbene.addLayer(linie);
fahrtLinien[nummer] = { linie: linie, grund: grund, ecken: [von, nach] };
});
// Eine zuvor gewählte Fahrt nach dem Neuaufbau wieder hervorheben.
if (gewaehlteFahrt !== null) {
stilAnwenden(gewaehlteFahrt, false);
}
// Halte
fahrten.forEach(function (f) {
if (!f.lat) return;
karteEbene.addLayer(L.circleMarker([+f.lat, +f.lon], {
radius: 5, color: "#161a1d", weight: 1.5,
fillColor: "#8a949c", fillOpacity: 0.9
}).bindPopup(
"<b>" + textSicher(f.nach || "Halt") + "</b><br>"
+ zeitpunkt(f.ankunft, true) + "<br>"
+ nf(f.km) + " km gefahren"
));
});
// Aktueller Standort: heller Punkt mit Hof, damit er sich von den Halten
// abhebt, auch wenn er auf einem davon liegt.
if (standort && standort.lat) {
const hier = [+standort.lat, +standort.lon];
punkte.push(hier);
karteEbene.addLayer(L.circleMarker(hier, {
radius: 14, color: "#4caf7d", weight: 2, opacity: 0.45, fill: false
}));
karteEbene.addLayer(L.circleMarker(hier, {
radius: 7, color: "#161a1d", weight: 2,
fillColor: "#4caf7d", fillOpacity: 1
}).bindPopup(
"<b>" + textSicher(standort.address || "Aktuelle Position") + "</b><br>"
+ (standort.park_state === "IN_MOTION"
? "unterwegs &ndash; letzte bekannte Parkposition"
: "geparkt seit " + zeitpunkt(standort.datetime, true))
));
}
if (!karteAusgerichtet && punkte.length) {
karteAusgerichtet = true;
// Vor dem Ausrichten nachmessen: die Zoomstufe folgt aus der
// Containergröße, und die stimmt erst, wenn das Layout steht.
karte.invalidateSize();
// animate: false ist hier kein Schönheitsfehler, sondern Absicht. Eine
// Zoomfahrt von der Startansicht bis zum Ziel fordert jede Zwischenstufe
// an Kacheln an - bei einem Sprung über mehrere Stufen also ein Vielfaches
// dessen, was am Ende zu sehen ist. Alles davon ginge einmal nach draußen.
if (punkte.length === 1) {
karte.setView(punkte[0], 15, { animate: false });
} else {
// maxZoom, damit eine einzelne eng beieinanderliegende Gruppe nicht
// bis auf Hausnummernebene aufgezogen wird.
karte.fitBounds(L.latLngBounds(punkte), {
padding: [24, 24], maxZoom: 14, animate: false
});
}
}
}
/**
* Eine Fahrt aus der Liste auf der Karte zeigen: Ausschnitt auf ihre beiden
* Enden, Linie hervorheben, Sprechblase öffnen.
*
* Ohne animate: false zöge die Karte über jede Zwischenstufe hinweg und
* fordert dabei deren Kacheln an - siehe die Begründung bei karteFuellen().
*/
/**
* Eine Fahrt auswählen - der einzige Weg dorthin, gleich ob der Klick in der
* Liste oder auf der Karte kam. Beide Seiten werden hier zusammen gesetzt,
* sonst laufen Zeilenmarkierung und Linienfarbe auseinander.
*
* @param nummer Nummer der Fahrt in letzteFahrten
* @param hinzoomen Ausschnitt auf die Fahrt legen. Beim Klick auf der Karte
* unerwünscht: dort sieht man sie ja schon, und der Sprung
* risse einem den selbst gewählten Ausschnitt weg.
*/
function waehleFahrt(nummer, hinzoomen) {
const eintrag = fahrtLinien[nummer];
if (!karte || !eintrag) return;
const vorher = gewaehlteFahrt;
gewaehlteFahrt = nummer;
if (vorher !== null && vorher !== nummer) {
stilAnwenden(vorher, false); // alte Auswahl zurücksetzen
}
stilAnwenden(nummer, false);
if (hinzoomen) {
karte.fitBounds(L.latLngBounds(eintrag.ecken), {
padding: [40, 40], maxZoom: 13, animate: false
});
}
eintrag.linie.openPopup();
markiereFahrtZeile(nummer);
}
/* Vom Betrachter gewählte Einheit: "kwh" oder "prozent". */
const EINHEIT_SPEICHER = "skodaVerbrauchEinheit";
/**
* Einheit umschalten. Sichtbar wird die Auswahl über eine Klasse auf der
* Kachel; die Zeilen selbst bleiben unberührt und überstehen damit die
* minütliche Auffrischung der Liste.
*
* Die Wahl liegt im Browser des Betrachters, nicht auf dem Server - sie geht
* niemanden sonst etwas an. Lesen und Schreiben stehen in try/catch, weil
* der Zugriff in privaten Fenstern und bei gesperrten Seitendaten wirft.
*/
function setzeEinheit(einheit, merken) {
const kachel = $("skodaFahrtenKachel");
if (!kachel) return;
const kwh = einheit !== "prozent";
kachel.classList.toggle("zeigt-kwh", kwh);
kachel.classList.toggle("zeigt-prozent", !kwh);
const schalter = $("skodaEinheit");
if (schalter) {
schalter.querySelectorAll("button").forEach(function (b) {
const aktiv = (b.dataset.einheit === "kwh") === kwh;
b.classList.toggle("aktiv", aktiv);
b.setAttribute("aria-pressed", aktiv ? "true" : "false");
});
}
if (merken) {
try { localStorage.setItem(EINHEIT_SPEICHER, kwh ? "kwh" : "prozent"); } catch (e) { }
}
}
/** Umschalter verdrahten und die zuletzt gewählte Einheit wiederherstellen. */
function koppleEinheitSchalter() {
const schalter = $("skodaEinheit");
if (!schalter) return;
schalter.addEventListener("click", function (e) {
const knopf = e.target.closest("button[data-einheit]");
if (knopf) setzeEinheit(knopf.dataset.einheit, true);
});
let gemerkt = null;
try { gemerkt = localStorage.getItem(EINHEIT_SPEICHER); } catch (e) { }
setzeEinheit(gemerkt || "kwh", false);
}
/**
* In der Überschrift der Fahrtenliste offenlegen, worauf die
* Kilowattstunden beruhen. Ohne das steht dort eine Zahl, deren Herkunft
* niemand mehr nachvollziehen kann - sie ist ja nicht gemessen, sondern aus
* dem Ladestand und der eigenen Ladehistorie gerechnet.
*/
function zeigeVerbrauchsbasis() {
const feld = $("skodaVerbrauchBasis");
if (!feld) return;
const schalter = $("skodaEinheit");
if (!letzteKapazitaet) {
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
feld.textContent = "";
setzeHinweis(feld, null);
if (schalter) schalter.style.display = "none";
setzeEinheit("prozent", false);
return;
}
if (schalter) schalter.style.display = "";
const k = letzteKapazitaet;
feld.textContent = nf(k.je_soc_netto * 1000, 0) + " Wh je Prozent";
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
// Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten.
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) + " %, "
+ (k.gemessen
? "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."));
}
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
function markiereFahrtZeile(nummer) {
const liste = $("skodaFahrten");
if (!liste) return;
liste.querySelectorAll(".zeile.gewaehlt").forEach(z => z.classList.remove("gewaehlt"));
const zeile = liste.querySelector('.zeile[data-fahrt="' + nummer + '"]');
if (zeile) {
zeile.classList.add("gewaehlt");
zeile.scrollIntoView({ block: "nearest" }); // bei Klick auf der Karte
}
}
/**
* Text für eine Sprechblase entschärfen. Adressen kommen von einem fremden
* Dienst; ohne das ginge fremdes Markup ungefiltert ins Dokument.
*/
function textSicher(s) {
const knoten = document.createElement("div");
knoten.textContent = String(s);
return knoten.innerHTML;
}
/* ------------------------------------------------------------- Abschnitte */
async function ladeLive() {
try {
const d = await hole("live");
letzterStand = d.stand;
zeichneTeile(d.stand);
zeichneLive();
const alterText = $("skodaAlter");
if (!d.stand) {
alterText.textContent = "Noch kein Fahrzeug angebunden";
} else {
alterText.textContent = "Stand des Fahrzeugs: " + seit(d.alter_s)
+ " (" + zeitpunkt(d.stand.datetime) + ")";
}
} catch (e) {
$("skodaAlter").textContent = "Daten nicht abrufbar";
}
}
/*
* 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");
const liste = $("skodaLadungen");
if (!d.ladungen.length) { liste.innerHTML = leer("Noch keine Ladung aufgezeichnet."); return; }
liste.innerHTML = d.ladungen.map(function (l) {
const kwh = l.wh > 0 ? nf(l.wh / 1000, 1) + " kWh" : "auswärts";
return '<div class="zeile"><span class="wann">' + zeitpunkt(l.beginn)
+ '<span class="neben">' + l.soc_von + " → " + l.soc_bis + " % · "
+ (l.art || "") + "</span></span>"
+ '<span class="was">' + kwh + '<span class="neben">'
+ (l.kw_spitze ? nf(l.kw_spitze, 1) + " kW" : "") + "</span></span></div>";
}).join("");
} catch (e) {
$("skodaLadungen").innerHTML = leer("Ladungen nicht abrufbar.");
}
}
async function ladeKurve() {
try {
const d = await hole("kurve");
if (!d.punkte || !d.punkte.length) {
$("skodaKurveZeit").textContent = "noch keine Ladung";
kurveChart.data.datasets = [];
kurveChart.update();
return;
}
$("skodaKurveZeit").textContent = zeitpunkt(d.von) + " " + zeitpunkt(d.bis);
// Die Zeitachse 'timestack' rechnet mit Millisekunden, nicht mit
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
// stattdessen den heutigen Tag.
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
kurveChart.data.datasets = [
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
data: d.punkte.map(p => ({ x: zeit(p), y: p.wb_kw === null ? null : +p.wb_kw })) },
{ label: "Fahrzeug", yAxisID: "y", borderColor: "#7aa7c7", borderWidth: 2,
pointRadius: 0, borderDash: [4, 3], tension: .25,
data: d.punkte.map(p => ({ x: zeit(p), y: p.chg_kw === null ? null : +p.chg_kw })) },
{ label: "Ladestand", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
pointRadius: 0, tension: .1,
data: d.punkte.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) }
];
kurveChart.update();
} catch (e) {
$("skodaKurveZeit").textContent = "nicht abrufbar";
}
}
async function ladeGesundheit() {
try {
const d = await hole("gesundheit", "&tage=3650");
const befund = $("skodaBefund");
if (!d.ladungen.length) {
gesundChart.data.datasets = [];
gesundChart.update();
befund.innerHTML = '<p class="text-secondary mb-0">Noch keine auswertbare Ladung. '
+ 'Gezählt werden Ladungen an der eigenen Wallbox mit mindestens 20 Prozentpunkten Zuwachs.</p>';
return;
}
const punkte = d.ladungen.map(l => ({
x: new Date(String(l.beginn).replace(" ", "T")).getTime(), y: +l.je100,
soc_von: +l.soc_von, soc_bis: +l.soc_bis, wh: +l.wh
}));
const saetze = [{ label: "Ladung", data: punkte, backgroundColor: "#4caf7d", pointRadius: 4 }];
// Ausgleichsgerade, wenn genug Punkte da sind
if (d.trend) {
const t0 = punkte[0].x;
const t1 = punkte[punkte.length - 1].x;
const JAHR = 365.25 * 864e5;
saetze.push({
label: "Trend", type: "line", borderColor: "#9aa0a6", borderWidth: 1.5,
borderDash: [5, 4], pointRadius: 0, fill: false,
data: [{ x: t0, y: d.trend.achse },
{ x: t1, y: d.trend.achse + d.trend.steigung * (t1 - t0) / JAHR }]
});
}
gesundChart.data.datasets = saetze;
gesundChart.update();
// Befund in Worten
const t = d.trend;
let html = "";
if (t && t.prozent !== null) {
const eindeutig = Math.abs(t.steigung) > 2 * t.fehler;
html += '<div class="zahl">' + (t.prozent > 0 ? "+" : "") + nf(Math.abs(t.prozent), 2)
+ '<span class="einheit">% pro Jahr</span></div>';
html += '<p class="mb-0 mt-2 ' + (eindeutig ? "text-warning" : "text-secondary") + '">'
+ (eindeutig
? "Der Wert wandert messbar."
: "Von null nicht zu unterscheiden — keine Alterung nachweisbar.")
+ "</p>";
html += "<dl>"
+ "<div><dt>Nachweisbar wäre ab</dt><dd>" + nf(t.nachweisbar, 1) + " % im Jahr</dd></div>"
+ "<div><dt>Beobachtungszeitraum</dt><dd>" + nf(t.spanne_jahre, 1) + " Jahre</dd></div>";
} else {
html += '<p class="text-secondary mb-0">Für einen Trend sind es noch zu wenige Ladungen.</p><dl>';
}
const j = d.jahre[d.jahre.length - 1];
if (j) {
html += "<div><dt>Median " + j.jahr + "</dt><dd>" + nf(j.median, 1) + " kWh/100 %</dd></div>";
}
html += "<div><dt>Ausgewertete Ladungen</dt><dd>" + d.ladungen.length + "</dd></div></dl>";
befund.innerHTML = html;
} catch (e) {
$("skodaBefund").innerHTML = '<p class="text-secondary mb-0">Auswertung nicht abrufbar.</p>';
}
}
let letzteFahrten = [];
let letzterOrt = null;
/*
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist, aus den eigenen
* Ladungen bestimmt und um die Ladeverluste bereinigt - siehe
* kapazitaetSchaetzen() in ajax/skoda.php. Bleibt null, solange es zu wenige
* Ladungen gibt; dann zeigt die Fahrtenliste nur Prozent.
*/
let letzteKapazitaet = null;
async function ladeFahrten() {
try {
const d = await hole("fahrten", "&tage=180");
letzteFahrten = d.fahrten || [];
letzterOrt = d.standort || null;
letzteKapazitaet = d.kapazitaet || null;
zeigeVerbrauchsbasis();
const liste = $("skodaFahrten");
if (!letzteFahrten.length) {
liste.innerHTML = leer("Noch keine Fahrt aufgezeichnet.");
} else {
// data-fahrt trägt die Nummer im Feld letzteFahrten. Darüber findet der
// Klick weiter unten die passende Linie auf der Karte.
liste.innerHTML = letzteFahrten.map(function (f, nummer) {
// Der Ladestandsverbrauch stimmt nur, wenn zwischendurch nicht
// geladen wurde - sonst ist er zu klein oder negativ.
const proz = Number(f.soc_ab);
const brauchbar = proz > 0 && f.km > 0;
const jeProzent = brauchbar ? proz / f.km * 100 : null;
/*
* Beide Masse werden ausgegeben, sichtbar ist je nach Umschalter nur
* eines. Die Prozent sind gemessen, die Kilowattstunden daraus
* gerechnet - wer der Umrechnung nicht traut, schaltet um.
*
* Ohne bekannte Kapazität gibt es keine Kilowattstunden - lieber
* weniger anzeigen als eine Zahl, die auf einem geratenen Akku beruht.
*/
let verbrauch = "";
if (brauchbar) {
if (letzteKapazitaet) {
verbrauch = '<span class="v-kwh">'
+ nf(jeProzent * letzteKapazitaet.je_soc_netto, 1) + " kWh/100 km</span>";
}
verbrauch += '<span class="v-prozent">' + nf(jeProzent, 1) + " %/100 km</span>";
}
const zeigbar = f.von_lat && f.lat; // ohne Koordinaten nichts zu zeigen
const teile = [];
if (brauchbar && letzteKapazitaet) {
teile.push(nf(proz * letzteKapazitaet.je_soc_netto, 1) + " kWh für die Fahrt");
}
if (brauchbar) {
teile.push(proz + " Prozentpunkte Ladestand");
}
if (zeigbar) {
teile.push("klicken, um die Fahrt auf der Karte zu zeigen");
}
const hinweis = teile.join(" ");
return '<div class="zeile' + (zeigbar ? " anklickbar" : "") + '"'
+ ' data-fahrt="' + nummer + '"'
+ (hinweis ? ' title="' + textSicher(hinweis) + '"' : "")
+ (zeigbar ? ' tabindex="0" role="button"' : "")
+ '><span class="wann">' + zeitpunkt(f.ankunft)
+ '<span class="neben">' + textSicher(f.von || "?") + " → "
+ textSicher(f.nach || "?") + "</span></span>"
+ '<span class="was">' + nf(f.km) + " km"
+ '<span class="neben">' + verbrauch + "</span></span></div>";
}).join("");
}
const ort = $("skodaStandort");
if (letzterOrt) {
ort.textContent = (letzterOrt.park_state === "IN_MOTION" ? "unterwegs — zuletzt: " : "")
+ (letzterOrt.address || nf(letzterOrt.lat, 5) + ", " + nf(letzterOrt.lon, 5));
const link = $("skodaOsmLink");
if (link) {
link.href = "https://www.openstreetmap.org/?mlat=" + letzterOrt.lat
+ "&mlon=" + letzterOrt.lon + "#map=17/" + letzterOrt.lat + "/" + letzterOrt.lon;
}
} else {
ort.textContent = "Keine Position bekannt";
}
karteFuellen(letzteFahrten, letzterOrt);
} catch (e) {
$("skodaFahrten").innerHTML = leer("Fahrten nicht abrufbar.");
}
}
/**
* Klicks in der Fahrtenliste auf die Karte legen.
*
* Der Behandler sitzt einmalig am Listenbehälter, nicht an den Zeilen: die
* werden bei jeder Auffrischung neu gebaut, einzeln angeheftete Behandler
* gingen dabei verloren.
*/
function koppleFahrtenListe() {
const liste = $("skodaFahrten");
if (!liste) return;
function ausloesen(ziel) {
const zeile = ziel.closest(".zeile.anklickbar");
if (!zeile) return;
waehleFahrt(Number(zeile.dataset.fahrt), true);
// Die Karte steht weiter unten als die angeklickte Zeile - ohne das
// passierte der Sprung ausserhalb des Blickfelds.
const feld = $("skodaKarte");
if (feld && feld.getBoundingClientRect().bottom > window.innerHeight) {
feld.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
liste.addEventListener("click", (e) => ausloesen(e.target));
liste.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); // Leertaste soll nicht die Seite scrollen
ausloesen(e.target);
}
});
}
/* ------------------------------------------------------------- Steuerung */
/*
* Die Befehle sitzen an den Stellen im Fahrzeugbild, um die es geht: der
* Ladeanschluss steuert das Laden, der Innenraum Klima und Lüftung. Ein
* Antippen öffnet das Modal, das auch die Solarseite benutzt (#modalEV aus
* restricted/footer.php).
*
* Der Speichern-Knopf des Modals wird ausgeblendet - hier gibt es nichts zu
* speichern, jeder Knopf wirkt sofort.
*/
const STEUERUNG = {
laden: {
titel: "Laden",
zeile: "Startet oder stoppt den Ladevorgang am Fahrzeug. Die Wallbox "
+ "bleibt davon unberührt - sie gibt weiterhin frei, was sie freigibt.",
knoepfe: [
{ befehl: "laden_start", text: "Laden starten", stil: "success", zeichen: "play-fill" },
{ befehl: "laden_stop", text: "Laden stoppen", stil: "secondary", zeichen: "stop-fill" }
]
},
klima: {
titel: "Klima und Lüftung",
zeile: "Klimatisiert den Innenraum auf die eingestellte Temperatur oder "
+ "lüftet ihn durch. Beides geht auch ohne angestecktes Kabel.",
temperatur: true,
knoepfe: [
{ befehl: "klima_start", text: "Klima starten", stil: "info", zeichen: "thermometer-half" },
{ befehl: "klima_stop", text: "Klima stoppen", stil: "secondary", zeichen: "stop-fill" },
{ befehl: "lueftung_start", text: "Lüftung starten", stil: "info", zeichen: "wind" },
{ befehl: "lueftung_stop", text: "Lüftung stoppen", stil: "secondary", zeichen: "stop-fill" }
]
}
};
let steuerModal = null;
/** Die anklickbaren Stellen im Fahrzeugbild mit dem Modal verbinden. */
function koppleSteuerung() {
const knoten = document.querySelectorAll("#skodaAuto .steuer[data-steuer]");
if (!knoten.length || typeof bootstrap === "undefined") return;
steuerModal = new bootstrap.Modal(document.getElementById("modalEV"), { keyboard: true });
knoten.forEach(function (k) {
const oeffnen = function (e) {
e.stopPropagation(); // nicht zusätzlich die Hervorhebung umschalten
zeigeSteuerung(k.dataset.steuer);
};
k.addEventListener("click", oeffnen);
k.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); oeffnen(e); }
});
});
}
/** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */
function zeigeSteuerung(bereich) {
const b = STEUERUNG[bereich];
if (!b || !steuerModal) return;
document.getElementById("modal-title").textContent = b.titel;
const speichern = document.getElementById("modalSaveBtn");
if (speichern) speichern.style.display = "none";
const rumpf = document.getElementById("modal-body");
let html = '<div class="skoda-steuerung p-3">'
+ '<p class="text-secondary small">' + b.zeile + "</p>";
if (b.temperatur) {
html += '<label class="temperatur mb-3">Zieltemperatur'
+ '<span><input type="number" id="skodaKlimaTemp" class="form-control form-control-sm"'
+ ' min="16" max="29.5" step="0.5" value="21" inputmode="decimal"> &deg;C</span></label>';
}
html += '<div class="knoepfe">';
b.knoepfe.forEach(function (k) {
html += '<button type="button" class="btn btn-outline-' + k.stil + '" data-befehl="'
+ k.befehl + '"><i class="bi bi-' + k.zeichen + '"></i> ' + k.text + "</button>";
});
html += "</div>";
html += '<div class="skoda-rueckmeldung mt-3" id="skodaBefehlAntwort" role="status" aria-live="polite"></div>';
html += '<p class="text-secondary small mb-0 mt-3">Vier Befehle je Stunde. '
+ "Was das Fahrzeug ausführt, erscheint in der Anzeige erst beim nächsten Abruf.</p>";
if (bereich === "klima") {
html += '<p class="text-secondary small mb-0 mt-2">Die Standheizung fehlt bewusst: '
+ "ihr Start verlangt die Sicherheits-PIN des Fahrzeugs.</p>";
}
html += "</div>";
rumpf.innerHTML = html;
verdrahteBefehle(rumpf);
steuerModal.show();
}
/**
* Befehlsknöpfe im Modal verdrahten.
*
* Während ein Befehl unterwegs ist, werden alle Knöpfe gesperrt. Ein Befehl
* kostet eine Anfrage aus einem knappen Stundenkontingent - ein
* versehentlicher Doppelklick wäre die Hälfte davon.
*/
function verdrahteBefehle(bereich) {
const knoepfe = bereich.querySelectorAll("button[data-befehl]");
const meldung = bereich.querySelector("#skodaBefehlAntwort");
knoepfe.forEach(function (knopf) {
knopf.addEventListener("click", async function () {
const daten = new URLSearchParams({ befehl: knopf.dataset.befehl });
const feld = bereich.querySelector("#skodaKlimaTemp");
if (knopf.dataset.befehl === "klima_start" && feld) {
daten.set("temperatur", feld.value);
}
knoepfe.forEach(k => { k.disabled = true; });
meldung.className = "skoda-rueckmeldung mt-3 warte";
meldung.textContent = "Befehl wird gesendet …";
try {
const antwort = await fetch("ajax/skodaCmd.php", {
method: "POST", body: daten, cache: "no-store"
});
const d = await antwort.json();
meldung.className = "skoda-rueckmeldung mt-3 " + (d.ok ? "gut" : "schlecht");
meldung.textContent = d.text;
// Der Zustand ändert sich im Fahrzeug, nicht in der Tabelle. Ein
// Abruf gleich danach brächte noch den alten Stand; das Modul holt
// ihn nach seinem eigenen Takt.
if (d.ok) setTimeout(ladeLive, 20000);
} catch (e) {
meldung.className = "skoda-rueckmeldung mt-3 schlecht";
meldung.textContent = "Der Server hat nicht geantwortet.";
} finally {
knoepfe.forEach(k => { k.disabled = false; });
}
});
});
}
/* ------------------------------------------------------------------ Start */
function alleLaden() {
ladeLive();
ladeStrecke();
ladeLadungen();
ladeKurve();
ladeGesundheit();
ladeFahrten();
}
document.addEventListener("readystatechange", function () {
if (document.readyState !== "complete") return;
baueKurve();
baueGesundheit();
karteAufbauen();
koppleFahrtenListe();
koppleEinheitSchalter();
koppleFahrzeugTeile();
koppleSteuerung();
zeichneTeile(null);
zeichneLive();
alleLaden();
solarMQTT.getMQTT();
// 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(); ladeStrecke();
}, 300000);
setInterval(ladeGesundheit, 900000);
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
// sein Container eine andere Größe hat.
let umbruch = null;
window.addEventListener("resize", function () {
clearTimeout(umbruch);
umbruch = setTimeout(() => karte && karte.invalidateSize(), 200);
});
});