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"} - - } - > -

{z.zweck}

- - - setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value })) - } - /> - -
- void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")} - > - {tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"} - - {z.gesetzt && ( - { - if (bestaetigen("Token löschen?")) { - void tokenSchreiben(z.dienst, "") - } - }} - > - Löschen - - )} -
-
- ))} - {tokenFehler &&

{tokenFehler}

} - - 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 && (
+ setzePausenzeitEntwurf(null)} disabled={laeuft}> + Verwerfen + void speichern({ pausenzeitMin: pausenzeitEntwurf as number })} disabled={laeuft} > {laeuft ? "Speichere …" : "Speichern"} -
)} {/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden @@ -513,44 +455,6 @@ export function Einstellungen({
- {dongle?.aktiv && ( - - Dongle - {dongle.stand?.fehler &&

{dongle.stand.fehler}

} - {(dongle.stand?.werte ?? []).length > 0 && ( - ( - - ))} - /> - )} - void dongleAbrufen()}> - {dongleLaeuft ? "Liest …" : "Jetzt lesen"} - - {dongleFehler &&

{dongleFehler}

} - - {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"} + + } + > +

{z.zweck}

+ + + setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value })) + } + /> + +
+ void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")} + > + {tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"} + + {z.gesetzt && ( + { + if (bestaetigen("Token löschen?")) { + void tokenSchreiben(z.dienst, "") + } + }} + > + Löschen + + )} +
+
+ ))} + {tokenFehler &&

{tokenFehler}

} + + 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.fehler}

} + {(dongle.stand?.werte ?? []).length > 0 && ( + ( + + ))} + /> + )} + void dongleAbrufen()}> + {dongleLaeuft ? "Liest …" : "Jetzt lesen"} + + {dongleFehler &&

{dongleFehler}

} + + {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 &&

Gespeichert.

} {entwurf && (
- void speichern()} disabled={laeuft}> - {laeuft ? "Speichere …" : "Änderungen speichern"} - - + + void speichern()} disabled={laeuft}> + {laeuft ? "Speichere …" : "Speichern"} +
)} ) } + +/* 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. +

+
+ ) + } + const rueckstand = + datensatz.meldezeit && datensatz.angekommen + ? Math.round( + (Date.parse(datensatz.angekommen) - Date.parse(datensatz.meldezeit)) / 1000, + ) + : null + return ( + + Letzter Datensatz + + + + + {datensatz.werte.map((w) => ( + + ))} + + } + /> +

+ {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"> ${minuten} Min. - ${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 - ${DONGLE.stand && DONGLE.stand.fehler - ? `${esc(DONGLE.stand.fehler)}` - : ""} - ${DONGLE.stand && (DONGLE.stand.werte || []).length ? `
- ${DONGLE.stand.werte.map((w) => `
-
${esc(w.titel)} ${esc(w.nummer)}
- ${ - w.geraet === null || w.geraet === undefined ? "–" : esc(String(w.geraet)) - }${w.einheit ? " " + esc(w.einheit) : ""}${ - w.offen !== null && w.offen !== undefined - ? ` → ${esc(String(w.offen))}, unterwegs` - : w.abweichung ? ` (wir: ${esc(String(w.unser))})` : "" - } -
`).join("")} -
` : ""} - - - ${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. -
` : ""}
Darstellung
@@ -3404,12 +3381,14 @@ function vEinst() {
+
Version
Installiert
${esc(APP_VERSION || "unbekannt")}Home-Assistant-Integration
-
-
Integration-Update ${(() => { const u = INTEGRATION_UPDATE; const laeuft = INTEGRATION_UPDATE_LAEUFT; @@ -3460,11 +3439,112 @@ function vEinst() { ${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler && !INTEGRATION_UPDATE_LAEUFT ? `${esc(INTEGRATION_UPDATE.fehler)}` : ""} -
`; +
+ +
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 && DONGLE.aktiv ? `
Dongle + ${DONGLE.stand && DONGLE.stand.fehler + ? `${esc(DONGLE.stand.fehler)}` + : ""} + ${DONGLE.stand && (DONGLE.stand.werte || []).length ? `
+ ${DONGLE.stand.werte.map((w) => `
+
${esc(w.titel)} ${esc(w.nummer)}
+ ${ + w.geraet === null || w.geraet === undefined ? "–" : esc(String(w.geraet)) + }${w.einheit ? " " + esc(w.einheit) : ""}${ + w.offen !== null && w.offen !== undefined + ? ` → ${esc(String(w.offen))}, unterwegs` + : w.abweichung ? ` (wir: ${esc(String(w.unser))})` : "" + } +
`).join("")} +
` : ""} + + + ${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.
`; + const rueckstand = d.meldezeit && d.angekommen + ? Math.round((Date.parse(d.angekommen) - Date.parse(d.meldezeit)) / 1000) + : null; + return `
Letzter Datensatz +
+
Gerät
${esc(d.geraet || "unbekannt")}
+
Angekommen
${dezeitpunkt(new Date(d.angekommen))}
+
Gerätezeit
${d.meldezeit ? dezeitpunkt(new Date(d.meldezeit)) : "–"}${ + rueckstand !== null ? `Rückstand ${de(rueckstand)} s` : "" + }
+ ${d.werte.map((w) => `
${esc(w.name)}
${esc(String(w.wert))}${ + w.einheit ? " " + esc(w.einheit) : "" + }
`).join("")} +
+ + ${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": [