Neustart-Festhaenger, OTA-Pruefsumme, Statistik-Kopfzahl (2026.8.31.4)

Die App blieb nach einem Neustart von Home Assistant auf dem alten Stand
stehen, mit einer 502-Fehlerzeile, die erst ein Beenden der App wegbekam.
Zwei Ursachen: 502/503/504 galten als harter Ladefehler statt als "Server
faehrt gerade hoch", und nach dem Wiederverbinden wurde genau einmal
nachgeladen - schlug das fehl, kam nie wieder ein Versuch. Beides behoben,
vier Regressionstests dazu.

OTA: das Buendel ist von HEAD ueber die Platte bis zu den ausgelieferten
Bytes nachweislich deckungsgleich. Die Fassung haengt jetzt trotzdem als
?v= an der Adresse (der statische Pfad kommt ohne Cache-Control, aber mit
ETag), und ein Pruefsummenfehler wird erklaert statt durchgereicht.

Statistik: der Gesamtwert steht jetzt als grosse Zahl in der Kopfzeile,
darunter bleiben vier Spalten - fuenf passen auf dem Telefon nachweislich
nicht in eine Zeile.

Fahrzeugfoto ohne Beschnitt und ohne Bodenschatten, damit auch ohne den
Schalter dafuer. Setup filtert zusaetzlich nach dem Signalnamen aus dem
Katalog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:35:56 +02:00
parent 4af38d7e65
commit 90efcc5b00
19 changed files with 565 additions and 161 deletions
+140 -1
View File
@@ -1,6 +1,8 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-08-30** (owner findings batch - vehicle pin, tile chevrons, select arrows,
**Last updated: 2026-08-31** (Neustart-Festhänger der App, OTA-Prüfsumme, Statistik-Kopfzahl,
Setup-Filter und die gemessene Wahrheit über die Dongle-Konfiguration; Manifest `2026.8.31.4`,
siehe Abschnitte AT und AU. Davor: 2026-08-30 (owner findings batch - vehicle pin, tile chevrons, select arrows,
SmartDeal dialog, address cache and pull-to-refresh; manifest `2026.8.30.19`, see sections AO, AP and AQ.
Before that: design-audit follow-through - the five priority items applied to
both codebases, plus the location preview's wrong pin; manifest `2026.8.30.8`, see section AN.
@@ -6050,6 +6052,143 @@ rendering bug in a view.
---
## AT. Neustart-Festhänger, OTA-Prüfsumme, Statistik-Kopfzahl, Setup-Filter (2026.8.31.4)
### Die App blieb nach einem Neustart von Home Assistant hängen
Gemeldet: während Update und Neustart zeigte die App „502 Error" unter einem längst
gedrückten „Jetzt neu starten", und die Meldung blieb stehen, nachdem Home Assistant
wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide in
`DatenKontext.tsx`:
1. **502 galt als harter Ladefehler.** `ApiFehler.istNetzproblem` ist nur `status === null`;
ein Vorschaltserver, der antwortet, während Home Assistant noch hochfährt, liefert
aber 502/503/504. Neu: `istVoruebergehend` fasst beides zusammen (500 bewusst **nicht**
das bessert sich durch Warten nicht). Solange schon Daten da sind, erscheint keine
Fehlerzeile mehr.
2. **Nach dem Wiederverbinden wurde genau einmal nachgeladen.** Die Websocket-Verbindung
steht regelmäßig ein paar Sekunden vor der REST-Schnittstelle. Schlug dieser eine
Versuch fehl, kam nie wieder ein `"verbunden"` — und damit nie wieder ein Versuch; die
App blieb bis zum Beenden auf dem Stand von vor dem Neustart, mitsamt Fehlerzeile.
Neu: `nachladenBisEsKlappt()` mit wachsenden Abständen (2/4/8/15/30/60 s, zusammen gut
zwei Minuten), sauber abgeräumt beim Verlassen.
Dazu ein Fehler, den dieselbe Stelle verdeckt hat: `neuLaden` hing an `bereit`, der Effekt
darunter aber nur an `api` — die dort festgehaltene Fassung sah `bereit` für immer als
`false`. `bereit` liegt jetzt in einem Ref, `neuLaden` ist stabil. Vier Regressionstests
(`DatenKontext.test.tsx`), gegen den alten Stand als fehlschlagend nachgewiesen.
Panel-seitig kein Gegenstück nötig: es hängt an Home Assistants eigener Verbindung, deren
Wiederanlauf schon in Abschnitt N behandelt ist.
### „App-Update: failed to download / checksum failed"
**Das Bündel ist nachweislich in Ordnung.** Geprüft von Ende zu Ende: der Blob bei
`HEAD`, die Datei auf der Platte und die tatsächlich über HTTP ausgelieferten Bytes
haben alle denselben SHA-256, den auch `bundle.json` nennt; git behandelt die Zip als
binär (`i/-text`), `core.autocrlf` kann ihr nichts anhaben. Die Prüfsumme, die das
Plugin vergleicht, ist genau dieser Wert (`CapgoUpdater.swift`, `calcChecksum` über die
heruntergeladene Zip vor dem Entpacken).
Der Fehler fiel zusammen mit dem Neustart — ein Download, der mitten hineinläuft, liefert
je nach Zeitpunkt einen Abbruch („failed to download") oder unvollständige Bytes
(„checksum"). Zwei Änderungen in `ota.ts`:
- Die Fassung hängt jetzt als `?v=` an der Bündel-Adresse. Home Assistant liefert
`/audi_dashboard_static/` **ohne** `Cache-Control`, aber mit `ETag` und
`Last-Modified` (nachgemessen) — genau der Fall, in dem jeder Zwischenspeicher nach
eigenem Ermessen eine Weile die alte Datei behalten darf. Dieselbe Vorsorge trifft das
Panel seit jeher für sein eigenes Bündel.
- Ein Prüfsummenfehler wird nicht mehr wortwörtlich durchgereicht, sondern erklärt: „Das
Update kam unvollständig an … sobald Home Assistant wieder läuft, noch einmal
versuchen."
### Statistik: der Gesamtwert steht in der Kopfzeile
Erst sollte „Absolut" eine fünfte Spalte links werden. Am laufenden Panel gemessen passt
das nicht: die Kachel ist auf dem Telefon innen **307px** breit, ein sechsstelliger
Kilometerstand wie `209.177` misst in Audi Type Wide 300 **67px** bei 16px und 54px bei
13px — fünf Spalten gingen nur mit kleinerer Schrift. Der Eigentümer hat daraufhin
entschieden, den Gesamtwert dorthin zu setzen, wo man ihn zuerst liest: **die große Zahl
oben in „Distanz und Fahrzeit" und „Getankte Menge und Kosten" ist jetzt der Gesamtwert,
nicht mehr der des laufenden Jahres** — die Jahreszahl im Titel entfällt entsprechend.
Darunter bleiben vier Spalten (Jahr/Monat/Woche/Tag), jetzt als `repeat(4, minmax(0,1fr))`
statt `auto-fit`, das auf dem Telefon nur drei Spalten zuließ und die vierte umbrach.
`ZEITRAEUME` rechnet weiter alle fünf, angezeigt wird `ZEITRAUM_SPALTEN`.
### Kein Schatten, kein Beschnitt — und kein Schalter mehr dafür
Gemessen statt geschätzt: das Foto ist 2000×1125 (16:9), der Kasten stand auf 16:8 und
`object-fit: cover` schnitt oben und unten je ein Viertel ab — das Dach lag darin. Statt
das Verhältnis auf 16:9 festzuschreiben (was beim nächsten Foto in einem anderen Format
wieder schneiden würde) bestimmt jetzt das Bild selbst die Höhe; der Platzhalter behält
sein festes Verhältnis, er hat keines von sich aus. Nachgemessen: Kasten 367×207 zu
2000×1125 — kein Beschnitt.
Gleichzeitig sind der Ausblend-Verlauf und der Bodenschatten entfallen (beide Codebasen,
inklusive der `height:220px`-Regel im iOS-Overlay, die den Beschnitt sonst auf breiten
Schirmen wieder erzwungen hätte). Damit hatte der Schalter **„Fahrbahn unter dem
Fahrzeug"** nichts mehr zu schalten und ist ebenfalls weg — samt `bodenAn`/`szeneKlasse()`
im Panel und `useBoden`/`bodenKlasse`/`OHNE_SZENE` in der App. Der Regressionstest für
den geteilten Einstellungsspeicher (`theme.test.tsx`) prüft das jetzt an
`useTabBeschriftung` statt an `useBoden` — dieselbe Aussage, ein noch vorhandener Schalter.
### Setup: „Nur passende Sensoren" sucht nach dem Signalnamen
Domain und Einheit lassen bei einem Fahrzeug mit siebzig Sensoren immer noch Dutzende
übrig. Der Name, der neben der Überschrift als „(z. B. …)" steht, ist der tatsächliche
Signalname der Integration (`beispiel` in `FELDER`) und trifft genau einen. Er filtert
jetzt mit — **mit Rückfall**: findet er nichts, bleibt die ungefilterte Liste stehen, denn
er stammt aus dieser einen Installation. Zusätzlich zählt er in `entitaetScore()` mit 8
Punkten, damit auch der Vorschlag für ein leeres Feld davon profitiert.
## AU. Der Dongle: was die Konfiguration wirklich bewirkt (2026-08-31)
Nachgemessen an der echten Aufzeichnung im Testcontainer (7 bzw. 30 Tage Recorder), nicht
aus der Konfigurationsanzeige abgelesen.
**Was gut funktioniert.** Beim Fahren liefert das Gerät alle **10 s** (p90) einen Satz —
das ist die eingestellte `Min Period` für „Moving", und sie gilt für Geschwindigkeit wie
für Position gleichermaßen (771 bzw. 1165 Punkte in 7 Tagen). Für Strecke, Route,
Durchschnitt und vmax reicht das: in denselben 7 Tagen wurde ein Höchstwert von 118 km/h
erfasst. `can_fuel_volume` löst **0,1 l** auf (Werte wie 18,9 / 19,1 / 19,5 über 30 Tage;
in einzelnen Wochen sieht man nur ganze Liter, das ist die Meldung des Fahrzeugs, nicht
die Auflösung des Sensors) — die Grenze `MIN_STRECKE_VERBRAUCH_KM = 3.0` bleibt damit
richtig hergeleitet. Der Füllstand in Prozent springt in 1-%-Schritten.
**Was nicht funktioniert: das rohe Zündsignal als Fahrterkennung.**
`engine_ignition_or_acc_status` ist der ungefilterte Zündungseingang. In der Aufzeichnung:
am 29.08. drei Wechsel innerhalb von **30 Millisekunden**, danach ein Aus/Ein-Paar mit
**10 Sekunden** Abstand — und am 30./31.08. dreizehn Stunden am Stück „an", ohne Fahrt.
Solange die App eine Pausentoleranz hatte, hat die das aufgefangen. Seit deren Entfernung
(Abschnitt AS) trennt jeder solche Aussetzer eine Fahrt in zwei.
**Die Begründung für das Entfernen war falsch.** Der `Ignition OFF Timeout` von 900 s
unter *Trip \ Odometer* verzögert **nicht** dieses Signal, sondern ausschließlich das
geräteeigene Trip-Element ([Teltonika-Wiki](https://wiki.teltonika-gps.com/view/FMM003_Trip/Odometer_settings):
„timeout value to detect Trip end once the Ignition … is off"). Es gab also nie zwei
Zeitgeber hintereinander — es gab unseren, und daneben einen ungenutzten des Geräts.
**Die Empfehlung, noch nicht umgesetzt (Entscheidung des Eigentümers):**
`ZUENDUNG_SENSOR` auf
`binary_sensor.…_trip_status_true_if_trip_started_false_if_stopped` legen. Dieses Signal
- beginnt erst, wenn Zündung **und** Bewegung **und** `Start Speed` zusammenkommen —
dreizehn Stunden Standlicht-Zündung erzeugen damit keine Fahrt,
- endet erst 900 s nach Zündung aus — die Pausentoleranz kommt vom Gerät, ohne dass wir
einen zweiten Zeitgeber bauen,
- ist millisekundenfestes Prellen los.
Der Preis: das Fahrtende liegt 15 Minuten hinter dem tatsächlichen. Das war mit der
eigenen Pausentoleranz genauso und ist damit kein Rückschritt; wer es genauer will, müsste
das Ende nachträglich auf den letzten Punkt mit Geschwindigkeit > 0 zurückrechnen — eigenes
Arbeitspaket, hier bewusst nicht angefangen.
**Weiter offen, unabhängig davon:** echte Funklöcher. Im gemessenen Zeitraum gab es
mitten in der Fahrt eine Lücke von **2739 s** ohne einen einzigen Satz. Dagegen hilft keine
Einstellung am Gerät; das ist der in Abschnitt P/W beschriebene Fall, den die
Plausibilitätsgrenze (`UNPLAUSIBLE_KMH`) abfängt.
## Working conventions (observed — keep them)
- German is the project language: identifiers, comments, commits, UI texts. Exceptions:
+14
View File
@@ -36,6 +36,20 @@ export class ApiFehler extends Error {
get istNetzproblem(): boolean {
return this.status === null;
}
/** Vorübergehend nicht erreichbar: kein Netz, oder ein Server, der
gerade hochfährt.
502/503/504 sind genau das, was ein Neustart von Home Assistant
liefert - der Vorschaltserver antwortet schon, Home Assistant selbst
noch nicht. Das ist für die Oberfläche dasselbe wie kein Netz: warten
und den zwischengespeicherten Stand zeigen, nicht eine Fehlerzeile
stehen lassen, die niemand mehr wegbekommt. 500 gehört bewusst NICHT
dazu - das ist ein echter Fehler in der Anfrage, der sich durch
Warten nicht bessert. */
get istVoruebergehend(): boolean {
return this.istNetzproblem || this.status === 502 || this.status === 503 || this.status === 504;
}
}
export interface AnfrageOptionen {
@@ -0,0 +1,134 @@
/**
* Was passiert, wenn Home Assistant neu startet.
*
* Gemeldet vom Eigentümer (2026-08-31): während eines Neustarts zeigte die App
* „502 Error“, und die Meldung blieb stehen, nachdem Home Assistant längst
* wieder lief — erst ein Beenden der App half. Zwei Ursachen, beide hier
* abgesichert:
*
* 1. 502 galt als harter Ladefehler statt als „Server kommt gerade hoch“.
* 2. Nach dem Wiederverbinden wurde genau einmal nachgeladen. Schlug das
* fehl (die Websocket-Verbindung steht regelmäßig ein paar Sekunden vor
* der REST-Schnittstelle), kam nie wieder ein „verbunden“ — und damit nie
* wieder ein Versuch.
*/
import { render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { ApiFehler, type DataMetricApi } from "../api"
import { DatenAnbieter, useDaten } from "./DatenKontext"
import { beispielApi } from "../tests/beispieldaten"
function Anzeige() {
const { bereit, ladefehler } = useDaten()
return (
<div>
<span data-testid="bereit">{bereit ? "ja" : "nein"}</span>
<span data-testid="fehler">{ladefehler ?? "-"}</span>
</div>
)
}
/** Eine API, deren `profilLesen` sich auf Kommando wie ein gerade startender
Home Assistant verhält: die nächsten n Aufrufe mit 502 abweisen, danach
wieder normal antworten.
Die Fehlschläge werden bewusst gezählt und nicht an der Aufrufnummer
festgemacht: der Horcher meldet beim Abonnieren sofort "verbunden", die
Zählung liefe sonst schon beim Aufbau los. */
function apiMitNeustart() {
const echt = beispielApi()
let verbindungHorcher: ((z: string) => void) | null = null
let offeneFehlschlaege = 0
const profilLesen = vi.fn(async () => {
if (offeneFehlschlaege > 0) {
offeneFehlschlaege -= 1
throw new ApiFehler("Bad Gateway", 502)
}
return echt.profilLesen()
})
const api = {
...echt,
profilLesen,
live: {
...echt.live,
aufVerbindung: (h: (z: string) => void) => {
verbindungHorcher = h
h("verbunden")
return () => {}
},
},
} as unknown as DataMetricApi
return {
api,
profilLesen,
/** Home Assistant startet neu: die nächsten n Ladeversuche laufen ins
502, dann meldet sich die Websocket-Verbindung zurück. */
neustartMelden: (fehlschlaege: number) => {
offeneFehlschlaege = fehlschlaege
verbindungHorcher?.("verbunden")
},
}
}
describe("Neustart von Home Assistant", () => {
it("meldet 502 nicht als Ladefehler, solange Daten da sind", async () => {
const { api, neustartMelden } = apiMitNeustart()
render(
<DatenAnbieter api={api}>
<Anzeige />
</DatenAnbieter>,
)
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
neustartMelden(1)
// Der fehlgeschlagene Versuch darf keine Fehlerzeile hinterlassen.
await waitFor(() => expect(screen.getByTestId("fehler").textContent).toBe("-"))
expect(screen.getByTestId("bereit").textContent).toBe("ja")
})
it("versucht es nach einem fehlgeschlagenen Nachladen erneut", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
const { api, profilLesen, neustartMelden } = apiMitNeustart()
render(
<DatenAnbieter api={api}>
<Anzeige />
</DatenAnbieter>,
)
await waitFor(() => expect(screen.getByTestId("bereit").textContent).toBe("ja"))
const vorher = profilLesen.mock.calls.length
neustartMelden(2)
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 1))
// Ohne Wiederholung bliebe es bei diesem einen Versuch — und die App
// auf dem Stand von vor dem Neustart, bis jemand sie beendet.
await vi.advanceTimersByTimeAsync(3000)
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 2))
await vi.advanceTimersByTimeAsync(5000)
await waitFor(() => expect(profilLesen.mock.calls.length).toBe(vorher + 3))
expect(screen.getByTestId("fehler").textContent).toBe("-")
} finally {
vi.useRealTimers()
}
})
})
describe("ApiFehler.istVoruebergehend", () => {
it("gilt für kein Netz und für einen startenden Server", () => {
expect(new ApiFehler("offline", null).istVoruebergehend).toBe(true)
expect(new ApiFehler("bad gateway", 502).istVoruebergehend).toBe(true)
expect(new ApiFehler("unavailable", 503).istVoruebergehend).toBe(true)
expect(new ApiFehler("timeout", 504).istVoruebergehend).toBe(true)
})
it("gilt nicht für einen echten Serverfehler", () => {
// 500 bessert sich durch Warten nicht — das muss sichtbar bleiben.
expect(new ApiFehler("kaputt", 500).istVoruebergehend).toBe(false)
expect(new ApiFehler("nicht da", 404).istVoruebergehend).toBe(false)
})
})
+61 -8
View File
@@ -36,6 +36,14 @@ import { eigeneVersion, versionVergleichen } from "./appVersion"
import type { Einstellungen, Fahrzeug } from "./profilAdapter"
import { profilZuEinstellungen, profilZuFahrzeug, zusammenfuehren } from "./profilAdapter"
/** Abstaende zwischen zwei Ladeversuchen nach einem Wiederverbinden.
Wachsend, in Millisekunden, zusammen gut zwei Minuten. Der erste
Abstand ist kurz, weil Home Assistant nach einem Neustart meist binnen
Sekunden antwortet; die spaeteren sind lang, damit ein wirklich
abgestuerzter Server nicht dauernd angefragt wird. */
const WIEDERHOLABSTAENDE_MS = [2000, 4000, 8000, 15000, 30000, 60000]
export interface DatenWert {
bereit: boolean
/** Erste Ladung fehlgeschlagen und noch nie Daten gesehen. */
@@ -69,7 +77,10 @@ export interface DatenWert {
rohprofil: Profil | null
api: DataMetricApi
neuLaden: () => Promise<void>
/** Laedt alles neu; `true`, wenn es geklappt hat. Aufrufer duerfen den
Rueckgabewert ignorieren - er ist fuer die Wiederholung nach einem
Wiederverbinden da. */
neuLaden: () => Promise<boolean>
jetztAktualisieren: () => Promise<void>
/** Speichert geänderte Einstellungen/Fahrzeugdaten als ganzes Profil. */
profilSpeichern: (aenderung: {
@@ -113,7 +124,18 @@ export function DatenAnbieter({
const rohprofilRef = useRef<Profil | null>(null)
rohprofilRef.current = rohprofil
const neuLaden = useCallback(async () => {
// Dasselbe fuer `bereit`: neuLaden liest den Wert nur, darf aber nicht
// davon abhaengen. Stand er in den Abhaengigkeiten, bekaeme der Effekt
// unten (der bewusst nur an `api` haengt) fuer immer die Fassung vom
// ersten Rendern mit `bereit === false` - und wuerde nach einem
// Wiederverbinden jeden Aussetzer als harten Ladefehler melden, obwohl
// laengst Daten da sind.
const bereitRef = useRef(false)
bereitRef.current = bereit
/** Laedt alles neu. Gibt zurueck, ob es geklappt hat - der Aufrufer
entscheidet, ob er es noch einmal versucht. */
const neuLaden = useCallback(async (): Promise<boolean> => {
try {
const [p, f, t, s] = await Promise.all([
api.profilLesen(),
@@ -136,18 +158,39 @@ export function DatenAnbieter({
setzeServerVersion(versionsangabe?.app ?? null)
setzeOtaBuendel(versionsangabe?.buendel ?? null)
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
return true
} catch (fehler) {
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
const netzproblem = fehler instanceof ApiFehler && fehler.istNetzproblem
if (!netzproblem || !bereit) {
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
// Daten da sind — dann zeigt die App den zwischengespeicherten Stand
// mit Offline-Hinweis. Dazu zaehlt auch ein Home Assistant, der
// gerade neu startet (502/503/504), siehe istVoruebergehend.
const wartet = fehler instanceof ApiFehler && fehler.istVoruebergehend
if (!wartet || !bereitRef.current) {
setzeLadefehler(fehler instanceof Error ? fehler.message : String(fehler))
}
return false
}
}, [api, bereit])
}, [api])
// Erstladung plus Live-Verbindung.
useEffect(() => {
let abgemeldet = false
let nachladeUhr: ReturnType<typeof setTimeout> | null = null
/** Versucht es so lange, bis eine Ladung durchgeht - mit wachsendem
Abstand, damit ein dauerhaft nicht erreichbarer Server nicht im
Sekundentakt angefragt wird. Nach gut zwei Minuten ist Schluss;
dann hilft nur noch das naechste "verbunden" oder ein Zug nach
unten auf der Uebersicht. */
const nachladenBisEsKlappt = async (versuch = 0): Promise<void> => {
if (abgemeldet) return
if (await neuLaden()) return
if (abgemeldet || versuch >= WIEDERHOLABSTAENDE_MS.length) return
nachladeUhr = setTimeout(() => {
void nachladenBisEsKlappt(versuch + 1)
}, WIEDERHOLABSTAENDE_MS[versuch])
}
void neuLaden()
void api.warteschlange.laden()
@@ -155,7 +198,15 @@ export function DatenAnbieter({
setzeVerbindung(zustand)
// Nach einem Wiederverbinden kann während der Trennung etwas passiert
// sein, das kein Ereignis mehr ausgelöst hat.
if (zustand === "verbunden") void neuLaden()
//
// Warum das nicht ein einzelner Aufruf sein darf: nach einem Neustart
// von Home Assistant steht die Websocket-Verbindung regelmaessig ein
// paar Sekunden, bevor die REST-Schnittstelle antwortet - dazwischen
// kommt 502. Ein einziger Versuch schlug dann fehl, und weil danach
// kein weiteres "verbunden" mehr folgt, blieb die App bis zum
// Beenden auf dem Stand von vor dem Neustart stehen, mitsamt der
// Fehlerzeile (vom Eigentuemer gemeldet, 2026-08-31).
if (zustand === "verbunden") void nachladenBisEsKlappt()
})
const abZustand = api.live.aufZustand(({ entity_id, new_state }) => {
@@ -184,6 +235,8 @@ export function DatenAnbieter({
void api.live.starten()
return () => {
abgemeldet = true
if (nachladeUhr) clearTimeout(nachladeUhr)
abVerbindung()
abZustand()
abWarteschlange()
+36 -5
View File
@@ -83,6 +83,29 @@ export function buendelPasst(buendel: Buendelangabe | null, eigene: string | nul
export class OtaFehler extends Error {}
/**
* Erklärt einen fehlgeschlagenen Download, statt die Meldung des Plugins
* durchzureichen.
*
* „Checksum" heißt: die geladenen Bytes sind nicht die erwarteten. In diesem
* Projekt war die Ursache bisher nie ein kaputtes Bündel (Ablage, Auslieferung
* und Prüfsumme sind nachweislich deckungsgleich), sondern ein Download, der
* mitten in einen Neustart von Home Assistant lief. Das ist keine Sackgasse,
* sondern ein Grund, es gleich noch einmal zu versuchen - und genau das soll
* dort stehen, wo der Fehler steht.
*/
function ladefehlerText(fehler: unknown): string {
const meldung = fehler instanceof Error ? fehler.message : String(fehler)
if (/checksum|prüfsumme|pruefsumme/i.test(meldung)) {
return (
"Das Update kam unvollständig an. Das passiert, wenn Home Assistant " +
"während des Ladens neu startet. Sobald es wieder läuft, noch einmal " +
`versuchen. (${meldung})`
)
}
return `Das Update konnte nicht geladen werden (${meldung}).`
}
/**
* Lädt das Bündel und schaltet darauf um.
*
@@ -100,11 +123,21 @@ export async function buendelAnwenden(
): Promise<never> {
if (!otaMoeglich()) throw new OtaFehler("Updates gibt es nur in der App auf dem Gerät.")
const url = new URL(buendel.url, basisUrl).href
// Die Fassung hängt als Abfrageteil an der Adresse.
//
// Home Assistant liefert /audi_dashboard_static/ ohne Cache-Control, aber
// mit ETag und Last-Modified (nachgemessen). Genau dann darf jeder Zwischen-
// speicher - der des Geräts wie einer im Weg - die Datei nach eigenem
// Ermessen eine Weile behalten. Käme dabei die vorherige Zip zurück, während
// die Prüfsumme aus der Entität längst die neue nennt, scheiterte jedes
// Update mit einem Prüfsummenfehler, ohne dass irgendetwas kaputt wäre.
// Dieselbe Vorsorge trifft das Panel seit jeher für sein eigenes Bündel.
const adresse = new URL(buendel.url, basisUrl)
adresse.searchParams.set("v", buendel.version)
let geladen
try {
geladen = await CapacitorUpdater.download({
url,
url: adresse.href,
version: buendel.version,
// Das Plugin rechnet auf dem Gerät SHA-256 über die heruntergeladene
// Zip und verwirft sie bei Abweichung (calcChecksum in
@@ -113,9 +146,7 @@ export async function buendelAnwenden(
checksum: buendel.sha256,
})
} catch (fehler) {
throw new OtaFehler(
`Das Update konnte nicht geladen werden (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
)
throw new OtaFehler(ladefehlerText(fehler))
}
try {
+18
View File
@@ -12,8 +12,17 @@ import { summe } from "../format"
/** "absolut" ist kein Zeitraum, sondern alles je Erfasste - die Summe. */
export type Zeitraum = "jahr" | "monat" | "woche" | "tag" | "absolut"
/** Alle Zeiträume, die berechnet werden - einschließlich "absolut". */
export const ZEITRAEUME: Zeitraum[] = ["jahr", "monat", "woche", "tag", "absolut"]
/** Die Spalten, die eine Kachel zeigt.
"absolut" fehlt hier bewusst: der Gesamtwert steht seit dem 2026-08-31
als große Zahl in der Kopfzeile der Kachel (Ansage des Eigentümers). Eine
fünfte Spalte passte auf dem Telefon ohnehin nicht in eine Zeile - die
Kachel ist dort innen 307px breit, ein sechsstelliger Wert 67px. */
export const ZEITRAUM_SPALTEN: Zeitraum[] = ["jahr", "monat", "woche", "tag"]
export const ZEITRAUM_NAME: Record<Zeitraum, string> = {
jahr: "Jahr",
monat: "Monat",
@@ -146,6 +155,11 @@ export interface Jahresauswertung {
stundenJahr: number
literJahr: number
kostenJahr: number
/** Über den gesamten erfassten Zeitraum - die Zahl in der Kopfzeile. */
kmGesamt: number
stundenGesamt: number
literGesamt: number
kostenGesamt: number
tagKm: number
nachtKm: number
arbeitKm: number
@@ -206,6 +220,10 @@ export function jahresauswertung(
stundenJahr: jeZeitraum.jahr.stunden,
literJahr: jeZeitraum.jahr.liter,
kostenJahr: jeZeitraum.jahr.kosten,
kmGesamt: jeZeitraum.absolut.km,
stundenGesamt: jeZeitraum.absolut.stunden,
literGesamt: jeZeitraum.absolut.liter,
kostenGesamt: jeZeitraum.absolut.kosten,
tagKm,
nachtKm: kmJahr - tagKm,
arbeitKm,
+1 -8
View File
@@ -16,7 +16,7 @@ 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 } from "../format"
import { useBoden, useTheme } from "../theme"
import { useTheme } from "../theme"
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
import { DatensatzPopup } from "./DatensatzPopup"
@@ -49,7 +49,6 @@ export function Einstellungen({
neuLaden,
} = useDaten()
const [theme, themeSetzen] = useTheme()
const [boden, setzeBoden] = useBoden()
// "Fahrzeug einrichten" ist im Panel zugeklappt und oeffnet sich ueber den
// Knopf darunter (einrichtenOffen in vEinst()).
const [einrichtenOffen, setzeEinrichtenOffen] = useState(false)
@@ -356,12 +355,6 @@ export function Einstellungen({
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Aus zeigt nur die Symbole und gibt etwas Höhe frei
</span>
<Feld label="Fahrbahn unter dem Fahrzeug" last>
<Switch checked={boden} onChange={setzeBoden} aria-label="Fahrbahn unter dem Fahrzeug" />
</Feld>
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten
</span>
</Tile>
<Tile>
+11 -8
View File
@@ -14,7 +14,7 @@ import { useState, type ReactNode } from "react"
import { Fig, ProgressBar, Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { ZEITRAEUME, ZEITRAUM_NAME, jahresauswertung } from "../daten/statistik"
import { ZEITRAUM_NAME, ZEITRAUM_SPALTEN, jahresauswertung } from "../daten/statistik"
import type { Zeitraum } from "../daten/statistik"
import { datum, de, deOderStrich } from "../format"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
@@ -105,15 +105,18 @@ export function Statistik() {
// vier Mal für dieselbe Sache und fehlte in der Überschrift, die man
// zuerst liest.
const jeZeitraum = (auswahl: (z: Zeitraum) => string): [string, string][] =>
ZEITRAEUME.map((z) => [auswahl(z), ZEITRAUM_NAME[z]])
ZEITRAUM_SPALTEN.map((z) => [auswahl(z), ZEITRAUM_NAME[z]])
return (
<>
{/* Die große Zahl ist der Gesamtwert, nicht der des laufenden Jahres
- deshalb steht im Titel auch keine Jahreszahl mehr. Die vier
Spalten darunter bleiben die Zeiträume. */}
<StatKachel
titel={`Distanz und Fahrzeit ${a.jahr}`}
wert={de(a.kmJahr)}
titel="Distanz und Fahrzeit"
wert={de(a.kmGesamt)}
einheit="km"
neben={`${de(a.stundenJahr)} h`}
neben={`${de(a.stundenGesamt)} h`}
>
<Vierergruppe
titel="Gefahrene Distanz [km]"
@@ -128,10 +131,10 @@ export function Statistik() {
</StatKachel>
<StatKachel
titel={`Getankte Menge und Kosten ${a.jahr}`}
wert={de(a.literJahr)}
titel="Getankte Menge und Kosten"
wert={de(a.literGesamt)}
einheit="l"
neben={`${de(a.kostenJahr)}`}
neben={`${de(a.kostenGesamt)}`}
>
<Vierergruppe
titel="Getankte Liter [l]"
+1 -13
View File
@@ -12,7 +12,6 @@ import { useDaten } from "../daten/DatenKontext"
import { naechsterService } from "../daten/service"
import { datum, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { useBoden } from "../theme"
import { BILDPLAETZE, bildInfo } from "./bilder"
import { Blattzeile, Leerzustand } from "./bausteine"
import { Bild } from "./Bild"
@@ -30,17 +29,12 @@ const SERVICE_SYMBOL: Partial<Record<string, typeof SymbolOelwechsel>> = {
"Inspektion": SymbolInspektion,
}
/* Innenraumaufnahmen bekommen keinen Bodenschatten — `OHNE_SZENE` im Panel. */
const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen, fahrten, tankvorgaenge } = useDaten()
const [boden] = useBoden()
if (!fahrzeug || !einstellungen) return null
// Bodenschatten unter dem Fahrzeug, wie szeneKlasse() im Panel: abschaltbar,
// nicht bei Innenraumaufnahmen, blaustichig bei montierten Winterrädern.
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
/* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname -
siehe die Begruendung in Einstellungen.tsx. Der Vergleich gegen datei
@@ -50,12 +44,6 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
// Bei Winterrädern zeigt die Seitenansicht das Winterfoto, wie bildInfo()
// im Panel — das fehlte hier ganz.
const startbild = bildInfo(platz, winterraeder)
const bodenKlasse =
!boden || OHNE_SZENE.includes(platz.name)
? ""
: winterraeder
? " dm-szene--winter"
: " dm-szene--strasse"
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
@@ -104,7 +92,7 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
Typenschild, Kennzeichen und Statuszeile — dieselbe Aufteilung wie
vHome() im Panel (`.szene.bleed` gefolgt von einer `.tile.flat`).
Der Status stand hier bisher als eigenes Element unter der Szene. */}
<div className={`dm-szene${bodenKlasse}`}>
<div className="dm-szene">
<Bild
datei={startbild.datei}
beschreibung={startbild.beschreibung}
+36 -40
View File
@@ -269,49 +269,29 @@ input[type="date"].dm-eingabe {
margin-bottom: 22px;
}
.dm-szene::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
/* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere
Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen
darunter (gemeldet vom Eigentuemer, 2026-08-31). */
height: 26%;
background: linear-gradient(to bottom, transparent, var(--canvas) 96%);
pointer-events: none;
z-index: 1;
}
/* Der Ausblend-Verlauf ist am 2026-08-31 entfallen: er lag ueber dem
unteren Teil des Fotos, und der Eigentuemer hat zweimal darum gebeten,
das Fahrzeug unverdeckt zu sehen. Ebenso der Bodenschatten, der hier
frueher als .dm-szene--strasse::after stand - samt dem Schalter dafuer. */
/* Bodenschatten unter dem Fahrzeug (`.szene.strasse::after` /
`.szene.winter::after` im Panel, abschaltbar über den Schalter „Boden" in
den Einstellungen). */
.dm-szene--strasse::after,
.dm-szene--winter::after {
content: "";
position: absolute;
left: 8%;
right: 8%;
bottom: 6px;
height: 26px;
border-radius: 50%;
background: radial-gradient(ellipse at center, var(--shade), transparent 70%);
pointer-events: none;
}
.dm-szene--winter::after {
background: radial-gradient(ellipse at center, rgba(150, 190, 255, .16), transparent 70%);
}
/* Das Fahrzeugfoto wird nicht mehr beschnitten.
/* Panel: .carbox ~120px, .szene ~130px hoch - ein schmales, breites Band,
nicht das 4:3-Seitenverhältnis, das ImagePlaceholder sonst für Galerien
verwendet (dort korrekt, hier war es das nie - "carfix klein" im Panel ist
16:8). Scoped auf die Übersicht-Szene, damit andere ImagePlaceholder-
Stellen (Mein Audi, Reifen, ...) ihre eigene, richtige Größe behalten. */
Gemessen am echten Foto (2026-08-31): es ist 2000x1125 gross, also 16:9.
Der Kasten stand auf 16:8 - bei object-fit:cover wurde das Bild auf die
Kastenbreite skaliert und oben wie unten je ein Viertel abgeschnitten.
Das Dach lag darin.
Statt das Verhaeltnis auf 16:9 festzuschreiben - was beim naechsten Foto in
einem anderen Format wieder schneiden wuerde - bestimmt jetzt das Bild
selbst die Hoehe. Der Platzhalter behaelt sein festes Verhaeltnis, er hat
ja keines von sich aus. */
.dm-szene .ads-bildbox {
aspect-ratio: 16 / 8;
aspect-ratio: auto;
}
.dm-szene .ads-bildbox img {
height: auto;
}
/* Ohne Foto bleibt die Fläche flach und läuft nicht randlos: sonst stünden
@@ -685,9 +665,25 @@ button.dm-serviceblock:active {
braucht bei 16px Audi Type Wide etwa 60. auto-fit bricht deshalb um - auf dem
Telefon drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer
Reihe. Deckungsgleich mit .quad im Panel. */
/* Vier feste Spalten - Jahr, Monat, Woche, Tag muessen in EINE Zeile passen.
Vorher stand hier repeat(auto-fit, minmax(84px, 1fr)). Auf dem Telefon ist
die Kachel innen 307px breit, damit passten bei 12px Abstand nur drei
Spalten (3 x 84 + 2 x 12 = 276; vier waeren 372) - die vierte rutschte in
eine zweite Zeile.
Mit vier festen Spalten bleiben 67,75px je Spalte. Der laengste realistisch
vorkommende Wert ist ein sechsstelliger Kilometerstand wie "209.177": am
laufenden Panel gemessen 67px in Audi Type Wide 300 bei 16px. Es passt
also, aber ohne Reserve - eine fuenfte Spalte gaebe es nur mit kleinerer
Schrift, und genau deshalb steht der Gesamtwert jetzt in der Kopfzeile der
Kachel statt in einer eigenen Spalte.
Bewusst kein white-space:nowrap: waere ein Wert doch einmal breiter,
umbricht er innerhalb seiner Spalte, statt aus der Kachel zu laufen. */
.dm-quad {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
margin-top: var(--sp-3);
}
+5
View File
@@ -209,5 +209,10 @@ export function beispielApi(): DataMetricApi {
tankvorgangAktualisieren: async () => null,
tankvorgangLoeschen: async () => null,
jetztAktualisieren: async () => null,
// Die echte Fassung schluckt ihre Fehler selbst und liefert dann null.
// Fehlte sie hier ganz, endete jede Ladung im Fangzweig von neuLaden -
// die Seiten rendern trotzdem, aber ein Test ueber den Ladefehler waere
// wertlos.
appVersionAngabeLesen: async () => null,
} as unknown as DataMetricApi
}
+11 -11
View File
@@ -4,8 +4,8 @@
* Jeder Aufruf von `useTheme()` legte bis 2026-08-31 sein eigenes `useState`
* an. Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie;
* die Kopie in `App.tsx`, die `data-theme` setzt, blieb stehen — sichtbar
* wurde die Wahl erst nach einem Neustart der App. Der Boden-Schalter hatte
* denselben Fehler.
* wurde die Wahl erst nach einem Neustart der App. Die uebrigen Schalter
* hatten denselben Fehler.
*
* Der Test stellt genau diese Konstellation nach: zwei getrennte Komponenten,
* eine schaltet, die andere zeigt an.
@@ -14,26 +14,26 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { useBoden, useTheme } from "./theme"
import { useTabBeschriftung, useTheme } from "./theme"
function Anzeige() {
const [theme] = useTheme()
const [boden] = useBoden()
const [beschriftung] = useTabBeschriftung()
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="boden">{boden ? "an" : "aus"}</span>
<span data-testid="beschriftung">{beschriftung ? "an" : "aus"}</span>
</div>
)
}
function Schalter() {
const [theme, setzeTheme] = useTheme()
const [boden, setzeBoden] = useBoden()
const [beschriftung, setzeBeschriftung] = useTabBeschriftung()
return (
<div>
<button onClick={() => setzeTheme(theme === "nacht" ? "tag" : "nacht")}>Thema</button>
<button onClick={() => setzeBoden(!boden)}>Boden</button>
<button onClick={() => setzeBeschriftung(!beschriftung)}>Beschriftung</button>
</div>
)
}
@@ -57,17 +57,17 @@ describe("Ansichtseinstellungen, geteilt zwischen Komponenten", () => {
expect(screen.getByTestId("theme").textContent).not.toBe(vorher)
})
it("der Boden-Schalter der einen Komponente erreicht die andere", () => {
it("der Beschriftungs-Schalter der einen Komponente erreicht die andere", () => {
render(
<>
<Anzeige />
<Schalter />
</>,
)
const vorher = screen.getByTestId("boden").textContent
const vorher = screen.getByTestId("beschriftung").textContent
fireEvent.click(screen.getByText("Boden"))
fireEvent.click(screen.getByText("Beschriftung"))
expect(screen.getByTestId("boden").textContent).not.toBe(vorher)
expect(screen.getByTestId("beschriftung").textContent).not.toBe(vorher)
})
})
+7 -18
View File
@@ -11,13 +11,13 @@
*
* useTheme() App.tsx (setzt data-theme), Einstellungen.tsx (der Schalter),
* Typenschild.tsx (zweimal, für die Farbe des Schilds)
* useBoden() Einstellungen.tsx (der Schalter), Uebersicht.tsx (die Anzeige)
* useTabBeschriftung() Einstellungen.tsx (der Schalter), App.tsx (die Leiste)
*
* Der Schalter in den Einstellungen änderte damit nur seine eigene Kopie und
* den Speicher der Ablage die Kopie in App.tsx blieb stehen, `data-theme`
* wechselte nie, und die Umschaltung wirkte erst nach einem Neustart der App.
* Genau so gemeldet: Tag/Nacht Umschaltung wirkungslos". Der Boden-Schalter
* hatte denselben Fehler, nur ist er weniger auffällig.
* Genau so gemeldet: Tag/Nacht Umschaltung wirkungslos". Die übrigen
* Schalter hatten denselben Fehler, nur ist er dort weniger auffällig.
*
* `useSyncExternalStore` (in React eingebaut) löst das an der Wurzel: es gibt
* je Einstellung genau einen Wert, jeder Aufrufer sieht denselben, und ein
@@ -95,11 +95,6 @@ const THEMA = einstellung<Theme>(
(wert) => wert,
)
const BODEN = einstellung<boolean>(
"dm360.boden",
(roh) => roh !== "aus",
(an) => (an ? "an" : "aus"),
)
const TABBESCHRIFTUNG = einstellung<boolean>(
"dm360.tabbeschriftung",
@@ -129,16 +124,10 @@ export function useTheme(): [Theme, (theme: Theme) => void] {
return [theme, setzen]
}
/**
* Bodenschatten unter dem Fahrzeugbild im Panel der Schalter Boden" in den
* Einstellungen (`bodenAn`). Dort lebt er nur im Speicher und ist nach einem
* Neuladen wieder an; hier bleibt er erhalten, Standard wie im Panel: an.
*/
export function useBoden(): [boolean, (an: boolean) => void] {
const an = useSyncExternalStore(BODEN.horchen, BODEN.stand, BODEN.stand)
const setzen = useCallback((neu: boolean) => BODEN.setzen(neu), [])
return [an, setzen]
}
/* useBoden() ist am 2026-08-31 entfallen: der Bodenschatten unter dem
Fahrzeug ist weg (Ansage des Eigentuemers), damit hatte der Schalter
nichts mehr zu schalten. Der abgelegte Wert unter "dm360.boden" bleibt
liegen und stoert nicht - er wird schlicht nicht mehr gelesen. */
/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
@@ -1 +1 @@
{"version":"2026.8.31.3","sha256":"c0b6aa9b9740fa9c9390a6d200a30aeb8430937a22129e7d61fa88e5452f9283","bytes":266044,"gebaut":"2026-08-31T13:02:37Z"}
{"version":"2026.8.31.4","sha256":"48d4965f198c06dbcad882c76eca656de6cc8c18a17c7ff24a39c5c9a1e8c5a9","bytes":266032,"gebaut":"2026-08-31T13:30:52Z"}
@@ -368,12 +368,12 @@ function markenlogo() {
<span class="ph">${esc(label)}</span>
</span>`;
}
const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"];
function szeneKlasse(i) {
if (!bodenAn) return "";
if (OHNE_SZENE.includes(BILDER[i].name)) return "";
return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse";
}
/* Der angedeutete Boden unter dem Fahrzeug ist am 2026-08-31 entfallen -
der Eigentuemer wollte den Schatten weg, und damit hatte auch der
Schalter dafuer nichts mehr zu schalten. Frueher stand hier
szeneKlasse(), das je nach Bild und Reifensatz "strasse" oder "winter"
an .szene haengte; die zugehoerigen ::after-Regeln sind in
audi-dashboard.css beschrieben. */
function bildWeiter() {
bild = (bild + 1) % BILDER.length;
const img = ROOT.getElementById("carimg");
@@ -387,7 +387,7 @@ function bildWeiter() {
box.querySelector(".platzhalter-label").textContent = info.label;
}
const sz = ROOT.getElementById("szene");
if (sz) sz.className = "szene " + szeneKlasse(bild);
if (sz) sz.className = "szene";
[...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild));
}
@@ -1654,7 +1654,7 @@ function vHome() {
zwei kurze, haeufig gelesene Textzeilen lagen bisher unter einer 190px
hohen Karte und damit auf dem Telefon unterhalb des ersten Bildschirms. */
return `
<div class="szene bleed ${szeneKlasse(startIndex())}"><div class="carbox">${carImg("fest")}</div></div>
<div class="szene bleed"><div class="carbox">${carImg("fest")}</div></div>
<div class="tile flat" style="margin-bottom:20px">
${badge()}
<span class="label" style="margin-top:10px">${esc(CONFIG.kennzeichen)}</span>
@@ -1815,7 +1815,7 @@ function vTuerenKlappen() {
function vAudi() {
const r = CAR.reifen, a = r[r.aktiv.toLowerCase()];
return `
<div class="szene ${szeneKlasse(bild)}" id="szene"><div class="carbox">${carImg("galerie")}</div></div>
<div class="szene" id="szene"><div class="carbox">${carImg("galerie")}</div></div>
<div class="tile flat" style="margin:14px 0 16px">
${badge()}
<span class="label" style="margin-top:12px">${esc(CONFIG.kennzeichen)}</span>
@@ -2793,7 +2793,6 @@ const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift";
let beschriftung = (() => {
try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; }
})();
let bodenAn = true;
function csvDatei(name, kopf, zeilen) {
const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n");
const a = document.createElement("a");
@@ -2858,10 +2857,6 @@ function vEinst() {
<label for="schrift">Beschriftung in der Menüleiste</label>
<label class="switch"><input id="schrift" type="checkbox" data-schrift ${beschriftung ? "checked" : ""}><span class="track"></span></label></div>
<span class="label" style="margin-top:14px">Aus zeigt nur die Symbole und gibt etwas Höhe frei</span>
<div class="feld" style="margin-top:16px;border-bottom:0">
<label for="boden">Fahrbahn unter dem Fahrzeug</label>
<label class="switch"><input id="boden" type="checkbox" data-boden ${bodenAn ? "checked" : ""}><span class="track"></span></label></div>
<span class="label" style="margin-top:14px">Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten</span>
</div>
<div class="tile"><span class="label">Bild der Übersicht</span>
<div class="feld" style="border-bottom:0"><label for="startbild">Ansicht</label>
@@ -3040,6 +3035,10 @@ function entitaetScore(feld, e, suchtext) {
if (feld.domains && feld.domains.includes(e.domain)) score += 3;
if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3;
if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2;
// Der Signalname aus dem Katalog ("z. B. can_fuel_volume") ist der
// treffsicherste Hinweis, den es gibt - deutlich mehr wert als ein
// einzelnes Stichwort.
if (feld.beispiel && hay.includes(feld.beispiel.toLowerCase())) score += 8;
if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10;
return score;
}
@@ -3048,7 +3047,7 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
const feld = setupKatalog().find((f) => f.key === feldKey);
if (!feld) return [];
const text = (suchtext || "").trim().toLowerCase();
const liste = ALLE_ENTITAETEN.filter((e) => {
let liste = ALLE_ENTITAETEN.filter((e) => {
if (e.id === aktuellerWert) return true;
if (setupNurPassend) {
if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false;
@@ -3065,6 +3064,27 @@ function entitaetKandidaten(feldKey, suchtext, aktuellerWert) {
if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false;
return true;
});
/* "Nur passende Sensoren" sucht zusaetzlich nach dem Namen, der neben der
Ueberschrift als "(z. B. ...)" steht - dem tatsaechlichen Signalnamen der
Integration, nicht einem ausgedachten Beispiel (siehe FELDER in
einstellungen.py). Domain und Einheit allein lassen bei einem Fahrzeug
mit siebzig Sensoren immer noch Dutzende uebrig; der Signalname trifft
genau einen.
Mit Rueckfall: findet der Name nichts, bleibt die ungefilterte Liste
stehen. Eine leere Auswahl waere schlimmer als eine lange - der Beispiel-
name stammt aus dieser einen Installation, eine andere kann ihre Signale
anders nennen. */
if (setupNurPassend && feld.beispiel) {
const beispiel = feld.beispiel.toLowerCase();
const genau = liste.filter(
(e) =>
e.id === aktuellerWert ||
e.id.toLowerCase().includes(beispiel) ||
e.name.toLowerCase().includes(beispiel.replace(/_/g, " ")),
);
if (genau.length) liste = genau;
}
liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext));
return liste.slice(0, 60);
}
@@ -3647,6 +3667,13 @@ function vStat() {
const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag");
const fAbs = fahrtenSeit("absolut"), tAbs = tankSeit("absolut");
const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600;
// Die grosse Zahl oben in den beiden Kacheln zeigt seit dem 2026-08-31 den
// Gesamtwert, nicht mehr den des laufenden Jahres (Ansage des Eigentuemers).
// Damit steht "Absolut" dort, wo man es zuerst liest, und die Spalten
// darunter bleiben die vier Zeitraeume - fuenf passten auf dem Telefon
// nachweislich nicht in eine Zeile.
const kmAbs = sum(fAbs, (t) => t.distance_km), hAbs = sum(fAbs, (t) => t.duration_s) / 3600;
const lAbs = sum(tAbs, (f) => f.liters), eAbs = sum(tAbs, (f) => f.fuel_total_eur || 0);
const tJahr = tankSeit("jahr");
const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0);
const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km);
@@ -3655,12 +3682,12 @@ function vStat() {
const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm;
const lv = langzeitverbrauch();
return `
${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h",
quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"], [de(sum(fAbs, (t) => t.distance_km)), "Absolut"]]) +
quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"], [de(sum(fAbs, (t) => t.duration_s) / 3600), "Absolut"]]))}
${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €",
quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"], [de(sum(tAbs, (f) => f.liters)), "Absolut"]]) +
quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"], [de(sum(tAbs, (f) => f.fuel_total_eur || 0)), "Absolut"]]))}
${statTile("s1", "Distanz und Fahrzeit", de(kmAbs), "km", de(hAbs) + " h",
quad("Gefahrene Distanz [km]", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) +
quad("Fahrzeit [h]", [[de(hJahr), "Jahr"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag"]]))}
${statTile("s2", "Getankte Menge und Kosten", de(lAbs), "l", de(eAbs) + " €",
quad("Getankte Liter [l]", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) +
quad("Kraftstoffkosten [€]", [[de(eJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag"]]))}
${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "",
`<dl class="rows">
<div class="row"><dt>Getankt insgesamt</dt><dd>${de(lv.liter, 1)} l</dd></div>
@@ -3668,7 +3695,7 @@ function vStat() {
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : ""}</dd></div>
</dl>
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"], [schnittFahrtVerbrauch(fAbs), "Absolut"]])}
${quad("Näherung je Fahrt, je Zeitraum [l/100 km]", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.</span>`)}
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "", "% bei Tag", de(tagKm) + " km",
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
@@ -5773,7 +5800,6 @@ function ereignisseVerdrahten() {
}
if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; }
if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; }
if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; }
if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; }
if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; }
@@ -349,7 +349,9 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
.bildbox.carfix{height:260px}
.bildbox.carfix.klein{height:220px}
/* Feste 220px hier wuerden das Foto wieder beschneiden - die Hoehe
bestimmt seit dem 2026-08-31 das Bild selbst (siehe audi-dashboard.css). */
.bildbox.carfix.klein{height:auto}
.bildgrid{grid-template-columns:repeat(4,1fr)}
.quad{gap:14px}
@@ -596,30 +596,27 @@ button.leaf:active { background: var(--tile-2); }
blendet nach unten in die Fläche aus. */
.szene.bleed { margin: -10px -20px 0; padding: 0 0 10px; }
.szene.bleed .carbox { padding: 0 8px; }
.szene.bleed::before {
content: "";
position: absolute; left: 0; right: 0; bottom: 0;
/* 26/96 statt 42/78: der Verlauf soll die harte Bildkante ausblenden,
nicht das Fahrzeug. Mit 42% Hoehe und Vollton ab 78% war das untere
Drittel des Fotos praktisch einfarbig - Raeder und Schweller lagen
darunter (gemeldet vom Eigentuemer, 2026-08-31). */
height: 26%;
background: linear-gradient(to bottom, transparent, var(--canvas) 96%);
pointer-events: none;
z-index: 1;
}
.szene.strasse::after, .szene.winter::after {
content: "";
position: absolute;
left: 8%; right: 8%; bottom: 6px;
height: 26px;
border-radius: 50%;
background: radial-gradient(ellipse at center, var(--shade), transparent 70%);
}
.szene.winter::after { background: radial-gradient(ellipse at center, rgba(150,190,255,.16), transparent 70%); }
/* Der Ausblend-Verlauf und der Bodenschatten sind am 2026-08-31 entfallen.
Beide lagen ueber dem unteren Teil des Fotos, und der Eigentuemer hat
zweimal darum gebeten, das Fahrzeug unverdeckt zu sehen. Zurueckholen
waere ein linear-gradient auf .szene.bleed::before bzw. ein
radial-gradient auf .szene.strasse::after - beides steht in der
Versionsgeschichte. */
.carbox { position: relative; }
.carfix { width: 100%; aspect-ratio: 16 / 9; }
.carfix.klein { aspect-ratio: 16 / 8; }
/* Das Fahrzeugfoto wird nicht mehr beschnitten.
Gemessen am echten Foto (2026-08-31): es ist 2000x1125 gross, also 16:9.
Der Kasten stand auf 16:8 - bei object-fit:cover wurde das Bild auf die
Kastenbreite skaliert und oben wie unten je ein Viertel abgeschnitten
(796x220 Kasten, 796x448 skaliertes Bild). Das Dach lag darin.
Statt das Seitenverhaeltnis auf 16:9 festzuschreiben - was beim naechsten
Foto in einem anderen Format wieder schneiden wuerde - bestimmt jetzt das
Bild selbst die Hoehe. Der Platzhalter behaelt sein festes Verhaeltnis,
er hat ja keines von sich aus. */
.bildbox.carfix.klein { aspect-ratio: auto; height: auto; }
img.carfix.klein { aspect-ratio: auto; height: auto; }
/* Reifenfoto ist oft eine breite Aufnahme; statt beide Ränder symmetrisch
wegzuschneiden (object-fit:cover-Standard), bleibt bewusst der rechte Rand
des Originalfotos erhalten - dort sitzt das Rad meist im Bild. */
@@ -890,7 +887,23 @@ button.leaf:active { background: var(--tile-2); }
nebeneinander: bei 307px Innenbreite blieben je 51px, und "209.177" braucht
bei 16px Audi Type Wide rund 60. auto-fit bricht deshalb um - auf dem Telefon
drei plus zwei, auf einem breiten Bildschirm alle fuenf in einer Reihe. */
.quad { display: grid; grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); }
/* Vier feste Spalten - Jahr, Monat, Woche, Tag muessen in EINE Zeile passen.
Vorher stand hier repeat(auto-fit, minmax(84px, 1fr)). Auf dem Telefon ist
die Kachel innen 307px breit, damit passten bei 12px Abstand nur drei
Spalten (3 x 84 + 2 x 12 = 276; vier waeren 372) - die vierte rutschte in
eine zweite Zeile.
Mit vier festen Spalten bleiben 67,75px je Spalte. Der laengste realistisch
vorkommende Wert ist ein sechsstelliger Kilometerstand wie "209.177": am
laufenden Panel gemessen 67px in Audi Type Wide 300 bei 16px. Es passt
also, aber ohne Reserve - eine fuenfte Spalte gaebe es nur mit kleinerer
Schrift, und genau deshalb steht der Gesamtwert jetzt in der Kopfzeile der
Kachel statt in einer eigenen Spalte.
Bewusst kein white-space:nowrap: waere ein Wert doch einmal breiter,
umbricht er innerhalb seiner Spalte, statt aus der Kachel zu laufen. */
.quad { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin-top: var(--sp-3); }
.quad .n { display: block; font-family: "Audi Type Wide", var(--font-stack); font-size: 16px; font-weight: 300; color: var(--fg); line-height: 1.2; }
.quad .l { display: block; margin-top: 4px; font-size: 11px; letter-spacing: normal; color: var(--fg3); line-height: 1.3; }
.bar { display: flex; height: 4px; border-radius: var(--r-pill); overflow: hidden; gap: 2px; }
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.31.3",
"version": "2026.8.31.4",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],