Files
Smart-Dashboard/js/solar/skodaMQTT.js
T
adminandClaude Opus 5 d285ec27a0 Einstellungsseite, Skoda-Seite und Karte
Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:27 +02:00

1175 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Škoda-Seite.
*
* Zwei Quellen, bewusst getrennt:
*
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand, Restladezeit und
* die Wallbox-Leistung kommen laufend über solarManager/# herein und
* aktualisieren die Anzeige sofort.
*
* Ajax Alles, was nur in der Tabelle skoda steht - Türen, Fenster, Licht,
* Klappen, Kilometerstand, Position, Klima, Ladeziel - sowie sämtliche
* Auswertungen. Beim Laden einmal, danach minütlich.
*
* Das Fahrzeug meldet Türen und Licht nur, wenn es sich meldet; zwischen zwei
* Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
* auszugeben.
*/
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 '<div class="leer">' + text + "</div>";
}
/* ------------------------------------------------------- Zustand des Autos */
/*
* Die Zustandswörter der API in Anzeige übersetzen. UNKNOWN ist ausdrücklich
* kein Nein: das Fahrzeug sagt dann, dass es den Zustand selbst nicht kennt,
* und die Anzeige darf daraus kein beruhigendes "geschlossen" machen.
*/
const OFFEN_WERTE = ["OPEN", "OPENED", "TRUNK_OPENED", "NO", "UNLOCKED"];
const ZUSTANDSWORT = {
OPEN: "offen", OPENED: "offen", TRUNK_OPENED: "Kofferraum offen",
CLOSED: "zu", LOCKED: "verriegelt", UNLOCKED: "entriegelt",
ON: "an", OFF: "aus", YES: "ja", NO: "nein",
UNKNOWN: "unbekannt", UNSUPPORTED: "nicht vorhanden"
};
const TEILE = [
{ id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" },
{ id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" },
{ id: "teil-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(
'<a href="https://leafletjs.com" target="_blank" rel="noopener noreferrer">Leaflet</a>');
L.tileLayer("ajax/tile.php?z={z}&x={x}&y={y}", {
minZoom: KARTE_ZOOM_MIN,
maxZoom: KARTE_ZOOM_MAX,
// Die Richtlinie verlangt sichtbare Nennung - nicht verstecken.
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" '
+ 'target="_blank" rel="noopener noreferrer">OpenStreetMap</a>-Mitwirkende'
}).addTo(karte);
karteEbene = L.layerGroup().addTo(karte);
const heim = document.getElementById("skodaKarteHeim");
if (heim) heim.addEventListener("click", karteAufStandort);
// Beim Anlegen steht das Kartenfeld noch im Seitenaufbau. Leaflet misst
// dann eine falsche Größe, lässt rechts einen leeren Streifen stehen und
// wählt beim Ausrichten eine zu grobe Zoomstufe. Ein Nachmessen, sobald das
// Layout steht, räumt beides aus.
setTimeout(() => karte && karte.invalidateSize(), 0);
}
/** Karte auf den aktuellen Standort zentrieren. */
function karteAufStandort() {
if (karte && letzterOrt && letzterOrt.lat) {
karte.setView([+letzterOrt.lat, +letzterOrt.lon], 15);
}
}
/**
* Marker und Linien neu setzen.
*
* Ausgerichtet wird nur beim ersten Mal. Sonst risse die minütliche
* Auffrischung dem Betrachter die Karte unter der Hand weg, sobald er
* irgendwo hingezoomt hat.
*/
function karteFuellen(fahrten, standort) {
if (!karte || !karteEbene) return;
karteEbene.clearLayers();
const punkte = [];
fahrtLinien = {};
// Fahrten von alt nach neu, damit die jüngste obenauf liegt. Die Nummer im
// Ursprungsfeld wird mitgeführt, damit die Fahrtenliste daneben ihre Zeile
// der passenden Linie zuordnen kann.
fahrten.slice().reverse().forEach(function (f, i, alle) {
if (!f.von_lat || !f.lat) return;
const nummer = fahrten.length - 1 - i;
const von = [+f.von_lat, +f.von_lon];
const nach = [+f.lat, +f.lon];
const jung = (i === alle.length - 1);
punkte.push(von, nach);
const grund = jung ? FAHRT_STIL.juengste : FAHRT_STIL.normal;
const linie = L.polyline([von, nach], Object.assign({
dashArray: "6 6" // gestrichelt: Luftlinie, keine Route
}, grund)).bindPopup(
"<b>" + textSicher(f.von || "?") + " &rarr; " + textSicher(f.nach || "?") + "</b><br>"
+ nf(f.km) + " km &middot; " + zeitpunkt(f.ankunft, true)
);
linie.on("mouseover", () => stilAnwenden(nummer, true));
linie.on("mouseout", () => stilAnwenden(nummer, false));
linie.on("click", () => waehleFahrt(nummer, false));
karteEbene.addLayer(linie);
fahrtLinien[nummer] = { linie: linie, grund: grund, ecken: [von, nach] };
});
// Eine zuvor gewählte Fahrt nach dem Neuaufbau wieder hervorheben.
if (gewaehlteFahrt !== null) {
stilAnwenden(gewaehlteFahrt, false);
}
// Halte
fahrten.forEach(function (f) {
if (!f.lat) return;
karteEbene.addLayer(L.circleMarker([+f.lat, +f.lon], {
radius: 5, color: "#161a1d", weight: 1.5,
fillColor: "#8a949c", fillOpacity: 0.9
}).bindPopup(
"<b>" + textSicher(f.nach || "Halt") + "</b><br>"
+ zeitpunkt(f.ankunft, true) + "<br>"
+ nf(f.km) + " km gefahren"
));
});
// Aktueller Standort: heller Punkt mit Hof, damit er sich von den Halten
// abhebt, auch wenn er auf einem davon liegt.
if (standort && standort.lat) {
const hier = [+standort.lat, +standort.lon];
punkte.push(hier);
karteEbene.addLayer(L.circleMarker(hier, {
radius: 14, color: "#4caf7d", weight: 2, opacity: 0.45, fill: false
}));
karteEbene.addLayer(L.circleMarker(hier, {
radius: 7, color: "#161a1d", weight: 2,
fillColor: "#4caf7d", fillOpacity: 1
}).bindPopup(
"<b>" + textSicher(standort.address || "Aktuelle Position") + "</b><br>"
+ (standort.park_state === "IN_MOTION"
? "unterwegs &ndash; letzte bekannte Parkposition"
: "geparkt seit " + zeitpunkt(standort.datetime, true))
));
}
if (!karteAusgerichtet && punkte.length) {
karteAusgerichtet = true;
// Vor dem Ausrichten nachmessen: die Zoomstufe folgt aus der
// Containergröße, und die stimmt erst, wenn das Layout steht.
karte.invalidateSize();
// animate: false ist hier kein Schönheitsfehler, sondern Absicht. Eine
// Zoomfahrt von der Startansicht bis zum Ziel fordert jede Zwischenstufe
// an Kacheln an - bei einem Sprung über mehrere Stufen also ein Vielfaches
// dessen, was am Ende zu sehen ist. Alles davon ginge einmal nach draußen.
if (punkte.length === 1) {
karte.setView(punkte[0], 15, { animate: false });
} else {
// maxZoom, damit eine einzelne eng beieinanderliegende Gruppe nicht
// bis auf Hausnummernebene aufgezogen wird.
karte.fitBounds(L.latLngBounds(punkte), {
padding: [24, 24], maxZoom: 14, animate: false
});
}
}
}
/**
* Eine Fahrt aus der Liste auf der Karte zeigen: Ausschnitt auf ihre beiden
* Enden, Linie hervorheben, Sprechblase öffnen.
*
* Ohne animate: false zöge die Karte über jede Zwischenstufe hinweg und
* fordert dabei deren Kacheln an - siehe die Begründung bei karteFuellen().
*/
/**
* Eine Fahrt auswählen - der einzige Weg dorthin, gleich ob der Klick in der
* Liste oder auf der Karte kam. Beide Seiten werden hier zusammen gesetzt,
* sonst laufen Zeilenmarkierung und Linienfarbe auseinander.
*
* @param nummer Nummer der Fahrt in letzteFahrten
* @param hinzoomen Ausschnitt auf die Fahrt legen. Beim Klick auf der Karte
* unerwünscht: dort sieht man sie ja schon, und der Sprung
* risse einem den selbst gewählten Ausschnitt weg.
*/
function waehleFahrt(nummer, hinzoomen) {
const eintrag = fahrtLinien[nummer];
if (!karte || !eintrag) return;
const vorher = gewaehlteFahrt;
gewaehlteFahrt = nummer;
if (vorher !== null && vorher !== nummer) {
stilAnwenden(vorher, false); // alte Auswahl zurücksetzen
}
stilAnwenden(nummer, false);
if (hinzoomen) {
karte.fitBounds(L.latLngBounds(eintrag.ecken), {
padding: [40, 40], maxZoom: 13, animate: false
});
}
eintrag.linie.openPopup();
markiereFahrtZeile(nummer);
}
/* Vom Betrachter gewählte Einheit: "kwh" oder "prozent". */
const EINHEIT_SPEICHER = "skodaVerbrauchEinheit";
/**
* Einheit umschalten. Sichtbar wird die Auswahl über eine Klasse auf der
* Kachel; die Zeilen selbst bleiben unberührt und überstehen damit die
* minütliche Auffrischung der Liste.
*
* Die Wahl liegt im Browser des Betrachters, nicht auf dem Server - sie geht
* niemanden sonst etwas an. Lesen und Schreiben stehen in try/catch, weil
* der Zugriff in privaten Fenstern und bei gesperrten Seitendaten wirft.
*/
function setzeEinheit(einheit, merken) {
const kachel = $("skodaFahrtenKachel");
if (!kachel) return;
const kwh = einheit !== "prozent";
kachel.classList.toggle("zeigt-kwh", kwh);
kachel.classList.toggle("zeigt-prozent", !kwh);
const schalter = $("skodaEinheit");
if (schalter) {
schalter.querySelectorAll("button").forEach(function (b) {
const aktiv = (b.dataset.einheit === "kwh") === kwh;
b.classList.toggle("aktiv", aktiv);
b.setAttribute("aria-pressed", aktiv ? "true" : "false");
});
}
if (merken) {
try { localStorage.setItem(EINHEIT_SPEICHER, kwh ? "kwh" : "prozent"); } catch (e) { }
}
}
/** Umschalter verdrahten und die zuletzt gewählte Einheit wiederherstellen. */
function koppleEinheitSchalter() {
const schalter = $("skodaEinheit");
if (!schalter) return;
schalter.addEventListener("click", function (e) {
const knopf = e.target.closest("button[data-einheit]");
if (knopf) setzeEinheit(knopf.dataset.einheit, true);
});
let gemerkt = null;
try { gemerkt = localStorage.getItem(EINHEIT_SPEICHER); } catch (e) { }
setzeEinheit(gemerkt || "kwh", false);
}
/**
* In der Überschrift der Fahrtenliste offenlegen, worauf die
* Kilowattstunden beruhen. Ohne das steht dort eine Zahl, deren Herkunft
* niemand mehr nachvollziehen kann - sie ist ja nicht gemessen, sondern aus
* dem Ladestand und der eigenen Ladehistorie gerechnet.
*/
function zeigeVerbrauchsbasis() {
const feld = $("skodaVerbrauchBasis");
if (!feld) return;
const schalter = $("skodaEinheit");
if (!letzteKapazitaet) {
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
feld.textContent = "";
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 '<div class="zeile"><span class="wann">' + zeitpunkt(l.beginn)
+ '<span class="neben">' + l.soc_von + " → " + l.soc_bis + " % · "
+ (l.art || "–") + "</span></span>"
+ '<span class="was">' + kwh + '<span class="neben">'
+ (l.kw_spitze ? nf(l.kw_spitze, 1) + " kW" : "") + "</span></span></div>";
}).join("");
} catch (e) {
$("skodaLadungen").innerHTML = leer("Ladungen nicht abrufbar.");
}
}
async function ladeKurve() {
try {
const d = await hole("kurve");
if (!d.punkte || !d.punkte.length) {
$("skodaKurveZeit").textContent = "noch keine Ladung";
kurveChart.data.datasets = [];
kurveChart.update();
return;
}
$("skodaKurveZeit").textContent = zeitpunkt(d.von) + " – " + zeitpunkt(d.bis);
// Die Zeitachse 'timestack' rechnet mit Millisekunden, nicht mit
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
// stattdessen den heutigen Tag.
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
kurveChart.data.datasets = [
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
data: d.punkte.map(p => ({ x: zeit(p), y: p.wb_kw === null ? null : +p.wb_kw })) },
{ label: "Fahrzeug", yAxisID: "y", borderColor: "#7aa7c7", borderWidth: 2,
pointRadius: 0, borderDash: [4, 3], tension: .25,
data: d.punkte.map(p => ({ x: zeit(p), y: p.chg_kw === null ? null : +p.chg_kw })) },
{ label: "Ladestand", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
pointRadius: 0, tension: .1,
data: d.punkte.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) }
];
kurveChart.update();
} catch (e) {
$("skodaKurveZeit").textContent = "nicht abrufbar";
}
}
async function ladeGesundheit() {
try {
const d = await hole("gesundheit", "&tage=3650");
const befund = $("skodaBefund");
if (!d.ladungen.length) {
gesundChart.data.datasets = [];
gesundChart.update();
befund.innerHTML = '<p class="text-secondary mb-0">Noch keine auswertbare Ladung. '
+ 'Gezählt werden Ladungen an der eigenen Wallbox mit mindestens 20 Prozentpunkten Zuwachs.</p>';
return;
}
const punkte = d.ladungen.map(l => ({
x: new Date(String(l.beginn).replace(" ", "T")).getTime(), y: +l.je100,
soc_von: +l.soc_von, soc_bis: +l.soc_bis, wh: +l.wh
}));
const saetze = [{ label: "Ladung", data: punkte, backgroundColor: "#4caf7d", pointRadius: 4 }];
// Ausgleichsgerade, wenn genug Punkte da sind
if (d.trend) {
const t0 = punkte[0].x;
const t1 = punkte[punkte.length - 1].x;
const JAHR = 365.25 * 864e5;
saetze.push({
label: "Trend", type: "line", borderColor: "#9aa0a6", borderWidth: 1.5,
borderDash: [5, 4], pointRadius: 0, fill: false,
data: [{ x: t0, y: d.trend.achse },
{ x: t1, y: d.trend.achse + d.trend.steigung * (t1 - t0) / JAHR }]
});
}
gesundChart.data.datasets = saetze;
gesundChart.update();
// Befund in Worten
const t = d.trend;
let html = "";
if (t && t.prozent !== null) {
const eindeutig = Math.abs(t.steigung) > 2 * t.fehler;
html += '<div class="zahl">' + (t.prozent > 0 ? "+" : "−") + nf(Math.abs(t.prozent), 2)
+ '<span class="einheit">% pro Jahr</span></div>';
html += '<p class="mb-0 mt-2 ' + (eindeutig ? "text-warning" : "text-secondary") + '">'
+ (eindeutig
? "Der Wert wandert messbar."
: "Von null nicht zu unterscheiden — keine Alterung nachweisbar.")
+ "</p>";
html += "<dl>"
+ "<div><dt>Nachweisbar wäre ab</dt><dd>" + nf(t.nachweisbar, 1) + " % im Jahr</dd></div>"
+ "<div><dt>Beobachtungszeitraum</dt><dd>" + nf(t.spanne_jahre, 1) + " Jahre</dd></div>";
} else {
html += '<p class="text-secondary mb-0">Für einen Trend sind es noch zu wenige Ladungen.</p><dl>';
}
const j = d.jahre[d.jahre.length - 1];
if (j) {
html += "<div><dt>Median " + j.jahr + "</dt><dd>" + nf(j.median, 1) + " kWh/100 %</dd></div>";
}
html += "<div><dt>Ausgewertete Ladungen</dt><dd>" + d.ladungen.length + "</dd></div></dl>";
befund.innerHTML = html;
} catch (e) {
$("skodaBefund").innerHTML = '<p class="text-secondary mb-0">Auswertung nicht abrufbar.</p>';
}
}
let letzteFahrten = [];
let letzterOrt = null;
/*
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist, aus den eigenen
* Ladungen bestimmt und um die Ladeverluste bereinigt - siehe
* kapazitaetSchaetzen() in ajax/skoda.php. Bleibt null, solange es zu wenige
* Ladungen gibt; dann zeigt die Fahrtenliste nur Prozent.
*/
let letzteKapazitaet = null;
async function ladeFahrten() {
try {
const d = await hole("fahrten", "&tage=180");
letzteFahrten = d.fahrten || [];
letzterOrt = d.standort || null;
letzteKapazitaet = d.kapazitaet || null;
zeigeVerbrauchsbasis();
const liste = $("skodaFahrten");
if (!letzteFahrten.length) {
liste.innerHTML = leer("Noch keine Fahrt aufgezeichnet.");
} else {
// data-fahrt trägt die Nummer im Feld letzteFahrten. Darüber findet der
// Klick weiter unten die passende Linie auf der Karte.
liste.innerHTML = letzteFahrten.map(function (f, nummer) {
// Der Ladestandsverbrauch stimmt nur, wenn zwischendurch nicht
// geladen wurde - sonst ist er zu klein oder negativ.
const proz = Number(f.soc_ab);
const brauchbar = proz > 0 && f.km > 0;
const jeProzent = brauchbar ? proz / f.km * 100 : null;
/*
* Beide Masse werden ausgegeben, sichtbar ist je nach Umschalter nur
* eines. Die Prozent sind gemessen, die Kilowattstunden daraus
* gerechnet - wer der Umrechnung nicht traut, schaltet um.
*
* Ohne bekannte Kapazität gibt es keine Kilowattstunden - lieber
* weniger anzeigen als eine Zahl, die auf einem geratenen Akku beruht.
*/
let verbrauch = "";
if (brauchbar) {
if (letzteKapazitaet) {
verbrauch = '<span class="v-kwh">'
+ nf(jeProzent * letzteKapazitaet.je_soc_netto, 1) + " kWh/100 km</span>";
}
verbrauch += '<span class="v-prozent">' + nf(jeProzent, 1) + " %/100 km</span>";
}
const zeigbar = f.von_lat && f.lat; // ohne Koordinaten nichts zu zeigen
const teile = [];
if (brauchbar && letzteKapazitaet) {
teile.push(nf(proz * letzteKapazitaet.je_soc_netto, 1) + " kWh für die Fahrt");
}
if (brauchbar) {
teile.push(proz + " Prozentpunkte Ladestand");
}
if (zeigbar) {
teile.push("klicken, um die Fahrt auf der Karte zu zeigen");
}
const hinweis = teile.join(" – ");
return '<div class="zeile' + (zeigbar ? " anklickbar" : "") + '"'
+ ' data-fahrt="' + nummer + '"'
+ (hinweis ? ' title="' + textSicher(hinweis) + '"' : "")
+ (zeigbar ? ' tabindex="0" role="button"' : "")
+ '><span class="wann">' + zeitpunkt(f.ankunft)
+ '<span class="neben">' + textSicher(f.von || "?") + " → "
+ textSicher(f.nach || "?") + "</span></span>"
+ '<span class="was">' + nf(f.km) + " km"
+ '<span class="neben">' + verbrauch + "</span></span></div>";
}).join("");
}
const ort = $("skodaStandort");
if (letzterOrt) {
ort.textContent = (letzterOrt.park_state === "IN_MOTION" ? "unterwegs — zuletzt: " : "")
+ (letzterOrt.address || nf(letzterOrt.lat, 5) + ", " + nf(letzterOrt.lon, 5));
const link = $("skodaOsmLink");
if (link) {
link.href = "https://www.openstreetmap.org/?mlat=" + letzterOrt.lat
+ "&mlon=" + letzterOrt.lon + "#map=17/" + letzterOrt.lat + "/" + letzterOrt.lon;
}
} else {
ort.textContent = "Keine Position bekannt";
}
karteFuellen(letzteFahrten, letzterOrt);
} catch (e) {
$("skodaFahrten").innerHTML = leer("Fahrten nicht abrufbar.");
}
}
/**
* Klicks in der Fahrtenliste auf die Karte legen.
*
* Der Behandler sitzt einmalig am Listenbehälter, nicht an den Zeilen: die
* werden bei jeder Auffrischung neu gebaut, einzeln angeheftete Behandler
* gingen dabei verloren.
*/
function koppleFahrtenListe() {
const liste = $("skodaFahrten");
if (!liste) return;
function ausloesen(ziel) {
const zeile = ziel.closest(".zeile.anklickbar");
if (!zeile) return;
waehleFahrt(Number(zeile.dataset.fahrt), true);
// Die Karte steht weiter unten als die angeklickte Zeile - ohne das
// passierte der Sprung ausserhalb des Blickfelds.
const feld = $("skodaKarte");
if (feld && feld.getBoundingClientRect().bottom > window.innerHeight) {
feld.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
liste.addEventListener("click", (e) => ausloesen(e.target));
liste.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); // Leertaste soll nicht die Seite scrollen
ausloesen(e.target);
}
});
}
/* ------------------------------------------------------------- Steuerung */
/*
* Die Befehle sitzen an den Stellen im Fahrzeugbild, um die es geht: der
* Ladeanschluss steuert das Laden, der Innenraum Klima und Lüftung. Ein
* Antippen öffnet das Modal, das auch die Solarseite benutzt (#modalEV aus
* restricted/footer.php).
*
* Der Speichern-Knopf des Modals wird ausgeblendet - hier gibt es nichts zu
* speichern, jeder Knopf wirkt sofort.
*/
const STEUERUNG = {
laden: {
titel: "Laden",
zeile: "Startet oder stoppt den Ladevorgang am Fahrzeug. Die Wallbox "
+ "bleibt davon unberührt - sie gibt weiterhin frei, was sie freigibt.",
knoepfe: [
{ befehl: "laden_start", text: "Laden starten", stil: "success", zeichen: "play-fill" },
{ befehl: "laden_stop", text: "Laden stoppen", stil: "secondary", zeichen: "stop-fill" }
]
},
klima: {
titel: "Klima und Lüftung",
zeile: "Klimatisiert den Innenraum auf die eingestellte Temperatur oder "
+ "lüftet ihn durch. Beides geht auch ohne angestecktes Kabel.",
temperatur: true,
knoepfe: [
{ befehl: "klima_start", text: "Klima starten", stil: "info", zeichen: "thermometer-half" },
{ befehl: "klima_stop", text: "Klima stoppen", stil: "secondary", zeichen: "stop-fill" },
{ befehl: "lueftung_start", text: "Lüftung starten", stil: "info", zeichen: "wind" },
{ befehl: "lueftung_stop", text: "Lüftung stoppen", stil: "secondary", zeichen: "stop-fill" }
]
}
};
let steuerModal = null;
/** Die anklickbaren Stellen im Fahrzeugbild mit dem Modal verbinden. */
function koppleSteuerung() {
const knoten = document.querySelectorAll("#skodaAuto .steuer[data-steuer]");
if (!knoten.length || typeof bootstrap === "undefined") return;
steuerModal = new bootstrap.Modal(document.getElementById("modalEV"), { keyboard: true });
knoten.forEach(function (k) {
const oeffnen = function (e) {
e.stopPropagation(); // nicht zusätzlich die Hervorhebung umschalten
zeigeSteuerung(k.dataset.steuer);
};
k.addEventListener("click", oeffnen);
k.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); oeffnen(e); }
});
});
}
/** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */
function zeigeSteuerung(bereich) {
const b = STEUERUNG[bereich];
if (!b || !steuerModal) return;
document.getElementById("modal-title").textContent = b.titel;
const speichern = document.getElementById("modalSaveBtn");
if (speichern) speichern.style.display = "none";
const rumpf = document.getElementById("modal-body");
let html = '<div class="skoda-steuerung p-3">'
+ '<p class="text-secondary small">' + b.zeile + "</p>";
if (b.temperatur) {
html += '<label class="temperatur mb-3">Zieltemperatur'
+ '<span><input type="number" id="skodaKlimaTemp" class="form-control form-control-sm"'
+ ' min="16" max="29.5" step="0.5" value="21" inputmode="decimal"> &deg;C</span></label>';
}
html += '<div class="knoepfe">';
b.knoepfe.forEach(function (k) {
html += '<button type="button" class="btn btn-outline-' + k.stil + '" data-befehl="'
+ k.befehl + '"><i class="bi bi-' + k.zeichen + '"></i> ' + k.text + "</button>";
});
html += "</div>";
html += '<div class="skoda-rueckmeldung mt-3" id="skodaBefehlAntwort" role="status" aria-live="polite"></div>';
html += '<p class="text-secondary small mb-0 mt-3">Vier Befehle je Stunde. '
+ "Was das Fahrzeug ausführt, erscheint in der Anzeige erst beim nächsten Abruf.</p>";
if (bereich === "klima") {
html += '<p class="text-secondary small mb-0 mt-2">Die Standheizung fehlt bewusst: '
+ "ihr Start verlangt die Sicherheits-PIN des Fahrzeugs.</p>";
}
html += "</div>";
rumpf.innerHTML = html;
verdrahteBefehle(rumpf);
steuerModal.show();
}
/**
* Befehlsknöpfe im Modal verdrahten.
*
* Während ein Befehl unterwegs ist, werden alle Knöpfe gesperrt. Ein Befehl
* kostet eine Anfrage aus einem knappen Stundenkontingent - ein
* versehentlicher Doppelklick wäre die Hälfte davon.
*/
function verdrahteBefehle(bereich) {
const knoepfe = bereich.querySelectorAll("button[data-befehl]");
const meldung = bereich.querySelector("#skodaBefehlAntwort");
knoepfe.forEach(function (knopf) {
knopf.addEventListener("click", async function () {
const daten = new URLSearchParams({ befehl: knopf.dataset.befehl });
const feld = bereich.querySelector("#skodaKlimaTemp");
if (knopf.dataset.befehl === "klima_start" && feld) {
daten.set("temperatur", feld.value);
}
knoepfe.forEach(k => { k.disabled = true; });
meldung.className = "skoda-rueckmeldung mt-3 warte";
meldung.textContent = "Befehl wird gesendet …";
try {
const antwort = await fetch("ajax/skodaCmd.php", {
method: "POST", body: daten, cache: "no-store"
});
const d = await antwort.json();
meldung.className = "skoda-rueckmeldung mt-3 " + (d.ok ? "gut" : "schlecht");
meldung.textContent = d.text;
// Der Zustand ändert sich im Fahrzeug, nicht in der Tabelle. Ein
// Abruf gleich danach brächte noch den alten Stand; das Modul holt
// ihn nach seinem eigenen Takt.
if (d.ok) setTimeout(ladeLive, 20000);
} catch (e) {
meldung.className = "skoda-rueckmeldung mt-3 schlecht";
meldung.textContent = "Der Server hat nicht geantwortet.";
} finally {
knoepfe.forEach(k => { k.disabled = false; });
}
});
});
}
/* ------------------------------------------------------------------ Start */
function alleLaden() {
ladeLive();
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);
});
});