/*
* Š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 += '
Zieltemperatur'
+ ' °C ';
}
html += '
';
b.knoepfe.forEach(function (k) {
html += ' ' + k.text + " ";
});
html += "
";
html += '
';
html += '
Vier Befehle je Stunde. '
+ "Was das Fahrzeug ausführt, erscheint in der Anzeige erst beim nächsten Abruf.
";
if (bereich === "klima") {
html += '
Die Standheizung fehlt bewusst: '
+ "ihr Start verlangt die Sicherheits-PIN des Fahrzeugs.
";
}
html += "
";
rumpf.innerHTML = html;
verdrahteBefehle(rumpf);
steuerModal.show();
}
/**
* Befehlsknöpfe im Modal verdrahten.
*
* Während ein Befehl unterwegs ist, werden alle Knöpfe gesperrt. Ein Befehl
* kostet eine Anfrage aus einem knappen Stundenkontingent - ein
* versehentlicher Doppelklick wäre die Hälfte davon.
*/
function verdrahteBefehle(bereich) {
const knoepfe = bereich.querySelectorAll("button[data-befehl]");
const meldung = bereich.querySelector("#skodaBefehlAntwort");
knoepfe.forEach(function (knopf) {
knopf.addEventListener("click", async function () {
const daten = new URLSearchParams({ befehl: knopf.dataset.befehl });
const feld = bereich.querySelector("#skodaKlimaTemp");
if (knopf.dataset.befehl === "klima_start" && feld) {
daten.set("temperatur", feld.value);
}
knoepfe.forEach(k => { k.disabled = true; });
meldung.className = "skoda-rueckmeldung mt-3 warte";
meldung.textContent = "Befehl wird gesendet …";
try {
const antwort = await fetch("ajax/skodaCmd.php", {
method: "POST", body: daten, cache: "no-store"
});
const d = await antwort.json();
meldung.className = "skoda-rueckmeldung mt-3 " + (d.ok ? "gut" : "schlecht");
meldung.textContent = d.text;
// Der Zustand ändert sich im Fahrzeug, nicht in der Tabelle. Ein
// Abruf gleich danach brächte noch den alten Stand; das Modul holt
// ihn nach seinem eigenen Takt.
if (d.ok) setTimeout(ladeLive, 20000);
} catch (e) {
meldung.className = "skoda-rueckmeldung mt-3 schlecht";
meldung.textContent = "Der Server hat nicht geantwortet.";
} finally {
knoepfe.forEach(k => { k.disabled = false; });
}
});
});
}
/* ------------------------------------------------------------------ Start */
function alleLaden() {
ladeLive();
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);
});
});