/* * Š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); zeichneLadefenster(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) { if (bereich === "laden") { zeigeLadefenster(); return; } 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 += ''; } html += '
'; b.knoepfe.forEach(function (k) { html += '"; }); 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; }); } }); }); } /* ------------------------------------------------------------ Ladefenster */ /* * Laden im Stil der Bedienfenster der Solarseite (.lade in css/solar.css): * links der Ring mit Ladestand und Ladeziel und der eine grosse Knopf zum * Starten oder Stoppen, rechts die Wahl des Ladeziels. * * Jeder Befehl kostet eine der vier Anfragen je Stunde (skodaCmd.php). Das * Ladeziel wird deshalb nicht bei jedem Antippen geschickt, sondern erst, * wenn eine Weile keine andere Stufe mehr gewaehlt wurde. Bis das Fahrzeug * das neue Ziel meldet - das dauert bis zum naechsten Abruf -, steht die * eigene Wahl im Fenster und ist als "wird übernommen" gekennzeichnet. */ const LADEZIELE = [50, 60, 70, 80, 90, 100]; const LADEZIEL_WARTEN_MS = 1500; const LADEZIEL_HALTEN_MS = 15 * 60 * 1000; let ladezielWunsch = null; // { prozent, seit, gesendet } let ladezielUhr = null; function zeigeLadefenster() { if (!steuerModal) return; document.getElementById("modal-title").textContent = "Laden"; const speichern = document.getElementById("modalSaveBtn"); if (speichern) speichern.style.display = "none"; const S = "\u2009"; document.getElementById("modal-body").innerHTML = '
' + '' + '
' + '
' + '

Ladeziel

' + '
' + LADEZIELE.map(p => '').join("") + '
' + '
' + '
' + '
' + '
' + '
'; const fenster = $("skodaLadefenster"); fenster.querySelectorAll("[data-ziel]").forEach(k => k.addEventListener("click", () => { ladezielWunsch = { prozent: Number(k.dataset.ziel), seit: Date.now(), gesendet: false }; zeichneLadefenster(liveWerte()); clearTimeout(ladezielUhr); ladezielUhr = setTimeout(ladezielSenden, LADEZIEL_WARTEN_MS); })); fenster.querySelector("[data-sl=start]").addEventListener("click", () => { const w = liveWerte(); skodaBefehl(w.laedt ? "laden_stop" : "laden_start", {}); }); ladefensterMeldung(""); zeichneLadefenster(liveWerte()); steuerModal.show(); } function ladefensterMeldung(text, art) { const m = document.querySelector("#skodaLadefenster [data-sl=meldung]"); if (!m) return; m.className = "lade-meldung" + (art ? " " + art : ""); m.textContent = text || "Jeder Befehl ist einer von vier je Stunde. Was das Fahrzeug übernimmt, erscheint beim nächsten Abruf."; } async function ladezielSenden() { if (!ladezielWunsch || ladezielWunsch.gesendet) return; const w = liveWerte(); // Schon so eingestellt: nichts schicken, keinen Befehl verbrauchen. if (w.ziel === ladezielWunsch.prozent) { ladezielWunsch = null; zeichneLadefenster(w); return; } ladezielWunsch.gesendet = true; const ok = await skodaBefehl("ladeziel", { prozent: ladezielWunsch.prozent }); if (!ok) ladezielWunsch = null; zeichneLadefenster(liveWerte()); } /** Einen Befehl schicken; waehrenddessen ist das Fenster gesperrt. */ async function skodaBefehl(befehl, extra) { const fenster = $("skodaLadefenster"); if (!fenster) return false; const knoepfe = fenster.querySelectorAll("button"); fenster.dataset.sendet = "1"; knoepfe.forEach(k => { k.disabled = true; }); ladefensterMeldung("Befehl wird gesendet …", "warte"); let ok = false; try { const antwort = await fetch("ajax/skodaCmd.php", { method: "POST", body: new URLSearchParams(Object.assign({ befehl: befehl }, extra)), cache: "no-store" }); const d = await antwort.json(); ok = !!d.ok; ladefensterMeldung(d.text, d.ok ? "gut" : "fehler"); // Der Zustand aendert sich im Fahrzeug, nicht in der Tabelle - der // Abruf danach bringt ihn. if (d.ok) setTimeout(ladeLive, 20000); } catch (e) { ladefensterMeldung("Der Server hat nicht geantwortet.", "fehler"); } finally { delete fenster.dataset.sendet; knoepfe.forEach(k => { k.disabled = false; }); zeichneLadefenster(liveWerte()); } return ok; } /** Das offene Ladefenster mit den Live-Werten nachziehen. */ function zeichneLadefenster(w) { const fenster = $("skodaLadefenster"); if (!fenster) return; const feld = name => fenster.querySelector("[data-sl=" + name + "]"); const S = "\u2009"; // Die eigene Wahl gilt, bis das Fahrzeug sie meldet - oder zu lange nicht. if (ladezielWunsch && (w.ziel === ladezielWunsch.prozent || Date.now() - ladezielWunsch.seit > LADEZIEL_HALTEN_MS)) { ladezielWunsch = null; } const ziel = ladezielWunsch ? ladezielWunsch.prozent : w.ziel; const soc = w.soc !== null && Number.isFinite(w.soc) ? w.soc : null; fenster.querySelector(".lade-ist").style.strokeDasharray = (soc === null ? 0 : Math.max(0, Math.min(100, soc))) + " 100"; fenster.querySelector(".lade-ziel").style.strokeDasharray = (ziel ? ziel : 0) + " 100"; feld("soc").textContent = soc === null ? "–" : nf(soc) + S + "%"; feld("ringZiel").textContent = ziel ? "Ziel " + ziel + S + "%" : ""; const zustand = LADEZUSTAND[w.zustand]; const pille = feld("zustand"); pille.textContent = zustand ? zustand[0] : ""; pille.className = "lade-pille" + (w.laedt ? " an" : w.zustand === "CHARGING_INTERRUPTED" ? " warn" : ""); feld("gross").innerHTML = w.laedt && w.kw ? nf(w.kw, 1) + S + "kWlädt" : (w.km === null ? "–" : nf(w.km) + S + "kmReichweite"); let fertig = ""; if (w.laedt && w.restMin > 0) { const um = new Date(Date.now() + w.restMin * 60000); fertig = "fertig um " + String(um.getHours()).padStart(2, "0") + ":" + String(um.getMinutes()).padStart(2, "0"); } feld("zeile1").textContent = w.laedt ? (w.km === null ? "" : nf(w.km) + S + "km Reichweite") : ""; feld("zeile2").textContent = fertig; // Ohne Kabel lehnt das Fahrzeug den Start ab - und der Versuch kostete // trotzdem einen der vier Befehle. const start = feld("start"); const ohneKabel = !w.laedt && w.zustand === "CONNECT_CABLE"; start.classList.toggle("laeuft", w.laedt); start.classList.toggle("aus", ohneKabel); if (!fenster.dataset.sendet) start.disabled = ohneKabel; feld("startText").textContent = w.laedt ? "Laden stoppen" : ohneKabel ? "Kabel anstecken" : "Laden starten"; feld("erklaerung").textContent = w.zustand === "CONNECT_CABLE" ? "Kein Kabel angesteckt. Das Ladeziel lässt sich trotzdem schon einstellen." : "Startet oder stoppt das Laden am Fahrzeug. Die Wallbox bleibt davon unberührt – sie gibt weiter frei, was sie freigibt."; fenster.querySelectorAll("[data-ziel]").forEach(k => k.setAttribute("aria-checked", String(Number(k.dataset.ziel) === ziel))); feld("zielHinweis").textContent = ladezielWunsch ? (ladezielWunsch.gesendet ? "wird vom Fahrzeug übernommen …" : "wird gleich geschickt …") : (ziel ? "Das Fahrzeug hört bei " + ziel + S + "% auf zu laden." : "Das Fahrzeug meldet noch kein Ladeziel."); } /* ------------------------------------------------------------------ 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); }); });