Debug-Mode, eine Version-Kachel, Knoepfe nach HIG, Bilder ohne Verzoegerung (2026.9.4.11)

Debug-Mode: Zugaenge und Dongle stehen nicht mehr verstreut oben in den
Einstellungen, sondern zusammen hinter einem Schalter am Seitenende - dazu
neu der letzte Datensatz des Dongles (geraet.py). "Letzter Datensatz" heisst
dabei: alle Entitaeten des Geraets, deren Aktualisierung hoechstens zwei
Sekunden nach der juengsten liegt - die flespi-Integration setzt einen
Datensatz in Millisekunden. Angezeigt mit Ankunft, Geraetezeit und dem
Rueckstand zwischen beiden.

Beim Verifizieren gefunden: app_version_veroeffentlichen() lief nur beim
Start und nach einer Aktion, und beim Start haben die Entitaeten des Geraets
noch keinen Zustand - die Kachel waere dauerhaft leer geblieben. Laeuft
jetzt im Minutentakt mit, ohne Netzaufruf.

Ausserdem:
- "Version" und "Integration-Update" sind eine Kachel
- Speichern/Verwerfen als zwei gleich breite Knoepfe, der bestaetigende
  gefuellt und rechts; "Verwerfen" war in der App blosser Text
- Bilder werden nach dem ersten Zeichnen in der Leerlaufzeit vorgeladen UND
  dekodiert. Die Verzoegerung beim Seitenwechsel kam nicht vom Netz (31 Tage
  Cache), sondern vom asynchronen Dekodieren eines frisch erzeugten <img>.

214/214 Tests, Panel als Modul geparst, live gegen audi_ha_test geprueft.
This commit is contained in:
2026-09-04 12:15:33 +02:00
parent 63a45d4ee2
commit 4ab6b9fca5
14 changed files with 695 additions and 182 deletions
+9
View File
@@ -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
+19
View File
@@ -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 */
+5
View File
@@ -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<Buendelangabe | null>(null)
const [zugaenge, setzeZugaenge] = useState<ZugangAngabe[]>([])
const [dongle, setzeDongle] = useState<DongleAngabe | null>(null)
const [letzterDatensatz, setzeLetzterDatensatz] = useState<LetzterDatensatz | null>(null)
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
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,
+198 -119
View File
@@ -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<string | null>(null)
@@ -409,66 +411,6 @@ export function Einstellungen({
</ActionButton>
</Tile>
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen("Token löschen?")) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Fahrt beenden</span>
<Slider
@@ -482,24 +424,24 @@ export function Einstellungen({
/>
{/* 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 && (
<div className="dm-speicherleiste dm-speicherleiste--kachel">
<ActionButton onClick={() => setzePausenzeitEntwurf(null)} disabled={laeuft}>
Verwerfen
</ActionButton>
<ActionButton
variant="primary"
onClick={() => void speichern({ pausenzeitMin: pausenzeitEntwurf as number })}
disabled={laeuft}
>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
<button
type="button"
className="dm-textknopf"
onClick={() => setzePausenzeitEntwurf(null)}
disabled={laeuft}
>
Verwerfen
</button>
</div>
)}
{/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden
@@ -513,44 +455,6 @@ export function Einstellungen({
</span>
</Tile>
{dongle?.aktiv && (
<Tile>
<span className="ads-eyebrow">Dongle</span>
{dongle.stand?.fehler && <p className="dm-fehler">{dongle.stand.fehler}</p>}
{(dongle.stand?.werte ?? []).length > 0 && (
<Werteliste
kinder={(dongle.stand?.werte ?? []).map((w) => (
<Wertzeile
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
warnung={Boolean(w.abweichung)}
zusatz={
w.offen != null
? `${w.offen}, unterwegs`
: w.abweichung
? `(wir: ${w.unser})`
: undefined
}
/>
))}
/>
)}
<ActionButton disabled={dongleLaeuft} onClick={() => void dongleAbrufen()}>
{dongleLaeuft ? "Liest …" : "Jetzt lesen"}
</ActionButton>
{dongleFehler && <p className="dm-fehler">{dongleFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{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.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Darstellung</span>
@@ -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). */}
<Tile>
<span className="ads-eyebrow">Version</span>
<Werteliste
@@ -852,10 +760,6 @@ export function Einstellungen({
/>
}
/>
</Tile>
<Tile>
<span className="ads-eyebrow">Integration-Update</span>
{/* 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({
</ActionButton>
</Tile>
{/* 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). */}
<Tile>
<span className="ads-eyebrow">Debug-Mode</span>
<p className="dm-erklaerung">
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.
</p>
<ActionButton
variant={debugOffen ? "primary" : "default"}
onClick={() => setzeDebugOffen(!debugOffen)}
>
{debugOffen ? "Fertig" : "Öffnen"}
</ActionButton>
</Tile>
{debugOffen && (
<>
{zugaenge.length > 0 && (
<Tile>
<span className="ads-eyebrow">Zugänge</span>
{/* Aufklappbar, weil die Token im Alltag nichts zu suchen haben —
man fasst sie an, wenn einer abgelaufen ist, und sonst nie. */}
{zugaenge.map((z) => (
<Accordion
key={z.dienst}
title={z.titel}
summary={
<span className={z.gesetzt ? "dm-token-gesetzt" : "dm-token-offen"}>
{z.gesetzt ? (z.rest ? `gesetzt · …${z.rest}` : "gesetzt") : "nicht gesetzt"}
</span>
}
>
<p className="dm-erklaerung">{z.zweck}</p>
<Feld label="Neuer Token" last>
<input
className="dm-eingabe"
type="password"
autoComplete="off"
placeholder={z.gesetzt ? "ersetzt den hinterlegten" : "noch keiner hinterlegt"}
value={tokenEntwurf[z.dienst] ?? ""}
onChange={(e) =>
setzeTokenEntwurf((alt) => ({ ...alt, [z.dienst]: e.target.value }))
}
/>
</Feld>
<div className="dm-knopfreihe">
<ActionButton
variant="primary"
disabled={tokenLaeuft !== null || !(tokenEntwurf[z.dienst] ?? "").trim()}
onClick={() => void tokenSchreiben(z.dienst, tokenEntwurf[z.dienst] ?? "")}
>
{tokenLaeuft === z.dienst ? "Speichert …" : "Speichern"}
</ActionButton>
{z.gesetzt && (
<ActionButton
disabled={tokenLaeuft !== null}
onClick={() => {
if (bestaetigen("Token löschen?")) {
void tokenSchreiben(z.dienst, "")
}
}}
>
Löschen
</ActionButton>
)}
</div>
</Accordion>
))}
{tokenFehler && <p className="dm-fehler">{tokenFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Ein gespeicherter Token wird nie wieder angezeigt nur ob einer hinterlegt ist.
Dieselben Felder gibt es unter Einstellungen Geräte &amp;
Dienste Audi Dashboard Konfigurieren.
</span>
</Tile>
)}
{dongle?.aktiv && (
<Tile>
<span className="ads-eyebrow">Dongle</span>
{dongle.stand?.fehler && <p className="dm-fehler">{dongle.stand.fehler}</p>}
{(dongle.stand?.werte ?? []).length > 0 && (
<Werteliste
kinder={(dongle.stand?.werte ?? []).map((w) => (
<Wertzeile
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
warnung={Boolean(w.abweichung)}
zusatz={
w.offen != null
? `${w.offen}, unterwegs`
: w.abweichung
? `(wir: ${w.unser})`
: undefined
}
/>
))}
/>
)}
<ActionButton disabled={dongleLaeuft} onClick={() => void dongleAbrufen()}>
{dongleLaeuft ? "Liest …" : "Jetzt lesen"}
</ActionButton>
{dongleFehler && <p className="dm-fehler">{dongleFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{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.
</span>
</Tile>
)}
<LetzterDatensatzKachel datensatz={letzterDatensatz} />
</>
)}
{gespeichert && <p className="dm-erfolg">Gespeichert.</p>}
{entwurf && (
<div className="dm-speicherleiste">
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Änderungen speichern"}
</ActionButton>
<button type="button" className="dm-textknopf" onClick={() => setzeEntwurf(null)}>
<ActionButton onClick={() => setzeEntwurf(null)} disabled={laeuft}>
Verwerfen
</button>
</ActionButton>
<ActionButton variant="primary" onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere " : "Speichern"}
</ActionButton>
</div>
)}
</>
)
}
/* 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 (
<Tile>
<span className="ads-eyebrow">Letzter Datensatz</span>
<p className="dm-erklaerung">
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.
</p>
</Tile>
)
}
const rueckstand =
datensatz.meldezeit && datensatz.angekommen
? Math.round(
(Date.parse(datensatz.angekommen) - Date.parse(datensatz.meldezeit)) / 1000,
)
: null
return (
<Tile>
<span className="ads-eyebrow">Letzter Datensatz</span>
<Werteliste
kinder={
<>
<Wertzeile label="Gerät" wert={datensatz.geraet} />
<Wertzeile label="Angekommen" wert={datumZeit(datensatz.angekommen)} />
<Wertzeile
label="Gerätezeit"
wert={datensatz.meldezeit ? datumZeit(datensatz.meldezeit) : ""}
zusatz={rueckstand !== null ? `Rückstand ${de(rueckstand)} s` : undefined}
/>
{datensatz.werte.map((w) => (
<Wertzeile
key={w.name}
label={w.name}
wert={w.einheit ? `${w.wert} ${w.einheit}` : w.wert}
/>
))}
</>
}
/>
<p className="dm-erklaerung">
{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.
</p>
</Tile>
)
}
+43
View File
@@ -229,3 +229,46 @@ export async function bildVerkleinern(datei: File): Promise<Blob> {
}
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 <img> 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)
}
+5 -2
View File
@@ -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 {