Laden oeffnet jetzt ein Fenster wie auf der Solarseite: Ring mit Ladestand und Ladeziel, ein Knopf zum Starten oder Stoppen, das Ladeziel in Stufen von 50 bis 100 Prozent. skodaCmd.php kennt dafuer den Befehl ladeziel (PUT charging/limit) und prueft die Stufe, bevor ein Befehl aus dem Kontingent draufgeht. Gesendet wird erst, wenn eine Weile keine andere Stufe gewaehlt wurde; bis das Fahrzeug das Ziel meldet, steht die eigene Wahl im Fenster. Ohne Kabel ist der Start gesperrt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1760 lines
68 KiB
JavaScript
1760 lines
68 KiB
JavaScript
/*
|
||
* Škoda-Seite.
|
||
*
|
||
* Zwei Quellen, bewusst getrennt:
|
||
*
|
||
* MQTT Ladestand, Verriegelung, Reichweite, Ladezustand, Restladezeit und
|
||
* die Wallbox-Leistung kommen laufend über solarManager/# herein und
|
||
* aktualisieren die Anzeige sofort.
|
||
*
|
||
* Ajax Alles, was nur in der Tabelle skoda steht - Türen, Fenster, Licht,
|
||
* Klappen, Kilometerstand, Position, Klima, Ladeziel - sowie sämtliche
|
||
* Auswertungen. Beim Laden einmal, danach minütlich.
|
||
*
|
||
* Das Fahrzeug meldet Türen und Licht nur, wenn es sich meldet; zwischen zwei
|
||
* Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb
|
||
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
|
||
* auszugeben.
|
||
*
|
||
* Der Ladestand hat keine eigene Anzeige mehr: er steckt in der Akkuplatte der
|
||
* Fahrzeuggrafik - Füllhöhe, Zielmarke, Prozent und Reichweite setzt
|
||
* zeichneAkku(). Was sich nicht zeichnen lässt, steht in den Kennzahlen
|
||
* daneben (zeichneKennzahlen).
|
||
*/
|
||
|
||
var mqttData = {};
|
||
|
||
const solarMQTT = {
|
||
getMQTT: function () {
|
||
const connection = "wss://mqtt.nas.el-wa.org:443";
|
||
const client = mqtt.connect(connection, { rejectUnauthorized: false });
|
||
|
||
client.on("message", function (topic, message) {
|
||
mqttData[topic] = message;
|
||
if (topic.startsWith("solarManager/ev") || topic === "solarManager/P_PV"
|
||
|| topic === "solarManager/P_Grid") {
|
||
planeLiveAnzeige();
|
||
}
|
||
});
|
||
client.on("connect", function () { client.subscribe("solarManager/#"); });
|
||
client.on("error", function () { /* stille Wiederverbindung durch mqtt.js */ });
|
||
}
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ Hilfen */
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/** MQTT-Nutzlast als Zahl. Fehlt das Thema noch, kommt null. */
|
||
function mqZahl(name) {
|
||
const v = mqttData["solarManager/" + name];
|
||
if (v === undefined || v === null) return null;
|
||
const z = Number(String(v));
|
||
return Number.isFinite(z) ? z : null;
|
||
}
|
||
/** MQTT-Nutzlast als Text. */
|
||
function mqText(name) {
|
||
const v = mqttData["solarManager/" + name];
|
||
return (v === undefined || v === null) ? null : String(v);
|
||
}
|
||
|
||
const nf = (n, k = 0) => (n === null || n === undefined || !Number.isFinite(Number(n)))
|
||
? "–" : Number(n).toLocaleString("de-DE", { minimumFractionDigits: k, maximumFractionDigits: k });
|
||
|
||
/** Sekunden als "vor 3 min" / "vor 2 h 15 min" / "vor 4 Tagen". */
|
||
function seit(sekunden) {
|
||
if (sekunden === null || sekunden === undefined) return "";
|
||
const s = Math.max(0, Math.round(sekunden));
|
||
if (s < 90) return "vor " + s + " s";
|
||
const m = Math.round(s / 60);
|
||
if (m < 90) return "vor " + m + " min";
|
||
const h = Math.floor(s / 3600);
|
||
if (h < 36) return "vor " + h + " h " + Math.round((s - h * 3600) / 60) + " min";
|
||
return "vor " + Math.round(s / 86400) + " Tagen";
|
||
}
|
||
|
||
function zeitpunkt(iso, mitJahr = false) {
|
||
if (!iso) return "–";
|
||
const d = new Date(String(iso).replace(" ", "T"));
|
||
if (isNaN(d)) return "–";
|
||
return d.toLocaleString("de-DE", {
|
||
day: "2-digit", month: "2-digit", year: mitJahr ? "numeric" : undefined,
|
||
hour: "2-digit", minute: "2-digit"
|
||
});
|
||
}
|
||
|
||
/** Minuten als "1 h 35 min". */
|
||
function dauer(minuten) {
|
||
const m = Math.round(Number(minuten));
|
||
if (!Number.isFinite(m) || m <= 0) return "–";
|
||
if (m < 60) return m + " min";
|
||
return Math.floor(m / 60) + " h " + (m % 60) + " min";
|
||
}
|
||
|
||
/* --------------------------------------------------------------- Hinweise */
|
||
|
||
/*
|
||
* Erklärungen zu einer Zahl - woher sie stammt, was sie nicht abdeckt.
|
||
*
|
||
* Im title stehen sie am Zeiger gut, auf einem Berührungsgerät aber nirgends:
|
||
* Überfahren gibt es dort nicht, und der title erscheint nie. Das übliche
|
||
* Mittel dagegen ist, aus der Erklärung etwas Anfassbares zu machen - einen
|
||
* Auslöser, der Fokus annehmen kann, und ein Fenster, das beim nächsten
|
||
* Tippen daneben oder mit Esc wieder verschwindet. Genau das leistet ein
|
||
* Popover mit den Auslösern "focus hover": tippen öffnet, danebentippen
|
||
* schliesst, für Maus und Tastatur bleibt es beim Überfahren und Tabben.
|
||
*
|
||
* Damit das gefunden wird, trägt der Auslöser sichtbar ein Zeichen - die
|
||
* Klasse hat-hinweis setzt es. Eine Erklärung, die niemand vermutet, ist so
|
||
* gut wie keine.
|
||
*/
|
||
|
||
/** Erklärung an ein Element hängen; ohne Text wird sie wieder entfernt. */
|
||
function setzeHinweis(el, text) {
|
||
if (!el) return;
|
||
|
||
// Ohne Bootstrap bleibt der title. Weniger gut, aber nicht nichts.
|
||
if (typeof bootstrap === "undefined" || !bootstrap.Popover) {
|
||
if (text) el.title = text; else el.removeAttribute("title");
|
||
return;
|
||
}
|
||
|
||
const alt = bootstrap.Popover.getInstance(el);
|
||
if (alt) alt.dispose();
|
||
el.removeAttribute("title"); // sonst zeigt der Browser beides
|
||
|
||
if (!text) {
|
||
el.classList.remove("hat-hinweis");
|
||
["tabindex", "role", "aria-label"].forEach(a => el.removeAttribute(a));
|
||
return;
|
||
}
|
||
|
||
el.classList.add("hat-hinweis");
|
||
el.setAttribute("tabindex", "0"); // ohne Fokus kein Antippen auf iOS
|
||
el.setAttribute("role", "button");
|
||
el.setAttribute("aria-label", "Erklärung anzeigen");
|
||
new bootstrap.Popover(el, {
|
||
content: text,
|
||
trigger: "focus hover",
|
||
placement: "top",
|
||
container: "body", // sonst schneidet die Kachel ab
|
||
customClass: "skoda-erklaerung"
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Erklärungen unterhalb eines Knotens auflösen, bevor er neu gebaut wird.
|
||
* Ein offenes Fenster gehört zu seinem Auslöser - verschwindet der aus dem
|
||
* Dokument, bliebe es sonst am Bildschirm stehen.
|
||
*/
|
||
function loeseHinweise(wurzel) {
|
||
if (!wurzel || typeof bootstrap === "undefined" || !bootstrap.Popover) return;
|
||
wurzel.querySelectorAll(".hat-hinweis").forEach(function (el) {
|
||
const p = bootstrap.Popover.getInstance(el);
|
||
if (p) p.dispose();
|
||
});
|
||
}
|
||
|
||
async function hole(was, extra = "") {
|
||
const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" });
|
||
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
||
return antwort.json();
|
||
}
|
||
|
||
function leer(text) {
|
||
return '<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-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" },
|
||
{ id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" },
|
||
{ id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" }
|
||
];
|
||
|
||
/**
|
||
* Die Teile in der Fahrzeuggrafik mit ihren Einträgen koppeln - dieselbe
|
||
* Wirkung wie umgekehrt, nur von der Grafik aus angestossen. Die Gruppen
|
||
* stehen fest im Markup, also genügt einmaliges Verdrahten beim Seitenaufbau.
|
||
*/
|
||
function koppleFahrzeugTeile() {
|
||
document.querySelectorAll("#skodaAuto .teil[id]").forEach(function (knoten) {
|
||
const id = knoten.id;
|
||
knoten.addEventListener("pointerenter", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(id, true);
|
||
});
|
||
knoten.addEventListener("pointerleave", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(id, false);
|
||
});
|
||
knoten.addEventListener("click", () => hervorheben(id, "um"));
|
||
knoten.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hervorheben(id, "um"); }
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ein Fahrzeugteil und seinen Eintrag gemeinsam hervorheben.
|
||
*
|
||
* @param an true / false, oder "um" zum Umschalten (Tippen)
|
||
*/
|
||
function hervorheben(id, an) {
|
||
const knoten = $(id);
|
||
const chip = document.querySelector('.skoda-teile .chip[data-teil="' + id + '"]');
|
||
const soll = (an === "um") ? !(knoten && knoten.classList.contains("hervor")) : an;
|
||
if (knoten) knoten.classList.toggle("hervor", soll);
|
||
if (chip) chip.classList.toggle("hervor", soll);
|
||
}
|
||
|
||
function zeichneTeile(stand) {
|
||
const leiste = $("skodaTeile");
|
||
if (!leiste) return;
|
||
leiste.innerHTML = "";
|
||
|
||
TEILE.forEach(function (t) {
|
||
const wert = stand ? stand[t.feld] : null;
|
||
const knoten = $(t.id);
|
||
const anzeige = (wert === null || wert === undefined || wert === "")
|
||
? "unbekannt"
|
||
: (ZUSTANDSWORT[wert] || String(wert).toLowerCase());
|
||
|
||
const licht = t.feld === "lights" && wert === "ON";
|
||
const offen = !licht && wert && OFFEN_WERTE.indexOf(wert) >= 0;
|
||
|
||
if (knoten) {
|
||
knoten.classList.toggle("offen", !!offen);
|
||
knoten.classList.toggle("an", !!licht);
|
||
}
|
||
|
||
const chip = document.createElement("span");
|
||
chip.className = "chip" + (offen ? " offen" : "") + (licht ? " an" : "");
|
||
chip.textContent = t.name + ": " + anzeige;
|
||
chip.dataset.teil = t.id;
|
||
|
||
/*
|
||
* Eintrag und Fahrzeugteil zeigen aufeinander, damit auch ohne
|
||
* Farbunterscheidung klar ist, was gemeint ist.
|
||
*
|
||
* Mit Maus genügt das Überfahren. Auf einem Berührungsgerät gibt es das
|
||
* nicht - dort bleibt ein :hover nach dem Antippen sogar hängen. Deshalb
|
||
* schaltet ein Tippen die Hervorhebung um, und zwar auf beiden Seiten.
|
||
*/
|
||
chip.addEventListener("pointerenter", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(t.id, true);
|
||
});
|
||
chip.addEventListener("pointerleave", function (e) {
|
||
if (e.pointerType === "mouse") hervorheben(t.id, false);
|
||
});
|
||
chip.addEventListener("click", () => hervorheben(t.id, "um"));
|
||
leiste.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Live-Block */
|
||
|
||
/*
|
||
* Der Live-Block ist die Fahrzeugkachel: die Draufsicht mit dem Ladestand in
|
||
* der Akkuplatte und die Kennzahlen daneben.
|
||
*
|
||
* Er arbeitet in drei Schritten. liveWerte() sammelt aus MQTT und Tabelle
|
||
* einen Satz fertiger Werte, jede zeichne*-Funktion zeigt genau einen Bereich
|
||
* davon, und zeichneLive() ruft sie der Reihe nach auf. Keine der
|
||
* Zeichenfunktionen holt sich selbst etwas - sie bekommen alles übergeben und
|
||
* bleiben dadurch einzeln prüfbar und für sich verwendbar.
|
||
*/
|
||
|
||
let livePlanung = null;
|
||
function planeLiveAnzeige() {
|
||
if (livePlanung) return; // höchstens fünfmal je Sekunde
|
||
livePlanung = setTimeout(function () { livePlanung = null; zeichneLive(); }, 200);
|
||
}
|
||
|
||
let letzterStand = null; // letzte Zeile aus der Tabelle
|
||
|
||
/*
|
||
* Maße der Akkuplatte in Einheiten der viewBox. Sie stehen genau einmal hier
|
||
* und einmal im Markup (restricted/skoda.php); wer die Platte verschiebt,
|
||
* ändert beides.
|
||
*/
|
||
const AKKU = { oben: 108, hoehe: 270 };
|
||
|
||
/* Zustand der Platte. Die Klasse sitzt am <svg>, nicht an der Gruppe: der
|
||
Farbverlauf steht in <defs> und muss dieselbe Farbe erben. */
|
||
const AKKU_KLASSEN = ["akku-laedt", "akku-niedrig", "akku-unbekannt"];
|
||
|
||
/* Ladestand, unter dem die Platte rot wird. Reichweitenangst beginnt nicht
|
||
bei null, sondern dort, wo eine Fahrt zu planen ist. */
|
||
const AKKU_NIEDRIG = 15;
|
||
|
||
const LADEART = { AC: "Wechselstrom", DC: "Gleichstrom" };
|
||
|
||
const KLIMA = {
|
||
OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
|
||
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
|
||
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–"
|
||
};
|
||
/* Zustände, in denen die Klimatisierung tatsächlich arbeitet - nur dann hebt
|
||
sich ihre Kachel hervor. */
|
||
const KLIMA_LAEUFT = ["COOLING", "HEATING", "HEATING_AUXILIARY", "VENTILATION"];
|
||
|
||
/* Das Zeichen der Klimakachel sagt, was gerade geschieht, statt nur "Klima" zu
|
||
bebildern: Schneeflocke fürs Kühlen, Sonne fürs Heizen, Wind fürs Lüften.
|
||
Der Lüfter steht für alles Übrige - aus, fertig, unbekannt. */
|
||
const KLIMA_ZEICHEN = {
|
||
COOLING: "bi-snow",
|
||
HEATING: "bi-thermometer-sun",
|
||
HEATING_AUXILIARY: "bi-fire",
|
||
VENTILATION: "bi-wind",
|
||
COMPLETED: "bi-check2-circle"
|
||
};
|
||
const KLIMA_ZEICHEN_SONST = "bi-fan";
|
||
|
||
const LADEZUSTAND = {
|
||
CHARGING: ["lädt", "text-bg-success"],
|
||
CONSERVING: ["hält Ladung", "text-bg-secondary"],
|
||
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
|
||
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
|
||
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
|
||
DISCHARGING: ["entlädt", "text-bg-warning"]
|
||
};
|
||
|
||
/* ------------------------------------------------------- kleine Werkzeuge */
|
||
|
||
/** Zahl aus einem Tabellenfeld; leer, null und "" ergeben null statt 0. */
|
||
function feldZahl(wert) {
|
||
if (wert === null || wert === undefined || wert === "") return null;
|
||
const z = Number(wert);
|
||
return Number.isFinite(z) ? z : null;
|
||
}
|
||
|
||
/** Text eines Elements setzen, sofern die Seite es überhaupt hat. */
|
||
function setzeText(id, text) {
|
||
const el = $(id);
|
||
if (el) el.textContent = text;
|
||
}
|
||
|
||
/** Eine Klasse an einem Element setzen oder entfernen. */
|
||
function schalteKlasse(id, klasse, an) {
|
||
const el = $(id);
|
||
if (el) el.classList.toggle(klasse, !!an);
|
||
}
|
||
|
||
/** Genau eine Klasse aus einer Gruppe setzen, die übrigen entfernen. */
|
||
function setzeAusGruppe(el, gruppe, aktiv) {
|
||
if (!el) return;
|
||
gruppe.forEach(k => el.classList.toggle(k, k === aktiv));
|
||
}
|
||
|
||
/** "bis 14:20 Uhr" - der Zeitpunkt hinter der gemeldeten Restladezeit. */
|
||
function fertigUm(minuten) {
|
||
const m = Number(minuten);
|
||
if (!Number.isFinite(m) || m <= 0) return "";
|
||
const ende = new Date(Date.now() + m * 60000);
|
||
return "bis " + ende.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + " Uhr";
|
||
}
|
||
|
||
/** y-Kante eines Ladestands auf der Platte: 0 % unten, 100 % oben. */
|
||
function akkuKante(prozent) {
|
||
const p = Math.max(0, Math.min(100, Number(prozent) || 0));
|
||
return AKKU.oben + AKKU.hoehe * (1 - p / 100);
|
||
}
|
||
|
||
/** Ein Rechteck vom Boden der Platte bis zum Ladestand aufziehen. */
|
||
function setzeFuellstand(el, prozent) {
|
||
if (!el) return;
|
||
const y = akkuKante(prozent);
|
||
el.setAttribute("y", y.toFixed(1));
|
||
el.setAttribute("height", (AKKU.oben + AKKU.hoehe - y).toFixed(1));
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Sammelstelle */
|
||
|
||
/**
|
||
* Alles, was die Fahrzeugkachel zeigt, an einer Stelle zusammengetragen.
|
||
* MQTT hat überall Vorrang, die Tabelle ist der Rückfall - sie kann eine
|
||
* Stunde alt sein, MQTT ist von eben.
|
||
*/
|
||
function liveWerte() {
|
||
const stand = letzterStand;
|
||
|
||
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
|
||
const laedt = zustand === "CHARGING";
|
||
|
||
// Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter
|
||
// gemeint sein, ein Akku trägt keine 1500 Kilometer.
|
||
let km = mqZahl("evRange");
|
||
if (km === null && stand) {
|
||
const roh = feldZahl(stand.range_m);
|
||
if (roh !== null) km = roh > 1500 ? roh / 1000 : roh;
|
||
}
|
||
|
||
// Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am
|
||
// Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert.
|
||
const wbKw = mqZahl("evPower");
|
||
const carKw = stand ? feldZahl(stand.chg_kw) : null;
|
||
|
||
const pvW = mqZahl("P_PV");
|
||
|
||
return {
|
||
zustand: zustand,
|
||
laedt: laedt,
|
||
soc: mqZahl("evSOC") ?? (stand ? feldZahl(stand.soc) : null),
|
||
ziel: stand ? feldZahl(stand.target_soc) : null,
|
||
km: km,
|
||
kw: laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null),
|
||
pvKw: pvW === null ? null : Math.max(0, pvW / 1000),
|
||
restMin: mqZahl("evRemChrgTime") ?? (stand ? feldZahl(stand.chg_rem_min) : null),
|
||
ladeart: stand ? stand.chg_type : null,
|
||
odo: stand ? feldZahl(stand.odo_km) : null,
|
||
klima: stand ? stand.ac_state : null,
|
||
// null heißt: das Fahrzeug meldet den Zustand des Schlosses selbst nicht.
|
||
verriegelt: (stand && stand.locked !== null && stand.locked !== undefined)
|
||
? Number(stand.locked) === 1 : null,
|
||
/*
|
||
* Fährt gerade. Kommt allein aus der Tabelle - der Broker kennt den
|
||
* Parkzustand nicht, unter solarManager/ev* stehen nur Ladestand,
|
||
* Reichweite und Ladung. Der Sammler fragt das Fahrzeug unterwegs alle
|
||
* vier Minuten, die Seite lädt jede Minute nach: die Anzeige hinkt also
|
||
* um wenige Minuten hinterher. Für "rollt gerade" reicht das; ein
|
||
* Tachometer ist es nicht.
|
||
*/
|
||
faehrt: !!(stand && stand.park_state === "IN_MOTION")
|
||
};
|
||
}
|
||
|
||
/* --------------------------------------------------------- Zeichenschritte */
|
||
|
||
/**
|
||
* Die Akkuplatte im Fahrzeugboden - die Ladestandsanzeige der Seite.
|
||
*
|
||
* Gefüllt wird von hinten nach vorn, also im Bild von unten nach oben. Die
|
||
* Farbe kommt aus einer einzigen CSS-Variablen, die an der Zustandsklasse des
|
||
* <svg> hängt; hier wird nur die Klasse gewechselt.
|
||
*/
|
||
function zeichneAkku(w) {
|
||
const bild = $("skodaAuto");
|
||
if (!bild) return;
|
||
|
||
const bekannt = w.soc !== null && Number.isFinite(w.soc);
|
||
setzeAusGruppe(bild, AKKU_KLASSEN, !bekannt ? "akku-unbekannt"
|
||
: (w.laedt ? "akku-laedt" : (w.soc < AKKU_NIEDRIG ? "akku-niedrig" : null)));
|
||
|
||
const stand = bekannt ? w.soc : 0;
|
||
setzeFuellstand($("akkuFuellung"), stand);
|
||
setzeFuellstand($("akkuPegelFeld"), stand); // Schnittkante für die Strömung
|
||
|
||
// Die helle Kante sitzt auf der Füllung. Bei leerer Platte hat sie nichts
|
||
// zu begrenzen und bliebe sonst als Strich am Boden liegen.
|
||
const kante = $("akkuKante");
|
||
if (kante) {
|
||
kante.setAttribute("y", (akkuKante(stand) - 1.25).toFixed(1));
|
||
kante.style.display = (bekannt && stand > 0) ? "" : "none";
|
||
}
|
||
|
||
// Zielmarke. Gezeichnet ist sie auf 100 Prozent; verschoben wird die ganze
|
||
// Gruppe, damit Linie und Beschriftung beieinanderbleiben.
|
||
const marke = $("akkuZielMarke");
|
||
if (marke) {
|
||
marke.style.display = w.ziel ? "" : "none";
|
||
if (w.ziel) {
|
||
marke.style.transform = "translateY(" + (akkuKante(w.ziel) - AKKU.oben).toFixed(1) + "px)";
|
||
setzeText("akkuZielText", "Ziel " + nf(w.ziel) + " %");
|
||
}
|
||
}
|
||
|
||
setzeText("akkuText", bekannt ? nf(w.soc) : "–");
|
||
setzeText("akkuNeben", w.km === null ? "–" : nf(w.km) + " km");
|
||
|
||
// Der Titel der Gruppe ist Sprechblase und Vorlesetext zugleich - er nennt
|
||
// in Worten, was die Platte zeigt.
|
||
setzeText("akkuTitel", "Antriebsbatterie: "
|
||
+ (bekannt ? nf(w.soc) + " Prozent" : "Ladestand unbekannt")
|
||
+ (w.ziel ? ", Ladeziel " + nf(w.ziel) + " Prozent" : "")
|
||
+ (w.km === null ? "" : ", Reichweite " + nf(w.km) + " Kilometer")
|
||
+ (w.laedt ? ", lädt" : ""));
|
||
|
||
// Der Ladeanschluss pulst, der Weg zur Platte fließt - beides hängt an
|
||
// derselben Zustandsklasse wie die Füllung.
|
||
schalteKlasse("teil-plug", "laedt", w.laedt);
|
||
}
|
||
|
||
/**
|
||
* Der Klimaknopf im Fahrzeugbild. Sein Lüfterrad dreht sich, solange die
|
||
* Klimatisierung arbeitet - so zeigt der Knopf nicht nur, was er kann,
|
||
* sondern auch, ob es gerade geschieht.
|
||
*/
|
||
function zeichneKlimaknopf(w) {
|
||
schalteKlasse("skodaAuto", "klima-laeuft", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
|
||
}
|
||
|
||
/** Die Kacheln neben dem Fahrzeug: alles, was sich nicht zeichnen lässt. */
|
||
function zeichneKennzahlen(w) {
|
||
setzeText("skodaLeistung", w.kw ? nf(w.kw, 1) + " kW" : "–");
|
||
setzeText("skodaLadeart", (w.laedt && w.ladeart && LADEART[w.ladeart]) || "");
|
||
schalteKlasse("skodaKachelLeistung", "aktiv", w.laedt && w.kw);
|
||
|
||
setzeText("skodaRestzeit", w.laedt ? dauer(w.restMin) : "–");
|
||
setzeText("skodaFertig", w.laedt ? fertigUm(w.restMin) : "");
|
||
schalteKlasse("skodaKachelRestzeit", "aktiv", w.laedt && w.restMin > 0);
|
||
|
||
setzeText("skodaKm", w.odo ? nf(w.odo) + " km" : "–");
|
||
|
||
setzeText("skodaKlima", w.klima ? (KLIMA[w.klima] || w.klima) : "–");
|
||
schalteKlasse("skodaKachelKlima", "aktiv", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
|
||
const klimaZeichen = $("skodaKlimaZeichen");
|
||
if (klimaZeichen) {
|
||
klimaZeichen.className = "bi " + (KLIMA_ZEICHEN[w.klima] || KLIMA_ZEICHEN_SONST);
|
||
}
|
||
}
|
||
|
||
/** Die beiden Kennzeichen in der Kopfzeile: Schloss und Ladezustand. */
|
||
function zeichneAbzeichen(w) {
|
||
const zustand = $("skodaLadeZustand");
|
||
if (zustand) {
|
||
const eintrag = LADEZUSTAND[w.zustand] || [w.zustand || "–", "text-bg-secondary"];
|
||
zustand.textContent = eintrag[0];
|
||
zustand.className = "badge " + eintrag[1];
|
||
}
|
||
|
||
const schloss = $("skodaSchloss");
|
||
if (!schloss) return;
|
||
if (w.verriegelt === null) {
|
||
schloss.textContent = "Schloss unbekannt";
|
||
schloss.className = "badge text-bg-secondary";
|
||
} else if (w.verriegelt) {
|
||
schloss.textContent = "verriegelt";
|
||
schloss.className = "badge text-bg-success";
|
||
} else {
|
||
schloss.textContent = "offen";
|
||
schloss.className = "badge text-bg-warning";
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------ Ostergruß */
|
||
|
||
/*
|
||
* Ganz selten liegt eine Bananenschale auf der Fahrbahn.
|
||
*
|
||
* Geprüft wird alle 20 Sekunden, und nur während der Fahrt; die Chance liegt
|
||
* bei zwei Prozent. Das ist im Mittel einmal in gut sechzehn Minuten Fahrt -
|
||
* selten genug, dass man es kaum erwartet, und häufig genug, dass es
|
||
* irgendwann jemand sieht.
|
||
*
|
||
* Der Auftritt dauert anderthalb Sekunden, danach fällt die Klasse wieder ab.
|
||
* Wer Bewegungen abbestellt hat, bekommt ihn gar nicht erst - ein
|
||
* schleuderndes Auto ist genau das, was damit gemeint ist.
|
||
*/
|
||
const BANANE_TAKT = 20000;
|
||
const BANANE_CHANCE = 0.02;
|
||
const BANANE_DAUER = 1600;
|
||
|
||
function bananeVielleicht() {
|
||
const bild = $("skodaAuto");
|
||
if (!bild || !bild.classList.contains("faehrt")) return;
|
||
if (bild.classList.contains("banane")) return; // läuft schon
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||
if (Math.random() >= BANANE_CHANCE) return;
|
||
|
||
bild.classList.add("banane");
|
||
setTimeout(function () { bild.classList.remove("banane"); }, BANANE_DAUER);
|
||
}
|
||
|
||
/**
|
||
* Fahrt anzeigen: Fahrtwind am Fahrzeug, Abzeichen im Kopf der Kachel.
|
||
*
|
||
* Zwei Wege für dieselbe Aussage, und beide werden gebraucht. Die Streifen
|
||
* sieht man aus dem Augenwinkel, ohne zu lesen - dafür sind sie da. Wer die
|
||
* Seite vorgelesen bekommt oder Bewegungen abgeschaltet hat, hat von ihnen
|
||
* nichts; deshalb steht es zusätzlich als Wort neben Schloss und Ladezustand.
|
||
*/
|
||
function zeichneFahrt(w) {
|
||
const bild = $("skodaAuto");
|
||
if (bild) {
|
||
bild.classList.toggle("faehrt", w.faehrt);
|
||
// Hält der Wagen mitten im Schleudern an, bleibt sonst eine Schale auf
|
||
// der Fahrbahn liegen, die niemand mehr überfährt.
|
||
if (!w.faehrt) bild.classList.remove("banane");
|
||
}
|
||
const abzeichen = $("skodaUnterwegs");
|
||
if (abzeichen) {
|
||
abzeichen.hidden = !w.faehrt;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Herkunft des Ladestroms: was die Anlage im Augenblick liefert, gegen das,
|
||
* was die Wallbox zieht. Ohne Ladung hat der Balken nichts zu teilen und
|
||
* bleibt weg.
|
||
*/
|
||
function zeichneLadequelle(w) {
|
||
const box = $("skodaQuelleBox");
|
||
if (!box) return;
|
||
|
||
const zeigen = w.laedt && w.kw > 0 && w.pvKw !== null;
|
||
box.hidden = !zeigen;
|
||
if (!zeigen) return;
|
||
|
||
// Mehr als die Ladeleistung kann die Anlage nicht beitragen, auch wenn
|
||
// gerade mehr Sonne da ist - der Überschuss geht ins Haus oder ins Netz.
|
||
const ausPv = Math.max(0, Math.min(w.kw, w.pvKw));
|
||
const anteil = Math.round(100 * ausPv / w.kw);
|
||
|
||
$("skodaAnteilPV").style.width = anteil + "%";
|
||
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
|
||
setzeText("skodaQuelleText", nf(w.kw, 1) + " kW gesamt");
|
||
setzeText("skodaQuellePV", nf(ausPv, 1) + " kW");
|
||
setzeText("skodaQuelleNetz", nf(w.kw - ausPv, 1) + " kW");
|
||
}
|
||
|
||
/** Die Fahrzeugkachel neu zeichnen. */
|
||
function zeichneLive() {
|
||
const w = liveWerte();
|
||
zeichneAkku(w);
|
||
zeichneKlimaknopf(w);
|
||
zeichneKennzahlen(w);
|
||
zeichneAbzeichen(w);
|
||
zeichneFahrt(w);
|
||
zeichneLadequelle(w);
|
||
zeichneLadefenster(w);
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Diagramme */
|
||
|
||
let kurveChart = null;
|
||
let gesundChart = null;
|
||
|
||
const achsenFarbe = () => getComputedStyle(document.body).getPropertyValue("--bs-secondary-color") || "#9aa0a6";
|
||
const netzFarbe = "rgba(128,138,148,0.18)";
|
||
|
||
function baueKurve() {
|
||
kurveChart = new Chart($("skodaKurve"), {
|
||
type: "line",
|
||
data: { datasets: [] },
|
||
options: {
|
||
maintainAspectRatio: false,
|
||
animation: false,
|
||
// "nearest" statt "index": Wallbox-Verlauf und Fahrzeugmeldungen haben
|
||
// verschiedene Zeitpunkte, der n-te Wert der einen Reihe gehört nicht zum
|
||
// n-ten der anderen.
|
||
interaction: { mode: "nearest", axis: "x", intersect: false },
|
||
scales: {
|
||
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
||
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
||
// braeuchte einen Datums-Adapter, den es hier nicht gibt.
|
||
x: { type: "timestack", adapters: { date: { locale: "DE-de" } },
|
||
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
|
||
y: { title: { display: true, text: "kW", color: achsenFarbe() },
|
||
beginAtZero: true, grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
|
||
y1: { position: "right", min: 0, max: 100,
|
||
title: { display: true, text: "Ladestand %", color: achsenFarbe() },
|
||
grid: { drawOnChartArea: false }, ticks: { color: achsenFarbe() } }
|
||
},
|
||
plugins: { legend: { labels: { color: achsenFarbe() } } }
|
||
}
|
||
});
|
||
}
|
||
|
||
function baueGesundheit() {
|
||
gesundChart = new Chart($("skodaGesundheit"), {
|
||
type: "scatter",
|
||
data: { datasets: [] },
|
||
options: {
|
||
maintainAspectRatio: false,
|
||
animation: false,
|
||
scales: {
|
||
x: { type: "timestack", adapters: { date: { locale: "DE-de" } },
|
||
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } },
|
||
y: { title: { display: true, text: "kWh / 100 %", color: achsenFarbe() },
|
||
grid: { color: netzFarbe }, ticks: { color: achsenFarbe() } }
|
||
},
|
||
plugins: {
|
||
legend: { labels: { color: achsenFarbe() } },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: (c) => {
|
||
const p = c.raw;
|
||
if (p.soc_von === undefined) return c.dataset.label + ": " + nf(c.parsed.y, 1);
|
||
return nf(c.parsed.y, 1) + " kWh/100 % (" + p.soc_von + " → " + p.soc_bis
|
||
+ " %, " + nf(p.wh / 1000, 2) + " kWh)";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ Karte */
|
||
|
||
/*
|
||
* Leaflet-Karte mit den Kacheln aus dem eigenen Zwischenspeicher.
|
||
*
|
||
* Die Kachel-URL zeigt bewusst auf ajax/tile.php und nicht unmittelbar auf
|
||
* tile.openstreetmap.org: so verlässt die Parkposition - also die
|
||
* Wohnadresse - das Haus nur beim allerersten Betrachten einer Gegend, und
|
||
* einmal geholte Kacheln bleiben auch ohne Internet verfügbar. Warum das so
|
||
* gebaut ist und was die Kachelrichtlinie verlangt, steht in tile.php.
|
||
*
|
||
* Die Fahrtlinien sind gestrichelt. Die API liefert nur die letzte bekannte
|
||
* PARKposition, also Start und Ziel einer Fahrt; die gefahrene Strecke kennt
|
||
* niemand. Eine durchgezogene Linie auf einer Straßenkarte würde behaupten,
|
||
* das Auto sei genau dort langgefahren.
|
||
*/
|
||
|
||
const KARTE_ZOOM_MIN = 6; // muss zu ZOOM_MIN/ZOOM_MAX in tile.php passen
|
||
const KARTE_ZOOM_MAX = 18;
|
||
|
||
let karte = null; // die Leaflet-Karte, einmal angelegt
|
||
let karteEbene = null; // Marker und Linien, wird bei jeder Auffrischung ersetzt
|
||
let karteAusgerichtet = false; // beim ersten Mal auf die Daten zoomen, danach nicht mehr
|
||
|
||
/*
|
||
* Verknüpfung zwischen der Fahrtenliste und der Karte: Nummer der Fahrt im
|
||
* Feld letzteFahrten -> ihre Linie samt der beiden Stilvarianten. Wird bei
|
||
* jedem Füllen der Karte neu aufgebaut, weil die Linien dabei verworfen
|
||
* werden.
|
||
*/
|
||
let fahrtLinien = {};
|
||
|
||
/** Nummer der gerade gewählten Fahrt, oder null. */
|
||
let gewaehlteFahrt = null;
|
||
|
||
/*
|
||
* Die drei Zustände einer Fahrtlinie.
|
||
*
|
||
* Blau kennzeichnet die jüngste Fahrt, damit man sie ohne Suchen findet.
|
||
* Bernstein ist die Auswahl und muss das Blau überstimmen können - sonst
|
||
* bliebe unklar, welche Linie zur angeklickten Zeile gehört, sobald die
|
||
* jüngste gewählt ist. Bernstein steht auf dieser Seite ohnehin schon für
|
||
* "sieh hier hin": in derselben Farbe leuchten offene Türen am Fahrzeug.
|
||
*/
|
||
const FAHRT_STIL = {
|
||
normal: { color: "#8a949c", weight: 2, opacity: 0.55 },
|
||
juengste: { color: "#4b8fd6", weight: 3, opacity: 0.95 },
|
||
gewaehlt: { color: "#e0904f", weight: 5, opacity: 1 }
|
||
};
|
||
|
||
/**
|
||
* Den Stil einer Linie setzen. Die Auswahl gewinnt vor dem Überfahren, das
|
||
* Überfahren vor dem Grundzustand.
|
||
*
|
||
* Ohne diese eine Stelle für alle drei Zustände würde ein Mausklick auf eine
|
||
* gewählte Linie sie beim Verlassen wieder zurücksetzen - der Behandler für
|
||
* mouseout weiss sonst nichts von der Auswahl.
|
||
*/
|
||
function stilAnwenden(nummer, ueberfahren) {
|
||
const eintrag = fahrtLinien[nummer];
|
||
if (!eintrag) return;
|
||
if (nummer === gewaehlteFahrt) {
|
||
eintrag.linie.setStyle(FAHRT_STIL.gewaehlt).bringToFront();
|
||
} else if (ueberfahren) {
|
||
eintrag.linie.setStyle(Object.assign({}, eintrag.grund,
|
||
{ weight: eintrag.grund.weight + 2, opacity: 1 }));
|
||
} else {
|
||
eintrag.linie.setStyle(eintrag.grund);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Die Karte anlegen. Wird einmal beim Seitenaufbau gerufen; danach kümmert
|
||
* sich karteFuellen() um den Inhalt.
|
||
*/
|
||
function karteAufbauen() {
|
||
const feld = document.getElementById("skodaKarte");
|
||
if (!feld || typeof L === "undefined") return;
|
||
|
||
karte = L.map(feld, {
|
||
minZoom: KARTE_ZOOM_MIN,
|
||
maxZoom: KARTE_ZOOM_MAX,
|
||
zoomControl: true,
|
||
// Das Mausrad gehört auf dieser langen Seite dem Scrollen. Zoomen geht
|
||
// mit den Schaltflächen, per Doppelklick oder mit Strg und Mausrad.
|
||
scrollWheelZoom: false
|
||
}).setView([51.0, 10.0], 6);
|
||
|
||
// Leaflets Vorspann enthält von Haus aus eine Flaggengrafik. Die Nennung
|
||
// der Bibliothek bleibt, das Bild kommt weg.
|
||
karte.attributionControl.setPrefix(
|
||
'<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: '© <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 || "?") + " → " + textSicher(f.nach || "?") + "</b><br>"
|
||
+ 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(
|
||
"<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 – letzte bekannte Parkposition"
|
||
: "geparkt seit " + zeitpunkt(standort.datetime, true))
|
||
));
|
||
}
|
||
|
||
if (!karteAusgerichtet && punkte.length) {
|
||
karteAusgerichtet = true;
|
||
// Vor dem Ausrichten nachmessen: die Zoomstufe folgt aus der
|
||
// Containergröße, und die stimmt erst, wenn das Layout steht.
|
||
karte.invalidateSize();
|
||
// animate: false ist hier kein Schönheitsfehler, sondern Absicht. Eine
|
||
// Zoomfahrt von der Startansicht bis zum Ziel fordert jede Zwischenstufe
|
||
// an Kacheln an - bei einem Sprung über mehrere Stufen also ein Vielfaches
|
||
// dessen, was am Ende zu sehen ist. Alles davon ginge einmal nach draußen.
|
||
if (punkte.length === 1) {
|
||
karte.setView(punkte[0], 15, { animate: false });
|
||
} else {
|
||
// maxZoom, damit eine einzelne eng beieinanderliegende Gruppe nicht
|
||
// bis auf Hausnummernebene aufgezogen wird.
|
||
karte.fitBounds(L.latLngBounds(punkte), {
|
||
padding: [24, 24], maxZoom: 14, animate: false
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Eine Fahrt aus der Liste auf der Karte zeigen: Ausschnitt auf ihre beiden
|
||
* Enden, Linie hervorheben, Sprechblase öffnen.
|
||
*
|
||
* Ohne animate: false zöge die Karte über jede Zwischenstufe hinweg und
|
||
* fordert dabei deren Kacheln an - siehe die Begründung bei karteFuellen().
|
||
*/
|
||
/**
|
||
* Eine Fahrt auswählen - der einzige Weg dorthin, gleich ob der Klick in der
|
||
* Liste oder auf der Karte kam. Beide Seiten werden hier zusammen gesetzt,
|
||
* sonst laufen Zeilenmarkierung und Linienfarbe auseinander.
|
||
*
|
||
* @param nummer Nummer der Fahrt in letzteFahrten
|
||
* @param hinzoomen Ausschnitt auf die Fahrt legen. Beim Klick auf der Karte
|
||
* unerwünscht: dort sieht man sie ja schon, und der Sprung
|
||
* risse einem den selbst gewählten Ausschnitt weg.
|
||
*/
|
||
function waehleFahrt(nummer, hinzoomen) {
|
||
const eintrag = fahrtLinien[nummer];
|
||
if (!karte || !eintrag) return;
|
||
|
||
const vorher = gewaehlteFahrt;
|
||
gewaehlteFahrt = nummer;
|
||
if (vorher !== null && vorher !== nummer) {
|
||
stilAnwenden(vorher, false); // alte Auswahl zurücksetzen
|
||
}
|
||
stilAnwenden(nummer, false);
|
||
|
||
if (hinzoomen) {
|
||
karte.fitBounds(L.latLngBounds(eintrag.ecken), {
|
||
padding: [40, 40], maxZoom: 13, animate: false
|
||
});
|
||
}
|
||
eintrag.linie.openPopup();
|
||
markiereFahrtZeile(nummer);
|
||
}
|
||
|
||
/* Vom Betrachter gewählte Einheit: "kwh" oder "prozent". */
|
||
const EINHEIT_SPEICHER = "skodaVerbrauchEinheit";
|
||
|
||
/**
|
||
* Einheit umschalten. Sichtbar wird die Auswahl über eine Klasse auf der
|
||
* Kachel; die Zeilen selbst bleiben unberührt und überstehen damit die
|
||
* minütliche Auffrischung der Liste.
|
||
*
|
||
* Die Wahl liegt im Browser des Betrachters, nicht auf dem Server - sie geht
|
||
* niemanden sonst etwas an. Lesen und Schreiben stehen in try/catch, weil
|
||
* der Zugriff in privaten Fenstern und bei gesperrten Seitendaten wirft.
|
||
*/
|
||
function setzeEinheit(einheit, merken) {
|
||
const kachel = $("skodaFahrtenKachel");
|
||
if (!kachel) return;
|
||
const kwh = einheit !== "prozent";
|
||
kachel.classList.toggle("zeigt-kwh", kwh);
|
||
kachel.classList.toggle("zeigt-prozent", !kwh);
|
||
|
||
const schalter = $("skodaEinheit");
|
||
if (schalter) {
|
||
schalter.querySelectorAll("button").forEach(function (b) {
|
||
const aktiv = (b.dataset.einheit === "kwh") === kwh;
|
||
b.classList.toggle("aktiv", aktiv);
|
||
b.setAttribute("aria-pressed", aktiv ? "true" : "false");
|
||
});
|
||
}
|
||
if (merken) {
|
||
try { localStorage.setItem(EINHEIT_SPEICHER, kwh ? "kwh" : "prozent"); } catch (e) { }
|
||
}
|
||
}
|
||
|
||
/** Umschalter verdrahten und die zuletzt gewählte Einheit wiederherstellen. */
|
||
function koppleEinheitSchalter() {
|
||
const schalter = $("skodaEinheit");
|
||
if (!schalter) return;
|
||
schalter.addEventListener("click", function (e) {
|
||
const knopf = e.target.closest("button[data-einheit]");
|
||
if (knopf) setzeEinheit(knopf.dataset.einheit, true);
|
||
});
|
||
let gemerkt = null;
|
||
try { gemerkt = localStorage.getItem(EINHEIT_SPEICHER); } catch (e) { }
|
||
setzeEinheit(gemerkt || "kwh", false);
|
||
}
|
||
|
||
/**
|
||
* In der Überschrift der Fahrtenliste offenlegen, worauf die
|
||
* Kilowattstunden beruhen. Ohne das steht dort eine Zahl, deren Herkunft
|
||
* niemand mehr nachvollziehen kann - sie ist ja nicht gemessen, sondern aus
|
||
* dem Ladestand und der eigenen Ladehistorie gerechnet.
|
||
*/
|
||
function zeigeVerbrauchsbasis() {
|
||
const feld = $("skodaVerbrauchBasis");
|
||
if (!feld) return;
|
||
|
||
const schalter = $("skodaEinheit");
|
||
if (!letzteKapazitaet) {
|
||
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
|
||
feld.textContent = "";
|
||
setzeHinweis(feld, null);
|
||
if (schalter) schalter.style.display = "none";
|
||
setzeEinheit("prozent", false);
|
||
return;
|
||
}
|
||
if (schalter) schalter.style.display = "";
|
||
|
||
const k = letzteKapazitaet;
|
||
feld.textContent = nf(k.je_soc_netto * 1000, 0) + " Wh je Prozent";
|
||
|
||
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
|
||
// Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten.
|
||
setzeHinweis(feld, "Aus " + k.n + " Ladungen des letzten Jahres: "
|
||
+ nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, "
|
||
+ nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad "
|
||
+ nf(k.wirkungsgrad * 100, 1) + " %, "
|
||
+ (k.gemessen
|
||
? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem "
|
||
+ "Ladegerät, das Fahrzeug meldet dahinter."
|
||
: "angenommen, weil noch zu wenige Ladungen die Ladeleistung des "
|
||
+ "Fahrzeugs mitgeliefert haben."));
|
||
}
|
||
|
||
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
|
||
function markiereFahrtZeile(nummer) {
|
||
const liste = $("skodaFahrten");
|
||
if (!liste) return;
|
||
liste.querySelectorAll(".zeile.gewaehlt").forEach(z => z.classList.remove("gewaehlt"));
|
||
const zeile = liste.querySelector('.zeile[data-fahrt="' + nummer + '"]');
|
||
if (zeile) {
|
||
zeile.classList.add("gewaehlt");
|
||
zeile.scrollIntoView({ block: "nearest" }); // bei Klick auf der Karte
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Text für eine Sprechblase entschärfen. Adressen kommen von einem fremden
|
||
* Dienst; ohne das ginge fremdes Markup ungefiltert ins Dokument.
|
||
*/
|
||
function textSicher(s) {
|
||
const knoten = document.createElement("div");
|
||
knoten.textContent = String(s);
|
||
return knoten.innerHTML;
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Abschnitte */
|
||
|
||
async function ladeLive() {
|
||
try {
|
||
const d = await hole("live");
|
||
letzterStand = d.stand;
|
||
zeichneTeile(d.stand);
|
||
zeichneLive();
|
||
|
||
const alterText = $("skodaAlter");
|
||
if (!d.stand) {
|
||
alterText.textContent = "Noch kein Fahrzeug angebunden";
|
||
} else {
|
||
alterText.textContent = "Stand des Fahrzeugs: " + seit(d.alter_s)
|
||
+ " (" + zeitpunkt(d.stand.datetime) + ")";
|
||
}
|
||
} catch (e) {
|
||
$("skodaAlter").textContent = "Daten nicht abrufbar";
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Fahrleistung. Die Namen der Zeiträume stehen hier, ihre Länge in Tagen
|
||
* kommt vom Server mit - so muss nur eine Seite geändert werden, wenn ein
|
||
* Zeitraum hinzukommt.
|
||
*/
|
||
const STRECKE_ZEITRAEUME = { woche: "7 Tage", monat: "30 Tage", jahr: "12 Monate" };
|
||
|
||
async function ladeStrecke() {
|
||
const feld = $("skodaFahrleistung");
|
||
if (!feld) return;
|
||
loeseHinweise(feld);
|
||
try {
|
||
const d = await hole("strecke");
|
||
feld.innerHTML = Object.keys(STRECKE_ZEITRAEUME).map(function (name) {
|
||
const p = d.strecke ? d.strecke[name] : null;
|
||
const km = (p && p.km !== null && p.km !== undefined) ? nf(p.km) + " km" : "–";
|
||
|
||
/*
|
||
* Reicht die Aufzeichnung nicht über den ganzen Zeitraum, ist die Zahl
|
||
* kein Jahreswert, sondern nur das, was aufgezeichnet ist. Sie deshalb
|
||
* wegzulassen wäre falsch - sie kommentarlos als Jahr auszugeben aber
|
||
* auch. Also gestrichelt unterstrichen, mit dem Anfang im Titel.
|
||
*/
|
||
const knapp = p && p.km !== null && !p.vollstaendig;
|
||
return '<div class="posten' + (knapp ? " knapp" : "") + '">'
|
||
+ '<span class="v">' + km + '</span>'
|
||
+ '<span class="k">' + STRECKE_ZEITRAEUME[name] + "</span></div>";
|
||
}).join("");
|
||
|
||
// Die knappen Zeiträume tragen ihre Erklärung selbst - antippbar.
|
||
feld.querySelectorAll(".posten.knapp").forEach(function (posten) {
|
||
setzeHinweis(posten, "Aufgezeichnet wird erst seit " + zeitpunkt(d.seit, true)
|
||
+ ". Der Wert deckt deshalb nicht den ganzen Zeitraum ab, sondern nur "
|
||
+ "das, was seither aufgezeichnet ist.");
|
||
});
|
||
} catch (e) {
|
||
feld.innerHTML = '<div class="posten"><span class="v">–</span>'
|
||
+ '<span class="k">nicht abrufbar</span></div>';
|
||
}
|
||
}
|
||
|
||
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();
|
||
|
||
// Mit Wallbox-Verlauf (skoda_ladepunkte): die Leistung im Minutentakt,
|
||
// der Ladestand dazwischen nach der geflossenen Energie aufgefüllt, und
|
||
// die echten Meldungen des Fahrzeugs als Punkte darauf - damit sichtbar
|
||
// bleibt, was gemessen und was abgeleitet ist. Das Fahrzeug meldet beim
|
||
// Laden mitunter nur einmal in der Stunde; eine Linie durch diese Punkte
|
||
// allein erfände den Verlauf dazwischen.
|
||
if (d.verlauf && d.verlauf.length) {
|
||
const imBild = (m) => zeit(m) >= zeit(d.verlauf[0]) - 600000
|
||
&& zeit(m) <= zeit(d.verlauf[d.verlauf.length - 1]) + 600000;
|
||
const meldungen = (d.meldungen || []).filter(imBild);
|
||
kurveChart.data.datasets = [
|
||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: 0,
|
||
data: d.verlauf.map(p => ({ x: zeit(p), y: +p.kw })) },
|
||
{ label: "Ladestand, aufgefüllt", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
|
||
borderDash: [5, 4], pointRadius: 0, tension: 0, spanGaps: false,
|
||
data: d.verlauf.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) },
|
||
{ label: "Ladestand laut Fahrzeug", yAxisID: "y1", showLine: false,
|
||
borderColor: "#f0b429", backgroundColor: "#f0b429", pointRadius: 4, pointHoverRadius: 6,
|
||
data: meldungen.map(m => ({ x: zeit(m), y: +m.soc })) },
|
||
{ label: "Leistung laut Fahrzeug", yAxisID: "y", showLine: false,
|
||
borderColor: "#7aa7c7", backgroundColor: "#7aa7c7", pointRadius: 3, pointStyle: "rectRot",
|
||
data: meldungen.filter(m => m.chg_kw !== null && m.chg_state === "CHARGING")
|
||
.map(m => ({ x: zeit(m), y: +m.chg_kw })) }
|
||
];
|
||
kurveChart.update();
|
||
return;
|
||
}
|
||
|
||
// Ohne Wallbox-Verlauf - Ladungen von vor der Aufzeichnung - wie bisher
|
||
// aus den Zeilen der Fahrzeugtabelle.
|
||
kurveChart.data.datasets = [
|
||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
||
data: d.punkte.map(p => ({ x: zeit(p), y: p.wb_kw === null ? null : +p.wb_kw })) },
|
||
{ label: "Fahrzeug", yAxisID: "y", borderColor: "#7aa7c7", borderWidth: 2,
|
||
pointRadius: 0, borderDash: [4, 3], tension: .25,
|
||
data: d.punkte.map(p => ({ x: zeit(p), y: p.chg_kw === null ? null : +p.chg_kw })) },
|
||
{ label: "Ladestand", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
|
||
pointRadius: 0, tension: .1,
|
||
data: d.punkte.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) }
|
||
];
|
||
kurveChart.update();
|
||
} catch (e) {
|
||
$("skodaKurveZeit").textContent = "nicht abrufbar";
|
||
}
|
||
}
|
||
|
||
async function ladeGesundheit() {
|
||
try {
|
||
const d = await hole("gesundheit", "&tage=3650");
|
||
const befund = $("skodaBefund");
|
||
|
||
if (!d.ladungen.length) {
|
||
gesundChart.data.datasets = [];
|
||
gesundChart.update();
|
||
befund.innerHTML = '<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) {
|
||
if (bereich === "laden") {
|
||
zeigeLadefenster();
|
||
return;
|
||
}
|
||
const b = STEUERUNG[bereich];
|
||
if (!b || !steuerModal) return;
|
||
|
||
document.getElementById("modal-title").textContent = b.titel;
|
||
const speichern = document.getElementById("modalSaveBtn");
|
||
if (speichern) speichern.style.display = "none";
|
||
|
||
const rumpf = document.getElementById("modal-body");
|
||
let html = '<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"> °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; });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------ Ladefenster */
|
||
|
||
/*
|
||
* Laden im Stil der Bedienfenster der Solarseite (.lade in css/solar.css):
|
||
* links der Ring mit Ladestand und Ladeziel und der eine grosse Knopf zum
|
||
* Starten oder Stoppen, rechts die Wahl des Ladeziels.
|
||
*
|
||
* Jeder Befehl kostet eine der vier Anfragen je Stunde (skodaCmd.php). Das
|
||
* Ladeziel wird deshalb nicht bei jedem Antippen geschickt, sondern erst,
|
||
* wenn eine Weile keine andere Stufe mehr gewaehlt wurde. Bis das Fahrzeug
|
||
* das neue Ziel meldet - das dauert bis zum naechsten Abruf -, steht die
|
||
* eigene Wahl im Fenster und ist als "wird übernommen" gekennzeichnet.
|
||
*/
|
||
const LADEZIELE = [50, 60, 70, 80, 90, 100];
|
||
const LADEZIEL_WARTEN_MS = 1500;
|
||
const LADEZIEL_HALTEN_MS = 15 * 60 * 1000;
|
||
let ladezielWunsch = null; // { prozent, seit, gesendet }
|
||
let ladezielUhr = null;
|
||
|
||
function zeigeLadefenster() {
|
||
if (!steuerModal) return;
|
||
document.getElementById("modal-title").textContent = "Laden";
|
||
const speichern = document.getElementById("modalSaveBtn");
|
||
if (speichern) speichern.style.display = "none";
|
||
|
||
const S = "\u2009";
|
||
document.getElementById("modal-body").innerHTML =
|
||
'<div class="lade skoda-lade" id="skodaLadefenster">'
|
||
+ '<div class="lade-links">'
|
||
+ '<div class="lade-status">'
|
||
+ '<div class="lade-ring" aria-hidden="true"><svg viewBox="0 0 120 120">'
|
||
+ '<circle class="lade-spur" cx="60" cy="60" r="50" pathLength="100"/>'
|
||
+ '<circle class="lade-ziel" cx="60" cy="60" r="50" pathLength="100"/>'
|
||
+ '<circle class="lade-ist" cx="60" cy="60" r="50" pathLength="100"/>'
|
||
+ '</svg><div class="lade-ring-mitte"><b data-sl="soc">–</b><span data-sl="ringZiel"></span></div></div>'
|
||
+ '<div class="lade-text">'
|
||
+ '<div class="lade-auto">Antriebsbatterie <span class="lade-pille" data-sl="zustand"></span></div>'
|
||
+ '<div class="lade-gross" data-sl="gross">–</div>'
|
||
+ '<div class="lade-zeile" data-sl="zeile1"></div>'
|
||
+ '<div class="lade-zeile" data-sl="zeile2"></div>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<button type="button" class="lade-start" data-sl="start"><span class="lade-punkt"></span><span data-sl="startText">…</span></button>'
|
||
+ '<div class="lade-erklaerung" data-sl="erklaerung"></div>'
|
||
+ '</div>'
|
||
+ '<div class="lade-rechts">'
|
||
+ '<div class="lade-abschnitt">'
|
||
+ '<h3 id="skodaLadezielTitel">Ladeziel</h3>'
|
||
+ '<div class="lade-strom skoda-ziele" role="radiogroup" aria-labelledby="skodaLadezielTitel">'
|
||
+ LADEZIELE.map(p => '<button type="button" role="radio" aria-checked="false" data-ziel="' + p + '"><b>' + p + S + '%</b><span>'
|
||
+ (p === 80 ? "schonend" : p === 100 ? "Reise" : "") + '</span></button>').join("")
|
||
+ '</div>'
|
||
+ '<div class="lade-zeile" data-sl="zielHinweis"></div>'
|
||
+ '</div>'
|
||
+ '<div class="lade-meldung" data-sl="meldung" aria-live="polite"></div>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
|
||
const fenster = $("skodaLadefenster");
|
||
fenster.querySelectorAll("[data-ziel]").forEach(k => k.addEventListener("click", () => {
|
||
ladezielWunsch = { prozent: Number(k.dataset.ziel), seit: Date.now(), gesendet: false };
|
||
zeichneLadefenster(liveWerte());
|
||
clearTimeout(ladezielUhr);
|
||
ladezielUhr = setTimeout(ladezielSenden, LADEZIEL_WARTEN_MS);
|
||
}));
|
||
fenster.querySelector("[data-sl=start]").addEventListener("click", () => {
|
||
const w = liveWerte();
|
||
skodaBefehl(w.laedt ? "laden_stop" : "laden_start", {});
|
||
});
|
||
ladefensterMeldung("");
|
||
zeichneLadefenster(liveWerte());
|
||
steuerModal.show();
|
||
}
|
||
|
||
function ladefensterMeldung(text, art) {
|
||
const m = document.querySelector("#skodaLadefenster [data-sl=meldung]");
|
||
if (!m) return;
|
||
m.className = "lade-meldung" + (art ? " " + art : "");
|
||
m.textContent = text || "Jeder Befehl ist einer von vier je Stunde. Was das Fahrzeug übernimmt, erscheint beim nächsten Abruf.";
|
||
}
|
||
|
||
async function ladezielSenden() {
|
||
if (!ladezielWunsch || ladezielWunsch.gesendet) return;
|
||
const w = liveWerte();
|
||
// Schon so eingestellt: nichts schicken, keinen Befehl verbrauchen.
|
||
if (w.ziel === ladezielWunsch.prozent) {
|
||
ladezielWunsch = null;
|
||
zeichneLadefenster(w);
|
||
return;
|
||
}
|
||
ladezielWunsch.gesendet = true;
|
||
const ok = await skodaBefehl("ladeziel", { prozent: ladezielWunsch.prozent });
|
||
if (!ok) ladezielWunsch = null;
|
||
zeichneLadefenster(liveWerte());
|
||
}
|
||
|
||
/** Einen Befehl schicken; waehrenddessen ist das Fenster gesperrt. */
|
||
async function skodaBefehl(befehl, extra) {
|
||
const fenster = $("skodaLadefenster");
|
||
if (!fenster) return false;
|
||
const knoepfe = fenster.querySelectorAll("button");
|
||
fenster.dataset.sendet = "1";
|
||
knoepfe.forEach(k => { k.disabled = true; });
|
||
ladefensterMeldung("Befehl wird gesendet …", "warte");
|
||
let ok = false;
|
||
try {
|
||
const antwort = await fetch("ajax/skodaCmd.php", {
|
||
method: "POST", body: new URLSearchParams(Object.assign({ befehl: befehl }, extra)), cache: "no-store"
|
||
});
|
||
const d = await antwort.json();
|
||
ok = !!d.ok;
|
||
ladefensterMeldung(d.text, d.ok ? "gut" : "fehler");
|
||
// Der Zustand aendert sich im Fahrzeug, nicht in der Tabelle - der
|
||
// Abruf danach bringt ihn.
|
||
if (d.ok) setTimeout(ladeLive, 20000);
|
||
} catch (e) {
|
||
ladefensterMeldung("Der Server hat nicht geantwortet.", "fehler");
|
||
} finally {
|
||
delete fenster.dataset.sendet;
|
||
knoepfe.forEach(k => { k.disabled = false; });
|
||
zeichneLadefenster(liveWerte());
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
/** Das offene Ladefenster mit den Live-Werten nachziehen. */
|
||
function zeichneLadefenster(w) {
|
||
const fenster = $("skodaLadefenster");
|
||
if (!fenster) return;
|
||
const feld = name => fenster.querySelector("[data-sl=" + name + "]");
|
||
const S = "\u2009";
|
||
|
||
// Die eigene Wahl gilt, bis das Fahrzeug sie meldet - oder zu lange nicht.
|
||
if (ladezielWunsch && (w.ziel === ladezielWunsch.prozent || Date.now() - ladezielWunsch.seit > LADEZIEL_HALTEN_MS)) {
|
||
ladezielWunsch = null;
|
||
}
|
||
const ziel = ladezielWunsch ? ladezielWunsch.prozent : w.ziel;
|
||
const soc = w.soc !== null && Number.isFinite(w.soc) ? w.soc : null;
|
||
|
||
fenster.querySelector(".lade-ist").style.strokeDasharray = (soc === null ? 0 : Math.max(0, Math.min(100, soc))) + " 100";
|
||
fenster.querySelector(".lade-ziel").style.strokeDasharray = (ziel ? ziel : 0) + " 100";
|
||
feld("soc").textContent = soc === null ? "–" : nf(soc) + S + "%";
|
||
feld("ringZiel").textContent = ziel ? "Ziel " + ziel + S + "%" : "";
|
||
|
||
const zustand = LADEZUSTAND[w.zustand];
|
||
const pille = feld("zustand");
|
||
pille.textContent = zustand ? zustand[0] : "";
|
||
pille.className = "lade-pille" + (w.laedt ? " an" : w.zustand === "CHARGING_INTERRUPTED" ? " warn" : "");
|
||
|
||
feld("gross").innerHTML = w.laedt && w.kw
|
||
? nf(w.kw, 1) + S + "kW<small>lädt</small>"
|
||
: (w.km === null ? "–" : nf(w.km) + S + "km<small>Reichweite</small>");
|
||
let fertig = "";
|
||
if (w.laedt && w.restMin > 0) {
|
||
const um = new Date(Date.now() + w.restMin * 60000);
|
||
fertig = "fertig um " + String(um.getHours()).padStart(2, "0") + ":" + String(um.getMinutes()).padStart(2, "0");
|
||
}
|
||
feld("zeile1").textContent = w.laedt ? (w.km === null ? "" : nf(w.km) + S + "km Reichweite") : "";
|
||
feld("zeile2").textContent = fertig;
|
||
|
||
// Ohne Kabel lehnt das Fahrzeug den Start ab - und der Versuch kostete
|
||
// trotzdem einen der vier Befehle.
|
||
const start = feld("start");
|
||
const ohneKabel = !w.laedt && w.zustand === "CONNECT_CABLE";
|
||
start.classList.toggle("laeuft", w.laedt);
|
||
start.classList.toggle("aus", ohneKabel);
|
||
if (!fenster.dataset.sendet) start.disabled = ohneKabel;
|
||
feld("startText").textContent = w.laedt ? "Laden stoppen" : ohneKabel ? "Kabel anstecken" : "Laden starten";
|
||
feld("erklaerung").textContent = w.zustand === "CONNECT_CABLE"
|
||
? "Kein Kabel angesteckt. Das Ladeziel lässt sich trotzdem schon einstellen."
|
||
: "Startet oder stoppt das Laden am Fahrzeug. Die Wallbox bleibt davon unberührt – sie gibt weiter frei, was sie freigibt.";
|
||
|
||
fenster.querySelectorAll("[data-ziel]").forEach(k =>
|
||
k.setAttribute("aria-checked", String(Number(k.dataset.ziel) === ziel)));
|
||
feld("zielHinweis").textContent = ladezielWunsch
|
||
? (ladezielWunsch.gesendet ? "wird vom Fahrzeug übernommen …" : "wird gleich geschickt …")
|
||
: (ziel ? "Das Fahrzeug hört bei " + ziel + S + "% auf zu laden." : "Das Fahrzeug meldet noch kein Ladeziel.");
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ Start */
|
||
|
||
function alleLaden() {
|
||
ladeLive();
|
||
ladeStrecke();
|
||
ladeLadungen();
|
||
ladeKurve();
|
||
ladeGesundheit();
|
||
ladeFahrten();
|
||
}
|
||
|
||
document.addEventListener("readystatechange", function () {
|
||
if (document.readyState !== "complete") return;
|
||
|
||
baueKurve();
|
||
baueGesundheit();
|
||
karteAufbauen();
|
||
koppleFahrtenListe();
|
||
koppleEinheitSchalter();
|
||
koppleFahrzeugTeile();
|
||
koppleSteuerung();
|
||
zeichneTeile(null);
|
||
zeichneLive();
|
||
alleLaden();
|
||
solarMQTT.getMQTT();
|
||
|
||
// Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters:
|
||
// beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt.
|
||
setInterval(ladeLive, 60000);
|
||
setInterval(function () {
|
||
ladeLadungen(); ladeKurve(); ladeFahrten(); ladeStrecke();
|
||
}, 300000);
|
||
setInterval(ladeGesundheit, 900000);
|
||
setInterval(bananeVielleicht, BANANE_TAKT);
|
||
|
||
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
|
||
// sein Container eine andere Größe hat.
|
||
let umbruch = null;
|
||
window.addEventListener("resize", function () {
|
||
clearTimeout(umbruch);
|
||
umbruch = setTimeout(() => karte && karte.invalidateSize(), 200);
|
||
});
|
||
});
|