/* * Š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. */ 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"; } 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-sunroof", feld: "sunroof", name: "Panoramadach", zu: "CLOSED" }, { id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" }, { id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" }, { id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" } ]; /** * 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 */ 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 function zeichneLive() { const stand = letzterStand; // Ladestand: MQTT hat Vorrang, die Tabelle ist der Rückfall. const soc = mqZahl("evSOC") ?? (stand ? Number(stand.soc) : null); const ziel = stand && stand.target_soc ? Number(stand.target_soc) : null; const zustand = mqText("evChgState") || (stand ? stand.chg_state : null); const laedt = zustand === "CHARGING"; // Ring: der Umfang ist 2*pi*r, der Rest ergibt sich aus dem Ladestand. const ring = $("skodaRing"); if (ring) { const umfang = 2 * Math.PI * 82; ring.style.strokeDasharray = umfang; ring.style.strokeDashoffset = umfang * (1 - (soc || 0) / 100); ring.style.stroke = laedt ? "#4caf7d" : (soc !== null && soc < 15 ? "#e06060" : "#7aa7c7"); } const socText = $("skodaSocText"); if (socText) socText.textContent = soc === null ? "–" : nf(soc); // Zielmarke auf dem Ring const zielLinie = $("skodaZiel"); if (zielLinie) { zielLinie.style.display = ziel ? "" : "none"; if (ziel) zielLinie.setAttribute("transform", "rotate(" + (ziel * 3.6) + " 100 100)"); } $("skodaZielText").textContent = ziel ? nf(ziel) + " %" : "–"; // Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter // gemeint sein, ein Akku trägt keine 1500 Kilometer. let km = mqZahl("evRange"); if (km === null && stand && stand.range_m !== null && stand.range_m !== undefined) { const roh = Number(stand.range_m); km = roh > 1500 ? roh / 1000 : roh; } $("skodaReichweite").textContent = km === null ? "–" : nf(km) + " km"; // Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am // Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert. const wbKw = mqZahl("evPower"); const carKw = stand && stand.chg_kw !== null ? Number(stand.chg_kw) : null; const kw = laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null); $("skodaLeistung").textContent = kw ? nf(kw, 1) + " kW" : "–"; const restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null); $("skodaRestzeit").textContent = laedt ? dauer(restMin) : "–"; $("skodaKm").textContent = stand && stand.odo_km ? nf(stand.odo_km) + " km" : "–"; const klima = stand ? stand.ac_state : null; const KLIMA = { OFF: "aus", COOLING: "kühlt", HEATING: "heizt", HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet", COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–" }; $("skodaKlima").textContent = klima ? (KLIMA[klima] || klima) : "–"; // Ladezustand als Kennzeichen const ZUSTAND = { CHARGING: ["lädt", "text-bg-success"], CONSERVING: ["hält Ladung", "text-bg-secondary"], READY_FOR_CHARGING: ["bereit", "text-bg-info"], CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"], CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"], DISCHARGING: ["entlädt", "text-bg-warning"] }; const kz = $("skodaLadeZustand"); const eintrag = ZUSTAND[zustand] || [zustand || "–", "text-bg-secondary"]; kz.textContent = eintrag[0]; kz.className = "badge " + eintrag[1]; // Verriegelung. null heißt: das Fahrzeug meldet UNKNOWN. const schloss = $("skodaSchloss"); const zu = stand ? stand.locked : null; if (zu === null || zu === undefined) { schloss.textContent = "Schloss unbekannt"; schloss.className = "badge text-bg-secondary"; } else if (Number(zu) === 1) { schloss.textContent = "verriegelt"; schloss.className = "badge text-bg-success"; } else { schloss.textContent = "offen"; schloss.className = "badge text-bg-warning"; } // Ladeanschluss pulst beim Laden const stecker = $("teil-plug"); if (stecker) stecker.classList.toggle("laedt", laedt); // Herkunft des Ladestroms: was die PV im Augenblick liefert, gegen das, // was die Wallbox zieht. Mehr als die Ladeleistung kann die PV nicht // beitragen, auch wenn gerade mehr Sonne da ist. const box = $("skodaQuelleBox"); const pvW = mqZahl("P_PV"); if (laedt && kw && pvW !== null) { const pvKw = Math.max(0, Math.min(kw, pvW / 1000)); const anteil = Math.round(100 * pvKw / kw); $("skodaAnteilPV").style.width = anteil + "%"; $("skodaAnteilNetz").style.width = (100 - anteil) + "%"; $("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz"; box.style.display = ""; } else { box.style.display = "none"; } } /* ------------------------------------------------------------- Diagramme */ let kurveChart = null; let gesundChart = null; const achsenFarbe = () => getComputedStyle(document.body).getPropertyValue("--bs-secondary-color") || "#9aa0a6"; const netzFarbe = "rgba(128,138,148,0.18)"; function baueKurve() { kurveChart = new Chart($("skodaKurve"), { type: "line", data: { datasets: [] }, options: { maintainAspectRatio: false, animation: false, interaction: { mode: "index", intersect: false }, scales: { // 'timestack' ist die Zeitachse, die diese Seite mitbringt // (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time' // braeuchte einen Datums-Adapter, den es hier nicht gibt. x: { type: "timestack", adapters: { date: { locale: "DE-de" } }, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }, y: { title: { display: true, text: "kW", color: achsenFarbe() }, beginAtZero: true, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }, y1: { position: "right", min: 0, max: 100, title: { display: true, text: "Ladestand %", color: achsenFarbe() }, grid: { drawOnChartArea: false }, ticks: { color: achsenFarbe() } } }, plugins: { legend: { labels: { color: achsenFarbe() } } } } }); } function baueGesundheit() { gesundChart = new Chart($("skodaGesundheit"), { type: "scatter", data: { datasets: [] }, options: { maintainAspectRatio: false, animation: false, scales: { x: { type: "timestack", adapters: { date: { locale: "DE-de" } }, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }, y: { title: { display: true, text: "kWh / 100 %", color: achsenFarbe() }, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } } }, plugins: { legend: { labels: { color: achsenFarbe() } }, tooltip: { callbacks: { label: (c) => { const p = c.raw; if (p.soc_von === undefined) return c.dataset.label + ": " + nf(c.parsed.y, 1); return nf(c.parsed.y, 1) + " kWh/100 % (" + p.soc_von + " → " + p.soc_bis + " %, " + nf(p.wh / 1000, 2) + " kWh)"; } } } } } }); } /* ------------------------------------------------------------------ Karte */ /* * Leaflet-Karte mit den Kacheln aus dem eigenen Zwischenspeicher. * * Die Kachel-URL zeigt bewusst auf ajax/tile.php und nicht unmittelbar auf * tile.openstreetmap.org: so verlässt die Parkposition - also die * Wohnadresse - das Haus nur beim allerersten Betrachten einer Gegend, und * einmal geholte Kacheln bleiben auch ohne Internet verfügbar. Warum das so * gebaut ist und was die Kachelrichtlinie verlangt, steht in tile.php. * * Die Fahrtlinien sind gestrichelt. Die API liefert nur die letzte bekannte * PARKposition, also Start und Ziel einer Fahrt; die gefahrene Strecke kennt * niemand. Eine durchgezogene Linie auf einer Straßenkarte würde behaupten, * das Auto sei genau dort langgefahren. */ const KARTE_ZOOM_MIN = 6; // muss zu ZOOM_MIN/ZOOM_MAX in tile.php passen const KARTE_ZOOM_MAX = 18; let karte = null; // die Leaflet-Karte, einmal angelegt let karteEbene = null; // Marker und Linien, wird bei jeder Auffrischung ersetzt let karteAusgerichtet = false; // beim ersten Mal auf die Daten zoomen, danach nicht mehr /* * Verknüpfung zwischen der Fahrtenliste und der Karte: Nummer der Fahrt im * Feld letzteFahrten -> ihre Linie samt der beiden Stilvarianten. Wird bei * jedem Füllen der Karte neu aufgebaut, weil die Linien dabei verworfen * werden. */ let fahrtLinien = {}; /** Nummer der gerade gewählten Fahrt, oder null. */ let gewaehlteFahrt = null; /* * Die drei Zustände einer Fahrtlinie. * * Blau kennzeichnet die jüngste Fahrt, damit man sie ohne Suchen findet. * Bernstein ist die Auswahl und muss das Blau überstimmen können - sonst * bliebe unklar, welche Linie zur angeklickten Zeile gehört, sobald die * jüngste gewählt ist. Bernstein steht auf dieser Seite ohnehin schon für * "sieh hier hin": in derselben Farbe leuchten offene Türen am Fahrzeug. */ const FAHRT_STIL = { normal: { color: "#8a949c", weight: 2, opacity: 0.55 }, juengste: { color: "#4b8fd6", weight: 3, opacity: 0.95 }, gewaehlt: { color: "#e0904f", weight: 5, opacity: 1 } }; /** * Den Stil einer Linie setzen. Die Auswahl gewinnt vor dem Überfahren, das * Überfahren vor dem Grundzustand. * * Ohne diese eine Stelle für alle drei Zustände würde ein Mausklick auf eine * gewählte Linie sie beim Verlassen wieder zurücksetzen - der Behandler für * mouseout weiss sonst nichts von der Auswahl. */ function stilAnwenden(nummer, ueberfahren) { const eintrag = fahrtLinien[nummer]; if (!eintrag) return; if (nummer === gewaehlteFahrt) { eintrag.linie.setStyle(FAHRT_STIL.gewaehlt).bringToFront(); } else if (ueberfahren) { eintrag.linie.setStyle(Object.assign({}, eintrag.grund, { weight: eintrag.grund.weight + 2, opacity: 1 })); } else { eintrag.linie.setStyle(eintrag.grund); } } /** * Die Karte anlegen. Wird einmal beim Seitenaufbau gerufen; danach kümmert * sich karteFuellen() um den Inhalt. */ function karteAufbauen() { const feld = document.getElementById("skodaKarte"); if (!feld || typeof L === "undefined") return; karte = L.map(feld, { minZoom: KARTE_ZOOM_MIN, maxZoom: KARTE_ZOOM_MAX, zoomControl: true, // Das Mausrad gehört auf dieser langen Seite dem Scrollen. Zoomen geht // mit den Schaltflächen, per Doppelklick oder mit Strg und Mausrad. scrollWheelZoom: false }).setView([51.0, 10.0], 6); // Leaflets Vorspann enthält von Haus aus eine Flaggengrafik. Die Nennung // der Bibliothek bleibt, das Bild kommt weg. karte.attributionControl.setPrefix( '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 = ""; feld.removeAttribute("title"); 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. feld.title = "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"; } } 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(); 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 += ''; } 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; }); } }); }); } /* ------------------------------------------------------------------ Start */ function alleLaden() { ladeLive(); 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(); }, 300000); setInterval(ladeGesundheit, 900000); // Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass // sein Container eine andere Größe hat. let umbruch = null; window.addEventListener("resize", function () { clearTimeout(umbruch); umbruch = setTimeout(() => karte && karte.invalidateSize(), 200); }); });