/* ================================================================
Audi-Dashboard — panel_custom Custom Element
================================================================
Umbau von dashboard-muster-ohne-bilder.html (der verbindliche Prototyp
aus dem Lastenheft) zu einem panel_custom-Element für Home Assistant.
Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert
(dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde
nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den
Entitäten der Integration geladen statt fest im Code zu stehen) und die
DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten).
Bekannte Lücken in diesem Stand:
- Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits
vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als
gebrochene Bilder, bis bilder/ tatsächlich befüllt ist.
- Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur,
wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat.
*/
/* ---------------------------------------------------------------------
Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch
unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`)
weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel-
Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine
Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein
existierte.
--------------------------------------------------------------------- */
let HASS = null;
let ROOT = null; // this.shadowRoot der Panel-Instanz
let HOST = null; // die Panel-Instanz selbst (für :host([data-theme]))
let CONFIG = null;
let CAR = null;
let TRIPS = [];
let FILLS = [];
let BATTERIEVERLAUF = [];
let ENTITAETEN = null; // {katalog, werte} aus sensor.audi_dashboard_entitaeten (Setup-Menü)
let DATEN_GELADEN = false;
// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend -
// null, solange keiner bekannt ist (siehe standAlterText()).
let LETZTES_UPDATE_TS = null;
/* Cache-Brecher für audi-dashboard.css und audi-dashboard-ios.css: die
Stylesheets werden nicht als Modul geladen, sondern als in den
Shadow-Root gehängt - sie brauchen deshalb ihre eigene Versions-Query.
Ohne sie bliebe ein Browser, der sie einmal geladen hat, nach einer
CSS-Änderung auf dem alten Stand hängen, obwohl der Code selbst korrekt
neu geladen wird.
Die Zahl steht schon in der eigenen Modul-URL (?v=…, von
audi-dashboard-panel.js durchgereicht, ursprünglich von der Integration
beim Anmelden des Panels gesetzt) - eine eigene Abfrage braucht es
nicht. */
const CSS_VERSION = (() => {
try {
return new URL(import.meta.url).searchParams.get("v") || Date.now();
} catch (e) {
return Date.now();
}
})();
/* Basis-URL der ausgelieferten Dateien der Integration (CSS, Badges,
Markenlogo). Aus der eigenen Modul-URL abgeleitet statt fest verdrahtet:
damit stimmt sie auch dann, wenn sich der statische Pfad in const.py
einmal ändert, und es gibt keine zweite Stelle, die mitgepflegt werden
müsste.
NICHT hierunter fallen die Fahrzeugfotos: die lädt der Nutzer selbst hoch
und sie liegen weiterhin unter /local/bilder/ (siehe bilder.py) - im
Integrationsordner würde sie das nächste Update mitlöschen. */
const STATIK = new URL(".", import.meta.url).href.replace(/\/$/, "");
/* ------------------------------------------------- Namen des Backends
Die eine Stelle, an der die Entity-IDs und Dienstnamen stehen. Sie sind
ein Vertrag mit custom_components/audi_dashboard/const.py; die
Companion-App führt dieselbe Tabelle in src/api/types.ts.
Bis 2026-08-23 hießen sie pyscript.audi_dashboard_* bzw.
pyscript.audi_dashboard_: der Präfix gehörte pyscript, das die
Entitäten damals bereitstellte. Seit dem Umbau zur eigenen Integration
gehören sie ihr auch dem Namen nach. */
const DOMAIN = "audi_dashboard";
const E = {
profil: "sensor.audi_dashboard_profil",
fahrten: "sensor.audi_dashboard_fahrten",
tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge",
fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus",
batterieverlauf: "sensor.audi_dashboard_batterieverlauf",
entitaeten: "sensor.audi_dashboard_entitaeten",
belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis",
importStatus: "sensor.audi_dashboard_import_status",
appVersion: "sensor.audi_dashboard_app_version",
};
const $ = (s) => ROOT.querySelector(s);
/* ---------------------------------------------------------------- Format */
const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d });
const eur = (n) => de(n, 2);
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const sum = (a, f) => a.reduce((s, x) => s + f(x), 0);
/* Durchschnittspreis IMMER mengengewichtet: Summe Kosten / Summe Liter */
const schnittpreis = (list) => sum(list, (f) => f.fuel_total_eur ?? f.summe ?? 0) / sum(list, (f) => f.liters ?? f.liter ?? 0);
/* -------------------------------------------------------- Adapter-Schicht
Übersetzt die Fahrzeugprofil-Struktur aus dem Backend (fahrzeug/
einstellungen/versicherung/steuer/reifen/service/technik/ausstattung) in
die CONFIG/CAR-Form, mit der der Rest dieser Datei arbeitet - direkt aus
dem Prototyp übernommen, um dessen Render-Funktionen unverändert
weiterzuverwenden. */
function profilZuConfig(p) {
return {
fahrzeugtitel: p.fahrzeug.titel,
fahrzeugzusatz: p.fahrzeug.zusatz,
fahrzeuguntertitel: p.fahrzeug.untertitel,
kennzeichen: p.fahrzeug.kennzeichen,
startbild: p.einstellungen.uebersichtsbild,
version: "1.0",
tankvolumen: p.fahrzeug.tankvolumen_liter,
nachtVon: p.einstellungen.nacht_von,
nachtBis: p.einstellungen.nacht_bis,
pauseMin: p.einstellungen.fahrten_pausenzeit_min,
markenname: p.einstellungen.kraftstoffanbieter,
backupIntervall: p.einstellungen.backup_intervall || "aus",
letztesBackup: p.einstellungen.letztes_backup || null,
ausfuehrung: p.fahrzeug.ausfuehrung || "",
};
}
function profilZuCar(p, status) {
// km ist kein abgeleiteter Wert mehr, sondern wird vom Backend
// (reifenzaehler.py) direkt im Profil fortgeschrieben - jeder gefahrene
// Kilometer zählt dort auf den zum Zeitpunkt aktiven Satz.
const reifenSatz = (k) => p.reifen.saetze[k] || {};
return {
fin: p.fahrzeug.fin,
details: p.fahrzeug.details,
erstzulassung: p.fahrzeug.erstzulassung,
hu: p.fahrzeug.hauptuntersuchung_faellig,
odo: status.km ?? 0,
odoBekannt: status.km != null,
tankPct: status.tankprozent ?? 0,
tankBekannt: status.tankprozent != null,
reichweite: status.reichweite_km,
batteriespannung: status.batteriespannung,
gesichert: status.gesichert,
sicherheitscheck: status.sicherheitscheck || [],
zuendung: status.zuendung ?? null,
standortLat: status.standort_lat,
standortLon: status.standort_lon,
standortGenauigkeitM: status.standort_genauigkeit_m,
standortZeit: status.standort_zeit,
oelwechselFaelligTs: status.oelwechsel_faellig_ts,
oelwechselFaelligKm: status.oelwechsel_faellig_km,
inspektionFaelligTs: status.inspektion_faellig_ts,
inspektionFaelligKm: status.inspektion_faellig_km,
kmProTag: 18.3, // Fallback, nur solange kein Servicebuch-Eintrag existiert
// "Gültig bis" ist einschließlich - der Rabatt gilt noch am Stichtag
// selbst, erst am Folgetag schaltet der Schalter automatisch ab, ohne
// dass der Nutzer manuell eingreifen muss.
smartdeal: { aktiv: p.einstellungen.smartdeal.aktiv && (!p.einstellungen.smartdeal.laeuft_ab ||
p.einstellungen.smartdeal.laeuft_ab >= new Date().toISOString().slice(0, 10)), laeuftAb: p.einstellungen.smartdeal.laeuft_ab },
oel: {
modus: p.einstellungen.oelwechsel_intervall.modus,
km: p.einstellungen.oelwechsel_intervall.km,
monate: p.einstellungen.oelwechsel_intervall.monate,
herstellerKm: p.einstellungen.oelwechsel_intervall.hersteller_km,
herstellerMonate: p.einstellungen.oelwechsel_intervall.hersteller_monate,
},
versicherung: p.versicherung,
steuer: { ...p.steuer, faellig: p.steuer.faellig || "" },
reifen: {
aktiv: p.reifen.aktiv === "winter" ? "Winter" : "Sommer",
anzugsmoment: p.reifen.anzugsmoment_nm,
wechsel: p.reifen.wechsel,
sommer: reifenSatz("sommer"),
winter: reifenSatz("winter"),
},
service: p.service,
technik: p.technik,
ausstattung: p.ausstattung,
};
}
/* Rückrichtung: CONFIG/CAR (im Speicher gehalten, vom Prototyp-Code direkt
mutiert) zurück in die Profil-Form für audi_dashboard_profil_schreiben. */
function configCarZuProfil(vorherigesProfil) {
const p = JSON.parse(JSON.stringify(vorherigesProfil));
p.fahrzeug.titel = CONFIG.fahrzeugtitel;
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
p.fahrzeug.fin = CAR.fin;
p.fahrzeug.erstzulassung = CAR.erstzulassung;
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
p.einstellungen.uebersichtsbild = CONFIG.startbild;
p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin;
p.einstellungen.backup_intervall = CONFIG.backupIntervall;
p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb };
p.einstellungen.oelwechsel_intervall = {
modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate,
hersteller_km: CAR.oel.herstellerKm, hersteller_monate: CAR.oel.herstellerMonate,
};
p.versicherung = CAR.versicherung;
p.steuer = { ...CAR.steuer, faellig: CAR.steuer.faellig || null };
p.reifen.aktiv = CAR.reifen.aktiv.toLowerCase();
p.reifen.anzugsmoment_nm = CAR.reifen.anzugsmoment;
p.reifen.wechsel = CAR.reifen.wechsel;
for (const k of ["sommer", "winter"]) {
// km wird vom Backend fortgeschrieben (reifenzaehler.py, reagiert auf
// den Kilometerstand-Sensor) - hier nie mitschreiben, sonst würde ein im
// Browser gehaltener älterer Stand eine zwischenzeitliche Fortschreibung
// überschreiben.
const { km, ...rest } = CAR.reifen[k];
p.reifen.saetze[k] = { ...p.reifen.saetze[k], ...rest };
}
p.service = CAR.service;
return p;
}
let PROFIL_ROH = null; // letzter vom Backend gelesener Rohstand, für configCarZuProfil()
async function profilSpeichern() {
const neuesProfil = configCarZuProfil(PROFIL_ROH);
PROFIL_ROH = neuesProfil;
// HIG "Feedback": profilSpeichern() ist der Speicherpfad fuer praktisch
// jedes editierbare Feld im Panel (~25 Aufrufstellen) und wurde bisher an
// keiner davon awaited oder abgefangen - ein Fehlschlag war eine unhandled
// promise rejection ohne jede Rueckmeldung. Zentral hier gefangen, aus
// demselben Grund wie serviceRufen() weiter unten.
try {
await HASS.callService(DOMAIN, "profil_schreiben", { profil_json: JSON.stringify(neuesProfil) });
} catch (err) {
console.error("audi_dashboard: profilSpeichern", err);
hinweis("Speichern fehlgeschlagen", err && err.message ? err.message : "Die Änderung konnte nicht gespeichert werden.");
}
}
/* ------------------------------------------------------------- Bilder
Feste Dateinamen laut §7a. Diese Fotos lädt der Nutzer selbst hoch; sie
liegen deshalb unter /local/bilder/ (also in /config/www/bilder/) und
nicht bei den ausgelieferten Dateien der Integration - dort würde sie das
nächste Update mitlöschen (siehe bilder.py).
Solange ein Foto fehlt, behalten die Flächen ihre Maße und zeigen einen
Platzhalter, das Layout springt beim Nachliefern also nicht. */
const BILDER = [
{ name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" },
{ name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" },
{ name: "Frontansicht", label: "Auto Front", datei: "frontansicht.webp" },
{ name: "Heckansicht", label: "Auto Heck", datei: "heckansicht.webp" },
{ name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" },
{ name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" },
{ name: "Sitze", label: "Innenraum Sitze", datei: "sitze.webp" },
];
let bild = 0;
const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" };
// Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als
// Query-Parameter an alle bilder/-URLs gehängt (siehe bildInfo()
// unten) - ohne das würde der Browser nach einem Upload weiter das alte Bild
// oder den zwischenzeitlich gecachten 404 unter derselben URL zeigen.
let bildVersion = Date.now();
/* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a
noch nicht vorhanden) wird die Bildfläche durch ein generisches Symbol
plus eindeutiger Beschriftung und dem erwarteten Dateinamen ersetzt -
damit klar ist, welche Datei unter welchem Namen nachgereicht werden muss,
ohne dass sich am Layout etwas verschiebt (Maße bleiben über die CSS-
Klassen erhalten, siehe carfix/carfix.klein/radbild in audi-dashboard.css). */
/* A5: Der Platzhalter nennt das Motiv und die nächste Handlung - der
Dateiname ist Entwicklersprache und steht nur noch in den Einstellungen
(Bild der Übersicht: Ansicht wählen, dann auf das Bild tippen). Die Maße
bleiben über die CSS-Klassen erhalten, das Layout springt beim Nachliefern
eines Fotos nicht. */
function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) {
const idTeil = imgId ? ` id="${imgId}"` : "";
return `
`;
}
function startIndex() {
const i = BILDER.findIndex((b) => b.name === CONFIG.startbild);
return i < 0 ? 0 : i;
}
function bildInfo(i) {
const b = BILDER[i];
if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") {
return { label: SEITE_WINTER.label, datei: SEITE_WINTER.datei, src: `/local/bilder/${SEITE_WINTER.datei}?v=${bildVersion}` };
}
return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` };
}
function bildSrc(i) { return bildInfo(i).src; }
function carImg(modus) {
if (modus === "fest") {
const info = bildInfo(startIndex());
return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein");
}
const info = bildInfo(bild);
/* data-bildgalerie: ein Tipp irgendwo auf die Kachel (Foto oder Platzhalter)
schaltet zum naechsten Bild weiter - Hochladen/Ersetzen/Loeschen bleibt
bewusst allein den Einstellungen vorbehalten (Bild der Übersicht),
deshalb hier kein data-bildupload/data-bildklick, nur bildWeiter().
Der Wrapper ist noetig, weil das selbst bei fehlendem Foto per
.bildbox.bild-fehlt img{display:none} unsichtbar und damit unklickbar
ist - der bisherige Handler (id="carimg") griff dann ins Leere. */
return `
`; }
/* Wie mapBox(), aber mit Zentrieren-Knopf - nur im Einzelbeleg. Der Knopf
holt die Tankstelle zurück in die Mitte, nachdem man auf der Karte
herumgezogen hat. Er sitzt im .mapbox (position:relative), damit er über
der Karte schwebt, und ist bis zum Eintreffen der Koordinaten deaktiviert -
die kommen bei Belegen ohne station_lat erst aus dem Nominatim-Abruf
(siehe adresseAufloesen). */
function mapBoxMitZentrieren() {
return `
`;
}
/* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte):
die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest
hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen
schon aus einer echten, dafür vorgesehenen Datenquelle. Ohne Werkstatt-
Koordinaten bleibt nur ein neutraler Mittelpunkt (geografische Mitte
Deutschlands), keine an dieses Fahrzeug gebundene Vermutung. */
function kartenMittelpunkt() {
const pos = CAR.service.autohaus.pos;
return pos && pos.length === 2 ? pos : [51.1657, 10.4515];
}
function fakeTrack(a, b, n = 13) {
const pts = []; let seed = Math.round((a[0] + b[1]) * 1000);
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; };
for (let i = 0; i <= n; i++) {
const t = i / n, k = Math.sin(t * Math.PI);
pts.push([a[0] + (b[0] - a[0]) * t + rnd() * 0.05 * k, a[1] + (b[1] - a[1]) * t + rnd() * 0.05 * k]);
}
pts[0] = a; pts[n] = b; return pts;
}
function tiles() {
if (!MAP || !window.L) return;
const url = TILE_URL;
if (TILE) MAP.removeLayer(TILE);
TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP);
}
async function initMap() {
await leafletLaden();
const el = ROOT.getElementById("map");
if (!el || typeof window.L === "undefined") return;
MAP = window.L.map(el, { zoomControl: true, attributionControl: true, scrollWheelZoom: false });
TILE = null; tiles();
const rot = getComputedStyle(HOST).getPropertyValue("--red").trim() || "#F50537";
if (route.name === "trip") {
const t = TRIPS.find((x) => x.trip_id === route.id);
if (t && t.start_lat != null && t.end_lat != null) {
const track = fakeTrack([t.start_lat, t.start_lon], [t.end_lat, t.end_lon]);
const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9 }).addTo(MAP);
track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP));
window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || ""));
window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || ""));
MAP.fitBounds(line.getBounds(), { padding: [26, 26] });
} else {
MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke)
}
} else if (route.name === "fill") {
BELEG_STATION_POS = null; // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch
const f = FILLS.find((x) => x.tank_id === route.id);
if (f && f.station_lat != null) {
tankstelleAufKarteZeigen(MAP, { lat: f.station_lat, lon: f.station_lon }, f);
} else if (f && (f.station_address || f.station_name)) {
/* Ohne Koordinaten die Anschrift vom Beleg nachschlagen. Bis die Antwort
da ist, bleibt der neutrale Ausschnitt stehen - und die Karte könnte
inzwischen zu einer anderen Ansicht gehören, deshalb vor dem Zeichnen
prüfen, ob wir noch auf demselben Beleg sind. */
MAP.setView(kartenMittelpunkt(), 11);
const karte = MAP, belegId = route.id;
adresseAufloesen(f.station_address || f.station_name).then((pos) => {
if (!pos || MAP !== karte || route.name !== "fill" || route.id !== belegId) return;
tankstelleAufKarteZeigen(karte, pos, f);
});
} else {
MAP.setView(kartenMittelpunkt(), 11);
}
}
setTimeout(() => MAP && MAP.invalidateSize(), 60);
}
/* ---------------------------------------------------------------- Standort
Live-Position des Fahrzeugs (Übersicht-Kachel + Vollbildansicht) und des
Endgeräts (Browser-Geolocation). Bewusst außerhalb des sonst
durchgängigen "ein render() zeichnet alles neu"-Musters: eine Leaflet-
Karte verliert bei jedem Neuaufbau ihres DOM-Containers Kartenausschnitt
und Zoomstufe (genau wie MAP oben schon), und das Fahrzeug-Menü soll sich
beim Öffnen/Schließen/Ziehen nicht wie der Rest der App anfühlen (kein
kompletter Neuaufbau bei jedem Fingertipp). initMap() oben handhabt genau
dasselbe Problem bereits für Fahrt-/Belegkarten - hier nur konsequent für
eine interaktive Karte mit eigenem Bedienelement weitergedacht: Standort-
und Nutzerdaten ändern CAR/USER_POS und lösen ein normales render() aus
(Kartenneuaufbau inklusive, wie bei MAP), aber Menü-Auf/Zu/Ziehen und die
Zentrieren-Knöpfe manipulieren SMAP und das Menü-DOM direkt. */
let USER_POS = null; // {lat, lon, genauigkeit} oder null
let USER_POS_TS = 0;
let USER_POS_FEHLER = null;
let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung)
let STANDORT_ADRESSE_KEY = null;
let STANDORTMENU_OFFEN = false;
let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP); SMAP_STIL: null/"strasse" oder "satellit"
let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel
let FAHRZEUG_MARKER = null, USER_MARKER = null;
const STANDORT_PEEK_PX = 96; // sichtbarer Anteil im geschlossenen Zustand, muss zu --standort-peek in audi-dashboard.css passen
function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; }
/* Kartenmarkierung: poi-car aus dem CI-Satz, in den beiden gelieferten
Groessenvarianten - poi-car-l (48er-Raster, mehr Detail) ab 34 px, darunter
poi-car-s (24er-Raster). Die Nadelspitze sitzt unten, deshalb ankert Leaflet
am unteren Rand (siehe iconAnchor an den Aufrufstellen: Spitze bei y=45.25
im 48er- bzw. y=23.3 im 24er-viewBox). */
function fahrzeugMarkerSVG(px = 34) {
const gross = px >= 34;
const box = gross ? 48 : 24;
const sil = gross ? CI.poiCarLSil : CI.poiCarSil;
const icon = gross ? CI.poiCarL : CI.poiCar;
return ``;
}
/* Tankstellenposition im Einzelbeleg - dieselbe Nadelform wie beim Fahrzeug,
nur ohne Auto-Glyph (CI.poiL/poiS), damit beide Marker sofort auseinander-
zuhalten sind. Spitze unten wie bei poi-car, gleiche Ankerrechnung. */
function tankstellenMarkerSVG(px = 32) {
const gross = px >= 34;
const box = gross ? 48 : 24;
const sil = gross ? CI.poiLSil : CI.poiSSil;
const icon = gross ? CI.poiL : CI.poiS;
return ``;
}
/* Fahrzustand fürs Menü: kommt aus der Zündung (CAR.zuendung, vom Backend aus
demselben Sensor veröffentlicht, den auch fahrterkennung.py als maßgebliches
Signal nimmt). Ist kein Zündungssensor zugeordnet, bleibt der Zustand
ehrlich unbekannt, statt "steht" zu behaupten.
Vorher stand hier `TRIPS[0].status === "offen"` - gleich zwei Fehler
übereinander, die sich gegenseitig verstärkt haben:
1. "offen" heißt NICHT "unterwegs", sondern "Daten noch unvollständig"
(die Strecke fehlt, siehe fahrtabschluss_logik.py). Eine beendete
Fahrt ohne Kilometerstand - etwa weil zu dem Zeitpunkt kein
KM_SENSOR zugeordnet war - bleibt für immer "offen".
2. TRIPS[0] ist die ÄLTESTE Fahrt, nicht die neueste:
fahrten_veroeffentlichen() gibt profil.fahrten_lesen() unverändert in
Dateireihenfolge weiter, und die ist aufsteigend.
Zusammen hieß das: die älteste jemals unvollständig gebliebene Fahrt hat
das Fahrzeug dauerhaft als "fahrend" angezeigt. An der Testinstanz war das
eine Fahrt, die seit 18 Tagen beendet war.
parkplatz unterscheidet einen endgültigen Stillstand (laenger als die in
"Fahrten zusammenfassen" hinterlegte Pausenzeit) von einem blossen
Zwischenstopp. Das Feld ist vorbereitet, wird aber derzeit von keiner
Ansicht gelesen - eine "Letzter Parkplatz"-Anzeige gibt es noch nicht. */
function letzteFahrt() {
let neueste = null;
for (const t of TRIPS) {
if (!t || !t.ts_end) continue;
if (!neueste || t.ts_end > neueste.ts_end) neueste = t;
}
return neueste;
}
function standortZustand() {
const t = letzteFahrt();
const seitTs = t && t.ts_end ? new Date(t.ts_end).getTime() : null;
if (CAR.zuendung === true) return { faehrt: true, seitTs: null, parkplatz: false };
const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null;
return {
faehrt: false,
unbekannt: CAR.zuendung == null,
seitTs,
parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 15),
};
}
function dauerSeitText(ts) {
if (ts == null) return "unbekannt";
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000));
const tage = Math.floor(sekunden / 86400);
const stunden = Math.floor((sekunden % 86400) / 3600);
const minuten = Math.floor((sekunden % 3600) / 60);
if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`;
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`;
return `${minuten} Min.`;
}
/* Haversine-Näherung für die Distanzangabe oben links im Menü ("15 m"). */
function distanzMeter(a, b) {
if (!a || !b) return null;
const R = 6371000, rad = Math.PI / 180;
const dLat = (b.lat - a.lat) * rad, dLon = (b.lon - a.lon) * rad;
const s = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
function distanzText(m) { return m == null ? "" : m < 1000 ? Math.round(m) + " m" : de(m / 1000, 1) + " km"; }
/* Zeile unter dem Fahrzeugnamen: Abstand Nutzer -> Fahrzeug, sonst der Grund,
warum er noch fehlt. */
function standortAbstandText() {
if (!standortBekannt()) return "";
if (USER_POS) return distanzText(distanzMeter(USER_POS, { lat: CAR.standortLat, lon: CAR.standortLon })) + " entfernt";
return USER_POS_FEHLER || "Abstand wird ermittelt …";
}
function standortErfassen() {
if (!navigator.geolocation) { USER_POS_FEHLER = "GPS offline"; standortAnsichtAktualisieren(); return; }
navigator.geolocation.getCurrentPosition(
(pos) => {
USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy };
USER_POS_TS = Date.now(); USER_POS_FEHLER = null;
standortAnsichtAktualisieren();
},
(err) => {
// Diese Zeile steht direkt unter dem Fahrzeugnamen, an der Stelle, wo
// sonst der Abstand zum Auto steht - sie handelt aber vom Standort
// DIESES Geräts, nicht vom Fahrzeug. "Standortzugriff verweigert" las
// sich dort so, als sei der Standort des Autos nicht abrufbar.
//
// Die verweigerte Freigabe bleibt trotzdem eigens benannt: sie ist der
// einzige Fall mit einer anderen Abhilfe (Freigabe erteilen statt auf
// GPS-Empfang warten), und "GPS offline" würde dort in die falsche
// Richtung schicken.
USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline";
// Auch der Fehlschlag zaehlt als Versuch. Ohne diese Zeile greift die
// Drossel in standortErfassenFallsNoetig() nie, solange der Nutzer die
// Freigabe verweigert - und jedes Backend-Update loeste eine neue
// Hochgenauigkeits-Abfrage aus.
USER_POS_TS = Date.now();
standortAnsichtAktualisieren();
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
);
}
function standortErfassenFallsNoetig() {
// Auf USER_POS_TS statt auf USER_POS pruefen: sonst waere ein fehlgeschlagener
// Versuch kein Versuch, siehe Fehlerpfad oben.
if (USER_POS_TS && Date.now() - USER_POS_TS < 30000) return;
standortErfassen();
}
/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) -
wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem
Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen
der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits
dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel
auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an
derselben Stelle keinen zweiten Abruf auslöst. */
async function standortAdresseAufloesen(lat, lon) {
const key = lat.toFixed(3) + "," + lon.toFixed(3);
if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE;
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "de" } });
const daten = await res.json();
const a = daten.address || {};
const strasse = [a.road, a.house_number].filter(Boolean).join(" ");
STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].filter(Boolean).join(", ") || daten.display_name || "";
} catch (e) {
// Den Schluessel bewusst NICHT setzen: sonst gilt ein einzelner
// fehlgeschlagener Abruf (Rate-Limit, kurzer Netzausfall) fuer diese Zelle
// die ganze Sitzung lang als "schon versucht", und es blieben dauerhaft nur
// die Koordinaten stehen.
STANDORT_ADRESSE = null;
return null;
}
STANDORT_ADRESSE_KEY = key;
return STANDORT_ADRESSE;
}
function standortAdresseLaden() {
if (!standortBekannt()) return;
standortAdresseAufloesen(CAR.standortLat, CAR.standortLon).then((adresse) => {
const el = $("#standortAdresse");
if (el && adresse) el.textContent = adresse;
});
}
/* Gegenrichtung zu standortAdresseAufloesen: Adresse -> Koordinaten, für die
Tankstellennadel im Einzelbeleg. Nötig, weil der Beleg nur eine Anschrift
trägt und nichts im Backend station_lat/station_lon je befüllt - ohne diesen
Schritt bliebe die Karte dort dauerhaft ohne Nadel.
Ergebnisse landen in localStorage statt nur im RAM: eine Tankstellenadresse
ändert ihre Lage nicht, und der Einzelbeleg wird oft genug erneut geöffnet,
dass ein Cache nur für die laufende Sitzung Nominatim unnötig oft anfassen
würde (deren Nutzungsbedingungen bitten ausdrücklich darum, Ergebnisse zu
cachen). Auch ein Fehlschlag wird vermerkt - als null -, damit eine Adresse,
die Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird. */
const GEO_CACHE_KEY = "audi_dashboard_geocache";
let GEO_CACHE = null;
function geoCacheLesen() {
if (GEO_CACHE) return GEO_CACHE;
try { GEO_CACHE = JSON.parse(localStorage.getItem(GEO_CACHE_KEY)) || {}; }
catch (e) { GEO_CACHE = {}; }
return GEO_CACHE;
}
function geoCacheSchreiben(schluessel, wert) {
const cache = geoCacheLesen();
cache[schluessel] = wert;
try { localStorage.setItem(GEO_CACHE_KEY, JSON.stringify(cache)); }
catch (e) { /* voller/gesperrter Speicher: Cache bleibt dann RAM-only */ }
}
async function adresseAufloesen(adresse) {
const suche = (adresse || "").trim();
if (!suche) return null;
const cache = geoCacheLesen();
if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche];
let treffer = null;
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`,
{ headers: { "Accept-Language": "de" } }
);
const daten = await res.json();
if (Array.isArray(daten) && daten.length) {
treffer = { lat: parseFloat(daten[0].lat), lon: parseFloat(daten[0].lon) };
}
} catch (e) {
// Netzfehler ist kein "Adresse gibt es nicht" - nichts merken, damit der
// nächste Aufruf es erneut versucht (vgl. standortAdresseAufloesen).
return null;
}
geoCacheSchreiben(suche, treffer);
return treffer;
}
/* Setzt Nadel und Ausschnitt, sobald die Koordinaten da sind. Getrennt von
initMap(), weil der Abruf asynchron ist und die Karte bis dahin schon
steht. */
function tankstelleAufKarteZeigen(karte, pos, f) {
if (!karte || !window.L || !pos) return;
karte.setView([pos.lat, pos.lon], 14);
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${tankstellenMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] });
window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte)
.bindPopup((f.station_name || "") + " " + (f.station_address || ""));
// Position merken und den Knopf scharf schalten - er kann erst jetzt etwas
// Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe
// Aufrufstelle in initMap).
BELEG_STATION_POS = pos;
const knopf = ROOT && ROOT.getElementById("belegZentrieren");
if (knopf) knopf.disabled = false;
}
function belegAufTankstelleZentrieren() {
if (!MAP || !BELEG_STATION_POS) return;
MAP.flyTo([BELEG_STATION_POS.lat, BELEG_STATION_POS.lon], Math.max(MAP.getZoom(), 14));
}
function standortKartenlink() {
return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null;
}
async function standortTeilen() {
const url = standortKartenlink();
if (!url) return;
const titel = `${CONFIG.fahrzeugtitel} · Standort`;
if (navigator.share) {
try { await navigator.share({ title: titel, url }); } catch (e) { /* vom Nutzer abgebrochen */ }
return;
}
try { await navigator.clipboard.writeText(url); hinweis("Link kopiert", "Der Standort-Link wurde in die Zwischenablage kopiert."); }
catch (e) { hinweis("Teilen nicht möglich", url); }
}
/* Direkte DOM-Updates ohne render() (siehe Kopfkommentar) - hält SMAP und
ein offenes Menü am Leben, während neue Positionsdaten eintreffen. */
function standortAnsichtAktualisieren() {
if (route.name !== "standort") return;
standortMarkerZeichnen();
const dist = $("#standortDistanz");
if (dist) dist.textContent = standortAbstandText();
}
function standortTiles() {
if (!SMAP || !window.L) return;
if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE);
SMAP_TILE = SMAP_STIL === "satellit"
? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP)
: window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(SMAP);
}
function standortMarkerZeichnen() {
if (!SMAP || !window.L) return;
if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; }
if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; }
if (standortBekannt()) {
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(38)}
`, iconSize: [38, 38], iconAnchor: [19, 36] });
FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP);
FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen());
}
if (USER_POS) {
const icon = window.L.divIcon({ className: "user-pin-wrap", html: ``, iconSize: [16, 16], iconAnchor: [8, 8] });
USER_MARKER = window.L.marker([USER_POS.lat, USER_POS.lon], { icon, zIndexOffset: -100 }).addTo(SMAP);
}
}
function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); }
function standortAufUser() {
if (!USER_POS) { standortErfassen(); return; }
if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15));
}
function standortBeideZeigen() {
if (!SMAP) return;
if (standortBekannt() && USER_POS) SMAP.fitBounds([[CAR.standortLat, CAR.standortLon], [USER_POS.lat, USER_POS.lon]], { padding: [64, 64], maxZoom: 16 });
else if (standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], 15);
else if (USER_POS) SMAP.flyTo([USER_POS.lat, USER_POS.lon], 15);
}
async function initStandortMap() {
STANDORTMENU_OFFEN = false;
await leafletLaden();
const el = ROOT.getElementById("smap");
if (!el || typeof window.L === "undefined") return;
SMAP = window.L.map(el, { zoomControl: false, attributionControl: false });
SMAP_TILE = null; standortTiles();
/* Erst Ansicht, dann Marker: Leaflet legt Marker-Symbole nur auf einer Karte
mit gesetztem Mittelpunkt an - vorher angehaengte Marker blieben unsichtbar. */
if (standortBekannt()) SMAP.setView([CAR.standortLat, CAR.standortLon], 15);
else if (USER_POS) SMAP.setView([USER_POS.lat, USER_POS.lon], 15);
else SMAP.setView(kartenMittelpunkt(), 11);
standortMarkerZeichnen();
standortBeideZeigen();
standortAdresseLaden();
standortErfassenFallsNoetig();
setTimeout(() => SMAP && SMAP.invalidateSize(), 60);
}
async function initVorschauMap() {
await leafletLaden();
const el = ROOT.getElementById("tmap");
if (!el || typeof window.L === "undefined") return;
TMAP = window.L.map(el, {
zoomControl: false, attributionControl: false, dragging: false,
scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false,
});
TMAP_TILE = window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(TMAP);
if (standortBekannt()) {
TMAP.setView([CAR.standortLat, CAR.standortLon], 14);
const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] });
window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP);
} else {
TMAP.setView(kartenMittelpunkt(), 11);
}
standortErfassenFallsNoetig();
setTimeout(() => TMAP && TMAP.invalidateSize(), 60);
}
function standortMenuElement() { return ROOT.getElementById("standortmenu"); }
function standortMenuOeffnen() { const el = standortMenuElement(); if (el) el.classList.add("offen"); STANDORTMENU_OFFEN = true; }
function standortMenuSchliessen() { const el = standortMenuElement(); if (el) el.classList.remove("offen"); STANDORTMENU_OFFEN = false; }
function standortMenuUmschalten() { STANDORTMENU_OFFEN ? standortMenuSchliessen() : standortMenuOeffnen(); }
/* Ziehen am Griff/Kopfbereich des Menüs - eigene Pointer-Events-Kette,
bewusst getrennt von der Wisch-zum-Löschen-Logik unten (andere Achse,
anderer Auslöser), manipuliert das Menü-DOM direkt (siehe Kopfkommentar
Standort-Abschnitt). */
let smY0 = null, smOffenBeimStart = false;
/* Zugweg: alles ausser dem sichtbaren Anteil. Die Blatthoehe haengt am Inhalt,
deshalb wird sie beim Ziehen gemessen statt fest verdrahtet. */
function standortMenuHub() {
const el = standortMenuElement();
return el ? Math.max(0, el.offsetHeight - STANDORT_PEEK_PX) : 0;
}
function standortMenuVerdrahten() {
ROOT.addEventListener("pointerdown", (e) => {
if (route.name !== "standort" || !e.target.closest(".standortmenu-griffzone")) return;
smY0 = e.clientY; smOffenBeimStart = STANDORTMENU_OFFEN;
const el = standortMenuElement(); if (el) el.style.transition = "none";
});
ROOT.addEventListener("pointermove", (e) => {
if (smY0 === null) return;
e.preventDefault();
const el = standortMenuElement(); if (!el) return;
const dy = e.clientY - smY0;
const hub = standortMenuHub();
const basis = smOffenBeimStart ? 0 : hub;
const versatz = Math.max(0, Math.min(hub, basis + dy));
el.style.transform = `translateY(${versatz}px)`;
}, { passive: false });
ROOT.addEventListener("pointerup", (e) => {
if (smY0 === null) return;
const dy = e.clientY - smY0;
standortMenuGesteBeenden();
if (Math.abs(dy) > 40) { dy < 0 ? standortMenuOeffnen() : standortMenuSchliessen(); }
else standortMenuUmschalten();
});
/* Ohne pointercancel bleibt smY0 gesetzt, sobald das System die Beruehrung
uebernimmt (iOS-Randgeste, Multitouch, eingehender Anruf). Der
pointermove-Handler oben ruft dann preventDefault() fuer jede weitere
Bewegung - Scrollen waere in der ganzen App blockiert, bis irgendwo ein
pointerup kommt, der dann ein dy aus einem veralteten Startpunkt
auswertet. Die drei anderen Zeigergesten der Datei machen das laengst
richtig. */
ROOT.addEventListener("pointercancel", () => {
if (smY0 === null) return;
standortMenuGesteBeenden();
});
}
/* Setzt den Gestenzustand zurueck und gibt das Blatt wieder an die
CSS-Uebergaenge ab - gemeinsam von pointerup und pointercancel genutzt. */
function standortMenuGesteBeenden() {
const el = standortMenuElement();
if (el) { el.style.transition = ""; el.style.transform = ""; }
smY0 = null;
}
function standortKachel() {
return `
`;
}
/* Symbole aus dem Audi-CI-Satz (assets/icons/) - hier eingebettet, weil die
Panel-Datei ohne zusaetzliche Abrufe auskommen soll. */
const CI = {
mapLayer: '',
carSearch: '',
gps: '',
poiCar: '',
/* poi-car-l - grosse Variante desselben CI-Symbols (48er-Raster), fuer die
Fahrzeugnadel auf der Vollbildkarte. Siehe fahrzeugMarkerSVG(). */
/* poi-l/poi-s aus dem CI-Satz: neutrale Kartennadel mit Kreis, fuer die
Tankstellenposition im Einzelbeleg (poi-car bleibt dem Fahrzeug
vorbehalten, sonst waeren beide Nadeln auf einer Karte nicht zu
unterscheiden). Siehe tankstellenMarkerSVG(). */
poiL: '',
poiS: '',
poiCarL: '',
/* Vollton-Silhouetten fuer die Kartennadeln: poi-car/poi sind reine Kontur-
formen (Ring aus zwei gegenlaeufigen Randlinien + duenne Detaillinien im
Inneren) - eine Pixelprobe ergab nur 13-40% gefuellte Flaeche und war auf
Kartenkacheln praktisch unsichtbar (Nutzer-Feedback "Pin ist transparent/
durchsichtig"). Jede Silhouette ist exakt das erste Teilpfad-Segment des
jeweiligen CI-Pfads oben (die aeussere Ballonform, vor dem Punkt, der die
Ring-Innenlinie startet) - keine Neuzeichnung, nur der bereits im CI-Satz
enthaltene Umriss, mit fester Farbe statt currentColor gefuellt. Wird in
fahrzeugMarkerSVG()/tankstellenMarkerSVG() als unterste Ebene hinter dem
Original-Icon (dann weiss statt dunkel) gerendert. */
poiCarLSil: '',
poiCarSil: '',
poiLSil: '',
poiSSil: '',
selectAll: '',
planRoute: '',
upload: '',
fuelRange: '',
parking: '',
/* battery-12v-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert,
verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */
battery12vL: '',
battery12vS: '',
/* edit-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert,
verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */
editL: '',
editS: '',
/* oil-change-l/-s, inspection-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als
SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie
poi-l/poi-s. Die -s-Variante steht links neben der Kennzahl im
Nächster-Service-Block (vHome()) statt des vorherigen "bis zum/zur..."
Textes - die Serviceart wird jetzt ueber das Icon erkannt, nicht ueber
Worte. */
oilChangeL: '',
oilChangeS: '',
inspectionL: '',
inspectionS: '',
};
function ciSVG(pfad, px = 16) {
return ``;
}
function vStandort() {
const zustand = standortZustand(), bekannt = standortBekannt();
const distanz = standortAbstandText();
const adresseKey = bekannt ? CAR.standortLat.toFixed(3) + "," + CAR.standortLon.toFixed(3) : null;
/* Solange die Adresse nicht aufgeloest ist, stehen die Koordinaten da -
kein Platzhaltertext, der haengen bleiben kann. */
const koordinaten = bekannt ? CAR.standortLat.toFixed(4) + ", " + CAR.standortLon.toFixed(4) : "";
const adresseText = !bekannt ? "Kein GPS-Signal vom Fahrzeug"
: STANDORT_ADRESSE_KEY === adresseKey ? (STANDORT_ADRESSE || koordinaten) : koordinaten;
const link = standortKartenlink();
return `
`;
}
/* -------------------------------------------------------------- Ansichten
Ab hier praktisch unverändert aus dem Prototyp übernommen. */
function badge() {
// Offizielle Audi-Markenassets aus www/badges/ (positive = für hellen,
// negative = für dunklen Hintergrund) - welche Variante sichtbar ist,
// entscheidet reines CSS über :host([data-theme=...]) (siehe .badge img in
// audi-dashboard.css), genau wie beim Farbwechsel des restigen Interfaces
// beim Tag/Nacht-Umschalten. Kein Re-Render beim Theme-Wechsel nötig.
//
// Der Zusatz neben dem Badge (z. B. "Avant competition") setzt sich aus
// dem Modellnamen selbst zusammen - alles nach dem erkannten Badge-Präfix
// ("Avant"/"Sportback"/...) - plus der separat gepflegten Ausführung, statt
// aus dem alten, nicht mehr an die Modell-Auswahl gekoppelten
// CONFIG.fahrzeugzusatz (siehe Kopfkommentar bei FAHRZEUG_MODELLE).
const praefix = badgePraefix();
if (!praefix) return "";
const datei = BADGE_DATEIEN[praefix];
const modellZusatz = (CONFIG.fahrzeugtitel || "").slice(praefix.length).trim();
/* Die Ausfuehrung steht oft schon im Modellnamen ("RS 4 Avant competition"
plus Ausfuehrung "competition") - dann nicht ein zweites Mal anhaengen,
sonst steht dort "Avant competition competition". */
const ausfuehrung = (CONFIG.ausfuehrung || "").trim();
const doppelt = ausfuehrung && modellZusatz.toLowerCase().includes(ausfuehrung.toLowerCase());
const zusatz = [modellZusatz, doppelt ? "" : ausfuehrung].filter(Boolean).join(" ");
const alt = esc(CONFIG.fahrzeugtitel) + " Badge";
return `
${zusatz ? `${esc(zusatz)}` : ""}
`;
}
function teaser() {
if (!TRIPS.length && !FILLS.length) return "";
const t = TRIPS[0], f = FILLS[0];
return `
Zuletzt
${t ? `` : ""}
${f ? `` : ""}
`;
}
function naechsterTermin() { return termine().slice().sort((a, b) => a.ziel - b.ziel)[0]; }
// CI-Icon je Serviceart statt eines Wort-Zusatzes ("bis zum Ölwechsel" o.ä.) -
// nur Ölwechsel/Inspektion haben eines, die Hauptuntersuchung nicht. Verwendet
// von vHome() (Nächster-Service-Block) und vService() (Anstehende Termine).
// Das Ölwechsel-Icon ist bei gleicher Boxgröße optisch kleiner als das
// Inspektion-Icon (füllt in seinem eigenen 24x24-Raster nur eine 24x10-Fläche
// statt fast der vollen Höhe) - deshalb eigene, größere Boxgröße statt einer
// gemeinsamen Konstante.
const ART_ICON = { "Ölwechsel": CI.oilChangeS, "Inspektion": CI.inspectionS };
const ART_ICON_GROESSE = { "Ölwechsel": 26, "Inspektion": 20 };
function intervalle(t) {
const oel = CAR.oel, b = t.basis;
if (!b) return null;
const bauen = (km, monate) => ({ km: km ? b.km + km : null, datum: monatePlus(b.datum, monate) });
if (t.art === "Hauptuntersuchung") {
const eig = bauen(null, 24);
return { hersteller: null, eigen: eig, prognose: eig.datum };
}
const eKm = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm)) : 30000;
const eM = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)) : 24;
const eig = bauen(eKm, eM);
// "Herstellervorgabe" gilt bei Ölwechsel nur, wenn der Kunde in "Einrichten"
// tatsächlich die Herstellervorgabe gewählt hat - bei eigenem Intervall ist
// "eigen" maßgeblich, sonst würde anzeige = iv.hersteller || iv.eigen unten
// immer die Herstellerwerte zeigen und das Kundenintervall ignorieren.
const her = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? bauen(oel.herstellerKm, oel.herstellerMonate) : null) : bauen(30000, 24);
const perKm = prognose(eig.km).datum;
return { hersteller: her, eigen: eig, prognose: perKm && perKm < eig.datum ? perKm : eig.datum };
}
/* Vom Fahrzeug selbst gemeldete Fälligkeit (VW Group EU Data Act, siehe
einstellungen.py) - ergänzt die App-eigene, aus dem Servicebuch berechnete
Prognose (intervalle()/terminText() oben), ersetzt sie aber bewusst nicht:
die Werkscomputer-Logik dahinter ist nicht bekannt und könnte von den
selbst eingestellten Intervallen abweichen. null, wenn das Fahrzeug dazu
nichts meldet (Sensor fehlt/nicht verfügbar). */
function fahrzeugMeldungRoh(art) {
if (art === "Ölwechsel") return { ts: CAR.oelwechselFaelligTs, km: CAR.oelwechselFaelligKm };
if (art === "Inspektion") return { ts: CAR.inspektionFaelligTs, km: CAR.inspektionFaelligKm };
return { ts: null, km: null };
}
/* App-eigene Service-Prognose, eigenständig statt über die allgemeine
tagesleistung()/prognose()-Mischrechnung (die dafür zu träge ist, weil sie
den langfristigen Durchschnitt seit dem allerersten Servicebucheintrag mit
einfließen lässt): die Berechnung beginnt bewusst komplett neu ab dem Datum
der letzten tatsächlich durchgeführten Arbeit dieser Art.
Logik (wie vorgegeben): km und Tage seit diesem Eintrag ergeben die aktuelle
Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum
eingestellten Intervall ergibt das ein Datum - gedeckelt auf spätestens das
Intervall-Zeitlimit (z. B. 1 Jahr) nach dem letzten Eintrag, falls seither
wenig gefahren wurde.
Gemeinsame Grundlage für Ölwechsel und Inspektion (siehe die beiden
Aufrufer darunter) - beide rechnen identisch, nur mit anderem Intervall. */
function servicePrognose(stichwort, intervallKm, intervallMonate) {
if (!CAR.odoBekannt) return null;
const letzter = letzterEintrag(stichwort);
if (!letzter) return null;
const zielKm = letzter.km + intervallKm;
const restKm = Math.max(0, zielKm - CAR.odo);
const kappDatum = monatePlus(letzter.datum, intervallMonate);
let datum;
if (restKm <= 0) {
datum = heute();
} else {
const tageSeit = (heute() - dat(letzter.datum)) / dTag;
const kmSeit = CAR.odo - letzter.km;
if (tageSeit <= 0 || kmSeit <= 0) {
// Noch keine belastbare Fahrleistung seit dem Eintrag bekannt (z. B.
// direkt danach) - ohne Rate keine seriöse Km-Prognose, also das
// Zeitlimit als einzig belastbare Grenze zeigen.
datum = kappDatum;
} else {
const kmProTagSeither = kmSeit / tageSeit;
const tageBisZiel = restKm / kmProTagSeither;
const berechnet = new Date(heute().getTime() + tageBisZiel * dTag);
datum = berechnet < kappDatum ? berechnet : kappDatum;
}
}
return { km: zielKm, restKm, datum };
}
function oelwechselPrognose() {
const oel = CAR.oel;
return servicePrognose("öl",
oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm),
oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate));
}
/* Inspektion: dieselben 30.000 km / 24 Monate wie in intervalle()/termine().
Angezeigt wird davon bewusst nur das Datum ("voraussichtlich am …") - die
Restkilometer stehen bei der Inspektion schon in der Zeile daneben. */
function inspektionPrognose() { return servicePrognose("inspektion", 30000, 24); }
/* Kleine graue Zweitzeile unter dem Termin-Titel, in Service-Kachel und
Service-Seite identisch aufgebaut. Leer, solange kein passender
Servicebucheintrag vorliegt. */
function prognoseZeile(art) {
const p = art === "Ölwechsel" ? oelwechselPrognose() : art === "Inspektion" ? inspektionPrognose() : null;
if (!p) return "";
const km = art === "Ölwechsel" ? de(p.restKm) + " km, " : "";
return ` ${km}voraussichtlich am ${dedat(p.datum)}`;
}
function terminText(t) {
const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null;
const restKmAbs = restKm !== null ? Math.abs(restKm) : null;
const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : dedat(t.datum);
const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum)
: (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Servicebuch fortgeschrieben"));
return { haupt, neben, restKm };
}
/* Zählt seit LETZTES_UPDATE_TS (echter Zeitpunkt des letzten Fahrzeugstatus-
Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet
dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24,
dann Tage. */
function standAlterText() {
if (LETZTES_UPDATE_TS == null) return "unbekannt";
const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_TS) / 1000));
if (sekunden < 60) return `vor ${sekunden} Sek.`;
const minuten = Math.floor(sekunden / 60);
if (minuten < 60) return `vor ${minuten} Min.`;
const stunden = Math.floor(minuten / 60);
if (stunden < 24) return `vor ${stunden} Std.`;
return `vor ${Math.floor(stunden / 24)} Tage`;
}
function standAlterTicken() {
if (!ROOT || !CAR) return;
const sy = $("#sync span"); if (sy) sy.textContent = standAlterText();
}
function vHome() {
const g = CAR.gesichert;
const statusText = g === null ? "Status unbekannt" : g ? "Sicher abgestellt" : "Nicht sicher abgestellt";
const statusDot = g === null ? "warn" : g ? "" : "bad";
const pct = CAR.tankPct;
const t = naechsterTermin();
/* C2/C5/D1: Reihenfolge der myAudi-App - randloses Fahrzeugbild, Name und
Status, große Reichweite mit farbigem Balken, dann Aktionskacheln. Die
Kacheln führen zu Ansichten statt zu Fernsteuerung, die das Panel bewusst
nicht kann. */
return `
${(() => {
/* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich große
Kacheln nebeneinander - gleiches Maß, gleiche Zahlengröße, gleiche Farbe,
obwohl nur eine davon ein Knopf war und die beiden Zahlen Grundver-
schiedenes meinen: der Kilometerstand ist ein gemessener Ist-Wert, der
sich kaum ändert, der Service-Wert ein Countdown, der täglich kleiner
wird. Zwei gleich große Zahlen nebeneinander behaupteten damit eine
Gleichrangigkeit, die inhaltlich nicht besteht.
Jetzt: eine führende Kennzahl, darunter der Kilometerstand als ruhige
Wertezeile, getrennt durch eine Haarlinie - Hierarchie über Größe und
Farbe (Audi-CI) kombiniert mit Apple HIGs "differentiate controls from
content": der Chevron sitzt nur am Serviceblock, damit erkennbar bleibt,
welcher Teil auf die Service-Seite führt und welcher reine Anzeige ist.
Beides zusammen in einer normalen .tile mit Hintergrund - die Haarlinie
trennt dann innerhalb der Kachel, statt die Kachel zu ersetzen. */
const kmZeile = `
Kilometerstand
${CAR.odoBekannt ? de(CAR.odo) : "–"}km
`;
if (!t) return `
Nächster Service:
kein Eintrag im Servicebuch
${kmZeile}
`;
// naechsterTermin() waehlt bereits ueber alle drei Arten hinweg (Oelwechsel,
// Inspektion, Hauptuntersuchung) den zeitlich naechsten aus.
const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null;
const datum = t.kmDatum || t.datum;
const hauptwert = restKm !== null
? `${de(restKm)}km`
: (datum ? dedat(datum) : "–");
// Ohne Restkilometer steht das Datum selbst im Zahlenfeld - auf Nutzerwunsch
// jetzt in derselben Groesse wie die km-Zahl (vorher 30px, damit "15.04.2027"
// auf 375px nicht umbricht - am laufenden Panel geprueft, dass es bei 40px
// weiterhin einzeilig bleibt).
const zahlGroesse = 40;
// Icon statt "bis zum Ölwechsel"/"bis zur Inspektion" (nur fuer die beiden
// Arten, die eines haben). Die Hauptuntersuchung hat kein Restkilometer-Ziel
// und zeigt stattdessen den Art-Namen selbst unter dem Datum.
const artIcon = ART_ICON[t.art];
const nebenzeile = restKm !== null && datum
? `voraussichtlich am ${dedat(datum)}`
: t.art;
const figMarkup = artIcon
? `
${ciSVG(artIcon, 31)}
${hauptwert}
`
: `
${hauptwert}
`;
return `
${kmZeile}
`;
})()}
${standortKachel()}
${teaser()}`;
}
function vSicherheit() {
const eintraege = CAR.sicherheitscheck;
const g = CAR.gesichert;
const offen = eintraege.filter((e) => e.ok === false).length;
const unbekannt = eintraege.filter((e) => e.ok === null).length;
const sammelText = g === null
? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert`
: g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`;
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe
Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
return `
`;
}
/* ------------------------------------------------- Batteriespannungs-Verlauf
Handgebautes Diagramm ohne externe Bibliothek (kein Bundler im Projekt,
siehe INSTALL.md) - Zoom per Zwei-Finger-Pinch/Mausrad, Verschieben per
Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt.
bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit
Zoom/Pan flüssig bleiben. */
let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null, bvYDomain = null;
const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger)
let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste}
let bvTapKandidat = null, bvBewegtSeit = false;
const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22;
const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei
aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle
AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V,
50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch
entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V -
deutlich über dem Ruhespannungs-Maximum.
Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf,
ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive
Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet,
und zwar auf den Tagesminimalwert: der fällt typischerweise auf den
Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit
die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das
Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet),
tauchen entsprechend nicht in der Ladezustands-Statistik auf. Die Grenze
liegt bei 12,8 V (voller Ruhezustand laut obiger Recherche) statt z. B.
13,2 V, weil alles darüber bereits vom Generator stammt, nicht von der
Batterie selbst - solche Werte würden den Ladezustand künstlich zu gut
aussehen lassen. */
const AGM_RUHE_MAX_V = 12.8;
const BV_SOC_TABELLE = [
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
];
function bvSocProzent(v) {
const t = BV_SOC_TABELLE;
if (v <= t[0][0]) return 0;
if (v >= t[t.length - 1][0]) return 100;
for (let i = 0; i < t.length - 1; i++) {
const [v0, s0] = t[i], [v1, s1] = t[i + 1];
if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0));
}
return 100;
}
function bvSocLabel(soc) {
return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch";
}
// Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei
// Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) -
// dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok),
// damit die Skala zum Rest der App passt statt eigene Farben zu erfinden.
function bvHexZuRgb(hex) {
const h = hex.trim().replace("#", "");
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); }
function bvFarbstufen() {
const stil = getComputedStyle(HOST);
return {
bad: bvHexZuRgb(stil.getPropertyValue("--bad")),
warn: bvHexZuRgb(stil.getPropertyValue("--warn")),
ok: bvHexZuRgb(stil.getPropertyValue("--ok")),
};
}
function bvFarbe(v, stufen) {
let rgb;
if (v <= 11.6) rgb = stufen.bad;
else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6));
else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2));
else rgb = stufen.ok;
return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
}
/* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine
Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe
Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of-
health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt,
nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase
der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen
eingeordnet (übliche Lebensdauer 4-6 Jahre). */
function bvSohBewertung() {
const TAG_MS = 86400000;
if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." };
const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS;
if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." };
const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS;
const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs);
const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs);
const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length;
const delta = neuD - altD;
const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad";
const text = delta >= -0.05
? "Stabil - kein auffälliger Rückgang der Ruhespannung."
: delta >= -0.15
? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen."
: "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten.";
return { status, text, delta };
}
function vBatterieverlauf() {
// Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem
// Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der
// echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann.
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V)
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum }))
.sort((a, b) => a.t - b.t);
bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null;
const genug = bvPunkte.length >= 2;
bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null;
bvDomain = genug ? [...bvVollDomain] : null;
// Y-Achse einmalig aus allen Punkten festlegen, nicht bei jedem Zoom/Pan
// neu aus den gerade sichtbaren - sonst "streckt" sich die Achse bei jeder
// Geste neu auf den jeweils sichtbaren Min/Max-Ausschnitt.
if (genug) {
let yMin = Math.min(...bvPunkte.map((p) => p.v)), yMax = Math.max(...bvPunkte.map((p) => p.v));
if (yMax - yMin < 0.4) { const mitte = (yMax + yMin) / 2; yMin = mitte - 0.2; yMax = mitte + 0.2; }
const pad = (yMax - yMin) * 0.15;
bvYDomain = [yMin - pad, yMax + pad];
} else {
bvYDomain = null;
}
const stufen = bvFarbstufen();
const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null;
const soc = letzter ? bvSocProzent(letzter.v) : null;
const soh = bvSohBewertung();
const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)");
const leerText = BATTERIEVERLAUF.length === 0
? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist."
: "Noch zu wenige Messwerte für einen Verlauf.";
return `
`;
const anzeige = iv.hersteller || iv.eigen;
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
// Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
// eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
// Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
// dem Datum. Hauptuntersuchung bleibt bewusst ohne.
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
const hatMeldung = fm.km != null || fmDatum;
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
// Herstellerintervall - die Restkilometerzahl daneben waere nur eine
// zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
// eigenen (kuerzeren) Intervall ist sie eine echte Zusatzinformation.
const fussnote = hatMeldung && eigenOel
? (CAR.oel.modus === "hersteller"
? `voraussichtlich am ${dedat(eigenOel.datum)}`
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : "";
const wert = hatMeldung
? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}`
: (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum));
// Eine Fahrzeugmeldung stammt immer aus dem werkseigenen Wartungsprogramm
// des Bordcomputers - das kennt das eigene, in "Einrichten" gewaehlte
// Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller
// als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback
// (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht.
const herstellervorgabe = hatMeldung || iv.hersteller;
return `
${kopf}
${t.vereinbart ? `Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""}
Aus dem Servicebuch berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose.
${vSteuer()}`;
}
function vNotrufBearbeiten() {
const v = CAR.versicherung;
return `
Rufnummern ändern
`;
}
function vBeitrag() {
const v = CAR.versicherung;
return `
Beitrag anpassen
€
€
€
Der Jahresbeitrag oben ist die Summe dieser drei Posten und
rechnet sich beim Speichern automatisch neu — "Gültig ab" steuert den Titel der Jahresbeitrag-Kachel
`;
}
function vVertragsdetails() {
const v = CAR.versicherung;
return `
${v.leistungen.map((g) => `
${esc(g.gruppe)}
${g.posten.map((x, i) => `
${esc(x)}
`).join("")}
`).join("")}`;
}
function vVertragBearbeiten() {
const v = CAR.versicherung;
return `
`;
}
function vSteuerBearbeiten() {
const t = CAR.steuer;
return `
Kfz-Steuer
€
`;
}
/* B5: Die Wahl gilt dauerhaft, nicht nur bis zum Reload - sie wird beim Aufbau
der Menüleiste wieder angewendet. */
const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift";
let beschriftung = (() => {
try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; }
})();
let bodenAn = true;
function csvDatei(name, kopf, zeilen) {
const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n");
const a = document.createElement("a");
a.href = "data:text/csv;charset=utf-8," + encodeURIComponent("" + txt);
a.download = name; document.body.appendChild(a); a.click(); a.remove();
}
function vEinst() {
return `
Fahrzeug einrichten
${einrichtenOffen ? `
Ölwechsel-Intervall
${CAR.oel.modus === "hersteller"
? `
Laufleistung
${de(CAR.oel.herstellerKm)} km
Zeit
${CAR.oel.herstellerMonate} Monate
`
: `
`}
Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum
nachträglich an — aus dem Verlauf, den Home Assistant zu den oben zugeordneten
Sensoren bereits aufgezeichnet hat.
` : ""}
Darstellung
Aus zeigt nur die Symbole und gibt etwas Höhe frei
Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
Bild der Übersicht
Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen.
${(() => {
const info = bildInfo(startIndex());
return `
` :
`Ersparnis und Bildmarke werden ausgeblendet`}
${smartdealPopupOffen ? `
` : ""}
Fahrten zusammenfassen
Minuten
Steigst du kürzer als diese Zeit aus — etwa zum Tanken —,
bleibt es eine zusammenhängende Fahrt statt zweier
Fahrzeugprofil
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur
diese Datei getauscht — die App selbst bleibt unverändert.
Backup
Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" legt eine
Kopie auf dem Server ab, "Exportieren"/"Importieren" übertragen den aktuellen
Gesamtstand als einzelne Datei.
${CONFIG.letztesBackup ? `Letztes Backup:
${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : ""}
Daten ausgeben
Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in
Tabellenprogrammen aufgehen.
Updates laufen über HACS
HACS meldet selbst, wenn eine neuere Fassung bereitliegt, und installiert sie.
Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben dabei unangetastet —
die liegen außerhalb des Integrationsordners.
`;
}
let nmOffen = false;
let einrichtenOffen = false;
let bildMenuOffen = null;
let smartdealPopupOffen = false;
// "sommer"|"winter", welcher Reifensatz gerade sein km-Korrektur-Feld offen
// hat (Editier-Symbol neben der km-Anzeige) - null wenn keins offen ist.
let kmOffen = null;
/* --------------------------------------------------------- Setup-Popup
Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten ->
Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern"
an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */
let setupOffen = false;
let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder
let setupZuordnungStart = null; // Schnappschuss beim Öffnen - erlaubt "Abbrechen" ohne Nebeneffekt
let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken
let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null
let setupSuchtext = "";
let setupSpeichertLaeuft = false;
let ALLE_ENTITAETEN = [];
function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; }
function alleEntitaeten() {
if (!HASS || !HASS.states) return [];
return Object.keys(HASS.states).map((id) => {
const attrs = (HASS.states[id] && HASS.states[id].attributes) || {};
return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" };
});
}
function entitaetName(id) {
if (!id) return "";
const treffer = ALLE_ENTITAETEN.find((e) => e.id === id);
return treffer ? treffer.name : id;
}
/* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten,
Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der
Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart,
siehe entitaetKandidaten()). */
function entitaetScore(feld, e, suchtext) {
let score = 0;
const hay = (e.id + " " + e.name).toLowerCase();
for (const wort of feld.stichworte || []) if (hay.includes(wort)) score += 5;
if (feld.domains && feld.domains.includes(e.domain)) score += 3;
if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3;
if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2;
if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10;
return score;
}
function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
const feld = setupKatalog().find((f) => f.key === feldKey);
if (!feld) return [];
const text = (suchtext || "").trim().toLowerCase();
const liste = ALLE_ENTITAETEN.filter((e) => {
if (e.id === aktuellerWert) return true;
if (setupNurPassend) {
if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false;
if (feld.units && feld.units.length && !feld.units.includes(e.unit)) return false;
}
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
return true;
});
liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext));
return liste.slice(0, 60);
}
/* Ab diesem Wert gilt ein Vorschlag als belastbar. entitaetScore() vergibt für
einen reinen Domain-Treffer bereits 3 Punkte - eine Schwelle von 3 wäre
deshalb immer erfüllt und hätte jedes leere Feld mit dem erstbesten Sensor
der passenden Domain belegt (Reihenfolge aus Object.keys(HASS.states), also
willkürlich). 5 verlangt mindestens einen Stichworttreffer. */
const SETUP_VORSCHLAG_SCHWELLE = 5;
/* Bester Vorschlag für ein Feld, der noch nicht anderweitig vergeben ist.
Das "noch nicht vergeben" ist der Punkt: ohne diese Prüfung bekamen alle
vier Positionen einer Liste (Türen, Fenster, Schlösser) denselben Sensor,
weil der Vorschlag nicht vom Index abhängt. Der Sicherheitscheck hätte
danach viermal dieselbe Tür geprüft und "Sicher abgestellt" gemeldet,
obwohl drei Türen nie geprüft wurden. */
function setupVorschlag(feld, schonVergeben) {
for (const kandidat of entitaetKandidaten(feld.key, "", "")) {
if (schonVergeben.has(kandidat.id)) continue;
if (entitaetScore(feld, kandidat, "") < SETUP_VORSCHLAG_SCHWELLE) return "";
return kandidat.id;
}
return "";
}
function setupOeffnen() {
if (!setupBereit()) return;
ALLE_ENTITAETEN = alleEntitaeten();
setupNurPassend = true;
const werte = (ENTITAETEN && ENTITAETEN.werte) || {};
const zuordnung = {};
/* Bereits gesetzte und bereits vorgeschlagene IDs, damit kein Sensor
zweimal landet. Vorbelegte Werte kommen zuerst hinein. */
const vergeben = new Set();
for (const feld of setupKatalog()) {
const vorhandeneWerte = feld.liste ? (werte[feld.key] || []) : [werte[feld.key]];
for (const id of vorhandeneWerte) if (id) vergeben.add(id);
}
for (const feld of setupKatalog()) {
if (feld.liste) {
zuordnung[feld.key] = feld.positionen.map((_, i) => {
const vorhanden = (werte[feld.key] || [])[i];
if (vorhanden) return vorhanden;
const vorschlag = setupVorschlag(feld, vergeben);
if (vorschlag) vergeben.add(vorschlag);
return vorschlag;
});
} else {
const vorhanden = werte[feld.key];
if (vorhanden) { zuordnung[feld.key] = vorhanden; continue; }
const vorschlag = setupVorschlag(feld, vergeben);
if (vorschlag) vergeben.add(vorschlag);
zuordnung[feld.key] = vorschlag;
}
}
setupZuordnung = zuordnung;
setupZuordnungStart = JSON.parse(JSON.stringify(zuordnung));
setupSucheOffen = null; setupSuchtext = ""; setupSpeichertLaeuft = false;
setupOffen = true; render();
}
/* Das Setup lässt sich erst öffnen, wenn der Katalog wirklich da ist.
Ohne diese Prüfung öffnete sich das Fenster leer (setupKatalog() liefert
dann []), und ein Klick auf "Speichern" schrieb {} - was entitaeten.json
vollständig ersetzt und damit ALLE Zuordnungen löscht. Weil die Werte im
laufenden Prozess erhalten bleiben, wäre das erst beim nächsten Neustart
aufgefallen. */
function setupBereit() {
if (setupKatalog().length) return true;
hinweis("Setup noch nicht bereit",
"Die Liste der Sensor-Rollen wurde noch nicht vom Server geladen. Nach einem Neustart dauert das einen Moment - bitte gleich noch einmal versuchen.");
return false;
}
function setupSchliessen() {
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; render();
}
/* Standardwert (aus einstellungen.py, vor jedem Override) für ein Feld bzw.
eine Listenposition - Grundlage für den Zurücksetzen-Button. */
function setupStandardwert(key, idx) {
const standard = (ENTITAETEN && ENTITAETEN.standardwerte) || {};
const wert = standard[key];
if (idx == null) return wert || "";
return (wert || [])[idx] || "";
}
/* Entity-IDs, die im Arbeitsstand mehr als einer Rolle zugeordnet sind. */
function setupDuplikate() {
const zaehler = {};
for (const feld of setupKatalog()) {
const werte = feld.liste ? (setupZuordnung[feld.key] || []) : [setupZuordnung[feld.key]];
for (const id of werte) { if (id) zaehler[id] = (zaehler[id] || 0) + 1; }
}
return Object.keys(zaehler).filter((id) => zaehler[id] > 1);
}
/* Hier stand bis 2026-08-23 setupGeaenderteTriggerFelder(): drei Felder
(Zündung, Kilometerstand, Tankfüllstand) waren in der pyscript-Fassung
beim Modul-Laden fest in die Trigger-Registrierung eingebacken und wirkten
erst nach einem Neustart von Home Assistant. Das Setup-Fenster musste
deshalb erkennen, ob eines davon geändert wurde, davor warnen und einen
Neustart-Knopf anbieten.
Die Integration bindet ihre Zustandsbeobachter bei jedem Speichern neu
(trigger_neu_binden in koordinator.py). Die Warnung, der Knopf und der
Dienst dahinter sind damit ersatzlos entfallen - Speichern wirkt jetzt bei
allen Feldern sofort. */
function setupSpeichernAusfuehren() {
// Zweiter Riegel gegen den Datenverlust-Pfad: selbst wenn das Fenster auf
// irgendeinem Weg leer aufgeht, wird nichts geschrieben. overrides_schreiben()
// ersetzt die Datei vollstaendig - ein leeres Objekt loescht alles.
if (!setupZuordnung || !Object.keys(setupZuordnung).length) {
hinweis("Nichts zu speichern", "Es ist keine Zuordnung geladen. Bitte das Setup schliessen und neu oeffnen.");
return;
}
setupSpeichertLaeuft = true; render();
(async () => {
try {
await HASS.callService(DOMAIN, "entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) });
} catch (err) {
setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return;
}
setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render();
})();
}
/* Warnhinweis, falls der aktuell gewählte Sensor keinen brauchbaren Wert
liefert - sichtbar, bevor gespeichert wird, statt es erst hinterher in
der App zu bemerken. */
function entitaetStatusWarnung(wert) {
if (!wert) return "";
const zustand = HASS.states[wert] ? HASS.states[wert].state : null;
if (zustand === null) return `
${satz("sommer")}
${satz("winter")}`;
}
/* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden
Fahrt - dieselben Felder an beiden Stellen, damit sie nicht auseinander-
laufen können (gleiches Muster wie tankFelder()/tankFormularWerte()). Die
Felder decken genau das ab, was die Einzelfahrt-Seite anzeigt; Verbrauch,
Status und km-Quelle bleiben außen vor, die meldet das Fahrzeug bzw. leitet
das Backend ab. */
/* Gespeichert bleibt der kleingeschriebene Schlüssel - er wird an mehreren
Stellen verglichen (Pille auf der Einzelfahrt, Statistik "Arbeitsweg", CSV)
und steht so auch in allen bereits erfassten Fahrten. Angezeigt wird er
groß, deshalb artText() statt eines geänderten Werts. */
const FAHRT_ARTEN = ["privat", "arbeitsweg"];
const artText = (a) => (a ? a.charAt(0).toUpperCase() + a.slice(1) : "");
/* "1 Std. 15 Min." statt "75 min" - bei mehrstündigen Fahrten liest sich die
reine Minutenzahl schlecht. */
function dauerText(sekunden) {
if (sekunden == null || !isFinite(sekunden)) return "–";
const min = Math.max(0, Math.round(sekunden / 60));
const std = Math.floor(min / 60);
return std ? `${std} Std. ${min % 60} Min.` : `${min} Min.`;
}
/* Start und Ankunft eines Formulars als echte Zeitpunkte. Liegt die Ankunft
vor der Abfahrt, wurde über Mitternacht gefahren - dann zählt der Folgetag.
null, solange Datum oder eine der beiden Uhrzeiten fehlt. */
function fahrtZeitraum(praefix) {
const datum = $(`#${praefix}Datum`).value, ab = $(`#${praefix}Start`).value, an = $(`#${praefix}Ende`).value;
if (!datum || !ab || !an) return null;
const start = new Date(`${datum}T${ab}:00`);
let ende = new Date(`${datum}T${an}:00`);
if (isNaN(start) || isNaN(ende)) return null;
if (ende <= start) ende = new Date(ende.getTime() + dTag);
return { start, ende };
}
function fahrtDauerText(praefix) {
const z = fahrtZeitraum(praefix);
return z ? dauerText((z.ende - z.start) / 1000) : "–";
}
/* Datum für ein in Ortszeit. Bewusst nicht über
toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht
den Vortag. */
function datumsfeld(d) {
const zwei = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`;
}
function zeitfeld(d) {
const zwei = (n) => String(n).padStart(2, "0");
return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`;
}
function fahrtFelder(praefix, t) {
const start = t && t.ts_start ? new Date(t.ts_start) : new Date();
const ende = t && t.ts_end ? new Date(t.ts_end) : null;
const art = t && t.art ? t.art : FAHRT_ARTEN[0];
const wert = (v) => (v == null ? "" : v);
const startzeit = t ? zeitfeld(start) : "08:00", ankunftzeit = ende ? zeitfeld(ende) : "08:30";
// Die Dauer ist bewusst kein Eingabefeld: sie ergibt sich aus Start und
// Ankunft und würde sich sonst mit ihnen widersprechen können. Aus
// t.duration_s statt neu berechnet, damit eine über Mitternacht laufende
// bestehende Fahrt (Ankunft "kleiner" als Start) korrekt bleibt; bei einer
// neuen Fahrt (t === null) ergibt sich der Startwert aus denselben
// Vorgabezeiten wie die Felder selbst. Der data-fahrtzeitfeld-Handler
// rechnet bei jeder Änderung neu.
let dauerSekunden = t && t.duration_s != null ? t.duration_s : null;
if (dauerSekunden == null) {
const [sh, sm] = startzeit.split(":").map(Number), [eh, em] = ankunftzeit.split(":").map(Number);
dauerSekunden = ((eh * 60 + em) - (sh * 60 + sm) + (eh * 60 + em <= sh * 60 + sm ? 1440 : 0)) * 60;
}
const dauer = dauerText(dauerSekunden);
return `
${dauer}
km
`;
}
function fahrtFormularWerte(praefix) {
const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); };
const text = (id) => $(`#${praefix}${id}`).value.trim() || null;
const z = fahrtZeitraum(praefix);
return {
ts_start: z ? z.start.toISOString() : null,
ts_end: z ? z.ende.toISOString() : null,
art: $(`#${praefix}Art`).value,
start_ort: text("Startort"), ziel_ort: text("Zielort"),
odo_start: zahl("OdoStart"), odo_end: zahl("OdoEnde"), distanz: zahl("Distanz"),
};
}
function vTripsFormular() {
// eigene Funktion, damit sowohl der volle als auch der Leer-Zustand
// (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe
// Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf
// wechselte zu "Abbrechen", aber es erschienen keine Felder.
return `${fahrtOffen ? `
Neue Fahrt
${fahrtFelder("neuFahrt", null)}
` : ""}
`;
}
function vTrips() {
const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a);
/* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn
die Zündungs-Entität (Setup -> Für die Fahrterkennung nötig) erkennt,
dass der Motor läuft. */
if (!jahre.length) return `
Noch keine Fahrten
Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.
${vTripsFormular()}`;
let h = `
`;
jahre.forEach((j, ji) => {
const tj = TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j);
h += `
`; }
function statTile(id, label, fig, unit, extra, body) {
return `
${body}
`;
}
/* Echte Auswertung aus TRIPS/FILLS statt Beispielwerten. Nur abgeschlossene
Fahrten/Tankvorgänge zählen (distance_km bzw. liters gesetzt) - offene/
unvollständige Einträge haben noch keinen belastbaren Wert.
Der Verbrauch ist eine Näherung (getankte Menge im Zeitraum ÷ gefahrene
km im selben Zeitraum), da einzelne Fahrten keinen eigenen Verbrauchswert
tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen
l/100km-Wert. */
function periodStart(art) {
const jetzt = new Date();
if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1);
if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1);
if (art === "woche") {
const d = new Date(jetzt); d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d;
}
const d = new Date(jetzt); d.setHours(0, 0, 0, 0); return d;
}
const fahrtenSeit = (art) => TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) >= periodStart(art));
const tankSeit = (art) => FILLS.filter((f) => f.liters != null && new Date(f.ts) >= periodStart(art));
function istNachtZeit(ts) {
const z = new Date(ts).toTimeString().slice(0, 5), von = CONFIG.nachtVon, bis = CONFIG.nachtBis;
return von > bis ? (z >= von || z < bis) : (z >= von && z < bis);
}
/* D3: Die Strecke führt die Zeile, das Datum steht darunter. Umbruch nur
zwischen den Orten, nie am Pfeil. */
function strecke(t) {
const a = t.start_address, b = t.end_address;
if (!a && !b) return "Fahrt ohne Ortsangabe";
if (!b) return esc(a);
if (!a) return esc(b);
return `${esc(a)} → ${esc(b)}`;
}
/* A1.1: Der Verbrauch einer einzelnen Fahrt kommt vom Fahrzeug (OBD) und wird
vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */
const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null);
/* Mittelwert der gemeldeten Fahrtverbräuche, nach Distanz gewichtet. */
function schnittFahrtVerbrauch(list) {
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
const km = sum(mit, (t) => t.distance_km);
return km > 0 ? de(sum(mit, (t) => fahrtVerbrauch(t) * t.distance_km) / km, 1) : "–";
}
/* A1.2: Langzeitverbrauch = alle je getankten Liter geteilt durch alle bis zum
letzten Tankstop gefahrenen Kilometer. Die frühere Rechnung (Liter der
Periode ÷ km der Periode) ergab unmögliche Werte, sobald eine Fahrt fehlte
oder ein Tankvorgang noch unvollständig war. */
function langzeitverbrauch() {
const tanks = FILLS.filter((f) => f.liters != null).sort((a, b) => new Date(a.ts) - new Date(b.ts));
if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" };
const letzter = tanks[tanks.length - 1];
const bis = new Date(letzter.ts);
const liter = sum(tanks, (f) => f.liters);
/* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst -
er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt
er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */
const mitOdo = tanks.filter((f) => f.odometer_km != null);
let km = 0, basis = "erfasste Fahrten";
if (mitOdo.length >= 2) {
km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km;
basis = "Kilometerstand an den Tankvorgängen";
} else {
km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km);
}
const roh = km > 0 ? (liter / km) * 100 : null;
/* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen
physikalisch unmöglichen Wert als Tatsache anzuzeigen. */
const gueltig = roh != null && roh >= 3 && roh <= 30;
return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: roh != null && !gueltig };
}
function vStat() {
const jahr = new Date().getFullYear();
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
const tJahr = tankSeit("jahr");
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km);
const nachtKm = kmJahr - tagKm;
const arbeitFahrten = fJahr.filter((t) => t.art === "arbeitsweg"), privatFahrten = fJahr.filter((t) => t.art !== "arbeitsweg");
const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm;
const lv = langzeitverbrauch();
return `
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
quad("Gefahrene Distanz", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) +
quad("Fahrzeit", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))}
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
quad("Getankte Liter", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
quad("Kraftstoffkosten", [[de(eJahr), "Jahr €"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat €"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche €"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag €"]]))}
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–",
`
Getankt insgesamt
${de(lv.liter, 1)} l
Strecke bis zum letzten Tankstop
${de(lv.km)} km${lv.basis}
Letzter Tankstop
${lv.bis ? dedat(lv.bis) : "–"}
${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""}
${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.`)}
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
`
Arbeitsweg · ${de(arbeitKm)} kmPrivat · ${de(privatKm)} km
Fahrten Arbeitsweg
${de(arbeitFahrten.length)}
Fahrten privat
${de(privatFahrten.length)}
`)}`;
}
let neuOffen = false;
let fahrtOffen = false;
let tankBearbeitenOffen = false;
let fahrtBearbeitenOffen = false;
// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten }
// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars.
let belegEntwurf = null;
// Fehlermeldung aus einem Beleg-Upload (z. B. "Falsches Belegdatum"):
// { context, meldung } - context ist "neuTank"/"bearbTank" (Entwurf) oder
// eine tank_id (Einzelbeleg-Ansicht), je nachdem welcher Beleg-Button es war.
let belegFehler = null;
// Merkt sich beim Auslösen eines nur-parsenden Uploads (neu/bearb), welcher
// Entwurf auf das Ergebnis wartet - die Antwort selbst trägt dafür keine
// praefix-Information, nur tank_id (bei einem Parse-Ergebnis immer null).
let belegZielPraefix = null;
// Offenes Beleg-Fenster: { tankId, kontext, fehler }. Am Rechner wird der Beleg
// hineingezogen, am Telefon aus der Zwischenablage eingefügt (aus der Mail
// kopiertes PDF) - der Dateiauswahl-Knopf bleibt als dritter Weg erhalten.
let belegPopup = null;
/* Wischen nach links zum Löschen (Fahrten/Tankvorgänge/Servicebuch), analog
zu iOS-Listen. swipeZeile() umschließt eine bestehende Listenzeile mit
einem roten "Löschen"-Hintergrund; die eigentliche Wischgeste wird über
Pointer Events in ereignisseVerdrahten() ausgewertet (nicht Touch Events),
damit dieselbe Logik auch mit der Maus funktioniert. id folgt demselben
"art:id"-Schema wie data-go, siehe eintragLoeschen(). */
let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0;
let swipeWarGezogen = false;
const SWIPE_BREITE = 84;
/* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des
Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene
Variablen statt der swipe*-Variablen oben, da beide Gesten unabhängig
voneinander gleichzeitig "angefasst" werden können (Pointerdown auf einer
Listenzeile UND ganz oben gescrollt). */
let ptrY0 = null, ptrDrag = 0, ptrLaedt = false;
async function datenAktualisieren() {
try {
await HASS.callService(DOMAIN, "jetzt_aktualisieren", {});
const states = await HASS.callWS({ type: "get_states" });
const frisch = states.find((s) => s.entity_id === E.fahrzeugstatus);
if (frisch) NACHGELADEN.status = frisch;
} catch (e) {
// bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt
// holt den Stand ohnehin nach
}
datenLaden(true);
}
function schliesseAlleSwipes() {
ROOT.querySelectorAll(".swipe-wrap.swiped").forEach((w) => w.classList.remove("swiped"));
}
function swipeZeile(id, inhaltHtml) {
return `
${inhaltHtml}
`;
}
const LOESCH_TEXT = {
trip: "Fahrt löschen?",
fill: "Tankvorgang löschen?",
sbuch: "Servicebuch-Eintrag löschen?",
bild: "Foto löschen?",
};
const LOESCH_HINWEIS = {
trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.",
fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.",
sbuch: "Der Eintrag wird aus dem Servicebuch entfernt. Das lässt sich nicht widerrufen.",
bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.",
};
function eintragLoeschen(art, id) {
if (art === "trip") {
serviceRufen("fahrt_loeschen", { trip_id: id });
TRIPS = TRIPS.filter((t) => t.trip_id !== id);
} else if (art === "fill") {
serviceRufen("tankvorgang_loeschen", { tank_id: id });
FILLS = FILLS.filter((f) => f.tank_id !== id);
} else if (art === "sbuch") {
CAR.service.buch.splice(parseInt(id, 10), 1);
profilSpeichern();
}
}
/* Index eines gerade per "Eintrag hinzufügen" angelegten, aber noch nicht
bestätigten Servicebuch-Eintrags. Nur solange gesetzt, wie die Seite
offen ist und nicht gespeichert wurde - siehe data-sbneu/data-sbspeichern
und den Verwerfen-Schritt in go(). */
let entwurfIndex = null;
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];
/* Welche Fassung der Integration gerade installiert ist (Einstellungen ->
Version). Kommt aus sensor.audi_dashboard_app_version, also aus der
manifest.json der Integration - der Zahl, die auch HACS anzeigt.
Hier stand bis 2026-08-23 eine eigene Update-Funktion: "Update suchen"
klonte ein Git-Repository in einen Zwischenordner und kopierte pyscript/
und www/ von dort über den laufenden Code. Das war nötig, solange die App
keine Installationsform hatte, die Home Assistant selbst kennt. Als
HACS-Integration gibt es die: HACS prüft, lädt, installiert und kann
zurückrollen, mit Versionsanzeige und Änderungsprotokoll. Eine zweite,
selbstgebaute Update-Mechanik daneben wäre mehr Angriffsfläche als
Nutzen - der eigene Updater ist deshalb ersatzlos entfallen. */
let APP_VERSION = null;
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
Schriftzug unter dem Bild auf der Übersicht). Zusatz, Untertitel, Details
und die technischen Daten hängen bewusst nicht daran - die bleiben, auch
bei einem anderen Modellnamen, wie zuletzt im Profil eingetragen.
Beschränkt auf die Modelle, zu denen ein echtes Badge vorliegt (siehe
BADGE_DATEIEN/www/badges/) - für alles andere gäbe es sonst ein falsches
oder gar kein Badge auf der Übersicht. */
const FAHRZEUG_MODELLE = [
"RS 3 Sportback", "RS 3 Limousine", "RS 4 Avant",
"RS 5 Coupé", "RS 5 Sportback", "RS 6 Avant", "RS 6 Avant performance",
"RS 7 Sportback", "S3 Sportback", "S3 Limousine", "SQ7",
];
/* Badge-Zuordnung (www/badges/-positive.svg / -negative.svg, jeweils
das offizielle Audi-Markenasset). Schlüssel sind eindeutige Präfixe der
FAHRZEUG_MODELLE-Einträge - Reihenfolge ist hier egal, da sich keiner der
Präfixe mit einem anderen überschneidet. */
const BADGE_DATEIEN = {
"RS 3": "rs3", "RS 4": "rs4", "RS 5": "rs5", "RS 6": "rs6", "RS 7": "rs7",
"S3": "s3", "SQ7": "sq7",
};
function badgePraefix() {
const titel = CONFIG.fahrzeugtitel || "";
return Object.keys(BADGE_DATEIEN).find((p) => titel.startsWith(p)) || null;
}
/* Gemeinsames Formular für "Tankvorgang erfassen" und die Bearbeitung eines
bestehenden Belegs (Einzelseite) - dieselben Felder an beiden Stellen,
damit sie nicht auseinanderlaufen können. Preis je Liter ist bewusst kein
Eingabefeld, sondern wird aus Kosten/Liter berechnet (live über den
input-Handler in ereignisseVerdrahten, endgültig beim Speichern). */
const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"];
/* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: Differenz zum
zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe Regel
wie backendseitig in _distanz_seit_letzter_tankung() (belegverarbeitung.py),
hier nur zur Vorbefüllung, da der neue Datensatz noch nicht existiert. Beim
Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus
f.distance_km (siehe Aufrufer). */
function distanzVorschlag(aktuellerKm) {
if (aktuellerKm === "" || aktuellerKm == null || !FILLS.length) return "";
const letzte = FILLS.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a));
if (letzte.odometer_km == null) return "";
return Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10;
}
function tankFelder(praefix, f) {
// Ein zuvor hochgeladener, noch nicht gespeicherter Beleg (belegEntwurf)
// überlagert hier nur die Beleg-Felder - Kilometerstand/Distanz kommen
// immer vom Fahrzeug bzw. von f, nie vom Beleg (§7.7 Regel 2).
const b = belegEntwurf && belegEntwurf.praefix === praefix ? belegEntwurf.daten : null;
const q = b ? { ...(f || {}), ts: b.ts || (f && f.ts), liters: b.liters, fuel_total_eur: b.fuel_total_eur, discount: b.discount, station_name: b.station_name, fuel_type: b.fuel_type } : f;
const ts = q && q.ts ? new Date(q.ts) : new Date();
const datum = ts.toISOString().slice(0, 10);
const zeit = String(ts.getHours()).padStart(2, "0") + ":" + String(ts.getMinutes()).padStart(2, "0");
const km = f && f.odometer_km != null ? f.odometer_km : (CAR.odoBekannt ? CAR.odo : "");
const distanz = f ? (f.distance_km != null ? f.distance_km : "") : distanzVorschlag(km);
const liter = q && q.liters != null ? q.liters : "";
const kosten = q && q.fuel_total_eur != null ? q.fuel_total_eur : "";
const ersparnis = q && q.discount != null ? q.discount : "";
const kraftstoff = q && q.fuel_type ? q.fuel_type : KRAFTSTOFFSORTEN[0];
const station = q && q.station_name ? q.station_name : "";
const preisText = q && q.liters && q.fuel_total_eur != null && q.liters > 0
? `Preis je Liter: ${de(q.fuel_total_eur / q.liters, 2)} €` : "Preis je Liter: wird berechnet";
return `
km
€
${CAR.smartdeal.aktiv ? `
€
` : ""}
${preisText}`;
}
function tankFormularWerte(praefix) {
const liter = parseFloat(($(`#${praefix}Liter`).value || "0").replace(",", "."));
const kosten = parseFloat(($(`#${praefix}Kosten`).value || "0").replace(",", "."));
const kmFeld = $(`#${praefix}Km`).value;
const distanzFeld = $(`#${praefix}Distanz`).value;
const ersparnisFeld = CAR.smartdeal.aktiv ? $(`#${praefix}Ersparnis`) : null;
const datum = $(`#${praefix}Datum`).value, zeit = $(`#${praefix}Zeit`).value;
return {
ts: new Date(`${datum}T${zeit}:00`).toISOString(),
liter, kosten,
km: kmFeld === "" ? null : parseFloat(kmFeld),
distanz: distanzFeld === "" ? null : parseFloat(distanzFeld),
ersparnis: ersparnisFeld && ersparnisFeld.value !== "" ? parseFloat(ersparnisFeld.value.replace(",", ".")) : null,
kraftstoff: $(`#${praefix}Kraftstoff`).value,
station: $(`#${praefix}Station`).value,
};
}
function vFuelFormular() {
// Gleicher Grund wie bei vTripsFormular(): das Formular muss auch im
// Leer-Zustand erscheinen, nicht nur wenn schon Tankvorgänge vorhanden sind.
return `${neuOffen ? `
` : ""}
`;
}
function vFuel() {
const jahre = [...new Set(FILLS.map((f) => new Date(f.ts).getFullYear()))].sort((a, b) => b - a);
/* D5 */
if (!jahre.length) return `
Noch keine Tankvorgänge
Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen – oder den Vorgang gleich hier von Hand erfassen.
${vFuelFormular()}`;
let h = `
`;
jahre.forEach((j, ji) => {
const fj = FILLS.filter((f) => new Date(f.ts).getFullYear() === j);
const ersparnisJahr = sum(fj, (f) => f.discount || 0);
/* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge
folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher
konkurrierten drei gleich große rechtsbündige Werte miteinander. */
h += `
`;
const monate = [...new Set(fj.map((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" })))];
monate.forEach((m, mi) => {
const fm = fj.filter((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" }) === m);
h += `
`;
}
/* ---------------------------------------------------------- Steuerung */
const ICONS = {
home: '',
/* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz,
assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
audi: '',
trips: '',
stat: '',
fuel: '',
};
const TABS = [
{ id: "home", label: "Übersicht", eyebrow: "", title: "Übersicht" },
{ id: "audi", label: "Mein Audi", eyebrow: "", title: "" },
{ id: "trips", label: "Fahrten", eyebrow: "", title: "Fahrten" },
{ id: "stat", label: "Statistik", eyebrow: "", title: "Statistik" },
{ id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" },
];
/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */
const ICONS_VOLL = new Set(["audi"]);
let route = { name: "home" };
const ZURUECK = {
trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi",
vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails",
vertrag: "vers",
steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home",
battverlauf: "audi",
/* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren
Zurück-Pfeil. */
sicherheit: "home",
standort: "home",
};
const ICON = {
sonne: '',
mond: '',
};
function themeIcon() { const el = $("#themeicon"); if (el) el.innerHTML = HOST.dataset.theme === "nacht" ? ICON.sonne : ICON.mond; }
/* A6: gespeicherte Wahl, sonst Systemeinstellung. */
const THEME_KEY = "audi-dashboard-theme";
function themeGewaehlt() {
let gespeichert = null;
try { gespeichert = localStorage.getItem(THEME_KEY); } catch (e) { /* Privatmodus */ }
if (gespeichert === "nacht" || gespeichert === "tag") return gespeichert;
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "tag" : "nacht";
}
function themeSetzen(modus) {
HOST.dataset.theme = modus;
try { localStorage.setItem(THEME_KEY, modus); } catch (e) { /* Privatmodus */ }
tiles();
}
/* ------------------------------------------------------------ Action Sheet
B4: ersetzt confirm()/alert(). Ein offenes Sheet ist Zustand wie jeder
andere - render() zeichnet es mit, damit es Ansichtswechsel überlebt. */
let sheet = null;
const sheetSchliessen = () => { sheet = null; render(); };
function bestaetigen(titel, text, knopf, aktion, destruktiv = true) {
sheet = { titel, text, knopf, aktion, destruktiv };
render();
}
function hinweis(titel, text) {
sheet = { titel, text, knopf: null, aktion: null };
render();
}
/* ------------------------------------------------------- Beleg-Upload
Ein Weg für alle drei Quellen (Ziehen, Einfügen, Dateiauswahl). Kein
serviceRufen() hier: dessen catch loggt nur in die Konsole - siehe den
ausführlichen Kommentar an der früheren Aufrufstelle, der Grund gilt
unverändert. */
function belegDateiVerarbeiten(datei, tankId, kontext) {
if (!datei) return;
if (!istPdf(datei)) {
belegFehler = { context: kontext, meldung: "Nur PDF-Belege werden unterstützt" };
render(); return;
}
const leser = new FileReader();
leser.onload = () => {
const base64 = leser.result.split(",")[1];
HASS.callService(DOMAIN, "beleg_hochladen",
{ pdf_base64: base64, dateiname: datei.name || "beleg.pdf", tank_id: tankId }
).catch((err) => {
console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err);
belegFehler = { context: kontext, meldung: "Beleg konnte nicht hochgeladen werden" };
if (!tankId) belegEntwurf = null;
render();
});
};
leser.readAsDataURL(datei);
}
/* Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte
oder gezogene Datei kommt je nach Browser und Quelle mit leerem type an. */
function istPdf(datei) {
if (!datei) return false;
return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || "");
}
/* Nimmt das erste PDF aus einer DataTransfer-/Clipboard-Dateiliste. */
function belegAusDateiliste(dateien) {
return [...(dateien || [])].find(istPdf) || null;
}
function belegPopupSchliessenUndLaden(datei) {
const ctx = belegPopup;
if (!ctx || !datei) return;
belegPopup = null;
belegDateiVerarbeiten(datei, ctx.tankId, ctx.kontext);
render();
}
/* Telefon-Weg: das aus der Mail kopierte PDF liegt in der Zwischenablage.
navigator.clipboard.read() ist dort der einzige Weg, ohne dass der Nutzer
in ein Eingabefeld tippen muss - iOS zeigt dafür seine eigene
Einfügen-Abfrage. Der Knopf erscheint deshalb nur auf Tippgeräten, siehe
zwischenablageKnopfSinnvoll(). */
async function belegAusZwischenablage() {
const ctx = belegPopup;
if (!ctx) return;
if (!navigator.clipboard || !navigator.clipboard.read) {
belegPopup.fehler = "Dieser Browser gibt die Zwischenablage nicht heraus - bitte \"Datei auswählen\".";
render(); return;
}
try {
for (const eintrag of await navigator.clipboard.read()) {
const typ = (eintrag.types || []).find((t) => t === "application/pdf");
if (typ) { belegPopupSchliessenUndLaden(new File([await eintrag.getType(typ)], "beleg.pdf", { type: "application/pdf" })); return; }
}
belegPopup.fehler = "In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder \"Datei auswählen\".";
} catch (err) {
belegPopup.fehler = "Kein Zugriff auf die Zwischenablage - bitte \"Datei auswählen\".";
}
render();
}
/* Der Einfügen-Knopf wird nur auf Tippgeräten angeboten. Am Rechner scheitert
er zwangsläufig: clipboard.read() gibt aus Datenschutzgründen nur Text,
HTML und Bilder heraus, eine im Explorer kopierte Datei taucht dort nie
auf. Und das Tastenkürzel Strg+V hilft dort ebenfalls nicht - ohne
Eingabefeld im Fenster hat der Browser kein Ziel für die Einfüge-Geste
(vom Nutzer bestätigt). Am Rechner bleiben daher Ziehen und Dateiauswahl. */
function zwischenablageKnopfSinnvoll() {
return window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
}
/* ------------------------------------------------- Historien-Import-Popup
Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche
Import läuft im Backend (custom_components/audi_dashboard/historienimport.py)
und meldet seinen Stand über die Entität sensor.audi_dashboard_import_status:
der Dienstaufruf kehrt sofort zurück, weil ein Import über ein Jahr Verlauf
Minuten dauern kann. */
let importPopup = null;
/* Vorbelegung: die letzten 30 Tage. Wert im Format, das erwartet (Ortszeit, ohne Zeitzone, minutengenau) -
genau das, was der Dienst als Ortszeit interpretiert. */
function importZeitraumVorgabe() {
const jetzt = new Date();
const vor30 = new Date(jetzt.getTime() - 30 * 86400000);
const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 16);
return { von: alsFeld(vor30), bis: alsFeld(jetzt) };
}
function importErgebnisText(d) {
const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`;
const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"];
if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`);
if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt"));
if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge"));
if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung"));
return teile.join(" · ");
}
function vImportPopup() {
if (!importPopup) return "";
const p = importPopup;
const fertig = p.ergebnis && !p.laeuft;
// Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der
// Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG
// "Feedback"), statt das Fenster wortlos verschwinden zu lassen.
const koerper = fertig
? `
${esc(importErgebnisText(p.ergebnis))}
${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten
? `
Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} ·
${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.
Weiter zurück bewahrt Home Assistant nichts mehr auf.`
: ""}
${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten
? `
Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.`
: ""}`
: `
${p.fehler ? `${esc(p.fehler)}` : ""}
Bereits erfasste Fahrten bleiben unangetastet — überschneidet sich ein
Zeitraum, wird er übersprungen statt doppelt angelegt.`;
return `
Daten importieren aus Home Assistant
${koerper}
${fertig
? ``
: `
`}
`;
}
function vBelegPopup() {
if (!belegPopup) return "";
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
// Rechner Ziehen bzw. Dateiauswahl. Strg+V wird am Rechner bewusst NICHT
// mehr angeboten: ohne Eingabefeld im Fenster hat der Browser kein Ziel für
// die Einfüge-Geste, das Tastenkürzel läuft dort also ins Leere.
const telefon = zwischenablageKnopfSinnvoll();
return `
Beleg hochladen
${telefon
? `Beleg einfügendas aus der Mail kopierte PDF`
: `PDF hierher ziehenoder unten auswählen`}