diff --git a/AGENTS.md b/AGENTS.md
index ce540dc..e0d5e78 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,8 +6,9 @@ Speichern-Knopf, und die Share-Erweiterung trug eine andere Versionsnummer als d
dazu die Kopfmarke statt der Firmierung des Betreibers und ein
Cache-Brecher auf der .ipa-Adresse; die .ipa liess sich zuletzt nicht mehr
installieren - der Erweiterung fehlte die CFBundleVersion; dazu der QR-Scanner
-fuer den Zugangs-Token, `2026.9.4.8`,
-Abschnitte CC bis CE. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
+fuer den Zugangs-Token; dazu Debug-Mode, eine Version-Kachel, das Knopfpaar nach
+HIG und Bilder ohne Verzoegerung, `2026.9.4.11`,
+Abschnitte CC bis CF. Davor: Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt:
das Fahrtfenster stand in Geraetezeit, der Verlauf war nach Ankunftszeit sortiert. Dazu die neun
Paritaetsbefunde und die verunreinigte Batteriehistorie, `2026.9.4.1`, Abschnitt CB. Davor: Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und
schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie
@@ -9844,3 +9845,100 @@ unter „Zugangs-Token", das Blatt oeffnet mit Videofeld und Zielrahmen, und ohn
Kamerafreigabe steht dort die erklaerende Meldung statt einer leeren Flaeche.
**Nicht geprueft, weil es in dieser Umgebung nicht geht:** ein echter Scan - die
Browser-Vorschau gibt keine Kamera heraus. Das zeigt sich erst auf dem Geraet.
+
+## CF. Debug-Mode, eine Version-Kachel, Knoepfe nach HIG, Bilder ohne Verzoegerung (2026.9.4.11)
+
+Vier Punkte aus demselben Stapel des Eigentuemers, beide Codebasen.
+
+### Debug-Mode: Zugaenge, Dongle und der letzte Datensatz, zugeklappt und unten
+
+„Zugaenge" stand bisher als zweite Kachel ganz oben, „Dongle" mitten in der
+Seite - beides Dinge, die man einmal im Vierteljahr anfasst. Sie stehen jetzt
+zusammen hinter einem Schalter am Seitenende, zusammen mit einer dritten,
+neuen Kachel.
+
+**`geraet.py` (neu): der letzte Datensatz des Dongles.** Die flespi-Integration
+setzt die Entitaeten EINES Geraetedatensatzes nacheinander, aber innerhalb
+weniger Millisekunden (am 01.09.2026 gemessen: 2 ms zwischen Zuendung und
+zugehoeriger Meldezeit, Abschnitt AY). „Der letzte Datensatz" ist deshalb
+definiert als: alle Entitaeten des Geraets, deren letzte Aktualisierung
+hoechstens `FENSTER_S` (2 s) nach der juengsten liegt. Das Geraet wird nicht
+geraten, sondern ueber das Entitaetsregister der zugeordneten Meldezeit
+bestimmt (sonst Fahrtsignal, sonst Zuendung); findet sich keines, bleibt der
+Wert `None`.
+
+Angezeigt werden Geraetename, **Ankunft**, **Geraetezeit** und der **Rueckstand**
+zwischen beiden - genau die Angabe, deren Missachtung die Wurzel des
+3-km-Fahrt-Fehlers war (Abschnitt CB).
+
+**Ein echter Fund beim Verifizieren:** die Kachel blieb leer.
+`app_version_veroeffentlichen()` laeuft naemlich **nur** beim Start und nach
+einer Aktion (Token, Dongle, Update) - beim Start haben die Entitaeten des
+Geraets aber noch gar keinen Zustand, und danach wurde nie wieder
+veroeffentlicht. Der Minutentakt ruft sie jetzt mit. Kosten: ein kleiner
+Dateilesevorgang (`bundle.json`) je Minute, **kein** Netzaufruf - die Pruefung
+bei Gitea bleibt an ihrem Dienst (Abschnitt J).
+
+Live nachgewiesen im Panel: 46 Werte aus derselben Meldung, Geraetezeit
+04.09. 00:49 gegen Ankunft 12:08 - Rueckstand 40.745 s, weil der Dongle seit
+dem Vorabend schweigt und „Angekommen" nach einem Neustart Home Assistants
+eigener Zeitpunkt ist. Genau das sagt die Fussnote der Kachel auch.
+
+### „Version" und „Integration-Update" sind eine Kachel
+
+Es ist dieselbe Sache aus zwei Blickwinkeln; zwei Kacheln nebeneinander
+zwangen dazu, zwei Zahlen ueber eine Kachelgrenze hinweg zu vergleichen.
+
+### Das Knopfpaar Speichern/Verwerfen
+
+Gemeldet: „Verwerfen ist kein richtiger Button". Stimmt - in der App war es
+ein blosser Textknopf neben einem vollen. Jetzt **zwei gleich breite Knoepfe**,
+der bestaetigende gefuellt und rechts, der Ausweg daneben als zweite Rolle:
+ein Paar aus Bestaetigen und Abbrechen ist eine Entscheidung zwischen zwei
+Wegen, keine Haupt- und Nebensache, und welcher gewollt ist, sagt die Fuellung
+und nicht die Groesse.
+
+Geaendert an **beiden** Stellen der App (Regler-Kachel und Speicherleiste am
+Seitenende), sonst haette dieselbe Sache zwei Muster auf einer Seite. Im Panel
+standen beide bereits als volle Knoepfe, nur untereinander - dort jetzt
+dieselbe Zeile (`.knopfpaar`).
+
+**Nicht angefasst:** die „Abbrechen"-Textknoepfe der Bearbeiten-Formulare in
+`FahrtDetail.tsx`/`TankDetail.tsx`. Dieselbe Frage, aber nicht gemeldet und ein
+anderer Zusammenhang (Formular statt Einstellung) - hier vermerkt, nicht still
+mitgeaendert.
+
+### Bilder erscheinen ohne Verzoegerung
+
+Gemeldet: „Bilder laden beim Umschalten der Menues einige ms verzoegert."
+
+**Am Netz liegt es nicht**, und das ist der Punkt: die Adresse ist innerhalb
+einer Sitzung stabil, und Home Assistant liefert `/local/` mit 31 Tagen
+Cache-Dauer (in Abschnitt AR gemessen). Verzoegert wird das Bild selbst - ein
+frisch erzeugtes `` wird standardmaessig **asynchron dekodiert** und erst
+danach gezeichnet, und beim Seitenwechsel entsteht jedes Mal ein neues.
+
+`bilderVorladen()` holt alle Fahrzeugfotos einmal und ruft `decode()` darauf.
+Danach zeichnet dasselbe Bild beim naechsten Einhaengen sofort. Ausgefuehrt in
+der **Leerlaufzeit** nach dem ersten Zeichnen (`requestIdleCallback`, sonst
+`setTimeout`), damit es nicht mit dem Abruf von Profil, Fahrten und
+Tankvorgaengen konkurriert; das Datenvolumen ist dasselbe wie beim
+Durchblaettern aller Seiten, nur frueher.
+
+Live nachgewiesen im Panel: nach dem Laden stehen alle neun Bildplaetze plus
+die Winter-Seitenansicht in `performance.getEntriesByType("resource")`.
+
+### Verifiziert
+
+`py_compile` auf `geraet.py` und `koordinator.py`, Panel als **Modul** geparst,
+`tsc --noEmit` sauber, **214/214** Tests, `vite build` sauber, `audi_ha_test`
+auf `2026.9.4.11` sauber gestartet, und im Panel live nachgesehen: Debug-Mode
+klappt auf und zeigt Zugaenge, Dongle und die 49 Zeilen des letzten
+Datensatzes; „Version" traegt jetzt Installiert **und** Update.
+
+**Nicht live geprueft:** dieselben Kacheln in der App. Die Browser-Vorschau
+kennt keinen gespeicherten Zugang, und einen Token anzulegen oder einzugeben
+ist ausgeschlossen. Abgedeckt sind sie durch `tsc`, den
+Alle-Seiten-Rendertest und die Tatsache, dass das Panel dieselbe Struktur aus
+denselben Backend-Daten baut - der Zweig MIT Datensatz ist in der App damit
+strukturell, nicht optisch belegt.
diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx
index 58d635f..870e167 100644
--- a/companion-app/src/App.tsx
+++ b/companion-app/src/App.tsx
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
+import { bilderVorladen } from "./screens/bilder"
+
import { App as CapacitorApp } from "@capacitor/app"
import { DataMetricApi, zugangLesen } from "./api"
@@ -79,6 +81,13 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
void startklarMelden()
}, [])
+ /* Fahrzeugfotos einmal vorab holen und dekodieren, sobald Daten da sind -
+ siehe bilderVorladen(). Damit erscheint beim Umschalten der Seiten sofort
+ das Bild statt kurz des Platzhalters. */
+ useEffect(() => {
+ if (daten.verbindung === "verbunden") bilderVorladen()
+ }, [daten.verbindung])
+
/* Belege aus dem Teilen-Blatt.
Die Erweiterung legt das PDF ab und oeffnet die App - beim ersten Start
diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts
index 1eb6530..e52bbff 100644
--- a/companion-app/src/api/types.ts
+++ b/companion-app/src/api/types.ts
@@ -271,6 +271,24 @@ export interface DongleAngabe {
stand: DongleStand | null;
}
+/** Ein einzelner Wert aus der letzten Meldung des Dongles (geraet.py). */
+export interface DatensatzWert {
+ name: string;
+ wert: string;
+ einheit: string | null;
+}
+
+/** Die letzte Meldung des Dongles, so wie sie in Home Assistant ankam.
+ "angekommen" ist die Ankunftszeit, "meldezeit" die Zeit des Geraets -
+ der Abstand zwischen beiden ist der Puffer-Rueckstand. */
+export interface LetzterDatensatz {
+ geraet: string;
+ angekommen: string;
+ meldezeit: string | null;
+ werte: DatensatzWert[];
+ gesamt: number;
+}
+
/** Nutzlast von sensor.audi_dashboard_app_version. */
export interface AppVersionAngabe {
app: string | null;
@@ -278,6 +296,7 @@ export interface AppVersionAngabe {
integration_update: IntegrationUpdateAngabe | null;
zugaenge?: ZugangAngabe[];
dongle?: DongleAngabe;
+ letzter_datensatz?: LetzterDatensatz | null;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx
index 52df619..360bc2b 100644
--- a/companion-app/src/daten/DatenKontext.tsx
+++ b/companion-app/src/daten/DatenKontext.tsx
@@ -27,6 +27,7 @@ import {
type Fahrzeugstatus,
type IntegrationUpdateAngabe,
type DongleAngabe,
+ type LetzterDatensatz,
type ZugangAngabe,
type Profil,
type Tankvorgang,
@@ -75,6 +76,7 @@ export interface DatenWert {
/** Konfiguration des Dongles und eine ggf. vorgemerkte Aenderung
(flespi.py). null, solange kein flespi-Token hinterlegt ist. */
dongle: DongleAngabe | null
+ letzterDatensatz: LetzterDatensatz | null
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
@@ -123,6 +125,7 @@ export function DatenAnbieter({
const [otaBuendel, setzeOtaBuendel] = useState(null)
const [zugaenge, setzeZugaenge] = useState([])
const [dongle, setzeDongle] = useState(null)
+ const [letzterDatensatz, setzeLetzterDatensatz] = useState(null)
const [integrationUpdate, setzeIntegrationUpdate] = useState(
null,
)
@@ -168,6 +171,7 @@ export function DatenAnbieter({
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
setzeZugaenge(versionsangabe?.zugaenge ?? [])
setzeDongle(versionsangabe?.dongle ?? null)
+ setzeLetzterDatensatz(versionsangabe?.letzter_datensatz ?? null)
return true
} catch (fehler) {
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
@@ -301,6 +305,7 @@ export function DatenAnbieter({
integrationUpdate,
zugaenge,
dongle,
+ letzterDatensatz,
einstellungen,
fahrzeug,
fahrten,
diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx
index 0e1799c..8c7d405 100644
--- a/companion-app/src/screens/Einstellungen.tsx
+++ b/companion-app/src/screens/Einstellungen.tsx
@@ -9,13 +9,13 @@ import { useEffect, useRef, useState } from "react"
import { Accordion, ActionButton, Feld, Seg, Sheet, Slider, Switch, Tile } from "@audi-dash/ui"
import { ApiFehler, DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
-import type { Profil } from "../api"
+import type { LetzterDatensatz, Profil } from "../api"
import { eigeneVersion } from "../daten/appVersion"
import { Datumsfeld } from "./Datumsfeld"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
-import { datum, datumZeit, isoTag, standAlter } from "../format"
+import { datum, datumZeit, de, isoTag, standAlter } from "../format"
import { useTheme } from "../theme"
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
@@ -44,6 +44,7 @@ export function Einstellungen({
serverVersion,
zugaenge,
dongle,
+ letzterDatensatz,
verbindung,
api,
profilSpeichern,
@@ -250,6 +251,7 @@ export function Einstellungen({
// Geraet) und darf nicht wie ein toter Knopf aussehen. Das Ergebnis selbst
// kommt ueber die veroeffentlichte Entitaet zurueck (flespi.py), hier steht
// nur, was den Aufruf gar nicht erst erreicht hat.
+ const [debugOffen, setzeDebugOffen] = useState(false)
const [dongleLaeuft, setzeDongleLaeuft] = useState(false)
const [dongleFehler, setzeDongleFehler] = useState(null)
@@ -409,66 +411,6 @@ export function Einstellungen({
- {zugaenge.length > 0 && (
-
- Zugänge
- {/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
- man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
- {zugaenge.map((z) => (
-
- {z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
-
- }
- >
-
}
-
- Ein gespeicherter Token wird nie wieder angezeigt — nur ob einer hinterlegt ist.
- Dieselben Felder gibt es unter Einstellungen → Geräte &
- Dienste → Audi Dashboard → Konfigurieren.
-
-
- )}
-
Fahrt beenden
{/* Erscheint erst, wenn der Regler wirklich woanders steht - ein
dauerhaft sichtbarer, meist wirkungsloser Knopf waere Beiwerk. Die
- Anordnung folgt der Speicherleiste am Seitenende: bestaetigende
- Aktion als voller Knopf, der Ausweg daneben als schlichter Text. */}
+ Anordnung folgt Apples eigener Aufteilung fuer ein Wertepaar aus
+ Bestaetigen und Abbrechen: ZWEI gleich grosse Knoepfe, der
+ bestaetigende gefuellt und rechts (die fuehrende Hand erreicht ihn
+ zuerst), der Ausweg daneben als zweite Rolle. Vorher war "Verwerfen"
+ blosser Text und dadurch kaum als Bedienelement zu erkennen -
+ gemeldet vom Eigentuemer am 04.09.2026. */}
{pausenzeitGeaendert && (
}
-
- {dongle.stand?.gelesen_am
- // Der Punkt der Abkürzung ist zugleich der Satzpunkt - sonst
- // stünde dort "vor 1 Std..".
- ? `Gelesen ${standAlter(dongle.stand.gelesen_am).replace(/\.$/, "")}. `
- : "Noch nicht gelesen. "}
- Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich
- das nächste Mal meldet, steht die Änderung als „unterwegs" daneben.
-
-
- )}
Darstellung
@@ -841,6 +745,10 @@ export function Einstellungen({
)}
+ {/* Installierte Fassung und ihr Update stehen in EINER Kachel: es ist
+ dieselbe Sache aus zwei Blickwinkeln, und zwei Kacheln nebeneinander
+ zwangen zum Vergleichen zweier Zahlen ueber eine Kachelgrenze hinweg
+ (Vorgabe des Eigentuemers, 04.09.2026). */}
Version
}
/>
-
-
-
- Integration-Update
{/* HIG "Loading": Netzwerk zu Gitea plus Entpacken dauert unbestimmt
lang, ohne verlässliche Fortschrittsangabe - ein laufender
Indikator statt eines nur deaktivierten Knopfes verhindert, dass
@@ -966,17 +870,192 @@ export function Einstellungen({
+ {/* Debug-Mode: Zugaenge, Dongle und der letzte Datensatz des Geraets.
+ Drei Kacheln, die im Alltag nichts zu suchen haben und beim Suchen
+ eines Fehlers alles - deshalb zusammen, zugeklappt und ganz unten
+ (Vorgabe des Eigentuemers, 04.09.2026). */}
+
+ Debug-Mode
+
+ Zugangs-Token, die Einstellungen des Dongles und der letzte Datensatz, den das Gerät
+ gemeldet hat. Für die Fehlersuche, nicht für den Alltag.
+
+ setzeDebugOffen(!debugOffen)}
+ >
+ {debugOffen ? "Fertig" : "Öffnen"}
+
+
+
+ {debugOffen && (
+ <>
+ {zugaenge.length > 0 && (
+
+ Zugänge
+ {/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
+ man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
+ {zugaenge.map((z) => (
+
+ {z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
+
+ }
+ >
+
}
+
+ Ein gespeicherter Token wird nie wieder angezeigt — nur ob einer hinterlegt ist.
+ Dieselben Felder gibt es unter Einstellungen → Geräte &
+ Dienste → Audi Dashboard → Konfigurieren.
+
+
+ )}
+ {dongle?.aktiv && (
+
+ Dongle
+ {dongle.stand?.fehler &&
}
+
+ {dongle.stand?.gelesen_am
+ // Der Punkt der Abkürzung ist zugleich der Satzpunkt - sonst
+ // stünde dort "vor 1 Std..".
+ ? `Gelesen ${standAlter(dongle.stand.gelesen_am).replace(/\.$/, "")}. `
+ : "Noch nicht gelesen. "}
+ Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich
+ das nächste Mal meldet, steht die Änderung als „unterwegs" daneben.
+
+
+ )}
+
+ >
+ )}
+
{gespeichert &&
)}
>
)
}
+
+/* Der letzte Datensatz des Dongles. Bewusst eine eigene Komponente: die Liste
+ ist lang, hat mit den uebrigen Einstellungen nichts zu tun, und so bleibt
+ die Hauptkomponente lesbar. */
+function LetzterDatensatzKachel({ datensatz }: { datensatz: LetzterDatensatz | null }) {
+ if (!datensatz) {
+ return (
+
+ Letzter Datensatz
+
+ Noch nichts gemeldet. Dafür braucht es die Meldezeit oder das Fahrtsignal im Setup —
+ und mindestens einen Datensatz des Geräts, seit Home Assistant läuft.
+
+ {de(datensatz.gesamt)} Werte aus derselben Meldung. Was das Gerät in diesem Datensatz
+ nicht geschickt hat, steht hier nicht — es behält in Home Assistant seinen alten Wert.
+ Nach einem Neustart ist „Angekommen“ der Zeitpunkt des Neustarts, nicht der einer
+ Übertragung — der Rückstand sagt dann, wie lange das Gerät schon schweigt.
+
+
+ )
+}
diff --git a/companion-app/src/screens/bilder.ts b/companion-app/src/screens/bilder.ts
index d53419f..db4dd60 100644
--- a/companion-app/src/screens/bilder.ts
+++ b/companion-app/src/screens/bilder.ts
@@ -229,3 +229,46 @@ export async function bildVerkleinern(datei: File): Promise {
}
return datei
}
+
+/**
+ * Alle Fahrzeugfotos einmal im Hintergrund holen und dekodieren.
+ *
+ * WARUM
+ * -----
+ * Gemeldet als "Bilder laden beim Umschalten der Menues einige ms verzoegert".
+ * Innerhalb einer Sitzung liegt das nicht am Netz - die Adresse ist stabil und
+ * Home Assistant liefert `/local/` mit `max-age` von 31 Tagen, der Abruf kommt
+ * also aus dem Zwischenspeicher. Verzoegert wird das BILD selbst: ein frisch
+ * erzeugtes wird standardmaessig asynchron dekodiert und erst danach
+ * gezeichnet, und beim Seitenwechsel entsteht jedes Mal ein neues.
+ *
+ * `decode()` erledigt genau diesen Schritt vorab. Danach zeichnet dasselbe
+ * Bild beim naechsten Einhaengen sofort.
+ *
+ * WANN
+ * ----
+ * In der Leerlaufzeit nach dem ersten Datenladen, nicht beim Start - sonst
+ * konkurriert es mit dem Abruf von Profil, Fahrten und Tankvorgaengen, den der
+ * Nutzer wirklich braucht. Das Datenvolumen ist dasselbe wie beim Durchblaettern
+ * aller Seiten, nur frueher und ohne Warten.
+ */
+export function bilderVorladen(): void {
+ if (typeof window === "undefined" || typeof Image === "undefined") return
+ const starten = () => {
+ const namen = [...BILDPLAETZE.map((b) => b.datei), SEITE_WINTER.datei]
+ for (const name of namen) {
+ const url = bildUrl(name)
+ if (!url) continue
+ const bild = new Image()
+ bild.src = url
+ // Ein fehlendes Foto ist der Normalfall (nicht jeder Platz ist belegt) -
+ // deshalb still schlucken statt zu melden.
+ void bild.decode?.().catch(() => undefined)
+ }
+ }
+ const leerlauf = (window as unknown as {
+ requestIdleCallback?: (r: () => void, o?: { timeout: number }) => number
+ }).requestIdleCallback
+ if (leerlauf) leerlauf(starten, { timeout: 4000 })
+ else window.setTimeout(starten, 2000)
+}
diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css
index 0b36b18..0ff78ff 100644
--- a/companion-app/src/stile/screens.css
+++ b/companion-app/src/stile/screens.css
@@ -1590,8 +1590,11 @@ button.dm-listenzeile {
background: none;
border-top: 0;
}
-.dm-speicherleiste--kachel > :first-child {
- flex: 1 1 auto;
+/* Beide Knoepfe gleich breit: ein Paar aus Bestaetigen und Abbrechen ist
+ eine Entscheidung zwischen zwei Wegen, keine Haupt- und Nebensache. Welcher
+ davon der gewollte ist, sagt die Fuellung, nicht die Groesse. */
+.dm-speicherleiste > * {
+ flex: 1 1 0;
}
.dm-szene__zusatz {
diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json
index 969d241..e61f11a 100644
--- a/custom_components/audi_dashboard/frontend/app/bundle.json
+++ b/custom_components/audi_dashboard/frontend/app/bundle.json
@@ -1 +1 @@
-{"version":"2026.9.4.8","sha256":"d6fe041f31480ff67d9b39884e9ef8b5ac5795d8575716d4638a9f557198bc49","bytes":319995,"gebaut":"2026-09-04T09:50:03Z"}
\ No newline at end of file
+{"version":"2026.9.4.11","sha256":"937302e85e87c62326c4f7013cb026f50243b8cc5d521d752b5a3708120d2b91","bytes":320679,"gebaut":"2026-09-04T10:13:31Z"}
\ No newline at end of file
diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip
index 3fad36b..5053e5a 100644
Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ
diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js
index 726f102..07cbe92 100644
--- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js
+++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js
@@ -503,6 +503,33 @@ function bildInfo(i) {
}
return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` };
}
+/* Alle Fahrzeugfotos einmal vorab holen UND dekodieren.
+
+ Gemeldet als "Bilder laden beim Umschalten der Menues verzoegert". Am Netz
+ liegt es nicht: die Adresse ist innerhalb einer Sitzung stabil und Home
+ Assistant liefert /local/ mit 31 Tagen Cache-Dauer. Verzoegert wird das Bild
+ selbst - ein frisch erzeugtes wird standardmaessig asynchron dekodiert
+ und erst danach gezeichnet, und render() erzeugt bei jedem Seitenwechsel ein
+ neues. decode() erledigt genau diesen Schritt vorab.
+
+ Laeuft in der Leerlaufzeit, damit es nicht mit dem ersten Datenabruf
+ konkurriert, und nur einmal je Bildstand. */
+let bilderVorgeladen = null;
+function bilderVorladen() {
+ if (bilderVorgeladen === bildVersion) return;
+ bilderVorgeladen = bildVersion;
+ const starten = () => {
+ for (const datei of [...BILDER.map((b) => b.datei), SEITE_WINTER.datei]) {
+ const img = new Image();
+ img.src = `/local/bilder/${datei}?v=${bildVersion}`;
+ // Ein nicht hochgeladenes Foto ist der Normalfall - still schlucken.
+ if (img.decode) img.decode().catch(() => {});
+ }
+ };
+ if (window.requestIdleCallback) window.requestIdleCallback(starten, { timeout: 4000 });
+ else setTimeout(starten, 2000);
+}
+
function carImg(modus) {
if (modus === "fest") {
const info = bildInfo(startIndex());
@@ -3242,28 +3269,6 @@ function vEinst() {
` : ""}
- ${ZUGAENGE.length ? `
Zugänge
- ${ZUGAENGE.map((z, i) => `
-
-
- ${esc(z.zweck)}
-
-
-
- ${z.gesetzt ? `` : ""}
-
`).join("")}
-
- Ein gespeicherter Token wird nie wieder angezeigt — nur ob einer hinterlegt ist.
- Dieselben Felder gibt es unter Einstellungen → Geräte & Dienste → Audi Dashboard →
- Konfigurieren.
-
` : ""}
${(() => {
/* Der Regler schreibt NICHT mehr beim Loslassen. Sein Wert stellt zugleich
den Schlaf-Timeout des Dongles - eine Änderung am Fahrzeug, die über zwei
@@ -3280,8 +3285,10 @@ function vEinst() {
aria-label="Minuten bis zum automatischen Fahrtende">
- ${geaendert ? `
- ` : ""}
+ ${geaendert ? `
+
+
+
` : ""}
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die
@@ -3291,36 +3298,6 @@ function vEinst() {
Fortsetzen aber ein paar Sekunden fürs Aufwachen.
`;
})()}
- ${DONGLE && DONGLE.aktiv ? `
` : ""}
-
-
- ${DONGLE.stand && DONGLE.stand.gelesen_am
- // Der Punkt der Abkuerzung ist zugleich der Satzpunkt - sonst stuende
- // dort "vor 1 Std..". "vor 3 Tage" hat keinen und bekommt einen.
- ? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)).replace(/\.$/, ""))}. `
- : "Noch nicht gelesen. "}Der Regler oben schreibt den Schlaf-Timeout sofort
- hierher; bis das Gerät sich das nächste Mal meldet, steht die Änderung als
- „unterwegs" daneben.
-
Debug-Mode
+
+ Zugangs-Token, die Einstellungen des Dongles und der letzte Datensatz, den
+ das Gerät gemeldet hat. Für die Fehlersuche, nicht für den Alltag.
+
+
+ ${debugOffen ? `
+ ${ZUGAENGE.length ? `
Zugänge
+ ${ZUGAENGE.map((z, i) => `
+
+
+ ${esc(z.zweck)}
+
+
+
+ ${z.gesetzt ? `` : ""}
+
`).join("")}
+
+ Ein gespeicherter Token wird nie wieder angezeigt — nur ob einer hinterlegt ist.
+ Dieselben Felder gibt es unter Einstellungen → Geräte & Dienste → Audi Dashboard →
+ Konfigurieren.
+
` : ""}
+
+
+ ${DONGLE.stand && DONGLE.stand.gelesen_am
+ // Der Punkt der Abkuerzung ist zugleich der Satzpunkt - sonst stuende
+ // dort "vor 1 Std..". "vor 3 Tage" hat keinen und bekommt einen.
+ ? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)).replace(/\.$/, ""))}. `
+ : "Noch nicht gelesen. "}Der Regler oben schreibt den Schlaf-Timeout sofort
+ hierher; bis das Gerät sich das nächste Mal meldet, steht die Änderung als
+ „unterwegs" daneben.
+
` : ""}
+ ${(() => {
+ /* Der letzte Datensatz, wie er in Home Assistant angekommen ist - siehe
+ geraet.py. Der Abstand zwischen Gerätezeit und Ankunft ist die Angabe,
+ die bei gepufferten Datensätzen zählt: genau dieser Rückstand war die
+ Wurzel des 3-km-Fahrt-Fehlers. */
+ const d = LETZTER_DATENSATZ;
+ if (!d) return `
Letzter Datensatz
+
+ Noch nichts gemeldet. Dafür braucht es die Meldezeit oder das
+ Fahrtsignal im Setup - und mindestens einen Datensatz des Geräts,
+ seit Home Assistant läuft.
+
+ ${de(d.gesamt)} Werte aus derselben Meldung. Was das Gerät in diesem
+ Datensatz nicht geschickt hat, steht hier nicht - es behält in Home
+ Assistant seinen alten Wert. Nach einem Neustart ist „Angekommen" der
+ Zeitpunkt des Neustarts, nicht der einer Übertragung — der Rückstand
+ sagt dann, wie lange das Gerät schon schweigt.
+
`;
+ })()}
+ ` : ""}`;
}
let nmOffen = false;
let einrichtenOffen = false;
+let debugOffen = false;
/* Entwurf des Reglers "Fahrt beenden" (Minuten) - null heisst: nicht angefasst.
Er liegt bewusst NEBEN CONFIG.pausenzeit und nicht darin: erst der Knopf
"Speichern" uebernimmt ihn, weil derselbe Wert den Schlaf-Timeout des
@@ -4377,6 +4457,9 @@ let ZUGAENGE = [];
/* Was am Dongle steht, was noch aussteht - siehe flespi.py im Backend.
null, solange kein flespi-Token hinterlegt ist. */
let DONGLE = null;
+/* Der letzte Datensatz, den das Geraet gemeldet hat - siehe geraet.py im
+ Backend. null, solange weder Meldezeit noch Fahrtsignal zugeordnet sind. */
+let LETZTER_DATENSATZ = null;
// true, waehrend "Jetzt lesen" laeuft. Wie INTEGRATION_UPDATE_LAEUFT und aus
// demselben Grund: der Abruf geht ueber zwei Netze (wir -> flespi -> Geraet)
// und darf nicht wie ein toter Knopf aussehen.
@@ -5977,6 +6060,7 @@ function ereignisseVerdrahten() {
}
if (e.target.closest("[data-nmauf]")) { nmOffen = !nmOffen; render(); return; }
if (e.target.closest("[data-einrichten-auf]")) { einrichtenOffen = !einrichtenOffen; render(); return; }
+ if (e.target.closest("[data-debug-auf]")) { debugOffen = !debugOffen; render(); return; }
if (e.target.closest("[data-nmspeichern]")) {
const wert = parseInt($("#nm").value, 10);
if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); }
@@ -6701,6 +6785,9 @@ async function datenLaden(erzwingen) {
DONGLE = versionState.attributes.daten
? versionState.attributes.daten.dongle ?? null
: null;
+ LETZTER_DATENSATZ = versionState.attributes.daten
+ ? versionState.attributes.daten.letzter_datensatz ?? null
+ : null;
}
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
@@ -6743,6 +6830,9 @@ async function datenLaden(erzwingen) {
nachladeVersuche = 0;
render();
+ // Erst nach dem ersten Zeichnen: die Fotos sollen den Aufbau der Seite
+ // nicht ausbremsen, sondern den naechsten Seitenwechsel beschleunigen.
+ bilderVorladen();
return;
}
}
diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css
index 2c7f627..fcff5d1 100644
--- a/custom_components/audi_dashboard/frontend/audi-dashboard.css
+++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css
@@ -1657,3 +1657,17 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
scroll-behavior: auto !important;
}
}
+
+/* Ein Paar aus Bestaetigen und Abbrechen steht nebeneinander, beide gleich
+ breit: es ist eine Entscheidung zwischen zwei Wegen, keine Haupt- und
+ Nebensache. Welcher der gewollte ist, sagt die Fuellung, nicht die Groesse -
+ und der bestaetigende steht rechts, wie ueberall in iOS. */
+.knopfpaar {
+ display: flex;
+ gap: 10px;
+ margin-top: var(--sp-4);
+}
+.knopfpaar .aktion {
+ flex: 1 1 0;
+ margin-top: 0;
+}
diff --git a/custom_components/audi_dashboard/geraet.py b/custom_components/audi_dashboard/geraet.py
new file mode 100644
index 0000000..2250072
--- /dev/null
+++ b/custom_components/audi_dashboard/geraet.py
@@ -0,0 +1,140 @@
+"""Der letzte Datensatz des Dongles, so wie er in Home Assistant angekommen ist.
+
+WOFUER
+------
+Fuer die Fehlersuche am Fahrzeug, nicht fuer den taeglichen Gebrauch: Steht das
+Fahrzeug wirklich, oder ist nur die Meldung alt? Liefert das Geraet gerade
+ueberhaupt CAN-Werte? Die Antwort steht in Home Assistants Zustaenden, aber
+verteilt auf siebzig Entitaeten - hier zusammengefasst an einer Stelle, die
+beide Oberflaechen ohne eigene Registerabfrage lesen koennen.
+
+WAS "EIN DATENSATZ" IST
+-----------------------
+Die flespi-Integration setzt die Entitaeten EINES Geraetedatensatzes
+nacheinander, aber innerhalb weniger Millisekunden (am 01.09.2026 gemessen:
+Zuendung 07:35:23.633, zugehoerige Meldezeit 07:35:23.635 - siehe Abschnitt AY
+der AGENTS.md). Der letzte Datensatz ist deshalb: alle Entitaeten des Geraets,
+deren letzte Aktualisierung hoechstens FENSTER_S nach der juengsten liegt.
+
+Das ist bewusst eine Naeherung ueber die ANKUNFTSzeit. Die Zeit des Geraets
+steht daneben (aus MELDEZEIT_SENSOR), und genau der Abstand zwischen beiden ist
+die Angabe, die bei gepufferten Datensaetzen interessiert - er war die Wurzel
+des 3-km-Fahrt-Fehlers (Abschnitt CB).
+
+WELCHES GERAET
+--------------
+Nicht geraten: genommen wird das Geraet der Entitaet, die MELDEZEIT_SENSOR
+zugeordnet ist, sonst das des Fahrtsignals. Ist keines von beiden zugeordnet,
+gibt es nichts zu zeigen - dann bleibt der Rueckgabewert None, statt
+irgendein Geraet der Instanz zu waehlen.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta, timezone
+from typing import TYPE_CHECKING, Any
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import entity_registry as er
+
+from . import verlauf
+
+if TYPE_CHECKING:
+ from .einstellungen import Sensorzuordnung
+
+_LOGGER = logging.getLogger(__name__)
+
+# Wie weit ein Wert hinter dem juengsten liegen darf, um noch zum selben
+# Datensatz gezaehlt zu werden. Gemessen wurden Millisekunden; zwei Sekunden
+# sind grosszuegig genug fuer eine langsame Instanz und immer noch weit von der
+# kuerzesten Sendeperiode des Geraets entfernt (10 s beim Fahren).
+FENSTER_S = 2.0
+
+# Ein Datensatz des FMM003 traegt je nach Konfiguration einige Dutzend Werte.
+# Die Obergrenze ist kein fachliches Limit, sondern verhindert, dass eine
+# fehlerhafte Zuordnung die veroeffentlichten Attribute aufblaeht.
+MAX_WERTE = 80
+
+
+def _meldezeit(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
+ """Die Zeit, die das Geraet dem letzten Datensatz gegeben hat."""
+ if not werte.MELDEZEIT_SENSOR:
+ return None
+ zustand = hass.states.get(werte.MELDEZEIT_SENSOR)
+ if zustand is None:
+ return None
+ try:
+ gemeldet = datetime.fromtimestamp(float(zustand.state), tz=timezone.utc)
+ except (TypeError, ValueError, OSError, OverflowError):
+ return None
+ # Dieselbe Pruefung wie in der Fahrterkennung: eine Sensor-ID statt eines
+ # Zeitstempels ergibt sonst einen Zeitpunkt kurz nach 1970 (Abschnitt BO).
+ if not verlauf.geraetezeit_plausibel(gemeldet, datetime.now(timezone.utc)):
+ return None
+ return gemeldet.isoformat()
+
+
+def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
+ register = er.async_get(hass)
+ for eid in (werte.MELDEZEIT_SENSOR, verlauf.fahrtsignal(werte), werte.ZUENDUNG_SENSOR):
+ if not eid:
+ continue
+ eintrag = register.async_get(eid)
+ if eintrag and eintrag.device_id:
+ return eintrag.device_id
+ return None
+
+
+def letzter_datensatz(hass: HomeAssistant, werte: Sensorzuordnung) -> dict[str, Any] | None:
+ """Die Werte der juengsten Meldung des Dongles - None, wenn keine da ist."""
+ geraet_id = _geraet_id(hass, werte)
+ if not geraet_id:
+ return None
+
+ register = er.async_get(hass)
+ eintraege = er.async_entries_for_device(register, geraet_id, include_disabled_entities=False)
+ zustaende = []
+ for eintrag in eintraege:
+ zustand = hass.states.get(eintrag.entity_id)
+ # unavailable/unknown tragen keinen Wert - sie stehen fuer eine
+ # Entitaet, die das Geraet in diesem Datensatz nicht gemeldet hat.
+ if zustand is None or zustand.state in ("unavailable", "unknown"):
+ continue
+ zustaende.append(zustand)
+ if not zustaende:
+ return None
+
+ juengste = max(z.last_updated for z in zustaende)
+ grenze = juengste - timedelta(seconds=FENSTER_S)
+ im_datensatz = sorted(
+ (z for z in zustaende if z.last_updated >= grenze),
+ key=lambda z: z.attributes.get("friendly_name") or z.entity_id,
+ )
+
+ geraete_register = dr.async_get(hass)
+ geraet = geraete_register.async_get(geraet_id)
+ name = (geraet.name if geraet else None) or geraet_id
+
+ def kurz(voll: str) -> str:
+ """Der Geraetename steht schon in der Kopfzeile - hier nur das Signal."""
+ rest = voll[len(name):].strip() if voll.startswith(name) else voll
+ return rest or voll
+
+ return {
+ "geraet": name,
+ "angekommen": juengste.isoformat(),
+ # Die Zeit, die das GERAET dem Datensatz gegeben hat. Der Abstand zur
+ # Ankunft ist der Puffer-Rueckstand.
+ "meldezeit": _meldezeit(hass, werte),
+ "werte": [
+ {
+ "name": kurz(z.attributes.get("friendly_name") or z.entity_id),
+ "wert": z.state,
+ "einheit": z.attributes.get("unit_of_measurement"),
+ }
+ for z in im_datensatz[:MAX_WERTE]
+ ],
+ "gesamt": len(im_datensatz),
+ }
diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py
index 1dc1a13..e7bc653 100644
--- a/custom_components/audi_dashboard/koordinator.py
+++ b/custom_components/audi_dashboard/koordinator.py
@@ -51,6 +51,7 @@ from . import (
batterie,
fahrterkennung,
flespi,
+ geraet,
identitaet,
reifen,
screening,
@@ -253,6 +254,12 @@ class Koordinator:
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
+ # Muss mitlaufen, seit dort der letzte Datensatz des Dongles steht:
+ # beim Start haben dessen Entitaeten noch gar keinen Zustand, die
+ # Angabe waere sonst dauerhaft leer. Kostet einen kleinen Dateilesevorgang
+ # (bundle.json) je Minute und KEINEN Netzaufruf - die Pruefung bei Gitea
+ # bleibt an ihrem Dienst, siehe Abschnitt J.
+ await self.app_version_veroeffentlichen()
async def _takt_batterie(self, _jetzt: datetime.datetime) -> None:
await batterie.pruefen(self)
@@ -636,6 +643,12 @@ class Koordinator:
"aktiv": flespi.zugang(self) is not None,
"stand": self.flespi_stand,
},
+ # Der letzte Datensatz des Dongles, wie er hier angekommen
+ # ist - fuer die Fehlersuche, siehe geraet.py. None, solange
+ # weder Meldezeit noch Fahrtsignal zugeordnet sind.
+ "letzter_datensatz": geraet.letzter_datensatz(
+ self.hass, self.zuordnung.werte
+ ),
},
)
diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json
index 452fb8e..3ce3554 100644
--- a/custom_components/audi_dashboard/manifest.json
+++ b/custom_components/audi_dashboard/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
- "version": "2026.9.4.8",
+ "version": "2026.9.4.11",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [