/*
* Š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 '
' + text + "
";
}
/* ------------------------------------------------------- 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 , nicht an der Gruppe: der
Farbverlauf steht in 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,
/*
* Fährt gerade. Kommt allein aus der Tabelle - der Broker kennt den
* Parkzustand nicht, unter solarManager/ev* stehen nur Ladestand,
* Reichweite und Ladung. Der Sammler fragt das Fahrzeug unterwegs alle
* vier Minuten, die Seite lädt jede Minute nach: die Anzeige hinkt also
* um wenige Minuten hinterher. Für "rollt gerade" reicht das; ein
* Tachometer ist es nicht.
*/
faehrt: !!(stand && stand.park_state === "IN_MOTION")
};
}
/* --------------------------------------------------------- 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
* 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";
}
}
/* ------------------------------------------------------------ Ostergruß */
/*
* Ganz selten liegt eine Bananenschale auf der Fahrbahn.
*
* Geprüft wird alle 20 Sekunden, und nur während der Fahrt; die Chance liegt
* bei zwei Prozent. Das ist im Mittel einmal in gut sechzehn Minuten Fahrt -
* selten genug, dass man es kaum erwartet, und häufig genug, dass es
* irgendwann jemand sieht.
*
* Der Auftritt dauert anderthalb Sekunden, danach fällt die Klasse wieder ab.
* Wer Bewegungen abbestellt hat, bekommt ihn gar nicht erst - ein
* schleuderndes Auto ist genau das, was damit gemeint ist.
*/
const BANANE_TAKT = 20000;
const BANANE_CHANCE = 0.02;
const BANANE_DAUER = 1600;
function bananeVielleicht() {
const bild = $("skodaAuto");
if (!bild || !bild.classList.contains("faehrt")) return;
if (bild.classList.contains("banane")) return; // läuft schon
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (Math.random() >= BANANE_CHANCE) return;
bild.classList.add("banane");
setTimeout(function () { bild.classList.remove("banane"); }, BANANE_DAUER);
}
/**
* Fahrt anzeigen: Fahrtwind am Fahrzeug, Abzeichen im Kopf der Kachel.
*
* Zwei Wege für dieselbe Aussage, und beide werden gebraucht. Die Streifen
* sieht man aus dem Augenwinkel, ohne zu lesen - dafür sind sie da. Wer die
* Seite vorgelesen bekommt oder Bewegungen abgeschaltet hat, hat von ihnen
* nichts; deshalb steht es zusätzlich als Wort neben Schloss und Ladezustand.
*/
function zeichneFahrt(w) {
const bild = $("skodaAuto");
if (bild) {
bild.classList.toggle("faehrt", w.faehrt);
// Hält der Wagen mitten im Schleudern an, bleibt sonst eine Schale auf
// der Fahrbahn liegen, die niemand mehr überfährt.
if (!w.faehrt) bild.classList.remove("banane");
}
const abzeichen = $("skodaUnterwegs");
if (abzeichen) {
abzeichen.hidden = !w.faehrt;
}
}
/**
* 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);
zeichneFahrt(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,
// "nearest" statt "index": Wallbox-Verlauf und Fahrzeugmeldungen haben
// verschiedene Zeitpunkte, der n-te Wert der einen Reihe gehört nicht zum
// n-ten der anderen.
interaction: { mode: "nearest", axis: "x", 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(
'Leaflet ');
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: '© OpenStreetMap -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(
"" + textSicher(f.von || "?") + " → " + textSicher(f.nach || "?") + " "
+ nf(f.km) + " km · " + 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(
"" + textSicher(f.nach || "Halt") + " "
+ zeitpunkt(f.ankunft, true) + " "
+ 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(
"" + textSicher(standort.address || "Aktuelle Position") + " "
+ (standort.park_state === "IN_MOTION"
? "unterwegs – 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 ''
+ '' + km + ' '
+ '' + STRECKE_ZEITRAEUME[name] + "
";
}).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 = '– '
+ 'nicht abrufbar
';
}
}
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 '' + zeitpunkt(l.beginn)
+ '' + l.soc_von + " → " + l.soc_bis + " % · "
+ (l.art || "–") + " "
+ '' + kwh + ''
+ (l.kw_spitze ? nf(l.kw_spitze, 1) + " kW" : "") + "
";
}).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();
// Mit Wallbox-Verlauf (skoda_ladepunkte): die Leistung im Minutentakt,
// der Ladestand dazwischen nach der geflossenen Energie aufgefüllt, und
// die echten Meldungen des Fahrzeugs als Punkte darauf - damit sichtbar
// bleibt, was gemessen und was abgeleitet ist. Das Fahrzeug meldet beim
// Laden mitunter nur einmal in der Stunde; eine Linie durch diese Punkte
// allein erfände den Verlauf dazwischen.
if (d.verlauf && d.verlauf.length) {
const imBild = (m) => zeit(m) >= zeit(d.verlauf[0]) - 600000
&& zeit(m) <= zeit(d.verlauf[d.verlauf.length - 1]) + 600000;
const meldungen = (d.meldungen || []).filter(imBild);
kurveChart.data.datasets = [
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
borderWidth: 2, pointRadius: 0, fill: "origin", tension: 0,
data: d.verlauf.map(p => ({ x: zeit(p), y: +p.kw })) },
{ label: "Ladestand, aufgefüllt", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
borderDash: [5, 4], pointRadius: 0, tension: 0, spanGaps: false,
data: d.verlauf.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) },
{ label: "Ladestand laut Fahrzeug", yAxisID: "y1", showLine: false,
borderColor: "#f0b429", backgroundColor: "#f0b429", pointRadius: 4, pointHoverRadius: 6,
data: meldungen.map(m => ({ x: zeit(m), y: +m.soc })) },
{ label: "Leistung laut Fahrzeug", yAxisID: "y", showLine: false,
borderColor: "#7aa7c7", backgroundColor: "#7aa7c7", pointRadius: 3, pointStyle: "rectRot",
data: meldungen.filter(m => m.chg_kw !== null && m.chg_state === "CHARGING")
.map(m => ({ x: zeit(m), y: +m.chg_kw })) }
];
kurveChart.update();
return;
}
// Ohne Wallbox-Verlauf - Ladungen von vor der Aufzeichnung - wie bisher
// aus den Zeilen der Fahrzeugtabelle.
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 = 'Noch keine auswertbare Ladung. '
+ 'Gezählt werden Ladungen an der eigenen Wallbox mit mindestens 20 Prozentpunkten Zuwachs.
';
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 += '' + (t.prozent > 0 ? "+" : "−") + nf(Math.abs(t.prozent), 2)
+ '% pro Jahr
';
html += ''
+ (eindeutig
? "Der Wert wandert messbar."
: "Von null nicht zu unterscheiden — keine Alterung nachweisbar.")
+ "
";
html += ""
+ "
Nachweisbar wäre ab " + nf(t.nachweisbar, 1) + " % im Jahr "
+ "
Beobachtungszeitraum " + nf(t.spanne_jahre, 1) + " Jahre ";
} else {
html += 'Für einen Trend sind es noch zu wenige Ladungen.
';
}
const j = d.jahre[d.jahre.length - 1];
if (j) {
html += "
Median " + j.jahr + " " + nf(j.median, 1) + " kWh/100 % ";
}
html += "
Ausgewertete Ladungen " + d.ladungen.length + " ";
befund.innerHTML = html;
} catch (e) {
$("skodaBefund").innerHTML = 'Auswertung nicht abrufbar.
';
}
}
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 = ''
+ nf(jeProzent * letzteKapazitaet.je_soc_netto, 1) + " kWh/100 km ";
}
verbrauch += '' + nf(jeProzent, 1) + " %/100 km ";
}
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 '' + zeitpunkt(f.ankunft)
+ '' + textSicher(f.von || "?") + " → "
+ textSicher(f.nach || "?") + " "
+ '' + nf(f.km) + " km"
+ '' + verbrauch + "
";
}).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 = ''
+ '
' + b.zeile + "
";
if (b.temperatur) {
html += '
Zieltemperatur'
+ ' °C ';
}
html += '
';
b.knoepfe.forEach(function (k) {
html += ' ' + k.text + " ";
});
html += "
";
html += '
';
html += '
Vier Befehle je Stunde. '
+ "Was das Fahrzeug ausführt, erscheint in der Anzeige erst beim nächsten Abruf.
";
if (bereich === "klima") {
html += '
Die Standheizung fehlt bewusst: '
+ "ihr Start verlangt die Sicherheits-PIN des Fahrzeugs.
";
}
html += "
";
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);
setInterval(bananeVielleicht, BANANE_TAKT);
// 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);
});
});