Files
audi-app/AGENTS.md
T
tobias b73a8fb334 Meldezeit auch nach hinten prüfen - "Geparkt seit 20698 Tg." war die Unix-Epoche
geraetezeit() prüfte nur, ob die Meldezeit in der Zukunft liegt. Ein Sensor,
der 0 meldet, rutschte durch, und fromtimestamp(0) ergibt 1970 - auf der realen
Instanz sichtbar als "Geparkt seit 20698 Tg. 9 Std. 39 Min.".

Neu geraetezeit_plausibel(): nicht aus der Zukunft und nicht älter als
MELDEZEIT_RUECKLAUF (365 Tage). Geprüft wird an drei Stellen - beim Auslesen
der Gerätezeit, beim Setzen des Parkbeginns und beim LADEN aus dem
Laufzeit-Store, denn ein einmal falsch gespeicherter Wert überlebt sonst jedes
Update.

Fängt auch eine im Setup falsch zugeordnete ID-Entität ab: das flespi-Gerät hat
drei ID-Sensoren, deren Namen dem Zeitstempel ähneln, und deren Werte ergeben
als Unix-Zeit ebenfalls 1970.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:58:57 +02:00

7846 lines
638 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-09-02** (Null heisst unbekannt, Momentanwerte nur fuer
"jetzt", Uebersicht-Symbol vergroessert, `2026.9.2.7`, Abschnitte BN und BO.
Davor: Verbrauchsfaktor aus dem Tankbeleg, `2026.9.1.32`,
Abschnitt BM. Davor: Reifenzaehler ohne Fahrzeugwechsel, „Raeder“
statt „Reifen“, `2026.9.1.29`, Abschnitt BL. Davor: Ortsnamen aus dem Backend, Verbrauchseichung an
den Betankungen, Wertespalte ausgerichtet, `2026.9.1.27`, Abschnitt BK.
Davor: Einzelfahrt: Orte aus den Koordinaten, Kleingedrucktes weg, „Min.",
`2026.9.1.23`, Abschnitt BJ - dazu Orte in der Fahrtenliste ohne Netzabruf.
Davor: Strecken gestaffelt, Abschnitt BI. Davor: Audit ueber Panel, App und Backend, Abschnitt BH. Davor: Setup mit gruener Bestaetigung und Erwartungswert je
Listenposition, Abschnitt BG. Davor: Rueckblick ohne Nullfahrten, Streckenwahl gemeinsam in `verlauf.py`,
`2026.9.1.14`, Abschnitt BF. Davor: Fahrtstrecke auf 100 m: CAN als Anker, GNSS als Nachkommastelle,
`2026.9.1.10`, Abschnitt BE. Davor: Nachlauf-Abzug nur mit zugeordnetem `TRIP_SENSOR`, `2026.9.1.9`;
davor „502" beim Neustart entschärft, OTA-Bündel auf `2026.9.1.8`
nachgezogen, Abschnitt BC. Davor: „Geparkt seit" kommt von der Zündung und wird nach einem Neustart
aus der Aufzeichnung nachgeholt, Manifest `2026.9.1.7`,
Abschnitt BB. Davor: GNSS-Streckenrollen vorbereitet, `2026.9.1.5`, Abschnitt BA.
Davor: Fahrtende ohne den 900-s-Nachlauf des Geräts, Manifest `2026.9.1.4`,
siehe Abschnitt AZ. Davor am selben Tag: Fahrtbeginn und -ende kommen vom Gerät (`MELDEZEIT_SENSOR`),
Manifest `2026.9.1.3`, siehe Abschnitt AY: dazu die Trennung von
Trip- und Zündungsrolle und die Gerätezeit für Tankvorgänge. Davor am selben Tag: Phantomfahrten beim Neustart abgestellt, Tankstand auf `wert_bei()`
nachgezogen, zwölf Nullfahrten gelöscht, „Open items" von vier MQTT-Karteileichen befreit;
Manifest `2026.9.1.1`, siehe Abschnitt AX. Davor am 2026-08-31: Streckenberechnung: `wert_bei()`
statt `naechster_wert()` und
Rückwärtssprünge verworfen, Manifest `2026.8.31.6`, siehe Abschnitt AW. Davor am selben Tag:
Audit über Panel und iOS-App: sieben Befunde, sechs behoben,
`ZUENDUNG_SENSOR` auf das Trip-Signal des FMM003 umgelegt; Manifest `2026.8.31.5`, siehe
Abschnitt AV. Davor am selben Tag: 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.
Before that: third parity round — the panel had never actually rendered Audi
Type (@font-face inside a shadow root is ignored), "Daten bearbeiten" now works in both, and the
last nine screens are aligned; manifest `2026.8.30.5`, see section AM. Before that: second parity round — the shared chrome turned out to be the real
problem, plus seven screens rebuilt and two panel-side defects fixed; manifest `2026.8.30.3`, see
section AL. Before that: a full 11-chapter panel↔companion-app visual/functional parity audit,
one continuous session — see "Chapter 1" through "Chapter 11 (Abschluss)" further down for the
complete record; companion-app manifest bumped to `2026.8.30.2`, OTA bundle rebuilt and redeployed;
2026-08-29: iOS signing prepared end to end — device build verified, signature
blocked on a device registration at Apple, see section AI; 2026-08-23: merged the
`umsetzung-datametric360` branch — companion app phases
110 done, see `UMSETZUNGSPLAN.md`; 2026-08-13: cleaned up remaining EU Data Act residue, fixed
oversized toggle switches, and fixed a desktop-layout audit (settings button / rings logo / popups
overflowing past the capped content column) — see `DESIGN_AUDIT_2026-08-13.md`; 2026-08-16: fixed
five user-reported bugs — removed Türschloss/Haubenschloss checks, fixed a translucent confirm-
sheet, added a flespi-style split lat/lon location source, hardened the "Lädt …" bootstrap race,
and added a generic (station-independent) fuel-receipt parser fallback; later the same day: removed
the now-unused combined `STANDORT_TRACKER` field entirely (flespi never provides it), closed a real
silent-failure gap in the receipt-upload error path, and replaced the vehicle's map marker with a
two-tone pin+car icon — see section C; a third round the same day found and fixed the long-standing
"fragmented Leaflet rendering" defect at its root — the Leaflet stylesheet was loaded into
`document.head` and therefore never reached the panel's shadow root — plus light-only map tiles,
the CI `poi-car` marker, the red frame on list rows, the select-field grey box, and two more
generic-receipt-parser gaps; a sixth round the same day fixed the map pins' real visibility problem
(the CI `poi-car`/`poi` icons are thin outline-only paths, only 13-40% filled — solved with a solid
silhouette layer extracted from each icon's own outer contour, not a redraw), applied a batch of
~20 user-reported UI polish items against Apple's Human Interface Guidelines, some via a guided
Q&A, and lowered the battery-voltage statistic's cutoff from 13.2V to 12.8V; a seventh round the
same day fixed a real CSS specificity bug that kept the wheel photo a wide 4:3 box instead of the
intended 96x96 square, moved the "Montiert" pill to match, added `color-scheme` so native `<select>`
popups stop rendering in the browser's default light palette against the dark app, root-caused and
fixed a JS crash in the "Mein Audi" image-cycle click handler, merged the separate "Fahrzeugbilder"
upload grid into "Bild der Übersicht" (pick a view, tap its photo to upload), and turned the back
arrow from red to the neutral headline color; a tenth round the same day ran a real full Apple HIG
audit (Navigation/Presentation/Selection & Input/Patterns categories, not just the sixth round's
5-page pass) and fixed the three findings that survived: closed a second, larger silent-failure gap
in `profilSpeichern()` itself (the shared save path for ~25 fields, previously an unhandled promise
rejection with zero user feedback), added a spinner to the "Lädt …" bootstrap screen, and gave the
Anzugsmoment/km-correction popups the same primary-button styling the Setup popup already used;
2026-08-17: fixed pull-to-refresh on Übersicht not working — the JS gesture logic was already
correct (verified via live pointer-event simulation), the real gap was a missing
`overscroll-behavior` on the scroll container letting the browser's own native overscroll compete
with it on real touch devices; same day: the fuel-receipt upload got a proper dialog — drag the PDF
in at the computer, paste it from the clipboard on the phone, file picker still available as a third
route — see section C; a twelfth round the same day removed `HECKKLAPPENSCHLOSS_SENSOR`, fixed a
half-cut-off image placeholder, gave the Inspektion its own "voraussichtlich am …" forecast, made
trips fully editable (new `audi_dashboard_fahrt_aktualisieren`, "Neue Fahrt" now offers every field
the detail page shows), aligned the page title with the tiles on phone width, and closed two
dead-ends in the bootstrap retry loop behind the reported hanging "Lädt …" screen; a thirteenth
round the same day replaced the trip form's editable "Dauer" with Start-/Ankunftszeit (duration is
now computed, not entered), capitalized the Art labels shown to the user, dropped the red styling
and hint text from the Arbeitsweg pill, and fixed a daylight-mode-only regression from round twelve
where the image placeholder's grey background had turned invisible-white; separately the same day,
`companion-app/` (the actual phone-app codebase, which had silently drifted six days behind the
panel) was brought back in sync with everything the panel gained since 2026-08-11 - see the
"Gap found while auditing..." entry near the end of section C; finally, the Übersicht's
Kilometerstand/Service tiles were redesigned after the owner asked how Apple HIG and Audi CI would
solve them - both answer the same way, and the merged "9,0 tkm/ 04/27" number field is gone;
2026-08-18: the owner rejected a follow-up mockup for the "Mein Audi" service preview and
"Anstehende Termine" boxes ("Lost information") - no code from that proposal was built; instead the
existing Nächster-Service box on the Übersicht had its "bis zum/zur ..." wording replaced by the
real Audi CI oil-change/inspection icons (user-supplied SVGs) placed left of the figure, in both
codebases, per the owner's explicit request); 2026-08-19: two small wording fixes on the panel's
"Mein Audi" Versicherung/Steuer summary tile (`vAudi()`) and its "Kfz-Steuer" detail tile
(`vSteuer()`) — "Zusammen" renamed to "Summe" and the "feste Kosten im Jahr" caption under the sum
dropped entirely, and the Kfz-Steuer amount's period caption now reads "im Jahr" instead of the raw
data value "jährlich" (matching the Versicherung row above it) whenever `steuer.zeitraum ===
"jährlich"`; the underlying stored value and the "jährlich"/"halbjährlich" edit-form dropdown
(`vSteuerBearbeiten()`) were deliberately left untouched, since this was a display-label fix, not a
data-model rename. No companion-app port was needed for the "Zusammen" row: `companion-app`'s "Mein
Audi" page (`MeinAudi.tsx`) only links to the Versicherung/Steuer screen via a `NaviKachel`, it never
rendered a combined Versicherung+Steuer sum in the first place, so there was nothing to change there
(pre-existing structural gap, not part of this fix). Companion's `Steuer()` edit screen has no
read-only "im Jahr"/"jährlich" display either — its only "jährlich" is a fallback default string
inside an editable-sentence, structurally different from the panel's read-only tiles — so it was left
as-is too. Later the same day: the "Vertrag" tile on that same "Mein Audi" Versicherung/Steuer page
was previously read-only end-to-end (Gesellschaft, Umfang, Vertragsnummer, Selbstbeteiligung,
Schadenfreiheitsklasse) — made every line editable, plus add/delete for the Selbstbeteiligung list
specifically (the only true repeating list among those fields). Panel: new `vVertragBearbeiten()`
view (route `vertrag`, back to `vers`) reached via a new zahnrad button on the "Vertrag" tile in
`vVers()`; Selbstbeteiligung rows get a small inline "×" delete button (new `.zeile-loeschen` CSS,
same 44×44pt invisible-`::before` tap-target trick as `.km-edit`) and a "+ Position hinzufügen"
`.aktion` button that pushes `["", ""]`; Schadenfreiheitsklasse "zuvor" (`sfAlt`) is edited here too
(the *current* SF stays editable only via the pre-existing "Beitrag anpassen" screen, cross-
referenced with a hint span, not duplicated). Companion: new `Vertrag()` screen (route `vertrag`,
its own NaviKachel on the Versicherung page) follows the codebase's own established edit-screen idiom
(local `entwurf` state + explicit "Speichern" button) rather than the panel's per-field autosave —
same behavior, different idiom, per the parity rule. Selbstbeteiligung edit/add/delete moved here
from the old read-only display inside `Vertragsdetails()`, which is removed (was showing the same
list twice would have been confusing). Schadenfreiheitsklasse is *not* added to companion's new
screen: companion never displayed `teile[].sf`/`sfAlt` anywhere before this change (not even
read-only) — building a whole new UI section for a field the app never surfaced is out of scope for
"make the existing lines editable"; documented here as a pre-existing gap, not silently dropped.
Verified live in the Docker test container (panel: add/edit/delete a Selbstbeteiligung position,
values persisted through profilSpeichern()+re-render) and via `tsc --noEmit` + the companion-app test
suite (100/100 incl. the render-every-page smoke test, which now also covers `vertrag`) + `vite
build`, since companion-app needs a live backend connection the local dev server doesn't have.
2026-08-23: **retroactive data import** — see section F below for the full entry. In short: HA's
recorder keeps sensor history only 10 days by default, which silently capped how far back anything
could ever be reconstructed; `recorder_snippet.yaml` raises that to a year, and a new
`historienimport.py` + "Daten importieren aus Home Assistant" button (both codebases)
rebuilds trips, refuels and battery history for any past window out of that recorder history.
This file is the entry point for every new agent
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
the linked documents — this file points, it does not duplicate.
> **Maintenance rule (binding):** whenever you change this repo in a way that affects anything
> recorded here — completing an open item, making or reversing a decision, adding/removing a
> component, discovering a new gap — **update this file in the same session** (tick the checkbox,
> adjust the status table, bump the "Last updated" date). This file must always be written in
> **English**, even though the rest of the project is German.
> **Parity rule (binding, stated by the owner 2026-08-17):** the HA panel
> (`homeassistant/www/audi-dashboard-app.js`) and the companion app (`companion-app/`) must
> **always be at the same functional state**. When a request names only the panel, that is *not* a
> scoping decision — the owner simply tests there because the Docker instance makes it quick to see.
> Port every user-visible change to both in the **same session**, and say so in the report. The two
> are independent implementations (vanilla JS + shadow DOM vs. React + `@audi-dash/ui`), so a port
> is a re-implementation, not a copy: keep the *behaviour and wording* identical, let each side use
> its own idiom, and skip only what genuinely cannot apply (documented per case — e.g. panel-only
> CSS bugs, or HA-panel-only concepts like the entity Setup menu). If a port is impossible or
> unreasonably large, say so explicitly instead of leaving it silently undone. This rule exists
> because the two had already drifted six days apart once (see section A).
**How this file is loaded:** Claude Code does not read `AGENTS.md` natively; the root `CLAUDE.md`
imports it via `@AGENTS.md` (official recommended pattern). Other agents (Codex, Cursor, Copilot)
read `AGENTS.md` directly. Edit content here, not in `CLAUDE.md`.
---
## Working rules
### Karpathy guidelines (adopted as project standard)
From [`multica-ai/andrej-karpathy-skills`](https://github.com/multica-ai/andrej-karpathy-skills),
behavioral rules derived from Andrej Karpathy's observations on LLM coding failures. They bias
toward caution over speed — for trivial tasks, use judgment.
1. **Think before coding.** Don't assume. Don't hide confusion. Surface tradeoffs. State
assumptions explicitly; if multiple interpretations exist, present them — don't pick silently.
If a simpler approach exists, say so. Push back when warranted. If something is unclear, stop
and ask.
2. **Simplicity first.** Minimum code that solves the problem. Nothing speculative: no features
beyond what was asked, no abstractions for single-use code, no unrequested configurability, no
error handling for impossible scenarios. If 200 lines could be 50, rewrite.
3. **Surgical changes.** Touch only what you must. Don't "improve" adjacent code, comments, or
formatting; don't refactor what isn't broken; match existing style. Remove only orphans **your**
change created; mention (don't delete) pre-existing dead code. Every changed line should trace
directly to the request.
4. **Goal-driven execution.** Define success criteria, loop until verified. "Fix the bug" → write a
test that reproduces it, then make it pass. For multi-step tasks, state a brief plan with a
verify step per item.
### Claude Code practices (current guidance, 2026)
- Keep this file **concise and specific** — instructions concrete enough to verify ("run X before
Y"), markdown headers/bullets, no dense prose. Context files reduce adherence as they grow;
target roughly ≤200 lines of actual instruction.
- Don't record here what the code or git history already shows (directory dumps, dependency
lists) — record pitfalls, rationale, decisions, and conventions that differ from defaults.
- Conflicting instructions get picked arbitrarily — when updating, **remove** superseded rules
rather than stacking corrections.
- Verify loading with `/context` (file must appear under **Memory files**); edit via `/memory`.
- **This file (plus the session's task list) is the source of truth for what's done — not a leftover
plan-mode file.** A `~/.claude/plans/*.md` file surfaced by the system as "not yet complete" can be
stale: plan files aren't marked complete when their work finishes. Before claiming a feature is
unbuilt (e.g. "the Setup menu isn't implemented yet"), check the `[x]`/`[ ]` items below and/or the
task list — trust those over an old plan file's own claim about its status. (Caused a real mistake
2026-08-12: told the user the Setup menu wasn't built yet, when it had shipped hours earlier in
the same project — see the "Setup menu" `[x]` entry further down.)
---
## What this project is
Private replacement for the Audi *connect plug & play* app (discontinued end of 2026), for exactly
one vehicle (**Audi RS 4 Avant competition**), single user, self-hosted on Home Assistant, access
via Tailscale only. Read-only toward the vehicle — no remote control. Covers: vehicle status, trip
log, fuel log with Shell receipt parsing, service forecasting, insurance/tax, tire management.
**License rule (hard):** Audi Type fonts, four-rings SVG, and model-badge SVGs are cleared **only
for this one private, unpublished installation**. Therefore `design-system/` is brand-free (it gets
uploaded to Claude Design), while the HA panel and the future sideload-only app may use the real
assets. Never mix these the other way around.
**Reading order for a new session:**
1. This file (overview + open items)
2. `UMSETZUNGSPLAN.md` — the step-by-step execution plan for all open items (13 phases with
commands and acceptance criteria); when working on an open item, follow the plan's phase
3. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
4. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, phases 110 built)
5. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
6. `bauauftrag.md`**historical only**; code has diverged (see SPECIFICATION.md §7)
## The three projects in this repo
| Area | What | Status |
|---|---|---|
| `custom_components/audi_dashboard/` | The HA integration: backend, panel, OTA app bundle. HACS is not used and cannot be — private repo, categorical refusal on HACS's side, no GitHub mirror any more; `install.ps1` is the permanent, only install path (section H) | ✅ **finished, in use** — replaced the pyscript backend on 2026-08-23 (section H) |
| `homeassistant/` | Install package, recorder snippet, guides — **no code any more** | ✅ |
| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible |
| `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) |
| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data |
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) |
Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only),
`bauauftrag.md`/`.html` = original build brief (historical), `DESIGN_BRIEF_DATAMETRIC360.md` = the
prompt for the Claude Design project.
**`custom_components/audi_dashboard/` in one paragraph:** a normal HA integration with a config
flow, one `sensor` platform and 18 services. `koordinator.py` owns the runtime state and the
wiring; the domain modules beside it carry the logic: trip detection via the FMM003 ignition
sensor with pause tolerance (`fahrterkennung.py`), two-stage trip completion via recorder history
(`screening.py` — the odometer often updates only on the next trip), fill-up detection on
fuel-level rise (`tankerkennung.py`), Shell PDF parser (`shell_beleg_parser.py`, in-process via
`pypdf`, the only tested part of the repo), tire km counter, backup, image management, retroactive
history import. `ablage.py` is the only place that touches files. Frontend: `frontend/audi-dashboard-app.js`
(~4,900 lines, custom element, no framework/bundler), 5 tabs + ~19 detail routes, served by the
integration itself under `/audi_dashboard_static/` with the manifest version as the cache breaker.
Entity IDs are assigned **in the UI** (Settings → Fahrzeug einrichten → Setup), stored as overrides
in `/config/audi_dashboard/entitaeten.json`; `einstellungen.py` holds only the built-in defaults
(all empty). Deploy: HACS, or `homeassistant/installationspaket/install.ps1` where HACS can't reach
the repo.
**`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with
reconnect backoff, persistent offline write queue, credential storage), domain logic
(`src/daten/`: profile adapter, statistics, service forecast, data context, OTA update flow — see
section H), all 21 screens (`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and
icons. Verified by 117 unit and render tests (`npm test`) plus the two smoke suites against a live
Home Assistant (`npm run smoke`, `npm run smoke:auth`). `npm run dev` in the repo root starts it;
`testumgebung/aufsetzen.sh` provides the server side.
**DataMetric360 architecture (short — details in `COMPANION_APP_ARCHITECTURE.md`):**
- Future data source: **Teltonika FMM003** on the CAN bus, fully replacing the iPhone WLAN sensor
and the VAG integration.
- **Current data path (since 2026-08-12): FMM003 → flespi (native Codec8/TCP, IMEI auth) → HA.**
Both earlier directions are dropped: Traccar first, then the self-hosted MQTT/TLS broker — the
router's port-forward turned out to be unusable. The decision logs deliberately remain in the
architecture doc, marked ÜBERHOLT. Details and open steps in section B below.
- Frontend access: HA REST + WebSocket with a long-lived access token (no `hass` object).
- External access: Cloudflare Tunnel + reverse proxy with a path allowlist; HA itself stays
unreachable. Domain **`datametric360.de`** registered (all-inkl, 2026-08-11; originally `.app`,
switched to `.de` 2026-08-28 — every doc reference updated the same day, including the note in
`COMPANION_APP_ARCHITECTURE.md` §5 item 4 that `.app`'s HSTS-preload advantage no longer applies).
- Distribution: **sideload only** (license reason) — hence real Audi assets are allowed there.
- Trip detection moves to FMM003 ignition; the pause-tolerance feature
(`fahrten_pausenzeit_min`) is a deliberate product decision and must be preserved.
---
## Review findings (2026-08-11)
### Design/CI review of `main` (2026-08-13) — see `DESIGN_REVIEW_2026-08-13.md`
Code review of all UI layers plus headless-browser screenshots of the panel (mock `hass`),
checked against the repo's own Audi-CI rules. Headlines: the iOS overlay
(`audi-dashboard-ios.css`) overrides the documented palette/radii, adds shadows/blur and turns
red into a fill color **without any recorded decision** (visible e.g. as a red-filled "Abbrechen"
next to a black "Speichern" in the setup popup); `design-system/` still carries pre-audit state
(old low-contrast `--fg3 #657081`, no focus styles at all, 13.5px inputs, 34px icon buttons);
the `design/` export's RS-6 error extends into the technical sample data (73 l tank, 285/30 R22,
FIN series 4G). Four browser-verified rendering defects: switches stretched by
`.feld label{flex:1 1 auto}` (state display becomes ambiguous), double "offen" in the trip list,
empty oil-change tile when the service book is empty, and fragmented Leaflet rendering in all
three maps (✅ **fixed 2026-08-16** — root cause was Leaflet's stylesheet being loaded into
`document.head`, which never reaches the panel's shadow root; see the third-round entry in
"Open items"). Positives: the 300/400 font-weight rule and de-DE
formatting hold everywhere; `design-system/` is verifiably brand-free.
### HA panel — known gaps (most also in SPECIFICATION.md §7)
-**FIXED 2026-08-27 (section U):** `start_lat/lon`, `end_lat/lon` and `route` on individual
trips were dead schema — only `historienimport.py` ever populated them, the live detection path
never did, and the trip-detail map drew a **fabricated** line via `fakeTrack()` — not a real
track. Both are now populated (`screening.py`'s new `_position_screenen()` for live trips,
`route` added to the import path too), and `fakeTrack()` is removed — the map now draws the real
route, or an honest straight line between known points, never an invented curve.
`addresses`/`avg_speed_kmh` on individual trips remain unpopulated (a separate, still-open gap —
reverse geocoding is its own work package, see historienimport.py's own note).
- **No GPS fallback in trip completion:** trips without an odometer match stay `status="offen"`
forever (`modules/fahrtabschluss_logik.py:16-20`).
- **RAM-only state:** running trip (`_fahrt_start_ts`) and fuel low-water-mark
(`_tiefststand_pct`) do not survive an HA restart. Deliberately deferred hardening.
- **Inactive features:** `UPDATE_REPO_URL = ""` (self-update inactive) in
`pyscript/modules/einstellungen.py`. `www/bilder/` has no vehicle photos (all slots show
placeholders); `steuer.faellig` unset. (`BATTERIE_SENSOR` was the same kind of gap until
2026-08-12 — now wired to the FMM003's `external_power_voltage`, see section B/C below. Several
*other* fields are newly inactive since the same date for a different reason — the
`TommiG1/HA_VAG-EU-Data-Act` integration they depended on was dropped: `KM_SENSOR`,
`TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, all door/window/lock sensors, oil-change/
inspection sensors. All are configurable again via the new Setup menu, see section C.)
- **Robustness:** `profil_lesen()` in `modules/profil.py` does not handle a missing/corrupt
`fahrzeugprofil.json` — all callers throw.
- **Tests:** only the receipt parser is tested (`data/tests/test_shell_beleg_parser.py`, 10 real
receipts) — and its test PDFs are gitignored, so a fresh clone can't run it. Backend and
frontend: no tests, no CI.
- **Leaflet via CDN:** trip map needs public internet in addition to the Tailscale tunnel.
### Documentation drift ✅ FIXED 2026-08-11 (kept as a record of what was wrong)
- `homeassistant/README.md:99`, `INSTALL.md:204`, and the header comment
`audi-dashboard-app.js:13-15` claim the statistics view shows sample numbers — **false**;
`vStat()` computes real values from `TRIPS`/`FILLS`.
- `homeassistant/README.md:110` mentions the removed 97% full-tank rule (removal documented in
`belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files.
- Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed).
### Audit leftovers — two of three now fixed in the new app
- ✅ Swipe-to-delete without a gesture-free fallback — **fixed in the app**: every list row also
carries an always-visible "…" menu (`companion-app/src/screens/Zeilenmenue.tsx`). Still open in
the old panel, which is being replaced anyway.
- ✅ Leaflet from a CDN — **fixed in the app**: bundled from node_modules as a lazy chunk.
- 🟡 Popup close-by-tap-outside in the old panel: still unchecked (the app uses the library's
Popup, which is keyboard-reachable).
- Audit's own note: these three may be better done in DataMetric360 than retrofitted — the panel
gets replaced anyway.
### companion-app / design-system ✅ RESOLVED 2026-08-11
- **Not wired together** — fixed: an npm workspace in the repo root links `@audi-dash/ui` into the
app as a real dependency.
- Both packages: no `node_modules`, no `dist` — smoke tests need `npm install` first
(design-system additionally `npm run build`; `scripts/smoke.mjs` imports from `../dist/`).
- No unit tests, no Storybook (substitute: SSR smoke over 21 cases in design-system).
- `design/` export (2026-08-11) has two known, already-commissioned fixes not yet in the export:
shows **RS 6 instead of RS 4**, and **16 sub-pages** the HA panel already has are missing
(list in `design/README.md`).
---
### Found while building (2026-08-11) — all fixed
Three defects that only surfaced by running against a real Home Assistant, not by reading code:
1. **The data layer declared field names the backend never sends.** `Fahrzeugstatus` had
`tank_prozent`/`sicher_abgestellt`/`sicherheit`; the backend writes
`tankprozent`/`gesichert`/`sicherheitscheck`. Every screen would have read `undefined` without
anything failing. `technik`/`ausstattung` were typed as objects but are arrays.
2. **The profile adapter handed out live references into the raw profile.** Editing a form would
have silently mutated the baseline and broken the promise never to overwrite the
backend-maintained tire odometer. Sections are copied now.
3. **`design-system` still carried the pre-audit `--fg3: #657081`** (3.0:1 on `--tile`, fails WCAG
AA) that the panel had already fixed to `#8a94a3`. The new app would have inherited a
already-solved contrast defect.
Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is
what the smoke scripts run on — rewritten as plain fields.
Three more that only screenshots revealed — nothing failed, the pixels were simply wrong:
4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's
automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed
the block to zero height and took model name, badge and plate with it. Fixed with
`.dm-inhalt > * { flex: none }`.
5. **The model name appeared twice** — once as the badge image, once as text beside it.
6. **Filenames overflowed the gallery thumbnails.**
And one in the backend, found by watching the log against a current Home Assistant: the odometer
screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip
stayed without a distance, visible only as a warning. Now runs through `task.executor`.
**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA
2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served
from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the
native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not
apply. Verify this again at commissioning if the app ever moves to a separate hostname.
**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239
of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a
vendored MIT library served from Home Assistant itself, which satisfies the actual requirement
(no network call, token never leaves the local network) and round-trips correctly.
## Pending: this branch has diverged from `main` (noted 2026-08-13)
`ha_install.md` (root) planned the move from pyscript to a **native HA integration** with a config
flow — UI-only setup, no YAML. **That move happened on 2026-08-23; see section H.** The plan
document is now historical: it still argued for a self-reporting `UpdateEntity` against the Gitea
repo because HACS is GitHub-only, and that part was dropped — the built-in updater was removed
rather than reimplemented, and updates go through HACS (or the install script where HACS cannot
reach the repo). All APIs in it were verified against the running 2026.8.1 instance. It also
records which review findings that move eliminates by design.
A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the
three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole
mapping; "reset" has no effect on 15 of 17 fields; all four list positions get the same sensor,
which makes "securely parked" report safe while three doors were never checked).
`main` has moved 18 commits ahead of `umsetzung-datametric360` (FMM003 switch, sensor-mapping setup
menu, iOS/large-screen overlay from Claude Design). **Deliberate decision: do not merge yet** — the
owner keeps working on `main` first.
Three files conflict (`AGENTS.md`, `homeassistant/INSTALL.md`,
`homeassistant/pyscript/fahrterkennung.py`); four more are touched by both sides but merge cleanly.
**One hazard that a clean merge will not catch.** This branch changed `profil_lesen()` in
`homeassistant/pyscript/modules/profil.py` to return `None` when the profile file is missing or
corrupt, and guarded all seven call sites that existed here. `profil.py` is untouched on `main`, so
it merges silently — but `main`'s call sites (`fahrterkennung.py`, `tankerkennung.py`,
`reifenzaehler.py`, `modules/frontend_veroeffentlichung.py`) do **not** guard against `None` and
would raise `AttributeError` on a missing profile instead of logging a clear error.
When merging: take `main`'s FMM003 version of `fahrterkennung.py`, then re-apply the `None` guard to
every remaining `profil.profil_lesen()` call site.
## Open items
Execution order, exact steps, and acceptance criteria for every item below live in
`UMSETZUNGSPLAN.md` (phases 113). Additional decisions of 2026-08-11: **no Electron** (Capacitor
wraps the web app for iPhone; a PWA home-screen install is the accepted intermediate step) and
**no separate backend** (the app talks to the HA REST/WebSocket API directly).
### A) Build DataMetric360 (the big block)
- [x] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
then re-export to `design/`
- [x] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
dependency (possibly add a workspace/monorepo root)
- [x] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
- [x] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/`
**never** into `design-system/`
- [x] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
`ablageSetzen()` hook already exists)
- [x] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
locally under HA `/local/`, architecture §3)
- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
against `audi_ha_test` with a real token
- [x] Offline UX per design brief (offline marker, visible pending queue)
- [ ] **QR-Code-Scanner fürs Token-Onboarding (angefragt 2026-08-28, noch nicht geplant/gebaut)**
2026-08-11 bewusst weggelassen ("nur wenn es einfach bleibt"), der Owner will das jetzt doch als
Alternative zum manuellen Einfügen.
**Umfang halbiert am 2026-09-01:** Home Assistant zeigt den Token beim Anlegen bereits selbst
als QR-Code an. Die geplante eigene Seite unter `/local/dm360-qr.html` entfällt damit ersatzlos —
gebraucht wird nur noch die **Scanner-Seite in der App** (Barcode-Plugin
`@capacitor-mlkit/barcode-scanning` + Kamera-Berechtigung). Offen bleibt eine Frage, die vor dem
Bauen zu klären ist: HAs eigener QR trägt nur den Token, nicht die Instanz-URL — die muss also
weiterhin von Hand kommen oder aus dem Netz gefunden werden.
### B) Infrastructure / commissioning
> **Aufgeräumt am 2026-09-01.** Vier Einträge sind hier ersatzlos entfallen, weil der
> MQTT-/Mosquitto-Weg seit dem Wechsel auf flespi (2026-08-13) nicht mehr existiert: FMM003
> verkabeln, Broker-Erreichbarkeit, MQTT Client Type, erste Codec-JSON aufzeichnen. Das Gerät
> läuft seit dem 13.08. produktiv über flespi; die Feldzuordnung steht und ist gemessen (siehe
> Abschnitt AU).
- [x] Switch `datametric360.de` nameservers at all-inkl to Cloudflare ("full setup") — **erledigt,
vom Eigentümer bestätigt am 2026-09-01: Domain liegt bei Cloudflare, Nameserver stehen, die
iOS-App ist darüber angebunden.**
prerequisite for the tunnel; domain carries nothing else, so this is consequence-free. Step-by-
step runbook for this and everything below it now exists:
[`homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md`](homeassistant/INTERNET_ZUGRIFF_EINRICHTEN.md)
(written 2026-08-28) — account creation, DNS/nameserver changes, and clicking through the HA
add-on UI all remain the owner's own action; nothing here can be done unattended.
- [x] Decide hostname split — **resolved 2026-08-28: no split, one hostname is enough.** The original
app-domain-vs-API-subdomain question assumed a served web build; distribution stayed native/
sideload only (never built as a web app), so `https://datametric360.de` alone covers both API
calls and the app's own OTA bundle. See `COMPANION_APP_ARCHITECTURE.md` §5 item 4.
- [x] Choose reverse proxy — **decided 2026-08-28: Nginx Proxy Manager**, not Traefik. See
`COMPANION_APP_ARCHITECTURE.md` §5 item 3.
- [x] Define the reverse-proxy path allowlist (depends on final entity/service names) — kept current
2026-08-28: corrected the stale `/local/dm360/*` assumption (never built) to the app's real
remote need, its OTA bundle at `/audi_dashboard_static/app/*` (`@capgo/capacitor-updater`). See
`homeassistant/REVERSE_PROXY.md`.
- [x] Generate TLS certificates for Mosquitto + device (small private CA) — done 2026-08-11, 10-year
validity; Mosquitto configured (`certfile`/`keyfile`/`cafile`/`require_certificate: true`).
**Gegenstandslos seit dem Wechsel auf flespi (2026-08-13):** das Gerät spricht dort seinen
nativen Codec-8-Kanal über TCP, ohne Zertifikate. Der Mosquitto-Aufbau bleibt nur als
Bauanleitung stehen, falls je ein eigener Broker zurückkommt.
- [x] Move trip detection to FMM003 ignition — done 2026-08-12. `fahrterkennung.py` rewritten:
trigger is now `einstellungen.ZUENDUNG_SENSOR`
(`binary_sensor.testzone_fmm003_engine_ignition_or_acc_status`, on = trip running) instead
of the iPhone WLAN sensor; same `task.unique()` pause-tolerance pattern kept unchanged.
`sensor.iphone_wifi_connection` and the `fahrzeug.wlan_name` profile field are fully removed
(backend and frontend). At the same time, all entity IDs sourced from the now-abandoned
`TommiG1/HA_VAG-EU-Data-Act` HACS integration were blanked in `einstellungen.py`
(`KM_SENSOR`, `TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, door/window/lock sensors,
oil-change/inspection sensors) — the consuming features (Übersicht tiles, Reifenzähler,
automatic Tankerkennung, Fahrtabschluss-Screening) are untouched and degrade gracefully to
"unbekannt"/inactive per the existing `zustand_oder_none()` convention, rather than being
deleted; `fahrtabschluss.py`/`tankerkennung.py`/`reifenzaehler.py` now guard their
`@state_trigger(f"{einstellungen.KM_SENSOR}")`-style decorators with
`if einstellungen.KM_SENSOR:` (untested previously whether pyscript tolerates an empty
trigger string — not worth relying on). `STANDORT_TRACKER` now points at the real
`device_tracker.testzone_fmm003` and `BATTERIE_SENSOR` at
`sensor.testzone_fmm003_external_power_voltage` (the vehicle's 12V bus voltage as measured
by the tracker — NOT `sensor.testzone_fmm003_battery_voltage`, which is the tracker's own
internal backup cell and unrelated to the car). Real entity list for this device: the user's
`Entiaeten.csv` export (OneDrive, not in this repo). Verified in `audi_ha_test`: pyscript
reload (`pyscript.reload`) produced no errors/warnings for any of the changed files; the
Übersicht correctly shows "Status unbekannt" and em-dash placeholders for the now-blank VAG
fields instead of crashing (that container itself has no `device_tracker.testzone_fmm003`
registered, so Standort still shows "Kein GPS-Signal" there — expected, the real HA instance
has it). `INSTALL.md` step 4 and its troubleshooting table have since been rewritten
(2026-08-12) to point at the Setup menu instead of manual `einstellungen.py` edits, and to
use the current FMM003 field names (`ZUENDUNG_SENSOR`/`STANDORT_TRACKER`/`BATTERIE_SENSOR`)
instead of `WLAN_SENSOR`/TommiG1 — not live-tested against a real install (INSTALL.md itself
was never run end-to-end in this project, per its own opening note), but consistent with the
actual code.
### C) Maintain the existing HA panel (low priority — being replaced)
- [x] Wide-screen/desktop layout (`@container (min-width:860px)` in `audi-dashboard-ios.css`,
turns the same `.tabbar` into a 264px side-nav via CSS only, no JS/markup duplication) — done
2026-08-11 from the Claude Design "DataMetric360 Board" draft (`claude.ai/design` project
`c28a8d4d-ec4e-4178-9e49-ab5b90c02097`). Found and fixed a real regression while verifying it
live in `audi_ha_test`: the overlay's `:host > div{height:100%}` (meant to drop the old
412px-cap for the sidebar layout) was applied unconditionally instead of only inside the
`@container` block, which broke the **normal mobile layout too** — HA doesn't reliably
propagate a real height down through `panel_custom`, so `height:100%` collapsed `main#view` to
~0px (tabbar rendered right under the header, content invisible). Fix: kept the outer wrapper
viewport-anchored (`min(880px, calc(100dvh - 24px))`, matching `audi-dashboard.css`'s original
reasoning) at all widths, and only drop the 880px cap (`calc(100dvh - 24px)`, still
viewport-anchored, not parent-relative) inside `@container (min-width:860px)`. Verified live at
both narrow (mobile tab bar, full content) and wide (1144px container, grid side-nav,
navigation clicks, settings back-arrow) — screenshots taken, no regressions found.
- [x] Desktop-Sidebar-Markenklon (`.navmarke`) nachgezogen — done 2026-08-12. The user compared the
deployed app against the live Claude Design "Board" (the two-viewport comparison page, which
genuinely iframes this project's own `audi-dashboard-app.js`/`-ios.css`, not a static mockup)
and asked why the desktop Audi-rings placement differed: Design showed the rings pinned above
the sidebar nav list, ours showed them in the content header next to the title. Root cause,
confirmed via `DesignSync get_file` + byte-diff: `audi-dashboard-app.js` was already fully in
sync (identical), but `audi-dashboard-ios.css` had drifted — the Design project had since
added a `.navmarke` sidebar clone of the rings (only visible in the `@container
(min-width:860px)` block; `.rings{display:none!important}` hides the header copy there so the
mark appears once) plus a filled-pill active-tab style (`.tabpille`), neither pulled back
locally. Diffed the two full files first to confirm every change was additive/CSS-only, then
replaced the local file wholesale. Verified in `audi_ha_test` at both 402px and 1400px: rings
now sit above the nav list on desktop, gear icon alone in the content header, mobile view
unaffected, no console errors beyond the pre-existing missing-vehicle-photo 404 gap.
- [x] Standort-Kachel (live vehicle GPS position on the Übersicht, above "Zuletzt") — done
2026-08-12. New tile with a real (non-fake) Leaflet mini-map, opens a fullscreen "Standort"
route on tap; fullscreen has 4 floating controls (map style, center-on-vehicle,
center-on-user, fit-both) and a draggable myAudi-style bottom sheet (peek/expand/close by
drag or tap, opens on vehicle-marker click) showing distance-to-user, address (client-side
reverse geocoding via the public Nominatim API — same "public API, no key, low single-
vehicle volume" reasoning as the existing Leaflet-CDN gap below), fährt/steht/"Letzter
Parkplatz" state, fuel/range, and Route (Google Maps deep link) / Teilen (Web Share API)
actions. Backend: new `STANDORT_TRACKER` entity-ID setting in `einstellungen.py` (device_
tracker with lat/lon attributes), published via a new `_standort()` helper in
`frontend_veroeffentlichung.py`. The built-in default points at the author's test device
(`device_tracker.testzone_fmm003`); until the FMM003 actually delivers positions (section B),
that entity does not exist and the tile shows "Kein GPS-Signal vom Fahrzeug". Verified end-to-end in `audi_ha_test` with a manually
created `device_tracker.test_fahrzeug` test entity (tile, fullscreen map, and menu all
confirmed rendering/populating correctly); the device's own location (browser Geolocation
API) and the Route/Teilen deep links work independently of the vehicle-GPS gap. Pushed to
the Claude Design project (`c28a8d4d-ec4e-4178-9e49-ab5b90c02097`, "DataMetric360 Board")
for visual refinement — the JS/CSS ship functionally complete but visually plain
(placeholder vehicle-marker glyph, default `.aktion`/`.tile` styling); Claude Design's job is
polish, not structure. One known rough edge: reverse-geocoded address didn't resolve within
a few seconds in the `audi_ha_test` container (no crash, stays on "Adresse wird ermittelt …"
indefinitely) — likely just that container's outbound network to Nominatim specifically;
unconfirmed whether this reproduces on the real HA instance.
- [x] Setup menu (Einstellungen → Fahrzeug einrichten → last item when expanded) — done
2026-08-12. Lets the user map every sensor role the app uses (~18 roles, 3 of them 4-item
door/window/lock lists) to real HA entities from a searchable dropdown, instead of hand-
editing `einstellungen.py`. New backend module `pyscript/modules/entitaeten.py`: a
`FELDER` catalog (label, hint, expected domain/device_class/unit, keywords, whether it's
one of the trigger-bound fields) plus JSON override I/O (`data/entitaeten.json`, same
atomic-write pattern as `profil.py`) and `overrides_anwenden()`, which does
`setattr(einstellungen, key, value)` on the already-imported `einstellungen` module — every
existing consumer does `import einstellungen` + live attribute access (verified: none use
`from einstellungen import X`), so this needs no changes to any consumer file. New service
`audi_dashboard_entitaeten_schreiben` (`frontend_api.py`) and publication
`entitaeten_veroeffentlichen()` (`frontend_veroeffentlichung.py`) follow the project's
existing read-via-state/write-via-service pattern. Frontend: new popup (`vSetupPopup()`,
modeled on the existing `.sheet` pattern) with a from-scratch searchable-combobox component
(none existed in the repo before) — client-side only, built directly from `HASS.states`, no
new backend read service; a global "Nur passende Sensoren anzeigen" switch hard-filters
candidates by domain/unit, a keyword+domain+unit score ranks and auto-suggests for empty
fields. Deliberately does NOT auto-apply changes for the three trigger-bound fields
(`ZUENDUNG_SENSOR`/`KM_SENSOR`/`TANK_SENSOR``@state_trigger` bakes the entity ID in at
module-load time) — the UI shows an inline "wirkt erst nach Neustart" hint instead of
attempting a risky self-`pyscript.reload()` from inside a running service call. Two real
bugs found and fixed during live verification, both now also pushed to the Claude Design
project: (1) a full popup re-render on every keystroke fought the search input for focus —
fixed by patching just the candidate-list DOM node on `input`, matching the same fix the
existing `data-tankpreisfeld` handler already uses for the identical reason; (2) the popup
background used `--tile-2`, which Design had since changed to a translucent
`rgba(255,255,255,.1)` in the night theme (for the frosted-glass tile look elsewhere) — made
the whole popup partially see-through against the app content behind it. Fixed by switching
to `--tile-deckend` (Design's own opaque-surface token, already used by `.standortmenu` for
the same reason), with `var(--canvas)` as a defensive fallback.
- [x] Fresh-install packaging + `update.ps1` fix — done 2026-08-12. While preparing a short
quick-install checklist, found that `update.ps1` never copied `audi-dashboard-ios.css` or
`www/badges/*` — both are loaded by the running app (the ios overlay is injected by the app
itself via JS, badges by the model picker since item #51), so every `update.ps1` run since
those were added silently left the deployed copy stale on those two. Fixed (script now copies
both). Also removed two stray `*.bak-before-audit-merge` files from `www/` (already gitignored
at repo root, never committed, just local clutter from an earlier merge). New
`installationspaket/` — a generated, gitignored bundle (pyscript/, www/ minus the two backup
files, data/ with only the example profile + receipt parser, `configuration_snippet.yaml`,
`update.ps1`) plus a short `ANLEITUNG.md` checklist for copying to a fresh HA instance;
`INSTALL.md` stays the authoritative, detailed reference. Regenerate on demand, don't keep it
permanently in sync — it's a deployment snapshot, not a second source of truth.
- [x] Setup-menu polish: 5 additions on top of the existing Setup popup — done 2026-08-12.
Backend (`pyscript/modules/entitaeten.py`): a `_STANDARDWERTE` snapshot taken at module-load
time (before `overrides_anwenden()` ever runs), exposed via `aktueller_stand()` as
`standardwerte` — needed because `setattr()` on the live `einstellungen` module is permanent
for the process, so "reset to default" has to write the original value back explicitly rather
than just omitting the override. New service `audi_dashboard_neustart` (`frontend_api.py`)
wrapping `homeassistant.restart()`, called only from an explicit user-confirmed button, never
automatically. Frontend (`audi-dashboard-app.js`): (1) live current-value preview next to each
candidate in the search dropdown (`entitaetZeilenMarkup`, reads `HASS.states[e.id].state`);
(2) inline warning under a field if its currently assigned entity is `unavailable`/`unknown`/
missing (`entitaetStatusWarnung`); (3) duplicate-assignment check on save
(`setupDuplikate`) — blocks with a confirm-sheet ("trotzdem speichern?") rather than silently
allowing the same entity in two roles; (4) per-field reset button (only shown when the current
value differs from `standardwerte`), reverts to the built-in default; (5) after saving, if any
of the 3 trigger-bound fields actually changed (`setupGeaenderteTriggerFelder`, compares
against a snapshot taken when the popup opened), a confirm-sheet offers "Jetzt neu starten"
calling the new service. Verified live in `audi_ha_test`: value preview and unavailable-warning
confirmed correct (`ZUENDUNG_SENSOR`'s configured entity genuinely doesn't exist in this test
container → "Entität nicht gefunden." shown as designed); duplicate-check correctly caught both
a deliberately-forced KM/TANK collision and pre-existing door/window/lock auto-suggestion
collisions (a real side effect of this container's limited matching entities, not a bug);
reset button appeared/disappeared correctly as values diverged from/matched the default;
save round-tripped through the real service into `data/entitaeten.json` (byte-inspected) and
into the published `pyscript.audi_dashboard_fahrzeugstatus` state; restart-needed sheet named
exactly the changed trigger-bound field(s) and no others. Did not actually trigger a restart
during verification (would have restarted the shared test container) — the service call itself
was left unexercised beyond confirming it registers without error at pyscript load time.
- [x] Decide the iOS-vs-Audi-CI conflict from `DESIGN_REVIEW_2026-08-13.md` §A — decided
2026-08-13, owner's call: **iOS look made official** (Option 1 of the two the review offered).
`SPECIFICATION.md` §2 rewritten to describe the iOS values (colors, 16px radius) as canonical;
`bauauftrag.md` §8 now counts as superseded (left untouched, per this project's convention of
never editing that file — see its own "historical only" note above). The pre-decision Audi-CI
spec text is preserved verbatim in [`AUDI_CI_ARCHIV_2026-08-13.md`](AUDI_CI_ARCHIV_2026-08-13.md),
which also documents *why reverting later needs no restore*: `audi-dashboard-ios.css` is a
purely additive overlay loaded after `audi-dashboard.css` in `_aufbauen()`
(`audi-dashboard-app.js` ~line 3990) — the Audi-CI base values are still fully intact in the
unmodified base stylesheet, untouched by this decision. Reverting is just: stop loading the
overlay `<link>`.
- [x] Fix the two implementation bugs from the same review, independent of the direction decision
above — done 2026-08-13. **§B, red-semantics inversion:** `audi-dashboard-ios.css` `.aktion`
no longer fills every action red; base `.aktion` is now a neutral `--ios-fill` secondary
button, a new `.aktion.primaer` rule is the only one filled with `--ios-tint` (red), matching
the `.aktion`/`.primaer`/`.loeschen` semantics the base stylesheet already defined (outline
secondary / filled primary / destructive). Also fixed as part of the same finding: switch
on-state now uses `--ok` (iOS system green) instead of red; the desktop-sidebar active-tab
background (`.tab.on`) is now the neutral `--ios-fill` instead of a red tint, red stays only
as the accent text/icon color; two chart-bar segments in `audi-dashboard-app.js` that used
`--red` for a neutral data category ("Arbeitsweg" trips, first segment of the insurance
contribution bar) now use `--fg` like their sibling segments. **Radius inconsistency:**
`.setup-popup` and `.standortmenu` in `audi-dashboard.css` now reference `var(--r-tile)`
instead of a hardcoded `20px` literal, so both track whichever design (iOS 16px or, if
reverted, Audi-CI 20px) is actually active. Verified live in `audi_ha_test`: Setup-popup
Abbrechen/Speichern and the "Einrichten"/"Setup"/"Fertig" buttons in Einstellungen render with
the corrected fills at both mobile and desktop widths, switches render green, no console
errors.
- [x] Clean up remaining "EU Data Act" residue from the FMM003 pivot (2026-08-13) — the
Einstellungen "Version" tile in `audi-dashboard-app.js` hardcoded `Fahrzeugdaten: EU Data
Act / Abruf alle 15 Minuten` and `Position: iPhone / Companion App`, both stale: vehicle data
and position now come exclusively from the FMM003 (`ZUENDUNG_SENSOR`, `BATTERIE_SENSOR`,
`STANDORT_TRACKER` in `einstellungen.py`), not the abandoned HACS integration or the iPhone.
Both rows now read `FMM003`. Also fixed `testumgebung/konfiguration.yaml` +
`testumgebung/README.md`: the `input_text.test_wlan` / `iPhone WiFi Connection` template
sensor simulated WLAN-based trip detection, fully removed 2026-08-12 (`fahrzeug.wlan_ssid` no
longer exists anywhere) — its "Fahrt auslösen" example was non-functional. Replaced with
`input_boolean.test_zuendung` driving a `binary_sensor.testzone_fmm003_engine_ignition_or_acc_status`
template sensor matching the real `ZUENDUNG_SENSOR` entity ID, so trip detection stays
testable. The remaining KM/Tank/Range/door/window/lock template sensors correctly still
describe the abandoned `TommiG1/HA_VAG-EU-Data-Act` integration (per `einstellungen.py`) and
were left in place — those fields are blank by default but stay manually mappable via the
Setup menu, so the fixtures remain useful for testing that path; comments updated to make
this distinction explicit.
- [x] Fix oversized toggle switches ("Slider") across the app (2026-08-13) — `.feld label` in
`audi-dashboard.css` (`flex: 1 1 auto`, meant to let the *text* label grow so the control
lands at the row's right edge) unintentionally also matched `<label class="switch">`, since
the toggle switch is itself implemented as a `<label>` — its own `.switch { flex-shrink: 0;
width: 46px }` rule lost because `.feld label` has equal-or-higher specificity and comes
later in the file. Every switch inside a `.feld` (Einstellungen, Tanken, Reifen, ...)
stretched to fill the row (~260-300px measured live, instead of 46-51px). Fixed by excluding
switches from the selector: `.feld label:not(.switch)`. Verified live in `audi_ha_test` at
mobile (375px) and desktop widths — switches render at the correct ~51px iOS size, no
console errors.
- [x] Fix desktop (≥860px container) layout audit findings (2026-08-13, see
`DESIGN_AUDIT_2026-08-13.md` for full detail) — user-reported: (1) the settings gear button
sat far past the right edge of the visible content column on wide screens because `.topbar`
(grid-column:2, same track as `main#view`) had no `max-width:860px` while `main#view` did —
the track itself is `minmax(0,1fr)` and stretches to the full remaining window width, so
`.head{flex:1}` pushed `.profilbtn` to the track's far edge, ~450px past where `main#view`
visually ends. Fixed by giving `.topbar`/`.phone:has(.back.on) .topbar`/`.ptr` the same
`max-width:860px`. (2) In phone width the Audi rings (`#marke`/`.rings`) are, per existing
code comment, the *only* access to Einstellungen (the gear icon is hidden there) but were
42×24px — well under the 44px minimum touch target every sibling icon-button uses. Bumped to
58×32px. (3) Found while verifying (1): `.setup-popup`, `.sdpopup`, `.sheet`, and
`.standortmenu` are all `position:absolute` with `left`/`right` relative to the *full*
`.phone` box (sidebar included), not the content column, and had no desktop max-width either
`.setup-popup` measured 1644px wide on a 1920px window (10px margins only). Fixed with
`max-width:560px` (`.setup-popup`/`.sdpopup`/`.sheet`) or `640px` (`.standortmenu`) plus
`margin-left/right:auto` in the same container query — deliberately not
`transform:translateX(-50%)`, which would fight these elements' own transform-based entry
animations. Centering is relative to the full `.phone` width (sidebar included), not the
860px content column exactly — a ~138px rightward offset from true content-center remains at
1920px; fixing that precisely would require moving these overlays into grid-column 2 in the
markup, deferred as a larger structural change. All three verified live in `audi_ha_test` at
375px and 1920px, no console errors.
- [x] Fix 4 more user-reported design findings (2026-08-13, same session, see
`DESIGN_AUDIT_2026-08-13.md` "Runde 2" for full detail with before/after measurements):
(1) page title sat ~30px right of the content tiles below it on wide screens — `.back`
reserves 44px+gap in `.topbar` even when inactive (`opacity:0`, not `display:none`), fixed
with `.back:not(.on){width:0;...}` in the desktop container query, root pages only (detail
pages with `.back.on` unaffected, verified back-navigation still works). (2) the chevron on
the 5 `.go`-tiles (Fahrzeug/Service/Versicherung/Reifen/Schutzbrief on Mein Audi/Versicherung)
was vertically centered on the *entire* multi-row tile (`top:50%` in the iOS overlay) instead
of next to the title, overlapping wrapped row text — removed the override, falls back to the
base `top:var(--sp-5)` next-to-title position. (3) the Ölwechsel-Intervall `<select>` values
("10.000 km"/"1 Jahr") looked like inert text — iOS overlay strips border/background from
`.feld select` and colors it the same grey as any other secondary text; added
`.feld select{color:var(--ios-tint)}` so picker fields read as editable the way iOS
conventionally signals it (accent color), free-text `.feld input`s deliberately left alone.
(4) the expand/collapse `.mark` chevron on group headers (Fahrten "2026"/"August") looked
like a checkmark — it uses the classic "2 borders + rotate(45deg)" CSS chevron technique,
which requires a square box; `.mark` was 6×10px (non-square, sized for the unrelated SVG-path
`.chev`/`.go` icons), made it 8×8px. Two more reported findings ("grey box around all menu
icons", "red partial frame on Fahrten/Tanken rows") were investigated (computed-style
diffing across all 5 tabs in both themes, a temporary 2.5x `transform:scale()` zoom on the
live swipe-row DOM) but could not be reproduced — documented in the audit file as open,
pending a screenshot from the real device.
- [x] Fix five user-reported bugs (2026-08-16):
(1) **Türschloss-/Haubenschloss-Erkennung entfernt** — user asked for these two checks to be
permanently removed (not just left blank/unmapped like the rest of the retired VAG-
integration fields). Removed `TUERSCHLOSS_SENSOREN`/`HAUBENSCHLOSS_SENSOR` from
`entitaeten.py`'s `FELDER` catalog (no longer offered in the Setup menu) and from
`einstellungen.py` (attributes deleted, not just blanked), and removed their entries from
`_sicherheitscheck()` in `frontend_veroeffentlichung.py` (no longer listed under "Geprüfte
Punkte"). `HECKKLAPPENSCHLOSS_SENSOR` (tailgate lock) was deliberately left untouched — user
named only the two door/hood locks. Also removed the now-orphaned
`input_boolean.test_entriegelt` helper and the four `test_audi_lock_*` template sensors from
`testumgebung/konfiguration.yaml` (door-open/window fixtures for `TUER_SENSOREN`/
`FENSTER_SENSOREN` are untouched), and updated `testumgebung/README.md` accordingly.
(2) **"Doppelt zugeordnet" (and every other confirm-sheet) was see-through** — `.sheet-gruppe`/
`.sheet-abbrechen` in `audi-dashboard.css` used `--tile-2`, which the iOS overlay's night
theme redefines as `rgba(255,255,255,.10)` (translucent, meant for tile-on-tile layering) —
the same root cause already fixed for `.setup-popup`/`.standortmenu` on 2026-08-13, just not
carried over to the generic confirm-sheet used by `bestaetigen()`/`hinweis()`. Switched both
to `var(--tile-deckend, var(--canvas))`, matching the existing fallback pattern already used
elsewhere in this file (base CSS must not go blank if the purely-additive iOS overlay is ever
reverted, since `--tile-deckend` is an overlay-only token).
(3) **FMM003 coordinates arrive as two separate sensors, not one device_tracker** — user
reported "coordinates are split into latitude and longitude". Root cause: `_standort()` in
`frontend_veroeffentlichung.py` only ever read `latitude`/`longitude` as *attributes of a
single `STANDORT_TRACKER` device_tracker entity* (HA's usual convention) — but some
integrations (confirmed live in `audi_ha_test`'s own simulated FMM003 device, which exposes
separate "Latitude coordinate value"/"Longitude coordinate value" sensors) publish them as
two independent `sensor` entities instead, which `_standort()` had no way to consume. Added
`STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR` (new optional fields in `einstellungen.py` +
`entitaeten.py`'s Setup-menu catalog, group "standort") as a second path: `_standort()` now
tries `STANDORT_TRACKER` first (unchanged behavior when it works), and falls back to reading
the two plain sensors' states directly when the tracker is unset or has no coordinates.
(4) **App can show only "Lädt …" until a tab is clicked** — known, already-mitigated race
(see the `nachladeAnstossen` header comment, `audi-dashboard-app.js`): on a slow/cold backend
start, `render()` no-ops until `DATEN_GELADEN` flips true, and the previously observed
manual fix was a HA panel remount. Root cause of *why* the click helps was not fully
reproducible in `audi_ha_test` (this is a genuine uncertainty, flagged rather than guessed
around) — but as a safe, low-risk hardening, `go()` (the tab/menu click handler) now calls
`datenLaden(false)` proactively if `DATEN_GELADEN` is still false, so any click that happens
to occur after data has actually become available is guaranteed to pick it up immediately
instead of depending on timing.
(5) **Fuel-receipt parser only understood Shell's exact layout** — user asked for a generic
algorithm for other/unknown fuel stations: find the address, find the total ("Gesamt"/
"Absolut"), find the liters ("Menge"/"Amount"), divide to get the paid price/liter, compare
against a printed "Preis/Liter" — a mismatch means a discount, found on the receipt as a
minus-marked amount. Implemented exactly this as `_parsen_generisch()` in
`homeassistant/data/shell_beleg_parser.py`, wired as a fallback in `main()` (Shell-specific
`_parsen()` tried first, unchanged — verified the existing 10-receipt regression suite still
passes byte-for-byte; `_parsen_generisch()` verified against a synthetic non-Shell receipt
with a known discount, all fields and the discount math correct). No separate "SmartDeal"
flag was needed — the frontend already treats any populated `discount` as SmartDeal-eligible
(gated only by the user's own "SmartDeal aktiv" switch, not by station name), so a generically
parsed Shell receipt behaves identically to one parsed via the strict path. Receipt-key
generation uses `hashlib.sha1` (not the built-in `hash()`, which is randomly salted per
process and would have broken the §7.7 duplicate-detection dedup across the parser's
per-receipt subprocess invocations).
All five deployed and verified live in `audi_ha_test` (version `1786752000`): Sicherheit-
Liste and Setup-Katalog confirmed to no longer offer Türschloss/Haubenschloss; the new
Breitengrad/Längengrad Setup fields confirmed present and correctly auto-suggesting the
container's real split lat/lon sensors; "Doppelt zugeordnet" confirmed rendering on an opaque
card by deliberately triggering it (this container's limited entity set causes a real
duplicate auto-suggestion); pyscript reload clean, no new console errors beyond the
pre-existing placeholder-image 404s. Synced to `installationspaket/`.
- [x] Follow-up round the same day (2026-08-16), three more items:
(1) **Removed `STANDORT_TRACKER` entirely** — user confirmed the combined device_tracker
field is no longer needed (flespi only ever provides the split lat/lon sensors added earlier
that day). Deleted the field from `einstellungen.py` and `entitaeten.py`'s Setup catalog, and
simplified `_standort()` in `frontend_veroeffentlichung.py` down to the single split-sensor
path (no more two-branch fallback). `overrides_schreiben()`'s existing "only known keys
survive" behavior (see its docstring) meant no explicit migration was needed for stale
`STANDORT_TRACKER` entries already sitting in `data/entitaeten.json` — confirmed live: this
container's own leftover override file still had `STANDORT_TRACKER` from earlier testing
(plus stale `TUERSCHLOSS_SENSOREN`/`HAUBENSCHLOSS_SENSOR` from before finding (1) of the
previous entry), all silently dropped on the next Setup save, which is exactly why the GPS
tile was still showing "Kein GPS-Signal" after the user's own remap attempt earlier — that
attempt happened to save while the old combined field was still in the catalog, so nothing
ever actually persisted `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`. Redid the Setup save
live (mapped both to `sensor.testcar_b9_fmm003_testintegratoin_lat/longitude_coordinate_value`)
and confirmed the Übersicht/fullscreen Standort map switched from the "no signal" placeholder
to a real rendered map at the correct coordinates (South Tyrol terrain, "Burgleralm" label
visible).
(2) **Fuel-receipt "silently does nothing", root cause found** — reproduced the exact
real upload flow (button click → real `<input type=file>` → synthetic `File`/`DataTransfer`
dispatch → `FileReader``hass.callService`) via browser instrumentation: the happy path
works completely (backend parses, publishes `pyscript.audi_dashboard_beleg_ergebnis`, frontend
fills the form) — `belegverarbeitung.py`'s existing `try/except` around `_parser_aufrufen()`
already publishes a visible `fehler` for parser failures specifically. The actual gap:
`base64.b64decode()` and `_pdf_speichern()` in `audi_dashboard_beleg_hochladen()` were
*outside* that try block — any failure there (corrupted upload, disk full, ...) raised an
unhandled exception with zero UI feedback, since `serviceRufen()`'s generic `.catch()` only
does `console.error()`. Widened the try block to cover all three steps. Also stopped using
`serviceRufen()` for this one call site specifically (`audi-dashboard-app.js`, the
`eingabe.onchange` handler) — it now calls `HASS.callService()` directly with its own
`.catch()` that sets `belegFehler` and re-renders, so a rejected service call (network,
timeout — anything that bypasses the backend's own state-publish) is visible too, not just
logged. Verified against the real 10-receipt Shell suite (still passing) both before and
after this change.
(3) **Vehicle map marker replaced with a two-tone pin+car icon** — added `CI.pinCar` (a
balloon/pin path + white badge circle + a simplified car-front glyph built from rects, not
circles, after an isolated-DOM-injection test round showed a circles-for-headlights version
read as a face/animal rather than a car) to `audi-dashboard-app.js`, replacing the single-color
`poiCar` teardrop previously used by `fahrzeugMarkerSVG()`. Updated both marker call sites'
`iconAnchor` to the new pin's actual tip position and simplified `.fahrzeug-pin` in
`audi-dashboard.css` (no more `color:var(--red)`/white-halo-filter hack, since the new icon
carries its own fixed colors). Verified the icon markup renders correctly via an isolated
DOM-injection test (bypassing Leaflet); could **not** get a final on-map screenshot in
`audi_ha_test` — the Standort map tiles themselves only ever partially load in this container
(reproduced on a fully fresh reload, unrelated to this change), consistent with a previously
documented network limitation of this specific sandbox (see the Standort-Kachel entry above:
reverse-geocoding via Nominatim had the same kind of container-specific network issue).
Structurally verified only; needs a look on a real device/network to confirm final visual
placement.
All three deployed to `audi_ha_test` (version `1786758000`) and synced to `installationspaket/`.
- [x] Third round the same day (2026-08-16) — **the "fragmented Leaflet rendering" defect is solved
at its root**, plus four smaller items:
(1) **Leaflet CSS never reached the shadow root.** `leafletLaden()` appended
`leaflet.min.css` to `document.head`, but the panel renders inside `this.shadowRoot` — and
document stylesheets do not cross a shadow boundary. Inside the panel, `.leaflet-tile
{position:absolute}` and friends therefore never applied: the tiles laid themselves out as
ordinary in-flow `<img>` elements (two per row, ~1040 px of stacked height inside a 190 px
box), so only a thin strip of map was ever visible, and the marker pane ended up ~1009 px
below the visible area — which is exactly why the new vehicle pin appeared "missing". This
also explains the earlier misdiagnosis as a container/network limitation: every tile request
succeeded (`complete:true`, `naturalWidth:512`), the geometry was the problem. Fixed by
injecting the stylesheet into `ROOT` and awaiting it before the map is built; the script tag
stays in `document.head` (it must, `window.L` is global). The CSS injection is deliberately
re-checked on every `leafletLaden()` call rather than guarded by the `window.L` check, because
a HA panel remount produces a fresh shadow root while `window.L` is already set. Verified live:
both the Übersicht preview map and the fullscreen Standort map now fill their containers and
show the pin at the vehicle position.
(2) **Maps are now always light.** `TILES[theme]` switched to CARTO `dark_all` in Nacht mode;
replaced by a single themeless `TILE_URL` (`light_all`), matching how Google Maps & co. keep
the standard road map light regardless of app theme.
(3) **Vehicle marker now uses the real CI `poi-car` icons** (`poi-car-l.svg` / `poi-car-s.svg`
from the delivered icon set) instead of the hand-built two-tone pin from the previous round:
`CI.poiCarL` (48-grid) at ≥34 px, the existing `CI.poiCar` (24-grid) below, anchors recomputed
per variant. Since these are single-color outline forms in `currentColor`, `.fahrzeug-pin`
regained a stacked white drop-shadow halo so the outline stays readable on map tiles.
(4) **Red partial frame on Fahrten/Tanken list rows** (open since the 2026-08-13 design audit,
finding 3) — reproduced and confirmed by recoloring the delete button live. Row heights are
fractional (66.28 px), so `.swipe-content`'s edges miss the device-pixel grid and the red
`.swipe-delete` behind it bled through as a hairline around every row. Fixed by only painting
the delete button while a swipe is actually happening: `.swipe-delete{visibility:hidden}` plus
a `wischt` class set on the first movement of the gesture (and the existing `swiped` class for
the open row). Verified the full gesture still works — button appears at the first pixel of
drag and the row settles open at 84 px.
(5) **Select fields now show the grey background box** (design audit finding 1) — replaced
`.feld select{color:var(--ios-tint)}` with `background:var(--ios-fill);padding:8px 10px`,
matching `.mitEinheit input` ("Pause bis [15] Minuten"), as the user asked. Verified on
Modell / Ölwechsel-Intervall / Bildposition; free text fields stay plain, so the box now
genuinely marks "there is a choice here".
(6) **Generic receipt parser: total and station name** — a real Austrian non-Shell receipt
returned the right litres but 6,00 € and no station. Two causes, both fixed: the free-running
total pattern matched the *column heading* `SUMME-EUR` and read the next line's article number
as the amount, and bon printers letter-space headings (`G E S A M T BETRAG EUR: 92,60`), which
the keyword never matched. Total detection is now line-based — keyword checked against the
whitespace-stripped line, amount must be a real money value (`_GELD`, decimals required), tax
lines (`MWST`/`UST`/`VAT`/`STEUER`/`NETTO`) excluded, largest remaining candidate wins. The
address heuristic no longer demands three fixed lines with a 5-digit postcode (AT uses 4
digits, and this receipt carries `SHELL TANKSTELLE, 6450 SÖLDEN` on one line): it finds the
first postcode line in the header and takes the name from the line above. `Preis/L` without a
spelled-out "Liter" now matches too. Result on that receipt: 42,13 l · 92,60 € · 2,198 €/l ·
no discount · `AUTO B. FRISCHMANN GMBH`. The 10-receipt Shell regression suite still passes.
All deployed to `audi_ha_test` (version `1786765000`) and synced to `installationspaket/`.
- [x] Fourth round the same day (2026-08-16):
(1) **The "grey box around the icons" report is resolved — it was never a defect.** After five
fruitless reproduction attempts across two sessions, the user clarified what they actually
meant: the grey box seen on "10.000 km", "1 Jahr", "15" is the *desired* pattern, and **every
value the user can change should carry it** to signal editability. `.feld input`/`.feld select`
in `audi-dashboard-ios.css` now all get `background:var(--ios-fill);padding:8px 10px`;
inputs with their own visual language (`checkbox`/`radio`/`range`/`file`) are excluded and keep
the transparent base rule, so the three switches stay untouched. The separate
`.mitEinheit input` rule became redundant and was removed (its width/alignment still come from
`audi-dashboard.css`). Verified live: FIN, Kennzeichen, Erstzulassung, Ausführung, Modell,
both Ölwechsel selects, Ansicht, Pause bis and Autom. Backup all boxed; the three checkboxes
not. **Do not re-open this as a bug.**
(2) **Station display name is now "Marke, Straße, Ort"** (e.g. `Shell, Pascalstr. 8,
Ingolstadt`) instead of the bare operator name (`A. Zrenner GmbH`) — the brand and place say
more when scrolling the Tankvorgänge list. New `_marke()`/`_ist_strasse()`/`_tankstelle()`
helpers in `shell_beleg_parser.py`, shared by both parser paths so Shell and non-Shell
receipts format identically. The brand is matched only against the first 8 lines (the receipt
header) — searching the whole text would let "Total" hit a totals line. Missing parts are
dropped rather than left as empty comma slots (the Austrian receipt prints no street:
`Shell, SÖLDEN`), and without a recognizable brand the operator name takes the brand's place.
`station_address` keeps the full street + postcode + city as before. The regression suite's
`test_bekannte_stationen` expectations were updated to the new format (intended change, not a
break); all 8 tests / 164 subtests pass.
(3) **`installationspaket/` is versioned from now on** (user request) — removed from
`homeassistant/.gitignore`. It carries no real vehicle data, only
`data/fahrzeugprofil.example.json` with empty FIN/Kennzeichen placeholders; verified before
un-ignoring. **Keep it in sync whenever `pyscript/` or `www/` changes** — that was already the
working rule, but it is now visible in the repo when it is forgotten.
Deployed to `audi_ha_test` (version `1786766000`).
- [x] Fifth round the same day (2026-08-16) — one-click installer and the Einzelbeleg map:
(1) **`installationspaket/Installieren.cmd` + `install.ps1`** automate steps 13 of
`ANLEITUNG.md`. The `.cmd` wrapper exists because double-clicking a `.ps1` opens it in an
editor and the execution policy blocks it; the bypass is per-call, the machine policy is not
touched. Safe to re-run: never overwrites `fahrzeugprofil.json`, `fahrten.jsonl`,
`tankvorgaenge.jsonl`, `entitaeten.json`, `ha_token.txt`; backs `configuration.yaml` up to
`.bak` and replaces its own marker-delimited block instead of appending twice; refuses to
touch the file at all when `pyscript:`/`panel_custom:` are already there from another source
(duplicate top-level keys are invalid YAML — HA would not start); aborts before writing if
the target has no `configuration.yaml`. `-Pruefen` is a dry run. **`install.ps1` is stored
UTF-8 *with* BOM on purpose** — PowerShell 5.1 reads scripts as ANSI otherwise and mangles
every umlaut; the files it *writes* stay BOM-less (YAML). Verified against a fake config
tree: fresh install, re-run idempotency, data preservation, foreign-key refusal,
wrong-directory abort, and the merged YAML parses.
(2) **Tankstellenposition im Einzelbeleg.** The red `circleMarker` is now the CI `poi` pin
(`CI.poiL`/`CI.poiS` from `poi-l.svg`/`poi-s.svg`, via `tankstellenMarkerSVG()`) — deliberately
the plain pin, not `poi-car`, so vehicle and station stay distinguishable.
**Found while testing: nothing in the backend ever writes `station_lat`/`station_lon`**, so
this marker had never actually been reachable. Fixed by geocoding the receipt address in the
frontend (`adresseAufloesen()`, Nominatim `/search` — the forward counterpart to the existing
`standortAdresseAufloesen()`), with results cached in `localStorage` (`audi_dashboard_geocache`)
including negative results, since a station address does not move and Nominatim's terms ask
for caching. Confirmed live: `Shell, SÖLDEN` → 46.9756/11.0111.
(3) **Zentrieren-Knopf** in the Einzelbeleg map card (`mapBoxMitZentrieren()`, `CI.gps`
byte-identical to the delivered `gps-s.svg`), disabled until the coordinates arrive. `.mapbox`
gained `position:relative` so the control anchors to the card. Only there, per explicit
request — the Standort fullscreen already has its own centre controls.
Deployed to `audi_ha_test` (version `1786769000`).
Note for testing map behaviour in this app: `initMap()` runs on **every** `render()`, and the
app re-renders on each backend push (~30 s), rebuilding the map and re-centring it. Panning
the map from the console and checking later therefore proves nothing — spy on
`L.Map.prototype.flyTo` instead.
- [x] Sixth round the same day (2026-08-16) — a large batch of user-reported UI polish items, plus
the map pins' real visibility root cause:
(1) **Map pins were genuinely near-invisible, not just a styling nit.** The user reported the
vehicle's map pin looked "transparent"; reproduced empirically (not by eye) by rasterizing the
live marker's SVG to a canvas and sampling alpha — only ~13% of the icon's own bounding box had
any ink, and the exact center pixel was fully transparent. Root cause: `CI.poiCarL`/`poiCar`
(and `poiL`/`poiS`, used for the Einzelbeleg station pin) are pure **contour** forms — the
balloon outline is drawn as two nested, oppositely-wound paths that cancel to a 1px ring under
the default nonzero fill rule, plus thin interior detail lines; there is no filled area at all.
A first fix (solid navy circle behind just the icon's round "head", via `::before`) was
explicitly rejected by the user ("I want the whole icon solid, not just the car" / "make the
needle opaque") — correct, since it only covered ~36% of the icon and left the pointed tip as a
bare outline. Real fix: each icon's `d` attribute is multiple sub-paths (split at top-level
M/m); the **first** sub-path alone is already the complete, correct outer balloon silhouette
(verified by rasterizing it in isolation — solid center pixel, 36-41% fill ratio, matching a
normal teardrop-in-square ratio). Extracted that first sub-path verbatim (no redrawing) into
four new `CI.poiCarLSil`/`poiCarSil`/`poiLSil`/`poiSSil` constants, fixed-color-filled (not
`currentColor`), and `fahrzeugMarkerSVG()`/`tankstellenMarkerSVG()` now render it as the bottom
layer behind the original detailed icon; `.fahrzeug-pin`'s color switched from dark navy to
white so the original outline/detail lines read against the new solid fill, exactly like the
white-glyph-on-solid-teardrop convention of Google/Apple Maps pins. Applies uniformly to both
marker types now (station pin included, per explicit user request), so the earlier per-variant
`.auto` CSS modifier and its circle-position math were removed again as dead weight.
(2) **Battery voltage statistic cutoff lowered 13.2V → 12.8V** (`AGM_RUHE_MAX_V` in
`audi-dashboard-app.js`) — user's point: anything above ~12.8V (full-charge resting voltage per
the existing in-code research citation) is already alternator output, not the battery's own
state, so admitting up to 13.2V let charging-tail values distort the charge-state stats.
(3) **Battery chart Y-axis no longer "stretches."** `bvZeichnen()` recomputed `yMin`/`yMax` from
whatever points were currently zoomed into (min 0.4V span + 15% pad) on every redraw, so the
axis rescaled on every pan/zoom gesture. Now computed once from the full dataset in
`vBatterieverlauf()` (`bvYDomain`) and reused unchanged across zoom/pan.
(4) Removed two explanatory paragraphs from the Batteriespannung detail view ("Näherung auf
Basis…" and "Ziehen zum Verschieben…") per explicit request.
(5) **Tankfüllung now shows "X % / X l"** (`vAudi()`) instead of a literal-litre primary value
with a redundant "100 % von 58 l" sub-line.
(6) Removed the "· noch etwa X l im Tank" suffix from the Übersicht's "Reichweite" label
(`vHome()`); the now-unused `liter` local was removed with it.
(7) **Active/primary buttons are grey again, not red**`.aktion.primaer` in
`audi-dashboard-ios.css` filled with `--ios-tint` (red) since the 2026-08-13 iOS-overlay
decision; explicit user request to drop that and match the base stylesheet's neutral
`var(--fg)`/`var(--canvas)` fill. Destructive actions (`.aktion.loeschen`) intentionally stay
red — Apple's own HIG reserves red specifically for destructive actions, so this one use is
correct per the guideline, not just left over.
(8) **`<select>` fields now carry an Apple-HIG pull-down chevron.** They already had
`appearance:none` (native arrow removed) but no replacement indicator at all — a real gap, not
cosmetic. Added a CSS `background-image` chevron (light/dark variants) in both stylesheets;
base `audi-dashboard.css` kept independently correct (own light/dark variant via
`:host([data-theme=tag])`) per this repo's "purely additive overlay" convention for the ios
file.
(9) **Apple HIG research round.** Read Color/Typography/Layout/Buttons/Materials directly off
developer.apple.com (the WebFetch tool returned only page titles for this JS-rendered site: the
in-app Browser tool's `get_page_text` worked and was used instead) and cross-checked against
actual measurements in this codebase, then asked the user via `AskUserQuestion` which findings
to apply. Accepted: text below the 11pt iOS legibility minimum (battery-chart axis labels were
8px, the header's "Sync" line was 10.5px) raised to 11px; the new compact "Montiert" pill's
*invisible* hit area (not its visual size) expanded to the 44×44pt minimum via a `::before`
with negative insets, same technique applied to future compact controls. Declined, deliberately:
switching the app's few weight-300 ("Light") text spots to 400 — Audi Type only ships 300/400,
and the user chose to keep Audi CI's own weight over HIG's general "avoid Light" guidance;
making popups/sheets translucent to match the Materials guidance — the app already restricts
translucency to the nav layer only (tab bar), which *is* the HIG-correct pattern, and popups
were deliberately made opaque on 2026-08-16 earlier the same day to fix a real see-through bug,
so translucency there would be a regression, not an improvement.
(10) Removed "Aus dem Fahrzeug gelesen" and the Einrichten intro paragraph ("Fahrgestellnummer,
Kennzeichen, Erstzulassung…") from `vEinst()`, both per explicit request.
(11) **Fuel bar color now matches the headline** (`var(--fg)`) instead of a red/yellow/green
status gradient in `vHome()`; the `balkenFarbe` local was removed with it.
(12) **Selected menu icon is white (headline color), not red**, in the desktop side-nav
(`.tab.on` inside the `@container (min-width:860px)` block in `audi-dashboard-ios.css`) — the
mobile tab bar already used `var(--fg)` there, this brought the desktop variant in line.
(13) **Reifenfoto (Sommerrad/Winterrad) now crops from the right edge**, not the center —
`.radbild` gained `object-position: right center`; the photo is typically wider than the 96px
square slot and the wheel itself tends to sit toward the right of the source photo.
(14) **"Montiert" button rebuilt as a compact top-right pill** (new `.montiert` class,
replacing the old full-width `.seg`-wrapped button) per explicit layout instructions: top edge
aligned with the wheel photo's top (`top:var(--sp-5)`, same offset the photo already has from
the tile's own padding), right inset equal to the tile's standard left content inset (both
`var(--sp-5)`) so left/right margins match. Markup reordered so the button renders before
`.radkopf` (position is absolute either way, but keeps DOM order sensible).
(15) **Tab-bar active highlight now covers icon *and* label when the label is visible**, not
just a tight pill behind the icon — `.tabbar:not(.ohne) .tab.on` gets the highlight background
directly (whole button), with the inner `.tabpille` background suppressed in that case; the
icon-only mode (`.tabbar.ohne`) keeps the original tight icon-pill unchanged.
Deployed to `audi_ha_test` (version `1786901000`); pin fix visually confirmed live (solid
teardrop pin with white car glyph, clearly over the map's "Obergurgl" label text) and the
tab-bar highlight confirmed covering both icon and "Übersicht" label.
- [x] Seventh round the same day (2026-08-16) — more user-reported layout/interaction bugs:
(1) **Wheel photo (Sommerrad/Winterrad) was never actually a square, despite the fifth round's
crop-direction fix.** Root cause: `bildMitPlatzhalter()` puts the `radbild` class on *both* the
wrapping `.bildbox` div and the `<img>`; the old `.radbild{width:96px;height:96px;...}` rule and
`.bildbox{width:100%;aspect-ratio:4/3;...}` have equal specificity (one class each), and
`.bildbox` is declared later in the file, so it silently won — the box stayed a full-width 4:3
rectangle the whole time, `object-position:right center` just changed which slice of that wide
box showed. Fixed with a `.bildbox.radbild{width:96px;height:96px;aspect-ratio:1/1}` compound
selector (two classes, unambiguously higher specificity than plain `.bildbox`); `.radbild` alone
now only carries `object-position`. Verified live: `.bildbox.radbild` now measures exactly
96×96px.
(2) **"Montiert" moved from top-right to top-left**, per explicit request — this was the visible
symptom of (1): the pill was anchored top-right assuming it overlaid the (accidentally) full-
width photo; once the photo is a real 96px square on the left, top-left is where it actually
needs to sit to stay over the picture.
(3) **Native `<select>` popups now get a `color-scheme` hint.** The closed `<select>` box itself
was already themed correctly, but the *opened* native option list ignores app CSS and falls back
to the browser/OS default palette unless `color-scheme` says otherwise — this is what made
"Modell" and every other dropdown "flash" bright white against the dark app on open. Added
`color-scheme: dark` under `:host([data-theme="nacht"])` and `color-scheme: light` under
`:host([data-theme="tag"])` in `audi-dashboard-ios.css`.
(4) **Fixed a real JS crash in the "Mein Audi" image-cycle click handler.** `bildWeiter()` (fires
on tapping the hero photo) tried `box.querySelector(".platzhalter-datei").textContent = ...`
that class was never emitted by `bildMitPlatzhalter()` (the actual class is `.platzhalter-
aktion`, and in gallery mode it deliberately shows a static "Foto hinzufügen" prompt, not a
filename). `querySelector` returned `null`, the assignment threw, and the exception aborted the
function *before* reaching the line that updates the `.dots` page indicator — so the photo
itself advanced (that line ran first) but the dots never moved, reading as "doesn't work."
Removed the dead line entirely; verified live that the `.dots` `on` class now correctly moves
with each click and no exception is thrown.
(5) **Merged the separate "Fahrzeugbilder" upload grid into "Bild der Übersicht"**, per explicit
request: pick a view from the existing "Ansicht" dropdown, then tap the (now also click-to-
upload) preview below it to upload/replace/delete that specific photo — reusing the same
generic `data-bildklick`/`bildMenuOffen` popup plumbing already used by the wheel-photo tiles,
not a new mechanism. `bildInfo()`'s existing winter-side-view special case (swaps in
`seitenansicht-winter.webp` while winter tires are marked mounted) means the winter variant
stays uploadable through the same control, gated by current tire season instead of a permanently
visible separate slot. The now-unused `BILDER_UPLOAD_SLOTS` constant and the `.bildgrid`/
`.bildslot`/`.bildslot-label`/`.carfix.mini` CSS were removed as dead code; a new
`.bildmenu.ansicht` positions the ersetzen/löschen popup anchored to the tile's *bottom* edge
(not a fixed top offset like the other two menu variants) since this preview's height varies
with the tile's own content instead of being a fixed small thumbnail.
(6) **Back arrow changed from red (`--ios-tint`) to the neutral headline color (`--fg`)** in
`audi-dashboard-ios.css`, matching every other navigation-color decision made this project (red
stays reserved for destructive actions per Apple's HIG).
Initially investigated but could not reproduce (see below for the real fix, found later the same
day): user reported that hiding tab labels ("Beschriftung in der Menüleiste" off) makes the
*entire* tab bar disappear. First pass traced the CSS cascade and the commit that added the sixth
round's icon+label highlight and found it correctly scoped to `:not(.ohne)`; live DOM/computed-
style testing (toggle, click the label, navigate away/back, both container widths) checked
`#tabbar`'s and each `.tab`'s own box — all stayed visible, sized, and classed correctly — so the
bug looked unreproducible and was provisionally left alone.
Deployed to `audi_ha_test`; wheel-photo squareness, Montiert position, back-arrow color, and the
image-cycle dots all confirmed live via direct DOM/computed-style checks (no visual screenshot
tool available this session — see running note below).
Follow-up same day: user reported the `color-scheme` fix alone wasn't enough — dropdowns were
"still very bright." Correct: a `<select>`'s *opened* option list is rendered by the browser/OS
as a separate surface outside the page's render tree entirely — confirmed firsthand when a
screenshot tool call actually hung for 30s trying to capture it, and even a real click-to-open
attempt afterward produced no visible change in any screenshot. `color-scheme` only gets partial
credit there; the reliable lever is that Chrome/Firefox/Edge *do* honor explicit
`background-color`/`color` set directly on `<option>` elements for the popup rows. Added
`.feld select option{background-color:var(--canvas);color:var(--fg)}` — deliberately `--canvas`,
not `--tile-2` (translucent in the night theme, would let the browser's own light backdrop show
through). Verified via `getComputedStyle` on the actual `<option>` nodes
(`background-color: rgb(12,16,20)`, `color: rgb(255,255,255)` in night theme) since the popup
itself is provably outside what any screenshot in this tool can capture — user should confirm
visually on their own device.
Also: "Montiert" and the wheel photo were requested swapped — photo now sits on the tile's right
edge (`.bildbox.radbild{margin-left:auto}` inside its full-width wrapper), Montiert stays
`left`-anchored, unchanged from the seventh round.
**Found the real tab-bar bug right after, from the user's own screenshot** ("in this view i dont
see any menu items" — a live repro of icon-only mode, this time actually screenshotted since the
browser pane had recovered). The earlier live-testing pass checked `#tabbar` and `.tab`'s own
boxes and both looked fine — the actual defect was one level deeper and only visible by measuring
the icon itself: `.tabbar.ohne .tab span{display:none}` (in both stylesheets) was meant to hide
only the trailing label `<span>${t.label}</span>`, but the icon is *also* wrapped in a `<span
class="tabpille">` — a bare type selector `span` does not care about class, so the rule hid the
icon's wrapper too. `getBoundingClientRect()` on the `<svg>` read `{w:0,h:0}` and
`getComputedStyle(.tabpille).display` read `"none"` once actually checked — that was the missing
measurement in the first pass. This is exactly the reported "whole menu bar disappears": in icon-
only mode every tab lost both its label *and* its icon, leaving five empty buttons in an otherwise
correctly-visible bar. Fixed with `.tabbar.ohne .tab span:not(.tabpille){display:none}` in both
`audi-dashboard.css` and `audi-dashboard-ios.css` (the base file's copy was already shadowed by
the ios one at matching specificity, but fixed too rather than left as a landmine). Verified live
via screenshot: all 5 tab icons render correctly in icon-only mode, active tab still shows its
filled pill.
- [x] Eighth round the same day (2026-08-16) — Reifen tile relabel + reflow, per explicit request:
"Sommer"/"Winter" → **"Sommerräder"/"Winterräder"** (only the section headline text in
`vReifen()`'s `satz()`; the singular "Sommerrad"/"Winterrad" photo label and the `data-satz`
value compared against `CAR.reifen.aktiv` elsewhere were deliberately left untouched — the first
is grammatically correct as-is for a single wheel photo, the second is an internal state key, not
display text, and changing it would break the mount-toggle matching in the `[data-satz]` click
handler). That headline is now an actual **headline** — new `.tile-headline` class (20px,
`var(--fg)`, same typographic family as the page `.title` one step down) replacing the small,
muted `.label` styling every other in-tile section title uses.
Reflowed so the headline+km sit **above** "Montiert" instead of the wheel photo overlaying it:
new `.radkopf-row` (flex, `justify-content:space-between`) puts the headline/km block and the
wheel photo side by side at the tile's top, both top-aligned; `.montiert` dropped `position:
absolute` (no longer needs to float over the photo now that the photo isn't in that space) and
became a normal-flow pill with `margin-top` under the headline block — simpler than the previous
overlay hack, same 44×44pt invisible tap target via `::before`. `.bildbox.radbild`'s `margin-
left:auto` (added last round to right-align it under its old block-flow parent) was removed since
`.radkopf-row`'s flex layout does that job now. Deployed to `audi_ha_test`, confirmed live via
screenshot for both Sommerräder (mounted) and Winterräder (unmounted) tiles.
- [x] Ninth round the same day (2026-08-16) — "Mein Audi" image-cycle tap, still broken after the
seventh round's `bildWeiter()` crash fix: reproduced with a genuine mouse click (not a JS
`.click()` call) via screenshot and found the actual cause was one level upstream. This docker
test container has zero uploaded car photos, so every gallery image is in `bild-fehlt` state,
and `.bildbox.bild-fehlt img{display:none}` makes the `<img id="carimg">` itself un-hit-testable
— a real tap lands on the `.platzhalter` div instead, which the old handler
(`e.target.id === "carimg"`) never matched, so nothing happened. First fix wrapped the gallery
image in a `data-bildgalerie` container that opened the upload picker when empty and advanced
when a photo was loaded; the user then clarified uploads should stay exclusively in Einstellungen
("Bild der Übersicht") — Mein Audi should only ever cycle. Settled on: the wrapper stays (needed
so taps land on something clickable regardless of `bild-fehlt` state) but always calls
`bildWeiter()`, no upload branch. Verified live with a real click (screenshot → click → recheck
`.dots`): the active dot correctly advances, no console errors beyond this environment's
pre-existing service-worker/404 noise.
Also this round: replaced the "Farbskala Ladezustand (AGM, Ruhespannung)" text-only label above
the battery voltage color scale with a small battery icon + "Ruhespannung" (new `.bv-skala-kopf`
flex row, same geometry as the header's `.sync` icon+text pattern). First attempt hand-drew a
generic outline battery glyph since no "battery" icon exists in this repo's local CI subset
(`design/assets/icons/`); the user then supplied the real `battery-12v-l.svg`/`battery-12v-s.svg`
files from the actual Audi CI icon set (not something this session has fetch access to — asked
the user rather than guessing a URL or fabricating an icon and calling it authentic). Added both
as `CI.battery12vL`/`CI.battery12vS` (verbatim paths, `l` on the 48-grid, `s` on the 24-grid,
matching the existing `poi-l`/`poi-s` size-variant convention); only the `s` variant is used here
via `ciSVG()` since this is a small 15px inline label icon.
Deployed to `audi_ha_test`, confirmed live via screenshot.
Also this round (user then supplied the real `edit-l.svg`/`edit-s.svg` too): added a manual
km-correction control to the Reifen tiles, per explicit request — a small pencil icon (Audi CI
`edit-s`, new `CI.editS`/`CI.editL`) to the left of each Sommerräder/Winterräder km figure, "the
counter shall add continuously to these km even if the user changes the km". The tricky part
was persistence: `configCarZuProfil()` *deliberately* strips `km` before every profile write
(see its own comment - km is "vom Backend fortgeschrieben", a stale browser copy must never
clobber a since-elapsed automatic increment), so simply mutating `CAR.reifen[k].km` and calling
`profilSpeichern()` like every other Reifen field does would silently drop the edit. Added a
dedicated backend service instead, `audi_dashboard_reifen_km_setzen(satz, km)` in
`reifenzaehler.py`, that only overwrites the stored counter itself and leaves
`reifen.referenz_odo_km` (the last-processed odometer reference) untouched - the existing
`kilometerstand_geaendert()` trigger always reads the counter fresh off disk before adding the
next delta, so continuing to accumulate from the corrected value required no other backend
change at all. New `kmOffen` state (which satz's correction field is open, mirroring the
existing `nmOffen`/`bildMenuOffen` single-open-popup pattern) plus `data-kmauf`/
`data-kmspeichern` handlers, styled like the existing `.montiert`/`.zahnrad` controls (44×44pt
invisible tap target via `::before`). Verified end-to-end: edited Sommerräder from 150→4200 km,
confirmed the pyscript service ran via the container's HA log, then did a full page reload
(forces a real backend re-fetch, not just optimistic local UI state) and the corrected value
was still there.
- [x] Tenth round the same day (2026-08-16) — a real full Apple HIG audit, not just the sixth
round's 5-page pass: read Tab Bars, Sidebars, Sheets, Alerts, Action Sheets, Toolbars/
Navigation Bars, Buttons, Pickers, Loading, Feedback, Privacy, and Undo/Redo directly off
developer.apple.com and cross-checked each against the actual code (not just CSS) — most of
the app already held up (5-tab bar, destructive-button placement, 44pt tap targets,
`prefers-reduced-motion` support, confirm-sheet button ordering all already correct). Three
real findings surfaced, all fixed and user-approved via `AskUserQuestion` before implementing:
(1) **A second, larger silent-failure gap than the one already fixed for receipt upload.**
`profilSpeichern()` — the shared save path for roughly 25 editable fields across the app
(Anzugsmoment, service book, SmartDeal, tax, insurance, oil change, pause time, backup
interval, start image, ...) — was `async` with no `try`/`catch` at any of its ~25 call sites,
so a failed backend write was an unhandled promise rejection with zero user feedback; the
popup had already closed and the UI already updated optimistically, so the change looked
saved when it silently wasn't. Wrapped the one call inside `profilSpeichern()` itself in
`try`/`catch`, reusing the existing `hinweis()` error-sheet pattern — one central fix instead
of ~25 call-site patches. `serviceRufen()` (the separate wrapper used by km-correction, manual
trip/fuel entry, tire-set switching, deletions, restart, ...) got the identical treatment.
Verified live by monkey-patching `hass.callService` to reject inside the running app (via
`host.hass.callService = () => Promise.reject(...)`), then triggering a real Anzugsmoment save
through the actual UI: before the fix, the popup silently closed with a stale "Uncaught (in
promise)" console entry and no user-visible sign anything went wrong; after the fix, a
"Speichern fehlgeschlagen" sheet appears with the real error text. A follow-up full reload
confirmed the failed test write never reached the backend (value reverted to the real stored
120 Nm) — the fix only adds feedback, it doesn't change persistence behavior.
(2) **"Lädt …" bootstrap screen got a progress indicator** (new `.lade-spinner` CSS, a plain
rotating ring) instead of bare static text, per HIG Loading guidance to show something moving
while content loads — notable here because this exact screen has a documented history of
genuinely hanging, so a spinner also helps a user tell "still loading" apart from "stuck".
Respects the existing global `prefers-reduced-motion` override (`*, *::before, *::after`
block already forces `animation-duration:.01ms` app-wide) with no extra code.
(3) **Anzugsmoment and km-correction popups now use `.aktion.primaer` on Speichern**, matching
the Setup and SmartDeal popups, which already distinguished their primary action — these two
were the only popups in the app stacking two visually-identical plain buttons.
All three verified live in `audi_ha_test` (version `1786916894`): spinner CSS confirmed via
`getComputedStyle` (16×16px, `border-radius:50%`, `animation-name:lade-spin`) since a genuine
race-condition-timed screenshot proved too fast to catch reliably; Speichern/Abbrechen
hierarchy confirmed via screenshot on both popups; error-sheet fix confirmed via the
monkey-patch test above. Synced to `installationspaket/`.
- [x] Eleventh round (2026-08-17) — "why can't I pull to refresh on Übersicht", investigated as a
real bug report rather than assumed working: live-tested the gesture by dispatching real
`PointerEvent`s through the exact `pointerdown`/`pointermove`/`pointerup` handlers in
`ereignisseVerdrahten()` (the `ptrY0`/`ptrDrag` logic) — every step fired correctly (drag
threshold, "Loslassen zum Aktualisieren" label swap, spinner, `datenAktualisieren()` call,
collapse), so the app's own JS/CSS was never the defect. Root cause instead: `main#view` (the
actual scroll container) had no `overscroll-behavior` set at all, so on a **real** touchscreen
the browser's own native overscroll/pull gesture can intercept or compete with the custom drag
before it reaches the threshold — this can't reproduce via desktop testing or synthetic pointer
events, only real touch hardware, which is exactly why the live test above passed while the
user's real phone didn't work. Fixed with `overscroll-behavior-y: contain` on `main#view` plus
`overscroll-behavior: none` on the outer `:host > div` wrapper, so a gesture leaving `main#view`
never chains up into the underlying Home Assistant page either. Re-ran the same synthetic
pointer-event test after deploying — gesture still fires identically end-to-end, confirmed via
`getComputedStyle` that `overscroll-behavior-y` computes to `contain`; no visual regressions,
no new console errors. Synced to `installationspaket/`. Not yet confirmed fixed on the user's
actual device — the code-level fix is the standard remedy for this exact symptom class, but ask
if it's still reproducing before assuming this is fully closed.
- [x] Beleg-Upload: Ziehen und Einfügen (2026-08-17) — "Beleg hochladen" opened the native file
picker directly, which is the wrong affordance for both of the user's actual routes: at the
computer the PDF should be draggable in, on the phone it arrives as a PDF copied out of a mail
and needs pasting. Replaced the immediate picker with a small dialog (`belegPopup` state,
`vBelegPopup()`, rendered into the existing `#overlay` alongside `sheetMarkup()`/
`vSetupPopup()`): a dashed drop area plus "Aus Zwischenablage einfügen" and "Datei auswählen"
(the old path, kept as the third route). The upload itself was extracted out of the click
handler into `belegDateiVerarbeiten()` so all three sources share one code path — including the
deliberate non-`serviceRufen()` error handling whose reasoning is unchanged (see its comment).
Notes worth keeping: `dragover` **must** `preventDefault()` or the browser just opens the PDF
itself and no `drop` ever fires; the drag/drop/paste listeners hang on `document`, not on the
drop zone, because the zone lives in the shadow DOM and a drag crossing its edge would
otherwise be lost — and because `render()` replaces the overlay's `innerHTML`, so per-element
listeners wouldn't survive. Phone pasting uses `navigator.clipboard.read()` (iOS shows its own
paste prompt) with the keyboard `paste` event as the desktop path. Non-PDFs are rejected with
an in-dialog message instead of being uploaded. Verified live in `audi_ha_test` by
instrumenting `hass.callService` and driving real `DragEvent`/`ClipboardEvent`/`KeyboardEvent`s:
drop → zone highlights (`darueber`), dialog closes, service called with the right filename and
base64 payload; paste → same; a dropped PNG → "Das war kein PDF." and no service call; Escape →
closes. Synced to `installationspaket/`.
**Nachbesserung am selben Tag, nach Fehlermeldung des Nutzers** ("copy paste from pc brings
error no pdf but it is the latest bill") — zwei echte Fehler, beide bestätigt:
(1) `navigator.clipboard.read()` (der "Aus Zwischenablage einfügen"-Knopf) gibt laut
Spezifikation **nur Text, HTML und Bilder** heraus. Eine im Explorer/Finder kopierte PDF-Datei
taucht dort grundsätzlich nicht auf - der Knopf konnte am Rechner also gar nicht funktionieren
und behauptete dann fälschlich, es liege kein PDF vor. Der Weg, der dort funktioniert, ist
Strg+V: das `paste`-Ereignis trägt die Datei in `clipboardData.files`. Die Meldung nennt jetzt
genau das, statt die Datei zu beschuldigen; die Ablagefläche sagt es von vornherein
("oder mit Strg+V einfügen", per `tastenkuerzelEinfuegen()` auf Mac Cmd+V).
(2) Die Erkennung hing allein am MIME-Typ (`type === "application/pdf"`) - eine aus dem
Explorer kopierte oder gezogene Datei kommt je nach Browser und Quelle aber mit **leerem**
`type` an und wurde dadurch abgelehnt. Neue Hilfsfunktion `istPdf()` prüft zusätzlich die
Dateiendung; live nachgestellt mit genau diesem Fall (`new File([...], "Tankbeleg_August.pdf",
{})`, `type === ""`) - vorher abgelehnt, jetzt korrekt hochgeladen.
(3) Auf Rückfrage des Nutzers ("why is it that way? computer has copy paste function") noch
einen Schritt weiter: Der Einfügen-Knopf wird am Rechner jetzt **gar nicht mehr angeboten**
(`zwischenablageKnopfSinnvoll()`, Prüfung auf `(pointer: coarse)`), weil er dort prinzipiell
nicht funktionieren kann - ein Knopf, der zwangsläufig in eine Fehlermeldung läuft, ist
schlechter als keiner. "Datei auswählen" wird dort zur primären Aktion; auf dem Telefon bleibt
alles wie zuvor. Der Unterschied dahinter, kurz: Strg+V ist eine bewusste Nutzergeste an die
Seite, deshalb bekommt die Seite den vollen Inhalt inklusive Dateien - `clipboard.read()` ist
die Seite, die von sich aus die Zwischenablage ausliest, und das wird aus Datenschutzgründen
stark eingeschränkt (sonst könnte jede Seite still mitlesen, was zuletzt kopiert wurde).
Live geprüft: Desktop zeigt nur noch "Datei auswählen [primaer]"/"Abbrechen", Mobil (375px,
`pointer: coarse`) weiterhin alle drei Knöpfe.
(4) **Auch Strg+V half am Rechner nicht** - vom Nutzer getestet und zurückgemeldet ("ctrl+v
doesnt work either, pc doesnt know where to paste"). Stimmt: Chrome stellt eine Einfüge-Geste
nur zu, wenn es ein Einfüge-Ziel gibt (Eingabefeld/contenteditable), und das Fenster hat
keines. Der Hinweis ist deshalb überall entfernt; am Rechner heißt es jetzt schlicht "PDF
hierher ziehen / oder unten auswählen", am Telefon "Beleg einfügen / das aus der Mail
kopierte PDF". Der `paste`-Listener bleibt als stille Zugabe für Browser, die das Ereignis
doch zustellen, wird aber nirgends mehr beworben; `tastenkuerzelEinfuegen()` wurde damit
verwaist und ist entfernt. **Am Rechner also bewusst nur zwei Wege: Ziehen und Dateiauswahl.**
- [x] Preis je Liter überall auf 2 Nachkommastellen (2026-08-17, Nutzerwunsch: "€/l for gas is
common to be displayed as x,xx €/l") — alle sieben Stellen von `de(..., 3)` auf `de(..., 2)`
umgestellt: Jahres- und Monatsschnitt in der Tankliste, Zeilenwert je Tankvorgang, "Preis je
Liter" im Einzelbeleg, die Live-Vorschau in beiden Tankformularen (statisch und im
`data-tankpreisfeld`-Handler) sowie die CSV-Spalte "€/l". Im CSV kein Informationsverlust: der
Wert ist aus den ebenfalls exportierten Spalten Liter und Kosten jederzeit neu berechenbar.
`companion-app` (Nachfolger-App) brauchte keine Änderung - deren `eur()` rundet ohnehin schon
auf 2 Stellen. Live geprüft: keine `de(..., 3)`-Aufrufe mehr in der Datei, Liste und
Einzelbeleg zeigen "2,07 €/l", "1,99 €/l" usw.
- [x] Twelfth round (2026-08-17) — six user-reported items, one of them a real dead-end in the
bootstrap:
(1) **`HECKKLAPPENSCHLOSS_SENSOR` removed for good**, the same treatment Türschloss/
Haubenschloss got on 2026-08-16: gone from `einstellungen.py` (attribute deleted, not
blanked), from `entitaeten.py`'s Setup catalog, and from `_sicherheitscheck()` in
`frontend_veroeffentlichung.py`. `HECKKLAPPE_SENSOR` (the lid itself) stays. Verified live:
"Geprüfte Punkte" no longer lists it, the Setup catalog no longer offers it.
(2) **Bild-Platzhalter in Einstellungen war halb abgeschnitten** — reproduced by measurement,
not by eye: the placeholder rendered 260px tall inside a 132px `overflow:hidden` box. Cause:
`bildMitPlatzhalter()` puts the size class (`carfix`) on the box, the `<img>` **and** the
placeholder `<div>`, and the desktop container query said `.carfix,.bildbox.carfix
{height:260px}` — the bare selector therefore also hit the placeholder, whose `position:
absolute;inset:0` is over-constrained by an explicit height (the height wins, `bottom:0` is
ignored). Scoped both rules to `.bildbox.carfix`; image and placeholder fill the box via
their own `100%` anyway. Verified: placeholder now measures exactly the box height.
(3) **Inspektion bekommt eine "voraussichtlich am …"-Zeile** wie der Ölwechsel, aber auf
Wunsch nur mit dem Datum. `oelwechselPrognose()` wurde dafür auf einen gemeinsamen Kern
`servicePrognose(stichwort, km, monate)` zurückgeführt; `inspektionPrognose()` nutzt
dieselben 30.000 km / 24 Monate wie `intervalle()`/`termine()`. Angezeigt in der
Service-Kachel auf "Mein Audi" (auch ohne Fahrzeugmeldung, anders als beim Ölwechsel - dort
nennt `terminText()` den generischen Termin schon "voraussichtlich", die Zahl stünde sonst
doppelt) und auf der Service-Seite. Live geprüft: beide Stellen zeigen "voraussichtlich am
20.10.2027", Ölwechsel unverändert.
(4)+(5) **"Neue Fahrt" hat jetzt alle Felder der Einzelfahrt, und die Einzelfahrt lässt sich
bearbeiten.** Neues gemeinsames `fahrtFelder()`/`fahrtFormularWerte()` (Datum, Startzeit,
Dauer, Art, Start-/Zielort, Start-/Endkilometer, Distanz) nach demselben Muster wie
`tankFelder()`/`tankFormularWerte()`, benutzt vom Erfassungsformular und vom neuen
"Werte bearbeiten" auf der Einzelfahrt-Seite (`fahrtBearbeitenOffen`, Vorbild `vFill()`).
Backend: `audi_dashboard_fahrt_manuell_anlegen` um dieselben Felder erweitert, neuer Service
`audi_dashboard_fahrt_aktualisieren`. Zwei Punkte, die dabei zählen: von Hand gesetzte Felder
landen in `edited_fields`, damit das Kilometerstand-Screening (§7.2) sie nicht später
überschreibt - und weil `_datensatz_aktualisieren()` genau diese Liste schützt, hätte ein
zweites Bearbeiten sonst nichts mehr bewirkt; dafür gibt es jetzt `profil.fahrt_bearbeiten()`
(`schutz=False`), der Schutz gilt gegen die Automatik, nicht gegen den Menschen. Fehlt die
Distanz, ergibt sie sich aus den beiden Kilometerständen (`km_quelle: "manuell"`, Status
"vollständig"). Gleich mitgezogen: die Art-Pille auf der Einzelfahrt schaltete bisher nur
lokal um und war nach dem nächsten Backend-Push wieder weg - sie ruft jetzt denselben
Service. **Merkposten pyscript:** Generator-Ausdrücke gibt es dort nicht
(`NotImplementedError: not implemented ast ast_generatorexp`, an `next((f for f in …), None)`
real gesehen) - Listen-Abstraktionen dagegen schon. Live end-to-end geprüft: anlegen,
bearbeiten, erneut bearbeiten, jeweils in `fahrten.jsonl` nachgesehen und nach Reload wieder
im Formular.
(6) **Titelzeile stand auf dem Handy 34px rechts von den Kacheln.** Derselbe Befund wie im
Design-Audit 2026-08-13, dort aber nur in der Desktop-Container-Query behoben: `.back` ist
auf Wurzelseiten unsichtbar (`opacity:0`), belegt aber Breite plus Gap. Die Regel
`.back:not(.on){width:0;…}` gilt jetzt auf allen Breiten und hebt per `margin-right:-4px`
auch den Gap auf; `.topbar` bekam links 16px statt 20px, passend zu `--seitenrand` von
`main#view`. Gemessen: Titel und Kachel beginnen auf dem Handy beide bei 16px, auf dem
Desktop beide bei 564px (dort blieben vorher 4px Rest). Detailseiten (`.back.on`) unberührt.
- [x] Ladebildschirm: der Takt darf nicht abreißen (2026-08-17) — Nutzerbericht "nach dem Neuladen
dreht sich nur 'Lädt …', erst ein Klick auf einen Menüpunkt zeigt die App". Beim Lesen des
Bootstraps fanden sich zwei echte Sackgassen, die genau dieses Verhalten erklären:
`nachladeAnstossen()` stieg bei `!HASS || !HASS.callWS` **ohne** neuen Timer aus, und
`datenLaden()` bei `!HASS` ebenso - danach lief nichts mehr von selbst, und der einzige
Ausweg war ein zufälliger hass-Push oder ein Menüklick (`go()` ruft `datenLaden()` mit auf,
genau die Abmilderung vom 2026-08-16). Beide planen jetzt die nächste Runde, statt
auszusteigen; `disconnectedCallback()` stoppt den Timer dafür sauber, `connectedCallback()`
stößt ihn wieder an. **Nicht reproduziert:** weder ein einfacher Reload noch ein
Container-Neustart mit sofortigem Reload haben den Hänger in `audi_ha_test` gezeigt (die App
war jedes Mal binnen ~2s da) - die Sackgassen sind im Code belegt, dass sie *die* Ursache
beim Nutzer waren, ist damit aber nicht bewiesen. Tritt es erneut auf: Browser-Konsole
erfragen, bevor weiter geraten wird.
- [x] Thirteenth round (2026-08-17, same day) — five more user-reported items on the trip form and
one theme bug found while verifying it:
(1) **"Dauer" replaced by "Ankunftszeit"; duration is no longer editable.** `fahrtFelder()`
now has Startzeit + Ankunftszeit inputs and a plain (non-input) duration display next to
them, computed by `fahrtZeitraum()`/`dauerText()` and kept live via a new
`data-fahrtzeitfeld` input handler (same "patch the one node, don't call render()" pattern
the existing `data-tankpreisfeld` handler uses, for the same reason: a full re-render would
steal focus from the field being typed in). An arrival time not later than the start time is
treated as crossing midnight (`+1 Tag`), matching how `_fahrt_beenden()` already handles a
real overnight trip. `fahrtFormularWerte()` derives `ts_start`/`ts_end` from the same
calculation - the backend never sees a separate duration field, so it can't disagree with
the times. Verified live: 09:00→10:45 shows "1 Std. 45 Min.", 23:30→00:15 shows "45 Min."
(crossed midnight correctly), and editing an existing trip's arrival time on the Einzelfahrt
page persisted the recalculated `duration_s` (7200s for a 10:00→12:00 edit, checked in
`fahrten.jsonl`).
(2) **Art values now display capitalized** ("Privat"/"Arbeitsweg") wherever shown to the user
- the trip-edit `<select>`, the trip-list row, and the Einzelfahrt pill - via a new
`artText()` helper; the stored value stays lowercase (`"privat"`/`"arbeitsweg"`), since it's
compared in several places (the pill, the "Arbeitsweg" statistic, the CSV export) and already
exists that way in every trip recorded so far. The CSV export itself was deliberately left
showing the raw value - a data file, not UI copy.
(3) **Arbeitsweg pill no longer red.** `.pill.work` (`color:var(--red)` / a translucent red
fill in the iOS overlay) is gone from both stylesheets along with the now-unused `work` class
on the button; the pill uses the same neutral styling regardless of Art, matching the request
that Arbeitsweg should look like Privat, not draw attention to itself.
(4) **"tippen zum Umschalten" hint removed** from under the Einzelfahrt pill, per explicit
request - the pill is still clickable, just without the caption.
(5) **Found while re-verifying the placeholder fix from round twelve: it broke daylight mode.**
`.bildbox.bild-fehlt .platzhalter{background:var(--tile)}` (added round twelve) reads fine in
Nacht theme, where `--tile` is a faint white overlay on a near-black canvas - but the iOS
overlay's Tag-theme block redefines `--tile:#FFFFFF`, identical to the canvas/most tiles
around it, so the placeholder box became invisible in daylight mode (measured live:
`background-color: rgb(255,255,255)` on both the placeholder and its surroundings). Switched
to `--ios-fill` (`rgba(120,120,128,.16)` in Tag theme, `.24` in Nacht) - the same token
`.feld select`/`.aktion` already use to mark "this is a surface", visibly grey in both themes.
Verified live in Tag theme: `background-color: rgba(120,120,128,.16)`, clearly a distinct box
against the white canvas (see screenshot taken during verification).
All five deployed to `audi_ha_test` (version `1787009000`) and synced to `installationspaket/`;
console showed only the pre-existing service-worker/404 noise from the missing vehicle photos,
nothing new.
- [x] Service-Seite: Prognose-Fussnote bricht nicht mehr um (2026-08-17) — Nutzermeldung, das Datum
der Ölwechsel-Prognose stehe in einer eigenen Zeile. Nachgemessen statt geschätzt: das
`<small>` in der rechten Spalte war 29px hoch bei 217px Breite (zwei Zeilen), die
Inspektions-Fussnote darunter mit kürzerem Text 14px (eine). Ursache ist nicht die Textlänge
allein, sondern der Platz: `.row` ist ein Flex mit `dt`/`dd`, und `dd` bekommt nur den Rest
neben dem Titel - für "9.029 km, voraussichtlich am 14.04.2027" bei 12,5px reicht das nicht.
Statt an Schriftgröße oder Text zu drehen, wandert die Fussnote jetzt als eigenes Element
(`.row-fussnote`) neben `dt`/`dd` und umbricht per `flex:1 0 100%` auf eine eigene Zeile über
die **volle** Kachelbreite - dieselbe Stelle, an der sie vorher schon optisch stand, nur ohne
die künstliche Breitenbegrenzung. Das `flex-wrap:wrap` ist bewusst mit `:has(.row-fussnote)`
auf genau diese Zeilen eingegrenzt, damit keine der übrigen `.row`-Verwendungen (17 Zeilen
allein auf "Mein Audi") ihr zweispaltiges Layout verliert. Die vorher verschachtelte
fm-/nicht-fm-Verzweigung im Markup ist dabei zu einem `hatMeldung`-Zweig für den Wert plus
einem separat berechneten `fussnote`-String zusammengefallen - die Anzeigelogik selbst
(Ölwechsel-Fussnote nur bei Fahrzeugmeldung, Inspektion immer) ist unverändert. Live geprüft
bei 375px und 1280px: beide Fussnoten 14px hoch (einzeilig), 307px bzw. 636px breit, linksbündig
auf der Kachelkante; alle 17 `.row` auf "Mein Audi" weiterhin zweispaltig; keine neuen
Konsolenfehler. Deployed als Version `1787003000`, synchron in `installationspaket/`.
- [x] **Gap found while auditing "what's needed for a real iPhone app" (2026-08-17), then closed
the same day:** `companion-app/src/screens/` hadn't been touched since 2026-08-11 — every
panel fix and feature from the six days since (HIG audit rounds, receipt upload, trip
editing, Inspektion forecast, ...) existed only in `homeassistant/www/audi-dashboard-app.js`.
User decision: port everything now, before any native build work (Android build itself
deprioritized/uncertain per the user - Paul has an Apple Developer Program **[WRONG, see
section AI: it is a free Personal Team, not a paid membership]** and will help with
iOS signing). Audited both codebases field-by-field (not by diffing the panel's CSS/markup,
which doesn't transfer to `@audi-dash/ui` components) and found six real, mechanically
distinct gaps, all fixed:
1. **`AGM_RUHE_MAX_V` was still 13.2** in `Batterie.tsx` - lowered to 12.8, matching the
panel's 2026-08-16 fix (same rationale: above that is already alternator voltage).
2. **Dead `wlan_name` field** - `profilAdapter.ts` still read/wrote it and `Einstellungen.tsx`
showed it as an editable "WLAN des Fahrzeugs" field, even though WLAN-based trip detection
was fully removed from the backend on 2026-08-12 (replaced by FMM003 ignition). Editing it
had zero effect. Removed from the type, both directions of the adapter, the settings
screen, and both test fixtures.
3. **No Inspektion forecast** - `service.ts` only had `oelwechselPrognose()`. Refactored the
shared math into a `servicePrognose()` core (kept `oelwechselPrognose()`'s public
signature/behavior identical - all 9 existing tests still pass unchanged) and added
`inspektionPrognose()` (fixed 30.000 km / 24 Monate, no customizable interval like the oil
change has), wired into `Service.tsx` as a new "Eigene Prognose (Inspektion)" row. 3 new
tests.
4. **Arbeitsweg pill was red** - `Pill variant="work"` in `Fahrten.tsx`/`FahrtDetail.tsx`;
`@audi-dash/ui`'s own source comment says the variant literally "recolors border+text to
the accent red". Stopped passing the variant in both spots - same fix as the panel, no
change needed to the shared design-system component itself.
5. **Trip creation only took Beginn/Ende/Art** - the panel's fuller field set (Startort,
Zielort, Start-/Endkilometer, Distanz) didn't exist here. New shared
`src/screens/FahrtFelder.tsx` (type, defaults, backend-payload conversion, and the
presentational field group) used by both the create form (`Fahrten.tsx`) and a **new**
edit capability.
6. **`FahrtDetail.tsx` was entirely read-only** - no edit, no Art toggle after creation. Added
a "Bearbeiten" toggle reusing `FahrtFelder.tsx`, backed by a new `DataMetricApi
.fahrtAktualisieren()` (mirrors the existing `tankvorgangAktualisieren()` pattern) calling
the backend's `audi_dashboard_fahrt_aktualisieren` service (built this session on the panel
side, so the backend contract already existed and needed no changes).
**Explicitly investigated and found NOT to apply** (worth recording so it isn't re-attempted):
price-per-litre rounding (companion-app's `eur()` already always renders 2 decimals - the
3-decimal bug the panel had never existed here); pull-to-refresh `overscroll-behavior` (no
native pull gesture implementation here to fix); the panel's drag/paste/file-picker receipt
dialog (solves a **desktop browser clipboard** problem that doesn't exist in a Capacitor
native app - the plain `<input type=file>` already in `TankDetail.tsx` gets iOS's native
Files/Photos picker for free, which already covers "a PDF saved from Mail"); every
Setup-menu/entity-mapping feature (HA-panel-only concept, the companion app consumes the
backend's already-resolved published state and has no equivalent screen, nor should it);
the many purely cosmetic HIG-audit CSS fixes (icon fills, chevrons, tab-bar highlight
bugs, ...) - those target the panel's own hand-written markup/CSS, not
`@audi-dash/ui`'s components, and don't transfer.
Verified after every change: `npm run typecheck` (clean throughout), `npm run test` (95/95
passing, up from 90 - added interaction tests for the new create-form fields and the
edit-toggle pre-fill, plus 3 for `inspektionPrognose()`), `npm run build` (succeeds). Also
corrected a stale README claim ("Capacitor-Hülle und sichere Ablage" both listed as open) -
the secure-storage adapter is actually fully wired (`ablageNativ.ts`, activated from
`main.tsx`) and only the native `ios/`/`android/` platform folders themselves (deliberately
gitignored, regenerated via `npx cap add`) are the missing piece, and that step needs a Mac.
- [x] Übersicht: Kilometerstand-/Service-Kacheln neu gestaltet (2026-08-17) — Nutzer fragte, wie
Apple HIG und die Audi-CI das lösen würden. Befund: die Ölwechsel-Kachel presste zwei
verschiedene Größen in **ein** Zahlenfeld (`teile.join("/ ")``9,0 tkm/ 04/27`) — zwei
Einheiten, davon eine erfundene (**„tkm" schreibt niemand**, und die Mein-Audi-Servicebox
nannte dieselbe Zahl schon immer „9.029 km", d. h. dieselbe Tatsache war an zwei Stellen
unterschiedlich formatiert), zwei Schrägstriche mit zwei Bedeutungen, und auf 375 px
zweizeilig umgebrochen. Beide Regelwerke laufen hier auf dieselbe Auflösung hinaus, live
nachgelesen: Apple HIG *Layout* („Make essential information easy to find by giving it
sufficient space… don't obscure it by crowding it with nonessential details") und
*Typography* („Adjust font weight, size, and color… to help people visualize hierarchy");
die Audi-CI definiert `.fig` (Audi Type Wide 300) ausdrücklich als *„large numeric
figures"* — **eine** Zahl, nicht zwei. Umgesetzt: Wert und Kontext getrennt
(`9.029 km` groß, `vsl. 04/2027` als leisere Zweitzeile), echte Einheit statt „tkm", und
die Kachel ist jetzt ein `button.tile.tilebtn` mit `.go`-Chevron nach `service:x` — vorher
eine Sackgasse, obwohl die Service-Seite existiert. `mmjj()` wurde dadurch verwaist (nur
noch das volle Jahr wird gebraucht) und ist durch `mmjjjj()` ersetzt. Die HU-/Inspektions-
Zweige sind mit dem Ölwechsel-Zweig zusammengefallen, weil sie nach der Trennung dieselbe
Form haben. Live geprüft (Tag und Nacht, 375 px): beide Kacheln einzeilig, Tippen öffnet
Service mit aktivem Zurück-Pfeil.
**companion-app hatte diesen Fehler nicht** — dort standen die Termine schon als
Label/Wert/Zusatz-Zeilen. Stattdessen dort der benachbarte Gruppierungsfehler behoben: die
zwei Service-Zeilen hingen *innerhalb* der Kilometerstand-Kachel (zwei Termine unter einer
Überschrift, die nichts mit ihnen zu tun hat) und führten nirgendwohin. Jetzt eigene,
antippbare `Tile variant="button" chevron``service`. Geprüft: typecheck sauber, 95/95
Tests, Build erfolgreich.
- [x] Übersicht: Kilometerstand/Service statt zwei gleichrangiger Kacheln (2026-08-17) — Nutzer
mochte das Kachelpaar nicht und fragte, wie Apple HIG bzw. die Audi-CI es lösen würden. Beide
Regelwerke wurden dafür belegt, nicht aus dem Gedächtnis zitiert: die HIG-Seiten „Layout“
(„Differentiate controls from content“; Wichtigkeit über Position und Größe) und „Widgets“
(dynamische statt statischer Inhalte; „sparse layouts can make the widget seem unnecessary“)
live von developer.apple.com gelesen, die CI-Regeln aus dem projekteigenen
`AUDI_CI_ARCHIV_2026-08-13.md` (flache Flächen mit Haarlinien statt gestapelter Karten;
`.fig`/Audi Type Wide als eigene typografische Klasse für Zahlen; Hierarchie nur über Größe
und Farbe, da nur die Schnitte 300/400 existieren). Gemessener Befund: beide Kacheln
166×113px, gleiche 29px-Zahl, gleiche Farbe — obwohl nur die rechte ein Knopf war (erkennbar
allein an einem 12px-Chevron) und die beiden Zahlen Grundverschiedenes meinen (Ist-Wert vs.
täglich schrumpfender Countdown). Beide Schulen sagen dazu dasselbe, sie unterscheiden sich
nur im Mittel. Ergebnis nach drei Iterationen mit dem Nutzer (Zwischenstände jeweils als
Mockup gezeigt, bevor Code angefasst wurde): das `.duo`-Raster entfällt, stattdessen eine
führende Kennzahl in einer normalen `.tile` mit Hintergrund (erst flach entworfen, auf
Nutzerwunsch dann doch als Box) — Zeile „Nächster Service:“, Chevron oben rechts, 40px-Zahl,
Unterzeile „bis zum Ölwechsel · vsl. 15.04.2027“, Haarlinie, darunter der Kilometerstand als
`.servicezeile`. Der Chevron sitzt bewusst nur am `.serviceblock`-Knopf, nicht über der ganzen
Fläche: damit ist der obere Teil das Bedienelement und die Kilometerstand-Zeile reine
Anzeige (live geprüft — Klick auf den Block öffnet „Service“, Klick auf die Zeile navigiert
nicht). „Nächster Service“ meint auf Nutzerwunsch **jede** Serviceart; `naechsterTermin()`
wählte ohnehin schon über Ölwechsel/Inspektion/Hauptuntersuchung hinweg aus, neu ist nur die
Beschriftung samt `ART_BIS`-Zuordnung für den richtigen Artikel („bis zu**m** Ölwechsel“, aber
„bis zu**r** Inspektion“). Fällt kein Restkilometerwert an (Hauptuntersuchung hat kein
km-Ziel), rückt das Datum selbst ins Zahlenfeld, dann bei 30px statt 40px, damit „15.04.2027“
auf 375px nicht umbricht. Von der Änderung verwaist und deshalb mitentfernt: `mmjjjj()` (nur
hier benutzt) und die `.duo`-Regeln in beiden Stylesheets. **Bewusst offen gelassen:** der
Statuspunkt aus der HIG-Variante („alles ruhig“ vs. „bald fällig“) — der Nutzer hat ihn nicht
übernommen. **Bewusst nicht entschieden von mir:** das volle Tagesdatum statt „04/2027“ war
Nutzerwunsch; angemerkt wurde dabei, dass die Prognose an einem einzigen Arbeitstag von
12.04. über 14.04. auf 15.04.2027 wanderte (`oelwechselPrognose()` extrapoliert aus km/Tag,
jede Odometer-Aktualisierung verschiebt sie), das Tagesdatum also mehr Genauigkeit verspricht,
als der Wert hat. Live geprüft bei 375px und 1280px: Block und Wertezeile fluchten mit der
Kachelkante (16→359 bzw. 564→1236), Chevron bündig rechts, Tippfläche 88px hoch, keine neuen
Konsolenfehler. Deployed als Version `1787007000` (zwischenzeitlich auf Nutzerwunsch von
`.tile.flat` auf eine normale `.tile` mit Hintergrund umgestellt — `.serviceblock:active
{opacity:.55}` als Tipp-Rückmeldung ergänzt, da der übliche `.tilebtn`-Hintergrundwechsel
hier nicht geht), synchron in `installationspaket/`. Entwurf und Begründung als Artefakt:
„Zwei Kacheln, drei Antworten“.
- [x] Nächster-Service-Box: CI-Icon statt "bis zum/zur ..."-Wortlaut (2026-08-18) — der Nutzer
lehnte zunächst einen Folge-Entwurf ab, der dieselbe HIG/CI-Systematik auf die "Mein Audi"-
Service-Vorschau und "Anstehende Termine" auf der Service-Seite anwenden sollte ("I don't like
it" → per Rückfrage: "Lost information", also zu viel weggelassen) — dieser Vorschlag wurde
**nicht** umgesetzt, keine Codeänderung daraus übernommen. Stattdessen ein präziser, neuer
Wunsch für die bereits bestehende Nächster-Service-Box auf der Übersicht: das Wort
("bis zum Ölwechsel"/"bis zur Inspektion") vor der Kennzahl entfällt zugunsten der echten Audi-
CI-Icons `inspection-l/-s`/`oil-change-l/-s` (vom Nutzer als SVG geliefert, aus
`C:\Users\tobia\Downloads\Icons 2026-08-09 13_04_03\`, verbatim übernommen wie die übrigen
CI-Icons dieses Projekts — l im 48er-, s im 24er-Grid), links der Zahl platziert; auf
Nutzerwunsch anschließend nochmal um 30% vergrößert (24px → 31px). Umgesetzt in beiden
Codebasen: Panel bekam `CI.oilChangeL/-S`/`CI.inspectionL/-S` (neben den bestehenden CI-
Icon-Konstanten) und ein neues `.fig-zeile`-Wrapper-Div (`display:flex;align-items:center`)
um Icon + `.fig` in `vHome()`'s Serviceblock; die Nebenzeile zeigt jetzt nur noch
`vsl. DATUM`. Companion-app bekam die identischen Pfade als neue `SymbolOelwechsel`/
`SymbolInspektion`-Komponenten in `symbole.tsx` (eigener `RahmenFlaeche`-Wrapper, da diese
zwei — anders als die generischen Tab-Icons dort — gefüllte Flächen-Icons sind, kein
stroke-Wrapper) und eine neue `.dm-servicefig`-CSS-Klasse in `screens.css`; `Uebersicht.tsx`
wählt das Symbol über eine kleine `SERVICE_SYMBOL`-Tabelle nach `service.art`. **Wichtig:** die
Icons blieben bewusst außerhalb von `design-system/` (Lizenzregel oben — echte Audi-CI-Assets
dürfen nur in Panel/companion-app, nicht in das markenfreie `@audi-dash/ui`) — die Verdrahtung
liegt deshalb direkt im jeweiligen Screen, nicht in der gemeinsamen `Fig`-Komponente. Die
Hauptuntersuchung hat kein eigenes Icon (kein Restkilometer-Ziel, `restKm` ist dort immer
`null`) und behält `bisText()` als einzige Kennzeichnung — das deckt sich in beiden Codebasen
genau mit der Bedingung, unter der ein Icon überhaupt existiert. `npm run typecheck`/
`npm run test` (100/100)/`npm run build` sauber. Live im Docker-Testcontainer geprüft (Version
`1787012001`, synchron in `installationspaket/`): Ölwechsel-Icon rendert links der "8.999 km"-
Kennzahl, Unterzeile zeigt nur noch "vsl. 18.04.2027", keine neuen Konsolenfehler.
- [x] Nächster-Service-Box: Hauptuntersuchung-Datum in Zahlengröße + Art-Name statt "bis zur ..."
(2026-08-18) — Folgewunsch zum Icon-Umbau: das Datum im Zahlenfeld war für die
Hauptuntersuchung (kein Restkilometer-Ziel) bisher kleiner gesetzt (30px statt 40px), extra
damit lange Daten wie "15.04.2027" auf 375px nicht umbrechen; der Nutzer wollte stattdessen
dieselbe 40px-Größe wie bei Ölwechsel/Inspektion, plus darunter statt "bis zur
Hauptuntersuchung" nur noch das Wort "Hauptuntersuchung" selbst. Vor der Umsetzung am
laufenden Panel nachgemessen (nicht angenommen): "15.04.2027" misst bei 40px/Audi Type Wide
182px, der Kachel-Container hat 322px — die ursprüngliche Wrap-Sorge, die zur 30px-Regel
geführt hatte, bestätigt sich bei den tatsächlichen Container-Maßen nicht. `zahlGroesse` ist
jetzt fest 40 (Panel `vHome()` und companion-app `Uebersicht.tsx`, `Fig size={40}`
unabhängig von `restKm`). Da `bisText()`/`ART_BIS` (Panel) und `bisText()`/`BIS_TEXT`
(companion-app `service.ts`) dadurch ihre einzige Aufrufstelle verloren, wurden beide als
Orphans entfernt (inkl. des zugehörigen companion-app-Tests in `service.test.ts` — von 100 auf
99 Tests, kein Funktionsverlust, nur der jetzt toten Funktion beraubt). Live im
Docker-Testcontainer per DOM-Patch simuliert (Testfixture hat aktuell Ölwechsel als
nächstgelegenen Termin, nicht die Hauptuntersuchung) und bei 390px Breite screenshotet:
"06.08.2028" einzeilig bei 40px, "Hauptuntersuchung" darunter, kein Icon (wie vorgesehen — es
gibt kein CI-Icon für die Hauptuntersuchung). `npm run typecheck`/`npm run test`
(99/99)/`npm run build` in companion-app sauber. Deployed als Panel-Version `1787012002`,
synchron in `installationspaket/`.
- [x] "Anstehende Termine"-Box (Service-Seite) umgebaut: drei sichtbar getrennte Blöcke
(2026-08-18) — auf Wunsch des Nutzers ein neuer Entwurf (Apple HIG + Audi CI, wie beim
Übersicht-Umbau), mit expliziten Vorgaben: Herstellervorgabe bleibt für Ölwechsel und
Inspektion möglich, "voraussichtlich ..." bleibt für beide erhalten, alle drei Arten werden
sichtbar getrennt, die Hauptuntersuchung bekommt bewusst weder Herstellervorgabe noch
Prognosezeile. Durchlief drei Mockup-Runden als Artefakt ("Drei Termine, drei Blöcke") vor der
Umsetzung: **Runde 1** abgelehnt ("Major Information is missing"). **Runde 2** ergänzte ein
vollständiges Feld-Inventar (Tabelle: jedes Element aus `vService()` mit Fundstelle im
Vorschlag), integrierte den vereinbarten Werkstatttermin in den Hauptvergleich statt als
Anhang, zeigte den "kein Eintrag im Servicebuch"-Zustand. **Runde 3** (auf drei
Inline-Kommentare des Nutzers im Artefakt): Ölwechsel-Icon von 20px auf 26px angehoben (füllt
in seinem 24×24-Raster nur eine 24×10-Fläche statt der fast vollen Höhe des
Inspektion-Icons — per `getBBox()` nachgemessen, kein exakter Ausgleich möglich ohne das Icon
unverhältnismäßig breit zu machen), Herstellervorgabe-Zeile auch am Ölwechsel-Beispiel gezeigt
(vorher nur bei Inspektion), der Hauptuntersuchungs-Block von einem Icon-Platzhalter-Abstand
auf echt linksbündig geändert. Danach vom Nutzer freigegeben ("Ok, implement it").
**Umsetzung, beide Codebasen:**
Panel (`vService()`, `audi-dashboard-app.js`): `<dl class="rows">` durch drei
`<div class="termingruppe">` ersetzt (Kopfzeile mit Icon + Art-Name + optionalem
`.tg-chip` für einen vereinbarten Werkstatttermin, optionale `.tg-vorgabe`-Zeile, `.tg-wert`
als 22px/Gewicht-300-Zahl, optionale `.tg-prognose`-Fußzeile) — Auswahllogik für Wert/
Fußzeile/Herstellervorgabe unverändert aus dem bisherigen Code übernommen, nur die Markup-
Struktur geändert. `ART_ICON`/`ART_ICON_GROESSE` (26px Ölwechsel, 20px Inspektion) von
`vHome()` auf Modulebene gehoben, da jetzt von zwei Stellen genutzt — `vHome()`s eigene
Icon-Größe (31px, beide gleich) bewusst unverändert gelassen, das ist eine offene, dem Nutzer
gestellte Rückfrage. Der Werkstatt-Chip nutzt `var(--red)` nur als Text-/Rahmenfarbe, keine
Füllung — deckt sich mit der bereits bestehenden Projektregel "Rot ist Akzent, nie Fläche"
(siehe iOS-Overlay-Fix vom 2026-08-13). Deployed als Version `1787013000`, synchron in
`installationspaket/`, live im Docker-Testcontainer per direkter DOM-Abfrage geprüft (JS statt
Screenshot, da die Browser-Pane in dieser Sitzung zeitweise nicht kompositierte): alle drei
Blöcke rendern mit korrektem Text, Icons in 26px/20px, Hauptuntersuchung ohne Icon, keine
neuen Konsolenfehler.
companion-app (`Service.tsx`): dieselbe Drei-Blöcke-Struktur, aber eine **echte
Neustrukturierung, kein 1:1-Kopieren** — die bisherige Kachel hieß "Nächster Ölwechsel" und
mischte Ölwechsel-, Inspektions- und Hauptuntersuchungs-Zeilen fachfremd in einer gemeinsamen
`Werteliste`; jetzt "Anstehende Termine" wie im Panel, mit denselben drei `.dm-termingruppe`-
Blöcken. Neue `SymbolOelwechsel`/`SymbolInspektion`-Komponenten in `symbole.tsx` (bereits für
die Übersicht angelegt, hier wiederverwendet). Neuer Export `letzterInspektion()` in
`service.ts` (Gegenstück zu `letzterOelwechsel()`) für den "kein Eintrag"-Zustand der
Inspektion. **Zwei bewusste, dokumentierte Vereinfachungen gegenüber dem Panel** (vorbestehende
Lücken, nicht durch diese Änderung verursacht): (1) companion-app hat keine "Termin
vereinbaren"-Funktion und keine `vereinbart`-Datenfelder für Service-Termine — der Werkstatt-
Chip entfällt deshalb ersatzlos, es gibt nichts anzuzeigen. (2) companion-app hat kein
Gegenstück zu `intervalle()`/dem generischen Herstellerintervall-Fallback des Panels; die
Herstellervorgabe-Zeile wird stattdessen direkt aus `fahrzeug.oel.modus` abgeleitet (Ölwechsel:
nur bei `modus === "hersteller"`, Inspektion: immer, da sie kein einstellbares Intervall hat —
deckt sich inhaltlich mit dem Panel-Verhalten, auch ohne dieselbe Funktion zu teilen). Beide
Punkte sind funktional gleichwertig zum bisherigen companion-app-Stand, nicht neu eingeführte
Lücken. `npm run typecheck` sauber, `npm run test` 99/99, `npm run build` erfolgreich; die
bestehenden "Alle Seiten rendern mit Beispieldaten"-Tests rendern die Service-Seite weiterhin
ohne Absturz. Kein Live-Screenshot in dieser Sitzung (bräuchte einen laufenden Dev-Server samt
Backend-Anbindung) — strukturell verifiziert über Typecheck/Tests/Build.
**Zwei Nachbesserungen direkt nach der ersten Live-Prüfung, beide vom Nutzer gemeldet:**
(1) "Herstellervorgabe" fehlte beim Ölwechsel, obwohl der angezeigte Wert (29.100 km /
25.07.2028) vom Fahrzeug selbst gemeldet wird (`hatMeldung`/`fahrzeug.oelwechselFaelligTs`).
Root Cause: die Zeile war bisher ausschließlich an `iv.hersteller` (Panel) bzw.
`fahrzeug.oel.modus === "hersteller"` (companion-app) gekoppelt — dem *App-eigenen*
Intervall-Modus aus "Einrichten". Der Bordcomputer kennt diesen App-Modus aber gar nicht;
eine Fahrzeugmeldung stammt immer aus dem werkseigenen Wartungsprogramm und ist damit per
Definition Herstellervorgabe, unabhängig vom gewählten App-Modus. Das Testprofil hat den
Ölwechsel aktuell auf `modus: "eigen"` stehen (10.000 km/12 Monate statt 30.000 km/24
Monate) — das war korrektes Verhalten der alten Bedingung, nur die Bedingung selbst war zu
eng. Fix: `herstellervorgabe = hatMeldung || iv.hersteller` (Panel) bzw.
`oelHatMeldung || fahrzeug.oel.modus === "hersteller"` (companion-app) — dieselbe alte
Bedingung bleibt für den Fall ohne Fahrzeugmeldung erhalten (App-eigener Fallback auf das
Herstellerintervall), kommt jetzt aber zusätzlich bei einer echten Fahrzeugmeldung zum
Tragen. (2) Die Prognose-Fußzeile zeigte "8.999 km, voraussichtlich am 18.04.2027" auch dann,
wenn in "Einrichten" tatsächlich Herstellervorgabe gewählt ist — dort rechnet die eigene
Prognose ohnehin schon mit dem Herstellerintervall, die Restkilometerzahl wäre also nur eine
zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert direkt darüber. Fix: die "X km,"-
Vorsilbe entfällt, sobald `CAR.oel.modus`/`fahrzeug.oel.modus === "hersteller"` ist — nur beim
eigenen (kürzeren) Intervall bleibt sie stehen, weil sie dort echte Zusatzinformation ist
(matcht damit exakt, wie die Inspektion-Fußzeile schon immer aussah, da sie kein einstellbares
Intervall hat). Mit dem Testprofil (`modus: "eigen"`) bleibt die Kilometerzahl also weiterhin
sichtbar — korrekt, das ist genau der Fall, für den sie gedacht ist; umschalten auf
Herstellervorgabe in "Einrichten" würde sie ausblenden. Beide Fixes deployed als Panel-Version
`1787013001`, synchron in `installationspaket/`, live per DOM-Abfrage geprüft: Ölwechsel-Block
zeigt jetzt "Herstellervorgabe", keine neuen Konsolenfehler. companion-app: `npm run typecheck`
sauber, `npm run test` 99/99, `npm run build` erfolgreich.
- [x] **Parity rule eingeführt (2026-08-17):** der Nutzer stellte klar, dass Panel und
`companion-app/` immer denselben funktionalen Stand haben sollen — auch wenn eine Anfrage nur
das Panel nennt, das ist reiner Testkomfort (Docker-Instanz), keine Scope-Entscheidung. Regel
als verbindlicher Absatz direkt unter der Maintenance-Regel oben in dieser Datei verankert.
Als erste Anwendung sofort im selben Zug die Übersicht-Neugestaltung von oben nach
`companion-app/` portiert (siehe `Uebersicht.tsx`): `naechsterService()` in `daten/service.ts`
neu — Gegenstück zu `naechsterTermin()` im Panel, wählt über Ölwechsel-/Inspektions-Prognose
und die von Hand gepflegte Hauptuntersuchung hinweg den zeitlich nächsten Termin, `bisText()`
liefert denselben Artikel wie `ART_BIS` im Panel. Die beiden bisherigen Kacheln
"Reichweite"/"Kilometerstand" nebeneinander plus eine separate "Service"-Kachel mit
Werteliste wichen einer Reichweiten-Kachel plus einer Service-Kachel mit `.dm-serviceblock`
(Knopf, nur der obere Teil) und `.dm-servicezeile` (reine Anzeige) darunter — dieselbe
Control/Content-Trennung wie im Panel. `npm run typecheck` blieb sauber, `npm run test` auf
100/100 (von 95 — vier neue Tests für `naechsterService()`/`bisText()` in `service.test.ts`,
einer in `screens.test.tsx`, der mit einem `vi.fn()` als `geheZu` wirklich belegt, dass ein
Klick auf den Serviceblock navigiert und ein Klick auf die Kilometerstand-Zeile es nicht tut
— dafür musste `zeige()` in `screens.test.tsx` erst einen injizierbaren `geheZu`-Parameter
bekommen, vorher war er hart auf `() => {}` verdrahtet), `npm run build` erfolgreich.
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
list further drift not yet fixed (WLAN references still in `SPECIFICATION.md`,
`homeassistant/README.md`, the profile template)
- [x] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` — done on the
`umsetzung-datametric360` branch (2026-08-11), brought over by the 2026-08-13 merge. Returns
`None` on a missing or malformed file instead of raising; all 5 call sites
(`backup.py` ×2, `fahrterkennung.py`, `frontend_veroeffentlichung.py`, `reifenzaehler.py`,
`tankerkennung.py`) already guard for `None` — verified by grep across the merged tree, no
caller left unguarded (this was flagged as the merge's main risk in `REVIEW_main_2026-08-13.md`
§"Was den Branch betrifft").
- [x] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 — decided
2026-08-11 (`UMSETZUNGSPLAN.md` Phase 2): swipe-delete keyboard fallback, popup keyboard
access, and self-hosting Leaflet stay unfixed in this panel and are addressed only in
DataMetric360.
- [x] Persist the RAM-only states (trip start, fuel low-water-mark) — erledigt mit dem Umbau zur
echten Integration: beide liegen im Laufzeit-Store des Koordinators
(`…_laufzeit`: `fahrt_start_ts`, `tiefststand_pct`, `tiefststand_liter`) und überleben einen
Neustart. Am 2026-09-01 am laufenden Container nachgesehen.
- [x] Fahrzeugfoto hochladen — erledigt, über die App (`seitenansicht.webp`, 2026-08-31)
- [ ] `steuer.faellig` setzen (Betriebsdaten, kein Code) — steht noch auf `None`
### D) Once the panel is superseded
- [ ] **Archive** `audi-dashboard-app.js`, don't delete (settled decision, architecture §1)
### E) Versicherungs-Details overhaul (planned, not started — noted 2026-08-19)
The owner wants to revisit "Versicherung/Steuer" more broadly next session, beyond the line-editing
work already done (see section C in the changelog above: "Vertrag" tile made fully editable with
add/delete for Selbstbeteiligung). Three separate asks, none scoped or started yet:
- [ ] Store the insurer's own Vertragsdetails PDF against the policy (upload/attach + view later) —
needs a decision on storage location (mirrors the existing vehicle-photo/backup file handling
under `www/bilder/`? a new profile-attachment concept?) and where it's surfaced (new tile on
"Vertragsdetails", or the main "Vertrag" tile)
- [ ] A free-text field for the owner's own manual notes/summary on the insurance (not derived from
any structured field) — likely a `notiz`-style textarea, panel already has a `.notiz` CSS class
pattern used elsewhere (see `feld.notiz` in `audi-dashboard.css`) that could apply here
- [ ] Redesign the "Versicherung/Steuer" Übersicht/summary box — **owner explicitly said they don't
yet know what they want here**; needs a requirements conversation (what should be surfaced at a
glance vs. moved to detail screens) before any mockup, not just a straight implementation
Not planned/scoped in either codebase yet — start with clarifying questions on the PDF storage model
and the summary-box redesign goals before touching code, per this project's mockup-first convention
for open-ended redesign asks (see the "Anstehende Termine" 3-round mockup process, section C).
### F) Retroactive data import from the HA recorder (built 2026-08-23)
**The retention answer, because it was the owner's actual question.** Two different lifetimes were
being conflated:
- **The app's own records live forever already.** `fahrten.jsonl`, `tankvorgaenge.jsonl`,
`batteriespannung.jsonl` and `fahrzeugprofil.json` under `/config/audi_dashboard/` have no purge
path anywhere in `profil.py` — nothing trims them, ever. That was already true before this change.
- **HA's raw sensor history did not.** `recorder.purge_keep_days` defaults to **10 days**, and the
test instance had no `recorder:` block at all. That default was the real ceiling: it caps how far
back *any* reconstruction can ever reach, because deleted recorder rows are gone permanently.
`homeassistant/recorder_snippet.yaml` (new) raises it to 365 days with `auto_repack: true`. Measured
on the test instance rather than guessed: 90.858 state rows over 17 days ≈ 5.300/day, 64,7 MB DB →
roughly 1,9 M rows and 11,5 GB for a year. 96 % of that traffic is the `cupra_eu_data_act`
integration (~1.737 states per entity per 17 days across ~50 entities), 1 % flespi/FMM003, 2 % the
rest — so the file carries a commented-out `exclude:` list targeting the EU-Data-Act side for anyone
who needs to shrink it. Deliberately **no `include:` block**: an include would stop HA recording
everything else entirely, and since 97 % of this instance is vehicle data it would save almost
nothing while costing every other integration its history.
**The import itself.** `pyscript/historienimport.py` (new) exposes
`audi_dashboard_historie_importieren(start, ende)`. It reads the same sensors the live triggers
watch and derives the same records they would have produced:
- trips from the ignition sensor — contiguous "on" stretches, then merged where the gap is shorter
than `fahrten_pausenzeit_min`, which reproduces the live pause rule (`task.unique()` +
`task.sleep()` in `fahrterkennung.py`) after the fact; odometer, GPS start/end filled from the
other histories; stretches under 60 s dropped as ignition-without-a-drive
- refuels from the tank sensor using the same low-water-mark logic and the same thresholds as
`tankerkennung.py` (5 l / 9 pp, whichever is more sensitive)
- daily min/max battery voltage, same shape as `batterieverlauf.py`
Re-running is safe by design: trips whose window overlaps an existing trip are skipped (whatever
their origin), refuels dedupe on a 90-minute window, battery days merge in `profil`'s own
min/max update. Created records carry `source: "import"` alongside the existing `ha`/`manual`/`auto`.
**How the history is read, and why.** Via HA's own
`homeassistant.components.recorder.history.get_significant_states` through `task.executor()`, not by
opening `home-assistant_v2.db` with sqlite3 directly. Direct SQL was the tempting option
(`allow_all_imports` is already on, no config change needed) but the recorder schema is HA-internal
and changes between versions; the function is the interface HA itself uses. The cost is one new
config line, **`hass_is_global: true`** in the pyscript block (`configuration_snippet.yaml`), without
which the `hass` name does not exist in pyscript — everything else in the app works fine without it,
only the import reports that it cannot read the history. `significant_changes_only=False` matters:
the default mode drops exactly the small numeric steps that odometer distance and refuel detection
are built from.
**Frontend, both codebases.** Panel: `vImportPopup()` reusing the existing `.beleg-popup` overlay
pattern, opened from a new button under Einstellungen → Einrichten, with Von/Bis
`datetime-local` fields defaulting to the last 30 days, "Importieren" primary and "Abbrechen"
second. The dialog stays open and shows the result rather than closing optimistically — the one
place in the app where that is right, because the result *is* the point of the action. Progress
comes from polling `pyscript.audi_dashboard_import_status` (same pattern as the update status),
since pyscript services return immediately while the work continues in the background.
Companion: `screens/HistorienImport.tsx` using the design system's `Popup variant="form"`, plus
`api.historieImportieren()` / `api.importStatusLesen()`. Deliberately **not** routed through the
offline queue like every other write: the import is a long-running operation whose result is the
reason you invoked it, and a queued run firing later from a dead session would surprise the user —
offline, it simply should not be offered.
**One real bug found and fixed while testing:** `profil.batterieverlauf_tageswert_aktualisieren()`
appended in insertion order. That was invisible while only the live path wrote it (it always adds
today, i.e. always the newest), but the import adds *past* days — they landed after newer ones and
the frontend chart, which draws in file order, would have run backwards. It now sorts by date,
restoring the invariant its own docstring already promised.
**Verification.** The test container is not running while the car is driven (owner's note), so its
recorder holds no real trips. Instead a synthetic history was seeded straight into the recorder DB —
two drives, one with a 5-minute mid-drive ignition gap, one 30-second ignition burst, a two-step
refuel, and two days of voltage — and the import reproduced it exactly: 3 trips (the gap correctly
merged into one 50-minute/40 km trip), 1 rejected as too short, 1 refuel, 2 battery days, distances
matching the seeded odometer to the kilometre. A second run over the same window created 0 and
reported 3 as already present, confirming dedup. Both runs were driven through the real UI in the
browser, not by calling the service directly. `tsc --noEmit` clean, companion tests 106/106 (6 new
for the result-sentence pluralisation), `vite build` clean, HA config check and startup clean.
**Still open / worth knowing:** the import has only ever run against seeded data, never against a
real multi-week recorder history on the production instance — the first real run is the honest test,
especially for how long a full year takes (the UI waits up to two minutes before saying it continues
in the background). Address reverse-geocoding is not attempted for imported trips (`start_address`/
`end_address` stay null, same as live-detected ones before the user edits them).
### H) HACS: the pyscript backend became a real integration (2026-08-23)
Owner asked for it directly ("build the hacs") after deferring it earlier the same day. The
deferral reasoning still held — converting first means debugging the rewrite and the real
environment at once — but the owner overrode it, so the conversion happened.
**What was actually blocking HACS.** Two independent things, both checked against the installed
HACS 2.0.5 rather than from memory:
- **No HACS category installs to `/config/pyscript/`.** The six categories resolve to
`custom_components/<domain>/`, `www/community/<name>/`, `python_scripts/`, `themes/`,
`custom_templates/` and `appdaemon/apps/<name>/` (verified in `repositories/*.py``localpath`).
The app needed `/config/pyscript/` **and** `/config/audi_dashboard/` **and** `configuration.yaml`
entries. This is what the conversion solved.
- **HACS is GitHub-only, and does not work here — permanently, not just today.** `github.com`/
`api.github.com` are hardcoded throughout; the repo lives on `gitea.nothaft.cloud`. Briefly
mirrored to `github.com/T130B/DM360` on 2026-08-24, but that changes nothing: **HACS
categorically refuses private repositories**, confirmed against its own docs
(hacs.xyz/docs/faq/private_repositories: "Private GitHub repositories can not be used with HACS
at all... HACS can only get publicly available information") — no token, no signed-in-account
exception. An earlier note in this file claimed the opposite ("HACS must be signed in with the
account that owns it"); that was wrong and is corrected here. The repo is **private on purpose**
(it carries the Audi typeface and the model badges, licensed for this one private install only),
so making it public to satisfy HACS is not on the table — and since the mirror bought nothing
(HACS still can't see a private repo, mirrored or not), the owner decommissioned it the same day.
**Development is Gitea-only from here on; there is no GitHub presence.** `hacs.json` was removed
(dead weight with no GitHub target to serve), and `manifest.json`'s `documentation`/
`issue_tracker`/`codeowners` point at the Gitea repo instead.
`homeassistant/installationspaket/install.ps1` is not a fallback — it is **the only installation
path**, and the docs (`README.md`, `homeassistant/INSTALL.md`, `installationspaket/ANLEITUNG.md`,
`VERSIONIERUNG.md`, the script's own header) say so. Don't reintroduce a GitHub mirror or
`hacs.json` without the owner raising it again — the private/public constraint that killed this
path hasn't changed and isn't expected to.
**The shape.** `custom_components/audi_dashboard/` at repo root — not for HACS's benefit (see
above, HACS is off the table), just where `install.ps1` and Home Assistant itself expect a
`custom_components` entry to live. Config flow, one
`sensor` platform, 18 services, ~2,900 lines of pyscript ported to ~2,400 lines of integration.
`koordinator.py` holds the runtime state and does the wiring that `@service`/`@state_trigger`/
`@time_trigger` used to do implicitly; the domain modules keep their old names and structure so the
diff stays readable. `modules/` was flattened (it only existed because pyscript treats that folder
specially). Three renames earned their keep: `profil.py``ablage.py` (it handles trips and
fill-ups too, the old name was actively wrong), `entitaeten.py``zuordnung.py` (avoids collision
with HA "entities"), `frontend_veroeffentlichung.py``veroeffentlichung.py`.
**Five install steps disappeared, and none of them by hand-waving:**
| Was | Now |
|---|---|
| `pyscript:` block with `allow_all_imports` + `hass_is_global` | gone — no YAML at all |
| `panel_custom:` block | the integration registers its own panel |
| copy the frontend to `/config/www/` | served from `frontend/` under `/audi_dashboard_static/` |
| long-lived token in `audi_dashboard/ha_token.txt` | recorder API read directly (`verlauf.py`) |
| `pip install pypdf` in the container | `requirements` in `manifest.json`, HA installs it |
| copy `fahrzeugprofil.example.json` by hand | seeded from `vorlage/` on first setup |
Verified on a genuinely fresh throwaway container, not reasoned about: 12 entities, zero trips,
zero fill-ups, zero battery days, profile created from the template, and **pypdf 6.16.2 installed
by HA itself**.
**The naming contract changed, and both frontends had to follow.** `pyscript.audi_dashboard_x`
`sensor.audi_dashboard_x`, `pyscript.audi_dashboard_y``audi_dashboard.y`. The prefix only ever
existed because pyscript owned the domain. Both frontends now carry the names in one table each
(`const.py``frontend/audi-dashboard-app.js` `E`/`DOMAIN``companion-app/src/api/types.ts`
`ENTITAETEN`/`DIENST_DOMAIN`). **A companion app from before this change finds nothing after the
upgrade** — it must be rebuilt in the same step. The panel can't drift: it ships inside the
integration.
**Three long-standing weaknesses fixed, because the conversion made them cheap:**
1. **The payload no longer hits the recorder database.** `_unrecorded_attributes = {"daten"}` on the
entity. Only a real entity belonging to an integration can do this — pyscript's bare `state.set()`
can't, so every publish wrote the complete trip archive to the DB, every 60 seconds. Confirmed by
querying the history API: only `icon` and `friendly_name` come back. Note the mechanism moved:
exclusion now comes from `state_info["unrecorded_attributes"]` on the Entity, **not** from a
per-domain `recorder.py` platform hook (checked in `db_schema.py` of the installed 2026.8.0).
2. **A running trip survives a restart.** `fahrt_start_ts` and the tank low-water mark live in a
`Store` instead of only in memory. This was a documented data-loss bug.
3. **Trigger rebinding is live.** `@state_trigger` baked the entity ID in at module load, so
changing ZUENDUNG/KM/TANK_SENSOR needed an HA restart — the setup dialog had a warning and a
restart button for exactly that. `trigger_neu_binden()` replaces all three; the warning, the
button and the `neustart` service are gone.
**Fixing (2) exposed a new failure mode, and it was found by testing rather than reasoning.** If the
ignition goes off *while HA is down*, nobody sees the transition — the resumed trip would stay open
forever and the next real trip would be appended to its start. `nach_neustart_fortsetzen()` closes
it at the last recorded ignition timestamp (not "now" — HA may have been down for days), and runs
via `async_at_started` so a not-yet-restored source can't be mistaken for "off". Verified live: an
orphaned trip was closed at 21:12:43, the last value the recorder held.
**The self-updater is gone.** `updateverwaltung.py` cloned a git repo into a staging folder and
copied `pyscript/` and `www/` over the running code — necessary only as long as the app had no
install shape Home Assistant knows. HACS is that shape: it checks, downloads, installs, shows a
changelog and can roll back. A second, homemade update mechanism beside it is more attack surface
than value. The Settings tile now shows the installed version and points at HACS.
**Versioning collapsed to one number.** `manifest.json` `version` is the single source: HA lists it,
HACS compares it, the integration publishes it as `sensor.audi_dashboard_app_version`, it hangs on
the panel URL as `?v=…`, and vite reads it for `__APP_VERSION__` (hard-failing if absent). The
`VERSION` file and the Unix-seconds cache-buster in `audi-dashboard-version.json` are both gone —
two sources for one fact would have drifted, and the comparison would have gone silently wrong. The
new cache breaker is strictly better: it changes only on real changes. `VERSIONIERUNG.md` rewritten.
**Installer got smaller and much safer.** It copies one folder and touches nothing else. The
`configuration.yaml` manipulation — the one way an installer can stop HA from starting, and the
reason the old script needed timestamped backups, a conflict refusal and a read-back rollback — is
gone with the YAML it used to write. What remains: refuses to delete a folder whose `manifest.json`
doesn't claim `domain: audi_dashboard`, reads the manifest back after copying. Tested on a mock
config dir: dry run, first install, update run, and the refusal path.
**Removed from the repo** (recoverable via `git log`): `homeassistant/pyscript/`,
`homeassistant/www/audi-dashboard-*` + `badges/`, `homeassistant/data/`, `homeassistant/install.sh`,
`homeassistant/update.ps1`, the `installationspaket/` copy of everything (the duplicate-and-sync
arrangement is gone — the installer reads the real folder), `configuration_snippet.yaml` (nothing
left to add), `ANLEITUNG.pdf` (stale and not regenerable here; the `.md` beside it is current).
`data/tests/``tests/belegparser/`, pointing at the integration's parser copy; 8/8 still pass.
**Do not resurrect the pyscript tree.** If something is missing, port it — running both backends
writes two sets of trips into the same files.
**Don't re-litigate HACS.** Confirmed dead-end, not a gap — see the H) intro above. Nothing about
Gitea-vs-mirrored-GitHub changes it: HACS refuses private repos regardless of where they're
mirrored, and the private/public constraint (licensed Audi assets in the repo) isn't going away.
`install.ps1` is the permanent, only install path, not a stand-in.
**Stale HACS claims found and fixed in code comments/UI text (2026-08-24).** The docs-level cleanup
above happened the same day as this section was written, but a full-repo grep afterward (prompted by
the owner spotting live UI text still claiming "Updates laufen über HACS") turned up leftovers the
first pass missed, because it focused on the standalone docs (`README.md`, `INSTALL.md`, etc.) and
not source comments or UI strings. Fixed: the panel's Einstellungen → Version tile literally told the
user HACS would notice and install updates itself — replaced with the actual mechanism
(`install.ps1`, panel ships inside the integration and can't go stale on its own). Also fixed,
all now pointing at `install.ps1`/"jeder Auslieferung" instead of "HACS"/"HACS-Update": comments in
`audi-dashboard-app.js` (the version-tracking block and the retired-updater history note),
`companion-app/vite.config.ts` (manifest-version header comment), `const.py` (`STATIK_URL`,
`BUENDEL_ORDNER`, `BILDER_ORDNER` comments), `bilder.py` (module docstring), `__init__.py`
(`cache_headers=False` rationale), and a diagram branch in `VERSIONIERUNG.md` that stated "HACS
vergleicht sie gegen das Repository und meldet Updates" as flat fact, contradicting the boxed note
one screen above it. Left alone: `install.ps1`'s own header (already correctly explains the
exclusion), `ota-paket.ps1`'s comment (already correct), and one debugging note in
`audi-dashboard-app.js` (~line 4720) that recalls what was actually running in a specific test
container during a past incident — a historical fact about that container, not a claim about how
this project works, so not in scope for this cleanup. Also out of scope, a different topic entirely:
mentions of the *retired vehicle-data-source* HACS integration `TommiG1/HA_VAG-EU-Data-Act` in older
planning docs (`SPECIFICATION.md`, `COMPANION_APP_ARCHITECTURE.md`, `bauauftrag.md/html`,
`testumgebung/`) — that HACS integration is unrelated to whether HACS can install *this* app; it was
FMM003's predecessor as a data source, already documented as retired elsewhere in those same files.
Manifest bumped to `2026.8.24.3` and `npm run ota` rerun (the panel text change is user-visible).
### The deployed-parity problem, and what was built for it (2026-08-23)
The parity rule (binding, above) guarantees *source* parity: both codebases change in the same
session. It guarantees nothing about what is *running*. After the conversion the two sides are
wired differently in a new way:
- the panel ships **inside** the integration — one `install.ps1` run (the only delivery path — HACS
is a dead end here, see section H) moves backend and panel together, and it cannot go stale at all
- the companion app is a Capacitor shell with `webDir: "dist"` and **bundled** assets, sideloaded
through Xcode. It stays on whatever was bundled at signing time, silently, indefinitely
The version comparison above is what makes that visible: the app compares its compiled-in stamp
against `sensor.audi_dashboard_app_version` and says so in the existing `Hinweisleiste`, whose
stated principle is already "nie eine stille Veraltung".
Deliberate choices worth keeping: the comparison is **equality only**, never greater/less — the
version is an identifier, not a number, and ordering it would be false precision that breaks
silently on a format change. Either side missing yields `"unbekannt"` and **no** warning, so an
older backend or an offline start cannot produce a false alarm. And the vite build **fails hard** if
the manifest version is absent rather than emitting an app that cannot detect its own staleness.
**OTA delivery: built and verified end to end, 2026-08-24.** `@capgo/capacitor-updater` 8.51.14
installed (MPL-2.0, peer `@capacitor/core: ^8.0.0` against our `^8.5.0`). Manual mode, not
`autoUpdate` (that stays `false` on purpose — an update must never swap the UI out from under
someone mid-entry): `companion-app/src/daten/ota.ts` wraps `download({url, version, checksum})` +
`set({id})`, wired to a button in the Einstellungen screen (`otaUpdateVerfuegbar` /
`otaAusloesen`). `notifyAppReady()` runs from a `useEffect` in `AngemeldeteApp` (`App.tsx`) —
deliberately there and not in `main.tsx`, so a crash before React actually renders never gets
confirmed and the plugin's `appReadyTimeout` rollback (`capacitor.config.ts`, 20s, more generous
than the 10s default because first load also fetches profile/trips/refuels) takes over.
The bundle itself lives **inside the integration**, not under `/local/` as first sketched:
`custom_components/audi_dashboard/frontend/app/{bundle.zip,bundle.json}`, built by
`companion-app/scripts/ota-paket.ps1`, served at `/audi_dashboard_static/app/bundle.zip`. Reason
for the relocation: `/local/` would have needed its own delivery step that `install.ps1` doesn't
touch and could be forgotten; inside the integration folder it travels with every install/update
automatically, same as the panel. `sensor.audi_dashboard_app_version`'s `daten` gained a `buendel`
field (`koordinator._buendel_lesen()`) carrying `{version, sha256, bytes, gebaut, url}``null`
when no bundle is published, a normal state, not an error.
One real bug the build caught: `[IO.Compression.ZipFile]::CreateFromDirectory` on Windows
PowerShell 5.1 (.NET Framework, not Core) writes **backslashes** as path separators for nested
entries — a spec-violating zip that iOS would not have unpacked correctly. Caught by inspecting the
actual zip contents after the first build, not assumed; fixed by writing entries by hand via
`ZipFile::Open` + `CreateEntryFromFile` with `\` replaced by `/`. The plugin verifies SHA-256 over
the *downloaded zip itself* before unpacking (`CapgoUpdater.swift` `calcChecksum`, confirmed by
reading the plugin source, not the README) — `ota-paket.ps1` hashes the same file it writes, so the
two can't drift.
Verified against the running test container: entry reload picks up code changes but **not** a
manifest version bump (HA caches the `Integration` object — needs a full restart, same lesson as
the pyscript-removal step in section G); after a real restart, `sensor.audi_dashboard_app_version`
published the new manifest version and a complete `buendel` block; the zip served over HTTP hashed
identically to `bundle.json`'s `sha256`; `unzip -l`/`-t` confirmed 13 entries, `index.html` at the
root, no backslashes, no corruption.
**A second, quieter bug surfaced 2026-08-24 when a manifest-only metadata change (the GitHub-removal
commit) prompted the question of whether it needed a version bump.** It didn't — but the question
exposed that nothing enforced `frontend/app/bundle.json`'s version staying in sync with
`manifest.json`'s on a *real* bump. Trace the failure: bump manifest to V2, ship via `install.ps1`,
forget `npm run ota` → backend publishes `app: "V2"` live, but `bundle.json` (frozen at build time)
still says V1, and the already-installed app is still V1. `Hinweisleiste` compares app-vs-server and
correctly says "you're outdated, update" (V1 ≠ V2) — but the Update screen's `buendelPasst()`
compares app-vs-*bundle* and says "you're current" (V1 = V1, the bundle's stale number). Two
contradictory messages, and no way to actually reach V2 via OTA until someone notices and reruns
`npm run ota` — the exact kind of silent-until-discovered coordination bug this project has fixed
repeatedly elsewhere (trip status, tire counter, `edited_fields`). Fixed at the one chokepoint every
delivery already passes through: `install.ps1` now reads `frontend/app/bundle.json`'s version right
after reading the manifest's, warns loudly and adds the mismatch as the **first** Restliste item if
they differ (a missing bundle file is not a mismatch — OTA is optional, silence is correct then).
Verified both ways on a mock config dir: matching versions print "OTA-Bündel passt zur Integration"
and add nothing to the Restliste; a deliberately bumped manifest (tested via a temporary edit,
reverted byte-for-byte after, diffed against `HEAD` to confirm) produces the warning and lands it as
Restliste item 1. `VERSIONIERUNG.md`'s "Was du tun musst" now lists the OTA rebuild as its own
numbered step (2, before shipping) instead of omitting it entirely.
Capacitor's `server.url` pointed at HA was considered and **rejected**: it would make the app as
current as the panel, but the shell then cannot boot without reaching HA, gutting the deliberately
built offline queue (`api/warteschlange.ts`).
**`install.ps1` couldn't authenticate to the Samba share, and gave a misleading error (found and
fixed 2026-08-24, during the owner's first real deployment attempt).** The owner's Samba add-on was
running and reachable — confirmed via its own log (`smbd`/`nmbd` started cleanly) and via
`Test-Path`/`net use` from an interactive PowerShell session, which worked. Yet `install.ps1` kept
reporting "Ziel nicht erreichbar" for the exact same path. Root cause: `Test-Path` on a UNC path
**never prompts for credentials** — without an already-authenticated SMB session to that server, a
password-protected share just returns `false`, with no error and no password prompt. The script had
no credential handling at all; it only ever worked by accident, piggybacking on a session the user
happened to have already established by hand (Explorer, or a manual `net use`). Once that session
dropped — confirmed happening organically here when the Samba add-on itself restarted mid-session —
the next run failed again with the same unhelpful message, even though path and password were both
correct. Two side findings along the way, also worth remembering: (1) `Test-Path` against a UNC path
can fail silently for reasons other than credentials, e.g. process elevation — a `net use` session
established in a non-elevated shell is invisible to a process launched via "Run as administrator",
because they're different logon contexts; `Installieren.cmd` needs no elevation and should always be
run plainly. (2) Add-ons on Home Assistant OS often bind to their **own IP**, separate from HA
Core's — the Samba add-on here answered on a different address than the one shown on
Settings → System → Network, confirmed via the add-on's own broadcast log line and by successfully
loading the HA frontend on that same IP.
Fixed at the point of failure: if the target path isn't reachable, `install.ps1` now prompts for a
username (defaulting to `homeassistant`, the Samba add-on's account) and password, clears any stale
connection to the same path first (`net use $Ziel /delete`), and authenticates
(`net use $Ziel /user:... ...`) before retrying `Test-Path`. Only a genuine auth/path failure after
that throws. No more manual `net use` pre-step required. Verified the script still parses
(`PSParser]::Tokenize`) after the edit; end-to-end verification pending the owner's next real run.
### G) Fresh-install audit + installer hardening (2026-08-23, before the first real deployment)
> **Historical from here on.** This describes the pyscript-era package, which section H replaced
> later the same day. The *findings* still matter — the blank-delivery rule and the safety
> properties carried over into the new installer — but the files it names (`pyscript/`, `www/`,
> `data/`, the `configuration.yaml` block) no longer exist. Kept as the record of what was wrong
> and why the current shape looks the way it does.
Owner is about to install on the real HA OS instance and asked for two things: that the one-click
package produce a genuinely blank app, and that it carry no risk to the existing HA system.
**The one real contamination bug.** `pyscript/modules/einstellungen.py` shipped with two hardcoded
entity IDs from a long-gone test instance — `ZUENDUNG_SENSOR =
"binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"` and `BATTERIE_SENSOR =
"sensor.testzone_fmm003_external_power_voltage"` (note `testzone_`, older even than the current test
container's `testcar_b9_…`). Invisible during all testing, because the test instance's own
`entitaeten.json` overrides both. On a fresh install with no overrides they would have been the
*effective* values — and worse than merely dangling: `fahrterkennung.py` guards trigger registration
with `if einstellungen.ZUENDUNG_SENSOR:`, explicitly built so an unset sensor registers no trigger at
all. A set-but-nonexistent value defeats exactly that guard. Both are now `""` like every other field,
and the file header was rewritten — it still claimed the EU-Data-Act integration was gone, which
stopped being true when `cupra_eu_data_act` appeared.
Everything else in the package was already clean, verified rather than assumed: no `fahrten.jsonl`,
no `tankvorgaenge.jsonl`, no `batteriespannung.jsonl`, no `entitaeten.json`, no real tokens (the
`ha_token` hits are path constants), and `fahrzeugprofil.example.json` carries only model specs —
FIN, Kennzeichen, Erstzulassung, HU, insurance numbers and all counters are empty or zero.
**Verified by actually doing it**, not by reading the installer: a throwaway `ghcr.io/home-assistant/
home-assistant:stable` container, the package copied in exactly as `install.ps1` does it, pyscript
config entry added, restarted. Result: `/config/audi_dashboard/` held only `fahrzeugprofil.json`,
`shell_beleg_parser.py` and an empty `belege/`; no trip, fuel or battery file existed at all; zero
ERROR lines in the log.
**Installer safety, audited and then hardened.** The audit found the good news first: `install.ps1`
contains no `Remove-Item` anywhere, no `robocopy /MIR`, never touches `.storage/`, never reads or
writes `automations.yaml`/`scripts.yaml`/`secrets.yaml`, and never restarts HA. The only file it
changes outside its own folders is `configuration.yaml` — which is also the only way it could stop HA
booting. Three gaps closed there:
- the fixed `configuration.yaml.bak` was overwritten on every run, so a second run destroyed the
pristine original. Backups are now timestamped, and the plain `.bak` is written only once
- after writing, the file is read back and checked (still at least as long as before, marker block
present exactly once, prior content byte-identical); any deviation **rolls back automatically**
from the timestamped backup and moves the step to the manual to-do list
- the header now states the safety properties explicitly, so they can be checked rather than trusted
Tested end to end against mock config dirs: `-Pruefen` wrote nothing; a real run preserved a foreign
`light:` group and left `automations.yaml` byte-identical (SHA-256 compared) while appending the
block; the output parses as valid HA YAML with all foreign keys intact; a second run left exactly one
block and preserved `fahrzeugprofil.json`; and against a config that already had its own `pyscript:`
and `panel_custom:` the file came back byte-identical, as designed.
**The genuine remaining risk is disk space, not the installer.** `recorder_snippet.yaml`'s 365 days
is ~11,5 GB, and on HA OS with an SD card or small eMMC a full disk stops HA from starting — nothing
to do with this app. The snippet header and the installer's to-do list now say so plainly, tell the
user to check Einstellungen → System → Speicher first, and recommend `purge_keep_days: 90` when space
is tight (enough for the "catch up on the past" purpose, since the import moves data into the app's
own permanent `.jsonl` files anyway).
**Two user-reported bugs fixed 2026-08-23, both worse than they looked:**
*"Fahrzeug fährt" never changed.* `standortZustand()` inferred the driving state from
`TRIPS[0].status === "offen"` — two independent errors stacked. First, `"offen"` does not mean
"driving"; it means **data incomplete** (distance still missing, see `fahrtabschluss_logik.py`) — a
trip created by `_fahrt_beenden()` *when it ends*. A trip that never got its odometer, e.g. because
no `KM_SENSOR` was mapped at the time, stays `"offen"` forever. Second, `TRIPS[0]` is the **oldest**
trip, not the newest: `fahrten_veroeffentlichen()` passes `profil.fahrten_lesen()` through in file
order, which is ascending. Together: the oldest ever-incomplete trip pinned the display to "fährt"
permanently. The test instance had one open for 18 days.
Fixed at the source rather than by patching the index: the backend now publishes `zuendung` in
`fahrzeugstatus` from the already-mapped `ZUENDUNG_SENSOR` — the same signal `fahrterkennung.py`
treats as authoritative, so display and detection cannot disagree by construction. `null` (no sensor
mapped) renders "Fahrzustand unbekannt" rather than claiming "steht".
The companion had the mirror-image bug: `liveZustandLesen()` read `status["zuendung"]` (never
published) and `status["lat"]`/`status["lon"]` (backend publishes `standort_lat`/`standort_lon`), and
`Fahrzeug` declared none of those fields — so `LiveFahrt` showed "Das Fahrzeug steht" permanently and
never a position. Fields added to `Fahrzeugstatus`/`Fahrzeug` and the adapter, so the type now
catches this class of bug — which its own header comment already warned about from a previous
occurrence.
*"Standortzugriff verweigert" was misleading.* It sits directly under the vehicle name where the
distance-to-car goes, but describes **this device's** browser geolocation — it read as if the car's
location were unavailable. Now "GPS offline", as the owner asked. Permission denial keeps its own
string ("GPS-Freigabe fehlt"): it is the one case with a different remedy, and "GPS offline" would
send the user looking at signal instead of at a permission toggle.
**Also fixed this round (panel-only):** the "Teilen" pill on the location sheet was invisible in
day mode — `.standort-pille` used `background: var(--tile)` while the sheet under it uses
`var(--tile-deckend)`, and the iOS overlay sets *both* to `#FFFFFF` in day mode. Measured contrast
was 1.00:1, white on white. Both pills now follow the app's own button language (`.aktion` /
`.aktion.primaer`): outline for the secondary, `var(--fg)` fill for "Route". Text contrast measured
1721:1 in both themes afterwards. This also retired `--line-strong` — a *line* colour — being used
as a button fill. No companion-app port: the location sheet's share/route pills do not exist there,
which the parity rule exempts as panel-only.
### I) Ölwechsel/Inspektion could vanish entirely with a mapped sensor and no Servicebuch (found and fixed 2026-08-24)
Owner reported: "no inspektion and ölwechsel shown even if there is the right sensor and
herstellervorgabe." Both were true — sensor mapped correctly, "Herstellervorgabe" selected — and the
row still just said "kein Eintrag im Servicebuch" (no Servicebuch entry).
**Root cause, identical in both codebases.** The Ölwechsel/Inspektion display combines two
independent sources on purpose (see the header comment in `companion-app/src/screens/Service.tsx`):
the vehicle's own reported due date/km (`fahrzeugMeldungRoh()` / `fahrzeug.oelwechselFaelligTs` etc.,
sourced from the mapped HA sensor) is meant to stand *alongside* the app's own Servicebuch-based
forecast, not depend on it. But the actual gating checked only the Servicebuch side:
- Panel (`frontend/audi-dashboard-app.js`, `vService()`): `const iv = intervalle(t); if (!iv) return
...„kein Eintrag im Servicebuch"...` — and `intervalle()` returns `null` whenever there's no
Servicebuch basis, full stop. The vehicle-reported `fm`/`hatMeldung` was computed *after* this early
return, so a mapped sensor with valid data never even got read for a fahrzeug with an empty
Servicebuch — exactly the state of a freshly set-up car.
- companion-app (`src/screens/Service.tsx`): same shape, `{oelHatEintrag ? ... : "kein Eintrag im
Servicebuch"}` — gated purely on a Servicebuch entry existing, ignoring `oelHatMeldung`/
`inspHatMeldung` computed one line above it. `src/daten/service.ts`'s `naechsterService()` (feeds
the Home-screen "Nächster Service" tile) had the same gap one level up: it only ever built
candidates from `oelwechselPrognose()`/`inspektionPrognose()` (both Servicebuch-only), never
falling back to `fahrzeug.oelwechselFaelligTs`/`inspektionFaelligTs`.
**Fixed by reordering, not by adding a new data path** — the sensor data was already being read
correctly by the backend and delivered to both frontends; only the display-gating ignored it.
Panel: `fm`/`hatMeldung` now computed *before* the early-return check, which now reads `if (!iv &&
!hatMeldung)`; `anzeige`/`herstellervorgabe` guarded for `iv` possibly being `null` (both remain safe
because the code paths that dereference `iv` only run when `hatMeldung` is false, and the new guard
guarantees `iv` is non-null whenever that's the case). companion-app: `oelHatEintrag ||
oelHatMeldung` / `inspHatEintrag || inspHatMeldung` as the render condition; `naechsterService()`
gained an `else if (fahrzeug.oelwechselFaelligTs)` / `...inspektionFaelligTs` fallback branch per
service type, added only when the Servicebuch-based prognose is `null` — a real Servicebuch entry
still wins when both exist (own forecast is finer-grained, factoring actual driven km/day). The
Hauptuntersuchung path is unaffected either way: `fahrzeugMeldungRoh("Hauptuntersuchung")` always
returns `{ts: null, km: null}`, so `hatMeldung` is always `false` for it — same behavior as before.
Verified: `tsc --noEmit` clean, all 120 companion-app tests pass (including 3 new regression tests in
`service.test.ts` covering the meldung-only fallback for both service types and confirming a real
Servicebuch prognose still takes priority when both are present), panel JS re-checked with `node
--check`. `manifest.json` bumped to `2026.8.24.2` and `npm run ota` rerun (own rule from section H,
enforced by `install.ps1` since the same day) since this touched both the panel and the companion-app.
### J) Self-update: the integration pulls its own updates from Gitea (built 2026-08-24)
**Why.** `install.ps1` (section H) stopped being a reliable update path: Windows Smart App Control
blocks its execution, and unlike SmartScreen there is no "run anyway" override — once fully
enabled, Smart App Control can only be turned off by reinstalling Windows. The owner's actual daily
pain wasn't the block itself but the whole cycle it forced: open Gitea, download a zip, unpack it,
run the (now-blocked) installer, re-enter the Samba password every time (see section H's Samba-auth
fix earlier the same day). Owner chose, from three options laid out (free workarounds first / code-sign
the installer / have Home Assistant pull its own updates), the third: **the integration now updates
itself.** `install.ps1` remains necessary for the *first* install only — the integration has to
already be running before it can update itself — which is the rare case; the daily-update tedium is
what this closes.
**Safety design — stricter than install.ps1, because it runs inside what it replaces.**
`install.ps1` is an external process against a foreign HA instance; this feature's code lives
*inside* `custom_components/audi_dashboard/` and replaces that very folder while its own service
call is still executing. `aktualisierung.py`'s module docstring carries the full reasoning; the
shape:
1. Download and verification happen entirely in a sibling staging folder
(`custom_components/audi_dashboard_update_staging/`) — the live folder is never touched until the
download is confirmed complete and plausible.
2. The swap is `os.rename`, not delete-then-copy: the live folder is renamed to
`audi_dashboard_backup` (kept, not deleted — reversible) before the staging folder is renamed into
its place. If the second rename fails, the first is undone automatically rather than leaving a
half-replaced folder.
3. The downloaded `manifest.json` is checked for `domain == "audi_dashboard"` and a `version` field
before either rename runs — matches install.ps1's own "won't touch a folder that isn't
provably ours" rule (section G/H).
4. **No automatic reload or restart.** A reload triggered from inside the same service call that's
still running would risk being interrupted by its own reload. The UI shows a "restart Home
Assistant" message instead — the same pattern install.ps1's Restliste already uses.
5. Nothing outside `custom_components/` is touched — same invariant as install.ps1.
6. The check step (a Gitea network call) runs only on explicit button press, never on the
coordinator's normal publish tick — same "no unsolicited background action" stance as the OTA
`autoUpdate: false` decision.
Renaming the very folder the running code was imported from is safe on Linux (this project runs on
Home Assistant OS / Docker): Python holds no open file handles on `.py` sources after import
completes, and `os.rename` only operates on path strings, not live handles.
**Token storage.** No prior mechanism existed for secrets in this integration — `config_flow.py`'s
`entry.data` was always `{}`, no `OptionsFlow` existed. Added `AudiDashboardOptionsFlow`
(Settings → Devices & Services → Audi Dashboard → Configure), one masked field
(`CONF_GITEA_TOKEN` in `const.py`), stored in `entry.options` — HA's own encrypted config-entry
store, never `configuration.yaml`. A read-only-scoped Gitea token is enough; the form text says so.
**What was built:**
- `custom_components/audi_dashboard/aktualisierung.py` — `version_pruefen()` (one lightweight Gitea
Contents-API call for just `manifest.json`, no repo download) and `update_installieren()`
(downloads the full repo archive via Gitea's archive endpoint — Gitea has no single-folder
download — delegates to the blocking `entpacken_pruefen_tauschen()` via
`hass.async_add_executor_job`). The blocking function takes all paths as parameters rather than
reading module constants directly, specifically so it's testable against a temp directory without
touching the real integration folder.
- Two new services (`update_pruefen`, `update_installieren`), registered in `dienste.py` following
the existing `SCHEMATA`/`behandler`/`_als_dienst()` pattern; both zero-field, both catch
`aktualisierung.AktualisierungsFehler` and turn it into a user-readable message instead of an
unhandled exception.
- `koordinator.py`: `alles_veroeffentlichen()`'s `E_APP_VERSION` publish was factored out into its
own `app_version_veroeffentlichen()` method and extended with `integration_update`. This holds the
*last* check/install result (`self.letzte_update_pruefung`) — deliberately **not** recomputed on
every publish tick, only ever set by the two new service handlers, so normal operation triggers zero
Gitea calls.
- Both frontends got a new "Integration-Update" tile (companion-app: `Einstellungen.tsx` +
`IntegrationUpdateAngabe` type in `api/types.ts` + `integrationUpdate` in `DatenKontext.tsx`; panel:
new tile next to the existing Version tile in `audi-dashboard-app.js`, backed by a new
`INTEGRATION_UPDATE` global read from `daten.integration_update`, wired to the existing
`data-*`/`serviceRufen()`/`ereignisseVerdrahten()` click-dispatch pattern) — not an extension of the
existing OTA "App-Update" tile, which is a different concept (the Capacitor iOS shell going stale)
that doesn't apply to a browser-rendered panel.
**Verified, honestly scoped by what this session's sandbox could actually run:**
- The safety-critical pure logic (`entpacken_pruefen_tauschen()`) has 7 unit tests
(`tests/aktualisierung/test_aktualisierung.py`, `unittest`, same style as
`tests/belegparser/test_shell_beleg_parser.py`) covering: successful swap with the old version
preserved in the backup folder, `__pycache__` cleanup, wrong domain rejected, missing `version`
field rejected, missing `manifest.json` rejected, corrupt zip rejected, archive missing the
expected path rejected — and every rejection case asserts the live folder is byte-for-byte
untouched. Run inside the project's existing throwaway `ghcr.io/home-assistant/home-assistant:stable`
test container (`audi_ha_test`) via `docker exec`, since no Python interpreter exists in the
session sandbox itself.
- The complete integration (config flow, both services, koordinator changes, both frontends) was
deployed into that same container and restarted from a clean state, twice, ending on
`Audi Dashboard 2026.8.24.4 eingerichtet` with zero errors or tracebacks — confirms the
`OptionsFlow`/service registration/coordinator wiring is structurally correct against a real,
current (2026.8.0) Home Assistant.
- companion-app: `tsc --noEmit` clean, all 127 tests pass.
- **Not verified**: an actual successful download+swap against the real private Gitea repo, since
that needs the owner's own token — entering it is the owner's action via the HA UI, not something
to hand to an assistant in chat. The failure paths (missing token, bad token, unreachable Gitea) are
covered by the unit tests and code review, not by a live call against Gitea. First real end-to-end
use is the owner's to do once a token is configured — worth a first careful try on the test
container before the real instance, same caution as every other install.ps1-era change in this
project.
**Docs updated to stop calling install.ps1 "the only way":** `README.md`, `VERSIONIERUNG.md` — both
now say install.ps1 is the only way *to install*; the self-update is now the preferred way *to
update* an already-running instance.
**Two fixes found during the owner's own first live test (2026-08-24, same day).** First attempt
against the test container ("nothing changed") turned out to be a real user error caught by log
inspection, not a bug: the check was clicked before the token had actually been saved in the
Options Flow — `dienste.py`'s log showed exactly why (`Kein Gitea-Token hinterlegt`), confirming the
error path works. But this surfaced a genuine observability gap: `update_pruefen`/
`update_installieren` only ever logged on *failure* — a successful check was silent, so "nothing in
the log" was ambiguous between "not clicked yet" and "worked fine." Fixed by adding an `_LOGGER.info`
line on the success path of both services. Separately, the owner asked for the panel's idle-state
text ("Ein Gitea-Zugriffstoken muss zuvor... eingetragen sein") to only appear as part of an actual
error, not as a permanent reminder cluttering the tile before the first check — removed from the
idle branch; the same information already surfaces via `integration_update.fehler` when it's
actually true. companion-app never had this line in the first place, so no parity fix needed there.
**A real bug found the same day, from a genuinely confusing error report.** After a successful
install-then-restart cycle, a later "Update installieren" attempt failed with:
`Konnte den bestehenden Ordner nicht sichern: [Errno 2] No such file or directory:
'/config/custom_components/audi_dashboard_backup' -> '/config/custom_components/audi_dashboard_backup'`
— note both sides of the `os.rename` are the *same path*, which shouldn't be possible given
`INTEGRATIONSORDNER`/`_BACKUP_ORDNER` are built from clearly different string literals. Root cause,
found via `docker logs`: the log line's logger name was `custom_components.audi_dashboard_backup.dienste`,
not `custom_components.audi_dashboard.dienste`. Home Assistant scans **every** folder directly under
`custom_components/` for a `manifest.json` and loads whatever `domain` it declares — by folder
content, not folder name. The `audi_dashboard_backup` folder left behind by a successful swap is a
byte-for-byte copy of the integration, manifest included, so across a restart HA loaded it as a
*second* `audi_dashboard` integration. Python then imported its `dienste.py` as its own module
(`custom_components.audi_dashboard_backup.dienste`), whose `__file__`-derived `INTEGRATIONSORDNER`
*is* the backup folder — making `_BACKUP_ORDNER` collapse onto the same path for that rogue copy.
This duplicate-domain state also explained two other symptoms reported the same session: the panel
intermittently doing nothing visible on "Auf Update prüfen"/"Update installieren" (whichever
integration's service registration won the race), and the frontend getting stuck on the bootstrap
"Lädt …" screen after a restart (confirmed live: with the stray `audi_dashboard_backup` folder still
present, `audi_dashboard` appeared **twice** in the loader's warnings and the panel never finished
loading; deleting the stray folder and restarting fixed both immediately).
**Fix**, in `entpacken_pruefen_tauschen()`: right after the backup rename succeeds, its
`manifest.json` is itself renamed to `manifest.json.bak` — structurally invalid as an integration, so
HA's scanner skips it. On the rollback path (second rename fails), the `.bak` is restored to
`manifest.json` *before* the backup folder is renamed back into place, so a restored live folder is
never left without a valid manifest. Covered by two new unit tests (manifest absent from the backup
folder after a successful swap; manifest correctly restored after a forced rollback via a mocked
`os.rename`) — `tests/aktualisierung/test_aktualisierung.py` now has 8 tests, all passing. Verified
live: deployed the fix, deleted the stray backup folder from the test container, restarted — single
`audi_dashboard` loader warning, clean `eingerichtet` line, frontend loads normally. Manifest bumped
to `2026.8.24.7` (see section M below, same version bump covers both fixes).
### K) vCard import for the workshop contact (built 2026-08-24)
Service → Autohaus (gear icon) now has a "vCard importieren" button next to the existing form
fields, opening a file picker for a `.vcf` contact exported from a phone/desktop address book. Reads
`FN`/`ORG` (name, `FN` preferred), `TEL`, `EMAIL`, and `ADR` (the seven-part vCard address —
PO-Box/extended/street/locality/region/postal-code/country — joined into one readable line, since
the form's address field is a single string). Deliberately not a full vCard parser: only the four
fields the Autohaus form already has. Unfolds RFC 6350 §3.2 folded lines (CRLF + one WSP removed
entirely, not replaced with a space) and un-escapes `\,`/`\;`/`\n`/`\\` before use. Multiple
`TEL`/`EMAIL`/`ADR` lines: first one wins.
- Panel: `vcardParsen()` in `audi-dashboard-app.js`, wired into `vWerkstatt()`'s new hidden
`data-vcard-import` file input via the existing central `change` listener — same
parse-then-`hinweis()`-on-error pattern as the existing JSON profile/backup import handlers.
Updates `CAR.service.autohaus` fields directly and calls the existing `profilSpeichern()` (saves
immediately, matching this field's existing per-keystroke-save behavior).
Same isolated-function shape as `entpacken_pruefen_tauschen()` in section J's self-updater: parse
logic takes no dependency on `HASS`/global state, so it's directly testable.
- companion-app: new `src/screens/vcard.ts` (`vcardParsen()`, 7 unit tests in `vcard.test.ts`
covering folding, escaping, ORG fallback, first-wins on repeats, and the two "nothing readable"
error cases), wired into `Werkstatt()` in `Service.tsx`. Unlike the panel, this only populates the
local `entwurf` draft state — the existing explicit "Speichern" button still does the actual save,
matching this screen's existing edit-then-confirm pattern rather than the panel's live-save one.
Empty fields from the vCard never overwrite non-empty draft values.
Verified: `tsc --noEmit` clean, all 134 companion-app tests pass (127 + 7 new), panel JS re-checked
with `node --check`. Manifest bumped to `2026.8.24.5`, `npm run ota` rerun (both frontends changed).
### L) Self-update UX: loading feedback and a real restart button (2026-08-24, same day)
**The self-update feature (section J) got its first genuine live test against the real private
Gitea repo this same day**, and it worked — confirmed directly in the test container's log across
several real attempts (`Update installiert: Version 2026.8.24.5`, no manual file copying involved).
That's the first real end-to-end confirmation beyond the unit tests and structural container-restart
checks section J could offer on its own.
That same live test exposed two real UX gaps, both raised directly by the owner:
1. **No feedback during or after the ~1525 second install** (confirmed from the log's own
timestamps between a "Prüfung" and the matching "installiert" line). The button just sat there
with no state change — indistinguishable from a hang, and after success, the version still read
"2026.8.24.4 installiert" because that field only updates after a real restart, which nothing in
the UI was telling the user to do next beyond a paragraph of text.
2. **A separate, initially confusing "Lädt …" hang the owner saw and asked about.** Traced by
reproducing it directly: `.lade-spinner`/"Lädt …" is this project's own **existing** top-level
bootstrap loading screen (shown whenever `DATEN_GELADEN` is false — i.e., no live connection to
Home Assistant yet), not anything related to the update button specifically. Clicking the new
"Jetzt neu starten" button and watching it live in a browser reproduced the *exact* same screen —
because a `homeassistant.restart` call genuinely does drop the frontend's connection until HA
finishes rebooting, and this is the correct, expected screen for that. The most likely explanation
for the owner's *original* sighting (before this button existed): the Docker Desktop restart from
earlier in the same session dropped the connection the same way. Not a bug — but the fact that it
was indistinguishable from one was the actual, valid complaint: nothing signposted that a restart
was in progress versus something being stuck.
**Fixed with a three-state UI, researched against Apple HIG's guidance on indeterminate progress**
(unknown-duration network operations get a running indicator, not just a disabled control; on
completion the UI leads straight to the next required action instead of leaving the user to find it
themselves):
- **Prüfen/Installieren, in progress** — a small spinner + "Prüfe …"/"Installiere …", reusing the
panel's own pre-existing `.lade-spinner` (originally built, per its own comment, for the exact same
"don't let 'still loading' look like 'stuck'" reason at app bootstrap) and a new equivalent
`.dm-spinner`/`.dm-ladezeile` pair added to companion-app's `screens.css` (that side had no spinner
at all before, only a button-text swap).
- **On success** — the tile switches directly to "Installation abschließen - Jetzt neu starten",
which calls `homeassistant.restart` (a core HA service any admin could already trigger via
Settings → System → Restart — this button doesn't grant a new capability, it surfaces an existing
one at the point it's actually needed). Its own click shows "Home Assistant startet neu …";
no `.finally`-style cleanup after that call succeeds, since the connection normally drops before
the promise would ever resolve — only a genuine rejection (service refused, etc.) reaches the
catch block.
- **On a real request-level failure** (not the already-handled "no token"/"bad token" case from
section J, which returns normally with `fehler` set — this is Gitea unreachable, the websocket
itself failing, or any other exception that never made it back from Home Assistant at all) — this
was previously silent in companion-app (a `try/finally` with no `catch` re-throws into an
unhandled promise rejection). Added explicit `catch` blocks on all three actions in both frontends,
each surfacing its own message rather than one shared silently-stale error field.
- Panel: `INTEGRATION_UPDATE_LAEUFT` (`"pruefen" | "installieren" | "neustart" | null`), the
`data-update` click handler rewritten from `serviceRufen()` (fire-and-forget) to direct
`HASS.callService()` promise chains so the loading flag can wrap them; `vEinst()`'s tile rewritten
with the three states above.
- companion-app: `updateNeustartLaeuft`/`updateAktionFehler` state and `updateNeustartAusloesen()` in
`Einstellungen.tsx`; new `homeAssistantNeuStarten()` in `api/index.ts` (`dienstAufrufen("homeassistant",
"restart")` — note the domain isn't `DIENST_DOMAIN`, this is a core HA service, not one of this
integration's own).
Verified live end-to-end in the test container via browser automation: triggered the real restart
button, watched the connection drop into the bootstrap "Lädt …" screen exactly as reasoned above,
confirmed via `docker logs` that Home Assistant actually rebooted
(`Audi Dashboard 2026.8.24.6 eingerichtet` after restart, version now genuinely current — not just
the pre-restart file swap). companion-app: `tsc --noEmit` clean, full suite still green. Manifest
bumped to `2026.8.24.6` and `npm run ota` rerun per the standing rule in `VERSIONIERUNG.md` — both
frontends changed (new spinner/restart-button behavior is real, visible UI, not internal-only).
### M) Übersicht/Service mismatch on a blank install: two real bugs (2026-08-24, same day)
The owner tested on a **blank installation** (no Servicebuch entries yet) and found two bugs:
1. **Service showed the vehicle's own km/date; Übersicht showed something else (or nothing).** Root
cause was worse on the panel than in companion-app: `termine()` (panel) built its candidate list
purely from `letzterEintrag()` (a real Servicebuch entry) — with none, `km`/`datum`/`ziel` all
stayed `null`, so `vHome()` (Übersicht) rendered a broken/empty service block, while `vService()`
already had its own separate `meldungsPrognose()` fallback and displayed correctly. companion-app's
`naechsterService()` did already call `meldungsPrognose()` as a fallback, but paired the *raw*
signed `fahrzeug.oelwechselFaelligKm` with a *computed* projected date, which could read
inconsistently with `Service.tsx`'s own primary line. Fixed by adding the same meldung-based
fallback to `termine()` (new block right after `oelTermin`/`inspTermin` are built: `if
(termin.basis) return;` then reads `oelMeldungsPrognose()`/`meldungsPrognose()`), and by having
`meldungsPrognose()` always return `restKm` (`Math.abs(faelligKm)`) so both codebases use one
consistent value instead of recomputing it differently at each call site. Verified live: emptied
the test container's `service.buch` array (backed up first, restored after), confirmed Service and
Übersicht showed byte-identical numbers (8.687 km / 28.03.2027) where Übersicht previously would
have shown nothing usable at all.
2. **The Ölwechsel forecast ignored the owner's own (shorter) interval from "Einrichten" until a
Servicebuch entry existed** — it silently kept showing the manufacturer's own countdown instead,
with no restKm shown either. Root cause: `meldungsPrognose()`/`fahrzeugMeldungRoh()` only ever
relay the vehicle sensor's own manufacturer-interval countdown; nothing about them reads
`oel.modus`/`oel.km`/`oel.monate`. Without a Servicebuch entry there's no known "last real oil
change" to anchor a custom-interval projection from — the owner chose (asked directly, see
conversation) to estimate one anyway: back-calculate an assumed last-service point as
`(manufacturer's own due point) (manufacturer's own interval length)`, then project the owner's
shorter interval from that same assumed point. An approximation, not exact — it can never be as
good as a real Servicebuch entry — but it's the only way to show a number that respects the
owner's chosen interval before the first entry exists, instead of silently ignoring the choice.
Implemented once in `service.ts`
(`meldungsPrognoseEigenesIntervall()` + `oelMeldungsPrognose()`, the latter dispatching to the
existing `meldungsPrognose()` unchanged when `modus === "hersteller"`) and mirrored 1:1 in the
panel (same two functions, same math, `Date`-based month arithmetic instead of `service.ts`'s
ISO-string `monatePlus()` since the panel's own `monatePlus()` only accepts `TT.MM.JJJJ` strings
and the vehicle meldung's timestamp is ISO). Both `Service.tsx`/`vService()`'s footnote for this
fallback case now show `restKm` alongside the date when the owner's interval is shorter than the
manufacturer's, exactly like the existing Servicebuch-based prognose already does. Inspektion is
unaffected — it has no owner-selectable interval, always the manufacturer's own.
Only Ölwechsel has a selectable interval, so this only applies there. If `oel.herstellerKm`/
`herstellerMonate` are themselves unknown, both functions degrade to the plain (unchanged)
`meldungsPrognose()` rather than guessing further.
Verified: companion-app — 4 new tests for `meldungsPrognoseEigenesIntervall()`/`oelMeldungsPrognose()`
(restKm/date math, the "already overdue by the owner's interval" clamp-to-zero case, no-fahrten
fallback, graceful degradation without hersteller values), full suite green at 142/142, `tsc --noEmit`
clean. Panel: `node --check` clean; live-verified in the test container using the exact same
blank-install simulation as bug 1 above — with `service.buch` emptied and the container's real
`oel` settings (`modus: "eigen"`, `10.000 km`/`12 Monate`, manufacturer `30.000 km`/`24 Monate`), the
footnote showed **8.700 km, voraussichtlich am 23.07.2027** — the derived number from
`28.700 30.000 + 10.000`, using the vehicle's real raw meldung — closely matching (not identical to,
as expected for an approximation) the real Servicebuch-based value of 8.687 km / 28.03.2027 seen with
the Servicebuch restored. Restored the container's real profile afterward; no live data was left
altered.
Manifest bumped to `2026.8.24.7` (covers this section and section J's manifest-neutering fix above —
one version bump, both landed the same day); superseded by `2026.8.24.8` in section N below the same
day. `npm run ota` rerun once, covering both this section's and section N's frontend changes together
(see section N).
### N) Sicherheit neu gestaltet: verriegelt / Türen & Klappen / Fenster & Dach / Licht (2026-08-24, same day)
The owner asked for a rework of the "Sicherheit" screen (`vSicherheit()`/`Fahrzeugstatus.tsx`), which
until now showed one flat list of every door/window/heckklappe/haube check with no grouping and no
lock, roof, or light status at all. Requested: four named collector rows — **Fahrzeug verriegelt**
(new lock sensors, state "abgeschlossen"), **Türen und Klappen geschlossen** (with an arrow to a
detail view of every door, Motorhaube, and Kofferraum individually), **Fenster und Dach geschlossen**
(Dach = Schiebedach), **Licht ausgeschaltet** (Standlicht, showing the text **"Kein Licht"** for the
good state, not a generic "in Ordnung"). The owner also supplied a concrete mockup: green
checkmark-circles instead of the old plain-color dot, only the second row carrying a chevron.
Went through plan mode given the cross-codebase, backend+frontend scope. Before planning, checked the
real entities available in the test container's `core.entity_registry` (`cupra_eu_data_act` platform)
to ground the sensor-role design in what actually exists rather than guessing: per-door
`binary_sensor.*_door_lock` (`device_class: lock`), `..._tailgate_lock`, `..._hood_lock`, `..._sunroof`
(`device_class: window`), `..._parking_lights` (`device_class: light`). HA's own convention for all of
these is `off` = the good state (locked/closed/no light) — the exact same `state == "off"` → `ok=True`
check this integration already uses for door/window sensors. No new check pattern needed, only new
sensor roles.
**Backend:**
- `einstellungen.py` — five new `Sensorzuordnung` fields: `TUERSCHLOSS_SENSOREN` (4 positions, mirrors
`TUER_SENSOREN`), `HECKKLAPPE_SCHLOSS_SENSOR`, `HAUBE_SCHLOSS_SENSOR` (paired with the existing
open-sensors), `DACH_SENSOR`, `LICHT_SENSOR`. Matching `FELDER` catalog entries under the existing
`"sicherheit"` group — the Setup-Menü is entirely `FELDER`-driven (confirmed: its group headers come
straight from this list), so the new roles appeared there with zero extra frontend work.
- `veroeffentlichung.py`'s `_sicherheitscheck()` — extended with the five new checks, each entry now
additionally tagged `"gruppe"`: `"verriegelt"` (all 6 locks), `"tueren_klappen"` (4 doors + Motorhaube
+ the renamed "Kofferraum" — was "Heckklappe"; the underlying `HECKKLAPPE_SENSOR` field itself is
unchanged, so an existing assignment isn't silently broken by the relabel), `"fenster_dach"` (4
windows + Dach), `"licht"` (Standlicht alone). `gesichert` is untouched — `all()` over the full flat
list is mathematically identical to "AND of all four group results," so no logic change, just more
entries in the same list.
- **Consequence flagged to the owner**: because every entry in the flat list (including brand-new,
unassigned ones) counts toward `gesichert`, any existing installation shows "Zustand nicht
vollständig bekannt" for the overall status until the three new roles are assigned in Setup — same
"unknown beats assumed-good" principle this project has followed throughout, but worth knowing before
it looks like a regression.
**Frontend (both codebases):** a small grouping helper (`sicherheitsGruppe()` in the panel,
`gruppenErgebnis()` + filter in companion-app) turns the flat list into a group's aggregate `ok`
(`null` the moment one entry is unknown, or if the group has zero entries at all — never `true` via an
empty-array vacuous truth). Both `vSicherheit()`/`Fahrzeugstatus.tsx` rewritten to the four-row layout;
each row's own label text changes with its state (e.g. "Kein Licht" / "Licht eingeschaltet" / "Licht
unbekannt") rather than pairing a fixed label with a separate status word — matches the mockup, which
shows no secondary value text at all. New checkmark-circle indicator (`statusKreis()` in the panel,
`StatusKreis` component in companion-app's `bausteine.tsx`) — green check / red cross / amber
question-mark SVG in a filled circle, replacing the old plain dot for this screen only; the rest of the
app keeps `.dot`/`.dm-punkt` unchanged. New route `"tuerenklappen"` in both routing tables
(`ZURUECK`/`navigation.ts`), reachable only from the "Türen und Klappen geschlossen" row; its detail
page is the old flat-list style filtered to just that group (`vTuerenKlappen()`, new
`screens/TuerenKlappen.tsx`).
**Verified:**
- Backend: `python3 -m py_compile` on both changed modules, clean restart in the test container with a
single `audi_dashboard` loader warning (no duplicate-domain regression from section J/M's fix).
- companion-app: `tsc --noEmit` clean, full suite green at 145/145 (added: grouping/unknown-propagation
test, chevron-navigates-only-this-row test, detail-page-shows-only-its-group test; updated the one
pre-existing test and the `beispieldaten.ts`/`profilAdapter.test.ts` fixtures for the now-required
`gruppe` field on `SicherheitsPunkt`).
- Panel: `node --check` clean.
- Live end-to-end in the browser against the test container: before assigning the new sensor roles, all
three new rows correctly showed "unbekannt" (amber) and the overall status read "Zustand nicht
vollständig bekannt" — confirming the flagged consequence above is real and correctly wired, not
theoretical. Opened Setup, scrolled to "Sicherheit": all five new fields were **already
auto-suggested** to the exact real entities found earlier (the existing stichworte/device_class
matching worked without any manual searching) — saved (one pre-existing, unrelated "doppelt
zugeordnet" warning about `Front left door`/`Front left window`, present before this change, dismissed
via the existing "Trotzdem speichern" override). After saving: "Fahrzeug nicht verriegelt" showed red
(the demo lock entities report unlocked — correct, not a bug), "Türen und Klappen geschlossen" and
"Fenster und Dach geschlossen" showed green, "Licht ausgeschaltet" showed green. Clicked the chevron on
row 2: navigated to "Türen und Klappen" showing all four doors, Kofferraum, and Motorhaube individually,
all green "zu" — confirmed the detail page and the "Kofferraum" relabel both work.
Manifest bumped to `2026.8.24.8`. `npm run ota` rerun (`scripts/ota-paket.ps1 -Bauen`): picked up
`2026.8.24.8` automatically from the manifest, built clean, `bundle.zip` (233.244 Bytes) + sha256 +
`bundle.json` written — covers both this section's and section M's frontend changes, since neither had
triggered a bundle rebuild yet. `Installieren.cmd` picks the bundle up automatically; no separate step
needed for a self-update-based deployment.
**Same-day follow-up from live owner feedback (2026.8.24.9):**
- **"Kein Licht" reverted to "Licht ausgeschaltet"** — the owner's original wording was right; my
interpretation of their earlier parenthetical note was wrong. Fixed in both frontends.
- **Locks simplified to one sensor**: dropped `HECKKLAPPE_SCHLOSS_SENSOR`/`HAUBE_SCHLOSS_SENSOR`
entirely (owner: central locking means individual locks can't be operated separately, so checking
them adds nothing), and `TUERSCHLOSS_SENSOREN` (4 positions) collapsed to a single
`TUERSCHLOSS_SENSOR` — the driver's door lock stands for the whole vehicle, same reasoning. `"Fahrzeug
verriegelt"` is now one sensor check, not six. No frontend code changes needed for this — both
`sicherheitsGruppe()`/`gruppenErgebnis()` only ever consume the flat, `gruppe`-tagged list generically,
never reference specific sensor-role names.
- **Real, previously-undiscovered bug found via the owner reporting "Nächster Service still empty" on a
genuinely blank instance** (Servicebuch cleared AND `erstzulassung` also blank - the owner's own test
container ended up in exactly this state): `naechsterTermin()`'s comparator, `(a, b) => a.ziel -
b.ziel`, silently coerces a `null` `ziel` to `0` (the Unix epoch) in JS arithmetic. A candidate with
genuinely no data (no Servicebuch entry, no vehicle meldung, and for Hauptuntersuchung specifically no
`erstzulassung` to fall back to) therefore sorted as "nearest" ahead of a *different* candidate that
had a real, computed date — because epoch-1970 always beats any real future date arithmetically. This
starved out Ölwechsel/Inspektion even when section M's meldung-fallback had correctly computed a real
number for them; the tile just showed "" under "Hauptuntersuchung" instead. Not a regression from
section M — this comparator predates it and was always latent, just needed a candidate with a
genuinely absent `ziel` to surface. Fixed by filtering out `ziel === null` candidates before sorting,
returning `null` only when literally none of the three have a usable date (matching `vHome()`'s
existing, previously-dead `if (!t)` "kein Eintrag im Servicebuch" branch, which can now actually be
reached). companion-app's `naechsterService()` was checked and confirmed unaffected: it only ever
pushes a candidate when a real computed value exists (no null-`datum` candidates ever enter the array)
and sorts ISO date *strings* via `localeCompare()`, not raw arithmetic — structurally immune to this
class of bug.
- **Root cause of the browser not showing my fixes at first**: `audi-dashboard-app.js` is fetched by the
frontend with a cache-busting `?v=<manifest version>` query string. Editing the file on disk without
also bumping the manifest version left the URL unchanged, so the browser kept serving a cached copy
(confirmed via `performance.getEntriesByType('resource')` showing `transferSize: 0`) even across a
forced navigation and a hard-reload keystroke. Bumping the version forced a real refetch. Worth
remembering for any future panel-JS-only fix: a version bump isn't just the project's documentation
rule, it's load-bearing for actually seeing the change in a running browser tab.
Manifest bumped to `2026.8.24.9` for this follow-up round.
**A second same-day follow-up round (2026.8.24.10 / .11), more live owner feedback:**
- **Locks simplified further to a single sensor**: `TUERSCHLOSS_SENSOREN` (4 positions) collapsed to
one `TUERSCHLOSS_SENSOR` — owner: the central locking system locks/unlocks every door together, an
individual lock can't be operated separately, so the driver's door alone represents the whole
vehicle's lock state. "Fahrzeug verriegelt" is now exactly one sensor check. No frontend changes
needed (same reason as the Kofferraum/Motorhauben-Schloss removal above: both grouping helpers only
ever consume the flat, `gruppe`-tagged list, never a specific sensor-role name).
- **`vSicherheit()`'s route title renamed from "Sicherheit" to "Fahrzeugstatus"** in the panel, matching
what companion-app's `navigation.ts` already called this page (`TITEL.sicherheit`) — the two frontends
had drifted apart on this one label. The `FELDER` catalog's `"sicherheit"` *sensor-role group* heading
(Setup-Menü section, a different concept entirely — sensor categorization, not a page name) is
untouched.
- **A real, previously-undiscovered bug in `naechsterTermin()` (panel), found from the owner reporting
"I deleted everything in Servicebuch, why isn't Ölwechsel shown on Übersicht?"**: the sort comparator
`(a, b) => a.ziel - b.ziel` coerces a `null` `ziel` to `0` (the Unix epoch) under JS arithmetic. On
this owner's test container, both the Servicebuch *and* `erstzulassung` ended up empty at once, so the
Hauptuntersuchung candidate's `ziel` was genuinely `null` (no entry, no `erstzulassung` fallback
either) — and epoch-1970 sorts before any real future date, so HU won "nearest" over Ölwechsel even
though section M's meldung-fallback had correctly computed a real number for Ölwechsel. The tile
rendered "" under "Hauptuntersuchung" instead of the real Ölwechsel figure. Not a regression from
section M — this comparator predates it and was always latent, just needed a candidate with a
genuinely absent `ziel` to expose it. Fixed by filtering out `ziel === null` candidates before
sorting, returning `null` only when literally none of the three have a usable date (the existing
`if (!t)` "kein Eintrag im Servicebuch" branch in `vHome()` can now actually be reached — it never
could be before, since `termine()` always returns exactly three entries and the old comparator always
produced *some* truthy result). companion-app's `naechsterService()` was checked and confirmed
structurally immune: it only ever pushes a candidate when a real computed value exists, and sorts ISO
date *strings* with `localeCompare()`, never raw arithmetic on a possibly-`null` value.
- **"Licht ausgeschaltet" reverted back from "Kein Licht"** — the owner's original wording was right;
my read of their earlier parenthetical was wrong. Fixed in both frontends' idle/ok-state label.
- **Cache-busting gotcha hit again, worse this time**: after the `naechsterTermin()` fix, the browser
kept showing the *old* broken behavior even after a forced navigation **and** a hard-reload keystroke
(`ctrl+shift+r`) — `performance.getEntriesByType('resource')` still showed `transferSize: 0` for
`audi-dashboard-app.js` afterward, meaning even a hard reload didn't bypass this particular cache.
Only bumping the manifest version (changing the `?v=` query string HA embeds when registering the
panel's script URL) forced a real refetch. Confirmed this isn't just a documentation nicety: **any**
panel-JS-only edit needs a version bump to actually be visible in a running browser tab, full stop —
a hard reload is not a reliable substitute.
- **Wide/desktop layout headline fixed** (`audi-dashboard-ios.css`, `@container (min-width:860px)`
block): sub-pages reached via the back arrow (Standort, Service, Fahrzeugstatus, Identität und
Technik, everything with `.back.on`) were rendering their title at `font-size:26px` — the owner
described this as "bold" in wide/desktop mode versus "not bold, centered" on the phone layout. There
is no actual `font-weight` bump anywhere in this stylesheet (`.title` is `font-weight:400`
everywhere, confirmed by grepping the whole file) — the 26px size alone, against an app that mostly
uses weight-300 body text (see `VERSIONIERUNG.md`/working-conventions "font weights 300/400 only"),
reads as heavy at a glance even at the *same* weight as every other title. This also violates the iOS
Human Interface Guidelines pattern this stylesheet otherwise deliberately follows: a "large title"
belongs to root/tab pages (`.title{font-size:34px}`, unscoped by `.back.on`, left untouched — the
owner didn't flag `Mein Audi`'s own big top-level title as wrong), while pushed/detail pages keep a
small, standard title regardless of screen size. Fixed by dropping the back-button-page override to
`font-size:17px` — the exact same value already used for these same pages in the narrow/phone layout
(`.phone:has(.back.on) .title` at the file's un-scoped, phone-default rule) — for genuine
cross-breakpoint consistency, not just a smaller number. Left `text-align:left` as-is: that's a
property of the wide layout's sidebar+column grid (`.topbar{justify-content:flex-start}`,
`.head{align-items:flex-start}`, both still scoped to `.back.on` and untouched), consistent with how
the *also-left-aligned* top-level large titles behave in this same layout — normal desktop-app
convention, not the trait the owner was actually flagging. Verified live at an actual `1280×800`
viewport (the container-query breakpoint needs a real wide render, not just the browser tool's
`desktop` preset, which turned out to stay narrow) — before the fix, "Service"/"Standort" rendered
visibly larger than their phone-layout counterparts; after, both match.
Manifest bumped to `2026.8.24.11` across this round. `npm run ota` rerun once more at the end to keep
the OTA bundle's embedded version aligned with the manifest (no further companion-app source changes
after the "Licht ausgeschaltet" text fix earlier in this same round, so this rebuild only updates the
stamped version number, not the bundle contents).
**A third same-day fix (2026.8.24.12): the update-button scroll jump, specifically in wide/desktop
mode.** The owner reported the page visibly jumps when pressing "Auf Update prüfen"/"Update
installieren" on a large screen. Reproduced live at `1280×800`: clicking the button while scrolled deep
into "Einstellungen" (`scrollTop` ~1773) landed at `scrollTop` ~1517 after the click resolved — a real,
non-zero shift, not the element being replaced (`main#view` stayed `isConnected: true` throughout, so
it wasn't a full re-mount either) and not simply `scrollTop` reset to 0 the way section L's
narrow-mode work had assumed for this kind of bug. `render()`'s own scroll-preservation
(`v.scrollTop = gleicheAnsicht ? merkeScroll : 0`) should apply here (same route, same id) and normally
does — the update flow specifically still lost position, most likely because the service call also
triggers a *separate*, second re-render path outside `render()`'s own bookkeeping (`sensor.audi_dashboard_app_version`
changing state reaches the embedded panel through Home Assistant's own `set hass()` plumbing, not
through this app's `route`/`letzteAnsicht` mechanism at all) — plausible but not conclusively traced
before the owner asked to move on rather than keep chasing the exact mechanism. Fixed pragmatically
instead of surgically: the `data-update` click handler for `pruefen`/`installieren` now captures
`$("#view").scrollTop` once before acting, and re-asserts it via `requestAnimationFrame` after *each*
of its own `render()` calls (both the immediate one and the one in `.finally()`) — this corrects the
position regardless of what else might be moving it, without needing to identify or touch whatever the
second path turns out to be. Verified live at `1280×800`: repeated the exact same scroll-deep-then-click
sequence that reproduced the bug: **no visible jump**, page stayed on the same section
before and after. (Separately noticed, not fixed, not in scope of what was asked: the check's *result*
only shows up in the currently-open tab after a full page reload, even though it's correctly persisted
server-side immediately — confirmed via `docker logs` showing the check completed and a subsequent
fresh page load showing the right "Verfügbare Version" — likely because nothing in this flow re-reads
`sensor.audi_dashboard_app_version`'s `daten` attribute into `INTEGRATION_UPDATE` and re-renders when
that entity's state changes out from under the open tab. Pre-existing, not caused by this fix, not
reported by the owner as a problem — worth a look if it ever is.)
Manifest bumped to `2026.8.24.12` for this fix.
**A fourth same-day fix (2026.8.24.13): the two "needs a manual page refresh" reports the owner filed
right after — turned out to be the exact gap flagged (but not investigated) at the end of the previous
paragraph.** Two distinct reports, one shared root cause on the panel side:
1. **"Update installieren" needs a manual page refresh to see the restart button.** Traced to
`datenLaden()`: it unconditionally reads `sensor.audi_dashboard_app_version`'s `daten.integration_update`
into the module-level `INTEGRATION_UPDATE` variable on every tick, but only calls `render()` inside the
`geaendert` block — which only compares `profilState`/`fahrtenState`/`tankState`/`statusState`/`battState`
identity, never `versionState`. A version-only state change (exactly what `update_pruefen`/
`update_installieren` produce) updated the in-memory value but never triggered a re-render on its own —
the UI only caught up whenever some unrelated entity happened to change next, which in practice meant
"never, until a manual reload re-initializes everything from scratch." Fixed by tracking `letzterVersionState`
the same way the other five states are tracked, and calling `render()` directly whenever `versionState`'s
identity changes and nothing else already triggered one (a small `if (versionGeaendert && DATEN_GELADEN) {
render(); return; }` right after the existing `geaendert` block, left untouched).
2. **The "Home Assistant startet neu …" spinner also needs a manual page refresh after the restart
actually completes.** Root cause was different: `INTEGRATION_UPDATE_LAEUFT` is set to `"neustart"`
right before calling the `homeassistant.restart` service, and the *only* place that ever cleared it
back to `null` was the `.catch()` error handler on that same call — deliberate, per section L's original
comment, since a successful restart normally disconnects the page before the success path could run
anyway. But nothing ever picked it back up on the other side: once the websocket reconnects after HA
comes back, `set hass()` fires again (`HASS` was never nulled out while disconnected, so this isn't the
`erster`/first-connect path), but no code ever reset the stuck `"neustart"` flag, so the spinner stayed
forever. Fixed by listening once for the underlying `home-assistant-js-websocket` connection's own
`"ready"` event (fires on every successful (re)connect, confirmed live via
`HASS.connection.addEventListener`) — wired in `set hass()`, guarded by a new module-level
`VERBINDUNG_UEBERWACHT` flag so it's only attached once rather than once per `hass` push — and clearing
`INTEGRATION_UPDATE_LAEUFT`/re-rendering there if it was still `"neustart"`.
**Checked companion-app for the same two bugs (parity rule) rather than assuming React's own re-render
model made it immune:** bug 1 does *not* reproduce there — `updatePruefenAusloesen()`/
`updateInstallierenAusloesen()` in `Einstellungen.tsx` already `await neuLaden()` right after the service
call resolves, which re-fetches `appVersionAngabeLesen()` directly over REST and updates React state
synchronously, no push-event dependency. Bug 2, however, **does** reproduce identically: `updateNeustartLaeuft`
is local `useState` in `Einstellungen.tsx`, deliberately left out of the `try/finally` (same reasoning as
the panel), and nothing else ever reset it — `DatenKontext.tsx`'s own reconnect handling
(`api.live.aufVerbindung()` → `neuLaden()` on `"verbunden"`) only refreshes the *shared* `integrationUpdate`
context value, which this screen-local boolean doesn't derive from. Fixed with a small `useEffect` in
`Einstellungen.tsx` watching the already-available `verbindung` context field, clearing
`updateNeustartLaeuft` when it flips to `"verbunden"` while still `true`.
**Verified (panel, first pass):** `node --check` clean. Live in the test container at `2026.8.24.13`:
clicked "Update installieren" (Gitea test remote genuinely offered `.12`, since that's what had last been
pushed there) — the tile transitioned on its own from "Installiere …" straight to "Version 2026.8.24.12
installiert … Jetzt neu starten" with **no page reload**, confirming bug 1 fixed. Clicked "Jetzt neu
starten" and waited for the container to fully restart — this is where a third, previously-undiscovered
bug surfaced (below), which blocked a clean confirmation of bug 2 until it was also fixed.
**A third bug found while verifying bug 2 for real (2026.8.24.14), not a new report — an already-known,
already-commented race in the code that a genuine backend restart finally exercised end to end.** After
the real `homeassistant.restart` completed, the browser tab reconnected but the panel stayed stuck on the
static "Lädt …" placeholder indefinitely — not the Einstellungen page with a lingering spinner (bug 2's
literal symptom), but back on Übersicht, showing nothing. Traced to: Home Assistant's `panel_custom`
mechanism recreates the `<audi-dashboard-panel>` custom element from scratch after a real backend restart
(confirmed live — a fresh `_aufbauen()` run, fresh skeleton DOM), but the JS *module* itself is not
reloaded, so all its module-level state (`CONFIG`/`CAR`/`DATEN_GELADEN`/etc.) survives untouched from
before the restart. The fresh element's first `datenLaden()` call didn't reliably produce a `render()`
(the exact scenario `go()`'s own existing comment already describes: *"Bekanntes Race … auf einem langsam
startenden Backend kann die App auf 'Lädt …' hängen bleiben … der bislang einzige beobachtete Ausweg war
ein Tabwechsel"*), and — unlike `go()`, which unconditionally calls `render()` on every tab click regardless
of this race — nothing about `_aufbauen()` itself ever painted anything beyond the static skeleton.
Confirmed live: clicking any tab immediately showed full correct data (proving `CONFIG`/`CAR` were fine in
memory all along — only the *paint* was missing), exactly matching `go()`'s own workaround comment.
Fixed the same way `go()` already does it, just made unconditional instead of click-triggered: `_aufbauen()`
now calls `render()` immediately after building a fresh skeleton, if `DATEN_GELADEN` is already `true` (i.e.
this is a rebuild after already having loaded once, not the very first paint — that case is correctly left
to `datenLaden()`'s own arrival). This is the real, complete fix for "the app doesn't recover cleanly after
HA restarts without user interaction" — bug 2's spinner-not-clearing report was one visible symptom of it;
this fix (plus the earlier `"ready"`-listener reset of `INTEGRATION_UPDATE_LAEUFT`) is what makes the whole
class of "just restarted, tab open, nobody touches it" scenario self-heal.
**Verified (panel, final pass) at `2026.8.24.14`:** `node --check` clean. Triggered a real
`homeassistant.restart` directly (`HASS.callService("homeassistant", "restart", {})` from the open tab, no
button click, to isolate this from the update-install flow specifically), confirmed via `docker logs` that
the integration set up a second time end to end (`s6-rc` stop/start sequence, "Audi Dashboard 2026.8.24.14
eingerichtet" logged twice). The browser tab — never manually reloaded, never clicked — automatically showed
full real content (vehicle name, range, "Nächster Service", …) the moment it reconnected. No stuck "Lädt …",
no manual refresh, no tab click needed. This also confirms bug 2 properly this time (the spinner-clearing
`"ready"` listener from the first pass runs as part of the same reconnect path this test exercises).
- companion-app: `tsc --noEmit` clean, full suite still green at 145/145 (no behavior change needed
testing beyond the existing suite for its own bug-2 fix — a reconnect-driven reset of already-tested
local state, not new business logic; the deeper element-recreation race is panel-specific, since
companion-app is a normal React SPA that doesn't get its component tree torn down and rebuilt by an
external host on backend restart).
Manifest bumped to `2026.8.24.13`, then `2026.8.24.14` once the deeper race above was found and fixed in
the same live-testing session. `npm run ota` not rerun yet for this round — only the `useEffect` addition
in `Einstellungen.tsx` touches companion-app source (the panel-side fixes don't affect the OTA bundle,
which ships companion-app only), no OTA bundle rebuild triggered until the owner asks or the next round
bundles it together with something else.
## O. Single-trip duration formatting, and a defensive error boundary for the companion-app (2026.8.24.15/.16)
**Bug report: the single-trip detail view ("Einzelfahrt") showed raw minutes ("75 min") instead of an
hours-and-minutes breakdown.** Traced to `vTrip()` in the panel using `Math.round(t.duration_s / 60)}
min` directly instead of the already-existing `dauerText()` helper, which every *other* duration display
in the panel already goes through (the trip-edit form's live duration field, `fahrtDauerText()`). Fixed by
routing `vTrip()`'s "Dauer" row through `dauerText()` too. Manifest bumped to `2026.8.24.15`.
**Follow-up correction (2026.8.24.16): the owner explicitly rejected `dauerText()`'s own output format.**
`dauerText()` produced `"1 Std. 15 Min."` — technically consistent with the rest of the panel at the time,
but not what was wanted. Explicit ask: `"1 h 10 min"` exactly. This happens to be the *exact* format
companion-app's own `format.ts` `dauer()` helper already produces (`"2 h 14 min"` / `"14 min"`, 2-digit
padded minutes) — so `dauerText()` was changed to match it precisely (`std ? "${std} h
${String(min % 60).padStart(2, "0")} min" : "${min} min"`), rather than inventing a third convention.
Since `dauerText()` is the shared helper, this fixes both the trip detail view and the trip-edit form's
live duration display in one change. Verified live in the test container (real restart to pick up the new
manifest version, not just a copied file — the panel's module URL is versioned from the *in-memory*
version read at integration setup, so a filesystem-only change isn't visible until either a restart or an
integration reload): opened a real 7200 s trip, confirmed the row reads exactly `"Dauer 2 h 00 min"`.
**Separate, unrelated report investigated the same session: "Update prüfen" in the companion-app leads to
a black page; only navigating away and back to Einstellungen shows the result.** Reviewed the whole call
path in detail — `updatePruefenAusloesen()`/`api.updatePruefen()`/`DatenKontext.neuLaden()` — and found
nothing that should throw: every state field the Einstellungen screen reads off `integrationUpdate` is
already optional-chained, `neuLaden()` catches its own errors, `ActionButton`'s default `type="button"`
rules out an accidental form submit/navigation. Could **not** reproduce live this session: reproducing
needs a real Home Assistant long-lived access token to connect the companion-app dev server to the test
backend, and generating one (even for the disposable local test container) was refused by the standing
"never handle auth/secret creation" rule — this needed the owner's own action, not a workaround.
**What was found instead, and fixed regardless of the exact root cause: the app had zero React error
boundaries anywhere.** `main.tsx` rendered `<App />` directly. In React, an uncaught exception during
render anywhere in the tree unmounts the *entire* app — `#wurzel` goes empty, and what's left on screen is
just the page's own background, which in Nacht/dark theme is close to black. That matches the "black page"
report closely: not a stuck loading state (which has its own distinct `.dm-laden` visual and no plausible
trigger tied to a version-check REST call), but a silent full unmount with no error surfaced anywhere —
consistent with "only navigating away and back fixes it" too, since re-entering the screen remounts the
tree from scratch and the triggering state is gone. Added `src/ErrorGrenze.tsx` (a class component — error
boundaries require `componentDidCatch`/`getDerivedStateFromError`, hooks can't do this), wired around
`<App />` in `main.tsx`. On a caught error it now shows a visible "Etwas ist schiefgelaufen" tile with the
error message and a "Neu laden" button, and logs the error (plus component stack) to the console — instead
of a silent blank screen. This does not identify the original root cause (if the report reproduces again,
the console will now show what actually threw, which is the fastest path to the real fix), but it directly
addresses the reported symptom either way: a future crash is visible and recoverable, not indistinguishable
from a hang. `tsc --noEmit` clean, full suite still green at 145/145 (behavior of the happy path is
unchanged; the boundary only activates on an actual render exception, which no existing test triggers).
## P. Trip splitting/data-quality bugs traced to the FMM003 losing signal mid-drive (2026.8.25.1)
**Bug report: "gaps within one trip" after `Daten importieren aus HA`, and separately a trip with
physically impossible data (implausible average speed).** Both traced to the same underlying cause: the
FMM003 (the Teltonika GPS tracker that has been the ignition/GPS/km source since the 2026.8.13 switch away
from the iPhone-WiFi detector, see `SPECIFICATION.md`'s "Teilweise überholt" banner) drops its cellular/GPS
connection intermittently while the car is still being driven — parking garages, tunnels, dead zones. When
that happens the ignition entity (`ZUENDUNG_SENSOR`) goes to Home Assistant's `unavailable` state for a
while, then reports `on` again once the FMM003 reconnects, even though the ignition was never actually off.
**Root cause 1 — live trip detection treated "no signal" as "ignition off":**
`Koordinator._zuendung_geaendert()` (`koordinator.py`) fires on *every* `state_changed` event for the
ignition entity, `unavailable` included, and passed the raw state straight into
`fahrterkennung.zuendung_geaendert()`. That function computed `an_vorher = alt == "on"`; when the entity
resumed reporting `on` after an `unavailable` blip, `alt` was `"unavailable"`, not `"on"`, so
`an_vorher` was `False` — the code read this as "ignition just turned on", i.e. a *new* trip, splitting
one continuous drive into two the moment an FMM003 dropout outlasted the configured pause window
(`fahrten_pausenzeit_min`, 15 min default). Fixed in `fahrterkennung.py`: `zuendung_geaendert()` now
returns immediately (no-op) when the new state isn't literally `"on"` or `"off"` — `unavailable`/`unknown`
carry no information about the ignition and must not be treated as either. The "is a trip currently
running" check was also switched from `alt == "on"` to the coordinator's own `fahrt_start_ts is not None`,
which is the actual ground truth and no longer depends on what the entity's state happened to be during a
dropout.
Historical import (`historienimport.py`'s `_fahrtfenster()`) was **not** affected by this — it reads
`verlauf_lesen()`, which already drops `unavailable`/`unknown` points entirely (`verlauf.py`'s
`_rohverlauf()`), so a dropout never registers as an "off" transition there. That's also why re-running
the import over an already-affected period doesn't repair it: the already-split live trips exist as two
separate stored records, and the import's overlap check (`_ueberschneidet()`) skips anything that overlaps
an existing trip rather than merging. Going forward (with the live fix above) new drives won't split in
the first place; already-split historical entries would need a manual merge (not built — out of scope for
this round, flag if it comes up again).
**Root cause 2 — no plausibility check on imported trip distance:** separately, a physically-impossible
average speed was possible whenever `wert_bei()` matched a stale odometer reading across a data gap
(sensor outage, sparse reporting) at one edge of a trip window. Added `UNPLAUSIBLE_KMH = 300` in
`historienimport.py`: if a trip's computed average speed exceeds it, the distance/odometer fields are
dropped (trip still gets created, `status` falls back to `"offen"` — same "data missing, not zero"
semantics already used everywhere else in this file) and a warning is logged with the trip window and the
rejected numbers, rather than silently showing an impossible figure. `screening.py` (the live-trip
km-completion path) has no equivalent guard yet — not touched this round since it wasn't what was reported,
but worth applying the same idea there if a similar impossible-speed trip shows up on a live-detected trip.
Verified: both files `py_compile` clean, copied into the `audi_ha_test` container, manifest bumped to
`2026.8.25.1`, container restarted, confirmed `"Audi Dashboard 2026.8.25.1 eingerichtet"` in the log with
no setup errors. Live reproduction of an actual FMM003 dropout wasn't attempted (needs a real signal
outage while driving, not reproducible in the test container) — this is a static-analysis-grounded fix,
not one confirmed against a captured dropout event.
## Q. Optional direct-liter fuel sensor, for more accurate refill detection (2026.8.25.2)
**Feature request: the automatic refill detection (`tankerkennung.py`) only ever had the tank's percent
sensor to work with**, converting the "+5 L" threshold into percentage points via
`fahrzeug.tankvolumen_liter` from the vehicle profile. Accurate enough to *detect* a refill, but it can
never tell the app how many liters were actually put in — the auto-created tank record's `liters` field
stayed `None` until a receipt was uploaded or the owner typed it in by hand. The FMM003 (see section P)
reads the tank volume directly from CAN in liters on this instance, alongside the existing percent reading
— a second, independent signal that doesn't need the profile-based conversion and, being already in
liters, hands over a real estimate of the refilled amount for free.
Added `TANK_LITER_SENSOR` as a new, optional role in `einstellungen.py` (same `FELDER` catalog pattern as
every other role — the setup menu picks it up automatically, no frontend work needed, confirmed by the
same reasoning as the pending Sicherheit-Neugestaltung plan). Wired into `koordinator.py` exactly like
`TANK_SENSOR`: its own state-change observer, its own persisted low-point (`tiefststand_liter`, stored and
restored across restarts the same way `tiefststand_pct` already was).
`tankerkennung.py` gained `tankvolumen_geaendert()`, the liter-based twin of the existing
`fuellstand_geaendert()` — same low-point-tracking logic (a refill often arrives in several small reported
steps, so the comparison is against the last-seen low point, not the immediately previous value), but the
threshold is `LITER_SCHWELLE` (5 L) directly, no percentage conversion. **Whichever sensor is assigned,
only one of the two paths ever creates a tank record for the same event**: `fuellstand_geaendert()` now
checks `TANK_LITER_SENSOR` and stays silent if it's assigned, since the liter path is strictly more
accurate and would otherwise double up the same refill into two records. Unassigned, behavior is
unchanged — pure percent-based detection exactly as before, zero-risk to any existing setup. When the
liter path fires, the detected increase is written into the new record's `liters` field (rounded to 0.1 L)
as a rough starting value — the record still lands as `status: "unvollständig"` (price/station still
unknown), same as always; the owner corrects/confirms it via the existing inline edit form (panel) or a
receipt upload (both codebases), no new UI needed for this either.
Extended `historienimport.py`'s `_tankvorgaenge_importieren()` the same way, for the same reason section P
called out for trips: leaving the historical import on the old percent-only path while live detection uses
the more accurate liter path would have reintroduced exactly the kind of live/import inconsistency that
was just fixed there. It now reads `TANK_LITER_SENSOR`'s history too (added to the `verlaeufe` dict in
`importieren()`) and prefers it under the identical condition as the live path.
Verified: all four touched files (`einstellungen.py`, `koordinator.py`, `tankerkennung.py`,
`historienimport.py`) `py_compile` clean in the `audi_ha_test` container; manifest bumped to `2026.8.25.2`,
restarted, confirmed `"Audi Dashboard 2026.8.25.2 eingerichtet"` with no errors. Did not verify the new
setup-menu entry by clicking through it live (a browser-tooling networking issue blocked reaching the test
instance's UI this round) — relying on the FELDER catalog being genuinely code-driven, per the same
confirmed mechanism the pending Sicherheit plan already documented. `screening.py`/companion-app's
`TankDetail.tsx` were deliberately left untouched: the estimate flows through existing display/edit paths
without needing either to change.
## R. Five owner-requested features in one round: battery measurement list, swipe-delete, diagram-width fix,
CI icon substitutions, wheel-set archiving (2026.8.25.3/.4)
**1. Battery measurement list.** The battery-voltage tile only ever showed a hand-drawn chart. Added a
"list" corner button (CI `list-s`) that opens a new list view — one row per day (the existing daily
min/max data, unchanged storage shape) showing date, time, voltage, and now also outside air temperature.
Backend: new optional `AUSSENTEMP_SENSOR` role (`einstellungen.py`); `batterie.py`'s 5-minute check now also
reads it and passes it to `ablage.batterieverlauf_tageswert_aktualisieren()`, which gained an `aussentemp`
parameter — stored as `min_temp_c`, **only updated when a measurement also sets that day's minimum voltage**
(the temperature is tied to that specific reading, not a daily average). Older entries and setups without
the sensor simply carry `min_temp_c: None` — no backfill, no migration. New screens: companion-app
`BatterieListe.tsx` (route `battliste`, reached from an `IconButton`/`SymbolListe` in `Batterie.tsx`'s
header) and panel `vBatterieliste()` (route `battliste`, reached via a new `.iconbtn`/`ciSVG(CI.listS)`
button in `vBatterieverlauf()`'s tile header).
**2. Swipe-to-delete for the new list, per entry.** Reused the existing pattern in both codebases rather
than inventing a new one: companion-app's `Zeilenmenue` (wipe + accessible menu button, see its own
docstring) and the panel's `swipeZeile()`/`eintragLoeschen()`/`LOESCH_TEXT`/`LOESCH_HINWEIS` machinery
already used for trips/tank vorgaenge, extended with a `"batt"` art. New backend service
`batterieverlauf_loeschen` (field: `datum`) → `ablage.batterieverlauf_eintrag_loeschen()` (new) →
`batterie.eintrag_loeschen()` (new, wired in `dienste.py`).
**3. Battery diagram "still stretched" on a large monitor — panel only (confirmed with the owner; the
diagram in question doesn't exist as a separate wide layout in companion-app).** Root cause found in
`audi-dashboard-ios.css`: past 860px viewport width the whole app switches to a wide two-column layout
(`@container (min-width:860px)`) where `main#view`'s content column is 772px wide (860 2×44px side
padding). The battery chart's SVG (`#bvSvg`, `audi-dashboard-app.js`) is drawn on a fixed `viewBox="0 0 320
170"` with `preserveAspectRatio="none"` and `width:100%` — at 772px wide against a native 320-unit design,
that's a ~2.4× horizontal stretch with the height staying fixed at 170px, turning the chart into an
unnaturally wide, flat band. Fixed the same way the existing popups in that file already solve the identical
problem one section up: `#bvChart{max-width:400px;margin-left:auto;margin-right:auto}` inside the same
`@container` block, no changes to the SVG's own drawing logic (`BV_W`/`BV_H`/`bvZeichnen()`), which section
O of this file already spent real effort getting right for a *different* stretch bug (the Y-axis
rescaling one) — deliberately left untouched here.
**4. CI icon substitutions — `list-l/-s`, `polls-l/-s`, `package-l/-s`, all requested by the owner
(SVG source supplied directly from their local Audi icon export folder, same as every other CI icon in this
codebase — see the recurring "vom Nutzer als SVG geliefert, verbatim uebernommen" comment convention).**
Added to the `CI` catalog in `audi-dashboard-app.js`. `polls-s` replaces the Statistik tab's hand-drawn bar
icon (`ICONS.stat`) in the panel's tab bar — needed adding `"stat"` to `ICONS_VOLL` (the existing mechanism
for filled-vs-outline icon rendering; CI paths carry their own `fill="currentColor"` but would otherwise
also inherit the tab bar's `stroke:currentColor` from `.tab svg`, doubling the line weight around the
shape). Companion-app's `symbole.tsx` had no filled-icon mechanism at all yet (its `SymbolAudi` etc. are
plain stroked outlines, unlike the panel's CI-icon-based tab bar) — added a `voll` prop to the shared
`Rahmen` wrapper (`fill`/`stroke` swap) as the equivalent mechanism, then `SymbolStatistik` (parity with
the panel), plus `SymbolListe`/`SymbolPaket` for the other two icon uses below. `list-s`/`package-s` are the
corner/inline buttons described in points 1 and 5; the `-l` (48-grid) variants are stored for parity with
every other CI icon pair in the catalog but aren't wired to a render path yet, same as the pre-existing
`editL`/`oilChangeL`/etc.
**5. Wheel-set archiving ("Neue Räder anlegen").** A `package-s` button sits between the wheel photo and
the editable Hersteller/Modell fields on the Reifen page in both codebases (panel: inline always-editable
`data-rf` inputs, matching that page's existing convention; companion-app: read-only `Wertzeile`s with a
separate edit form, matching *its* existing convention — no new interaction pattern introduced in either).
Pressing it asks "Neue Räder anlegen?" (panel: the existing `bestaetigen()` action sheet; companion-app: the
existing `window.confirm`-based `bestaetigen()` already used by `wechseln()` on the same page) before doing
anything. On confirmation, `reifen.py`'s new `archivieren(k, satz)`: snapshots the season's *entire* current
record (km, marke, modell, dot, mass, druck_vorne/hinten, kommentar, plus a generated id and today's date as
`ersetzt_am`) into `reifen.archiv` (newest first), then resets the live record — **km to 0 (explicit owner
decision) and marke/modell/dot/kommentar to blank** (a replacement tire hasn't been characterized yet),
**but mass/druck_vorne/druck_hinten/bild carried over** (size and target pressure don't usually change for
a same-fitment replacement — a judgment call, not something the owner specified; flag if wrong). Three new
services (`reifen_archivieren`, `reifen_archiv_aktualisieren`, `reifen_archiv_loeschen`), all deliberately
**bypassing `profilSchreiben`/`profilSpeichern`** for the same reason `reifen_wechseln`/`reifen_km_setzen`
already do (see `reifenzaehler.py`'s own docstring) — a full-profile round trip from a stale browser copy
could clobber a km value the backend fortgeschrieben in the meantime.
Overview UI: an "Archiv" tile at the bottom of the Reifen page, one row per archived set. Companion-app uses
the design system's `Accordion` (title = "Sommerräder"/"Winterräder", summary = "Ersetzt am ... · N km"),
expanding to a read-only `Werteliste` with a "Bearbeiten" button that swaps in an edit form (mirrors
`TankFormular`'s style) — plus a destructive "Löschen" button. The panel reuses its own existing
`acc-h`/`acc-b`/`data-acc` accordion mechanism (same as the trips/tankvorgaenge year/month lists) with
always-inline-editable fields (`data-rfa="id|feld"`, parallel to `data-rf`) that call
`reifen_archiv_aktualisieren` directly on change, plus a `data-reifenarchivloeschen` button.
`profilAdapter.ts` gained a `ReifenArchivEintrag` type and `Fahrzeug.reifen.archiv`; `profilZuFahrzeug()`
reads it straight off the profile (`?? []`), and `zusammenfuehren()` needed no change — it clones the raw
profile first and only ever touches `aktiv`/`anzugsmoment_nm`/`wechsel`/`saetze`, so `archiv` survives
untouched exactly like it already did for every other field this function doesn't know about.
Verified: `py_compile` clean on all six touched backend files (`einstellungen.py`, `ablage.py`,
`batterie.py`, `const.py`, `dienste.py`, `reifen.py`) plus `services.yaml` entries added for all four new
services; `node --check` clean on the panel JS; companion-app `tsc --noEmit` clean and full suite green at
**146/146** (one new test — the existing "every registered page renders with sample data" smoke test picked
up `battliste` automatically); `npm run build` and `npm run ota` both succeeded (OTA bundle rebuilt at
`2026.8.25.4`, sha256 `9d5ebc2a62da4bbddcd766a462726bb51a213ea4095e367e88a8e5d321a9f8e4`). Manifest bumped
`2026.8.25.2` → `.3` (backend) → `.4` (frontend + OTA), both restarts in `audi_ha_test` confirmed clean via
`"Audi Dashboard ... eingerichtet"` with no tracebacks, including the periodic battery-check log line
firing without error against the new `AUSSENTEMP_SENSOR` code path. **Not verified live in a browser** —
the setup-menu entry for `AUSSENTEMP_SENSOR`, the new list screens, the icon substitutions' actual pixel
rendering, and the archive accordion/edit flow were none of them clicked through in this round (a
browser-tooling networking issue blocked reaching the test instance's UI, same blocker noted in section Q).
This entire round rests on static analysis, compiler/test-suite checks, and clean service-registration logs
— not on having seen any of it render.
## S. Live browser access to the test instance, a "Generatorspannung" remark, and two real bugs section R's
static-only verification missed (2026.8.25.5.8)
**The owner logged this session into the test instance's browser at `http://localhost:18123` (not the
`:8123` used earlier - a different, working port for this environment).** This unblocked live verification
for the first time since the FMM003 investigation's `/profile/security` refusal (section P) — worth noting
for future sessions: try `:18123` before assuming the browser tooling can't reach this project's test
container.
**Generator-voltage remark (2026.8.25.5):** `AGM_RUHE_MAX_V = 12.8` (the resting-vs-alternator-voltage
threshold, already used by the diagram's own point filter and the SOC estimate) was not applied to the new
measurement list from section R — a day whose minimum was never actually measured at rest still displayed
next to a date/time as if it were one. Companion-app: exported the constant from `Batterie.tsx`, added a
`Pill` reading "Generatorspannung" in `BatterieListe.tsx` when `min > AGM_RUHE_MAX_V`. Panel: same threshold
already in scope in `vBatterieliste()`, added a `.pill`-styled badge under the date in the `.v` column.
**Live verification then immediately found two real defects that every static check in section R had
missed** — `py_compile`/`tsc`/`node --check`/the test suite all passed because none of them render CSS:
1. **Archive edit fields visually truncated ("Continenta", "SportConta", "275/30 R2").** `vReifenArchiv()`'s
`feld()` helper unconditionally wrapped every input in `<span class="mitEinheit">` — a class meant for
short number+unit pairs (km, Nm, bar) that hard-codes `input{width:96px}`. None of the fields that
actually used this helper (marke/modell/mass/dot/kommentar) needed a unit at all, so the wrapper was pure
accident, not a deliberate width choice. Fixed by dropping `mitEinheit` from that helper entirely,
matching the plain `<input>` the live (non-archived) season fields already use one screen up. Confirmed
live: all three previously-clipped fields now render in full.
2. **Owner follow-up, also confirmed live before and after:** the archive accordion's collapsed summary
line squeezed `4.542 km` into a `<small>` immediately after the "Ersetzt am" date, and separately asked
for the Kommentar/Notiz field specifically to get more room. Both fixed the same way in both codebases:
Kommentar/Notiz now renders label-above-input at full width (`.feld--breit` in the panel CSS; a
bypassed-`Feld` block in companion-app) instead of sharing a row with its label like every other short
field — and the collapsed summary now shows the km figure at title size/color (`.acc-h .k`/
`.ads-acc-h__k`-equivalent styling) with the date dropped to its own smaller line below, instead of the
two being run together.
**Lesson for this project going forward, worth remembering deliberately:** a clean compile/test pass is not
evidence a layout is correct — none of the tooling in this repo renders CSS or checks visual width. Section
R shipped a real, user-visible truncation bug behind a fully green test suite. When browser access is
available (now confirmed reachable at `:18123`), prefer at least one live screenshot of any new or changed
input-heavy screen over relying on compiler output alone.
Verified: panel `node --check` clean at every step; companion-app `tsc --noEmit` clean, suite green at
146/146, `npm run build`/`npm run ota` succeeded. Manifest `2026.8.25.4` → `.5` (Generatorspannung) → `.6`
(mitEinheit fix) → `.7` (Kommentar width + archive header restyle, panel) → `.8` (same two, companion-app
parity + final OTA rebuild, sha256 `464c294c6c07dcb636a8ea3bd93c7e525b21722defb41f573e2b31fefade49ea`).
Every restart in `audi_ha_test` confirmed clean via log, **and this time also confirmed live in the browser**
at each step from `.6` onward — screenshots taken of the battery list (Generatorspannung pill rendering
correctly, including a real entry below the threshold correctly *not* tagged), the Statistik tab's `polls-s`
icon, and the Reifen archive section before and after both fixes.
## T. Battery plausibility floor, and the archive button moved onto the "Montiert" row (2026.8.25.9)
**Owner-set plausibility floor: `SPANNUNG_MIN_V = 8.0` in `batterie.py`.** A 12V lead-acid/AGM battery's
resting voltage doesn't fall meaningfully below this even deeply discharged — a reading under it is a
sensor/connection fault, not a real measurement, and per the owner's explicit instruction it must not be
tracked or listed at all (not just hidden at display time, unlike the existing `AGM_RUHE_MAX_V` upper
bound in `Batterie.tsx`/`vBatterieverlauf()`, which still stores out-of-range readings and only filters them
when computing SOC/trend/the chart's own point set). `pruefen()` now rejects a reading below the floor
before it ever reaches `ablage.batterieverlauf_tageswert_aktualisieren()`, logging a warning instead.
`historienimport.py`'s `_batterie_importieren()` got the identical floor (imports `SPANNUNG_MIN_V` from
`batterie.py` rather than duplicating the number) — the recurring live/import parity rule from sections P
and Q: a stray sub-8V blip in the recorder history must not become a "tracked" day-minimum on import just
because the live path would have rejected it.
**Reifen page: the package-s "Neue Räder anlegen" button now sits on the same row as "Montiert"** (panel
only — companion-app has no equivalent "Montiert" button in this tile to align it with; its own
Fahrzeugwechsel button lives in a separate tile entirely, so there was nothing to move there). Was its own
row below `Montiert` with `justify-content:flex-end`; now both buttons share one
`justify-content:space-between` flex row. Confirmed live — the two align correctly on one line at their
respective ends.
Verified: `py_compile` clean on `batterie.py`/`historienimport.py`, `node --check` clean on the panel JS,
manifest bumped to `2026.8.25.9`, `audi_ha_test` restart confirmed clean via log **and confirmed live in the
browser** (screenshot of the Reifen page showing `Montiert` and the package icon sharing one row).
## U. Fahrten start/end position and a real track, live-detected trips included; map tile provider fixed;
Start/Ziel restructured; Status line removed; battery diagram uses the full column width (2026.8.27.1)
**Bug report: "Start and Endposition as well as Track is not visible at Fahrten - even if the Sensors for GPS
provides Data."** Root cause: `start_lat`/`start_lon`/`end_lat`/`end_lon`/`route` were only ever populated by
`historienimport.py` (added when `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR` were introduced, 2026-08-16) -
the **live** trip-detection path (`fahrterkennung.py`'s `leere_fahrt()`/`fahrt_beenden()`) never filled them,
so any trip the FMM003 detected in real time (the normal case) stayed without a map, only trips created via
"Daten importieren aus Home Assistant" ever had one. This was the documented "GPS is dead schema for trip
records" gap (see the HA-panel known-gaps list above) - now closed for the live path too, using the same
GPS sensors that already work for the Standort tile and for import.
**Backend**, mirroring the existing odometer-screening pattern (`_fahrt_screenen()`/`naechster_wert()`) rather
than inventing a new mechanism: `verlauf.py` gained `route_aus_verlauf(lat_verlauf, lon_verlauf, start, ende)`
- pairs each latitude point inside the trip window with its nearest-in-time longitude point (the two are
independent sensors on the FMM003, not guaranteed to update together) into a chronological `[[lat, lon], ...]`
list, `None` below two points. `screening.py`'s `durchfuehren()` now also runs a `_position_screenen()` pass
over every trip still missing `start_lat`/`end_lat`/`route` - **decoupled from the odometer `status` field**
(`offen`/`vollständig`), since GPS and odometer data can each arrive late independently; a trip already
`vollständig` on kilometers can still be missing position, and vice versa. Re-entrant like the odometer path:
already-filled fields are left alone, so repeated screening passes (every trip end, every KM_SENSOR change)
converge without re-work. `historienimport.py`'s `_fahrten_importieren()` now also writes `route` (previously
only `start_lat`/`end_lat`), using the same helper, for the same live/import parity reason section P and Q
already established for other fields.
**A real, live-reproduced timing edge case, found and understood, not worked around with a special case:**
first-attempt screening immediately after a trip ends can occasionally read the recorder microseconds before
the just-posted GPS points are committed, landing on a stale nearest-value instead. Reproduced deliberately
(temporarily set `fahrten_pausenzeit_min` to 0 to make a synthetic trip end instantly, restored afterward) -
the first screening pass picked a 5-minute-old position; nudging `KM_SENSOR` a few seconds later re-triggered
`durchfuehren()`, which then read the fully-committed history and filled in the correct, precise points and
route. Under the real default pause (15 minutes), the recorder has settled long before screening ever runs,
so this isn't a practical concern - and even under the artificial 0-minute test it self-healed on the very
next screening trigger, which is the same "eventually consistent, never stuck" property `_lueckenlos_verketten`
already relies on elsewhere in this file. No code change needed for this - it's an inherent, already-handled
property of the retry-on-next-trigger design, not a defect.
**Frontend, both codebases - stopped fabricating a track, matches an existing project value.** The panel's
`fakeTrack()` (a seeded-random wiggle between two points, flagged in the known-gaps list as "not a real
track") is removed; `initMap()`'s trip branch now draws the real `route` polyline when present ("Streckenlinie
aus dem GPS-Verlauf ... echte Route"), or an honest straight (dashed) line between the two known points when
only start/end are known but no route - matching the stance `companion-app/src/screens/Karte.tsx` already
took from the start (its own header comment: "Das ist hier nicht übernommen... zeigt die Seite ehrlich, dass
keine Route bekannt ist, statt eine glaubwürdig aussehende Erfindung zu zeigen"). `Karte.tsx` gained an
optional `route` prop (drawn solid when present, falling back to the existing dashed straight line) so it can
now also render a real track, not just the two endpoints; `FahrtDetail.tsx` passes `fahrt.route` through.
`api/types.ts`'s `Fahrt.route` was typed `unknown | null` (dead field, per its own header comment) - now
`[number, number][] | null`, matching what the backend actually sends.
**Map tile provider was showing "API key required" watermark tiles - found while verifying the above, a
separate real bug.** `TILE_URL` (panel) and `KACHELN.tag/.nacht` (companion-app `Karte.tsx`) pointed at
CARTO's anonymous `basemaps.cartocdn.com` tiles, which now require an account/API key - without one, every
tile rendered as a plain "API key required" graphic instead of a map, on **every** map in both apps (Standort,
trip detail, tank receipt), not just the newly-fixed trip track. Switched both to the standard
`tile.openstreetmap.org` raster tiles, which remain free and keyless. companion-app's `Karte.tsx` also lost
its per-theme dark/light tile switch in the same change (it had never gotten the panel's 2026-08-16
"maps are always light" decision) - now themeless like the panel, for the same reason (`useTheme` import and
the now-unused `theme` dependency removed).
**Owner-requested restructure of the Start/Ziel rows** (Einzelfahrt detail page, both codebases): was
address-first with the date as a small caption (Start row only; Ziel had no date at all). Now both rows show
date + time on the primary line and the address below as the secondary line - panel via a new `dezeitpunkt()`
helper (mirrors the existing inline `dedat(x) + " · " + toLocaleTimeString(...) + " Uhr"` pattern used
elsewhere in the file), companion-app via `Wertzeile`'s existing `zusatz` prop (the same mechanism already
used for other value+caption pairs).
**Owner-requested removal: "Status is not working. It says 'vollständig' but that's not correct. Remove
Status line."** Removed the panel's `<dt>Status</dt><dd>${t.status}...</dd>` row from `vTrip()` entirely, per
the explicit instruction - not replaced with a corrected version, removed. companion-app's `FahrtDetail.tsx`
never had an equivalent explicit "Status:" row (only a conditional "noch offen" `Pill`, which never shows the
literal text "vollständig") - nothing to change there, a documented non-issue rather than a silent skip.
**Owner-requested: "The Diagram for Battery Voltage shall use the max. available screen wide. In
Large-Screen-Mode it is unnecessary small."** The `max-width:400px` cap added 2026-08-25 (section R) to stop
the SVG chart stretching into a flat band on wide screens (fixed pixel `height:170px` + `width:100%` +
`preserveAspectRatio="none"` at 772px column width) had the side effect of capping it far below the available
772px column, on any screen. Root-fixed instead of re-tuning the cap: the `<svg>` now carries
`aspect-ratio:${BV_W}/${BV_H}` (320/170) instead of a fixed pixel height, so height scales with width and the
chart can never distort again regardless of container width - the `max-width:400px` rule is removed outright,
the chart now uses the full content column. The x-axis zoom/pan pointer handlers (`bvPlotRahmen()`, wheel
listener) were checked and confirmed unaffected: they derive everything from `rect.width`/`clientX`, never a
fixed pixel height.
**Verified live end-to-end in `audi_ha_test` at `http://localhost:18123`** (not just compiled/typechecked -
this file's own section S lesson: a clean compile pass doesn't prove a map or a layout is correct). Simulated
a full trip via direct HA REST state posts (ignition on -> three moving GPS points -> ignition off, pause
temporarily set to 0 for a fast test, restored after) and confirmed via screenshot: real OpenStreetMap tiles
render (no "API key required"), a genuine polyline follows the three simulated points with correct start/end
markers, "Start"/"Ziel" show date+time above the (unknown) address, no "Status" row appears anywhere on the
page. Battery diagram confirmed at a 1400px viewport: SVG measured 736×391px, ratio 1.882 matching
320/170 exactly (no distortion), using the full column instead of the previous 400px cap. Deleted the
synthetic test trip afterward; console showed only the pre-existing ServiceWorker/404 noise (ungenerated
vehicle photos, PWA service worker in this test environment), nothing new. companion-app: `tsc --noEmit`
clean, full suite green at 146/146, `npm run build` and `npm run ota` both succeeded (bundle rebuilt at
`2026.8.27.1`). Manifest bumped `2026.8.25.9` → `2026.8.27.1`.
**Same-day follow-up (2026.8.27.2): two smaller items found/requested while verifying the above.**
(1) A stale `min: 0.0` day-entry sat in `batteriespannung.jsonl`, recorded at 09:46 that day - **before**
the 15:12 restart that deployed the 8V plausibility floor (`SPANNUNG_MIN_V`, section T). The floor only
rejects *new* sub-8V readings going forward; it doesn't retroactively purge what was already stored, so the
0V value kept showing until removed. Deleted via the app's own `batterieverlauf_loeschen` service (the same
one the measurement list's swipe-to-delete uses) - not a code fix, a one-off data cleanup; the real instance
would need the same swipe-delete if the same stale-reading symptom ever shows up there.
(2) **Owner request: "Diagram Axis for Voltage: 8V to 15V"** (both battery voltage diagrams - the panel's
zoomable `#bvChart` and companion-app's `Verlaufsdiagramm` in `Batterie.tsx`, neither of which had a fixed
axis before, both computed min/max from the visible data ± padding). Both now draw a fixed Y-axis instead of
the data-driven range they used before - simpler, and the panel's `bvYDomain` no longer needs computing at
all when there's data, it's just a fixed pair of endpoints. Two immediate owner follow-ups the same session
adjusted the endpoints twice more: **8-15V → 10-15V** (`2026.8.27.3`), then **the `SPANNUNG_MIN_V`
plausibility floor itself raised 8.0 → 10.0 in `batterie.py`** to match (`2026.8.27.4`) - `historienimport.py`
imports the constant rather than duplicating it, so it picked up the new floor with no separate edit needed,
consistent with the live/import parity rule elsewhere in this file. The floor's module docstring was softened
from "physikalisch nicht plausibel" (no longer strictly true at 10V, a real if unlikely reading for a failing
battery) to "gilt als unplausibel" (an owner-set threshold, not a hard physical limit). Verified live at each
step: axis labels read `10,0 / 11,7 / 13,3 / 15,0` on the panel after the last change; companion-app confirmed
via `tsc --noEmit` each time (no dedicated visual check - same fixed-endpoint math, no zoom/pan feature to
interact with there); `batterie.py` re-`py_compile`d clean after the floor change. Manifest bumped
`2026.8.27.1` → `.2` → `.3` → `.4`, `npm run ota` rerun for the two frontend-touching steps, `audi_ha_test`
restart confirmed clean via log at every step (single `audi_dashboard` loader warning each time, no
duplicate-domain regression).
**Third follow-up (2026.8.27.5): a real regression from the `.1` width fix, plus a robustness gap, both
found from an owner screenshot of the wide-screen chart.** (1) **"Numbers are too large" / "reduce the
height"** - the `.1` fix (aspect-ratio matching the 320:170 viewBox so height scales with width) meant the
whole coordinate system, axis text included, scaled up together: at the full ~772px column width the SVG's
Y-scale grew to ~2.4×, so the `font-size="11"` axis labels rendered at ~26px and the chart itself grew to
~410px tall - both regressions from the same root cause, not two separate bugs. Reverted to a **fixed pixel
height** (`height:${BV_H}px` = 170px, dropping `aspect-ratio` entirely) with `width:100%` unchanged - the
Y-scale is now always exactly 1:1 regardless of container width, so axis text stays a normal ~11px and the
chart stays a compact, constant 170px tall on any screen; only the X-axis stretches to fill the column,
which is exactly the wide-and-short shape the owner asked for. companion-app's `Verlaufsdiagramm` never had
this bug (`.dm-diagramm` already used a fixed `height:120px` with no SVG-embedded text) - nothing to fix
there, confirmed by inspection.
(2) **"0V is being respected on the real instance - why isn't it ignored?"** - the screenshot showed a data
point plotted **below the axis's bottom edge**, past the 10V gridline, and a `0,0 V` SOC readout. Two
different things bundled in that one report: the underlying data point is almost certainly a pre-`.4`
reading (the same class of stale-data issue as `.2` item 1 above) sitting in the **real instance's**
`batteriespannung.jsonl` from before that instance was updated to run the raised `SPANNUNG_MIN_V=10.0` floor
- this session only ever touched `audi_ha_test`, never the owner's real instance, so nothing here confirms
the real instance is even running `.4`/`.5` yet (needs the self-update button + restart, or `install.ps1`,
plus a swipe-delete of the offending day exactly like `.2` item 1). Separately, and fixed here regardless of
that: **`bvSkalaY()` never clamped its output** - a point outside `[yMin, yMax]` (whatever the reason: a
stale reading, a not-yet-updated instance, a future edge case) extrapolated linearly past the plot area
instead of pinning to the axis edge, which is what actually drew the line running off the bottom of the
chart in the screenshot. Clamped the fraction to `[0, 1]` before scaling, in both codebases (companion-app's
equivalent `y()` in `Verlaufsdiagramm` got the identical clamp, for the same reason - it had no text-scaling
bug but shared this gap). This is a defensive rendering fix, not a data fix - it stops a bad point from
visually breaking the chart, but the owner's real instance still needs updating and the stale entry still
needs deleting for the *readout* (SOC/current-value tiles) to stop showing 0V too. Verified live: SVG
measured 736×170px at a 1400px viewport (Y-scale exactly `1`), axis label `getComputedStyle` font-size
`11px`, screenshot confirmed normal-sized labels and a compact chart using the full column. companion-app:
`tsc --noEmit` clean. Manifest bumped to `2026.8.27.5`, `npm run ota` rerun, `audi_ha_test` restart confirmed
clean via log.
**Fourth follow-up (2026.8.27.6): the real gap behind the `.5` "0V on the real instance" report - a second,
unfiltered publish path, not (only) stale/not-yet-updated data.** Owner report: "Mein Audi/Zustand Box"
still shows `Batteriespannung 0,0V`, while the Batterie-Messwerte list has no such entry. Traced to a second
code path that was never touched by the `SPANNUNG_MIN_V` floor at all: `veroeffentlichung.py`'s
`fahrzeugstatus()` publishes `batteriespannung` as the **raw, unfiltered current `BATTERIE_SENSOR` reading**
(`_zu_zahl(zustand_oder_none(...))`), completely independent of `batterie.py`'s history-recording path that
the floor actually guards. The panel's "Zustand" tile (`audi-dashboard-app.js` ~line 1611) and
companion-app's `MeinAudi.tsx`/`Batterie.tsx` "Aktuell gemeldet" line all read this same published field
directly - so a live sensor glitch (confirmed live: the test container's `BATTERIE_SENSOR` was genuinely
reporting raw state `"0"`) always showed through unfiltered there, in both codebases, regardless of whether
the instance had ever been updated. Fixed by applying the identical floor to this publish too: `spannung =
None` when the raw reading is below `SPANNUNG_MIN_V`, so "Zustand"/`Aktuell gemeldet` now show "unbekannt"/
"—" for the same implausible readings the history path already silently drops - one backend fix covers both
frontends, since both read the same published field. Needed a **function-local** import of `SPANNUNG_MIN_V`
from `batterie.py` (not a module-level one): `batterie.py` already imports `zustand_oder_none` from
`veroeffentlichung.py`, so a top-level import the other direction risks a circular-import failure depending
on load order (batterie.py reaches its own `from .veroeffentlichung import zustand_oder_none` line *before*
its `SPANNUNG_MIN_V = 10.0` assignment, so an eager reverse import can hit a partially-initialized module).
A local import inside `fahrzeugstatus()` sidesteps this entirely - both modules are always fully loaded by
the time that function actually runs. Verified live: a real Docker Desktop outage interrupted this fix
mid-session (confirmed via `docker ps` failing, unrelated to the code) - resumed once Docker was back.
`py_compile` clean, `audi_ha_test` restart confirmed clean via log **with no circular-import traceback**
(the actual proof this pattern needed, `py_compile` alone can't catch an import-order bug), and live in the
browser: raw sensor confirmed still reporting `"0"` via a direct state query, "Zustand" tile now reads
"Batteriespannung unbekannt" instead of "0,0 V". No companion-app source changes this round (backend-only
fix), so no OTA rebuild needed. Manifest bumped to `2026.8.27.6`.
## V. Per-trip Verbrauch (l/100 km): dead schema closed with a liter-based approximation, real-sensor
question left open (2026.8.27.7)
**The trip detail's "Verbrauch" row (`fahrtVerbrauch()`, `audi-dashboard-app.js`) always showed "liegt nicht
vor" - traced (owner asked "why is it so? what sensor are you waiting for?") to `t.verbrauch_l_100km` being
dead schema exactly like `start_lat`/`route` were before section U: it existed **only** in the frontend, no
backend code path (live or import) had ever written it, and no Setup-mappable sensor role existed for it
either. The "vom Fahrzeug gemeldet" caption was aspirational leftover, not a description of anything actually
wired up.**
**Owner's decision, to close this properly rather than just deleting the dead row:** two options exist -
(a) the FMM003 might expose a genuine short-trip l/100km value directly over OBD (the owner is checking), or
(b) compute an approximation from `TANK_LITER_SENSOR`'s liter-delta over the trip ÷ distance. Built (b) now
as the working default, with (a) tracked as an explicit follow-up: **if a real short-trip consumption sensor
turns up, swap to it directly instead of this approximation** - noted here rather than only in a chat
message, per this file's own "state changes, note it" maintenance rule.
**Implementation**, mirroring the existing `route_aus_verlauf()`/position-screening pattern from section U
for the exact same reason (live vs. import parity, no duplicated math): new
`verbrauch_aus_literstaenden(start_liter, end_liter, distance_km)` in `verlauf.py` - `None` unless both liter
readings and a positive distance are available *and* the liter level actually fell (a level that rose or held
steady means a mid-trip refuel or noise, not a meaningful ratio). `verbrauch_l_100km` added to the trip schema
(`leere_fahrt()`, was entirely absent before). Live path: new `_verbrauch_screenen()` in `screening.py`,
wired into `durchfuehren()` exactly like `_position_screenen()` - runs whenever `TANK_LITER_SENSOR` is mapped,
re-attempted on every screening pass until it succeeds (liter data can lag just like odometer/GPS data).
Import path: `_fahrten_importieren()` in `historienimport.py` computes it inline from the already-fetched
`verlaeufe["tank_liter"]` history (was fetched for tank-refill import already, just never used for this).
Both frontends: panel's caption changed from the false "vom Fahrzeug gemeldet" to "Näherung aus
Tankfüllstand"; companion-app's `FahrtDetail.tsx` had **no Verbrauch row at all before this** (a pre-existing
parity gap, not something this change broke) - added one in the same position as the panel's (after
"Durchschnitt"), same honest caption, `verbrauch_l_100km` added to the `Fahrt` type (was typed `unknown`
nowhere - simply absent).
**A second, real bug found and fixed while live-testing this: `_fahrt_screenen()` (the existing odometer
screening, unchanged by this feature) never synced its result back onto the in-memory `fahrt` dict, only into
storage.** `_vollstaendig()` only ever wrote `distance_km`/`km_quelle`/`status` into a local `aenderungen`
dict, which `_fahrt_screenen()` then persisted via `fahrt_aktualisieren()` - but never applied to the `fahrt`
object itself, unlike `_position_screenen()`, which already does `fahrt.update(aenderungen)` for exactly this
reason. Consequence: when a trip's distance was computed for the first time and the *same* `durchfuehren()`
pass then reached the new `_verbrauch_screenen()` step, the `ohne_verbrauch` filter still saw the stale
`distance_km: None` from the `fahrten` list fetched at the top of the function - `verbrauch_l_100km` silently
stayed unfilled for one whole screening cycle, only catching up on the *next* trigger. Fixed by adding the
same `fahrt.update(aenderungen)` to `_fahrt_screenen()`, matching `_position_screenen()`'s already-correct
pattern - found by reproducing it directly (a trip's `distance_km` and `verbrauch_l_100km` both stayed empty
after the first screening pass, filled in only after a second explicit trigger) rather than by reading the
code and guessing.
Verified: `py_compile` clean on all five touched backend files (incl. this `screening.py` fix), `node --check`
clean on the panel JS, companion-app `tsc --noEmit` clean and full suite green at 146/146. Live end-to-end in
`audi_ha_test` (`TANK_LITER_SENSOR` was already mapped to the container's real
`sensor.testcar_b9_fmm003_testintegratoin_can_fuel_volume`, liters - no Setup change needed): ran a synthetic
20.0→19.0 l trip over a simulated 10 km, confirmed `verbrauch_l_100km: 10.0` computed correctly and rendered
on the panel's trip detail ("10,0 l/100 km · Näherung aus Tankfüllstand", screenshot taken). companion-app's
`FahrtDetail` row was **not** live-rendered this round (no running dev server connected to a backend this
session) - verified only via `tsc --noEmit` and the full test suite, same as several other companion-app-only
changes this session.
**Separately, while pulling data for this: found the container's `ZUENDUNG_SENSOR` mapping had changed since
last checked** - now `binary_sensor...engine_ignition_or_acc_status` (stable, no chattering observed) instead
of the `instant_movement_status` entity used earlier this session, which **was** flapping on/off dozens of
times per minute (test-integration noise, unrelated to any real vehicle behavior). The owner had reconfigured
the Setup mapping themselves in the meantime. Confirms this container's synthetic test entities remain
unrelated to the owner's real-vehicle trip-merging report from the same day (section P/the "no clean
ignition-off signal" conversation) - it needs the real instance's own history, not this container's.
Manifest bumped to `2026.8.27.7`, `npm run ota` rerun (companion-app source changed).
## W. Correction: `audi_ha_test` is connected to the real vehicle, not synthetic data - and the real
trip-merging/GPS-jump report, root-caused with real data (2026.8.27.8)
**Correction to sections U and V above: the owner confirmed `audi_ha_test` (this session's "test container")
receives live telemetry from the real car/real FMM003 - the same sensors are wired into both this container
and the real HA instance in parallel.** Both prior sections state the opposite ("synthetic test-integration
noise", "unrelated to the owner's real-vehicle... report") - that was wrong, and this project's own
convention is to correct rather than silently rewrite history, so it stays here rather than editing U/V after
the fact. The erratic on/off chatter observed on `instant_movement_status` earlier the same day is real
telemetry from a real accelerometer/movement-based signal, not fabricated test noise - see below for what it
actually means. **The owner also confirmed creating synthetic test trips in this container (as done
throughout sections R-V) remains fine** - it's a secondary instance fed by the same sensors, not the sole
copy of their trip history, so the established "simulate via `hass.callApi` POST states, verify, clean up
afterward" testing convention stands unchanged going forward.
**With that corrected, the real trip-merging/GPS-jump report (originally raised in section P's conversation)
was root-caused directly from this container's real recorder history - no separate pull from a "production"
instance was needed after all, since this *is* that instance's real data.** Matched the owner's report
exactly once UTC/local (`CEST = UTC+2`) is accounted for:
- `t-da363d681915` (`09:55:31``12:05:01` UTC = **11:5514:05 local**) - the wrongly-merged trip.
- `t-8be105166025` (`12:28:05``12:29:12` UTC = **14:2814:29 local**), `distance_km: 22.0` over 67 seconds
(~1180 km/h) - the "GPS jumps 22 km" report.
**Root cause of the merge, confirmed from the raw history (not the "unavailable-instead-of-off" hypothesis
originally guessed in section P's conversation):** `instant_movement_status` (mapped as `ZUENDUNG_SENSOR` at
the time) reported sustained, unbroken `on` for 4564-minute stretches with zero transitions - including
straight through the ~30-minute real stop the owner described around `10:22` UTC, with no `off` at all during
it. Where it did flip `off`, it almost always flipped back `on` within milliseconds to a couple of seconds,
so the 15-minute pause-close logic (`fahrterkennung.py`) never got an uncanceled window to actually end the
trip - every near-instant `on` cancels the pending close (`k.warte_ende_ab_abbrechen()`, correct behavior in
isolation) before the wait can complete. No `unavailable` states appeared anywhere in the relevant window, so
the section P dropout-hypothesis genuinely doesn't apply to this specific incident. The entity itself is the
problem: a real-time movement/accelerometer heuristic, not a debounced ACC/ignition state - it can both
chatter during genuine driving *and* fail to register a genuine stop. **The owner had already fixed this
independently by remapping `ZUENDUNG_SENSOR` to `engine_ignition_or_acc_status`** (confirmed stable, no
chatter, when checked) - the correct fix; nothing further needed there from this end.
**The GPS-jump trip (`t-8be105166025`) is a second, related but distinct defect: the live trip-completion
path had no plausibility guard on computed average speed at all.** `historienimport.py` already had one
(`UNPLAUSIBLE_KMH = 300`, added 2026-08-25/section P) for exactly this failure mode - a real 22 km drive
whose *actual* duration was much longer than 67 seconds, but whose recorded trip window got compressed by the
chattering ignition entity creating a spuriously short boundary, while `naechster_wert()`'s odometer lookup
still (correctly, on its own terms) found the real km delta nearest those wrong timestamps. `screening.py`
(the live path) never got the equivalent guard - a gap already flagged as open in section P but not built
until now. Fixed by moving the check into a shared home: `UNPLAUSIBLE_KMH` and a new `durchschnitt_kmh()`
helper now live in `verlauf.py` (both `screening.py` and `historienimport.py` import them, no more duplicated
constant), and `screening.py`'s `_vollstaendig()` - the single choke point both `_fahrt_screenen()` and
`_lueckenlos_verketten()` already funnel through - now rejects (not just the distance, both `odo_start` *and*
`odo_end`) whenever the implied average speed exceeds the limit, logs a warning, and leaves the trip `offen`
to be retried on the next screening pass, exactly matching how `historienimport.py` already handles this
class of error.
**The two already-broken real trip records were deliberately left untouched** - they're the owner's real
driving history, not disposable test data (see the correction above), so fixing/deleting them is the owner's
call via the app's own edit/delete controls, not something to do unilaterally from this session.
**Owner then confirmed synthetic trips in this container remain fine** (same sensors feed both this
container and the real instance in parallel, this one is not the sole copy of trip history) **- and drew a
hard line that stays for the rest of this project: `audi_ha_test`/`:18123` is the only instance ever to be
touched; the real instance is never to be worked on, full stop.** Attempted a live end-to-end repro of the
exact bug (POSTed a fake +22 km jump over ~8 s, same recipe used successfully for the position/verbrauch
features earlier this session) - and hit a new, worth-remembering limitation: **the real FMM003/flespi
integration re-asserts its own live odometer reading shortly after a manual `POST /api/states` write**, so
the simulated +22 km value never actually landed in the recorder at all (confirmed via a direct history
query - only the real, unchanged ~21324 km value ever appears). This container's earlier "first screening
pass runs before the recorder commits" races (sections R/S) may have partly been this same effect rather than
pure commit latency - a real competing writer, not just replay timing. **Consequence for future sessions:**
simulating a *specific* sensor value in this container is unreliable for entities the real FMM003 actively
reports (ignition, odometer, GPS, tank) - it can work if the value is posted right before the dependent
event fires (as it did for the position/verbrauch tests), but don't assume a posted value will still be there
moments later if the real device happens to report in the meantime.
Fell back to the same no-live-state verification as the first pass: `py_compile` clean on all three touched
backend files, `audi_ha_test` restart confirmed clean via log (single `audi_dashboard` loader warning, no
duplicate-domain regression), and the plausibility math verified by calling `durchschnitt_kmh()` directly
against the real bug's own numbers (no HA state involved): `durchschnitt_kmh(22.0, 67)` → `1182.1` km/h,
correctly exceeding `UNPLAUSIBLE_KMH=300` and therefore now rejected; a real plausible drive
(`durchschnitt_kmh(22.0, 1200)` → `66.0` km/h) correctly passes through unaffected. No companion-app source
changes this round (backend-only). Manifest bumped to `2026.8.27.8`.
## X. Statistik-Tab-Icon zu groß, Batteriediagramm im Breitbildschirm "professioneller" gestaltet (2026.8.27.9)
**Owner report 1: "statistic icon seems to be too large in comparison to Car icon or Petrol-Station
icon."** Measured live rather than guessed (`getBBox()` on all five rendered tab SVGs in
`audi_ha_test`, all share one `viewBox="0 0 24 24")`: the hand-drawn tab icons (home/audi/trips/fuel)
fill 32-72% of their box - they were drawn with deliberate internal padding, the usual convention for
this icon style. `CI.pollsS` (the Statistik tab's CI icon since section R) is drawn edge-to-edge and
fills 91.7% - a real, measured mismatch, not just a subjective impression. Fixed by wrapping the icon
in a centered `scale(0.7)` (`translate(3.6,3.6) scale(0.7)`, about (12,12)) rather than a special-cased
pixel size - this keeps it correctly proportioned at both of `.tab svg`'s breakpoints (26px mobile,
22px desktop in `audi-dashboard-ios.css`) without maintaining two numbers. Re-measured after the fix:
44.9% fill, now within the sibling range (32-72%), close to "trips" (46.7%). Same mismatch confirmed
and fixed in companion-app's `symbole.tsx` `SymbolStatistik` (parity rule) - it shares the identical
`CI.pollsS` path via the same `Rahmen` wrapper as `SymbolAudi`/`SymbolFahrten`/`SymbolTanken`.
**Owner report 2: "The Diagram for Battery Voltage shall use the max. available screen wide... find a
solution to make it look professional."** The wide-screen width fix from section U (fixed
`height:${BV_H}px`, `width:100%`, letting only the X-axis stretch) was functionally correct but bare at
772px column width: still only 3 x-axis labels (start/middle/end, computed from fixed fractions
regardless of real pixel width) and a plain unfilled line - looked sparse rather than "finished".
Panel-only, per the same reasoning section R/S already established for this exact chart ("the diagram
in question doesn't exist as a separate wide layout in companion-app" - confirmed with the owner at the
time; `Verlaufsdiagramm` in companion-app's `Batterie.tsx` uses a fixed `height:120px` box with no
wide-layout breakpoint, nothing to change there). Two additions to `bvZeichnen()`, both restrained and
consistent with the project's iOS-HIG direction (Apple's own Health/Stocks charts use exactly this
pattern) rather than a heavier redesign:
- A low-opacity linear-gradient area fill under the line (`<linearGradient id="bvGrad">`, `var(--fg)`
fading `0.16 → 0`), purely decorative, no new information, drawn between the gridlines and the line
so the grid still shows through.
- The x-axis tick count is now computed from the SVG's actual rendered pixel width
(`svg.getBoundingClientRect().width`, ~110px minimum spacing per label, clamped 3-6) instead of a
fixed `[0, 0.5, 1]` - a narrow phone chart still shows 3, the 772px desktop column shows up to 6.
Also added `stroke-linecap="round" stroke-linejoin="round"` to the polyline for cleaner corners.
- Verified live at 1400px: SVG measured 736×170px (fixed height preserved, no distortion regression),
axis text confirmed still 11px (no repeat of the earlier text-scaling bug from section U's `.5`
entry), 6 tick labels generated, gradient `<defs>`/area `<path>` both present in the rendered markup.
The test container's actual battery history only spans ~2 days, so several of the 6 labels read the
same day-precision date (`09.08.26`/`10.08.26` repeated) - a property of this container's narrow
synthetic dataset, not a defect in the tick logic itself; a real multi-week history would show 6
distinct dates.
Verified: `node --check` clean on the panel JS, companion-app `tsc --noEmit` clean. Manifest bumped to
`2026.8.27.9`, `audi_ha_test` restart confirmed clean via log (single `audi_dashboard` loader warning),
both fixes re-verified live in the browser after the restart (icon fill-percentage measurement above,
chart dimensions/gradient/ticks above). `npm run ota` rerun (companion-app source changed:
`symbole.tsx`), bundle rebuilt at `2026.8.27.9`, sha256
`66fef05581b26086d76500d864af5704cd568a39474ece47df88fb7c9c51dc50`.
**Follow-up (2026.8.27.10): Statistik-Icon auf Nutzerwunsch 10% größer** - Faktor 0.7 → 0.77
(`translate(2.76,2.76) scale(0.77)`), identisch in beiden Codebasen.
**Follow-up (2026.8.27.11): das Batteriediagramm war immer noch falsch - ein echter Darstellungsfehler
plus ein Filter, der 7 von 9 Messtagen verschluckt hat.** Owner sent a screenshot ("thats still not
fine") plus four instructions: no dates under the diagram, every measurement as a dot, date visible on
click, and zoom must keep working when more dots appear; then a fifth: only the minimum gets a dot, the
maximum appears as text on click.
Two genuine defects behind the screenshot, both found by measuring rather than by eye:
1. **`preserveAspectRatio="none"` on a fixed 320-unit viewBox distorted everything horizontally.** At
the 736px desktop column that is a 2.3× horizontal stretch applied to the whole coordinate system -
the axis numbers rendered visibly wide (measured: "10,0" ≈ 50px wide at font-size 11), and the six
date labels from `.9` collided with each other because their text stretched while their anchor
spacing did not. This is the same class of bug as section U's `.5` entry (text scaling with the
coordinate system), which was fixed there by pinning the height - the horizontal axis had the same
problem and was missed. Fixed properly this time: `bvZeichnen()` measures the rendered width and
sets `viewBox="0 0 <gemessene Breite> 170"`, so **one coordinate unit is exactly one pixel** and
nothing can distort at any width; `preserveAspectRatio` is removed entirely. `BV_W` is now only a
starting value (`bvBreite` holds the live width), `bvSkalaX()`/the gridline width use `bvBreite`,
and `bvPlotRahmen()` collapsed to plain pixel arithmetic. A `ResizeObserver` on the SVG redraws on
window resize, so the viewBox cannot go stale (setting the viewBox does not change the element's
size, so no feedback loop).
2. **The chart filtered out every day without a resting-voltage reading.** `bvPunkte` was built with
`e.min != null && e.min <= AGM_RUHE_MAX_V` - so days where the car never stood still long enough
(minimum still above 12,8 V, i.e. alternator voltage) vanished from the chart completely. In the
test container that reduced 9 recorded days to **2**, which is exactly the near-empty two-dot chart
in the owner's screenshot, and why the range read "09.08.26 10.08.26" instead of the real
09.0825.08. The filter is dropped for the chart (owner: "every Battery-Measurement as a dot"); the
values that genuinely need resting voltage keep it via a **new, separate `bvRuhePunkte`** - the SOC
readout (`letzter`) and `bvSohBewertung()` both switched to it, otherwise a generator reading would
have shown a permanent "100 % · Sehr gut".
Per the owner's instructions: the x-axis date labels are gone entirely (the period still stands under
the chart as `#bvRange`, and a single measurement's date/time comes from tapping its dot); one dot per
recorded day, drawn at the **minimum** only; the tooltip now reads
`18.08.2026 · 08:57 Uhr · Min 13,02 V · Max 13,47 V` (plus outside temperature when recorded) - that is
where the maximum lives now, it has no dot of its own. `BV_MB` dropped 22 → 12 since nothing is written
under the plot any more. Zoom/pan is untouched and confirmed still working.
**companion-app (parity rule):** its chart never had defect 2 (it already filtered only `min != null`,
no AGM cut) and had no date labels either, so those two needed nothing. It *did* differ on the owner's
other two points: no dots at all, and the daily maximum drawn as a permanent second grey line. Ported:
one `<circle>` per measurement, the max line removed, tap-a-dot shows the same
date/time/Min/Max/temperature line underneath, and the same dynamic-viewBox treatment (measured width +
`ResizeObserver`, `preserveAspectRatio="none"` removed) - needed there too, because with `none` the new
dots would have rendered as horizontally stretched ellipses. **Zoom/pan was deliberately NOT ported**:
the panel's pinch/wheel/drag handling is ~150 lines of pointer bookkeeping (`bvZeiger`/`bvGestStart`/
`bvDomainKlemmen`) with no counterpart in companion-app, and building it there is its own work package
rather than part of this fix - flagged here per the parity rule's "say so explicitly" clause instead of
being left silently undone.
Verified live in `audi_ha_test` at a 1400px viewport after the restart (`2026.8.27.11 eingerichtet`,
clean log): `viewBox="0 0 736.0 170"` against a 736px-wide element (exactly 1:1, `preserveAspectRatio`
absent), **9 dots** instead of 2, the only remaining `<text>` elements are the four y-axis labels
(`10,0 / 11,7 / 13,3 / 15,0` - no dates), range label now spans the true `09.08.26 25.08.26`, and
"10,0" measures **21,7 × 12 px** (undistorted at font-size 11; it was ~50px wide before). A synthetic
pointerdown/up on the 6th dot produced the tooltip quoted above. Five wheel-zoom steps narrowed the
range to `14.08.26 20.08.26` showing 4 dots, and "Zoom zurücksetzen" restored the full range -
zoom confirmed intact. companion-app: `tsc --noEmit` clean, suite green at 146/146 (the
render-every-page smoke test covers the reworked chart), `npm run ota` rebuilt at `2026.8.27.11`,
sha256 `d0f77e854a2664331314ffc64a26712e0982c80e145aed7b25cabc41907e5974`.
**Follow-up (2026.8.27.12): the two codebases' SOC (Ladezustand) curves disagreed.** Owner asked "how is
soc calculated" - answering it surfaced that the panel (`bvSocProzent()`, 11-point linear interpolation)
and companion-app (`ladezustand()`, a 6-step lookup) genuinely disagreed on the same voltage - worst case
15 percentage points apart (12.1 V: 40% panel vs 25% app), and the app also only ever looked at the
*newest* stored day rather than walking back to the last resting reading like the panel does, so it could
show "—" on a day the panel showed a real number. Owner: "ok use the interpolated for both". Ported the
panel's exact 11-point table and linear-interpolation math into companion-app's `ladezustand()` -
`Batterie.tsx`. **Deliberately NOT ported**: the "walk back to the last resting reading" selection logic
- that's a separate, larger behavior change beyond "align the curve" and wasn't asked for; flagged here so
it isn't mistaken for done. No dedicated tests referenced the old stepped values (grepped first), so
nothing else needed updating. Verified: `tsc --noEmit` clean, suite green 146/146.
**Follow-up (2026.8.27.13): the battery temperature field, why it was always empty, and two real "match
must be close in time" gaps found and fixed.** Owner: "i cant see any temperature at the battery" -
investigated live rather than assumed. Root cause was mundane: `AUSSENTEMP_SENSOR` was correctly mapped
and healthy (30°C, fresh), but the FMM003 voltage source was offline (dongle down), so every 5-minute
`pruefen()` tick in `batterie.py` was rejecting the reading at the `SPANNUNG_MIN_V=10.0` plausibility
floor **before ever reading temperature** - confirmed via the recurring log line
`Batteriespannung 0.0 V unter der Plausibilitätsgrenze`. Separately, all 9 stored entries predate the
temperature feature (2026-08-25/section R) entirely, so none carry `min_temp_c` regardless.
Owner pushed back on "nothing to backfill" with "i bet there is a temperature recorded the same time when
the voltage was measured" - correct, and worth the correction: queried the recorder directly rather than
assuming, and outside-temperature history exists for every one of the 9 stored days (recorder now keeps a
year, section F). That exposed a **real, previously-unnoticed gap**: `historienimport.py`'s
`_batterie_importieren()` never fetched `AUSSENTEMP_SENSOR` history at all (`verlaeufe` dict had no
`"aussentemp"` key) - so even running the historical import over that same window would never have
backfilled it. Fixed: `verlaeufe["aussentemp"]` added to `importieren()`'s fetch, and each day's minimum
now looks up the nearest temperature reading via the recorder history, same pattern as the existing
GPS/liter pairing helpers.
**Then the owner set a hard constraint: "temperature must be same time as voltage. 60 seconds delay is
fine but not longer. is ther always a temperature when voltsge vomes?"** Measured rather than assumed
(live recorder query over 48h on the container's then-mapped `cupra_eu_data_act` temperature sensor): NO
- 22.4% of its own update gaps exceeded 60s, with a max gap of ~36.6 hours (duplicate-timestamp template
sensor, not a real device). That answer justified building a real distance bound rather than trusting an
unbounded nearest-match:
- New `wert_in_naehe(zielzeit, verlauf, max_abstand_s=NEBENWERT_MAX_ABSTAND_S)` in `verlauf.py` - same
nearest-match algorithm as the existing `naechster_wert()`, but returns `None` instead of an arbitrarily
distant value when even the closest point exceeds the bound. `NEBENWERT_MAX_ABSTAND_S = 60`, the owner's
own number, documented as owner-set rather than derived.
- **Import path** (`historienimport.py`): switched from `naechster_wert()` to `wert_in_naehe()` for the
temperature pairing.
- **Live path** (`batterie.py`): this had the same latent gap and nobody had asked about it yet -
`zustand_oder_none()` returns whatever a stale entity's last value was, no matter its age, so a live
reading could just as easily have paired a many-hours-old temperature with a fresh voltage measurement.
Fixed by comparing the two sensors' own `last_updated` timestamps (via `hass.states.get()` directly,
bypassing `zustand_oder_none()`'s attribute-stripping for this one lookup) against each other - not
against wall-clock "now", since both are read from the current `hass.states` snapshot but can carry
different ages. Same `NEBENWERT_MAX_ABSTAND_S` bound, imported from `verlauf.py` (no circular-import risk:
`verlauf.py` has no local imports of its own, unlike the `SPANNUNG_MIN_V` situation elsewhere in this
file that needed a function-local import).
- Verified the boundary itself with a deterministic Python check (`wert_in_naehe()` called directly with
synthetic 3s/60s/61s/36h-offset timestamps) rather than relying on live timing: 3s and 60s pass through
unchanged, 61s and 36h correctly return `None`.
**Then the owner remapped `AUSSENTEMP_SENSOR` from the flaky `cupra_eu_data_act` sensor to the real FMM003
CAN sensor and asked to check again.** Confirmed live: the FMM003 temperature and voltage entities update
in the same telemetry batch (~3s median gap between temperature updates, sub-second gap between the two
sensors' own timestamps) - a fundamentally different reliability profile from the old sensor, so the 60s
guard should pass consistently going forward. One caveat surfaced and reported honestly rather than
glossed over: this FMM003 temperature entity's own history only reaches back to ~2026-08-25 (confirmed via
a recorder query for 2026-08-09: zero points), so a re-import can never backfill temperature for the
five oldest stored days (08-09 through 08-24) - only 08-25 onward is reachable with this sensor, no matter
how the import logic is written.
Attempted a real live write to confirm end-to-end (temporarily POSTing a plausible voltage value on a
repeating interval, same technique used earlier this session), but the owner flagged mid-wait that the
FMM003 itself is currently offline - correctly stopped rather than waiting on a device that was never
going to answer; the interval was cleared and the sensor reset to `0`, confirmed no stray entry was written
to `batteriespannung.jsonl` in the meantime.
**Owner then asked to "import the available data"** - ran `audi_dashboard.historie_importieren` live
(`2025-08-28` through now) via the real service call. Result (`sensor.audi_dashboard_import_status`):
`batterie_tage: 9`, plus one genuinely new entry the live path had never captured on its own
(`2026-08-27`, min 12.149 V / max 15.439 V - real recorder history from earlier the same day, before the
dongle went offline). Its `min_temp_c` came back `null`; verified this is correct, not a bug - the nearest
FMM003 temperature reading to that day's exact minimum timestamp was **180 seconds** away (25 points in a
60-minute window around it, so the sensor was reporting, just not within the 60s bound at that specific
moment). The 9 pre-25-08 entries stayed without temperature, exactly as predicted by the FMM003 history-gap
finding above - not a defect, the data genuinely isn't there for that sensor over that window.
Verified throughout: all touched Python files `py_compile` clean via the container (`verlauf.py`,
`historienimport.py`, `batterie.py`), companion-app `tsc --noEmit` clean and suite green 146/146,
`audi_ha_test` restarted clean at each step ending on `2026.8.27.13` (single `audi_dashboard` loader
warning, no traceback). `npm run ota` rebuilt at `2026.8.27.12`, sha256
`5ef88ee826431b5b2cff5020c83430be88d729b51284459f36a5388963e441f3` (companion-app source changed for the
SOC curve; `.13`'s backend-only changes needed no OTA rebuild).
**Follow-up (2026.8.27.14): `NEBENWERT_MAX_ABSTAND_S` raised from 60s to 300s, with real FMM003 telemetry
as the reason - not a guess.** Owner suspected voltage and temperature were being compared across the
wrong sensors; investigated rather than assumed - confirmed via `config/entity_registry/list` that both
`external_power_voltage` and `can_ambient_air_temperature` belong to the same single FMM003 device
(`device_id 3667296563855653c6895ed57b6b9030`, platform `flespi`) - no duplicate device, no sensor mixup.
Also double-checked against a documented past mistake in this file (section B): confirmed `voltage` used
`external_power_voltage` (the car's 12V bus), not `battery_voltage` (the FMM003's own internal backup
cell) - both exist on this device and are easy to conflate.
Owner then asked why there'd be any delay at all if both come from the same device. Answer, grounded in
a live-pulled table rather than general knowledge: a GPS/telematics tracker like the FMM003 reads dozens
of independent CAN parameters (70+ entities on this one device) and transmits them at different rates to
save cellular data - voltage changes constantly and gets reported often, ambient temperature barely moves
and gets reported rarely. Measured over 46h: 851 voltage state-changes vs. 187 temperature state-changes
on the same device. Pulled an actual table for the owner's named 12:00-13:00 local drive (`t-da363d681915`,
resampled to one row per minute, collapsed into ranges): normal (non-blackout) temperature gaps of 8s,
19s, 34s, 174s, and **593s** all occurred during otherwise-healthy reporting - not a connectivity problem,
just the device's own per-parameter reporting cadence. Separately, a real 45-minute stretch
(10:14-10:59 UTC) showed **both** sensors going completely silent at once - a genuine connectivity
dropout, the same root cause already documented in section P, not a reporting-cadence issue.
Recommended widening the window from 60s to 300s (5 minutes) on three grounds: (1) the worst *normal*
gap measured was 593s, so 60s was rejecting healthy pairings, not just genuinely stale ones; (2) outside
air temperature is physically slow-changing, so several minutes of staleness introduces negligible real
error, unlike GPS position or the voltage reading itself; (3) `batterie.py`'s `pruefen()` only runs every
5 minutes anyway, so demanding sub-minute precision from the pairing doesn't buy anything the measurement
itself doesn't already lack. Owner picked 300s over the alternative 600s suggestion. `NEBENWERT_MAX_ABSTAND_S`
in `verlauf.py` changed `60 → 300`; `wert_in_naehe()`'s own algorithm is unchanged (still "nearest in
time, `None` if even the closest point exceeds the bound") - only the bound moved, confirmed via the same
kind of deterministic boundary test as the `.13` entry: 299s/300s pass, 301s and the real 593s/2700s cases
correctly return `None`.
Verified: `py_compile` clean on `verlauf.py`, `audi_ha_test` restarted clean on `2026.8.27.14` (single
`audi_dashboard` loader warning, no traceback). No companion-app source changed this round (the constant
lives only in the backend), so no OTA rebuild needed.
**Follow-up (2026.8.27.15): a real merge-logic bug meant the `.14` widening could never actually backfill
anything - re-running the import kept showing `min_temp_c: null` even for entries the new 300s window
should have caught.** Owner: "i still dont see a single temperature at a measured voltage." Traced to
`ablage.py`'s `batterieverlauf_tageswert_aktualisieren()`: it only ever wrote `min_temp_c` inside the
`if spannung < eintrag["min"]:` branch - i.e. only when a measurement set a *new, lower* minimum. Re-running
the import over an already-recorded day computes the exact same historical minimum it found before (same
source data, deterministic), so `spannung < eintrag["min"]` is always `False` on a second pass - the branch
that would apply the newly-reachable temperature never ran, no matter how wide `NEBENWERT_MAX_ABSTAND_S`
got. This directly contradicts the `.13` entry's own docstring claim ("ein erneuter Import ... ergänzt sie
dort nachträglich") - that claim was aspirational, not actually true, until this fix. Added an `elif`
branch: `spannung == eintrag["min"] and eintrag.get("min_temp_c") is None and aussentemp is not None` →
fill in `min_temp_c` without touching `min`/`min_ts` (they're already correct; only the temperature was
ever missing). Live and import paths both benefit automatically - no separate change needed in either
`batterie.py` or `historienimport.py`, both already call through this one shared function.
**Verified by actually re-running the import, not just by re-reading the code:** before the fix, all 9
importable entries still showed the state from the `.13`-era import (only `2026-08-27` even attempted,
and it alone stayed `null` at 180s > the then-60s bound). After deploying `.15` and re-running
`audi_dashboard.historie_importieren` over the same window: **6 of the 9 entries now carry a real
`min_temp_c`** (08-15: 26.0°C, 08-17: 26.0°C, 08-23/24/25/27: 20.0°C) - direct proof the fill-in-gap logic
works, not just that it compiles. Two entries (08-16, 08-18) still show no temperature - checked why rather
than assuming a bug: a first ±3h recorder query for both returned suspiciously identical `10800.0s`
(exactly 3h) distances, which turned out to be an artifact of that query window's own edge, not the true
nearest point. Widening to ±24h found the real answer: the nearest FMM003 temperature reading is **14.7h**
away for 08-16 and **13.8h** away for 08-18 - genuinely no temperature data near those specific moments on
the device's side, unrelated to the 300s bound or this fix. The two oldest entries (08-09, 08-10) remain
without temperature for the already-documented reason in the `.13` entry (FMM003 history starts ~08-25 for
this specific test device... **correction**: the temperature history actually reaches back to at least
08-15, per this round's own findings - the `.13` entry's "history starts ~08-25" claim was based on a
single spot-check at 08-09 and turned out to be an overgeneralization; the accurate statement is simply
"no temperature data near this specific moment," checked per-day rather than assumed as a blanket cutoff
date. Worth remembering: a live spot-check at one date does not establish a device's reporting start date -
check the specific timestamp that actually matters.
Verified: `py_compile` clean on `ablage.py`, `audi_ha_test` restarted clean on `2026.8.27.15` (single
`audi_dashboard` loader warning, no traceback), re-running the import produced the 6-of-9 result quoted
above via direct file inspection, not just a trusted service-call return. No companion-app source changed
(backend-only), no OTA rebuild needed.
**Follow-ups (2026.8.27.16-.17), same session:** two corrections from the owner after `.15`, both applied
to the same three "which voltage counts as what" spots (`audi-dashboard-app.js`, companion-app's
`Batterie.tsx`): (1) `AGM_RUHE_MAX_V` (classification: "is this a battery reading or already the
generator") raised from 12.8 → 13.0 V, owner-set directly ("max voltage for AGM Battery is 13V"). This
also meant the diagram's dot set had to go back to filtering by this threshold - a mid-session detour
(section U's later rounds) had made the diagram plot *every* measurement including generator readings; the
owner's "13,01 shall be generator und not part of the diagram" reinstated the filter, just at the new 13.0
ceiling instead of the old 12.8 one. (2) The owner then separately clarified the SOC curve's own "100% /
voll" anchor should **stay** at 12.8 V ("12,8v voll was already right") - so `AGM_RUHE_MAX_V` (13.0,
classification) and `BV_SOC_TABELLE`'s last point (12.8, the SOC-100% anchor) are now two deliberately
different numbers, not one; a reading between 12.8-13.0 V counts as a valid battery measurement (gets a
dot, feeds the SOH trend) but still displays 100% SOC via the table's existing clamp-above-last-point
behavior - no new code needed for that overlap, just careful separation of the two constants and their
captions/comments. Also added, same round: "Mein Audi"/Zustand's Batteriespannung row now shows the
**last plausible voltage** instead of "unbekannt" when the live reading is currently implausible (e.g.
dongle offline reporting 0V) - a small RAM-only cache in `veroeffentlichung.py`
(`_letzte_gueltige_spannung`), updated whenever a valid reading is observed, read as fallback otherwise.
Deliberately not persisted (same reasoning as this project's other RAM-only runtime state) - a restart
shows "unbekannt" again until the next real reading, rather than resurrecting a possibly very old value
from disk.
**A real reachability report that could not be reproduced.** Owner reported the Batteriespannung row in
"Mein Audi"/Zustand isn't clickable specifically in wide/desktop layout ("cursor changes but click does
nothing"). Investigated thoroughly and could not reproduce: `document.elementFromPoint()` hit-testing at
the row's exact screen position at 1400px width correctly resolved to the row itself (not an overlapping
element); a full pointerdown→pointerup→click sequence dispatched there navigated correctly; a genuine
OS-level trusted click via the browser tool's `computer` action (not a scripted DOM event - this is the
closest thing to a real click available for remote testing) also navigated correctly. No `pointer-events`
rule in the desktop `@container` CSS block touches `.row`. Left open, pending the owner's exact window
width and a browser-console check at the moment of the failed click - flagging here rather than guessing
further blindly.
**A significant process gap, found and fixed the hard way: the self-update mechanism (section J) silently
undid this entire session's work.** The owner ran "check for update" / "install" via the Einstellungen
tile, which is designed to work exactly as documented - it pulled the latest **committed** state from
Gitea and replaced the live `custom_components/audi_dashboard/` folder with it. The problem: every fix in
this session (going back through sections U-W and all of this section) had only ever been deployed to
`audi_ha_test` via direct `docker cp`, never committed - so Gitea's `HEAD` was still sitting at `80d73e8`,
several rounds behind. The self-update did precisely what it's built to do; the gap was entirely on this
session's side for not committing incrementally. Confirmed directly: `audi_ha_test`'s live manifest read
back `2026.8.27.8` immediately after the owner's update, not `.17` - and every symptom the owner reported
(Batteriespannung showing "unbekannt" again, the diagram's max reading back at 13,99 V, "Ruhespannung"
back at a stale value) was exactly consistent with the pre-session code, not a new regression. Fixed in
two steps: re-deployed the current local files to `audi_ha_test` via `docker cp` (restoring `.17`
behavior for continued testing), then committed and pushed everything to Gitea (`172db54`) so the
self-update path is no longer stale. **Lesson for future sessions, stated plainly: `docker cp` to
`audi_ha_test` is a testing convenience, not a delivery mechanism - it is invisible to the self-updater and
to `install.ps1` alike. Commit and push considerably more often than "when the owner explicitly says
so," specifically before suggesting or allowing a self-update/install run, or a long session's worth of
verified, deployed fixes can vanish in one click.** (`git push` needed one retry for the
already-documented Gitea Credential Manager flakiness - consistent with every other push this project has
made.)
## Y. Full cross-codebase bug audit: six real defects found and fixed (2026.8.27.19)
Owner asked for a general audit of the app, not tied to a specific report. Ran three parallel reviews
(backend Python, panel JS, companion-app) rather than one pass, each grounded in reading the actual current
code rather than trusting this file's own history. Six real, concrete defects survived; three items the
reviews raised as *possible* concerns were checked and confirmed correct as-is (documented below so they
aren't re-flagged). No crash-class bugs (undefined-reference errors, the historical `bvRuhePunkte` class of
bug) were found anywhere - confirmed via ESLint `no-undef` over the whole panel file plus manual review.
**1. (High) companion-app's `Batterie.tsx` showed generator voltage as "Ruhespannung" and skewed the SOH
trend.** The panel's `bvPunkte` (the single, already-filtered `<= AGM_RUHE_MAX_V` array used for the
headline, trend, and diagram alike - see section X's `.16`/`.17` history) was never mirrored here:
companion-app's `messwerte` had no such filter, so the newest day's minimum reading - even a generator
reading above 13.0 V - could show as the "Ruhespannung" headline, and the trend average wasn't excluding
those days either. Fixed with the identical one-line filter the panel already applies:
`verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)` in `Batterie.tsx` - this one array already
drives the headline, the trend, and the diagram, so a single filter fixes all three. `BatterieListe.tsx`
(the measurement list) is correctly left unfiltered, exactly like the panel's `vBatterieliste()` - it tags
generator readings with a pill instead of hiding them, which is a different, already-correct concept.
**2. (High) Manually-entered trips/refuels could be silently duplicated by a later import.**
`historienimport.py`'s own docstring promises every import-created record is checked for overlap against
*all* existing ones, "egal ob live erkannt, von Hand angelegt oder aus einem früheren Import" - but the
manual-entry paths (`fahrterkennung.manuell_anlegen()`/`aktualisieren()`, `belege.tankvorgang_manuell()`/
`tankvorgang_aktualisieren()`) stored whatever timestamp the frontend sent verbatim via a raw
`datetime.fromisoformat()`, while `historienimport.als_zeit()` normalizes the same kind of input (naive =
local time, per its own comment) to a timezone-aware UTC datetime. `_ueberschneidet()`'s overlap check and
the tank-vorgänge dedup window both explicitly skipped any record with a naive timestamp - so every
manually-created trip or refuel was invisible to a later import's dedup, and could be re-created as a
duplicate. Fixed by moving the normalization into a shared function, `zeit_normalisiert()` in `verlauf.py`
(a leaf module with no local imports, chosen specifically to avoid a circular import between
`fahrterkennung.py`/`belege.py` and `historienimport.py`) - `historienimport.als_zeit` is now an alias for
it. Every write path (`manuell_anlegen`, `aktualisieren`, `tankvorgang_manuell`, `tankvorgang_aktualisieren`)
now normalizes through it before storing. `_ueberschneidet()`/`_tankvorgaenge_importieren()`'s
`bekannte_zeiten` collection were also changed to run existing stored timestamps through the same function
instead of skipping naive ones - this also correctly protects any already-stored legacy record with a naive
timestamp from before this fix, not just new writes.
**3. (Medium) Battery voltage history had no backup coverage.** `sicherung.py`'s `DATEIEN` tuple (used by
"Backup jetzt erstellen") listed `fahrzeugprofil.json`/`fahrten.jsonl`/`tankvorgaenge.jsonl`/
`entitaeten.json` but not `batteriespannung.jsonl` - even though `batterie.py`'s own docstring frames it as
a deliberately multi-year dataset for detecting battery aging. Added it to `DATEIEN`; `_kopieren()` already
skips any file that doesn't exist, so this is zero-risk for an install with no battery history yet.
**4. (Low/Medium) Tank-vorgang import was all-or-nothing per sensor once `TANK_LITER_SENSOR` was mapped.**
`_tankvorgaenge_importieren()` used to read *only* the liter sensor's history once one was mapped, silently
losing any refuel from before the liter sensor's own recorder history started (e.g. it was mapped
mid-project, per section Q) even though the percent sensor's own history covered that period. Rewrote the
function to run both passes when a liter sensor is mapped: the percent-based pass now covers the period
strictly *before* the liter history's first point, and the liter-based pass (more accurate, since it's
already in liters) covers everything from there on - both share the same `bekannte_zeiten`/`neue` state so
the same physical refill can't be created twice if the two histories overlap. Unmapped-liter-sensor
behavior (pure percent-based import) is unchanged.
**5. (Moderate) Panel's Statistik page mislabeled a tank-based estimate as vehicle-reported data.** The
"Vom Fahrzeug gemeldet, je Zeitraum" tile and its caption claimed `t.verbrauch_l_100km` (per-trip fuel
consumption) came from the vehicle's own OBD reporting - it doesn't; section V documented that this field
is entirely a backend-computed approximation from `TANK_LITER_SENSOR` liter deltas, and the trip-detail
page already correctly labels it "Näherung aus Tankfüllstand". Only the Statistik tile and a stale code
comment (`fahrtVerbrauch`'s own header) still claimed OBD provenance. Fixed both to say what the value
actually is - a text-only change, the underlying computation was already correct.
**6. (Low) companion-app's "Art der Fahrten" chart still colored the Arbeitsweg segment red.** The panel
fixed this exact chart on 2026-08-13 (section C, the iOS-overlay red-semantics-inversion finding: red is
reserved for destructive actions, this segment is a neutral data category) but companion-app's
`Statistik.tsx` never received the port - its Arbeitsweg segment stayed `var(--red)` while its sibling
Privat segment already used the neutral `var(--fg3)`. Changed to `var(--fg)`, matching the panel's own
Arbeitsweg color exactly (not `--fg3`, which the panel reserves for the Privat segment).
**Checked and confirmed NOT a bug** (raised by one of the three reviews, verified against current code):
`AGM_RUHE_MAX_V`, `NEBENWERT_MAX_ABSTAND_S=300`, `_letzte_gueltige_spannung`, the `ablage.py` temperature
backfill branch, `historienimport.py`'s `aussentemp` fetch, and `batterie.py`'s freshness check - all
already correct per sections T/W/X's history; the route/back-navigation tables (`ZURUECK` vs. the panel's
dispatcher) - a full cross-check found every route has exactly one matching entry both ways; and the panel's
`ereignisseVerdrahten()` listener wiring - guarded by the existing idempotency lock, no double-registration
risk.
Verified: all five touched backend files (`verlauf.py`, `historienimport.py`, `fahrterkennung.py`,
`belege.py`, `sicherung.py`) `py_compile` clean in `audi_ha_test`; panel `node --check` clean; companion-app
`tsc --noEmit` clean and full suite green at 146/146. Manifest bumped to `2026.8.27.19` (panel text
changed), `npm run ota` rerun (companion-app source changed, `Batterie.tsx`/`Statistik.tsx`), `audi_ha_test`
restarted and confirmed clean via log (`Audi Dashboard 2026.8.27.19 eingerichtet`, no traceback; the only
`ReferenceError: bvRuhePunkte` lines in the post-restart log predate the setup-complete line and come from a
stale browser tab's cached pre-restart script, not a new regression). Committed and pushed the same day
(`90fff7b`), per this file's own binding lesson from section W.
## Z. Battery-voltage fallback didn't survive a restart - now seeded from disk (2026.8.27.20)
Owner report right after the section Y fixes: "batteriespannung is still unbekannt!" - live investigation
(browser session against `audi_ha_test` at `:18123`, confirmed reachable per section W's note) found this
was NOT a regression from section Y: the FMM003 dongle was genuinely reporting `external_power_voltage: 0`
live (confirmed via `hass.states` in the browser console), and `_letzte_gueltige_spannung`
(`veroeffentlichung.py`, the RAM-only "last known good voltage" fallback added in section W) had been wiped
by the container restarts done while verifying section Y's fixes - it had no valid reading to fall back to
since the last restart, so "unbekannt" was the technically-correct result of the existing design.
Owner's response: **"wait. I want to see the last measured voltage!"**, then, pre-empting a staleness
concern before it was raised: **"no lifedate! The dongle will be always offline when car is off"** - i.e.
the RAM-only, resets-on-restart design (deliberately chosen in section W to avoid "resurrecting a possibly
very old value") no longer matches what's wanted: the dongle being offline is the *expected* steady state
whenever the car is parked, so the fallback needs to survive restarts, and its age must never be a reason to
hide it - an old real reading is still better than "unbekannt".
Fixed by seeding the RAM cache from the already-persisted `batteriespannung.jsonl` history once, at startup,
instead of changing the cache to a Store (would duplicate data already saved elsewhere for a different
purpose - the day-level battery history). New `spannung_cache_vorladen(verlauf)` in `veroeffentlichung.py`,
called once from `Koordinator.starten()` (`koordinator.py`) right before the first `alles_veroeffentlichen()`
so even the very first publish after a restart already carries the seeded value instead of a transient
"unbekannt". `fahrzeugstatus()` itself (the per-tick publish function) is untouched - it already reads
`_letzte_gueltige_spannung["wert"]` as its fallback, this just means that value is no longer empty
immediately after a restart.
**First version was wrong - owner caught it: "you got me wrong". "last measured voltage" pulled from the
raw `min`/`max` of the last day entry regardless of value - which on this container's real data meant
15,4 V, the day's *max*, i.e. generator/charging voltage (`batterie.py`'s own docstring documents that
min/max are stored raw, unfiltered by any resting-voltage border - see section N/T history).** A 15,4 V
"Batteriespannung" reading is misleading - that's not the battery's own state. Owner: **"last measured
voltage - but within the agreed borders! confirm me the border!"** - confirmed and agreed: **10,0 V
(`SPANNUNG_MIN_V`, the existing plausibility floor) to 13,0 V (`AGM_RUHE_MAX_V`, the existing
resting-vs-generator ceiling)** - the same two borders already governing the live reading itself
(`fahrzeugstatus()`) and the frontend's diagram/trend filtering (section X), just not previously shared
into the backend as a named constant. Added `AGM_RUHE_MAX_V = 13.0` to `batterie.py` (backend's own copy,
next to `SPANNUNG_MIN_V` - `veroeffentlichung.py` imports both locally inside the function to avoid the
same circular-import risk `SPANNUNG_MIN_V` already routes around). `spannung_cache_vorladen()` rewritten to
scan the ENTIRE stored history (not just the last day), collect every `min`/`max` reading that falls inside
`[SPANNUNG_MIN_V, AGM_RUHE_MAX_V]`, and seed the cache with the chronologically most recent one - so a day
where the car was never at rest (both min and max above 13,0 V) is correctly skipped in favor of the most
recent day that does have a genuine resting-range reading.
Verified: `py_compile` clean on all touched files, manifest bumped `2026.8.27.20` → `2026.8.27.21`,
`audi_ha_test` restarted twice and confirmed clean via log both times (`... eingerichtet`, no traceback) with
the dongle still reporting `0 V` live throughout. First (unfiltered) version showed **15,4 V** live - correct
per that version's own logic, wrong per the actual requirement, caught by the owner before being accepted.
Corrected version confirmed live via the published entity's own `daten.batteriespannung` attribute:
**12,149 V** - the most recent stored reading that actually falls within 10,0-13,0 V (2026-08-27's daily
minimum, `12.149` at `09:44:00Z`), correctly skipping that same day's `15.439` maximum and every one of the
three preceding fully-out-of-border days (`2026-08-23`/`24`/`25`, both min and max pinned at `13.988`, always
excluded).
## AA. Battery measurement list rows looked clickable but weren't (2026.8.27.22)
Owner report: "The line items at messwerte seem to be clickable. But they shall not be clickable" - the
Batterie-Messwerteliste (`vBatterieliste()`/`BatterieListe.tsx`) only supports swipe-to-delete, no per-row
navigation, but the rows visually signaled otherwise.
Root cause, identical shape in both codebases: the shared list-row CSS class (`.leaf` in the panel,
`.dm-listenzeile` in companion-app) is used for two genuinely different kinds of rows - real navigable
buttons (`<button class="leaf" data-go="...">` for trips/refuels/service-book entries) *and* this one plain,
non-navigating `<div class="leaf">`/`<div className="dm-listenzeile">` (battery measurements, swipe-delete
only). The CSS never distinguished the two: `cursor: pointer` (both stylesheets) and, in the panel,
`.leaf:active { background: var(--tile-2) }` (a tap/press highlight) applied to every `.leaf`/
`.dm-listenzeile` element regardless of whether it was a `<button>` or a plain `<div>` - so the battery list
rows got the hand cursor and the press-highlight "something happens on tap" affordance despite having no
click handler at all.
Fixed by scoping both rules to the element type that's actually clickable: `button.leaf`/
`button.dm-listenzeile` instead of the bare class selector, in `audi-dashboard.css` and
`companion-app/src/stile/screens.css`. No markup changes needed in either codebase - `vBatterieliste()` and
`BatterieListe.tsx` already rendered their rows as plain `<div>`s, only the shared CSS was too broad. Every
other `.leaf`/`dm-listenzeile` usage (trips, refuels, service book) is already a real `<button>`, so they
keep the cursor/press-highlight unchanged.
Verified: companion-app `tsc --noEmit` clean, full suite green at 146/146; panel `node --check` clean;
manifest bumped to `2026.8.27.22`, `audi_ha_test` restarted and confirmed clean via log. Confirmed live via
the panel's own shadow DOM (`getComputedStyle`, navigating through the real `data-go` click chain, not just
reading source): the battery list's `<div class="leaf">` now computes `cursor: auto` (was `pointer`), while
a genuinely navigable trip row (`<button class="leaf">`) still correctly computes `cursor: pointer` -
confirming the fix is properly scoped, not a blanket removal.
## AB. First round of a large owner batch: Reifen/Version/Wartungsplan fixes (2026.8.28.1)
Owner sent 12 items in one message. Worked through the quick, well-specified ones first; the two larger
ones (combined export/import popup, Setup-menu sensor field redesign) are being scoped with the owner before
implementation - see the open items this creates below.
- **Archiv-Knopf nicht in Zeile mit "Montiert"** (Reifen-Seite, panel only - no companion-app equivalent):
root cause was `.montiert`'s own `margin-top:var(--sp-4)` (added 2026-08-16 round eight, when the pill sat
alone under the headline block) - once the archive icon button joined it on the same flex row (section R),
that leftover margin threw off `align-items:center`'s vertical centering between the two children, even
though both were genuinely siblings in the same row. Moved the spacing to the wrapping row div instead of
one child. Verified live: both centers compute to the identical `584.08px` Y-coordinate.
- **Archiv-Icon soll ein Knopf mit Hintergrund sein**: new `.archivbtn` class (panel), permanent
`background: var(--tile-2)` instead of the generic `.iconbtn`'s hover/active-only background.
- **companion-app: Wechseltermin-Datumsfeld sah aus wie ein gestreckter Kreis statt einer Box**: root cause
is the standard cross-browser gap - `input[type="date"]` needs an explicit `appearance:none` reset or
Safari/WKWebView (this app's iOS shell) keeps its own heavily-rounded native chrome regardless of author
CSS on `background`/`border`/`border-radius`. Neither `.dm-eingabe` nor the shared `@audi-dash/ui` `Feld`
component reset it. Fixed locally in `companion-app/src/stile/screens.css` (not in the shared design-system
package - no other field type shows this, scoped to `input[type="date"].dm-eingabe` only) with
`-webkit-appearance:none`/`appearance:none` plus `width:fit-content` (owner's explicit ask: shrink to the
actual date text instead of the shared `Feld`'s `min-width:132px`). **Not live-verified** - no running
companion-app session with backend auth this round; verified via `tsc --noEmit` and the full suite only,
same limitation as several other companion-app-only changes this session.
- **Mein Audi/Reifen-Box restructured**: was `Reifen · Sommer montiert` as the tile header, then
`<dt>Continental SportContact</dt><dd>275/30 R20 97Y XL</dd>` as the first row. Header simplified to just
"Reifen"; the row's left side now reads `montiert: Sommerräder` (owner's exact wording, `${r.aktiv}räder`)
instead of the brand/model, keeping the size (`275/30 R20 97Y XL`) as the right-hand value, per the owner's
explicit "montiert... shall be on the left side". companion-app's equivalent "Mein Audi" tile
(`MeinAudi.tsx`) is structurally a single-line `NaviKachel` (`${aktiv} · ${km} km`), not the panel's
multi-row `.rows` list - it never had "Sommer montiert" or brand/model text to begin with, so nothing to
port here (documented structural difference, not a skipped fix).
- **"offen" caption removed from the Wechseltermin row**: `vereinbart ? "vereinbart" : "offen"` → `... : ""`
- owner: "the '-' is sufficient". Same structural note as above - companion-app's tile has no equivalent
caption to change.
- **"Servicebuch" renamed to "Wartungsplan" everywhere it's user-visible**, both codebases - tile/page
headings, empty-state text, confirm-delete dialogs, CSV export button label, the "Mein Audi" Service
NaviKachel's summary text. Deliberately NOT renamed: the `ServicebuchEintrag` TS type, the `Servicebuch()`
component/function names (panel and companion-app), the `sbuch` route key, and every code comment - same
precedent as the 2026-08-19 "Zusammen"→"Summe" rename (display-label fix, not a data-model rename).
- **"Version" tile cleaned up**: removed the "Fahrzeugdaten"/"Position" rows (both hardcoded to the literal
string "FMM003", carried no live information) and the "Dashboard" row, whose value came from `CONFIG.version`
- a frontend constant hardcoded to `"1.0"` at `profilZuConfig()` (confirmed via grep: no other reader),
never updated since. Removed that dead constant along with its row rather than leaving an orphan. Only
"Installiert" (the real `APP_VERSION` from the backend) remains.
Verified: `node --check` clean on the panel, `tsc --noEmit` clean and full suite green (146/146) on
companion-app. Manifest bumped to `2026.8.28.1` (new day, per this project's `YYYY.M.D.N` scheme - the prior
session's work had reached `.27.22`). `audi_ha_test` restarted, confirmed clean via log
(`Audi Dashboard 2026.8.28.1 eingerichtet`). Live-verified via the panel's own shadow DOM: Reifen tile shows
`montiert: Sommerräder | 275/30 R20 97Y XL` and `15.10.2026 | `; Version tile shows only "Installiert"; the
Archiv button and "Montiert" share one Y-center exactly, with a visible background disc behind the icon
(screenshot taken).
**Open, not yet started - scoped with the owner before implementation:**
- **Item 8 (informational, answered in chat, not yet acted on)**: both the live path
(`tankerkennung.py`→`ablage.distanz_seit_letzter_tankung()`) and the import path
(`historienimport.py`'s `_distanz_zum_vorherigen()`) compute "distance since last refuel" as an odometer
delta (current/refuel `KM_SENSOR` reading minus the previous refuel's own recorded `odometer_km`) - a
derived calculation, not read from any vehicle sensor. Owner pointed out a real FMM003 sensor,
`sensor.testzone_fmm003_calculated_distance_covered_after_the_latest_refueling` (device-side, presumably
more accurate/authoritative than our own subtraction), that reports this directly and "can be used instead
of the calculation" - **done, see section AC below** (`TANK_DISTANZ_SENSOR`).
- **Item 9: done, both codebases** - panel in section AC, companion-app port in section AD.
- **Item 11: done, panel only (no companion-app equivalent, see section AF)** - Setup-Menü sensor role
fields now show the mapped sensor's ID suffix and live value next to the headline instead of a static
description.
## AC. Item 8 built (TANK_DISTANZ_SENSOR); Item 9 built on the panel: "Datensatz sichern/laden" (2026.8.28.3)
**Item 8.** Owner: "you can use the sensor instead of our current logic. I guess it also helps for fuel
refill detection the best way." New optional `TANK_DISTANZ_SENSOR` (`einstellungen.py`, `FELDER` catalog,
group `fahrterkennung`) - the FMM003's own `calculated_distance_covered_after_the_latest_refueling`. New
shared helper `tankerkennung.distanz_seit_tankung(k, odometer_km)`: reads the sensor if mapped, else falls
back to the existing `ablage.distanz_seit_letzter_tankung()` - used at all three places a refuel's
`distance_km` gets set (`tankerkennung._automatisch_anlegen()`, `belege.tankvorgang_manuell()`'s
auto-suggestion, and `historienimport._tankvorgaenge_importieren()`, the last via `wert_bei()` on the
sensor's own fetched history). **The "helps with refill detection" half of the idea was investigated, not
built**: tried to read the sensor's recorder history in the test container to see whether it resets
synchronously with a real refuel (which would make it useful as a detection signal in its own right, not
just a value source) - the frontend's `history/period` REST call came back empty because it defaults to
significant-changes-only, unlike this project's own `verlauf.py` (`significant_changes_only=False`,
deliberately, for exactly this reason) - inconclusive, not investigated further. Confirmed live: entity
`sensor.testcar_b9_fmm003_testintegratoin_calculated_distance_covered_after_the_latest_refueling` exists in
`audi_ha_test` (reads `407` km); `py_compile` clean on all four touched backend files.
**Item 9, panel only - companion-app not yet ported (parity rule, flagged, not silently skipped).** Merged
the "Fahrzeugprofil" tile and "Daten ausgeben" tile into one "Fahrzeugprofil" tile with two buttons -
"Datensatz sichern" (a popup with 4 instant, clientside export actions: Fahrzeugprofil as JSON,
Fahrten/Tankvorgänge/Wartungsplan as CSV - unchanged logic, just moved into the popup) and "Datensatz laden"
(the same 4 as file pickers). The separate "Backup" tile (combined profil+fahrten+tank JSON bundle plus the
scheduled-backup mechanism) was never named by the owner and is untouched.
**The real new work is CSV import**, which didn't exist for any dataset before. Design question resolved via
`AskUserQuestion` before writing any code: re-importing an edited CSV **matches by ID and updates only the
CSV's own columns**, never a full-dataset replace - a full replace would have silently deleted every field
not in the CSV's few display columns (GPS, route, `edited_fields`, receipt links, ...) on every existing
record, even ones the owner never touched. New `csv_import.py` (own module, own moduledocstring with the
full reasoning): a `_de_zahl()` parser (`"1.234,5"` -> `1234.5`), a `_zeilen()` CSV reader (`;`-delimited,
BOM-tolerant), and one importer function per dataset, dispatched via `importieren(k, art, inhalt)`. New
service `csv_importieren(art, inhalt)` (`dienste.py`/`const.py`/`services.yaml`) - **synchronous**, not
backgrounded like `historie_importieren` (a CSV file is small, the whole point is that the frontend gets an
immediate, precise result). Result published through the existing `sensor.audi_dashboard_import_status`
entity (`k.import_status_veroeffentlichen("csv_fertig", {...})`) rather than inventing a second status
entity for what is, in HIG terms, the same kind of event.
**Fahrten/Tankvorgänge have a real ID already (`trip_id`/`tank_id`) - now exported as a new first CSV
column.** Matched rows go through the existing, already-safe `fahrterkennung.aktualisieren()` (its
`_handfelder()` already drops `None` values, an untouched field was never at risk there) and
`belege.tankvorgang_aktualisieren()`. **The second one was not already safe** - found while designing this:
it unconditionally overwrote every one of its ~9 fields with `daten.get(x)`, `None` included, whenever any
one of them changed (a gap flagged but deliberately left alone during the earlier bug audit in this same
session, since nothing had needed the fix yet). CSV import needed it fixed for real, so it now only touches
keys actually present in `daten` (`"key" in daten`, not `daten.get(key)`) - verified this doesn't change the
existing manual-edit-form caller's behavior, since that caller already sends every key on every save
regardless of whether the value is empty.
**Wartungsplan has no ID at all** - entries live as a plain list inside the vehicle profile
(`profil["service"]["buch"]`), addressed elsewhere in the app only by list index (`vSbuch()`/`Servicebuch()`
in both frontends already work this way). Retrofitting a real ID was judged bigger than this feature
warranted (touches delete/edit routing in both codebases). CSV import for this dataset instead matches on
**Datum+Art** as a natural key - documented in `csv_import.py`'s own docstring as a known, accepted
limitation: editing either of those two columns for an existing row makes it unmatchable and creates a
duplicate instead of updating.
Verified end-to-end, live, in the browser against `audi_ha_test` (not just compiled/typechecked - this
file's own section S lesson) - created and then updated one record of each of the three CSV-backed types via
synthetic `File`/`DataTransfer` dispatch through the real popup/file-input flow, confirmed via the actual
published entity data each time, then deleted the test records afterward:
- Fahrten: created (`1 neu angelegt`), then updated the same `trip_id` (`1 aktualisiert`, `distance_km`
22→60) while `odo_end`/`start_lat`/`route`/`source` all stayed byte-identical - the core guarantee, proven,
not assumed.
- Tankvorgänge: created with de-DE-formatted `"45,00"`/`"1,75"`/`"78,75"` (parsed correctly to `45`/`1.75`/
`78.75`), then updated `station_name` by `tank_id` while `odometer_km` stayed untouched - the exact case
`belege.tankvorgang_aktualisieren()`'s fix was for.
- Wartungsplan: created, then updated by Datum+Art match (`km`/`Werkstatt`/`Kosten` changed, `buchLength`
stayed 1 - no duplicate).
**One real bug found and fixed during this live testing, not by code review**: the first version read the
result via `HASS.states[E.importStatus]` immediately inside the service call's `.then()` - a race, since a
`state_changed` push arrives over its own websocket message and isn't guaranteed to have landed in the
frontend's local state cache by the time the service-call promise resolves. Reproduced live: the popup said
"Keine gültigen Zeilen gefunden" even though the backend had genuinely created the record (confirmed via the
entity's own fresh state). Fixed by switching to the existing `importStatusLesen()` helper (already used by
`importAusloesen()` for `historie_importieren`, for the identical reason) - it fetches fresh via
`HASS.callWS({type:"get_states"})`, bypassing the local cache race entirely. Re-tested after the fix:
correct result every time.
Verified: `node --check` clean on the panel; companion-app untouched, `tsc --noEmit` still clean. Manifest
bumped `2026.8.28.1` → `.2` (initial popup) → `.3` (the race-condition fix - a second restart was needed
specifically to see this fix, since the panel JS is cache-busted by manifest version, not by content hash).
`audi_ha_test` restarted at each step, confirmed clean via log every time.
## AD. Item 9 ported to companion-app (2026.8.28)
companion-app's starting layout differed from the panel's: no standalone "Fahrzeugprofil" tile existed at
all (its "Sicherung" tile already bundled profile export together with the server-side backup trigger -
`profilExportieren()` there actually downloads the combined `{profil, fahrten, tankvorgaenge}` bundle, the
companion-app equivalent of the panel's separate, untouched "Backup" tile, not of "Fahrzeugprofil" - so
"Sicherung" was correctly left alone). "Daten ausgeben" only exported Fahrten/Tankvorgänge (no Wartungsplan,
no CSV import at all, no ID columns). Ported by turning "Daten ausgeben" into the same "Fahrzeugprofil" tile
+ "Datensatz sichern"/"laden" popup the panel now has, reaching the same end state as the panel through a
different starting layout - exactly what the parity rule asks for.
**The CSV column names had to be made byte-identical to the panel's** (`"ID"`, `"Start"`, `"Ende"`, `"km"`,
etc.), not merely similar - the backend CSV parser (`csv_import.py`) is shared by both frontends and looks
columns up by exact name. companion-app's own prior CSV export used different header text entirely
("Beginn"/"Strecke km"/"Dauer s") - harmless before, since nothing ever read it back, but would have broken
cross-frontend round-tripping (export from the panel, import into companion-app, or vice versa) had it been
left as-is under a shared backend parser.
**Found a second real, pre-existing bug while doing this**: companion-app's `ServicebuchEintrag` type
(`Service.tsx`) declares `betrieb`/`notiz`, but the actual backend list the panel writes to
(`profil["service"]["buch"]`) uses `werkstatt`/`kosten`/`arbeiten` - no translation layer exists anywhere
(`profilAdapter.ts` passes `service` through as an untyped `Record<string, unknown>`), so a Wartungsplan
entry created in one codebase genuinely doesn't display correctly in the other (shows "Betrieb: —", no
cost). Out of scope for this feature to fix properly - `DatensatzPopup.tsx`'s Wartungsplan CSV export reads
the real field names via a documented `as Record<string, unknown>` cast (with an inline comment explaining
why) rather than trusting the local, apparently-wrong type. Flagged as a separate background task
(`task_7105e44c`) for a dedicated fix - decide the canonical field names (almost certainly the panel's, the
older and more established convention), fix `ServicebuchEintrag` and `Service.tsx`'s read/write code, and
simplify the CSV export's workaround cast once the type itself is correct.
New `src/screens/DatensatzPopup.tsx` (own file, mirrors `HistorienImport.tsx`'s shape/idiom: `Popup
variant="form"`, own open/close/error/result state, an `aktiv` ref guarding against a state update after
unmount). New `api.csvImportieren(art, inhalt)` (`api/index.ts`) - not queued, same reasoning as
`historieImportieren()` (a deliberate one-off action, not a form field that should tolerate being offline).
Fahrzeugprofil import reuses the existing `api.profilSchreiben(profil)` directly (NOT the `DatenKontext`
convenience `profilSpeichern()`, which only merges partial `einstellungen`/`fahrzeug` fields - a loaded file
needs to replace the whole profile, matching the panel's `profil_schreiben` semantics exactly), followed by
`neuLaden()` to refresh derived state. `importStatusLesen()`/`zustandLesen()` fetch over REST, not a locally
cached reactive object - so companion-app was never exposed to the race-condition class of bug the panel
needed a dedicated fix for (see section AC) - confirmed structurally, not just assumed.
Also removed: a dead `dateiwahl` file-input ref in `Einstellungen.tsx` (declared, rendered, never wired to
an `onChange` or `.click()` anywhere) - pre-existing, unrelated scaffolding, but sitting directly next to
the new, functionally-equivalent file-input mechanism this change adds made it actively confusing to leave,
so removed rather than merely noted.
Verified: `tsc --noEmit` clean, full suite green at 146/146 (including the "every page renders with sample
data" smoke test, which covers `Einstellungen.tsx`), `vite build` succeeds. **Not live-verified** - no
running companion-app session with backend auth this round, the same structural limitation noted repeatedly
elsewhere in this file for companion-app-only changes.
## AE. Wartungsplan schema mismatch between panel and companion-app fixed (2026-08-28)
The pre-existing schema mismatch flagged in section AD above (companion-app's `ServicebuchEintrag` naming
`betrieb`/`notiz` while the actual stored data — `profil["service"]["buch"]`, written by both the panel and
the backend — uses `werkstatt`/`kosten`/`arbeiten`, with no translation layer anywhere) is now fixed.
Decided `werkstatt`/`kosten`/`arbeiten` as canonical, per section AD's own reasoning: the panel's naming is
the older, already-established convention, and real installations' stored data already uses it.
`companion-app/src/screens/Service.tsx`: `ServicebuchEintrag` renamed to `werkstatt?: string` /
`kosten?: number | null` / `arbeiten?: string`. The add/edit form's "Betrieb"/"Notiz" fields became
"Werkstatt" / a new "Kosten" (€) field / "Durchgeführte Arbeiten" (matching the panel's own three-field
layout and its "Durchgeführte Arbeiten" label exactly, not just the field name) — companion-app previously
had no way to record cost on a Wartungsplan entry at all, this closes that gap too. The read-only entry
display gained matching `Wertzeile`s (`Werkstatt`, `Kosten` — "noch offen" when unset, matching the panel's
placeholder wording — `Durchgeführte Arbeiten`), replacing the old `Betrieb`/`Notiz` rows.
`DatensatzPopup.tsx`'s Wartungsplan CSV export (built the same day as the bug was found, section AC/AD) had
worked around the mismatch with an `as Record<string, unknown>` cast and an explanatory comment reading the
real field names directly; both are now redundant and removed — it reads `e.werkstatt`/`e.kosten` through
the corrected type like any other field.
`src/tests/beispieldaten.ts`'s two Wartungsplan fixture entries updated from `betrieb` to `werkstatt`
(no `kosten`/`arbeiten` needed adding — the existing tests don't assert on them). No dedicated
`Service.test.ts` exists; grepped the whole `src/` tree for other `betrieb`/`notiz` consumers before and
after — `Einstellungen.tsx`/`Uebersicht.tsx` only pass the raw `buch` array through as `ServicebuchEintrag[]`
without touching individual field names, so neither needed a change.
Verified: `tsc --noEmit` clean, full suite green at 146/146 (no new tests needed — a schema/naming
alignment, not new behavior), `vite build` succeeds. **Not live-verified against a real backend** — same
structural limitation as section AD immediately above (no running companion-app session with backend auth
this round). The underlying stored data was never wrong (the panel and backend always used
`werkstatt`/`kosten`/`arbeiten`); this was a companion-app-only frontend read/write bug, so no data migration
is needed — an existing Wartungsplan entry will display correctly the next time companion-app is rebuilt and
deployed with this fix.
## AF. Item 11: Setup-Menü zeigt jetzt den zugeordneten Sensor statt einer statischen Beschreibung (2026.8.28.4/.5)
Panel only - the Setup menu (sensor-role mapping) is an HA-panel-only concept, no companion-app equivalent
exists to port this to (companion-app consumes the backend's already-resolved state, it has no Setup UI at
all - see the parity rule's own carve-out for this exact case).
Every field row in Setup used to show a static, developer-written description under the label ("on = Fahrt
läuft, erkennt Fahrtbeginn/-ende.", "Optional, vom CAN..."). Owner: replace it with the *actual*
currently-mapped sensor's technical ID (just the part after the installation-specific noise) shown next to
the headline, plus a separate indicator of the KIND of value each field expects.
**First version (`.4`) got two things wrong, both caught live by the owner before being accepted:**
1. Hardcoded searching for the literal substring `"fmm003_"` and keeping everything after it - the owner
pointed out this still leaves *other* installation-specific noise in the result (`"testintegratoin_..."` -
the flespi channel's own instance name, not part of the sensor's real, stable identity; a different
installation would have a different instance name here, making a hardcoded strip target wrong for anyone
but this one test container).
2. Combined the ID-suffix with the sensor's *current live reading* into one string next to the headline. The
owner: show only the expected sensor identity there - and separately, "next to the Sensor Headline",
show the *kind* of value the field expects (`Tankfüllstand [%]`, `Tankvolumen [l]`, `12V-Batteriespannung
[V]`), not a live reading.
**Fixed in `.5`:**
- `entitaetIdKurz(entityId)` no longer searches for a hardcoded string. New `setupGemeinsamesPraefix()`
computes the longest character-for-character common prefix (case-insensitive, trimmed back to the last
shared `_`) across every entity ID currently assigned across the *entire* Setup catalog (domain-stripped),
requiring at least two mapped entities to compare. In this real installation that correctly resolves to
`"testcar_b9_fmm003_testintegratoin_"` - device slug, integration name, and instance name all stripped in
one step, none of it hardcoded. `entitaetIdKurz()` strips that computed prefix only when a given entity's
ID actually starts with it; otherwise it shows the full domain-stripped ID rather than an incorrect partial
strip. **This fallback path fired for real, unprompted, during live verification**: `TANK_DISTANZ_SENSOR`
(added the same day, section AC) had been auto-suggested to `sensor.audi_rs_4_avant_monthly_mileage` - a
genuinely different source (not FMM003) sharing no prefix with the rest - and correctly rendered the full,
unstripped ID instead of a wrong truncation. Confirms the graceful-degradation design works, not just the
common case.
- The live-value read (`entitaetWertKurz()`/`setupErwarteterSensor()`) is gone entirely - removed, not just
unused. In its place, new `feldEinheitAnzeige(feld)` derives a **static** "kind of value" label straight
from the field's own `FELDER` catalog entry (`einstellungen.py`) - `"on/off"` for every `binary_sensor`
role (HA's binary_sensor domain always reports the raw state as on/off regardless of `device_class` - that
only changes how *other* HA frontends translate it for display, not the raw value this app reads), the
first unit in `units` preferring a lowercase-distinct variant when more than one is listed (picks `"l"`
over `"L"` for `TANK_LITER_SENSOR`, `"km"` over `"mi"` elsewhere), `"Datum"` for `date`/`timestamp`
`device_classes`, nothing for a `button` role or a field with neither (GPS coordinates). This label is
appended to the field's own headline text (`"Tankfüllstand [%]"`) and is independent of whether anything is
currently mapped - unlike the ID-suffix hint, which needs a live mapping to have anything to show.
`feld.hinweis` itself is untouched in the backend `FELDER` catalog - still valid data, simply not read by
this UI anymore; not removed, since nothing else depends on knowing it's unused and removing it wasn't asked.
CSS: `.setup-feld-kopf` changed from a column (label above the old description) to a row (space-between) so
the ID-suffix hint sits beside the label; new `.setup-feld-einheit` (a slightly dimmer `--fg3`) for the
bracketed unit label appended to the headline text itself.
Verified live in `audi_ha_test` both times (not just read from source - this file's own section S lesson).
First pass (`.4`): confirmed the row layout change itself (label and hint measured on the identical
Y-coordinate) before the owner's correction arrived. Second pass (`.5`), after the fix, read eight real Setup
rows via the shadow DOM in one pass and matched every one against the owner's own examples exactly:
`"Zündung/ACC-Status [on/off]"` / `"testcar_b9_fmm003_testintegratoin_engine_ignition_or_acc_status"`,
`"Tankfüllstand [%]"`, `"Tankvolumen (Liter) [l]"`, `"12V-Batteriespannung [V]"`, `"Außentemperatur [°C]"` -
plus the `TANK_DISTANZ_SENSOR` fallback case above and a screenshot confirming the visual layout. `node
--check` clean at each step, manifest bumped `2026.8.28.4` → `.5`, `audi_ha_test` restarted and confirmed
clean via log both times.
**`.5`'s own verification above was itself misleading - the owner caught the real bug immediately after
("I still see testcar_b9_fmm003_testintegratoin_ and audi_rs_4_avant_").** `.5`'s `setupGemeinsamesPraefix()`
computed ONE global prefix by intersecting every mapped sensor's ID down to their shared characters - and a
single outlier (any one field mapped to a sensor from a different source than the rest) collapses that
intersection to nothing, so EVERY field falls back to its full, unstripped ID. The live verification quoted
above only exercised four fields and happened not to include the one that broke it - a real gap in how
thoroughly that pass was checked, not a fluke in the code.
Fixed properly in `.6`: replaced the single global intersection with pairwise voting (every sensor pair's own
common prefix, the most frequently occurring one wins) - more robust against one outlier, but **still wrong
for a different reason**, found by the owner's report and confirmed by pulling the actual live
`sensor.audi_dashboard_entitaeten` data (not by re-reading the source - the only way this surfaced): this
installation genuinely has **two comparably-sized sensor sources at once** - 9 FMM003-sourced roles
(ignition, odometer, tank, battery, GPS) and 18 roles from a second integration, prefixed
`audi_rs_4_avant_` (all four doors, all four windows, tailgate/hood/lock/sunroof/light, all four service-date
fields). A single global "winner" prefix, however it's computed, can only ever strip ONE of the two families
correctly and leaves the other's IDs fully unstripped - exactly what both `.5` and `.6` did, just for
different roles each time.
**`.7` drops the idea of a single global prefix entirely.** `entitaetIdKurz()` now finds, for the entity
being displayed, its own single closest relative among every other currently-mapped sensor (the one it
shares the longest prefix with) and strips only that pairwise prefix - computed independently per field, so
it doesn't matter how many different sensor sources are mixed together or how large each cluster is. Verified
by pulling the *actual* stored `werte` mapping from `sensor.audi_dashboard_entitaeten` (not fabricated test
IDs like the `.5`/`.6` passes used) and running the real algorithm against it before deploying - this is what
caught the `.6` design flaw *before* declaring it fixed, rather than after another owner report. A pleasant
side effect: `NAECHSTER_OELWECHSEL_SENSOR`/`NAECHSTE_INSPEKTION_SENSOR` (`oil_change_due`/`inspection_due`)
and their `_STRECKE_SENSOR` counterparts (`oil_change_distance`/`inspection_distance`) now pair off with each
other specifically and reduce to `"due"`/`"distance"` - each field lands next to its true sibling, not just
"some" shared prefix.
**Follow-up (`.9`), two more owner-reported items on the same session:**
1. **"Nur passende Sensoren anzeigen" only hard-filtered by domain/unit, not by the "Datum" output shown
next to a field's headline** (`feldEinheitAnzeige()`). Fields with `device_classes: ["date",
"timestamp"]` and no `units` (the two Ölwechsel/Inspektion date roles) fell through both the domain
check (only restricts to `sensor` domain, which is nearly every entity) and the unit check (empty
`units` array skips it entirely) - so with the filter ON, every `sensor.*` entity in the whole instance
was offered, not just date/timestamp ones. `entitaetKandidaten()` now mirrors `feldEinheitAnzeige()`'s
own decision exactly: when a field has no `units` but its `device_classes` includes `date`/`timestamp`,
a candidate must itself have `device_class` `date` or `timestamp` to pass the hard filter. Verified live
by reading the actual rendered candidate list for "Nächster Ölwechsel (Datum)" via the shadow DOM and
cross-checking each of the 14 results' real `device_class` against `hass.states` - all 14 were genuine
`date`/`timestamp` sensors, zero false positives.
2. **`Neuer Tankvorgang` (`tankFelder()`): "Kilometerstand" and "Getankte Liter" were the only two fields
in the form without the `.mitEinheit` wrapper** every other km/€/l field in the app already uses (see
e.g. the Wartungsplan "Kilometerstand"/"Kosten" fields, `vSbuch()`) - so they rendered at the browser's
default, much wider intrinsic input size instead of the app's usual fixed compact width, and without a
unit suffix. Wrapped both in `.mitEinheit` (`km`/`l`), matching the existing convention instead of
introducing new CSS.
3. **Real bug: "Gefahrene Distanz" showed `-98` when opening a new Tankvorgang.** `distanzVorschlag()`
subtracts the last recorded fill's `odometer_km` from the live odometer with no floor - live-inspected
the actual data in `audi_ha_test` and found the most recent stored fill (`f-c7b0e818451e`, `source:
"auto"`, 2026-08-27T17:37) carries `odometer_km: 21400`, while the vehicle's current live odometer
(`sensor.audi_dashboard_fahrzeugstatus`'s `km`) reads `21302` - lower than that fill's own recorded
basis, so the subtraction went negative (`21302 - 21400 = -98`, exactly the reported number). Left the
underlying record itself untouched - unclear whether it's a genuine real-vehicle detection or leftover
test-data noise, and either way it's the owner's own trip/tank history, not something to delete
unilaterally. Fixed the display symptom instead: `distanzVorschlag()` now returns `""` (no suggestion)
rather than a negative number - a negative "distance driven" is never meaningful regardless of why the
underlying data disagrees. **Worth the owner's own look**: that stray fill's `odometer_km` (21400)
exceeding the current live reading (21302) suggests either a bad detection or a since-corrected odometer
somewhere in this test container's history - if it recurs on the real instance, worth checking there too.
4. Also handled the owner's report "there is still the Text with the round collums in the Sensor Page"
(referring to the `.7`/`.8` ellipsis fix) - re-verified directly against the live served CSS and by
scanning every text node and placeholder in the rendered Setup popup's DOM: the ellipsis truncation is
genuinely gone server-side, nothing found. Most likely a stale cached browser tab on the owner's side
(this project has hit that exact class of issue repeatedly - see section N) rather than a remaining code
defect; flagged to the owner rather than guessed around further.
Verified: `node --check` clean, manifest bumped to `2026.8.28.9`, `audi_ha_test` restarted and confirmed
clean via log; all three fixes re-verified live in the browser (Tanken form screenshot showing `0` instead
of `-98` and the shrunk `Kilometerstand`/`Getankte Liter` fields; Setup candidate-list device_class check
above).
**`.10`: the owner's "round collum" report was about something else entirely** - not the ellipsis (already
fixed and confirmed gone at `.9`), but the parenthetical suffixes on seven Setup field labels
("Nächster Ölwechsel **(Datum)**", "Tankvolumen **(Liter)**", "Türschloss **(Fahrertür)**", ...) - "remove
alle words in round collum for Sensors". Stripped the `(...)` suffix from all seven `FELDER` labels in
`einstellungen.py` (`TANK_LITER_SENSOR`, `REFRESH_BUTTON`, `TUERSCHLOSS_SENSOR`,
`NAECHSTER_OELWECHSEL_SENSOR`/`OELWECHSEL_STRECKE_SENSOR`, `NAECHSTE_INSPEKTION_SENSOR`/
`INSPEKTION_STRECKE_SENSOR`). Safe to do without losing the Datum-vs-Restkilometer distinction the
parenthetical used to carry: the `.7`/`.8` work earlier this same round already added a `[Datum]`/`[km]`
unit label next to every headline (`feldEinheitAnzeige()`) - that bracket now carries the disambiguation
the round-bracket suffix used to, so "Nächster Ölwechsel" now shows as two rows differing only by
`[Datum]` vs `[km]`, same as every other field pair in the catalog.
**Also `.10`: "Make the Popup wider because of the long Name"** - the owner had manually mapped
`TANK_DISTANZ_SENSOR` to `calculated_distance_covered_after_the_latest_refueling` (55 characters), which
the `.setup-popup`'s existing desktop cap (`max-width:560px`, from the 2026-08-13 design audit) had too
little room for even after the `.8` ellipsis fix let it wrap. Widened `.setup-popup` specifically to
`max-width:760px` in the `@container (min-width:860px)` block (`audi-dashboard-ios.css`) - split out of
the shared `.sdpopup,.sheet,.beleg-popup,.setup-popup` selector so only Setup (the one popup that shows
raw, potentially long sensor identifiers) gets the wider cap; the others are unaffected. Verified live at
a real 1400px viewport (not the browser tool's `desktop` preset, which stays narrow - same lesson noted
in section N): `getBoundingClientRect()`/`getClientRects()` on the actual rendered hint span confirmed
the popup measures 760px and the full 55-character name now renders on a single, unclipped line
(`scrollWidth === clientWidth`, `lineCount: 1`) instead of wrapping or overflowing.
Verified: `py_compile` clean on `einstellungen.py` (inside `audi_ha_test`, `MSYS_NO_PATHCONV=1` needed for
`docker cp`/`docker exec` on this machine's Git Bash - plain paths were silently mis-translated to a
Windows path once, worth remembering for future sessions), manifest bumped to `2026.8.28.10`,
`audi_ha_test` restarted and confirmed clean via log, both fixes confirmed live in the browser as above.
**`.11`: "Gefahrene Distanz" in `Neuer Tankvorgang` prefers the live `TANK_DISTANZ_SENSOR` reading.**
Owner report: after the `.9` negative-distance clamp, the field showed a plain "0" placeholder and the
owner expected "the current sensor output" there instead, plus asked to confirm the AUTO-DETECTED refuel
path already uses `TANK_DISTANZ_SENSOR`. Confirmed the second part first by reading `tankerkennung.py`:
`distanz_seit_tankung()` (called from `_automatisch_anlegen()`) already prefers the sensor and only falls
back to the odometer-subtraction when unmapped - built in section Q, unchanged. The frontend's manual-entry
prefill never had that preference though - `distanzVorschlag()` only ever did the FILLS-based subtraction.
Split it: the old body is now `distanzVorschlagBerechnet()` (pure fallback, same negative-clamp from `.9`),
and `distanzVorschlag()` itself now checks `ENTITAETEN.werte.TANK_DISTANZ_SENSOR` first and returns its
live numeric state if mapped and valid, falling back to `distanzVorschlagBerechnet()` otherwise - the exact
same "sensor preferred, own calculation as fallback" order as the backend, just mirrored in JS for the
still-unsaved form. Verified live: the container's real mapped sensor
(`sensor.testcar_b9_fmm003_testintegratoin_calculated_distance_covered_after_the_latest_refueling`) reads
`407` - opening `Neuer Tankvorgang` now shows `407` (real value, not placeholder-grey) instead of the
`.9`-era empty/`"0"`-placeholder state. companion-app has no "Gefahrene Distanz" field in its own Tanken
form at all (structurally never had one - the backend fills it in itself when omitted), so nothing to port
there, per the parity rule's carve-out for structural differences.
**`.12`: "offen"/"offen" and "51,59 l"/"85,59 €" were touching, not just close** - the Übersicht's
"Zuletzt" tile (`teaser()`) stacks a value and a `<small>` caption via `.leaf .v{display:flex;
flex-direction:column}` - the sibling `.leaf .k` column already had `gap:3px` for the same two-line
pattern, but `.leaf .v` never got it, so its two lines had zero gap and visually touched. Added the
matching `gap:3px`. Panel-only bug (companion-app's equivalent "Letzte Fahrt"/"Letzter Tankvorgang" tiles
use the shared `@audi-dash/ui` `NaviKachel` grid component, a structurally different layout with no
equivalent gap-less two-line stack) - confirmed via source, no companion-app change needed. Verified live:
`getComputedStyle` on the real rendered `.leaf .v` reports `gap: 3px`, and the screenshot shows clear
separation between both stacked value pairs.
Verified across `.11`/`.12`: `node --check` clean each time, manifest bumped `2026.8.28.10` → `.11` → `.12`,
`audi_ha_test` restarted and confirmed clean via log at each step.
**`.13`/`.14`: the `.12` gap fix wasn't the whole story - the owner sent a screenshot with the two value
stacks bracketed at visibly different X positions.** Measured live rather than guessed: after `.12`,
`.teaser .leaf .v` (added a `.teaser` class on the tile in `.13`, scoped `min-width:72px` to it) DID make
both `.v` boxes the same 72px width, but their absolute `left`/`right` still differed by 56px
(`getBoundingClientRect()` on both: row 1 at x=570-643, row 2 at x=627-699). Root cause: `.leaf` itself
lays out its three children (`.k`/`.v`/chevron) via `justify-content:space-between`, and `.leaf .k` (the
label) has no `flex-grow` - a longer label ("Hermann Mogler Mineralölg. GmbH" vs. the trip row's bare "→")
leaves less leftover space, and `space-between` distributes that shrunk leftover differently, shifting the
*entire* rest of the row including the now-equal-width `.v` block. Fixed by adding `.teaser .leaf .k{flex:1
1 auto;min-width:0}` alongside the `.13` width fix - `.k` now fills all remaining space itself, so `.v`
sits at a truly fixed offset from the right edge regardless of label length. Scoped to `.teaser` only
(same reasoning as the `.13` width fix - `.leaf` is reused by many other list rows across the app where
per-row value width is normal/desired, e.g. trip and refuel lists; forcing this there could look wrong).
Verified live: `getBoundingClientRect()` on both real rendered `.v` blocks now reports byte-identical
`left`/`right` (750/822 in both rows) - not just equal widths as after `.13` alone.
Verified: `node --check` clean at both steps, manifest bumped `2026.8.28.12` → `.13` → `.14`, `audi_ha_test`
restarted and confirmed clean via log each time.
## AG. Fahrgestellnummer (FIN/VIN) automatisch aus den zugeordneten Sensoren abgeleitet (2026.8.28.15)
Owner: "It shall be available at the EU Data Act as well as FMM003. Read it from all sensors (check which
sensors are used in setup) and compare the VIN if it is matching. If VIN is empty, than use just the one
available." The FIN was, until now, a purely manual profile field (`fahrzeugprofil.json`'s `fahrzeug.fin`,
edited via "Fahrzeug einrichten") - no sensor role for it existed at all.
Investigated the real data in `audi_ha_test` before designing anything, rather than assuming a dedicated VIN
*entity* exists on either integration: neither `cupra_eu_data_act` nor the FMM003 device expose a `sensor.*`
with "vin"/"fahrgestell"/"chassis" in its name or a matching state attribute (checked via a live
`hass.callWS` scan of every entity on both devices). The VIN **is** present, though - on the
`cupra_eu_data_act` device itself, in the HA **device registry**: `identifiers: [["cupra_eu_data_act",
"WUAZZZF48PA902804"]]` and `serial_number: "WUAZZZF48PA902804"`, exactly matching this test profile's own
manually-entered FIN. The FMM003 device in this specific test container does not carry a VIN-shaped
identifier (its own identifier is just its configured integration name) - real hardware may differ depending
on firmware/channel configuration (Teltonika Codec8 Extended has a dedicated VIN AVL parameter), which is
exactly why the owner asked for "check both, don't assume one".
Built on that finding rather than adding a new Setup field: a new `identitaet.py` derives the FIN from the
**device registry of whichever sensors are already mapped** in Setup, no separate FIN sensor role to pick.
`_zugeordnete_entity_ids()` walks every field of the live `Sensorzuordnung` dataclass generically (via
`dataclasses.fields()`, list-typed fields like `TUER_SENSOREN` included) to get every entity ID currently in
use; for each, its owning device is resolved (entity registry → device registry) and checked for a
VIN-shaped identifier/serial number (`_SIEHT_AUS_WIE_FIN`: 17 chars, A-Z minus I/O/Q plus digits - the real
VIN standard's own exclusion of those three letters, chosen specifically so an unrelated device identifier
like "FMM003 - Testintegratoin" can never false-match). Distinct candidates found across all mapped devices
are collected into a set: zero → no-op (nothing found yet); exactly one → adopted automatically, but **only
if the profile's own FIN is still empty** (a manually-entered value is never overwritten - matches this
project's "human input always wins over automation" precedent, e.g. `_fahrt_screenen()`'s `edited_fields`
protection); more than one distinct value → logged as a warning naming both, deliberately not guessed at
(same "no suggestion beats a wrong one" stance as `entitaetIdKurz()`'s own fallback). Wired into two places
so it re-attempts whenever it has a real chance of succeeding: once after every HA start (`koordinator.py`'s
`_nach_start()`, alongside `fahrterkennung.nach_neustart_fortsetzen()` - deliberately not in `starten()`
itself, for the same device/entity-registry-not-ready-yet reason `trigger_neu_binden()`'s own docstring
already explains) and again after every Setup-menu save (`dienste.py`'s `entitaeten_schreiben` - a mapping
change may be exactly what makes derivation newly possible). Both call sites re-publish the profile
afterward so a freshly-derived FIN reaches the frontend immediately, not after the next 60s safety-net tick.
**Verified live, not just read from source** (this file's own section S lesson): backed up the test
container's real `fahrzeugprofil.json`, blanked `fahrzeug.fin`, restarted - log showed
`Fahrgestellnummer automatisch aus den zugeordneten Sensoren übernommen: WUAZZZF48PA902804`, the on-disk
file and the published `sensor.audi_dashboard_profil` entity both confirmed via direct query, no stale
frontend needed. Restored the original file byte-for-byte afterward and restarted once more to confirm a
clean, silent (no re-derivation - the guard against overwriting an already-set FIN held) boot. `py_compile`
clean on all three touched files (`identitaet.py` new, `koordinator.py`, `dienste.py`). No `services.yaml`
change needed (no new service, just two existing handlers doing more internally) and no frontend change
needed either - the "Fahrgestellnummer (FIN)" field in "Fahrzeug einrichten" already reads/writes this exact
profile field, so it now simply arrives pre-filled instead of blank, and stays fully editable/correctable by
hand same as before.
**Same-day follow-up (2026.8.28.16): an explicit "automatisch" switch, per owner request** ("make a Slider
next to 'Fahrgestellnummer (FIN)' ... automatisch turns on/off the auto-grab VIN from Sensor-Database ...
and also deactivates the sanity-check if VIN matches between sensors"). The original design's "only fill an
empty field, never touch a set one" heuristic is gone - replaced by an explicit, user-visible mode:
- New profile field `fahrzeug.fin_automatisch` (bool, default `true` - added to `vorlage/fahrzeugprofil.json`
and defaulted in `profilZuCar()` via `!== false` for profiles from before this field existed).
- `identitaet.fin_ableiten_und_uebernehmen()` rewritten: returns immediately if `fin_automatisch` is `false`
- **before** even calling `fin_kandidaten_ermitteln()`, so the multi-sensor comparison/mismatch-warning
never runs either, exactly as asked. When `true`, the derived value is now treated as authoritative and
**overwrites** whatever is currently in `fin` (including a previously hand-typed value) whenever a single
clear candidate is found - this is the whole point of an explicit toggle: no more guessing from emptiness,
the human's chosen mode decides. A `fin == kandidat` check avoids a pointless rewrite/republish when
nothing actually changed.
- Panel (`vEinst()`): a `<label class="switch">` reading "automatisch" placed between the "Fahrgestellnummer
(FIN)" label and the text input, in the same `.feld` row (owner's exact layout request) - the input gets
`disabled` whenever automatic is on (`.feld input:disabled{opacity:.5;cursor:not-allowed}`, new rule) and
a title tooltip explaining why, and re-enables the moment automatic is switched off. Wired via the same
`data-*`/central-`change`-listener idiom as every other switch in this file (mirrors `data-steuerlast`).
`CAR.finAutomatisch`/`p.fahrzeug.fin_automatisch` added to both directions of the profile↔CAR mapping.
- `dienste.py`'s `profil_schreiben` handler (not just `entitaeten_schreiben`) now also calls
`identitaet.fin_ableiten_und_uebernehmen()` after writing - this is the actual mechanism that makes
flipping the switch itself take effect immediately: toggling it goes through the ordinary profile-save
path (same as every other "Fahrzeug einrichten" field), so the just-written `fin_automatisch` flag is
what the very next derivation attempt reads.
**Verified live end-to-end, all four transitions, not just read from source**: (1) automatic on, empty
underlying state → real VIN appears, log line fires; (2) toggled off → input's `disabled`/`opacity`
computed style confirmed `false`/`1` (genuinely editable, not just visually similar - the app's normal
`.feld input` background already looks grey by design, so a screenshot alone wasn't proof); (3) typed a
fake value while off, saved → persisted to disk exactly as typed, confirmed via direct file read, log
stayed silent (sanity-check correctly skipped); (4) toggled back on → immediately overwrote the fake value
back to the real derived VIN, both the on-disk file and a fresh log line (`Fahrgestellnummer automatisch
... übernommen: WUAZZZF48PA902804`) confirmed within seconds, no manual save/reload needed. Layout also
confirmed clean at both a real 1400px desktop width and the default phone-width layout - no overflow at
either. `node --check` clean, manifest bumped to `2026.8.28.16`, `audi_ha_test` restarted and confirmed
clean via log.
Verified live in `audi_ha_test` at every step (`.6` then `.7`), each time reading the actual rendered Setup
popup fresh after a real restart (not a stale tab - confirmed via `performance.getEntriesByType('resource')`
showing the new `?v=` served with a nonzero transfer size each time). Final state (`.7`), all 20 single-value
fields checked in one pass: every FMM003 role shows its bare signal name
(`external_power_voltage`, `can_ambient_air_temperature`, ...), every `audi_rs_4_avant_` role shows its own
(`tailgate`, `hood`, `sunroof`, `parking_lights`, `due`, `distance`, ...), nothing shows installation noise
from either source. `node --check` clean at each step. Manifest `2026.8.28.5` → `.6` → `.7`, `audi_ha_test`
restarted and confirmed clean via log each time.
**`.7`'s "due"/"distance" reduction was itself wrong - owner caught it (`.8`): "Tankvolumen only shows
'fuel_volume' but the Sensor is 'can_fuel_volume'... nächster Ölwechsel is 'due' but shall be
'oil_change_due'."** `.7`'s per-entity nearest-single-relative algorithm still over-stripped: two fields from
the SAME sensor source can themselves share a namepart that has nothing to do with the installation prefix
(`oil_change_due`/`oil_change_distance` share `"oil_change_"`; `can_fuel_volume` shares `"can_"` with another
CAN-sourced sensor) - the nearest relative for a given entity is exactly the one most likely to share extra,
misleading structure with it, so picking the single nearest relative's prefix was the wrong metric all along,
not just an edge case. Replaced with a majority vote: every OTHER mapped entity casts one "vote" for whatever
prefix it shares with the entity being displayed (via the existing `praefixPaar()`), and the prefix with the
most votes wins (ties favor the shorter/safer one). The true installation prefix wins because an entity
shares it with nearly every other sensor from the same source (many votes); an incidental shared namepart
like `"oil_change_"` is shared with only the one sibling field (one vote) - so the majority is structurally
biased toward the correct, install-level prefix regardless of how many simultaneous sensor sources exist.
Also removed the CSS ellipsis truncation on the sensor-identity hint (`.setup-feld-hinweis` in
`audi-dashboard.css` had `white-space:nowrap;overflow:hidden;text-overflow:ellipsis`) per the owner's
explicit "remove the round collum (...)" - full identifiers like `can_fuel_volume` now show in full
(wrapping within the existing `max-width:55%` instead of truncating). Verified live in `audi_ha_test` at
`.8`: every field re-checked against the real Setup data - `can_fuel_volume`, `oil_change_due`/
`oil_change_distance`, `inspection_due`/`inspection_distance` all now show their full, correct suffix; the
FMM003 cluster (`external_power_voltage`, `can_ambient_air_temperature`, ...) and the `audi_rs_4_avant_`
cluster (`total_vehicle_mileage_read_from_can`, `front_left_door`, ...) both still strip correctly - the
majority-vote fix didn't regress the two-simultaneous-sources case `.7` was built to solve. `node --check`
clean, manifest bumped to `2026.8.28.8`, `audi_ha_test` restarted and confirmed clean via log.
## AH. Setup-Menü: fester Beispielname zusätzlich zum tatsächlich zugeordneten Sensor (2026.8.28.17)
Owner: the sensor-identity hint next to each Setup field headline (`entitaetIdKurz()`, section AG) changes
with whatever is currently mapped - useful for confirming a mapping, but it gives no orientation on what to
search for *before* anything is mapped, or after resetting/remapping. Wanted a second, permanently fixed
example name shown alongside it - `(z. B. can_fuel_volume)` / `(z. B. engine_ignition_or_acc_status)` -
styled prominently ("Sensorname-Font", white/big), with the real currently-mapped sensor kept smaller/grey
exactly as before.
Added a static `"beispiel"` string to every entry in the `FELDER` catalog (`einstellungen.py`) - one
plausible real-world sensor-ID suffix per role (`can_fuel_volume`, `engine_ignition_or_acc_status`,
`external_power_voltage`, `oil_change_due`/`oil_change_distance`, `front_left_door`, ...), several taken
directly from this project's own real Setup data (sections AC/AG). `FELDER` is serialized to the frontend
verbatim (`zuordnung.py`'s `katalog`), so no publish/service change was needed - the new key just appears on
`feld.beispiel` in JS.
`setupFeldzeile()` (`audi-dashboard-app.js`) now renders two stacked lines on the right side of each field's
header instead of one: `feld.beispiel` (fixed, never changes with the mapping) as
`.setup-feld-beispiel` - monospace like the sensor-ID font used elsewhere in this file
(`.entitaet-eintrag-id`), but larger (14px) and full-brightness (`var(--fg)`) - and the existing
`entitaetIdKurz()`-derived real mapping as `.setup-feld-hinweis`, unchanged styling (12.5px, `var(--fg2)`).
Both sit inside a new `.setup-feld-hinweise` flex-column wrapper (right-aligned, 2px gap); `.setup-feld-kopf`
switched from `align-items:baseline` to `flex-start` to keep the label aligned with the top of the now
two-line block instead of its vertical center. No change needed to `entitaetIdKurz()`/`praefixPaar()`
themselves (section AG's majority-vote logic) - only what's displayed alongside their result changed, not
how the real mapping is computed.
Verified live in `audi_ha_test` (not just read from source): read the first six rendered Setup rows via the
shadow DOM after a real restart - every row correctly shows both the fixed example and the real, currently
mapped sensor's short ID side by side and independently (e.g. Tankfüllstand: beispiel `fuel_level`, real
`fuel_level_in_tank_read_from_can` - genuinely different strings, confirming the example does not track the
mapping). `node --check` clean, `py_compile` clean on `einstellungen.py`, manifest bumped to `2026.8.28.17`,
`audi_ha_test` restarted and confirmed clean via log (single `audi_dashboard` loader warning, no traceback).
No companion-app change needed - the Setup menu is an HA-panel-only concept (parity rule's own carve-out,
already established for this exact screen in sections R/AF).
**Correction (2026.8.28.18), owner rejected the two-line design outright.** The invented static per-field
`beispiel` guesses were simply wrong for some fields ("Reichweite"/"Sofort-Aktualisierung" specifically
called out) - a hand-picked guess can't be trusted the way the actually-mapped sensor's own derived name can.
Owner's correction: **only one line**, showing the real, currently-mapped sensor (the existing
`entitaetIdKurz()` result, dynamic - not a static guess) formatted as `(z. B. REAL_SENSOR_NAME)`; the
`(z. B. `/`)` wrapper text in the same size/color as the field label on the left (`.setup-feld-label`,
14.5px/`var(--fg)`), the sensor name itself inside in the same size/color as the existing unit bracket
(`.setup-feld-einheit`, `[on/off]`/`[km]` - 12.5px/`var(--fg3)`); remove entirely for list fields (Türen/
Fenster - already excluded from `erwartet` by `feld.liste`, just needed the static example line gated the
same way, which it wasn't).
Removed the `"beispiel"` catalog data from `einstellungen.py`'s `FELDER` entirely (dead/wrong data, not
worth keeping around) and the `.setup-feld-hinweise`/`.setup-feld-beispiel` classes. `setupFeldzeile()`
now renders a single `<span class="setup-feld-hinweis">(z. B. <span class="setup-feld-einheit">...</span>)
</span>` - reusing the existing `.setup-feld-einheit` class for the inner sensor-name span instead of a new
rule, since the requested style is byte-identical to what that class already provides. `.setup-feld-hinweis`
itself changed from the old small/grey 12.5px to the label's own 14.5px/`var(--fg)`; `.setup-feld-kopf`
reverted to `align-items:baseline` (single line again, no more two-line stacking).
Verified live in `audi_ha_test` at `2026.8.28.18` (a fresh page load was required to see it - the panel's
script URL is version-query-busted and a stale tab keeps the old bundle even after the backend restarts,
confirmed via `performance.getEntriesByType('resource')` showing `?v=2026.8.28.18` only after navigating
again, same lesson as sections N/W): read every rendered Setup row's markup and computed styles - "Reichweite"
now shows the real `vehicle_range_read_from_can`, "Sofort-Aktualisierung" shows the real `refresh_now`
(both previously wrong static guesses), "Türen"/"Fenster" show no hint at all, and the wrapper/inner-name
computed styles matched the label (14.5px) and unit-bracket (12.5px, grey) styles exactly, byte for byte.
`node --check` clean, `py_compile` clean on `einstellungen.py`, `audi_ha_test` restarted and confirmed clean
via log (single `audi_dashboard` loader warning, no traceback).
---
## AI. iOS app built, signed ad-hoc, and prepared for over-the-air install (2026-08-29)
Task was: build the iOS app, sign it, commit the result — `UMSETZUNGSPLAN.md` Phase 10 step 7, the
last open item of that phase. **Result: done.** A signed `.ipa` exists and is committed; the
over-the-air delivery around it is built but not yet exercised (no HTTPS host running — see the end
of this section). Read this section start to finish before touching iOS signing: it went through two
wrong assumptions and one badly-signposted failure, all recorded below on purpose.
What was verified on this machine (Xcode 26.4, Node 22.20, `main` at `2026.8.28.18`):
- `npm run typecheck` clean, `npm test` 146/146 green, `npm run build` clean, `npx cap sync ios`
clean (both plugins found: secure-storage 8.0.0, capgo/updater 8.51.14).
- **Simulator build (Release) succeeds.**
- **Device build (Release, `generic/platform=iOS`, arm64) succeeds** with signing switched off —
8.5 MB `App.app`. So the code, the plugins and the Swift-package graph are all fine on device;
nothing but provisioning is missing.
**The first blocker (since resolved), verbatim from Apple:** `Communication with Apple failed: Your
team has no devices from which to generate a provisioning profile.` The account authenticated fine
(Xcode reached Apple and got a real answer, not an auth error) — team `RMACS9VLS4`, certificate
`Apple Development: paul.nothaft@me.com (C9L892Z59P)`, valid until 2027-07-17. Apple issues a
development profile only for **named devices**, and this team has none registered. No iPhone is
connected (`xcrun devicectl list devices` → none) and no device was ever paired with this Mac (no
`~/Library/Developer/CoreDevice`, no iOS DeviceSupport). There is no route around this from a
machine with no phone attached:
- development / ad-hoc profiles both require registered UDIDs;
- an App-Store-method export needs no devices but produces an IPA that iOS refuses to sideload;
- a locally `codesign`-ed `.app` without an embedded profile will not install either.
**Interlude, same day: the account was free at that moment.** Xcode's cache said
`teamName = "Paul Nothaft (Personal Team)"`, **`isFreeProvisioningTeam = 1`** — which meant no
portal device management, and a 7-day expiry on profile, App ID and device registration alike. The
owner then **bought the Apple Developer Program** and registered the iPhone through the portal.
**Two things this taught, both worth keeping:**
1. **A paid enrolment upgrades the existing team; it does not create a new one.** The Team ID stayed
`RMACS9VLS4` — the same string that had belonged to the Personal Team. This file briefly claimed
the opposite and the signing script briefly refused to run because of it. Verified by the issued
profile: team `RMACS9VLS4`, valid **2026-08-29 → 2027-08-29** (a year, not seven days).
2. **The note further up this file claiming "Paul has an Apple Developer Program" (2026-08-17
section) was wrong when written and right by accident twelve days later.** It is marked in place.
**`errSecInternalComponent` and a build that hangs forever: it is the keychain, not the code.** After
the first successful profile fetch, `xcodebuild` sat at the `CodeSign` step for 20+ minutes with no
output, then failed with `errSecInternalComponent`. Diagnosis that settled it, worth repeating
because the symptom points nowhere useful on its own: `lsof -p <codesign-pid> -i` showed **no network
sockets** (so not a timestamp-server stall, the other classic cause), and `sample <pid>` showed the
stack parked in `SecKeyCreateSignature` → `SecCDSAKeyCopyOperationResult` → `mach_msg` — blocked on
the security daemon, i.e. an unanswered **"allow codesign to use this key?" dialog** sitting on the
GUI. Fix is the owner clicking **"Always Allow"** (not "Allow" — a single archive signs 25+ binaries
and would re-prompt each time). Deliberately not used: `security set-key-partition-list`, which does
the same non-interactively but only by putting the login password on a command line.
**Result: it worked.** `App.ipa`, 2.4 MB, exported **ad-hoc**, signed
`Apple Distribution: Paul Nothaft (RMACS9VLS4)`, embedded profile
`iOS Team Ad Hoc Provisioning Profile: app.datametric360` valid to 2027-08-29, one provisioned device
(`00008130-001C089E3E8A001C`), `get-task-allow: false`. Committed to
`companion-app/auslieferung/App.ipa` — `ios/` is gitignored, so without a tracked path the artifact
would exist on one Mac only.
**Over-the-air install, built as `companion-app/scripts/ios-luftweg.sh <https-basis>`.** Emits
`manifest.plist` + a small install page + the icons into `ios/build/luftweg/`, reading bundle id and
version out of the `.ipa` rather than repeating them. The binding constraint is **HTTPS with a
publicly trusted certificate** for both the manifest and the `.ipa` — iOS refuses plain HTTP and
self-signed certs, so Home Assistant's `/local/` over the tailnet does **not** qualify. The fit for
this project is `tailscale serve --bg <ordner>` (real Let's Encrypt cert on `*.ts.net`, no open port,
iPhone already on the tailnet); the Phase 12 Cloudflare tunnel would do it later too. The link must
be opened in **Safari** — other browsers do not hand `itms-services://` to the system. Script
refuses a non-`https://` base outright rather than producing a manifest that fails silently on the
phone.
**"App cannot be installed because its integrity could not be verified" — it was a stale cached
provisioning profile.** First install attempt failed on the phone with that message. Everything on
the signing side measured clean, which is worth listing because it is the exact set of things not to
re-check next time: the `.ipa` fetched through Cloudflare was **byte-identical** (same SHA-256),
`codesign --verify --deep --strict` passed and satisfied its Designated Requirement, the signing
certificate was valid to 2027, the WWDR intermediates were current (G3→2030, G6→2036), and the
entitlements carried the right `application-identifier`.
The cause was the one thing not visible from the artifact: the embedded ad-hoc profile listed **one**
device, and by then the team had **two** registered. Registering a device at Apple does **not**
invalidate profiles already issued, and `-allowProvisioningUpdates` happily re-uses whatever sits in
`~/Library/Developer/Xcode/UserData/Provisioning Profiles` — so a rebuild silently keeps embedding the
outdated device list. **Fix: delete the cached `.mobileprovision` for that profile, then re-export.**
A full re-archive is unnecessary — `xcodebuild -exportArchive` re-signs, so ~1 minute against the
existing `.xcarchive` is enough. Verified afterwards: profile now lists both
`00008130-001C089E3E8A001C` and `00008140-000229913A0B001C`, still valid to 2027-08-29.
Not used, though the owner offered it: a hand-downloaded `Audi_App.mobileprovision`. It was a
**development** profile (`get-task-allow: true`) with a **7-day** life, so it would have needed a
development certificate instead of the distribution one and a re-install every week. Its value was
diagnostic, not operational — it is what revealed the second device.
**Developer Mode is required, and ad-hoc does not exempt you from it.** After the profile fix the app
installed and then refused to launch until Developer Mode was on. Since iOS 16 that applies to apps
with **Ad Hoc** profiles as well as development ones; only In-House (Enterprise) profiles are exempt,
and this project has no enterprise account. An earlier note in this section wrongly implied ad-hoc
avoided it. It is a one-time, per-device step and survives reboots:
**Einstellungen → Datenschutz & Sicherheit → Entwicklermodus → einschalten → Neustart → nach dem
Neustart „Einschalten" bestätigen und Code eingeben.** The menu entry only appears once the device
has been offered such an app, which by then it has. Worth knowing for a project with this security
posture: Developer Mode loosens the device's debugging restrictions, so it is a deliberate trade for
being able to sideload at all.
**First real-device finding: tapping a field zoomed the whole screen.** Reported on the Willkommen
screen — focus an input, iOS scales the page up and it becomes pannable, which reads as broken in a
native shell. Cause was **not** a missing rule but a losing one. `grundlage.css` and `.dm-eingabe`
both already set inputs to 16px precisely to avoid this (it is an old audit finding from the panel),
but `@audi-dash/ui`'s `.ads-feld input` sets **13.5px**, and class+type beats a bare class — so the
design-system value won everywhere a field sits inside `Feld`, which is everywhere. Below 16px iOS
auto-zooms on focus; that is the whole mechanism.
Fixed in `companion-app/src/stile/screens.css` with `.ads-feld input.dm-eingabe,
.ads-feld select.dm-auswahl { font-size: 16px }` — same value as already intended, just specific
enough to land. `@audi-dash/ui` stays untouched, matching the precedent set by the `type=date`
override directly below it in the same file. Verified in the built CSS (override present, higher
specificity, later in the file) and inside the packaged `.ipa`.
Deliberately **not** used: `user-scalable=no` / `maximum-scale=1` in the viewport meta. It would have
hidden the symptom in one line, but it disables pinch-zoom for everyone, and the same bundle is also
served as a web app from Home Assistant. Fixing the font size removes the cause and keeps zoom
available.
**Still open at end of session:** this Mac is logged out of Tailscale (`BackendState: NeedsLogin`),
which is moot while Gitea hosts the install (section AJ).
Also untouched: `MARKETING_VERSION` is still `1.0`/`1` from the Capacitor scaffold, so successive
`.ipa`s are indistinguishable in iOS Settings — harmless for installing, mildly annoying later.
**What was built instead of a hand-clicked Xcode signature:** `companion-app/scripts/ios-signieren.sh`
— build → `cap sync` → signed archive → `.ipa` export, one command. It exists because `ios/` is
gitignored on purpose (regenerable from the web bundle), which means **any signing setting clicked
in Xcode is silently lost at the next `npx cap add ios`**. A versioned script is the only place the
team ID can survive (`APPLE_TEAM_ID` overrides it). It copies the finished `.ipa` to
`auslieferung/`, which *is* tracked, for the same reason.
Deliberately **not** done: committing an unsigned `.ipa` as a stand-in while signing was still
blocked. It could not have been installed, and it is not what was asked for. Once the signature
worked, the real 2.4 MB artifact went in.
**Do not confuse the two things called "OTA" in this repo.** The older one (section on
`@capgo/capacitor-updater`) swaps the *web bundle* inside an already-installed app and is the normal
route for UI changes — no Xcode, no Apple. The new `ios-luftweg.sh` installs a whole new *native
app* and is only needed when native code, plugins or the signature change. Reaching for the second
when the first would do means a pointless Apple round trip.
Phase 10 step 7 is **ticked** in `UMSETZUNGSPLAN.md`; step 8's own acceptance test (app starts
natively, token lands in the Keychain) is **not** — that needs the app actually running on the phone,
which needs the HTTPS host below.
---
## AJ. The repository is public, and the license rule says it must not be (found 2026-08-29)
Found while answering "could Gitea host the over-the-air install?". It could — and the reason it
could is the problem. Measured, not inferred:
- `GET https://gitea.nothaft.cloud/api/v1/repos/paul/audi-app` → **`"private": false`**
- `GET .../raw/branch/main/companion-app/auslieferung/App.ipa` → **HTTP 200**, 2,437,551 bytes,
`application/octet-stream`, **no authentication**
- `GET .../raw/branch/main/companion-app/src/assets/audi/schriften/audi-type-400.woff2` → **HTTP 200**,
a real 22,868-byte WOFF2
So Audi Type, the four rings, the model badges and the signed `.ipa` that bundles all of them are
downloadable by anyone right now. That contradicts this file's own **hard license rule** (top of
document: cleared "only for this one private, unpublished installation") and
`companion-app/README.md`'s "dieses Repository bleibt privat". The exposure of the brand assets long
predates the `.ipa`; committing the `.ipa` on 2026-08-29 was done on the documented assumption that
the repo was private, which turned out to be false.
**Making it private does not break anything.** The obvious worry is the integration's self-update,
but `custom_components/audi_dashboard/aktualisierung.py` authenticates
(`_header()` → `Authorization: token …`) and returns early when no token is configured — it never
relied on anonymous access.
**It does break the "host the OTA install from git" idea**, which is why the two are recorded
together. The over-the-air download is performed by an iOS **system daemon**, not by Safari, so it
carries no cookies or login session; a private repo would hand it an HTML login page instead of the
`.ipa`. Gitea 1.26 no longer accepts API tokens as a URL query parameter, and embedding one in a
`manifest.plist` would mean writing a credential into a served file anyway. `tailscale serve` stays
the right host for that job (section AI).
Checked at the same time and clean: no tokens or credentials in tracked files (`.env.local` is
gitignored; the long hex strings in this file are bundle SHA-256s). Note that flipping visibility now
does not un-publish what was already reachable — the instance sits behind Cloudflare and may have
been cached or crawled.
**Owner's decision (same day): the `.ipa` stays in the public repo.** Reasoning given: the build is
ad-hoc signed, so it only installs on devices registered in the profile, and the link is only shared
with people who are registered — "zweifache Absicherung". The first half is correct: an ad-hoc `.ipa`
will not install on an unregistered device, and today the profile lists exactly one.
**What that reasoning does not cover, verified rather than argued.** A code signature governs
*installation*, not *reading the archive*. `unzip App.ipa` needs no key, no profile and no device,
and it yields:
```
Payload/App.app/public/assets/audi-type-400-CBKll8kj.woff2 → Web Open Font Format 2, 22,868 bytes
Payload/App.app/public/assets/audi-type-wide-300-*.woff2
Payload/App.app/public/assets/audi-type-extended-400-italic-*.woff2
```
— i.e. Audi Type in redistributable form, plus the rings and badges alongside. So device registration
protects *running the app*; it does nothing for the licensed assets, which is what the rule at the
top of this file is actually about. And it is moot either way while the repo is public: the same
fonts have long been fetchable as loose files under `companion-app/src/assets/audi/`, no `.ipa`
involved.
**Net state to be aware of, not to re-litigate:** publishing the app publicly means publishing Audi
Type. The owner has been told this explicitly and accepts it. If the position ever changes, the fix
is one setting (repo → private) plus `tailscale serve` for the over-the-air host (section AI), and
nothing else in the project depends on anonymous access.
**Consequence: Gitea itself is now the over-the-air host**, no Tailscale needed while the repo stays
public. `companion-app/auslieferung/` carries `App.ipa`, `manifest.plist` and both icons, generated by
`ios-luftweg.sh <basis> auslieferung` (the second argument writes straight into the tracked folder).
Verified against the live server: manifest and icons return HTTP 200, the manifest parses (`plutil
-lint`) and its asset URL resolves to the real `.ipa`.
One wrinkle worth knowing before debugging it twice: **Gitea serves every raw file as
`text/plain`**, deliberately, so the generated `index.html` is displayed as source and its install
button never renders. The manifest is unaffected — the iOS installer parses it as a plist regardless
of content type — so the working route is to paste the `itms-services://` URL into Safari's address
bar directly. The script prints that URL for exactly this reason.
**Gap found and closed the same day (2026-08-29, asked directly: "hast du sichergestellt dass Paul
mit einem neuen Xcode-Durchlauf auch die garantiert aktuelle Version erwischt?"):** the script built
cleanly from whatever was locally checked out, but never checked that against Gitea — a forgotten
`git pull` on Paul's Mac would have signed a stale commit with no warning, the exact class of silent-
staleness bug this project keeps fixing elsewhere (cache-busting, OTA-bundle-version matching,
`install.ps1`'s bundle-vs-manifest check). `ios-signieren.sh` now refuses to build unless the working
tree is clean and `HEAD` is exactly `origin/main` (fetches first, aborts with a clear message
otherwise) — added right after `cd "$APP"`, before the web build.
---
## AK. Full panel↔companion-app parity audit, done in resumable chapters (started 2026-08-29)
Owner request, after seeing an old/stale iOS build: since the HA panel is considered functionally
final, re-derive companion-app's screens from the panel's current state exactly, so Xcode only has
to build+sign an already-correct app — instead of Paul re-doing work because the two apps still
disagree somewhere. Split into 11 chapters (one per screen group) specifically so the work survives
a context/token cutoff mid-task: each chapter ends at a clean, typechecked+tested state before the
next starts, and the owner is asked after each one whether to continue now or pause. Chapters:
1 Übersicht, 2 Mein Audi/Fahrzeugdaten, 3 Service/Reifen, 4 Versicherung/Steuer, 5 Sicherheit, 6
Fahrten, 7 Tanken, 8 Statistik, 9 Batterie, 10 Einstellungen/Datensatz, 11 Abschluss (Build,
Versions-Bump, `npm run ota`, Gesamtbericht).
**Chapter 1 (Übersicht) — four real drifts found and fixed, all in `Uebersicht.tsx` (vs. the panel's
`vHome()`):**
1. Status-row wording didn't match: panel says "Nicht sicher abgestellt"/"Status unbekannt",
companion-app said "Nicht vollständig verschlossen"/"Zustand unbekannt" — no recorded decision
for the difference, just drift. Aligned to the panel's exact wording.
2. The Reichweite tile still showed a "rund X von Y l" liter estimate under the percentage — that
line was deliberately **removed from the panel on 2026-08-16** (owner request, see the sixth
round that day) and was never removed here. Dropped; the percentage now renders as a bare
number, matching `CAR.tankBekannt ? pct + " %" : ""` exactly. This needed a real data-layer gap
closed too: companion-app's `Fahrzeug` type had no `tankBekannt` field at all (unlike its
existing `odoBekannt` twin) — added to `profilAdapter.ts` (`status.tankprozent !== null/undefined`,
mirroring the panel's own `koordinator.py` derivation) and to the one test fixture that
constructs a full `Fahrzeug` object (`service.test.ts`).
3. The Reichweite figure rendered at `size={40}` — the same size as the smaller "Nächster Service"
number one tile down. The panel draws it at `font-size:60px`, a genuine hero number. Fixed to
`size={60}`.
4. When there's no next service (`service` is `null`), the panel deliberately renders that block as
a plain non-interactive `<div>` (no chevron, no click target) — "nothing to navigate to." Companion-
app always wrapped it in a clickable `<button>` with a visible chevron regardless. Fixed: the
button (with chevron) only renders when `service` exists; the empty state is a plain `<div
className="dm-serviceblock">` instead. `screens.css`'s `.dm-serviceblock` cursor/press-feedback
rules were re-scoped to `button.dm-serviceblock` so the non-interactive div doesn't get a
misleading pointer cursor.
**Checked and left alone, deliberately** (not drift, established idiom/UX choices): companion-app's
`Leerzustand` empty-state tiles for "Letzte Fahrt"/"Letzte Tankung" when no records exist yet, vs.
the panel's `teaser()` simply hiding the whole row — this is a systemic companion-app-wide UI
convention (friendly empty states instead of hidden sections), not something introduced by drift,
and changing it would touch far more than this one screen; the `naechsterService()`/`service.ts`
forecast math itself (restKm/date computation) was not re-verified line-by-line here — that belongs
to Chapter 3 (Service), where the full `termine()`/`meldungsPrognose()` family gets its own pass.
**Fifth item, found by the owner asking directly whether the tab bar/header was actually checked —
it hadn't been, and it turned out to hide the biggest gap of the chapter:** `Shell.tsx` (the shared
header/nav wrapper used by *every* screen, not part of the per-screen scope above) showed a gear
icon top-right on phone width, unconditionally. The panel's own design (Design-Audit 2026-08-13,
`.rings`/`.profilbtn` in `audi-dashboard-ios.css`) is the opposite: on phone width the gear
(`.profilbtn`) is `display:none` and the **Audi rings** sit top-right instead ("damit oben rechts
nur ein Element sitzt") — the rings are the *only* path to Einstellungen there. companion-app never
had a rings button in the narrow header at all. Fixed: `Shell.tsx`'s `kopf` now renders `<VierRinge
hoehe={17}>` (already used elsewhere for the wide sidebar) inside the same `.dm-kopf__aktion` slot,
`SymbolZahnrad` removed from that spot (still used in the wide sidebar's own footer). Also removed
`.dm-kopf__aktion[aria-current="page"]{color:var(--red)}` — dead now that the icon is an `<img>`
(color doesn't apply), and was already a violation of this project's own "red is destructive/accent
only, never a navigation state" rule (see the 2026-08-13 back-arrow/primary-button fixes) that had
simply never been caught because `Shell.tsx` isn't a "screen" any per-screen audit would reach.
`Shell.test.tsx`'s test name updated ("...über die Ringe, nicht über das Zahnrad") — the assertion
itself (`getByLabelText("Einstellungen")`) needed no change and was already passing throughout.
**Live verification unblocked, worth keeping as project infrastructure:** the first attempt to
verify this chapter live failed — CORS: `OPTIONS http://localhost:18123/api/` came back `403
Forbidden` from `audi_ha_test`, the documented constraint ("the web build is served from HA itself,
same-origin... or the native hull uses `CapacitorHttp`, which bypasses CORS" — a foreign dev-server
origin hits neither path). Added a **dev-only** Vite proxy (`vite.config.ts`, `server.proxy: {
"/api": { target: "http://localhost:18123", changeOrigin: true, ws: true } }`) — `server.*` never
applies to `vite build`, so this has zero effect on the shipped app. Pointing the onboarding
screen's server address at the dev server's own origin (`http://localhost:5173`) makes every
`/api/...` call same-origin from the browser's point of view; Vite's Node process (not subject to
browser CORS) forwards it server-side to `audi_ha_test`. Hardcoded to `:18123` specifically — never
point this at anything else. This unblocks live visual verification for every remaining chapter,
not just this one.
Verified: `npm run typecheck` clean, `npm test` 146/146 green, **and live end-to-end** against
`audi_ha_test` through the new proxy — screenshot confirms the rings render top-right (not a gear),
the Reichweite hero figure at real 60px size ("87 km" clearly dominant), the bare "15 %" with no
liter line, the service block correctly shows its chevron (a real next-service exists in this
container's data), and the tab bar order/icons match the panel.
**Owner escalation right after: "check EVERYTHING optically — every font, every field, every color,
every icon" — named three concrete symptoms (Mein Audi icon looked old, Reichweite laid out
differently, the Ölwechsel figure's font was wrong). All three were real, found by diffing CSS/SVG
source directly against the panel rather than by eye (no way to log into the real panel's frontend
from this session — LLAT tokens work for the REST/WS API, not an interactive HA session; entering a
password is off-limits per this project's standing rules — so every comparison here is source-level,
not a real pixel screenshot of the panel itself):**
1. **`SymbolAudi` (the "Mein Audi" tab icon) was a placeholder, not the real icon.**
`<polygon points="21.6,5 16.5,19 2.4,19 7.5,5" />` — a crude quadrilateral that was apparently
never replaced with the real Audi-CI "car from the front" glyph the panel uses (`ICONS.audi`,
filled/`voll`, from the myAudi icon set). Replaced with the exact same path, `voll` added to match
`ICONS_VOLL.has("audi")`.
2. **`.ads-title` (design-system, the page-title text used by `Shell.tsx`'s header) was 23px; the
panel's `.title` is 22px.** No recorded reason for the 1px drift — fixed in `design-system/src/
tokens/tokens.css` (design-system is meant to track the panel's own corrected values, see its own
`--fg3` comment from the 2026-08-11 contrast fix) and rebuilt (`npm run build` in `design-system/`
— it ships as a built package, `dist/`, so a source edit alone doesn't reach companion-app without
this).
3. **The Reichweite tile's whole layout order was inverted.** The panel's `.tankzeile` (explicitly
commented as a myAudi-app convention, the *only* tile in the whole panel with its label at the
bottom instead of the top) puts the big figure and the percentage side by side on one row
(`display:flex;align-items:flex-end;justify-content:space-between`), then the progress bar, then
"Reichweite" last. `Uebersicht.tsx` had the eyebrow label first (matching every *other* tile's
convention, but not this one's actual panel counterpart), and the figure/percentage stacked instead
of sitting in one row. Restructured to match exactly: new `.dm-tankzeile`/`.dm-tankzeile__pct` CSS
(byte-equivalent to `.tankzeile`/`.pct`), "Reichweite" label moved after the progress bar.
4. **`.dm-tgwert` (the big Ölwechsel/Inspektion figure on the Service screen's "Anstehende Termine")
had no `font-family` at all.** The panel's `.tg-wert` explicitly sets `"Audi Type Wide"`; without
it here, the number silently fell back to the narrow "Audi Type" body cut — a real, visible
different letterform, exactly the "font doesn't match" the owner described. Added
`font-family: var(--font-wide)`.
**Found but deliberately NOT fixed yet, flagged instead — needs its other call sites checked first:**
`.dm-navikachel__zahl` (the shared `NaviKachel` component's big value, used by the "Letzte Fahrt"/
"Letzte Tankung" tiles on Übersicht, and also by `Service.tsx`, `MeinAudi.tsx`, `Versicherung.tsx`)
renders at 20px/weight 300 with no `font-family` override — but the panel's actual teaser equivalent
(`.leaf .v`, the "Zuletzt" tile) is plain 13px body text, no Wide font, no hero treatment at all. This
is a real, likely bigger mismatch than the Ölwechsel one — but `NaviKachel` is shared across (at
least) five screens, some of which may have a *different* panel counterpart than `teaser()` with its
own, possibly-correct-as-is sizing. Fixing this blind risks regressing a screen not yet audited.
**Do not touch `NaviKachel`'s styling without first checking every one of its five call sites against
its own specific panel counterpart** (Chapter 2/3/4's territory, not just Chapter 1's).
Verified (items 14 above): `npm run typecheck` clean, `npm test` 146/146 green after every step,
**re-verified live** through the dev proxy at a true 375×812 mobile viewport (`resize_window` preset
"mobile" — the owner separately asked for this, since screenshots up to that point were desktop-pane-
narrow rather than an actual phone viewport): rings top-right, real car icon on "Mein Audi", the
Reichweite row/percentage/label order all confirmed matching.
**Standing method going forward, all remaining chapters:** structural/logic review alone is not
enough — cross-check every relevant CSS rule (`font-family`, `font-weight`, `font-size`, color
tokens) against the panel's own stylesheet line by line, not just JSX structure, since this is where
three of these four bugs actually lived.
**Real login access to the panel, corrected 2026-08-29:** the owner had `http://localhost:18123/
audi-dashboard` open and already authenticated in this session's own browser tool (a second tab,
separate from the companion-app dev-server tab) — contrary to the earlier "I can't log in" claim
made a few turns earlier in this same conversation. That claim was right about the actual constraint
(entering a password into a login field is off-limits, categorically, for any instance including a
disposable test one — confirmed `audi_ha_test` has no `trusted_networks` passwordless provider
either) but wrong to conclude "so I can never see the real panel" — an *already-authenticated* tab is
a different thing entirely, nothing was typed into a credential field to get it. Once pointed at it,
direct DOM/computed-style queries against the panel's real shadow root (`javascript_tool`, walking
into `audi-dashboard-panel`'s `shadowRoot`) became possible — ground truth, not inference from CSS
source. Also resized this tab to a real 375×812 viewport per the owner's request (`resize_window`
preset "mobile" on `tabId` for the HA tab too), since the panel embedded in HA's own sidebar at
desktop width is not what the phone app actually renders.
**Two more real bugs found this way, immediately:**
1. **`.ads-eyebrow` (design-system) used the pre-2026-08-13 uppercase Audi-CI eyebrow style —
10px, `text-transform:uppercase`, 0.2em letter-spacing — everywhere, across every screen.** Live-
measured against the panel's actual `.label` (used for "Reichweite" and the same role throughout):
`font-size:13px; letter-spacing:.01em; text-transform:none; font-weight:400; color:var(--fg3)` —
the iOS-overlay override from the "iOS look made official" decision (2026-08-13, see the
iOS-vs-Audi-CI section above), which explicitly reversed the old uppercase convention project-
wide. `design-system`'s neutral eyebrow was never updated to follow that decision — it still
ships the pre-decision look, correctly, since it's also a standalone kit for Claude Design and the
iOS decision was scoped to this app specifically. Owner's explicit call when asked which side
should change: **"die iOS App soll optisch möglichst identisch zur vorhandenen HA App sein"** —
so companion-app overrides it locally (same pattern as the font-family override just above it),
`design-system` itself untouched. This is likely the single biggest contributor to the "looks
old/mismatched" impression, since `.ads-eyebrow` is used for nearly every tile caption app-wide.
2. **The entire Standort feature (live vehicle GPS position) was completely missing from
companion-app** — zero files, zero route, confirmed via a source-wide grep for "Standort" before
concluding it wasn't just misnamed. The panel's version is substantial: a small non-interactive
preview map on Übersicht (`standortKachel()`), a fullscreen map screen with a real vehicle marker
(CI `poi-car-l` icon + solid silhouette, the contour-only-icon problem documented elsewhere in
this file), an optional own-device marker (browser geolocation), distance-to-vehicle text, reverse-
geocoded address (Nominatim, cached), a fährt/steht/"geparkt seit" status line, tank%/range values,
Route (Google Maps deep link) and Teilen (Web Share API, clipboard fallback) actions, four floating
map-style/center controls, and a draggable peek/expand bottom sheet. Owner's answer when asked
whether this was in scope: **"hier greift wieder 1. und selbstredend muss die technische Funktion
erstellt werden"** (of course the function has to be built too).
**Built: `src/screens/Standort.tsx`** (new), exporting `StandortVorschau` (the Übersicht preview
tile, embedded there right after the service tile — same position as `standortKachel()` in `vHome()`)
and `Standort` (the fullscreen screen, registered as a new route). `navigation.ts` gained
`"standort"` (`ZURUECK.standort = "home"`, `TITEL.standort = "Standort"`); `register.tsx` wires it.
Reused `Karte.tsx`'s established pattern (Leaflet via dynamic `import()`, bundled not CDN — same
reasoning as there: this app can be network-isolated to a VPN tunnel, and a CDN fetch failing
silently was an already-documented panel-era bug) but written fresh rather than extended, since
Standort's needs (live single point + a second, independent user point, click-to-open-sheet, style
toggle) don't fit `Karte`'s start/ziel/route-triplet API. The CI marker icons (`poi-car-l` outline +
its silhouette, `map-layer`/`car-search`/`gps`/`select-all`/`plan-route`/`fuel-range`/`parking`) are
verbatim panel path data, kept local to this file (matching the existing precedent of `symbole.tsx`'s
`SymbolOelwechsel`/`-Inspektion` — "vom Nutzer als SVG geliefert, verbatim übernommen").
`fahrzeug.standortLat/standortLon/standortZeit` and `zuendung` were already present and correctly
mapped in `profilAdapter.ts` (built earlier, never consumed by any screen until now) — no data-layer
work needed, only the UI.
**One deliberate difference from `standortMenuVerdrahten()`'s exact wiring:** the panel binds
pointer handlers on the whole shadow root filtered by `closest(".standortmenu-griffzone")` and
separately gives the grip a `data-standort-umschalten` click handler through the shared central
dispatcher. Ported as React `onPointerDown/Move/Up/Cancel` directly on the griffzone element (ref-
based direct `style.transform` writes during the drag, matching the panel's "manipulate the DOM
directly, no re-render per pixel" reasoning) — but the grip button's own `onClick` was **dropped**,
not ported 1:1: a plain tap already satisfies the pointerup handler's own `Math.abs(dy) <= 40` toggle
branch, and keeping both would double-toggle on every mouse/touch tap (open-then-immediately-close).
Keyboard access (the entire reason the panel made the grip a real `<button>`, per its own comment)
is preserved via a dedicated `onKeyDown` handler for Enter/Space instead.
**Verified: `npm run typecheck` clean, `npm test` 147/147 green (147, not 146 — the existing render-
every-page smoke test picked up the new `standort` route automatically), `npm run build` succeeds.
Live end-to-end through the dev proxy at a real 375×812 viewport**, and this is the first companion-
app-only feature in this file verified against genuinely live GPS/geocoding, not just fixtures: the
preview tile rendered a real map with the real car-pin marker at the test container's actual
coordinates; opening it showed the correct empty/denied-permission copy ("GPS-Freigabe fehlt",
byte-identical to the panel's own string) since this sandboxed browser has no location permission;
dragging/tapping the sheet handle opened it to reveal a **real, successfully reverse-geocoded
address** ("St 2047, 91809 Wellheim") via a live Nominatim call, the correct "Geparkt seit 7 Std. 26
Min." status (derived from this container's actual last-trip end time), the tank%/range values, and
both the "Route"/"Teilen" pills rendered correctly (primary filled vs. outline). One tooling note
worth keeping: clicking anything that triggers `navigator.geolocation.getCurrentPosition()` makes
the browser-automation tool's click call report a 30s timeout even though the click and the
resulting render both succeed — the permission prompt blocks the tool's own completion signal, not
the page. A follow-up screenshot after the reported timeout always showed the correct, fully-
rendered result — don't mistake this specific timeout for a real failure on this page.
Manifest/OTA not bumped yet — deferred to the chapter-11 wrap-up per the standing chapter plan,
same as every other companion-app-only change so far this audit.
**Owner follow-up round, same day — three real items and one false alarm:**
1. **"Teilen" had no icon at all** — a real omission in the port, not present in the panel: `Standort.tsx`'s share button was built as bare text. The panel's own markup (`ciSVG(CI.upload, 15)}Teilen`, read directly this time rather than assumed) uses the `upload` CI icon. Added `CI.upload` to `Standort.tsx` and rendered it before the label, matching exactly.
2. **Tank/range row icon, owner-requested change: "soll identisch zum Tanken-Icon sein."** Live-extracted from the real, authenticated panel DOM (see below) that this row currently uses `CI.fuelRange` — a different, filled CI icon, not the stroke-drawn tab icon (`ICONS.fuel`) used for the "Tanken" tab. This is a deliberate product correction, not a parity bug being fixed — applied to **both** codebases per the parity rule so they don't immediately drift apart again: panel's `.standortmenu-werte` now renders `ICONS.fuel` directly (stroke SVG, matching the tab icon's own wrapper attributes exactly) instead of `ciSVG(CI.fuelRange)`; companion-app's equivalent now renders the existing `<SymbolTanken groesse={16}>` component instead of the local `CI.fuelRange` path (which was removed from `Standort.tsx`'s `CI` object as the resulting orphan). `CI.fuelRange` was also removed from the panel's own `CI` catalog (no longer referenced anywhere). Manifest bumped to `2026.8.30.1`, deployed to `audi_ha_test`, restart confirmed clean via log, live-reconfirmed via the panel's real shadow DOM that `.standortmenu-werte` now renders the fuel-pump stroke icon.
3. **"Das oberste der vier Icons ist fehlerhaft" — investigated, not reproduced.** Extracted the live-rendered top control button's full `outerHTML` from both companion-app and the real, authenticated panel and diffed them: byte-identical SVG (`viewBox`, `width`/`height`, `fill`, the `mapLayer` path itself) and identical wrapper attributes. Also functionally exercised it (not just visually inspected): clicking it in companion-app flips `aria-pressed` and swaps the tile-layer source to the real ArcGIS satellite endpoint, exactly as designed — confirmed via `read_network_requests`-equivalent direct tile-`src` inspection, not just a screenshot. Could not find a defect through either markup or behavior. Flagged back to the owner to ask for a device screenshot rather than continuing to guess blindly.
4. **False alarm, root-caused and worth recording so it isn't chased again: "iOS App Darstellung ist aktuell im Zoom."** Not an app bug — the Browser tool's own `resize_window` mobile-viewport emulation had gotten into a visibly inconsistent state across this session (screenshots of the same 375×812-declared viewport rendered at wildly different effective scales moments apart, and a tab's internal id silently changed — `tab-2` became `tab-4` — partway through, which likely contributed). Confirmed via `getComputedStyle` that the actual values were correct throughout regardless of how the screenshots looked (`.ads-title` 22px, `.ads-fig` 60px, matching the panel's own live-measured values exactly, recorded earlier this file) — the CSS was never wrong. Fixed by clearing the viewport emulation (`resize_window` preset `"desktop"` on the affected tab), which returned the pane to its normal responsive size and resolved the visual "zoom" the owner was seeing directly in the shared pane. **Lesson for the rest of this project's remaining chapters:** don't trust an apparent size/scale difference between two screenshots taken via this tool without also checking computed pixel values — screenshot scale in this tool has proven unreliable this session, computed styles have not.
Owner's broader ask alongside these four: a full, no-detail-spared pass — "Prüfe vollständig. Ich kann dir nicht jede Abweichung aufzeigen" (check everything myself; fonts, frames/backgrounds that shouldn't exist, the tab-bar active-icon treatment) — explicitly including a standing offer to say if any font or icon asset is missing.
**Tab-bar active state, checked immediately after — a real, significant bug, the biggest single-CSS-rule find of the audit so far.** Live-measured both: the panel's active tab (`.tab.on` in `audi-dashboard-ios.css`) gets a background pill behind icon+label (`rgba(255,255,255,.14)` dark / `rgba(0,0,0,.07)` light, `border-radius:14px`, text color `var(--fg)` — no red anywhere). `design-system`'s `TabBar.css` `.ads-tab--on` instead used `border-top-color: var(--red)` — a **red top border**, a completely different mechanism, and a violation of this project's own long-standing "red is accent/destructive only, never a navigation state" rule (the same rule that removed red from the back arrow, primary buttons, and the Arbeitsweg pill in 2026-08-13/17) that `design-system` was simply never updated to follow. Fixed directly in `design-system` (same reasoning as the `.ads-title` fix earlier — this is a plain oversight, not a deliberate fork like the eyebrow case): removed the red border-top mechanism entirely, added the measured background-pill treatment with a light/dark override (`[data-theme="tag"]`). Rebuilt the package; verified live (`background-color: rgba(0,0,0,.07)`, `border-radius: 14px`) and via screenshot — grey pill behind the active tab, no red.
**Fourth reported round, same session — four more items, three real bugs and one clarification:**
1. **Grey card backgrounds where the panel has none.** The RS4/Kennzeichen block and the Reichweite tile were both plain `<Tile>` (default variant, `background: var(--tile)`) — but their panel counterparts are both `class="tile flat"` (confirmed live: `background-color: rgba(0,0,0,0)` on all three `.tile.flat` instances on this screen). `design-system`'s `Tile` already has a `variant="flat"` that does exactly this and was simply never used here. Fixed: `Uebersicht.tsx`'s Reichweite `<Tile>` → `<Tile variant="flat">`; the vehicle-image block's own wrapper (`.dm-szene`) had its `background: var(--tile)` removed directly in `screens.css` (it isn't a `<Tile>` at all, a plain div, so the fix lives in its own class).
2. **Fahrzeugbild too large.** Root cause: `ImagePlaceholder`'s default size is `aspect-ratio: 4/3` — correct for the gallery/wheel-photo usages elsewhere, but the panel's Übersicht image specifically uses `"carfix klein"` (16:8), and live-measured the panel's actual box at only ~120130px tall regardless of width. Rather than changing the shared component's default (would wrongly affect every other correctly-sized usage), scoped an override to `.dm-szene .ads-bildbox { aspect-ratio: 16/8 }` in companion-app's own `screens.css` — same "local override, shared component stays generically correct" pattern used for the font-family and eyebrow fixes.
3. **"Satelliten-Icon fehlerhaft" — investigated a third time, including now with the owner's own screenshot as a reference, still not reproduced.** Re-diffed `outerHTML`, then went one level deeper than the earlier pass and checked actual *computed* paint values on the live path element: `fill: rgb(16,20,24)` (correctly resolves the day-theme icon color), `fill-rule: nonzero` (default, same as the panel — no override exists on either side), `stroke: none`. Every measurable value matches. The icon (`mapLayer`, a 3-band stacked-diamond glyph) is genuinely hard to read at 22px in plain monochrome, which may be exactly what looks "off" in a screenshot — but that ambiguity is identical in both apps, not a companion-app defect, as far as three independent checks (markup, function, paint) can tell. Left as an open question back to the owner rather than guessed at further.
4. **"Standort Slide Up ist leer" — a real bug, found via DOM inspection, not a data/content problem.** The sheet's own content was always complete and correct (address, status, tank/range, both action buttons all present and populated in the DOM even while "empty"-looking) — confirmed directly: `.dm-standortmenu`'s measured `transform: translateY(147px)` against its own `243px` height correctly implements the 96px peek reveal, exactly as designed. The actual bug was one level up: `.dm-standort-vollbild` was capped at `height: min(640px, calc(100dvh - 190px))` (copied verbatim from the panel, correct *there* because the panel's whole app is itself boxed into an ~880px-tall card) — but companion-app's page has no such outer cap, so on a taller viewport the capped map+sheet left a large stretch of plain page background below it, the same canvas color as the (correctly) mostly-hidden sheet — reading as one big empty area. Fixed by making `.dm-standort-vollbild` fill the remaining flex space instead of capping (`.dm-inhalt > .dm-standort-vollbild { flex: 1 1 auto }`, overriding rahmen.css's blanket `.dm-inhalt > *{flex:none}` for this one screen specifically — the only screen whose entire content is one full-bleed interactive area with nothing else below it). Verified live: the map+sheet now fill the full screen down to the tab bar, no leftover blank strip, on the same viewport that showed the bug before.
Verified (this round): `npm run typecheck` clean, `npm test` 147/147, live-reconfirmed via reload + screenshot for all three real fixes (flat tiles transparent, image band compact, Standort screen fills to the tab bar). No panel-side changes this round — all four items were companion-app-only bugs, not places where the panel and app had made a deliberate, disclosed different choice.
**"Diese App ist älter als der Server — bitte aktualisieren" explained (owner asked directly):** this is the app's own compiled-in version (`__APP_VERSION__`, baked in from `manifest.json` at `vite build`/`vite dev` startup) compared against what the backend currently reports (`sensor.audi_dashboard_app_version`) — see `VERSIONIERUNG.md`. It's doing its job correctly right now: this session bumped the manifest to `2026.8.30.1` for the panel-side icon fix without restarting the *dev server process* (only reloading the page), so the running dev server is still serving the previous version's compiled-in number against a backend that has since moved on. Not a bug — the banner is the intended, working signal for exactly this situation, and would clear on the next full dev-server restart (or, in the real native app, the next OTA/Xcode rebuild).
**Next planned step, still open**: continue the systematic Shell.tsx/shared-chrome sweep the owner asked for, screen by screen, now informed by the pattern this round has repeatedly found — check every `<Tile>` variant choice (flat vs. default) and every locally-sized image/icon against the panel's live DOM, not just structure/logic.
## The single biggest find of the whole audit: `design-system`'s color/radius tokens were read from the wrong stylesheet (2026-08-30)
Owner, after several rounds of individually-fixed bugs: **"Font, Schriftgröße, Formatierung, Zeilenabstand, etc. sind noch wesentlich abweichend. Prüfe das immer mit."** — do a real typography sweep, not just background/size spot-fixes, and make it standing practice from here on.
Ran a systematic computed-style diff (font-family/size/weight/line-height/letter-spacing/color) across every text element on Übersicht, panel vs. companion-app, live. Two things fell out immediately:
1. **The `--fg2`/`--fg3` colors this file's very first color-token comparison (chapter 1) called "match exactly" don't, in practice — because that comparison only ever read `:host`/`:host([data-theme=...])` in the *base* `audi-dashboard.css`.** The panel **always** loads a second stylesheet on top, `audi-dashboard-ios.css`, which redefines nearly the entire palette (confirmed live via `getComputedStyle` on the real panel's `:host` — not by reading source, since two stylesheets cascading needed a live answer, not a static one). The base-file values were never actually reaching the screen in the real app; `design-system` had been quietly built against a dead reference the whole time. Corrected `design-system/src/tokens/tokens.css` to the actual live values for both themes:
| Token | Old (base-file) | Now (live iOS-overlay value) |
|---|---|---|
| `--canvas` (nacht/tag) | `#161b23` / `#FFFFFF` | `#0C1014` / `#F2F2F7` |
| `--tile` | `#1f2733` / `#f2f2f2` | `rgba(255,255,255,.055)` / `#FFFFFF` |
| `--tile-2` | `#2a3341` / `#e5e5e5` | `rgba(255,255,255,.10)` / `#EFEFF4` |
| `--line` / `--line-strong` | grey-based rgba | `rgba(255,255,255,…)` / `rgba(60,60,67,…)` (iOS separator convention) |
| `--fg2` | `#9aa1ad` / `#4c4c4c` | `#BFC4CC` / `#3C3C43` |
| `--fg3` | `#8a94a3` / `#666666` | `#8E8E93` / `#8E8E93` (same both themes — iOS `tertiaryLabel`) |
| `--ok`/`--warn`/`--bad` | Android-Material-ish greens/reds | iOS `systemGreen`/`systemOrange`/`systemRed` |
| `--r-tile` | `20px` | `16px` |
This single fix touches nearly every screen at once — every tile background, every secondary/tertiary text color, every corner radius in the app was drawn from the wrong palette. It's almost certainly the largest single contributor to "sieht insgesamt anders aus" of anything found this session, bigger than the individual per-element fixes around it. Also corrected in the same pass: `.ads-title` `letter-spacing` (`-.015em` → `-.01em`) and `line-height` (`1.15` → `1.2`), both live-measured against the real `.title`.
2. **The badge "zusatz" text (e.g. "Avant Competition" next to the model badge) was rendering at 22px/black/inherited-title styling — the panel's is 13px/`var(--fg2)`/plain.** Root cause in `Uebersicht.tsx`: the wrapping `<div>` incorrectly carried the `ads-title` class (only needed for the no-badge-detected text fallback path, but applied unconditionally), and the inner `<span>` used `.ads-sport` (italic, a style meant for something else, not this spot — the panel's `.badge .zusatz` is plain, not italic). Fixed: wrapper is now plain `dm-szene__zusatz` with no `ads-title`; the local `.dm-szene__zusatz` CSS rule (which had its own separate `font-size:19px` override, likely written to visually "match" the wrongly-inherited 22px title rather than the panel) corrected to `font-size:13px; color:var(--fg2); line-height:normal`; the inner span dropped `.ads-sport` entirely.
Rebuilt `design-system`, verified live: `.ads-title` letter-spacing/line-height now `-0.22px`/`26.4px` (byte-match to the panel's measured values), zusatz now `13px`/`rgb(60,60,67)`/non-italic (matches), `.ads-eyebrow` color now resolves to the correct `rgb(142,142,147)` (this was already using `var(--fg3)` from an earlier fix — it was correct code all along, just pointed at a wrong token value until this round). `npm run typecheck` clean, `npm test` 147/147.
**Standing rule from here on, per the owner's explicit instruction:** every remaining screen check must include font-family, font-size, font-weight, line-height, and letter-spacing — not just background/sizing — and should be verified against **live computed styles** on the real panel, not against static CSS source, given source-reading already produced one wrong "verified matching" conclusion this session (the token comparison above).
**Finishing the Übersicht typography pass, same round — three more real bugs, found by owner report and by the same live-computed-style method:**
1. **"87 km falsche Font/Formatierung."** `.ads-fig`'s `letter-spacing` was `-.02em` — copied from the *base* `audi-dashboard.css`'s `.fig`, the same class of mistake as the color tokens above: the iOS overlay overrides it again, separately, to `-.03em` (`audi-dashboard-ios.css: .fig{letter-spacing:-.03em}`, live-confirmed `-1.8px` at 60px = `-.03em`, not the `-1.2px`/`-.02em` companion-app was rendering). Fixed in `design-system/src/components/Fig/Fig.css`. Worth remembering going forward: **any class with a same-name rule in `audi-dashboard-ios.css` needs the overlay's value, not the base file's** — this is now the second time (colors, now letter-spacing) the base file alone gave a wrong answer.
2. **"Nächster Service und 8.600 km sind zu nah zusammen."** Confirmed: the panel's `.fig-zeile` (icon+figure row) has `margin-top:8px` relative to the label above it; companion's equivalent `.dm-servicefig` had none at all — zero gap. Added `margin-top:8px` to `.dm-servicefig`, plus the same margin on `.dm-serviceblock > .ads-fig` for the no-icon fallback case (Hauptuntersuchung), where the panel applies the identical margin directly to the bare `.fig` instead of a wrapper row.
3. **The "Letzte Fahrt"/"Letzte Tankung" teaser tiles, flagged earlier this session as "found but not fixed — check other `NaviKachel` call sites first."** Checked: `.dm-navikachel__zahl` (the 20px/weight-300 "hero number" treatment) turned out to be used **only** at these two call sites in the entire app (`grep` confirmed) — every other `NaviKachel` usage (`MeinAudi.tsx`, `Service.tsx`, `Versicherung.tsx`) passes a plain string through the ordinary `.dm-navikachel__wert` (untouched, out of scope). Safe to fix directly: the panel's real teaser value (`.leaf .v`/`.leaf .v small`, live-measured) is plain — `15px`/weight `400`/`var(--fg2)` for the value, `12.5px`/`var(--fg3)` for the caption — no Wide font, no hero treatment at all. `.dm-navikachel__zahl` corrected to those exact values; a new, more specific `.dm-navikachel__wert .dm-navikachel__zahl + small` rule added for the caption (a same-specificity `.dm-navikachel__zahl + small` was tried first and silently lost to the pre-existing `.dm-navikachel__wert small` rule later in the file — needed the extra specificity, not just presence, to actually win the cascade).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-reconfirmed for all three (`.ads-fig` letter-spacing `-1.8px`, `.dm-servicefig` margin-top `8px`, teaser value `15px`/`400`/`rgb(60,60,67)` and caption `12.5px`/`rgb(142,142,147)`) — plus a screenshot: visible gap now between "Nächster Service:" and "8.600 km", teaser tiles now read as plain text like the panel instead of oversized figures.
**Actually not quite closed — two more real bugs, found right after "closing" it:**
1. **Dev server itself got into a broken state ("iOS App freezed again")** — not an app bug. After many rapid `design-system` rebuilds + manifest bumps + hot-reloads this session, the Vite dev server's HMR/websocket state had visibly desynced (console showed dead `ws://localhost:5173/` connections and a stale `ReferenceError: StandortVorschau is not defined` that survived plain page reloads). Fixed by a full `preview_stop`/`preview_start` cycle, not just a reload — this also cleared the lingering "Diese App ist älter als der Server" banner from the earlier manifest bump, since the fresh server re-embeds the current version. **Worth remembering for the rest of this audit: a stuck/frozen-looking dev preview after several rebuilds in one session needs a real server restart, a page reload is not enough.**
2. **"RS4 + Kennzeichen not in line on left sided" — real, confirmed via bounding-rect measurement on both apps.** Live-measured the panel: the Kennzeichen text and the "Reichweite" label sit on **exactly the same left edge** (`left:16px` both — both live inside a zero-padding `tile flat`). Companion-app measured three *different* left edges for elements that should share one: Kennzeichen `38px`, "Reichweite" `22px`, "Nächster Service" `44px`. Root cause: `.dm-szene__text` (wrapping the badge/model-name/Kennzeichen block) carried its own `padding: var(--sp-4) var(--sp-4) var(--sp-5)` — a left/right inset the panel's equivalent (a zero-padding flat tile) never has. Fixed: `padding: var(--sp-4) 0 var(--sp-5)` — keeps the vertical spacing (gap under the image, gap before the next tile), drops the horizontal one. Verified live: Kennzeichen and "Reichweite" now both `left:22px` (companion-app's own page-padding equivalent of the panel's `16px`), byte-equal to each other.
**Flagged, not fixed — a bigger, systemic question, out of scope for a quick fix:** "Nächster Service" still sits at `44px`, not matching the `22px` the other two share — because `.ads-tile`'s own internal padding is `var(--sp-5)` (22px) uniformly, while the panel's equivalent `.tile` padding is `16px 18px` (different value, and asymmetric top/bottom vs. left/right). This is a **global** `Tile` padding question (affects every tile on every screen, not just this one), not a one-line local fix like the others in this round — noted here so it isn't lost, not yet acted on.
Verified (this round): `npm run typecheck` clean, `npm test` 147/147, dev server confirmed responsive after restart, alignment fix confirmed via matching bounding-rect `left` values and a screenshot.
**This closes the Übersicht screen for this audit** (owner confirmed via "erst die Übersicht noch zu Ende typografisch durchgehen … go for it"). Every text element on it has now been individually live-diffed against the panel at least once. Next: Mein Audi, same method (structure **and** full typography from the start this time, not retrofitted after the fact) — and now also carrying forward the open `Tile`-padding question above, worth checking again on the next screen before deciding whether it's a real systemic fix or specific to this one spot.
## Chapter 3 addendum: `Reifen.tsx` was structurally wrong, not just styled differently (2026-08-30)
Owner, mid-Chapter-5: "reifen sieht völlig unterschiedlich aus und passt nicht zu meiner identisch
vorgabe." Investigated `vReifen()` (panel, line 2924) against `Reifen.tsx` and found this screen had
never actually been brought in line — Chapter 3's entry above only added the two missing edit
features (Anzugsmoment/km pencils) onto the *old* structure, never checked the structure itself
against the panel.
**Real structural differences, all fixed:**
1. Panel shows **both** Sommerräder and Winterräder tiles simultaneously, always — companion-app used
a `Seg` toggle to show only one at a time. Removed `Seg`; both `satzTile("sommer")`/
`satzTile("winter")` now render unconditionally.
2. Panel has Anzugsmoment as its own top tile (eyebrow + big `.fig` value + gear-style pencil) — this
was already right from the earlier Chapter-3 pass, kept.
3. **Wechseltermin was previously folded into a combined "Montiert" tile as a read-only
`Wertzeile` + a plain date `<Feld>`** with no "Termin vereinbart" toggle at all. Rebuilt as its own
tile matching `vReifen()`'s: `Geplant am` date field, a `Switch` for `Termin vereinbart` (new —
didn't exist here before), and "In den Kalender übernehmen" — mirroring the panel's own quirk that
this one date/checkbox pair only actually saves when that button is clicked (`kalenderUebernehmen()`
saves via `profilSpeichern` and immediately downloads the `.ics`, matching `data-ics="reifen"`'s
`$("#rfDat")`/`$("#rfOk")` read at click time).
4. **Each satz tile's Hersteller/Modell/Größe/DOT/Solldruck/Kommentar fields were entirely read-only**
(`Werteliste`/`Wertzeile`) — a real functional gap, not just a display difference: there was no way
to edit a tire set's own data at all (only the archived sets, via `ArchivZeile`, were editable).
Panel's `data-rf` fields save live on `change`. Ported the same immediate-save behavior via
`onBlur` (React's closest equivalent to native `change` for text inputs) — `feldSpeichern()`, one
`profilSpeichern` call per field per satz.
5. Layout: `.radkopf-row` (headline+km left, wheel photo right, both top-aligned) — new
`.dm-radkopf-row`/`.dm-radkopf`/`.dm-tile-headline`/`.dm-reifenzeile`/`.dm-radfoto` CSS in
`screens.css`, live-measured off the panel's own `.radkopf-row`/`.tile-headline`/`.fig`/`.km-edit`
rules. `.dm-montiert`/`.dm-montiert--an`/`.dm-archivbtn` similarly copied from `.montiert`/
`.montiert.on`/`.archivbtn` (outline pill, filled black/canvas when the satz is the active one — was
previously a single "Auf X wechseln" `ActionButton`, not a per-satz clickable pill).
6. `montierenAuf()` (the per-satz "Montiert" pill) calls `reifen_wechseln` **without a confirmation
dialog** — the panel's own `data-satz` handler has none (`bestaetigen()` is only used for
*archiving*, not for switching); the old `wechseln()` function had one, removed to match exactly.
7. Kommentar field made full-width (label above, not beside) after a live check showed "Eingelagert
beim Autohaus" truncating in the label-beside-input layout — same `.feld--breit`/bypassed-`Feld`
pattern already established in `Versicherung.tsx`'s Vertragsnotiz.
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — both tire sets render simultaneously with real data (Sommerräder: 275/30 R20 97Y XL,
2,5/2,4 bar; Winterräder: Michelin Pilot Alpin 5, 265/35 R19, 3822, 2,65/2,5 bar, "Eingelagert beim
Autohaus" — none of this was previously visible or editable), Montiert pills correctly filled/outline
per active satz, Archiv section unaffected.
## Chapter 5 (Sicherheit) closed: three confirmed issues fixed in `Fahrzeugstatus.tsx`, plus a real
structural bug in `TuerenKlappen.tsx` found while verifying it (2026-08-30)
Continuing the audit resumed after the Reifen interruption above. `Fahrzeugstatus.tsx` vs. the
panel's `vSicherheit()` (line 1541) had three confirmed-but-unfixed issues from the prior pass, now
fixed:
1. **Missing "Draufsicht" top-view image** — added at the top via the existing `.dm-szene` wrapper
(same 16:8 treatment already established for Übersicht's vehicle image), reading
`"draufsicht.webp"` — which itself needed adding to `bilder.ts`'s `WEITERE_BILDER` list first
(confirmed via the backend's `bilder.py` that this filename is a real, recognized upload slot;
companion-app's own list was simply missing it).
2. **Wrong top-status indicator** — the top summary box used `<StatusRow>` (a plain colored dot),
inconsistent with both the panel (which reuses its checkmark-circle `statusKreis()` for the
summary too) and with the four detail rows below it (which already correctly use `<StatusKreis>`).
Replaced with a plain `<Tile>` + `<StatusKreis ok={gesichert}>` + text span, matching the panel's
`style="display:flex;align-items:center;gap:12px;padding:16px 20px"` box exactly — new
`.dm-sicherheitssammel` CSS in `screens.css`.
3. **Extra subtitle not in the panel** — the `statusStand`/timestamp line under the summary text was
dropped entirely (the panel's equivalent box has no such line); the now-unused `statusStand`
import was removed from the component.
**Found while verifying — `TuerenKlappen.tsx` didn't match its panel counterpart `vTuerenKlappen()`
(line 1576) either**, a real structural gap beyond a quick styling pass: it used a bespoke
`.dm-pruefliste`/`.dm-punkt` list (dot-before-label, single-column) instead of the established
`.dm-wertzeile` dt/dd row (label left at full strength, dot+status right in the secondary tone) every
other detail row in the app uses — and the panel's `vTuerenKlappen()` deliberately uses the *plain*
dot (`.dot`, not the checkmark circle from `vSicherheit()`) for this specific detail list. Rebuilt to
reuse `.dm-wertzeile` + `@audi-dash/ui`'s own `.ads-dot`/`.ads-dot--ok/--warn/--bad` (11px, byte-match
to the panel's `.dot`), removing the now-orphaned `.dm-pruefliste`/`.dm-punkt` CSS entirely (grepped
first — the only other reference was one test assertion, updated to check `.dm-wertzeile` instead).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — Draufsicht placeholder renders at the top, the summary box shows the green
checkmark-circle + "Fahrzeug ist sicher abgestellt" with no timestamp line, all four detail rows show
checkmark-circles with only "Türen und Klappen geschlossen" carrying a chevron, and its detail page
now shows the plain colored dot beside "zu"/"offen"/"unbekannt" on the right with the door/Kofferraum/
Motorhaube label at full strength on the left — matching `vTuerenKlappen()` exactly.
**Chapter 5 is closed.** Next: Chapter 6 (Fahrten) at the same depth, per the owner's "1" choice.
## Chapter 6 (Fahrten): mostly already correct, three real gaps found and fixed (2026-08-30)
`Fahrten.tsx`/`FahrtDetail.tsx`/`FahrtFelder.tsx` were largely already in good shape (the fields, the
edit-toggle idiom, the "Verbrauch" row, the Art `Pill` colors were all already right from earlier
work this session/project). Three real, fixable gaps found by diffing against `vTrips()`/`vTrip()`/
`fahrtFelder()`/`initMap()`:
1. **The trip list rows never showed the route at all.** `vTrips()`'s primary line per row is
`strecke(t)` — "Start → Ziel", one side if only one is known, or "Fahrt ohne Ortsangabe" — with
date+Art as the small caption. `Fahrten.tsx`'s rows showed only the km figure and a date/time/
duration caption; the actual journey (the single most identifying fact about a trip) was nowhere
in the list, only reachable by opening each trip individually. Rather than inverting the row's own
established primary/secondary convention (this codebase consistently makes the *value* the row's
`titel` across every list screen — Tanken.tsx included — even though the panel itself isn't
consistent about it either: `vTrips()` leads with route, `vTanken()`'s tank list leads with date),
added a `strecke()` helper (same three-case logic) and prepended it to the existing `unter` caption
line, closing the actual missing-information gap without fighting the app's own established idiom.
2. **`.dm-detail__zahl` was 40px everywhere, including `FahrtDetail.tsx`/`TankDetail.tsx`.** The panel
draws the hero figure at 52px specifically for `vTrip()`'s distance and the tank-detail's liters
(confirmed via grep: `style="font-size:52px"` at both call sites, vs. 40px/46px/34px elsewhere) —
40px is correct for the *other* detail screens (Reifen archive, Vertrag, ...) that share the same
class, so the fix is a new `.dm-detail__zahl--gross` modifier applied only to those two screens,
not a change to the shared default.
3. **`Karte.tsx`'s start and end markers were both plain red circles**, visually indistinguishable —
the panel's `initMap()` draws Start as a black-filled, white-ringed circle and Ziel in red
specifically so the two ends read differently at a glance; also added the small intermediate
red dots along a real GPS route (`route.slice(1,-1)`) that the panel draws and companion-app
didn't, and matched the polyline's `weight:2.5`/`opacity:0.9` (was `weight:2`, no opacity).
**Checked and left alone, deliberately:** `LiveFahrt.tsx` has no panel counterpart at all (no
`vLiveFahrt()` exists in the panel — this is a companion-app-only screen, explicitly marked in its own
header comment as built-but-disabled pending the FMM003 hardware), so there's nothing to diff it
against; the Art pill on `FahrtDetail.tsx` is decorative-only (no click-to-toggle like the panel's
`data-art` button) — capability still exists via the "Bearbeiten" form, an already-established
edit-toggle-vs-inline idiom difference, not a gap.
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — the trip list now shows "Ingolstadt → Nürnberg" / "Fahrt ohne Ortsangabe" per row, and
the trip detail's distance figure ("95,0 km") renders visibly larger, matching the panel's 52px hero
treatment. The marker-color fix in `Karte.tsx` is a straightforward style-parameter change, verified
by `tsc`/tests only — this test container's trips have no GPS track to render live.
**Chapter 6 is closed.** Next: Chapter 7 (Tanken) at the same depth.
## Chapter 7 (Tanken): a real string bug, missing accordion detail, and a completely missing
SmartDeal feature plus edit capability — all fixed (2026-08-30)
Diffed `Tanken.tsx`/`TankDetail.tsx` against `vFuel()`/`vFill()`/`tankFelder()`/`tankFormularWerte()`.
1. **Real bug: the plural of "Tankvorgang" was broken.** `` `${de(n)} Tankvorgang${n === 1 ? "" : "̈e"}` `` —
the non-1 branch was a bare combining-diaeresis-plus-"e" (`"̈e"`), not the actual word change
"Tankvorgänge" needs (a→ä, not just an appended suffix — unlike "Fahrt"/"Fahrten", which *is* a
suffix). Rendered as "Tankvorgang" with a stray floating umlaut. Fixed with explicit singular/
plural branches ("1 Tankvorgang" / "N Tankvorgänge").
2. **Year/month accordion headers showed only the cost total — the panel's secondary line (liter sum
+ average price for the year, average price for the month) and the SmartDeal-Ersparnis pill were
entirely absent.** `Accordion`'s `summary` prop already accepts a `ReactNode` (confirmed by reading
the component), so this needed no new component — a multi-line flex-column summary matching
`vFuel()`'s own markup byte-for-byte (17px/`var(--fg)` cost, plain secondary line, pill only when
`CAR.smartdeal.aktiv` and the period's discount sum is positive).
3. **SmartDeal was entirely unimplemented in this screen** — grepped the whole `Tanken.tsx`/
`TankDetail.tsx` for "smartdeal"/"discount"/"markenlogo" and found only `TankDetail.tsx`'s existing
read-only "Ersparnis" row (already correct, pre-existing). Everything else was missing: no brand-logo
image, no way to *enter* an Ersparnis value anywhere, no Kraftstoff selector in either form (the
panel's `KRAFTSTOFFSORTEN` list — `["V-Power Racing", "Super Plus", "Super"]` — had no companion-app
equivalent at all). Added `KRAFTSTOFFSORTEN` as a shared export in `bausteine.tsx` (used by both
`Tanken.tsx` and `TankDetail.tsx`), a `markenlogoUrl()` helper in `bilder.ts` pointing at the panel's
own bundled asset (`custom_components/audi_dashboard/frontend/bilder/shell-logo.svg`, served at
`/audi_dashboard_static/bilder/...` — a genuinely different static path than `/local/bilder/`'s
user-uploaded vehicle photos, confirmed by reading `const.py`'s `STATIK_URL` vs `BILDER_ORDNER`), and
wired a `<Pill>` with a graceful `onError`-hidden `<img>` for the year summary (matches the panel's own
`onerror="this.closest(...).classList.add('logo-fehlt')"` fallback intent — no broken-image icon,
just the text). Kraftstoff + a conditional (`fahrzeug.smartdeal.aktiv`) SmartDeal-Ersparnis field
added to `TankFormular` (new-entry form), both already supported by the API layer's
`TankvorgangFelder` (`kraftstoff`/`ersparnis` fields existed, just never surfaced in any form).
4. **`TankDetail.tsx` had no editing capability at all** — no "Bearbeiten" button, no edit form,
unlike `FahrtDetail.tsx`'s already-existing `FahrtBearbeiten`. The only way to correct a tank
entry's values was re-uploading a receipt. Built `TankBearbeiten`, mirroring `FahrtBearbeiten`'s own
idiom exactly (local draft state pre-filled from the record, `api.tankvorgangAktualisieren`,
Speichern/Abbrechen) with the full field set from `tankFelder()`: Zeitpunkt, Kilometerstand,
Gefahrene Distanz, Kraftstoff, Getankte Liter, Kosten, SmartDeal-Ersparnis (conditional), Tankstelle.
**Checked and left as an accepted idiom difference, consistent with Chapter 6's own finding:** the list
row's primary/secondary field choice (value-primary here, matching `Tanken.tsx`'s and `Fahrten.tsx`'s
own established, consistent convention) vs. the panel's date-primary — station name is already present
in the row's caption, so no information is missing, only reordered; and no page-level delete button on
`TankDetail.tsx` (delete already reachable via the list's `Zeilenmenue`, matching the same precedent
already accepted for `FahrtDetail.tsx` in Chapter 6).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — "13 Tankvorgänge" (was garbled), year/month accordions show the cost+liter/price
secondary line and a "29,76 €"/"12,90 €"/"20,36 €" SmartDeal pill per year (image gracefully hidden
in this dev environment since `/audi_dashboard_static` isn't proxied — text still shows, confirming the
fallback works), the tank detail's liter figure renders at the corrected 52px, and the new "Bearbeiten"
form opens fully pre-filled with real values (Kraftstoff "V-Power Racing", 42.13 l, 92.6 €, "Shell,
SÖLDEN", SmartDeal-Ersparnis field present) and "Abbrechen" correctly discards without persisting.
**Chapter 7 is closed.** Next: Chapter 8 (Statistik) at the same depth.
## Chapter 8 (Statistik): one real typography bug found and fixed (2026-08-30)
`Statistik.tsx` was already structurally solid — `StatGrid`/`Fig`/`ProgressBar` map cleanly onto
`quad()`/`.fig`/`.bar`/`.legend`, and the "Art der Fahrten" red-segment bug was already fixed earlier
this session (section Y). One real mismatch found by diffing computed styles against the panel's CSS:
- **`@audi-dash/ui`'s `StatGrid` caption (`.ads-quad__l`) used the old, pre-iOS-decision uppercase-
eyebrow style** (9px, `text-transform:uppercase`, `letter-spacing:.12em`) — the panel's `.quad .l`
is plain small grey text (11px, no letter-spacing, no uppercase). Same root cause as the
already-fixed `.ads-eyebrow` (design-system ships the pre-2026-08-13 Audi-CI convention on
purpose, for its other, brand-free audience; this app's own "match the iOS-look panel exactly"
decision needs a local override) — added right next to `.ads-eyebrow`'s own override in
`audi-schrift.css`, same reasoning, same file.
**Checked and left alone, deliberately:** the panel's `statTile()` header is collapsible
(`aria-expanded`, tap to reveal the body) with a secondary "extra" value shown next to the big figure
in the collapsed state; companion-app's tiles are always fully expanded, so there's no collapsed state
to preview into, and the same numbers are already visible in the `StatGrid` below — not a content gap,
an already-established "friendlier default" idiom difference (same category as this app's non-hiding
list rows elsewhere), left as-is rather than retrofitting an accordion onto five already-substantial
tiles. Also noticed live: "Strecke bis zum letzten Tankstop" showed a negative km value in this test
container's data — traced to `langzeitverbrauch()` in `daten/statistik.ts`, which is a byte-faithful
port of the panel's own `langzeitverbrauch()` (identical unguarded `mitOdo[last].odometer_km -
mitOdo[0].odometer_km` subtraction) — the panel would show the identical negative number against this
same underlying stray-odometer test data (the same class of issue already documented at length in
section AB item 3 for the Tanken form). Not a companion-app-specific defect and not fixed here, since
"fixing" only this side would make the two diverge instead of match.
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — the quad captions ("Jahr"/"Monat"/"Woche"/"Tag") now render at 11px, sentence case, no
letter-spacing, matching the panel exactly.
**Chapter 8 is closed.** Next: Chapter 9 (Batterie) at the same depth.
## Chapter 9 (Batterie): the voltage chart used a flat red line/dots instead of the panel's
health-color-coded points (2026-08-30)
`Batterie.tsx`/`BatterieListe.tsx` were already extensively parity-worked earlier this session
(sections R/S/T/X) — the fixed 1015V axis, the dynamic per-pixel viewBox, the min-only dots (no
separate max line), the tap-for-tooltip behavior, the `AGM_RUHE_MAX_V=13.0` classification threshold
and the `LADEKURVE` SOC table were all already byte-matched to the panel. One real mismatch remained,
found by reading `bvZeichnen()`'s actual drawing code line by line: the panel's line and dots are
**not** drawn in red at all — the polyline is `var(--fg)` (neutral), and each dot is colored by
`bvFarbe()`, a red→yellow→green gradient computed from the voltage itself (`≤11.6V` = bad, `11.612.2V`
= bad→warn blend, `12.212.8V` = warn→ok blend, `>12.8V` = ok), reading the actual `--bad`/`--warn`/
`--ok` CSS custom properties and RGB-interpolating between them (`bvHexZuRgb()`/`bvMischen()`/
`bvFarbstufen()`). Companion-app's `Verlaufsdiagramm` instead drew the line **and every dot** in a flat
`var(--red)` — a real violation of this project's own long-standing "red is accent/destructive only,
never a plain data color" rule (the same category as the Statistik/TabBar red-color findings earlier
this session), just never caught because nothing had diffed the actual SVG drawing code against the
panel's before. Ported the identical hex→RGB / linear-mix / threshold logic (`hexZuRgb`/`mischen`/
`farbstufenLesen`/`farbeFuerSpannung` in `Batterie.tsx`), reading the tokens live via
`getComputedStyle(document.documentElement)` (with a hardcoded fallback triple per color, for the
jsdom test environment where the real stylesheet isn't loaded) — the line switched to `var(--fg)`, each
circle now gets its own computed health color instead of the fixed red.
**Checked and left alone:** `BatterieListe.tsx`'s row layout, colors, and the "Generatorspannung" pill
threshold were already correct (verified earlier this session, section AA/S) — re-diffed against
`vBatterieliste()`'s actual markup this round and confirmed still byte-matching (k/v→titel/neben/unter
value-primary idiom, same accepted pattern as Fahrten/Tanken).
Verified: `npm run typecheck` clean, `npm test` 147/147, live-verified through the dev proxy against
`audi_ha_test` — the chart line now renders black/neutral (not red) and the dots render in their
health-mapped color (a yellow/warn-range dot confirmed at a real ~12.212.4V data point in this test
container's history), matching the panel's intent exactly.
**Chapter 9 is closed.** Next: Chapter 10 (Einstellungen/Datensatz) at the same depth.
## Chapter 10 (Einstellungen/Datensatz): four app-wide `@audi-dash/ui` bugs found — the single
biggest-impact chapter of the whole audit (2026-08-30)
Diffed `Einstellungen.tsx` against `vEinst()`. `Einrichtung.tsx` (server-address/token onboarding) has
no panel counterpart at all — a companion-app-only concept, correctly out of scope. Three real content
gaps in `Einstellungen.tsx` itself, all fixed:
1. **No "Backup importieren" — a real, one-directional data-loss risk.** `profilExportieren()` could
download a combined `{profil, fahrten, tankvorgaenge}` JSON backup, but nothing could read one back
in — the panel's `data-backup="import"` handler (→ the `backup_wiederherstellen` service) had no
companion-app counterpart at all. Built `backupImportieren()` (hidden file input, `FileReader`,
`JSON.parse`, validates `profil` is present, calls the same service directly via
`api.rest.dienstAufrufen` matching this file's own existing "Jetzt sichern" call style, then
`neuLaden()`), wired as a third button ("Von diesem Gerät einspielen") next to "Auf dieses Gerät
laden".
2. **No "Version" tile at all** — the panel's `vEinst()` shows the backend's own installed
`APP_VERSION` ("Installiert: ..."). companion-app fetches this exact value already
(`appVersionAngabeLesen()`'s `.app` field, stored as local `serverVersion` state in
`DatenKontext.tsx`) but only ever exposed the *comparison result* (`versionsstand`) through context
— the raw string was fetched and then discarded. Added `serverVersion` to `DatenWert`/the context
value, and a "Version" tile in `Einstellungen.tsx` right before "Integration-Update" (matching the
panel's tile order).
3. **SmartDeal tile was structurally simplified in two ways that lost information/behavior**: no brand
logo next to the label (panel: `<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}">` before "SmartDeal
aktiv"), and "Gültig bis" shown unconditionally instead of only when active (panel: "Ersparnis und
Bildmarke werden ausgeblendet" text when off). New `markenlogoUrl()` in `bilder.ts` (points at the
panel's own bundled `shell-logo.svg`, served under the integration's `/audi_dashboard_static/` path
— a genuinely different static route than `/local/bilder/`'s user-uploaded vehicle photos, confirmed
via `const.py`'s `STATIK_URL` vs `BILDER_ORDNER`), with the same graceful `onError`-hide fallback
used for the Tanken-chapter SmartDeal pill. `Feld`'s `label` prop already accepts a `ReactNode`, so
no new component was needed for the inline icon.
**Then, live-verifying those three fixes surfaced something much bigger: `@audi-dash/ui`'s shared
`Switch`, `Seg`, and `Feld` components were wrong in ways that affect every screen in the app that uses
them — not just Einstellungen.** Found by literally looking at the rendered page rather than only
reading JSX:
4. **Every toggle switch in the entire app was red instead of green.** `Switch.css`'s checked state
used `background: var(--red)` — a real "red as a non-destructive state color" violation, the same
category already fixed for the TabBar active pill, the Statistik Arbeitsweg bar segment, and the
back arrow/primary buttons earlier this project, just never caught here because nothing had diffed
`@audi-dash/ui`'s own component CSS against the panel's `.switch input:checked+.track` rule before.
Fixed to `var(--ok)`, matching `audi-dashboard-ios.css` exactly. This one rule affects literally
every switch in the app: "Beschriftung der Tab-Leiste", "Termin vereinbart" (Reifen, built this
session), SmartDeal, etc.
5. **`Seg` (the Nacht/Tag segmented control, used only here so far) drew the active segment with a red
outline ring and rendered all labels uppercase with heavy letter-spacing** — again the pre-iOS-
decision neutral style, same root cause as `.ads-eyebrow`/`.ads-quad__l` earlier this session, just
never carried over to `Seg`. Panel's actual `.seg`/`.seg button`/`.seg button.on` (always the
iOS-overlay values, confirmed live-measuring the token colors) is a filled track with a moving
pill (`background:var(--tile-2)`, `box-shadow`), no red anywhere, sentence case, no letter-spacing.
Rebuilt `Seg.css` to match exactly, and added a new `--ios-fill` token to `tokens.css` (both
themes, live-measured `rgba(120,120,128,.24)` dark / `.16` light) since the track background needed
it and `Feld`'s input boxes turned out to need the identical token (see next point) — a real,
reusable value, not a one-off hardcode.
6. **`Feld` (used by nearly every edit form in the app) had four separate mismatches, only found by
testing a genuinely long label live** ("Pause zwischen zwei Fahrten" collapsed to a stack of
single-word lines squeezing the input to nothing — a real, user-visible layout bug, not just a
color drift):
- `.ads-feld label` was 13px/`var(--fg2)` (the same pre-iOS-decision eyebrow style, again) instead
of the panel's 16px/`var(--fg)` — already fixed once for the read-only `Wertzeile` component in
the MeinAudi chapter, but never carried over to the editable-field component, despite `Feld` being
used far more often.
- The label also lacked `flex: 1 1 auto; min-width: 0` (present in the panel's *base* stylesheet,
not overridden by the iOS overlay, so still in force) — without it, the label competed for space
with the input instead of taking the row's remaining width and wrapping there.
- `.ads-feld input,select` had a hardcoded `min-width: 132px` floor with no matching `flex`/
`max-width` — the panel's own base rule is `min-width:0; max-width:100%; flex:0 1 auto`, letting
the input shrink to make room for the label. The 132px floor was exactly what starved "Pause
zwischen zwei Fahrten" down to 0px width.
- `.ads-mit-einheit input` (the compact number-plus-unit pattern — km/Nm/Minuten fields) had no
explicit `width`, only `min-width: 92px` — the panel's `.mitEinheit input` sets `width: 96px;
min-width: 0` explicitly; without a real width, the browser's unconstrained default input size
(~170190px) dominated, which is what actually produced the "Pause" field's collapse (measured
live: the input+unit group alone consumed 274 of the row's 279px before the fix).
- Also renamed the "Pause zwischen zwei Fahrten"/"Fahrten" label and tile eyebrow to the panel's
actual wording ("Pause bis"/"Fahrten zusammenfassen") while fixing this, since the original
companion-app phrasing was what triggered the multi-line wrap in the first place and the panel's
shorter wording is the actual target text anyway.
**Checked and left alone, deliberately:** the Setup-menu sensor-mapping UI (`vEinst()`'s "Setup —
Sensoren zuordnen" and the whole entity-catalog popup machinery) is an HA-panel-only concept with no
companion-app equivalent needed, per the parity rule's own established carve-out for this exact screen
(sections R/AF); "Nacht beginnt"/"Nacht endet" being editable in companion-app but not in the panel is
a companion-app-only *addition* (the `nacht_von`/`nacht_bis` profile fields exist and are read by both
apps' Statistik screens, but the panel's `vEinst()` never exposed an edit UI for them at all) — additive,
not a gap to remove.
Verified: `npm run typecheck` clean, `npm test` 147/147 at every step, `design-system`'s `npm run build`
rerun after each CSS change, live-verified through the dev proxy against `audi_ha_test` via computed-
style JS queries and screenshots (the Browser pane went in and out of a "hidden" state mid-chapter,
worth remembering: `get_page_text`/`javascript_tool` computed-style queries keep working even when
`computer` screenshots time out) — confirmed the Switch track computes to the real `--ok` RGB, the Seg
active pill's background/box-shadow/color match the panel's token values exactly with no red, the Feld
label renders at 16px/black, the Feld input box uses the real `--ios-fill` RGBA, and — after the two
follow-up width fixes — "Pause bis" renders on a single line with a compact 96px-wide "15" box, matching
`audi-dashboard-ios.css`'s actual rendered layout byte-for-byte instead of just close-enough.
**Chapter 10 is closed.** Next: Chapter 11 (Abschluss) — build, version bump, `npm run ota`, final
report.
## Chapter 11 (Abschluss): build, version bump, OTA rebuild, deployment, and the audit's own summary
(2026-08-30)
**Build/version/OTA:** `npm run build` (production `vite build`) succeeded clean. Manifest bumped
`2026.8.30.1` → `2026.8.30.2` (companion-app-only round, no panel-side source changed this whole
11-chapter audit). `npm run ota` rebuilt the bundle from the new manifest version automatically:
`bundle.zip` 248,241 bytes, sha256 `88922cb0e3a7603b6e4e2fe7daee85513ec9e16c4ae7b2e0229f514bde58915f`.
**Deployment hit a real, worth-remembering `docker cp` gotcha, distinct from the already-documented
Git-Bash path-mangling one.** After correcting the path issue (`MSYS_NO_PATHCONV=1`, per section AB's
existing note), the manifest inside the container still read the old version. Root cause:
`docker cp local_dir container:/existing/dir` does **not** overwrite the contents of an
already-existing destination directory — it copies `local_dir` *into* it as a nested subdirectory
(`/config/custom_components/audi_dashboard/audi_dashboard/...`), leaving the real top-level
`manifest.json` untouched. Confirmed by listing the container's folder and finding the stray nested
copy. Fixed by removing the nested duplicate and re-copying with a trailing `/.` on the source
(`docker cp local_dir/. container:/dest_dir`, the standard "copy contents, not the folder itself"
idiom) — this is the general rule going forward for this project: **`docker cp` into an existing
integration folder always needs the trailing `/.` on the source**, not just the `MSYS_NO_PATHCONV=1`
env var. Also cleared `__pycache__/*.pyc` before the final restart as a defensive measure (not
strictly proven necessary this round, but cheap insurance against stale bytecode). Verified via
`docker logs --tail 15`: single `audi_dashboard` loader warning, `Audi Dashboard 2026.8.30.2
eingerichtet`, no traceback.
### Full recap of this audit (chapters 111, one continuous pass, 2026-08-30)
Triggered by the owner's report that a build compiled by Xcode still looked stale/inconsistent
against the panel, and the instruction to check *everything* — font, size, weight, line-height,
letter-spacing, color, icon, and structure — not just spot-check. Went screen-group by screen-group:
1. **Übersicht** — wrong color-token source (base stylesheet instead of the always-loaded iOS
overlay, the single biggest individual color fix), `.ads-title`/`.ads-fig` letter-spacing/
line-height, the "Mein Audi" tab icon placeholder, badge-zusatz text styling, Reichweite tile
layout order, teaser-tile hero-figure sizing, alignment, `.dm-servicefig` spacing, the tab bar's
red active-indicator (should be a neutral pill), the Standort feature built from scratch (it
didn't exist in companion-app at all), a `.dm-szene` grey background that shouldn't be there, an
oversized vehicle image aspect ratio, and a Standort-fullscreen height-cap bug.
2. **Mein Audi** — a full structural rebuild (was a static photo grid + placeholder-text tiles;
became a cycling image + five real-data preview tiles), plus the systemic `Wertzeile` dt/dd
color reversal (affects every "field: value" row app-wide).
3. **Service/Reifen** — Service.tsx mostly already correct; Reifen.tsx needed a full structural
rebuild (both tire sets shown at once instead of a Seg toggle, separate Anzugsmoment/Wechseltermin
tiles, previously read-only Hersteller/Modell/Größe/DOT/Solldruck/Kommentar fields made editable
to match the panel's live-save fields) — this was the chapter re-opened mid-audit by a direct
owner complaint ("reifen sieht völlig unterschiedlich aus").
4. **Versicherung/Steuer** — a real `[object Object]` rendering bug in `Schutzbrief()` from three
mis-typed fields, rebuilt against the real nested data shapes.
5. **Sicherheit** — missing Draufsicht image, wrong top-status indicator component, an extra
timestamp line not in the panel, and a `TuerenKlappen.tsx` detail list using the wrong dot
component/row structure.
6. **Fahrten** — trip list rows missing the route text entirely, detail hero figure at the wrong
size, map start/end markers visually indistinguishable (both red instead of black-vs-red).
7. **Tanken** — a real broken-string plural bug ("Tankvorgang" + a stray combining diaeresis),
missing accordion secondary lines, a completely unimplemented SmartDeal feature (logo, Ersparnis
field, Kraftstoff selector), and `TankDetail.tsx` having no editing capability at all.
8. **Statistik** — one real typography bug (`StatGrid` caption using the pre-iOS-decision uppercase
eyebrow style).
9. **Batterie** — the voltage chart's line and dots were flat red instead of the panel's
neutral-line/health-color-coded-dots scheme (a real, if quieter, instance of the same "red as a
non-destructive data color" class of bug found repeatedly this session).
10. **Einstellungen/Datensatz** — three real content gaps (no backup-file import, no installed-version
display, SmartDeal tile missing its logo/conditional field) that, while verifying them live,
surfaced **four separate app-wide bugs in `@audi-dash/ui` itself** (`Switch` red-instead-of-green,
`Seg` red-ring-instead-of-filled-pill, and `Feld`'s label color/size and its input width/flex
handling) — the single highest-leverage fixes of the whole audit, since `Switch` and `Feld` are
used by nearly every edit screen in the app.
**The recurring lesson across all eleven chapters, worth stating plainly for the next session:**
almost every real bug fell into one of three buckets — (a) reading only the *base* panel stylesheet
instead of the always-loaded iOS overlay that overrides most of it, (b) `@audi-dash/ui` still
shipping its pre-2026-08-13 neutral/uppercase/red-accented styling in components nobody had re-diffed
against the panel's actual iOS-decision values, and (c) genuine structural/content gaps where a
companion-app screen was built once, early, and never revisited as the panel kept gaining features.
None of these were caught by `tsc`/`vitest` — every one needed an actual live render, often down to
`getComputedStyle` queries, to surface.
**Known, deliberately-not-fixed items, carried forward for a future session (flagged, not silently
dropped):**
- `.dm-fussnote`'s cross-cutting 35-site typography ambiguity (Service.tsx footnote size/color vs.
its many other uses) — flagged in Chapter 3, never resolved, still open.
- `Versicherung.tsx`'s top-level `vVers()` structure (separate Notruf/Jahresbeitrag-bar/Vertrag
tiles with their own edit gears) was never rebuilt 1:1 — companion-app kept its single-summary-tile
+ `NaviKachel`-grid structure. `Beitrag()`/`Vertrag()`/`Notruf()`/`Steuer()` sub-screens were never
individually re-verified against their panel counterparts this audit.
- `LiveFahrt.tsx` has no panel counterpart to diff against at all (a companion-app-only screen,
explicitly built-but-disabled pending real FMM003 live data) — untouched, correctly out of scope.
- The unreproduced satellite/map-layer-icon complaint from early in this session (checked three
times via markup, function, and computed paint values — never found a defect) remains open pending
a real device screenshot from the owner.
- A `Feld`-label reachability report ("Batteriespannung row not clickable on desktop") from earlier
this project could not be reproduced via any hit-testing method tried — flagged, not resolved.
Verified end to end: `npm run typecheck` clean and `npm test` 147/147 after every single change this
entire audit (never left red), `npm run build` clean, `design-system`'s own `npm run build` rerun
after every CSS-only fix, live-verified through the dev proxy against `audi_ha_test` at real mobile
viewport width for every chapter, manifest bumped to `2026.8.30.2`, OTA bundle rebuilt and matching,
integration redeployed and confirmed via a clean single-warning setup log.
## Chapter 2 (Mein Audi): a structural rebuild, not a styling pass — plus the second-biggest systemic typography find of the audit (2026-08-30)
Owner: "mache mit 2 bis 11 weiter" (continue with chapters 211).
**`MeinAudi.tsx` was not a styling-drift case — it was a fundamentally different, much poorer page than `vAudi()`.** The panel's version: a single cyclable vehicle photo (`carImg("galerie")`, tap-to-advance, dot indicator), a flat badge/Kennzeichen row, then five tiles each showing a **real, multi-row data preview** (Fahrgestellnummer/Details/Erstzulassung/Ausführung/Ausstattung; Kilometerstand/Tankfüllung/Batteriespannung; the actual next-service dates; Versicherung/Steuer/Summe amounts; Reifen montiert/Laufleistung/Wechseltermin) — every tile a button leading to its detail screen, chevron included. companion-app's version: a static grid of *all seven* photo slots (a concept that belongs to Einstellungen → Bild der Übersicht, not here) plus five generic `NaviKachel` "go" tiles, four of which showed **hardcoded placeholder sentences** ("Termine und Wartungsplan", "Beitrag, Vertrag, Kfz-Steuer") instead of any real value. Rebuilt from scratch to match the panel's structure: cycling image + dots (new local state, `BILDPLAETZE` index), flat badge/Kennzeichen tile (same pattern just fixed on Übersicht), and five `Tile variant="button" chevron` tiles each with a real `Werteliste`/`Wertzeile` data preview sourced from the same fields the panel reads (`fahrzeug.fin/details/erstzulassung`, `ausstattung` position count via the same `AusstattungGruppe`/`sonder.length` cast already used in `Fahrzeugdaten.tsx`, `fahrzeug.versicherung["beitrag"]`/`steuer["betrag"/"zeitraum"]`, `fahrzeug.reifen.wechsel`).
**One deliberate, documented scope reduction:** the panel's "Service" tile embeds its *entire* three-term list (Ölwechsel/Inspektion/Hauptuntersuchung, with all the meldung-vs-Servicebuch fallback logic that lives in `Service.tsx`). Reproducing that exact logic a second time here would have meant duplicating a large, delicate block of conditional logic. Used the existing `naechsterService()` instead (the same single-nearest-term function that already feeds the Übersicht tile) — a real, acknowledged simplification, not a silent omission; flagged here and in the component's own header comment. Worth extracting `Service.tsx`'s three-term computation into a shared, reusable function later if the owner wants the full breakdown here too.
**Systemic find, while wiring the new tiles' data rows: `Wertzeile`/`.dm-wertzeile` (`dt`/`dd`, used by every "field: value" row across the *entire* app — `Fahrzeugdaten.tsx`, `Reifen.tsx`, `Versicherung.tsx`, `TankDetail.tsx`, `FahrtDetail.tsx`, now `MeinAudi.tsx`, and more) had its typography backwards.** Live-measured the panel's real `.row dt`/`.row dd` (careful this time to check the iOS-overlay file, not just the base stylesheet, after getting burned by that exact gap twice already this session): **both 16px/weight 400** — `dt` (the field label) in `var(--fg)`, full-strength black/white; `dd` (the actual value) in `var(--fg2)`, the secondary tone. companion-app had it backwards and smaller: `dt` 13px/300/`var(--fg2)`, `dd` 14px/300/unset(≈`fg`) — label small and muted, value slightly bigger and full-strength, the opposite hierarchy from the panel's own convention. Fixed centrally in `.dm-wertzeile dt`/`dd` (screens.css) — one fix, reaches every screen that uses `Wertzeile`, not just this one. Also corrected the `<small>` zusatz caption size (`.dm-wertzeile small`/`.dm-navikachel__wert small`): `11.5px` → `12.5px` (live-measured `.row dd small`), and `.dm-wertzeile`'s own row padding `10px` → `11px` (matching `.row{padding:11px 0}`).
New CSS added for the rebuilt structure: `.dm-galerie-wechsel`/`.dm-galerie-punkte` (the cycling image + dots, mirroring `.dots` 1:1), `.dm-wertzeile--knopf`/`.dm-wertzeile__chevron` (the one clickable row — Batteriespannung → Battery screen — matching the panel's `.row[data-go]` pattern), and a scoped `.dm-galerie-wechsel .ads-bildbox{aspect-ratio:16/9}` (the panel's plain `"carfix"` class, not the `"carfix klein"` 16:8 used only on Übersicht's fixed image — confirmed by reading `carImg("galerie")`'s own call site, not assumed to match Übersicht's image).
Verified: `npm run typecheck` clean, `npm test` 147/147 (existing smoke test renders the new structure without throwing). Live-verified via screenshot at true mobile width: cycling image with 7 dots (first active/red), badge+Kennzeichen flush left matching the now-fixed Übersicht pattern, all five tiles showing real data with correctly black-label/grey-value rows and visible chevrons, Batteriespannung row showing a real live value (13,6 V) with its own chevron.
## Chapter 3 (Service & Reifen): mostly solid already, two real missing features found (2026-08-30)
Owner picked option 1 ("gleiches Tempo/Tiefe beibehalten") after a mid-audit status check — continuing at full depth across sessions rather than switching to a lighter pass.
**`Service.tsx`'s "Anstehende Termine" tile needed almost no work** — 4 of 5 live-measured elements (Herstellervorgabe, Prognose-Fußzeile, Leerzustand-Text, Eyebrow) already matched the panel exactly, entirely as a side effect of this session's earlier systemic fixes (color tokens, `.tg-wert`'s Wide font). Only `.dm-fussnote` (the explanatory paragraph) differs (panel: 13.5px/`var(--fg2)`; companion: 11.5px/`var(--fg3)`) — but that class is used **35 times across 13 files** for many different kinds of captions, not a 1:1 match to any single panel class. Left alone rather than risking 34 other call sites on one owner report; flagged for a future targeted look, not a blind global change.
**`Reifen.tsx`'s overall structure (a `Seg` toggle between Sommer/Winter instead of the panel's two-tiles-at-once layout) is a legitimate idiom difference, not a gap** — same information reachable, matches this project's own "different idiom, same content" allowance. But checking it surfaced **two genuinely missing features, not styling**: the panel lets the owner correct the **Anzugsmoment** (torque spec) and the **active tire set's own odometer** via a small pencil-icon edit, both wired to real backend writes (`profilSpeichern()` for Anzugsmoment; the dedicated `reifen_km_setzen` service for km, specifically *not* through `profilSpeichern()` — see `reifenzaehler.py`'s own reasoning, already documented elsewhere in this file, about not clobbering `referenz_odo_km`). Neither existed anywhere in companion-app — confirmed by grepping for `reifen_km_setzen`/`reifenKmSetzen` across `api/` and `screens/` (zero hits) and for `anzugsmoment` outside `Reifen.tsx` itself (only the one, read-only usage).
**Built, following this codebase's own established edit-toggle idiom** (matching `ArchivZeile`'s own pattern in the same file, not the panel's inline-always-editable fields):
- `api.reifenKmSetzen(satz, km)` (new, `api/index.ts`) — same `warteschlange.einreihen(DIENST_DOMAIN, "reifen_km_setzen", {satz, km})` shape as the existing `reifenArchivieren`.
- `SymbolEdit` (new, `symbole.tsx`) — the panel's `edit-s` CI icon, verbatim, following the same `RahmenFlaeche` pattern as `SymbolOelwechsel`/`SymbolInspektion` (a filled icon, not a stroke outline).
- Two pencil-icon-triggered inline edit panels in `Reifen.tsx`: Anzugsmoment (writes via `profilSpeichern`) and the currently-displayed tire set's km (writes via the new `api.reifenKmSetzen`, respecting the `Seg` toggle's `anzeige` state — correctly targets whichever set, sommer or winter, is on screen). New shared `.dm-bearbeiten-block` CSS for the expandable edit-panel container (border-top + spacing, matching the panel's own inline-edit-block treatment).
Verified: `npm run typecheck` clean, `npm test` 147/147. Live-tested both edit flows end-to-end through the dev proxy against `audi_ha_test` (not just rendered — actually opened each panel, confirmed the input pre-fills with the real current value: `120` Nm, `187992` km) — cancelled both without saving, so no real data was changed in the test container.
## Chapter 4 (Versicherung & Steuer): a real `[object Object]` rendering bug in `Schutzbrief()` (2026-08-30)
**`Schutzbrief()` in `Versicherung.tsx` mis-typed three of its five data lists as plain `string[]`, when the actual profile data is nested objects/tuples — the panel's own `sb.entweder`/`sb.gruppen`/`sb.nicht` were never string arrays.** Concretely: `entweder` is `{bez, grenze, zusatz}[]`, `gruppen` is `{titel, posten: [bez,wert,zusatz][]}[]`, `nicht` is `[bez, begründung][]`. The old code cast all three to `string[]` and rendered `<li>{eintrag}</li>` directly — for `entweder`/`gruppen` this would have printed literal `[object Object]` for every entry (confirmed this really was the bug, not a hypothetical: reproduced the *old* code's behavior mentally against the real profile data structure, which is an object array, not strings), and for `nicht` (an array of 2-tuples) would have shown something like the array's default `toString()` join rather than readable text. Found by reading the panel's actual `vSchutz()` markup line by line instead of assuming the existing companion-app types were correct — the same lesson as several earlier findings this audit: check the real data shape, don't trust an existing cast.
Rebuilt to match each field's real shape and the panel's own grouping: `entweder` → its own tile with `Wertzeile`s (bez/grenze/zusatz) plus the fixed "Mobilität wird binnen 60 Minuten organisiert" note (the panel shows this unconditionally whenever `entweder` has content, not behind some other flag — the previous code's structure had already lost this line entirely); `ausloeser` → its own tile with `gilt`/`versichert` as trailing footnotes (matching the panel's placement exactly, previously merged into a single made-up "summary" tile that doesn't exist in the panel); `gruppen` → **one tile per group**, each its own `Wertzeile` list (previously flattened into a single wrong list); `nicht` → `Wertzeile`s with the reason as the `zusatz` caption; `pflichten` → its own tile with `rang`/`stand` as trailing footnotes (again matching the panel's actual placement, not a separate summary tile).
Verified: `npm run typecheck` clean, `npm test` 147/147. Live-verified through the dev proxy against `audi_ha_test`'s real profile data — confirmed **zero** `[object Object]` anywhere on the rendered page (checked via `document.body.innerText.includes('[object Object]')`, not just eyeballing) and real, correctly formatted insurance terms showing throughout ("Weiterkommen: Bahn 1. Klasse bis 1 200 km…", "Am Schadenort: Pannenhilfe vor Ort bis 100 €…").
**Not yet done in this chapter** (context budget — flagged, not silently skipped): `vVers()`'s top-level structure (separate Notruf/Jahresbeitrag-with-breakdown-bar/Vertrag tiles with edit gears) vs. companion's single combined summary tile + `NaviKachel` grid was not rebuilt to match 1:1, unlike the `MeinAudi.tsx` rebuild in Chapter 2 — `Beitrag()`/`Vertrag()`/`Notruf()`/`Steuer()` sub-screens were not individually re-verified against their panel counterparts (`vBeitrag()`/`vVertragBearbeiten()`/`vNotrufBearbeiten()`/`vSteuer()`) this round. Worth a dedicated pass later. **Done 2026-08-30 in section AL below.**
## AL. Second parity round: the *global* chrome was the real problem, plus seven screens rebuilt (2026.8.30.3)
Owner, after the first audit shipped: **"Die HA App in der Smartphone Ansicht ist die Vorgabe. Die iOS App soll identisch zu dieser Ansicht sein... Alle Abweichungen der iOS App zur HA App sind in der iOS App anzuwenden. Fehlende Features sind einzubauen. Copy exactly."** Two explicit decisions taken during the round (`AskUserQuestion`): **(1)** where the panel is demonstrably wrong, fix the *panel* rather than copy the defect; **(2)** where companion-app has *more* than the panel, remove it.
**Method note that changed the findings, worth keeping:** `innerText` on a freshly-rendered panel view is read *too early* — placeholder markup for a missing photo only appears once the `<img>` `onerror` fires. Two screens (Sicherheit, Reifen) looked like they had extra content in companion-app until the panel was re-dumped with a ~900 ms delay, at which point both matched exactly. **Always wait after navigating the panel before comparing text.**
### The biggest find: the app-wide chrome, not any single screen
Every screen was wrong at once because the shared frame was. All measured live, panel vs. companion-app, at 375×812:
| | Panel | companion-app (before) |
|---|---|---|
| Content side padding | 16px (`main#view{--seitenrand:16px}`) | 22px (`--sp-5`) — every tile 20px narrower |
| Tile padding | `16px 18px` | `22px` |
| Tile-to-tile gap | 14px | **24px** (flex `gap` *plus* the tile's own margin) |
| Scrollbar | hidden | 8px wide, stealing content width on every screen |
| `--line` (day mode) | `rgba(60,60,67,.16)` | `.13` — every separator too faint |
| Missing-value dash | `` (en dash, 30×) | `` (em dash, 56×) |
Header: the panel's `.eyebrow` is `display:none` on phone width — companion-app printed a fixed **"DataMetric360"** above every single screen title, an element the panel does not have at all. The panel also **hides the rings on sub-pages** (back arrow instead) and renders sub-page titles at **17px centered** (iOS navigation-bar style) against 22px left-aligned here; it has no border under the header; and **nine route titles** were plain drift (`Fahrzeugdaten`→`Identität und Technik`, `Batterie`→`Batteriespannung`, `Versicherung & Steuer`→`Versicherung/Steuer`, `Beitrag`→`Beitrag anpassen`, `Notruf`→`Rufnummern`, `Werkstatt`→`Autohaus`, `Wartungsplan`→`Eintrag`, `Fahrt`→`Einzelfahrt`, `Tankvorgang`→`Einzelbeleg`).
Tab bar: labels were **9px UPPERCASE with .08em tracking** (the pre-2026-08-13 style again), icons 21px instead of 26px, no 63×39 icon pill, no translucent blur, no 26px home-indicator padding.
`@audi-dash/ui` carried three more app-wide instances of the same pre-iOS styling, each fixed against live-measured panel values: **`ActionButton`** (11px uppercase outline with a red hover → 16px normal text on a filled 14px-radius surface, plus a new `primary` variant for `.aktion.primaer`), **`Pill`** (10px uppercase outline → 12px normal on `--ios-fill`), and **`Accordion`** (chevron on the *wrong side* — right instead of left —, 6×10 instead of square 8×8 so it read as a tick rather than an arrow, and it turned **red** when expanded, the same "red as a navigation state" violation fixed twice before elsewhere). Also `Fig`'s unit lost its `max(11px, …)` floor, so the 20px odometer figure rendered its "km" at **6.4px**; `ProgressBar` was 4px on `--line` instead of 6px on `--ios-fill`; `StatusRow` had no padding/min-height and a 14px title instead of 16px; `ImagePlaceholder` was a dashed outline with a 10.5px tracked caption instead of the panel's filled `--ios-fill` box with a 13px caption; and `Tile`'s content span lacked `min-width:0`, so the Fahrgestellnummer pushed its row out of the tile.
### Features that were missing entirely
- **Pull-to-refresh** (`.ptr` + the gesture) and the **"vor 3 Std." data-age line** in the header — companion-app had neither, and instead a "Jetzt beim Fahrzeug nachfragen" button the panel does not have. Built in `Shell.tsx` (`useZiehenAktualisieren`, `standAlter()` in `format.ts`), the button removed.
- **Full-bleed vehicle image** with the fade-out gradient, the **ground shadow** (`strasse`/`winter`) and its **"Boden" switch** (`useBoden` in `theme.ts`), and the **winter side-view swap** (`bildInfo()` in `bilder.ts`). The image labels also differed ("Auto von der Seite" vs. the panel's "Auto Seite").
- **`Termin vereinbaren`**, the full **Autohaus** tile (address, phone, e-mail, maps links) and the **Wartungsplan list** with "Eintrag hinzufügen" on the Service screen — companion-app had two `NaviKachel`s instead, and the `sbuch` route was a whole list screen where the panel's is a **single-entry editor**. Route and screen rebuilt accordingly.
- The **Statistik tiles are collapsible in the panel** (`statTile()` → `.tile-h`/`.tile-b`) with a right-aligned secondary value in the head; companion-app rendered everything permanently expanded, without the head value and without the `quad()` sub-headings. Rebuilt, and `schnittFahrtVerbrauch()` (the missing "Näherung je Fahrt, je Zeitraum" row) added to `statistik.ts`.
- **Batterie**: the panel's chart has grid lines, Y-axis labels, an area fill, a date-range line, and pinch-zoom/pan with a reset button — none of it existed here; and the whole **"Bewertung" tile** (SOC with colour dot and label, SOH trend with its verdict text) was missing, replaced by four plain value rows.
- **`Vertragsdetails` rendered twelve em-dashes.** `versicherung.leistungen` is `{gruppe, posten[]}[]`, not `{bez, wert}[]` — the same mis-typed-data-shape defect as the `Schutzbrief()` `[object Object]` bug from chapter 4, one level up. Also on the Versicherung group: the whole `vVers()` structure (Notruf tile with *both* numbers as dial buttons, Jahresbeitrag with its 46px figure, per-part breakdown and segmented bar, Vertrag with Selbstbeteiligung rows and the SF "zuvor" line) was rebuilt; `Beitrag` gained "Gültig ab" and both SF fields; `Vertrag` gained the "Schadenfreiheitsklasse · zuvor" tile; `Notruf` was relabelled Inland/Ausland; `Steuer` got the Zeitraum dropdown, "Nächste Fälligkeit" and the Lastschrift switch.
- **`Fahrzeugdaten` hid over 3 000 characters of data sheet inside collapsed accordions** — the panel shows every technical group and every equipment group as its own flat tile. Rebuilt; the head tile also had the wrong name ("Identität" vs "Fahrzeug") and the wrong field set.
- **Fahrten/Tanken list rows** used a different anatomy (km/litres first, everything else in one long footer line, plus Arbeitsweg/offen/unvollständig pills the panel has no equivalent for). Rebuilt on a new shared `Blattzeile` (`.leaf` in the panel), the count header and top "eintragen" button removed (the panel puts the count in the year row and the form *below* the list), month labels shortened to the month name, and **`nachJahrUndMonat()` was sorting months and rows descending** where the panel keeps the backend's ascending order — both lists were reversed and opened the wrong month by default.
### Panel-side changes (both decided with the owner, not taken unilaterally)
1. **`teaser()` showed the OLDEST trip and fill, not the newest** — `TRIPS[0]`/`FILLS[0]` on backend lists that arrive ascending, under headings that read "Letzte Fahrt"/"Letzter Tankvorgang". Found only because companion-app sorted correctly and the two disagreed. Fixed in the panel (sort by timestamp, take the newest); companion-app already behaved correctly and was left alone.
2. **`vIdent()`'s "Daten bearbeiten" button is dead** — no `data-go`, no handler, does nothing when tapped. Deliberately **not** copied into companion-app; reported instead, since a dead control is not a feature worth reproducing.
### Deliberately not removed, despite decision (2) — needs an owner call
`Zeilenmenue`'s always-visible "···" button on trip/fill rows has no panel counterpart (the panel is swipe-only), but it exists to close a recorded accessibility finding (`AUDIT_2026-08-10.md` §4.1: swipe-only deletion is unreachable with VoiceOver or switch control). Removing it would re-open that defect, so it stays until the owner decides between "remove for exact parity" and "port it into the panel instead".
### Still open after this round
Einstellungen (section names, order and content differ substantially in both directions), MeinAudi, Standort, Schutzbrief, FahrtDetail, TankDetail, Werkstatt, BatterieListe, and the receipt-upload buttons the panel offers inside the *new*-fill form (`Beleg hochladen` / `Ohne Beleg speichern`) — companion-app only offers receipt upload on an existing fill's detail page.
Verified: `npm run typecheck` clean and `npm test` 147/147 after every step, `npm run build` clean, `design-system` rebuilt after each of its CSS changes, `node --check` on the panel bundle, and **live side-by-side at a real 375×812 viewport for every screen touched** — Übersicht, Statistik, Service, Versicherung and Vertragsdetails now produce **byte-identical `innerText`** to the panel, and Übersicht additionally matches element for element (tile heights 130/89/106/169/190, gaps 0/20/22/22/14). Manifest bumped to `2026.8.30.3`, `npm run ota` rebuilt (`bundle.zip` 253,799 bytes, sha256 `8d22031998d6cd18b4d2b3072f16ea87bd3600a107cc6a3c831ba20d3fd8c7ef`), integration redeployed to `audi_ha_test` (`docker cp …/. `, trailing dot per section AK) and confirmed via a clean single-warning setup log.
**Dev-proxy note:** `vite.config.ts`'s dev proxy now also forwards `/local` and `/audi_dashboard_static` to `:18123`. Without it the dev server answered image requests itself, so every optical comparison saw placeholders instead of the real photos and the Shell logo. `server.*` still never applies to `vite build`.
---
## AM. Third parity round: the panel had never rendered Audi Type, plus the last nine screens (2026.8.30.5)
Owner's three instructions after round two: **(1)** accessibility is not needed in either app — remove the "···" row menu; **(2)** "Daten bearbeiten" is required, so make it work *in the panel* first and then port it; **(3)** carry on with the open list.
### The find of the round: the panel was showing Helvetica, not Audi Type
Chasing a single wrapped list row ("Fahrt ohne Ortsangabe" broke onto two lines in the app, one in the panel) turned into the largest defect found so far. Same declared CSS on both sides, same 16px/400, and — verified by hash — **byte-identical woff2 files**. Yet the same string measured **173px in the app and 165.4px in the panel**.
165.4px is exactly Helvetica. Probing inside the panel's shadow root settled it: `"Audi Type"`, `"Times New Roman"`, `serif` and a deliberately invented family name all measured **145.7px** — the panel resolved every one of them to the same fallback, i.e. **"Audi Type" was not available to the panel at all**, and its text had always come out in Helvetica (the second entry of its own font stack). `document.fonts` listed no Audi face either.
Cause: the panel appends *both* stylesheets to its shadow root, and the @font-face rules sat at the top of `audi-dashboard.css`. **@font-face inside a shadow root is ignored — font faces are registered per document, never per shadow tree.** The mirror image of this project's own Leaflet bug (that stylesheet was in `document.head` and never reached the shadow root); same root cause, opposite direction. Fixed by splitting the three faces into `audi-dashboard-schriften.css` and linking *that one file* into `document.head` (`schriftenLaden()`); it contains nothing but @font-face, so the rest of the Home Assistant interface is untouched. Verified live afterwards: the panel now reports both Audi faces as loaded and measures the reference string at 173px — identical to the app.
This also retires a long tail of "the fonts look slightly different" impressions: until now the two apps genuinely rendered different typefaces.
### "Daten bearbeiten" (panel first, then ported)
The button in `vIdent()` had no `data-go` and no handler — dead. Now implemented in the panel (`vIdentBearbeiten()`, `identBearbeitenOffen`, reset in `go()`/`zurueck()`): every value of the technical data sheet becomes an input, every equipment entry an editable row, both with "×" and "+ Position hinzufügen", saved through `profilSpeichern()` per field — the same idiom as the Selbstbeteiligung editor. Deliberately **not** included: Modell, Kennzeichen, Ausführung, Erstzulassung and FIN, which "Fahrzeug einrichten" in Einstellungen already owns; two editors for one field drift apart. Verified live in the container (add → 8 rows → delete → 7 rows, persisted). Then ported to `Fahrzeugdaten.tsx` with the app's own draft-plus-"Fertig" idiom (≈50 fields would otherwise mean ≈50 whole-profile writes).
### Row menu removed
`Zeilenmenue` is now just the swipe row, as in the panel. `SymbolMehr` and the `.dm-zeile*` classes went with it (orphans of this change). `SwipeRow`'s delete button also still carried the pre-iOS styling (11px uppercase, tracked) and lacked the panel's `visibility:hidden`-until-swiped trick — both fixed. Rows now use the full width, exactly like the panel.
### The remaining nine screens
- **Einstellungen** rebuilt to `vEinst()`'s sections, order and wording: "Fahrzeug einrichten" is collapsible again and carries the identity fields plus the history import; "Darstellung" gained the missing **"Fahrbahn unter dem Fahrzeug"** switch (the setting existed since round two, the control did not); "Bild der Übersicht" shows the chosen view large with **tap-to-upload/replace/delete** — the app previously could not change photos at all and pointed at Home Assistant, although `bild_hochladen`/`bild_loeschen` have existed all along (new `api.bildHochladen`/`api.bildLoeschen` and a shared `BildMitMenue`). "Tankrabatt"→"Kraftstoff-Rabatt", "Sicherung"→"Backup" with the panel's texts and button labels, "Vergangene Daten" folded into "Fahrzeug einrichten", Fahrzeugprofil moved above Backup. Removed: "Nacht beginnt/endet", the two count rows under "Zugang", and several explanation paragraphs the panel does not have.
- **Mein Audi**: the Service tile shows **all three terms** again (the deliberate Chapter-2 simplification is gone), image captions use the panel's labels ("Auto Seite"), and the Batteriespannung row lost its chevron (the panel marks it only by cursor).
- **FahrtDetail** and **TankDetail** rebuilt to `vTrip()`/`vFill()`: map first, then the flat 52px figure tile, then one value-row tile in the panel's order with "Werte bearbeiten"/"Beleg ersetzen" inside it and the destructive action last. Both had their own header row, their own ordering and labels, and extra pills the panel does not carry.
- **New-fill form** gained the panel's three buttons (**"Beleg hochladen"**, "Speichern", "Ohne Beleg speichern"). A receipt upload *without* `tank_id` only parses and publishes the fields (`belege.py`), which the form now waits for and pre-fills — the app previously could only attach a receipt to an already-existing fill.
- **Einzelbeleg map**: the record has no station coordinates and the backend never fills them; the panel geocodes the receipt address through Nominatim with a localStorage cache. Ported as `daten/geokodierung.ts` plus `TankstellenKarte`.
- **Schutzbrief**: the per-row note belongs under the *label*, not under the value (`dt` = bez + small zusatz, `dd` = grenze) — it was the other way round; explanation paragraphs aligned.
- **Werkstatt** tile renamed to "Angaben zum Autohaus"; **Messwertliste** timestamps use "·" instead of a comma (new `datumUndZeit()`); **Standort** already matched exactly.
- New `.dm-erklaerung` class for the panel's 12.5px explanation paragraphs. This finally resolves the `.dm-fussnote` ambiguity flagged twice: rather than change a class used 35 times across 13 files, the panel-shaped role got its own class.
### Deliberately kept, with reasons
- **"Zugang" → "Verbindung trennen"** and the **App-Update (OTA)** tile have no panel counterpart *because they cannot*: the panel runs inside Home Assistant and knows neither a server address nor a bundle. Same category as the onboarding screen and `LiveFahrt`.
- **`Tankvolumen` and `Zusatz`** are in the data model but editable nowhere in the panel, so their editors were removed here per the owner's rule. They are now only changeable in the profile file — say the word and they get an editor in the panel instead.
Verified: `npm run typecheck` clean and `npm test` 147/147 after every step, `npm run build` clean, `node --check` on the panel bundle, live comparison at 375×812 for every screen touched. Manifest `2026.8.30.5`, `npm run ota` rebuilt (`bundle.zip` 255,695 bytes, sha256 `43fdc62a88cc212d98a808239c650e1d6a3cb33c243a576ef36014c0b8948943`), integration redeployed to `audi_ha_test`, clean restart with no traceback, and the font fix confirmed live in the panel.
**Tooling note worth keeping:** do not use `perl -pi -e 's|...|...|'` on these files. A pattern containing an escaped `|` terminated early, the substitution then matched every line, and `MeinAudi.tsx` came back with the replacement prepended to all 250 lines. It was fully reversible (the injection is deterministic), but the safe path is a small Node script that reads the file, checks the anchor exists, replaces once, and preserves CRLF — the source files are CRLF and a naive newline pattern will silently not match.
---
## AN. Design audit follow-through: the five priority items, and what measuring first changed (2026.8.30.6/.7)
Working through the priority list of the design audit published at
https://claude.ai/code/artifact/cb5bcae6-5ca5-43c1-a575-cdff96eb2a54. Every item was applied to
**both** codebases (parity rule) and verified live at 375x812 against `audi_ha_test`.
**1. Statistik tiles had no expand affordance** - the five collapsible tiles looked like fixed
readouts. The rotating chevron the accordion rows already use now sits in the tile head
(`.stat-kopfzeile` + `.mark` in the panel, `dm-statkopf__kopfzeile` + `ads-mark` in the app).
**2. Lists ran newest-last inside a newest-first frame.** Years descended while months and rows
ascended, so the newest trip sat at the bottom, off screen. Panel got `neuesteZuerst()` for
`vTrips()`/`vFuel()`; the app's `nachJahrUndMonat()` went fully descending. `teaser()` in the panel
was picking `TRIPS[0]`/`FILLS[0]` - the *oldest* record - under headings reading "Letzte Fahrt";
it now sorts by timestamp.
**3. `--fg3` on light ground was 2.92:1** against the page and 3.26:1 against a tile - too little
for a glance in sunlight. Darkened to `#6B6B70` (4.75:1 / 5.30:1) in `audi-dashboard-ios.css` and
`design-system`'s day theme; the night value stays `#8E8E93` (already 5.86:1). Verified live in
both: day `#6B6B70`, night unchanged.
**4. The type scale - and here measuring first changed the fix entirely.** The audit counted
*declared* sizes and found fourteen distinct values below 18px, which reads as a badly fragmented
scale. Measuring the **effective** `font-size` of every text element across eleven panel views told
a different story: a clean seven-step backbone (11 / 12.5 / 13 / 14 / 15 / 16 / 17) already carried
**96%** of 621 elements, with 27 stragglers sitting one notch beside a real step. Most of the
declared values never reached the screen at all, because the always-loaded iOS overlay overrides
them - the same trap recorded twice before in this file.
So the fix is narrow, not a restructure: snap the stragglers onto the nearest step
(11.5 -> 12.5, 12 -> 12.5, 13.5 -> 13, 14.5 -> 14, 16.5 -> 16), applied as one mechanical
substitution across panel CSS, panel inline styles, companion-app CSS and `design-system` at once,
so the two sides cannot drift apart on it. Nothing moves more than 1px. Also fixed: `<small>` in the
accordion head had no rule of its own and inherited the UA's `smaller` (0.833x) - 10.4px, below the
11px floor this project took from Apple's guidance; it now has an explicit 11px.
Deliberately untouched: `font-size: max(11px, .32em)` on the unit beside the large figures. Its 12.8
and 14.72px readings are that rule scaling with a 40px and a 46px figure, which is the point of it.
Re-measured afterwards across the same eleven views: **11 / 12.5 / 13 / 14 / 15 / 16 / 17**, nothing
else, plus the two intentional relative values. Fourteen values down to seven.
**5. The Ubersicht carried its two heaviest tiles in front of its lightest.** Without a stored photo
the placeholder still occupied 120px directly above the range - the most important number on the
page - and "Zuletzt", two short and frequently read lines, sat below a 190px map and therefore below
the fold on a phone. Placeholder narrowed to 72px (kept, not removed: it is the only sign that this
image slot exists), and "Zuletzt" moved in front of the location card in both codebases.
**Found while verifying, fixed too:** the app drew the Ubersicht service chevron as the text
character "" at 15px, where the panel draws `.go`, a 6x10 SVG polyline at stroke-width 1.4 -
visibly a different shape and weight from every other chevron in the app. Now the same SVG.
**Also fixed, from the owner's report "ubersicht/karte: hier stimmt ios noch nicht zum panel":** the
location preview drew the wrong pin. `fahrzeugMarkerSVG()` in the panel switches icon sets by size -
at or above 34px the 48-grid drawing, below it the 24-grid one, which is drawn for small sizes. The
app always used the 48-grid one, so the 32px preview showed a drawing meant to be seen at 38px+. The
app now mirrors the same switch, and the preview's map background is pinned to Leaflet's own #ddd,
which is what the panel actually shows (its own `var(--tile)` declaration loses to
`.leaflet-container`). Verified byte-identical afterwards: same marker markup (1087 characters, same
hash), same `translate3d(172px, 95px, 0)`, same drop-shadow, same colour.
Verified: `node --check` on the panel bundle, `design-system` rebuilt, companion-app `tsc --noEmit`
clean and 147/147 green after every step, `npm run build` clean. Live at 375x812 the Ubersicht of
both now yields the same text and the same size distribution (11x1, 12.5x3, 12.8x1, 13x8, 15x3,
16x3) and the same tile order. Manifest `2026.8.30.6`, then `2026.8.30.7` once the chevron fix
changed the app after the first bundle - two different bundles must never share one version number.
`npm run ota` rebuilt (`bundle.zip` 256.124 bytes, sha256
`556b8e7b43277366921e927ce56f8f748e0b7e5efaf021040fcfd99f0027e8c2`), redeployed to `audi_ha_test`,
clean restart.
**Header padding: the one place where copying the panel's number was wrong (2026.8.30.8).** The
owner asked why the iOS app shows a large gap above the headline. Measured: 105px, made of a 49px
"Diese App ist älter als der Server" band - the message itself, not a layout fault - and 56px of
header padding. That 56px is a faithful copy of the panel (`.topbar{padding:56px 20px 4px 16px}` in
the iOS overlay, confirmed live). It is also the one case found so far where copying the panel
exactly produces a *different* result: in the panel those 56px sit below Home Assistant's own header
bar, while the native shell has nothing above them but the status bar. Same number, different
picture. On the owner's instruction the added padding is gone; `.dm-kopf`/`.dm-kopf--unterseite` now
carry `env(safe-area-inset-top, 0px)` alone, so the status bar stays clear on the device while the
headline starts at the top everywhere without an inset. Deliberate, disclosed divergence from the
panel - do not "fix" it back by matching the number again.
**Committed and pushed** at the end of this session, after rebasing onto `25c0d07` (an `.ipa` refresh
pushed from elsewhere, no overlap).
---
## AO. Owner findings batch, 2026.8.30.9 - three of them real bugs with a single cause each
Seventeen findings in one go, all applied to both codebases unless noted.
**The three that were genuine defects, not drift:**
1. **No vehicle pin on the Standort map (app).** The marker effects read `leafletRef`/`kartenRef`,
which are only filled inside the asynchronous `import("leaflet")` callback. On mount they were
still null, the effects returned early - and since their dependencies (the coordinates) never
changed afterwards, they never ran again. A `karteBereit` flag, set once the map exists and added
to those dependency lists, brings them back. Same class of bug reached the tile-layer and
own-position effects; all four were fixed together.
2. **The chevron on the Mein-Audi tiles sat on half the tile's height (app).** `.ads-tile__go` was a
flex child with `margin-left`, so on a tile with several value rows it pointed into the middle of
a row instead of at the title that says where it leads. Now absolute at top 22px / right 18px -
what the panel has done since 2026-08-13. Measured before: 132 / 106 / 106 / 98px from the top.
3. **Select fields had no drop-down arrow at all (app).** `appearance:none` in `Feld.css` removed the
native arrow without putting anything back, so a picker was indistinguishable from a plain text
field. Reported at "Ansicht" and at the backup interval; the fix in `.ads-feld select` covers
every select in the app and copies the panel's own chevron image and metrics one to one.
**SmartDeal: the date field was broken *and* the question behind it was missing.** The app carried an
inline date input labelled "Gültig bis", the panel asks for the date in a dialog the moment the
switch is turned on and afterwards only displays it. The panel's behaviour is the one that makes
sense - `smartdeal.aktiv` is computed from that date (`laeuft_ab >= heute` in both codebases), so
without it the discount can never expire on its own. The dialog is now ported; the inline field is
gone, so there is exactly one way to set the value.
**Nominatim has started refusing us.** "Suddenly only GPS coordinates where an address used to be" is
not a display bug: the reverse lookup comes back without a CORS header (that is what throttling looks
like from a browser) and both apps fall back to coordinates as designed. What was missing is memory -
the panel kept a single point in RAM, the app the same, both gone on reload, with the reasoning "the
vehicle position changes constantly anyway". That holds for driving, not for parking: the car stands
in the same few places. Both now cache resolved addresses in the same persistent store the fuel-station
lookup already used (`rev:`-prefixed key, rounded to ~110 m); only real hits are stored, so a throttled
answer is retried rather than remembered as "no address here".
**Pull-to-refresh did not work on the device.** The gesture logic was fine - the scroll container had
no `overscroll-behavior`, so iOS's own rubber-band took the gesture before the threshold was reached.
Exactly the finding the panel had on 2026-08-17 and the app never received; `.dm-scroll` now carries
`overscroll-behavior-y: contain` and `touch-action: pan-y`. Not reproducible on a desktop, because
there is no native overscroll to compete with.
**The rest, briefly:** the Übersicht placeholder is back at full 16:8 size (the audit had narrowed it
to 72px; a strip does not show what belongs there); "Fahrgestellnummer" is now "FIN" and "Details"
shows "Informationen über das Auto" as a comment-weight hint rather than a truncated engine line; the
Batteriespannung row got its chevron back (an earlier note claiming the panel marks it by cursor only
was wrong - the panel has a `.chev` there); the Reifen box leads with "aktuell montiert: /
Sommerräder" and drops the dash under the change date; Anzugsmoment uses the gear, not the pencil (the
pencil belongs to the kilometre correction); the wheel odometer is back in the figure typeface at
32px; the map-layer icon is covered separately below; Teilen uses the real `share-s` from the CI set; and the sheet's
close button only exists while the sheet is raised - half-covered it looked operable and did nothing.
**The "could not be reproduced" finding was a night-mode-only defect (2026.8.30.10).** The owner
added the missing detail: night mode. In night mode `--tile` is deliberately translucent - a white
veil over the dark page - and `.ads-swipe-content` painted the same token a second time on top of
the tile it already sits on. Two veils add up, so every row in Fahrten and Tanken was visibly lighter
than the tile around it, while the year and month headers (not swipe rows) stayed correct. In day
mode both values are opaque white, which is why painting white on white showed nothing and the first
pass found the two sides identical. The panel does not have this: its iOS overlay sets
`.swipe-wrap .swipe-content{background:var(--tile-deckend)}`, an opaque token that `design-system`
never had. Added `--tile-deckend` (#171B21 / #FFFFFF, the panel's own values) and switched
`SwipeRow.css` to it. **Rule worth keeping: any surface painted on top of another surface needs the
opaque token, never `--tile`** - the bug is invisible in day mode by construction.
**Pull-to-refresh never worked on a real device, in either app - and the reason invalidates how it
was verified before.** Both implementations drove the gesture from `pointermove` and called
`preventDefault()` there. Per the Pointer Events spec that does **not** prevent scrolling: scrolling
is decided by `touch-action` alone. On a real touch screen the browser claims the vertical gesture,
fires `pointercancel` and stops sending `pointermove`; the handler's own cancel path then resets the
drag long before the 56px threshold. A mouse never triggers that takeover, and neither do synthetic
`PointerEvent`s - which is precisely why the 2026-08-17 session drove the whole gesture end to end
with dispatched pointer events, saw it work, and concluded the JS was fine. It was fine; it was
attached to the wrong event family. The `overscroll-behavior-y: contain` added then addresses scroll
*chaining* to the host page, a different problem, and was left in place.
Both now run the gesture on touch events - `touchstart` passive, `touchmove` with
**`{ passive: false }`** (without that flag `preventDefault()` is ignored and the browser scrolls
anyway), `touchend`/`touchcancel` - while the pointer path stays for mouse and stylus and now
ignores `pointerType === "touch"` so the two cannot fight over the same gesture. Panel: `ptrStarten()`
/ `ptrBewegen()` / `ptrBeenden()` extracted so both paths share one implementation. App: a manual
listener in a `useEffect`, because React registers `touchmove` passively and `preventDefault()` in an
`onTouchMove` prop would be silently ignored. Verified in both with genuine `TouchEvent`s - which the
old code could not have satisfied: threshold reached, label switching to "Loslassen zum
Aktualisieren", the refresh running, and `defaultPrevented === true` on the move, the one signal that
proves the native overscroll is actually suppressed.
One trap avoided while doing it: the app's early `return` for the wide layout sat in front of the new
`useEffect`, which would have made the hook run on some renders and not others. Moved behind it.
**Named places instead of a street address.** On the owner's question - a car park says more than the
nearest house number. Nominatim already returns `name` plus the type of the matched feature; both
apps now prefer that name when the type is one of parking / parking_space / parking_entrance /
bicycle_parking / motorcycle_parking / rest_area / services / fuel / charging_station, with the town
still appended. The type check is what keeps any incidentally named building from displacing the
address. And to answer the other half plainly: when nothing resolves at all, both show the
coordinates - that was already the designed fallback (`adresse ?? koordinaten`), and it is also what
a throttled lookup produces, which is exactly why the two cases were indistinguishable before the
cache landed.
**Header padding on the app was cut to the safe area on the owner's instruction** - see the note at
the end of section AN; the panel's 56px sit below Home Assistant's own header, the native shell has
nothing above them but the status bar.
**The map-layer icon took three passes, and the first two were my error - worth recording as a
method failure, not just a fix (2026.8.30.12).** The report was "icon wrongly placed in the button".
I measured the icon's own bounding box, found it filling 20.9 x 15.6 of its 24-grid against
neighbours filling 20x20 to 24x22, read that as *too heavy and off-centre*, and scaled it to 0.88
with a centring nudge - in both codebases. Two mistakes in that: I measured the **panel** and then
changed both, without ever measuring the app's own icon; and "fills less area than its neighbours"
is an argument for it looking *smaller*, which is what the owner had actually meant.
The owner reverted me on the panel ("it had the right size before"), which was correct. Measuring the
app's icon properly then found the real defect there: its `mapLayer` path began with an uppercase
**M** where the panel has a lowercase **m**. For the first command that is equivalent - but it
changes everything after it, because implicit coordinate pairs following `M` are *absolute* linetos
and those following `m` are *relative*. The second point therefore jumped to (-8.97, -5.66), far
outside the viewBox: the app's icon was geometrically wrong, not merely mis-sized. Copied the panel's
string verbatim; all four control icons now compare byte for byte.
With that fixed and my scaling reverted, the original complaint stood on its own and was simply
correct: the glyph is flat (20.9 x 15.6 where the others are near-square), so at the same 22px it
reads noticeably smaller. Now rendered at **26px** in both - the same remedy already used for the
oil-change icon in the service blocks, which gets 26px next to the inspection icon's 20px for exactly
this reason. Measured after: drawn 22.6 x 16.9px against neighbours at 22 x 20.3, 21.1 x 21.1 and
18.3 x 18.3.
**The method lesson:** when a report concerns one of two codebases, measure *that* one before
changing either - and when a measurement says an element is smaller than its neighbours, that is
evidence for "too small", not against it.
**One finding could not be reproduced:** "Fahrten/Tankfüllung: Auflistung/grauer Hintergrund ist
falsch". Panel and app are identical there - same backgrounds (transparent rows on a white tile),
same paddings, same indents, same text. Reported back rather than guessed at.
Verified: `node --check`, `design-system` rebuilt, `tsc --noEmit` clean, 147/147 green, deployed to
`audi_ha_test` and confirmed against both frontends at 375x812 - chevrons at 22px on all four tiles,
the Zustand row's chevron, the select arrow, the full-size placeholder, the SmartDeal dialog driven
end to end (state restored afterwards), the Reifen page's gear/date/figure, and on Standort: one
marker, the centred layer icon, the share glyph, and the close button appearing exactly when the sheet
is raised.
---
## AP. Surface tokens: the rule, and the sweep that verified it (2026.8.30.16)
Three separate defects in one day traced to the same mistake, so it is written down as a rule with a
way to check it.
**The rule.** `--tile` belongs on the page background and nowhere else. Anything painted *on* a tile
or a sheet takes `--tile-deckend` (an opaque surface) or `--ios-fill` (a control). The reason is that
`--tile` is deliberately **translucent at night** (a white veil over the dark page) and **opaque white
by day**, so painting it onto another surface fails in exactly one theme at a time:
| | day (`--tile` = #FFFFFF) | night (`--tile` = rgba(255,255,255,.055)) |
|---|---|---|
| on a tile / sheet | invisible - same colour, the element disappears | two veils add up, the area is visibly lighter |
That is why each of the three slipped through: whoever built it looked at one theme.
- Swipe rows in Fahrten and Tanken: doubled veil, night only.
- The sheet's close button on Standort: white on white, day only.
- The map area on the detail pages carried `--tile-2`, a second veil at night, where the panel uses
`--tile-deckend`.
**The sweep.** Checked at runtime rather than by grep, because what matters is the *resolved* colour
of an element against the resolved colour of its nearest painted ancestor - neither is visible in the
source. For each element with a non-transparent background, walk up to the first ancestor that also
paints; flag it when the two colours are equal, or when the child carries `--tile` and the ancestor is
a tile or a sheet (a tile on the page background is the correct base case and must not be flagged).
Run it in **both** themes; one theme alone proves nothing.
Result: panel 21 routes, app 18 pages, both themes - the only remaining hit is `swipe-content` on
`tile` in day mode, where both are opaque white by design (rows are meant to blend into the tile;
hairlines separate them), and it disappears at night because the row is opaque and the tile is not.
**The sweep was itself verified**, which matters more than the clean result: setting the close button
back to `var(--tile)` by hand made the detector flag it immediately, and removing the override made
the flag disappear. A check that has never been seen to fail is not evidence of anything.
---
## AQ. `node --check` cannot verify the panel bundle - check it as a module (2026.8.30.19)
**The failure.** `audi-dashboard-app.js` was syntactically broken from `2026.8.30.14` to `.18` - five
versions, all pushed to Gitea. The panel could not be parsed, `connectedCallback()` aborted, the
custom element ended up with **no shadow root at all**, and every page rendered blank. The owner hit
it on the real instance after updating to `.17` and restarting.
**The cause** was a one-line editing mistake: the rationale comment for the "Zündung an" change went
*inside* a `${...}` expression instead of into the surrounding markup text. Inside `${ }` the content
is JavaScript, so `<!--` is not an HTML comment there.
**Why the standing check missed it, which is the part worth keeping.** `node --check datei.js` parses
as a **script**, and in scripts HTML-like comments (`<!--`, `-->`) are explicitly legal under Annex B
of the language standard. The browser loads the very same file as a **module**, where they are
forbidden. So the project's habitual verification step is structurally incapable of catching this
class of error - it is not that it was run carelessly, it cannot find it.
**Rule from now on:** verify the panel bundle with the module grammar before shipping.
```bash
cp custom_components/audi_dashboard/frontend/audi-dashboard-app.js /tmp/probe.mjs && node --check /tmp/probe.mjs
```
The `.mjs` extension is the whole point - it switches Node to the same grammar the browser uses. Run
it over every file in `frontend/`, not just the big one. Applied retroactively across the day's
commits, this pinpointed `.14` as the first broken version in seconds.
**A second failure made it last five versions.** After `.14` the panel tab was never reloaded. Every
later "verified live" statement about the panel ran against the still-resident old script and was
therefore worthless - the page kept working precisely *because* it was stale. **Reload the panel
after every deploy before claiming anything about it**, and confirm the version actually served:
```js
performance.getEntriesByType('resource').filter(e => /audi-dashboard-app/.test(e.name)).map(e => e.name)
```
A blank panel has a reliable signature worth recognising: the element exists but `element.shadowRoot`
is `null`. That means `connectedCallback()` threw - look for a parse error in the bundle, not for a
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.
## AV. Audit Panel/iOS und die sieben Befunde daraus (2026.8.31.5)
Bericht mit allen Messwerten:
https://claude.ai/code/artifact/59b55b39-0d49-483d-a3b7-c29ca59af7bf
Der Eigentümer hat „alles" angewiesen, danach ausdrücklich auch die bis dahin
vertagte Sensor-Umstellung. Alle sieben Befunde sind erledigt bis auf Befund 07, der
eine Angabe des Eigentümers braucht.
### Die Zündung liegt jetzt auf dem Trip-Signal des Geräts
`ZUENDUNG_SENSOR` zeigt in `audi_ha_test` nicht mehr auf
`engine_ignition_or_acc_status` (den rohen, prellenden Eingang), sondern auf
`binary_sensor.…_trip_status_true_if_trip_started_false_if_stopped`. Begründung
vollständig in Abschnitt AU. Kurz: das Trip-Signal beginnt erst, wenn Zündung **und**
Bewegung **und** `Start Speed` zusammenkommen, und endet erst 900 s nach Zündung aus —
die Pausentoleranz kommt damit vom Gerät, ohne dass wir einen zweiten Zeitgeber bauen.
Geschrieben über den echten Dienst (`entitaeten_schreiben`), nicht durch Hantieren an
der Datei: nur diese eine Rolle hat sich geändert, alle 23 Einträge stehen noch.
**Merkposten Werkzeug:** `hass.callService(...)` aus dem Browser-Werkzeug heraus
scheiterte reproduzierbar mit der nichtssagenden Meldung `3`, `hass.callApi("POST",
"services/…")` mit demselben Rumpf ging durch. Bei so einer Meldung also den REST-Weg
nehmen, statt den Dienst zu verdächtigen.
**Der Preis, bewusst in Kauf genommen:** das Fahrtende liegt 15 Minuten hinter dem
tatsächlichen — genau wie mit der früheren eigenen Pausentoleranz, also 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 nicht angefangen.
### Live-Erkennung und Import bewerten dasselbe Fenster wieder gleich
`MINDESTDAUER_S = 60` lebt jetzt in `verlauf.py` neben `UNPLAUSIBLE_KMH` und aus
demselben Grund. `fahrterkennung.fahrt_beenden()` prüft sie, bevor eine Fahrt entsteht;
der laufende Start wird trotzdem zurückgesetzt, sonst hinge er für immer.
Vorher stand die Zahl allein in `historienimport.py` — dessen eigener Kommentar den
Widerspruch sogar benannte („die Live-Erkennung legt sie zwar an"). Am 31.08. sind daraus
an einem Tag **elf Fahrten mit 0 km** entstanden, zwei davon mit null Sekunden Dauer, alle
als „vollständig" im Bestand. Die Umstellung oben nimmt die Ursache, diese Grenze fängt
den Rest ab; beides zusammen, nicht eines statt des anderen.
**Nicht angefasst: die elf bereits gespeicherten Fahrten.** Das ist die echte Historie des
Eigentümers, und deren Bereinigung ist seine Entscheidung — dieselbe Linie wie in
Abschnitt W.
### Das Panel erfand einen Literpreis von 0,00 €
`f.liters ? f.fuel_total_eur / f.liters : null` — bei einem automatisch erkannten
Tankvorgang stehen die Kosten noch nicht fest, und `null / 11.5` ist in JavaScript `0`.
Liste, Einzelbeleg und CSV zeigten daraufhin einen Preis, den niemand bezahlt hat. Neue
Hilfsfunktion `literpreis(f)` prüft beides, wie die App an derselben Stelle seit jeher;
vier Aufrufstellen umgestellt. Live bestätigt: die drei Zeilen vom 27./29.08. zeigen jetzt
nur noch die Liter.
### „Ø NaN €/l" und „Ø ∞ €/l" waren erreichbar
`schnittpreis()` teilte ohne Nennerprüfung: ein Zeitraum ohne Liter ergibt `0/0`, einer
mit Kosten aber ohne Liter eine Division durch null, und `de()` reicht beides durch.
Einzelne solche Datensätze gibt es real; es braucht nur einen Monat, der ausschließlich
daraus besteht. Jetzt `if (liter <= 0) return 0`, wie `format.ts` es seit jeher tut.
### Der Setup-Dialog ist wirklich modal
Die Tab-Leiste hat einen eigenen, früher registrierten Zuhörer und umging damit die Sperre
`if (setupOffen) return` des großen click-Handlers — obwohl der Dialog
`aria-modal="true"` trägt. Er schwebte anschließend über einer anderen Seite, mitsamt der
nicht gespeicherten Zuordnung. Dieselbe Sperre (plus `sheet`) steht jetzt auch dort.
Live geprüft: Tabwechsel bei offenem Dialog bleibt wirkungslos, nach „Abbrechen" gehen
alle fünf Tabs und eine Detailseite wieder.
### Verwaister Code entfernt
Panel: `bildSrc()`, `ring()`, `zustandsDaten()` (ohne Aufrufstelle) sowie
`.bildgrid`, `.platzhalter-datei` und die beiden `.row-fussnote`-Regeln — Reste dreier
abgelöster Entwürfe. App: 20 `.dm-*`-Regeln, darunter `dm-detail__zahl`, seit der
Umstellung auf `Fig` ohne Markup.
**Wie geprüft wurde, damit die Liste stimmt:** ein Skript liest jede Regel und sucht ihre
Verwender im Quelltext beider Codebasen. Zwei Fallen dabei, beide real aufgetreten: Namen
aus deutschen Kommentaren sehen wie Klassen aus, und `ads-dot--${status}` wird zur
Laufzeit zusammengesetzt und taucht in keiner Suche auf — sechs Design-System-Klassen
standen deshalb zunächst falsch auf der Liste und wurden vor dem Melden aussortiert.
### Geschlossen: Befund 07, Erstzulassung
`fahrzeug.erstzulassung` ist leer, deshalb steht bei der Hauptuntersuchung in beiden
Oberflächen „kein Eintrag im Wartungsplan" — ohne dieses Datum gibt es keinen Anker,
weder aus dem Wartungsplan noch aus einer Fahrzeugmeldung. Kein Codefehler.
**Am 2026-09-01 vom Eigentümer geschlossen:** das ist eine Eingabe des App-Nutzers unter
Einstellungen → Fahrzeug einrichten, kein offener Punkt des Projekts. Nicht wieder aufmachen.
### Was am Panel und an der App in Ordnung war
Vier von fünf Tabs textgleich (die fünfte Abweichung war Befund 02 selbst), Service und
Fahrzeugstatus ebenfalls, 153 `esc()`-Aufrufe ohne Lücke bei Freitext, beide
Frontend-Dateien als **Modul** geparst (siehe Abschnitt AQ), 165/165 Tests grün, in der
Konsole beider Oberflächen nur das bekannte ServiceWorker-Rauschen.
Verifiziert: `py_compile` auf den drei Backend-Dateien, `node --check` gegen die
Modul-Grammatik, `tsc --noEmit` sauber, 165/165, `vite build` sauber,
`audi_ha_test` auf `2026.8.31.5` sauber gestartet, und jede der fünf Codeänderungen
einzeln live nachgewiesen.
## AW. Der Kilometerstand einer Fahrt: zuletzt davor, und nie rückwärts (2026.8.31.6)
Gemessen an der Fahrt vom 31.08. abends, 22:03-22:13 Ortszeit. Vier unabhängige
Messungen desselben Wegs: GPS-Spur 6,74 km, Geschwindigkeit integriert 8,32 km,
geräteeigener Zähler 7 km — und der CAN-Kilometerstand, den die App nimmt, **3 km**.
Der Parameter `Total Vehicle Mileage (CAN)` fiel ab 22:06 aus den Datensätzen, während
alle übrigen CAN-Werte bis 22:12 weiterliefen. Das ist eine Sache des Geräts, nicht der
App; zurückgestellt auf Entscheidung des Eigentümers, ebenso die GPS-Strecke als zweite
Messung (`km_quelle="gps"`, der offene Vorbehalt im Modulkopf von `screening.py`).
Zwei Befunde im Code wurden dabei behoben.
### wert_bei() statt naechster_wert()
`_fahrt_screenen()` holte beide Kilometerstände mit `naechster_wert()` — dem zeitlich
nächstgelegenen Wert, gleich ob davor oder danach. Für einen Zählerstand ist das falsch,
und `wert_bei()`s eigener Docstring sagt es wörtlich. Der Rückblick
(`historienimport.py:182`) hat immer `wert_bei()` benutzt: dieselbe Fahrt wurde je nach
Weg unterschiedlich bewertet — genau das, was die gemeinsame Herkunft von
`UNPLAUSIBLE_KMH` und `MINDESTDAUER_S` in `verlauf.py` ausschließt.
Am echten Verlauf nachgewiesen, nicht an erfundenen Zahlen: für den Fahrtbeginn um
16:39:42 UTC lieferte `naechster_wert()` **21325** (der Stand von 16:40:53, also 71 s
*nach* dem Beginn — und aus einem anderen Fahrzeug), `wert_bei()` dagegen **209177**,
den letzten Stand davor.
### Rückwärtssprünge werden verworfen
`_vollstaendig()` prüfte nur nach oben (`> UNPLAUSIBLE_KMH`). Ein Rückwärtssprung
ergab eine **negative** Strecke und wurde als „vollständig" gespeichert.
`historienimport.py:185` verlangt seit jeher `odo_end >= odo_start`.
Die Prüfung sitzt an der **Strecke**, nicht an der Geschwindigkeit: `durchschnitt_kmh()`
liefert bei Dauer 0 `None`, eine Fahrt mit null Sekunden käme also an einer
Tempoprüfung vorbei — und solche Fahrten gibt es im Bestand.
**Sie steht dem Fahrzeugwechsel nicht im Weg.** Der Eigentümer bewegt den Dongle während
der Testphase zwischen Fahrzeugen, daher die springenden Stände (am 31.08. meldete der
CAN-Kilometerstand 21325 zwischen 209177 und 209267). Die Prüfung vergleicht Anfang und
Ende *derselben* Fahrt, und jedes Fahrzeug zählt innerhalb seiner eigenen Fahrt aufwärts;
in allen 23 gespeicherten Fahrten hätte sie nie ausgelöst. Sie greift nur, wenn die
beiden Enden aus verschiedenen Fahrzeugen stammen — und verwirft dann beide Werte, statt
Unsinn festzuschreiben: die Fahrt bleibt „offen" und wird beim nächsten Screening erneut
versucht.
Verifiziert im Testcontainer gegen den echten recorder-Verlauf: sieben Fälle, darunter
drei Regressionen (normale Fahrt, unmögliches Tempo, echte 0-km-Fahrt bei stillstehendem
Zähler). `py_compile` sauber, `audi_ha_test` auf `2026.8.31.6` sauber gestartet.
### Zwei Beobachtungen am Rande, nicht angefasst
**Die App legt bei jedem HA-Neustart eine Fahrt an**, wenn das Trip-Signal gerade „an"
ist — nicht über `nach_neustart_fortsetzen()` (das schließt nur), sondern weil die
Entität beim Start neu registriert wird und der Zuhörer das als Wechsel nach „an" liest.
Als Beginn nimmt er **jetzt**. Genau daraus stammt die Fahrt `30.08. 19:43 -> 31.08.
08:37, 12,9 h, 0 km`. Das ist die offene Frage vom 31.08. („woher kommt der Start?") —
in der Praxis beantwortet sie der Code bereits, nur mit der falschen Antwort.
**`_verbrauch_screenen()` liest den Tankstand weiter mit `naechster_wert()`**
(`screening.py:246`), während der Import dort `wert_bei()` nimmt. Dieselbe Doppelung
wie oben, eine Ebene tiefer. Bewusst außerhalb des Auftrags gelassen.
## AX. Phantomfahrten beim Neustart, und der Tankstand zieht nach (2026.9.1.1)
### Jeder Neustart legte eine Fahrt an
`zuendung_geaendert()` prüfte den neuen Zustand, aber nicht den alten. Beim Hochfahren von
Home Assistant taucht die Zündungs-Entität neu auf; das kommt als Zustandswechsel mit
`old_state = None` an. Steht das Trip-Signal ohnehin auf „an" — beim FMM003 der Normalfall,
solange die Zündung nicht sauber aus war —, las der Beobachter das als „Zündung ging gerade an"
und legte eine Fahrt an, deren **Beginn schlicht der Zeitpunkt des Neustarts** war.
Daher der Eintrag `30.08. 19:43 → 31.08. 08:37, 12,9 h, 0 km`, und daher die Fahrt, die am
31.08. um 21:18:45 UTC beim Ausliefern von `2026.8.31.6` entstand.
Die Prüfung ist `if alt is None: return` — dieselbe, die `_kilometerstand_geaendert()` im
Koordinator seit jeher hat. Eine wirklich laufende Fahrt geht dabei nicht verloren: deren Beginn
liegt im Store und wird von `nach_neustart_fortsetzen()` aufgenommen.
Nachgewiesen: zwei Neustarts hintereinander bei `trip_status = on` und leerem Zwischenstand,
**null** `Fahrt gestartet`-Zeilen im Protokoll und `fahrt_start_ts` bleibt `null`. Vorher
entstand unter genau diesen Bedingungen jedes Mal eine.
**Was das nicht löst, und der Eigentümer ausdrücklich will:** einen Beginn und ein Ende, die
*aus den Daten des Dongles* stammen. Heute ist der Beginn `datetime.now()` im Moment, in dem
Home Assistant den Wechsel verarbeitet — gemessen 0,5 bis 139 s nach dem Zeitstempel des Geräts,
nach einer Funklücke Minuten. Und ein Wechsel, den es nie gab, wird nie erkannt: die echte Fahrt
vom 31.08., 22:0322:13 Ortszeit, hat die Live-Erkennung nicht gesehen, weil `trip_status`
schon seit 18:39 auf „an" stand. Eigenes Arbeitspaket, hängt an der Konfiguration des Geräts.
### Der Tankstand liest jetzt wie der Kilometerstand
`_verbrauch_screenen()` holte die Literstände weiter mit `naechster_wert()`, während
`historienimport.py` `wert_bei()` nimmt — dieselbe Doppelbewertung wie in Abschnitt AW, eine
Ebene tiefer. Der zeitlich nächstgelegene Stand zum Fahrtbeginn kann schon in der Fahrt liegen
und hätte dann einen Teil des Verbrauchs vorweggenommen.
`naechster_wert()` bleibt an einer Stelle richtig und steht dort weiter: Breiten- und
Längengrad in `_position_screenen()` sind keine Zähler.
### Nullfahrten entfernt
Auf Anweisung des Eigentümers („0 km Fahrten sind keine Fahrten und damit zu entfernen") wurden
am 2026-09-01 die zwölf Fahrten mit `distance_km == 0` gelöscht — über den eigenen Dienst
`fahrt_loeschen`, nicht durch Hantieren an der Datei, und nach einem `backup_jetzt`
(`backups/20260901_072541`). Bestand danach: 11 Fahrten, keine mit 0 km.
Das ist eine Abkehr von der Linie in Abschnitt W und AV, wo Nullfahrten bewusst stehen blieben.
Sie gilt, weil der Eigentümer sie ausdrücklich getroffen hat — nicht als neue Gewohnheit.
## AY. Der Dongle ist jetzt führend für Fahrtbeginn und -ende (2026.9.1.2)
Bis hierher war der Fahrtbeginn `datetime.now()` — die Uhrzeit, zu der Home Assistant den
Zustandswechsel *verarbeitet*. Hat das Gerät gepuffert (Funkloch, Tiefgarage, Tiefschlaf), liegt
die Wahrheit beliebig weit davor. Am 01.09.2026 gemessen: der Datensatz mit dem Fahrtende trug
die Gerätezeit **07:29:04** und kam um **07:35:23** an — sechs Minuten und 19 Sekunden später.
Neue Sensorrolle **`MELDEZEIT_SENSOR`** (`…_message_timestamp`, Unix-Sekunden). Ist sie
zugeordnet, stammen Beginn und Ende der Fahrt daraus statt aus der Ankunftszeit.
### Die Zeitzonenfrage, gemessen statt vermutet
Der Zeitstempel ist **UTC**, keine Ortszeit. Der Versatz zur UTC-Uhr von Home Assistant lag bei
Sekunden (1,3 s / 2,3 s / 232,8 s nach einer Funklücke), nicht bei zwei Stunden. Eine
Zeitzonenumrechnung wäre nicht nur unnötig — sie baute einen Zwei-Stunden-Fehler ein. Die
Gerätekonfiguration stützt das: `107 = After Time Sync` (das Gerät speichert erst, wenn seine
Uhr steht), NTP-Resync alle drei Stunden.
### Die Wartezeit, und warum sie da sein muss
Die flespi-Integration setzt die Entitäten eines Datensatzes **nacheinander**: gemessen stand die
Zündung um 07:35:23.633 und die zugehörige Meldezeit 2 ms später um 07:35:23.635. Wer sofort
liest, bekommt den Zeitstempel des *vorigen* Datensatzes — beim Fahren zehn Sekunden daneben, im
Stand aber möglicherweise **Stunden**, weil das Gerät dort nur alle paar Stunden einen Satz
schreibt (siehe die Data-Acquisition-Werte unten).
`_geraetezeit()` wartet deshalb bis zu `MELDEZEIT_FRIST_S = 2.0` in 50-ms-Schritten darauf,
dass `last_updated` der Meldezeit den Zeitpunkt des Zündungswechsels erreicht. Dafür reicht der
Koordinator die Ereigniszeit jetzt mit durch (`zuendung_geaendert(..., ereigniszeit)`).
Zwei Notbremsen, beide live ausgelöst und im Protokoll nachgewiesen:
- **Zukunft** (mehr als 5 Minuten voraus) → verworfen, Ankunftszeit gilt. Eine falsch gestellte
Geräteuhr darf die Auswertung nicht mitreißen.
- **Ende vor dem Beginn** → verworfen, Ankunftszeit gilt.
### Live nachgewiesen, alle vier Wege
| Fall | Ergebnis |
|---|---|
| Beginn mit Gerätezeit | Fahrt beginnt `08:04:40`, Ankunft war `08:11:04` — 6:24 früher |
| Ende mit Gerätezeit | Fahrt `08:04:40 → 08:09:22`, 282 s; Ankunft war `08:11:24` |
| Meldezeit vor dem Beginn | Warnung, Ankunft genommen, danach von `MINDESTDAUER_S` verworfen |
| Meldezeit in der Zukunft | Warnung, Ankunft genommen |
Geprüft über den echten Dienst `entitaeten_schreiben` (Rolle zugeordnet, alle übrigen 35
Einträge unangetastet); die dabei entstandene Testfahrt wurde über `fahrt_loeschen` wieder
entfernt. Bestand danach unverändert 11 Fahrten, keine mit 0 km, keine offene Fahrt.
### Was das NICHT löst — die Gerätekonfiguration
Die `.cfg` vom 01.09.2026 (gzip-gepackte Klartext-Parameterliste, mit `gunzip` lesbar) erklärt
den Rest, und er liegt nicht im Code:
| ID | Bedeutung | Wert | Standard |
|---|---|---|---|
| 102 / 103 | Sleep settings / Timeout | Deep Sleep / 15 min | Deep Sleep / 1 min |
| 10000 | *On Stop* Min Period | **43.200 s (12 h)** | 3.600 s |
| 10005 | *On Stop* Send Period | **86.400 s (24 h)** | 120 s |
| 10050 / 10055 | *Moving* Min Period / Send Period | 10 s / 60 s | 300 s / — |
| 11800 | Trip-Szenario | **1 = Low priority** | 0 |
| 11803 / 11804 | Start speed / Ignition off timeout | 3 km/h / 900 s | 5 / 60 |
| 11806 | Odometer Calculation source | **1 = OBD** | 0 = GNSS |
| 101 | Ignition settings | 8 = Engine RPM | 4 = Power Voltage |
Zwei Werte bestimmen alles Weitere:
1. **`11800 = Low priority`.** Laut Teltonika-Wiki macht Low priority „an additional record",
High priority dagegen „sends event packet **immediately** to the server using GPRS". Fahrtbeginn
und -ende warten also auf das nächste Sendefenster — im Stand bis zu 24 Stunden.
2. **`11806 = OBD`.** Der Kilometerzähler des Geräts ist damit eine Kopie des Fahrzeugwerts und
kann nie feiner sein als der: **ganze Kilometer**. Mit `GNSS` lieferte er nachweislich
Meterauflösung (209177,016 → ,019 → ,022). Gemessen an allen vom CAN gelesenen Streckenwerten
dieses Fahrzeugs gibt es **keinen** mit einer Nachkommastelle: Distanz seit Tanken, Distanz seit
Fehlerlöschung und Reichweite springen in ganzen Kilometern, die Servicedistanz in
Zehnerschritten. `segment_mileage` (die Strecke seit dem letzten Datensatz, `11802 = Between
records`) folgt derselben Quelle: mit GNSS Meter, mit OBD ganze Kilometer.
### Trip und Zündung sind zwei Rollen, nicht eine (2026.9.1.3)
Bis hierher zeigte `ZUENDUNG_SENSOR` auf das Trip-Signal — im Setup stand dann „Zündung/ACC-Status"
über einer Trip-Entität, und die echte Zündung war nirgends mehr zugeordnet. Der Eigentümer hat das
getrennt haben wollen, zu Recht: es sind zwei verschiedene Aussagen.
- **`TRIP_SENSOR`** (neu) — löst Fahrtbeginn und -ende aus. Das gefilterte Signal des Geräts.
- **`ZUENDUNG_SENSOR`** — nur noch die Anzeige „fährt/steht" im Fahrzeugstatus und auf der
Standortkarte. Am Gerät steht die Zündungsquelle jetzt auf `101 = 8` (Engine RPM); ACC und
Bordspannung sind raus, weil unzuverlässig. Der Entitätsname trägt „acc" weiterhin im Text, der
Inhalt kommt aber aus der Drehzahl.
`verlauf.fahrtsignal(werte)` ist die eine Stelle, die entscheidet, was Fahrten auslöst: der
Trip-Status, wenn zugeordnet, sonst die Zündung. Damit laufen bestehende Installationen unverändert
weiter, und Livepfad (`fahrterkennung.py`, `koordinator.py`) und Rückblick
(`historienimport.py`) können nicht auseinanderlaufen — dieselbe Regel wie bei
`UNPLAUSIBLE_KMH` und `MINDESTDAUER_S`.
Der Koordinator beobachtet beide: das Fahrtsignal für die Erkennung, die Zündung nur, um den Status
sofort neu zu veröffentlichen. Zeigen beide auf dieselbe Entität, entfällt der zweite Beobachter.
### Auch der Tankvorgang trägt jetzt die Gerätezeit
`tankerkennung._automatisch_anlegen()` stempelte mit `datetime.now()` — derselbe Fehler wie bei
den Fahrten, nur unbemerkt. `geraetezeit()` liegt deshalb jetzt in `verlauf.py` und wird von
beiden benutzt; `fahrterkennung._geraetezeit()` ist nur noch eine Weiterleitung.
Live nachgewiesen: ein aus einem gepufferten Datensatz erkannter Tankvorgang trägt `08:18:45`,
während Home Assistant ihn um `08:38:51` verarbeitet hat — **20 Minuten früher**.
**Der Livetest hat dabei einen echten Fehler gefangen**, den `py_compile` nicht sehen konnte: der
Import von `geraetezeit` in `tankerkennung.py` fehlte, und der `NameError` trat erst beim
Anlegen auf. Ein weiterer Beleg für die Regel dieses Projekts — kompiliert ist nicht verifiziert.
### Was am Gerät noch offen ist
`11800` steht jetzt auf High priority (Fahrtgrenzen werden sofort gesendet), `11806` wird auf
GNSS umgestellt. **Damit ist die Streckenrechnung noch nicht angepasst:** geplant ist, die Strecke
aus dem GNSS-Zähler zu nehmen (Meterauflösung) und den CAN-Kilometerstand als Anker zu behalten, an
dem sich Drift erkennen lässt. Noch nicht gebaut — erst messen, wenn das Gerät wirklich GNSS liefert,
sonst testet man gegen ganze Kilometer.
`11807` (Odometer Value) steht auf 0. Für die geplante Rechnung ist das gleichgültig, weil sie mit
Zuwächsen arbeitet; wer den absoluten Wert lesbar haben will, trägt dort den echten Kilometerstand ein.
## AZ. Das Fahrtende ohne den Nachlauf des Geräts (2026.9.1.4)
Das Trip-Signal endet nicht mit der Zündung, sondern `Ignition OFF Timeout` später — beim
FMM003 900 s. Als Auslöser ist es deshalb ideal (es prellt nicht, es bringt die Pausentoleranz
mit), aber das Fahrtende lag systematisch **15 Minuten zu spät**.
`_echtes_ende()` zieht `NACHLAUF_S = 900` ab. Die Rechnung stammt vollständig aus der Logik des
Geräts und überlebt deshalb ein Funkloch: der Datensatz kommt später, trägt aber seine eigene
Zeit (Abschnitt AY).
**Die Klemme ist der wichtigere Teil.** War das Signal nicht länger als der Nachlauf, wurde nicht
gefahren — dann wird das Ende auf den Beginn geklemmt und `MINDESTDAUER_S` verwirft den Vorgang.
Ohne sie entstünde aus dreißig Sekunden Zündung eine Viertelstunde Fahrt, also genau die Sorte
Nulleintrag, die Abschnitt AW ausgeräumt hat.
**Warum keine Einstellung in der Oberfläche.** Die Zahl gehört zum Gerät, nicht zum Fahrzeug und
nicht zum Nutzer — dieselbe Linie wie `UNPLAUSIBLE_KMH` und `MIN_STRECKE_VERBRAUCH_KM`. flespi
kann die Gerätekonfiguration zwar liefern (`GET /gw/devices/{id}/settings/all`, 127 benannte
Einstellungen, darunter `trip_scenario.ign_off_timeout`), aber dafür bräuchte die Integration
einen zweiten Zugang samt Token; sie liest heute ausschließlich Telemetrie. Verworfen als
unverhältnismäßig.
### Nachtrag (2026.9.1.9): der Nachlauf gilt nur, wenn das Trip-Element ihn erzeugt
`_echtes_ende()` zog `NACHLAUF_S` bedingungslos ab. Das ist richtig, solange die Fahrten vom
Trip-Signal ausgelöst werden — aber `verlauf.fahrtsignal()` fällt auf die **Zündung** zurück, wenn
`TRIP_SENSOR` nicht zugeordnet ist, und die hat keinen Nachlauf.
In dieser Konstellation hätte jede Fahrt fünfzehn Minuten verloren und jede kürzere wäre ganz
verschwunden — der Klemmzweig hätte sie auf Dauer 0 gesetzt und `MINDESTDAUER_S` verworfen. Genau
die Konstellation, in die eine bestehende Installation nach dem Update ohne Zutun läuft: die neue
Rolle ist leer, bis jemand sie zuordnet.
`if not k.zuordnung.werte.TRIP_SENSOR: return signal_ende`. Gefunden beim Durchdenken des
Update-Wegs für die reale Instanz, nicht durch einen Fehlerbericht.
Nachgewiesen: `TRIP_SENSOR` geleert, Zündung fünf Minuten an → Fahrt mit **300 s** angelegt statt
verworfen. Mit gesetztem `TRIP_SENSOR` bleibt der Abzug wie in Abschnitt AZ nachgewiesen.
### Die Gegenprobe meldet, statt zu korrigieren
`_nachlauf_gegenpruefen()` vergleicht das gerechnete Ende mit dem letzten beobachteten
Zündungswechsel auf „aus" und **warnt ab 120 s Abweichung, ohne etwas zu ändern**.
Der Grund für „nur warnen": wie genau „Zündung aus" bekannt ist, hängt am Operanden des
Zündungs-I/O-Elements. Er steht in der `.cfg` auf **3**; nach der Reihenfolge in der
Teltonika-Dokumentation (On Exit, On Entrance, On Both, Monitoring, On Hysteresis, On Change, On
Delta Change) ist das **Monitoring** — laut Wiki „No event at all. Values are recorded only when
other triggers worked". Der Wechsel erzeugte dann keinen eigenen Datensatz, sondern führe im
nächsten mit, und der beobachtete Zeitpunkt wäre zu spät statt exakt. Der Standard ist 5 (On
Change). **Offen und am Gerät zu prüfen** — explizit nummeriert steht die Operandenliste nicht in
der Dokumentation, und die Messung ist durch eigene Testschaltvorgänge verunreinigt. Steht der
Operand tatsächlich auf Monitoring, wäre `On Change` die bessere Einstellung: ein zusätzlicher
Datensatz je Zündungswechsel, keine zusätzliche Übertragung (Priorität bleibt Low).
Verifiziert in `audi_ha_test`:
| Fall | Ergebnis |
|---|---|
| Signal 1200 s (5 min Fahrt + Nachlauf) | Fahrt **300 s** gespeichert |
| Signal 930 s (30 s Zündung) | **keine** Fahrt, Bestand 12 → 12 |
| Gegenprobe | Warnung mit 887 s Abweichung ausgelöst und protokolliert |
Testfahrt anschließend über `fahrt_loeschen` entfernt; Bestand unverändert 11 Fahrten.
## BA. Zwei Rollen für die GNSS-Strecke, vorbereitet ohne Verbraucher (2026.9.1.5)
`11806` steht seit dem 01.09.2026 auf **GNSS**, und der Zähler liefert sofort, was er soll:
`total_calculated_mileage = 0.034` kurz nach dem Zurücksetzen — **Meterauflösung**, gegenüber
147 Schritten von exakt 1,0 km beim CAN-Wert über die gesamte Aufzeichnung.
Zwei neue Sensorrollen, **beide noch ohne Verbraucher**:
- `GNSS_KM_SENSOR` → `total_calculated_mileage`. Der robustere der beiden für eine
Fahrtstrecke: ein verlorener Datensatz verfälscht die Differenz nicht, der Zähler läuft weiter.
- `GNSS_TEILSTRECKE_SENSOR` → `segment_mileage` (Strecke seit dem letzten Datensatz,
`11802 = Between records`). Taugt für eine Summe nur, wenn kein Satz fehlt — zeigt dafür, **wo**
Strecke verloren ging, und ist damit die bessere Gegenprobe.
**Warum leer und ohne Wirkung:** die Rechnung soll gegen echte GNSS-Daten entstehen, nicht gegen
erfundene Reihen. Die Rollen sind angelegt und im Testcontainer bereits zugeordnet, damit nach der
ersten echten Fahrt sofort gemessen werden kann. Solange kein Code sie liest, ändert die Zuordnung
nichts.
**Die Absicht dahinter, damit sie nicht verlorengeht:** `KM_SENSOR` bleibt der Anker — er ist der
Tacho des Fahrzeugs und driftet nicht. Der GNSS-Zähler liefert die Nachkommastelle dazwischen und
verliert Strecke, wo kein Empfang ist. Beides zusammen ergibt eine Strecke auf 100 m genau, die
trotzdem am Fahrzeugwert hängt. Der alte Warnhinweis bei `KM_SENSOR` („niemals
`total_calculated_mileage` zuordnen") gilt unverändert — er zeigt jetzt auf die neue Rolle.
**Vor dem Zuordnen prüfen:** steht `11802` auf `Continuous` statt `Between records`, zählt
`segment_mileage` die laufende Fahrt statt der Teilstrecke und die zweite Rolle ist unbrauchbar.
Geprüft: 27 FELDER-Einträge, 27 Dataclass-Felder, keine Abweichung in beide Richtungen; Zuordnung
über den echten Dienst geschrieben, alle 27 Einträge stehen.
## BB. „Geparkt seit" kommt von der Zündung, nicht aus der Fahrtenliste (2026.9.1.6)
Die Anzeige rechnete `jetzt ts_end` der jüngsten Fahrt. Das ist keine Aussage über das Parken,
sondern über die **Fahrterkennung** — und die kann daneben liegen: am 01.09.2026 stand dort
„Geparkt seit 2 Tg. 16 Std.", während am Vorabend nachweislich 7 km gefahren wurden. Die Fahrt
fehlte im Bestand, weil `trip_status` seit 18:39 festhing.
Neu: der Koordinator merkt sich den Zeitpunkt, zu dem die Zündung zuletzt auf „aus" ging — **in
der Zeit des Geräts** (`verlauf.geraetezeit()`), nicht in der Ankunftszeit. Er liegt im
Laufzeit-Store neben `fahrt_start_ts` und überlebt einen Neustart; `veroeffentlichung` gibt ihn
als `geparkt_seit` heraus.
**Kein Rückfall auf die Fahrtenliste.** Ist der Wert `null`, zeigen beide Oberflächen „Fahrzeug
steht, Parkdauer unbekannt". Ausdrückliche Ansage des Eigentümers: eine falsche Zahl ist schlechter
als keine.
### Derselbe Neustart-Fehler, zum zweiten Mal
Beim Ausliefern fiel im Protokoll auf: `Zündung aus - geparkt seit …` **bei jedem Neustart**. Der
Zuhörer las die Registrierung der Entität als Wechsel — exakt die Phantomfahrt aus Abschnitt AX, nur
an anderer Stelle. `if alt is None: return` behebt es; der gespeicherte Wert überlebt den Neustart
ohnehin. **Merkposten:** jeder neue Zustandszuhörer in diesem Projekt braucht diese Prüfung.
Live nachgewiesen: Neustart lässt `09:34:15` unverändert · Zündung an → `null` · Zündung aus mit
einer Gerätezeit von vor 40 Minuten → `09:04:57` statt `jetzt`.
Companion-App gleichgezogen (Paritätsregel): `profilAdapter` um `geparktSeit` erweitert,
`standortZustand()` braucht die Fahrtenliste nicht mehr. `tsc --noEmit` sauber, 165/165 Tests grün.
### Nachtrag (2026.9.1.7): der Parkbeginn überlebt jetzt auch einen Neustart über Nacht
Der Zuhörer bekommt den Wechsel nur mit, wenn Home Assistant läuft. Ging die Zündung aus, während
es unten war — über Nacht der Normalfall —, feuerte nichts, und die Registrierung beim Hochfahren
wird ja bewusst ignoriert. Ergebnis: „Parkdauer unbekannt", obwohl der Zeitpunkt im recorder steht.
`_parkbeginn_nachholen()` läuft einmal in `_nach_start` und schlägt ihn dort nach — **nur**, wenn
kein Wert gespeichert ist und die Zündung gerade auf „aus" steht. Genommen wird der letzte echte
Wechsel **an → aus** im Fenster von sieben Tagen; gibt es keinen, bleibt es bei „unbekannt", statt
den Rand des Fensters zu erfinden.
Und wieder mit der **Meldezeit desselben Datensatzes**, nicht mit seiner Ankunftszeit — dieselbe
Begründung wie in `geraetezeit()`, hier nur rückblickend statt live.
Live nachgewiesen, und der Test trennt genau die beiden Zeiten: Speicher geleert, neu gestartet →
`Parkbeginn aus der Aufzeichnung nachgeholt: 09:04:57`. Das ist die Gerätezeit; angekommen war der
Datensatz um 09:44. Ein zweiter Neustart rührt den Wert nicht an und schreibt keine Zeile.
### Genauigkeit hängt am Operanden des Geräts
Der Zeitpunkt ist nur so genau, wie das Gerät ihn meldet. Stand der Operand des
Zündungselements auf **Monitoring** (3), erzeugte ein Wechsel keinen eigenen Datensatz und der
Zeitstempel wäre der des nächsten — beim Abstellen also bis zu 900 s zu spät. Der Eigentümer hat
Ignition und Movement am 01.09.2026 auf **On Change** gestellt; damit ist der Zeitpunkt exakt.
Priorität bleibt Low, es kostet also keine zusätzliche Funkverbindung, nur einen Datensatz mehr in
der Warteschlange.
### Wichtig für die noch offene Streckenrechnung: der GNSS-Zähler driftet im Stand
Die 0,034 km, die `total_calculated_mileage` kurz nach dem Zurücksetzen zeigte, waren **kein
Beleg für Auflösung, sondern GPS-Drift** — der Dongle lag unbewegt auf dem Schreibtisch (Angabe des
Eigentümers, 01.09.2026). Static Navigation ist eingeschaltet (`106 = 1`, Quelle `112 = Movement`)
und friert die Position ein, offenbar aber nicht den Kilometerzähler.
**Folge für Abschnitt BA:** die GNSS-Zuwächse dürfen nicht ungefiltert summiert werden. Sie brauchen
ein Tor — nur zählen, wenn Bewegung gemeldet ist. Dass `Movement` jetzt auf On Change steht, macht
genau dieses Tor erst möglich. Vor der ersten echten Fahrt ist die Größenordnung der Drift unbekannt;
sie ist als Erstes zu messen.
## BC. „502" beim Neustart, und das OTA-Bündel hing sieben Versionen zurück (2026.9.1.8)
### Die App meldete einen Fehler, wo keiner war
Nach „Jetzt neu starten" sprang der Knopf zurück und darunter stand **502** — während Home
Assistant ordnungsgemäß hochfuhr. Der Nutzer musste annehmen, der Neustart sei gescheitert, und
drückte erneut. Vom Eigentümer gemeldet am 2026-09-01, und schon in der Fassung vom 31.08. der
zweite Teil desselben Berichts.
Ursache: `updateNeustartAusloesen()` behandelte **jeden** Fehlschlag als Fehler. Über einen
Vorschaltserver kommt beim Neustart aber kein Verbindungsabbruch zurück, sondern eine saubere
502/503/504 — der Proxy antwortet, Home Assistant noch nicht. Genau der Fall, für den
`ApiFehler.istVoruebergehend` seit dem 31.08. existiert; er wurde hier nur nicht gefragt.
Jetzt: `if (fehler instanceof ApiFehler && fehler.istVoruebergehend) return` — der Bildschirm
bleibt auf „Home Assistant startet neu …", und der bestehende Effekt räumt ihn ab, sobald die
Verbindung wieder steht. Dieselbe Einstufung wie im `DatenKontext`, damit Anzeige und
Wiederverbindung denselben Fall gleich bewerten.
**Im Panel war es kein Fehler, aber die Lücke war dieselbe.** Es ruft über die WebSocket-Verbindung
(`HASS.callService`), da bricht die Leitung ab statt 502 zu liefern — der Kommentar dort sagte das
sogar. „Normalerweise" ist aber keine Garantie, sobald ein Vorschaltserver dazwischen steht.
Dieselbe Toleranz deshalb auch dort, damit beide Oberflächen denselben Fall gleich bewerten.
### Das OTA-Bündel stand noch auf 2026.8.31.5
Der Eigentümer meldete: Kopfzeile „App ist älter als der Server", Kachel „App ist aktuell 31.5",
Integration steht auf 1.7, und „Update prüfen" findet nichts. **Kein Fehler — die Anzeige sagte die
Wahrheit.** Sieben Versionen lang wurde nur die Integration ausgeliefert; `npm run ota` lief seit
dem 31.08. um 20:03 nicht mehr.
**Merkposten, der in VERSIONIERUNG.md fehlt:** eine Änderung an `companion-app/src` ist erst dann
beim Nutzer, wenn das Bündel neu gebaut wurde. Das Erhöhen der Manifest-Version allein reicht
nicht — sie beschreibt dann eine App, die es nicht gibt. Bei jeder Änderung an der App gehört
`npm run ota` dazu, nicht nur der Versionssprung.
Nachgewiesen: Manifest, `bundle.json` und die ausgelieferte Zip tragen alle `2026.9.1.8` und
denselben SHA-256 `c2f9192e…`; `tsc --noEmit` sauber, 165/165 Tests grün, Panel als **Modul**
geparst (Abschnitt AQ), `audi_ha_test` fehlerfrei gestartet.
## BE. Die Fahrtstrecke auf 100 Meter: CAN als Anker, GNSS als Nachkommastelle (2026.9.1.10)
Zwei echte Fahrten am 01.09.2026 haben die Datenform geliefert, auf die Abschnitt BA gewartet hat.
Beide Male schließt die Rechnung, und beide Male auf eine andere Weise — deshalb war das Warten
richtig.
### Was gemessen wurde
| Quelle | Fahrt 1 (13:42, mit Rücksetzung) | Fahrt 2 (16:20, sauber) |
|---|---|---|
| **Zählerzuwächse mit Rücksprungbehandlung** | **6,958** | **6,816** |
| GPS-Spur | 7,01 | 6,74 |
| Geschwindigkeit integriert | 6,91 | 6,70 |
| Teilstrecken summiert | 6,928 | 6,614 |
| CAN-Kilometerstand (ganze km) | 7 | 6 |
| Google Maps | 7,1 | — |
### Der Zähler, nicht die Teilstrecken — und warum ich zweimal falsch lag
Erst hielt ich den **Gesamtzähler** für robuster (Abschnitt BA), nach Fahrt 1 die **Teilstrecken**,
nach Fahrt 2 wieder den Zähler. Die Auflösung: es sind keine zwei Messungen. An jedem einzelnen
Datensatz gilt `Teilstrecke == Differenz des Zählers`, auf drei Nachkommastellen genau. Sie
unterscheiden sich nur darin, **wie sie kaputtgehen**:
- Der **Zähler** verliert bei einer Rücksetzung alles Vorherige (Fahrt 1: 0,415 km).
- Die **Teilstrecken** verlieren einzelne Datensätze — in Fahrt 2 waren **4 von 50 null**, obwohl
gefahren wurde, Summe dadurch 0,2 km zu niedrig.
`strecke_aus_zaehler()` summiert deshalb die **Zuwächse** und behandelt einen Rücksprung als
Rücksetzung (der neue Wert ist dann selbst der Zuwachs). Damit fällt beides weg: unempfindlich
gegen Rücksetzungen, und keine verlorenen Datensätze. Ohne Rücksetzung degeneriert es zu Ende
minus Anfang.
Der Eigentümer hat dazu angemerkt, dass im Endzustand alle Fahrten wie Fahrt 2 verlaufen — er
steckt den Dongle nur in der Entwicklungsphase um. Die Rücksprungbehandlung ist damit kein
Kernstück mehr, sondern ein billiges Netz für Batteriewechsel und Werkstattbesuch.
### Woher die Rücksetzung mitten in der Fahrt kam
Um 13:43:20, bei 65 km/h und laufendem Motor, ging `total_calculated_mileage` von 0,415 auf 0. Im
**selben Datensatz** zählte `total_vehicle_mileage_read_from_can` zum ersten Mal hoch
(61809 → 61810), ebenso `distance_traveled_since_codes_cleared`. Kein Verbindungsabbruch, kein
Neustart, Bordspannung stabil bei 14,4 V — das Gerät übernahm offenbar seinen konfigurierten
Startwert (`11807 = 0`), als der CAN-Kilometerstand erstmals gültig wurde. Nicht bewiesen; ein
Prüfstein wäre, `11807` auf den echten Kilometerstand zu setzen und zu sehen, ob der Sprung dann
dorthin geht statt auf null.
### Die Verfeinerung ersetzt nicht, sie prüft
`_gnss_verfeinern()` setzt `distance_km` nur dann auf den feinen Wert, wenn er **innerhalb der
Rundungsunschärfe des Ankers** liegt (`GNSS_TOLERANZ_KM = 1.0` — beide Enden des CAN-Werts sind auf
ganze Kilometer gerundet). Sonst behält der Fahrzeugwert recht und es bleibt bei `"odometer"`.
Live nachgewiesen, beides im selben Lauf:
- Fahrt 2: `Strecke auf 6.896 km verfeinert (Kilometerstand sagte 7.0 km)`, `km_quelle` jetzt
`"odometer+gnss"`.
- Drei ältere Fahrten aus der Zeit vor der GNSS-Umstellung: `GNSS-Strecke 209178.0 km weicht um
mehr als 1.0 km vom Kilometerstand (21.0 km) ab - der Fahrzeugwert bleibt stehen`. Genau dafür
ist die Schranke da.
### Nachtrag: kein Skalenfehler — 0,3 % zwischen GNSS und Rädern
Nach den beiden Fahrten stand der Verdacht im Raum, der GNSS-Zähler messe systematisch 1,52 % zu
kurz (gegen Streckenschätzungen aus Google Maps). **Das war falsch**, und die bessere Messung kam vom
Eigentümer: zwischen zwei Sprüngen des CAN-Kilometerstands ist das Fahrzeug **exakt 1,000 km**
gefahren. Was der GNSS-Zähler in derselben Spanne zählt, ergibt den Maßstab ohne jede Schätzung.
**Fahrt 2, zwischen erstem und letztem Kilometersprung: Räder 5,000 km, GNSS 5,015 km — Verhältnis
0,9970.** Fünfzehn Meter auf fünf Kilometer.
Fahrt 1 liefert 1,0879, ist aber unbrauchbar: ihr erster Kilometersprung *ist* der Datensatz mit der
Rücksetzung, die erste Runde zählt deshalb nur 0,391 km. Ohne sie 5,000 gegen 5,124 km.
**Einzelne Runden taugen nicht.** Sie streuen von 0,849 bis 1,194 km — ein Kilometersprung kann
irgendwo in den zehn Sekunden zwischen zwei Datensätzen liegen, bei 90 km/h also 250 m daneben; zwei
Ränder je Runde ergeben ±50 %. Nur die Summe über mehrere Runden am Stück trägt.
**Ein Korrekturfaktor ist damit vom Tisch**, bevor er gebaut wurde. Und die vermeintlich fehlenden
300 m der Fahrt 2 sind keine Messabweichung: der Tacho des Fahrzeugs lief über diese Fahrt von 61817
auf 61823, die echte Strecke liegt also zwischen 6 und 7 km — der GNSS-Wert von 6,896 km sitzt
mitten darin. Die Erwartung von 7,2 km war die Schätzung, nicht die Messung.
**Merkposten fürs nächste Mal:** eine Streckenschätzung aus einer Karten-App taugt nicht als
Referenz für Prozentfragen. Der Kilometerstand des Fahrzeugs liefert mit seinen eigenen Sprüngen ein
Lineal, das keine Annahme braucht — man muss nur zwischen den Sprüngen messen statt an den Rändern
der Fahrt.
### Nebenbei bestätigt
- **`NACHLAUF_S = 900` stimmt.** Fahrt 2: Zündung aus 16:33:39, Trip-Signal aus 16:48:44 — 905 s
Abstand. Das gespeicherte Fahrtende steht auf **16:33:36**, also drei Sekunden neben der
tatsächlichen Zündung.
- **Der Rückwärtssprung-Schutz griff live** und genau wie vorhergesagt: Fahrt 1 lief über einen
Fahrzeugwechsel (209177 → 61816), `-147361 km` wurden verworfen, die Fahrt blieb „offen".
- **Die RPM-Zündung prellt nicht** — zwei echte Wechsel über beide Fahrten, kein Paar unter zwei
Sekunden. `Avg Const = 10` (1 s) bleibt.
- **Das Bewegungstor funktioniert** seit `138 = 10` (Beschleunigungssensor + CAN Speed statt GNSS):
`movement` meldet wieder Stillstand, vorher hing es tagelang auf „on".
Verifiziert: `strecke_aus_zaehler()` gegen beide echten Fahrten plus sechs Randfälle
(zwei Rücksetzungen, ein Punkt, leer, unlesbare Werte); `py_compile`, `tsc --noEmit` sauber,
Bündel und Manifest auf `2026.9.1.10` mit demselben SHA-256.
## BF. Der Rueckblick legt keine Nullfahrten mehr an — und misst jetzt auf Meter (2026.9.1.12.14)
Der Eigentuemer hatte auf der realen Instanz alle Nullfahrten geloescht, und **bei jedem Import kam
eine davon wieder**: 31.08.2026, 14:4815:21, 33 Minuten, 209177 → 209177 km, vmax 3 km/h.
`MINDESTDAUER_S` liess sie durch, weil sie mit 33 Minuten lang genug war; eine Pruefung auf die
Strecke gab es im Rueckblick nie.
### Erst zu grob gedacht, dann korrigiert
Mein erster Wurf verwarf jedes Fenster mit `distanz == 0`. Der Eigentuemer hat richtig eingewandt:
seit der GNSS-Verfeinerung (Abschnitt BE) messen wir Meter — `„0,0 möchte ich nicht, ab 0,1 schon"`.
Eine Fahrt von 400 Metern steht im CAN-Wert als Null und waere verlorengegangen, obwohl gefahren
wurde.
Der Rueckblick liest deshalb jetzt ebenfalls den GNSS-Zaehler und entscheidet auf **eine
Nachkommastelle**.
### Eine Entscheidung, eine Stelle
Dabei stand die Toleranzlogik kurz doppelt im Code — in `screening.py` und in
`historienimport.py`. Genau die Doppelung, die dieses Projekt bei `UNPLAUSIBLE_KMH` und
`MINDESTDAUER_S` schon einmal teuer bezahlt hat. Sie liegt jetzt in `verlauf.py`:
- **`strecke_waehlen(grob, fein)`** — die feine Zahl gilt, solange sie in der Rundungsunschaerfe des
Ankers liegt, oder wenn es gar keinen Anker gibt. Sonst hat der Fahrzeugwert recht.
- **`ist_gefahren(distanz)`** — ab 0,1 km ja, darunter nein. Eine **unbekannte** Strecke gilt als
gefahren: eine Fahrt zu verwerfen, die man nicht gemessen hat, waere schlechter als eine ohne
Streckenangabe zu behalten. Nur die gemessene Null ist ein Nein.
Beide Wege rufen dieselben Funktionen; der Rueckblick meldet die uebersprungenen Fenster als eigene
Zahl (`„Zuendungen ohne gefahrene Strecke"`) in beiden Oberflaechen, damit er nichts stillschweigend
weglaesst.
### Verifiziert — und was nicht
- **13 Faelle gegen `strecke_waehlen`/`ist_gefahren`**, darunter die beiden echten Fahrten, die
400-Meter-Fahrt, die 100-Meter-Untergrenze, 40 Meter (keine Fahrt), fehlender Anker, fehlender
GNSS-Wert, gedrifteter Zaehler und beide Seiten der Toleranzgrenze.
- **Import live**: `fahrten_nicht_gefahren: 1`, null Fahrten mit 0 km im Bestand.
- **Livepfad live**: vier alte Fahrten korrekt abgelehnt, Meldung jetzt ohne die Konstante im Text.
- **Nicht live gezeigt:** ein Fenster mit 0,10,9 km, das der Import nun *behaelt*. Der Recorder
enthaelt kein geschlossenes Fenster dieser Groesse — die Rangierfahrten vom 31.08. liegen alle
innerhalb einer einzigen langen `trip_status`-Phase. Nachweisbar bleibt es an den 13 Faellen; live
zeigt es sich bei der naechsten kurzen Fahrt.
## BG. Setup: gruene Bestaetigung, Erwartungswert je Position (2026.9.1.11.15)
Zwei Wuensche des Eigentuemers vom 01.09.2026, die zusammengehoeren.
### Gruen, wenn die Zuordnung zum Beispielnamen passt
Enthaelt die zugeordnete Entitaet den Beispielnamen der Rolle, faerbt sich das Feld leicht gruen.
Dieselbe Pruefung, die `entitaetScore()` ohnehin mit 8 gewichtet - hier nur fuers Auge, damit man
eine richtig belegte Rolle sieht, ohne den langen Entity-Namen Zeichen fuer Zeichen mit dem Beispiel
daneben zu vergleichen.
Bewusst **nur ein Hinweis, keine Bedingung**: Installationen mit anderer Datenquelle haben andere
Namen und sind trotzdem richtig zugeordnet. Deshalb gruen als Bestaetigung, aber kein Rot und keine
Warnung, wenn es fehlt. Und nicht waehrend gesucht wird - dann steht die Sucheingabe im Feld, nicht
die Zuordnung. Die Farbe kommt per `color-mix` aus dem vorhandenen `--ok`-Ton und dem Feldgrund
statt aus einem eigenen Gruen, damit sie in beiden Themen ruhig bleibt.
`„Auf Standard zuruecksetzen"` ist im selben Zug entfallen - Knopf, Symbol, Zuhoerer, CSS und der
nur dafuer vorhandene Helfer `setupStandardwert()`.
### Listenfelder brauchen vier Beispiele, nicht eines
Tuer- und Fenstersensoren haben vier Positionen, aber nur **einen** Beispielnamen
(`front_left_door`). Der beschreibt die erste; die anderen drei koennen ihn nie enthalten. Folge:
nur die erste Position konnte gruen werden, und im Kopf stand ein Erwartungswert, der fuer drei von
vier Zeilen nicht galt.
Neu: `"beispiele"` je Position im Feld-Katalog, `feldBeispiel(feld, idx)` im Panel. Der
Erwartungswert steht jetzt **an jeder Position** statt im Kopf - rechtsbuendig, in der kleinen grauen
Schrift des Positionslabels. Auch die Sortierung der Vorschlagsliste nutzt ihn, sonst stuende in
allen vier Zeilen dieselbe Entitaet oben.
**Merkposten zur Typografie:** `.setup-feld-hinweis` ist fuer die Kopfzeile gebaut (14px, `--fg`,
rechtsbuendig, `max-width: 55%`). Im 12,5px-Positionslabel wirkt das falsch herum - groesser und
dunkler als die Zeile, in der es steht. Eine eigene Regel im Kontext
`.setup-unterfeld-label .setup-feld-hinweis` laesst es Groesse und Farbe erben; die Rechtsbuendigkeit
kommt aus `justify-content: space-between` am Label, wie eine Ebene darueber bei
`.setup-feld-kopf`.
### Merkposten: der Cache-Buster haengt an der Manifest-Version
Beim Ausliefern sah der Hinweis im Browser weiterhin links am Label - obwohl das CSS korrekt war.
Grund: Panel und Stylesheets werden mit `?v=<Manifest-Version>` geladen. Wird nach dem
Versionssprung noch einmal an CSS oder JS gefeilt, **aendert sich die Adresse nicht** und der Browser
serviert die alte Datei aus dem Zwischenspeicher (HA liefert `/audi_dashboard_static/` ohne
`Cache-Control`, nur mit ETag - siehe Abschnitt AT).
Nachgewiesen: `getComputedStyle` meldete `display: block` und `font-size: 14px`, nach einem
erzwungenen Neuladen derselben Datei `display: flex` und `12.5px`. Die Datei auf dem Server war die
ganze Zeit richtig.
**Regel daraus:** die Manifest-Version wird als LETZTES erhoeht, nach der letzten Aenderung an Panel
oder Stylesheet - nicht davor. Wer waehrend der Pruefung noch nachbessert, erhoeht danach noch
einmal.
## BH. Audit vom 01.09.2026 — vier Befunde, zwei behoben (2026.9.1.17)
Nach einem Tag mit siebzehn Auslieferungen ein Rundumblick über beide Oberflächen und das Backend.
**Was sauber war:** 24 Backend-Dateien `py_compile`, Panel als **Modul** geparst (Abschnitt AQ),
`tsc --noEmit` und `vite build` sauber, 165/165 Tests grün, 27 Katalogeinträge gegen 27
Dataclass-Felder ohne Abweichung in beide Richtungen, keine doppelten Schlüssel, jedes Feld mit
Beispielnamen, jedes Listenfeld mit so vielen Beispielen wie Positionen. In der Konsole nur das
bekannte ServiceWorker-Rauschen. Keine verwaisten Verweise auf das entfernte
`„Auf Standard zurücksetzen"`.
### Befund 1 (behoben): Livepfad und Rückblick maßen dieselbe Fahrt unterschiedlich
Die schwerste Sorte Fehler in diesem Projekt — und ich hatte sie am selben Tag selbst eingebaut.
`screening.py` las den Verlauf über `verlauf_lesen()`, das den Stand **zu Beginn** des Fensters
mitliefert. `historienimport.py` schnitt sein Fenster streng heraus (`f_start <= p[0] <= f_ende`)
und verlor genau diesen Punkt. An der echten Fahrt nachgemessen: **6,896 km gegen 6,816 km** für
denselben Zeitraum, je nachdem welcher Weg sie bewertete.
Neu `verlauf.zaehlerstrecke(punkte, start, ende)`: der Anker ist der letzte Datensatz **am oder
vor** dem Beginn, dieselbe Überlegung wie bei `wert_bei()`. Beide Wege schneiden jetzt durch
dieselbe Funktion. Nachgewiesen: Rückblick 6,896 — Livepfad 6,896.
Dritter Fall dieser Art nach `UNPLAUSIBLE_KMH` und `MINDESTDAUER_S`. **Regel bestätigt sich:**
sobald zwei Wege dieselbe Größe berechnen, gehört die Rechnung in `verlauf.py` — nicht als
Aufräumarbeit hinterher, sondern beim ersten Mal.
### Befund 2 (behoben, kleiner): `STANDARDWERTE` gingen unnötig über die Leitung
`zuordnung.py` schickte sie mit jeder Katalogantwort ans Frontend. Gelesen hat sie dort seit dem
Entfernen des Zurücksetzen-Knopfes (`2026.9.1.11`) niemand mehr. Die Konstante selbst bleibt — sie
trägt intern die Vorgabewerte und leitet `SCHLUESSEL` ab; nur die Veröffentlichung ist weg.
### Befund 3 (offen, gehört dem Eigentümer): die Reichweite ist vier Tage alt
Auf der Übersicht steht **87 km** als größte Zahl des Bildschirms — daneben 61 % Tank. Der Wert
stammt vom 29.08. und aus einem **anderen Fahrzeug**; die beiden zuletzt genutzten Autos liefern
`can.vehicle.remaining.range` nicht über CAN (an flespis Zeitstempeln nachgewiesen, HAs Recorder
kann tote von konstanten Sensoren nicht unterscheiden).
Kein Codefehler. Solange kein Fahrzeug den Wert liefert, wäre die Rolle `RANGE_SENSOR` besser leer:
dann steht dort „Reichweite unbekannt" statt einer falschen Zahl. Im Endzustand liefert das
Fahrzeug sie wieder — Entscheidung des Eigentümers.
### Befund 4 (offen, braucht eine Entscheidung): die Nullfahrt-Regel gilt nur im Rückblick
`ist_gefahren()` wird nur von `historienimport.py` aufgerufen. Der Livepfad kann weiterhin eine
Fahrt anlegen, die sich später zu 0,0 km ausrechnet — genau das, was der Eigentümer aus dem Bestand
haben wollte. Im aktuellen Bestand gibt es keine solche Fahrt, und unter der neuen Gerätekonfiguration
(`Start Speed = 3 km/h`) sollte sie kaum entstehen. Aber es ist wieder eine Regel, die nur ein Weg
kennt.
**Bewusst nicht einseitig gebaut:** der Livepfad kennt die Strecke erst beim Screening, also
nachträglich. Die Fahrt dann zu verwerfen hieße, einen bereits gespeicherten Datensatz automatisch
zu löschen. Das ist eine Entscheidung über die Daten des Eigentümers, keine über den Code — dieselbe
Linie wie in Abschnitt W.
## BI. Strecken gestaffelt anzeigen: Meter, Nachkommastelle, ganze Kilometer (2026.9.1.18)
Beim Audit fiel auf, dass die gewonnene Genauigkeit gar nicht ankam: `de()` formatiert ohne
Nachkommastelle, aus `6,896 km` wurde in beiden Oberflächen schlicht **„7 km"**. Wir haben den
ganzen Tag auf zehn Meter genau gemessen und es im letzten Schritt weggerundet.
| Strecke | Anzeige |
|---|---|
| unter 1 km | Meter, ganzzahlig — `400 m`, `999 m` |
| bis 99,9 km | eine Nachkommastelle — `1,0 km`, `6,9 km` |
| ab 100 km | ganze Kilometer — `104 km` |
`streckeTeile()`/`streckeText()` in beiden Codebasen, wortgleich. Zwei Feinheiten:
- **Gestaffelt wird nach dem GERUNDETEN Wert**, nicht nach dem rohen. 0,9996 km sind gerundet
1000 m und gehören damit in die km-Stufe — sonst stünde dort „1.000 m". Dasselbe an der Grenze zu 100.
- **`streckeTeile()` gibt Wert und Einheit getrennt zurück**, weil die Detailansicht die große Zahl
und ihre Einheit in getrennten Elementen setzt. Ohne das stünde unter „400" weiterhin „km".
Im Panel heißt die Funktion `streckeText`, nicht `strecke`: den Namen gibt es dort schon für die
Ortsangaben einer Fahrt („Ingolstadt → München"). `node --check` hat die Kollision gefunden.
Angewandt auf Einzelfahrt (Liste, Detail) und die Jahres-/Monatssummen. **Nicht** auf
Kilometerstände, Serviceintervalle oder Reichweite — das sind Zählerstände und Prognosen, keine
gefahrenen Strecken.
Verifiziert: 12 Fälle gegen die Staffelung samt beider Grenzen, 165/165 Tests grün nach Anpassung
einer Erwartung, `node --check` als Modul, `tsc --noEmit` und `vite build` sauber, im Browser
nachgesehen: die Fahrt vom 01.09. steht jetzt mit **6,9 km** statt „7 km".
Die angepasste Erwartung ist selbst ein Beleg: der Test verlangte `„43 km"` für eine Summe von
**42,5 km** — die alte Anzeige rundete also schon dort, wo es etwas zu zeigen gab.
## BJ. Einzelfahrt: Orte statt „unbekannt", Kleingedrucktes weg, „Min." (2026.9.1.19/.20)
### Start und Ziel standen auf „unbekannt", obwohl die Position bekannt war
`start_address`/`end_address` stehen in `HANDFELDER` — das Backend füllt sie **nie** von
selbst, sie kommen nur aus einer von Hand angelegten oder bearbeiteten Fahrt. Die **Koordinaten**
dagegen trägt das Screening ein: 12 von 14 Fahrten im Testbestand hatten Start- und Zielposition,
aber genau eine hatte eine Adresse.
Beide Oberflächen lösen die Koordinaten jetzt beim Öffnen der Einzelfahrt auf — über **denselben
Cache**, den die Standortansicht benutzt (`rev:`-Schlüssel, drei Nachkommastellen ≈ 110 m). Ein
Abruf je Ort, danach nie wieder.
**Bewusst nicht in der Liste.** Ein Vorratsabruf für alle Fahrten wäre genau das, worum Nominatim
in seinen Nutzungsbedingungen bittet, es nicht zu tun. Dort steht deshalb weiterhin „Fahrt ohne
Ortsangabe", bis jemand die Fahrt einmal geöffnet hat.
Beide Fassungen zeigen bis zur Antwort „wird ermittelt …" und fallen auf „unbekannt" zurück, wenn
es keine Koordinaten gibt oder Nominatim nichts findet. Das Panel prüft vor dem Eintragen, ob noch
dieselbe Fahrt offen ist — wer während des Abrufs weiterblättert, soll nicht die Adresse der
vorigen Fahrt in der neuen sehen.
Live nachgewiesen an der Fahrt vom 01.09.: `Start · Schottenau, 85072 Eichstätt` —
`Ziel · Am Anger 7a, 85111 Adelschlag`.
### Drei Erläuterungen entfernt
Auf Wunsch des Eigentümers entfallen die Kleingedruckten unter Durchschnitts- und
Höchstgeschwindigkeit sowie Verbrauch. Die Herleitungen stehen weiterhin im Quelltext
(`durchschnitt_kmh()`, `hoechstwert_im_fenster()`, `verbrauch_aus_literstaenden()`) — es
verschwindet die Anzeige, nicht das Wissen. Die Ortsangaben unter Start und Ziel bleiben: das sind
Daten, keine Erläuterung.
### „min" → „Min."
Deutsche Abkürzung, wie sie die Oberfläche an anderer Stelle längst verwendet
(`„Geparkt seit 2 Tg. 16 Std. 12 Min."`). In `dauerText()` bzw. `dauer()`, also überall wo eine
Dauer erscheint. Zwei Testerwartungen mitgezogen.
Verifiziert: Panel als Modul geparst, `tsc --noEmit` und `vite build` sauber, 165/165 Tests grün,
im Browser nachgesehen — Adressen, `Dauer 13 Min.`, und die Liste zeigt die Staffelung aus
Abschnitt BI (`6,9 km`, `21,0 km`, `104 km`, `478 km`).
### Nachtrag (2026.9.1.21.23): die Liste zeigt Orte, ohne einen einzigen Abruf
**Was schon im Speicher liegt, zeigt die Liste umsonst mit.** `ortAusCache()` liest nur den
Zwischenspeicher und fragt nie das Netz. Ein Abruf je Zeile wäre genau der Vorratsabruf, um dessen
Unterlassung Nominatim bittet — bei zweihundert Fahrten vierhundert Anfragen für einen Blick auf
die Liste.
**In der Liste nur der Ort, nicht die Anschrift** (Wunsch des Eigentümers): eine Zeile trägt Datum,
Art, Strecke und Verbrauch; „Schottenau, 85072 Eichstätt → Am Anger 7a, 85111 Adelschlag" ist länger
als der ganze Rest. Auf der Einzelfahrt bleibt die vollständige Anschrift.
Der Ortsname wird beim Auflösen **separat gemerkt** (`ort:`-Schlüssel), nicht nachträglich aus der
Anschrift geschnitten — fällt Nominatim auf `display_name` zurück, stünde hinter dem letzten Komma
das **Land**, und in der Liste stünde „Deutschland → Deutschland".
**Der Rückfall war trotzdem nötig, und das Ausliefern hat es gezeigt:** die Liste stand weiter voller
Anschriften. Grund — der Ortsname entsteht nur bei einem *frischen* Abruf, und die Adressen lagen
längst im Speicher, also kehrte die Auflösung sofort zurück und schrieb nichts. `stadtAusAnschrift()`
schneidet den Ort deshalb aus einer bereits gespeicherten Anschrift, **aber nur wenn hinter dem
letzten Komma eine Postleitzahl steht**. Damit greift es bei unserem eigenen Format
`„<Straße>, <PLZ> <Ort>"` und niemals bei `display_name`.
Geprüft: 7 Fälle gegen den Schnitt (mit/ohne Straße, benannter Platz, ohne PLZ, `display_name`-Form,
`null`). Im Browser nachgesehen: `„Eichstätt → Adelschlag"` in der Liste, volle Anschrift auf der
Einzelfahrt.
## BK. Der Ort kommt mit der Fahrt, und der Verbrauch wird an den Betankungen geeicht (2026.9.1.25-.27)
### Die Wertespalte der Fahrtenliste stand nicht in einer Linie
Gemeldet: km und l/100 km fluchten nicht, und bei langen Ortsnamen wird der
Abstand zu eng. Gemessen: die rechte Kante der Wertespalte lag je nach Zeile
bei 549px oder 520px, obwohl beide Zeilen gleich breit sind und an derselben
Stelle beginnen.
Ursache war nicht die Breite, sondern die **Anzahl der Kinder**: eine `.leaf`
hat drei (Label, Werte, Pfeil), und `justify-content: space-between` verteilt
freien Raum ZWISCHEN allen dreien. Die Wertespalte stand damit in der Mitte,
ihre Lage abhaengig von der Laenge des Labels. Mit `flex: 1` am Label bleibt
kein freier Raum uebrig, Werte und Pfeil sitzen buendig rechts.
`min-width: 0` gehoert dazu, sonst darf ein Flex-Kind nicht unter seine
Inhaltsbreite schrumpfen und `text-overflow` greift nie.
Nachgewiesen: alle sechs Zeilen der Liste teilen sich jetzt 629/721 statt
zweier verschiedener Kanten.
**Merkposten:** eine Zeile, deren Werte "mal so, mal so" stehen, ist fast nie
ein Breitenproblem - erst die Kinder zaehlen, dann messen.
### Ortsnamen loest jetzt das Backend auf
Bis hierher tat es jede Oberflaeche selbst und legte das Ergebnis in ihren
eigenen Zwischenspeicher. Drei Folgen, alle unnoetig: jedes Geraet fragte
dieselben Koordinaten erneut ab, ein frisch eingerichtetes Telefon zeigte fuer
alte Fahrten wieder "unbekannt", und ein geleerter Browserspeicher warf alles
weg.
Neu `geokodierung.py` plus ein Screening-Schritt `_orte_screenen()`. Der Ort
steht jetzt in der Fahrt: `start_address`/`end_address` (volle Anschrift) und
`start_stadt`/`end_stadt` (nur der Ortsname, fuer die Liste).
Nominatims Nutzungsbedingungen bestimmen fast jede Entscheidung darin: eine
Anfrage je Sekunde (`ABSTAND_S = 1.1`), hoechstens acht je Durchlauf
(`PRO_DURCHLAUF`, sonst waere ein Jahresrueckblick genau der Vorratsabruf, um
dessen Unterlassung gebeten wird), ein identifizierbarer User-Agent, und jedes
Ergebnis wird in `orte.json` gemerkt statt erneut abgefragt.
**Zwei Sperren gegen das Ueberschreiben von Hand Eingetragenem**, absichtlich
beide: der Schritt fuellt nur ein LEERES Feld, und `fahrt_aktualisieren()`
laesst ohnehin jedes Feld in `edited_fields` unberuehrt. Der Ortsname wird
dagegen auch neben einer handischen Anschrift gesetzt - er beschreibt die
Koordinate, nicht die Eingabe. In der Liste sticht die handische Anschrift
trotzdem: beide Oberflaechen pruefen dafuer `edited_fields`.
Ein Netz- oder Serverfehler wird NICHT gemerkt - sonst stuende eine
voruebergehend gedrosselte Antwort fuer immer als "kein Ort hier". Ein echtes
leeres Ergebnis dagegen schon.
Beide Oberflaechen fragen jetzt gar nichts mehr ab; `ortAusCache()`,
`stadtAusCache()`, `stadtAusAnschrift()` und `fahrtOrteNachtragen()` sind
ersatzlos entfallen.
Nachgewiesen: nach zwei Screening-Laeufen tragen 12 von 14 Fahrten einen Ort,
die beiden anderen haben gar keine Koordinaten. Die Liste zeigt
"Eichstaett -> Adelschlag"; damit ist auch der gemeldete Unterschied zwischen
Panel ("Adelschlag") und App ("Adelschlag -> Eichstaett") weg - er kam daher,
dass die beiden verschiedene Zwischenspeicher hatten.
### Verbrauch: Rohwert, Faktor, Anzeigewert
Der Anzeigewert einer bereits gespeicherten Fahrt wurde nie neu gerechnet -
die `wert_ab()`-Korrektur vom selben Tag erreichte die Altfahrten also nicht.
Das Auswahlkriterium haengt jetzt am ROHwert, nicht am Anzeigewert: die Fahrt
vom 01.09. ist damit von 11,4 auf **8,7 l/100 km** gewandert, genau auf den
Wert, den die Messung vorhergesagt hatte.
Drei Felder statt einem: `verbrauch_roh_l_100km` (die reine Messung, damit die
Rechnung wiederholbar bleibt), `verbrauch_faktor`, und `verbrauch_l_100km` als
Produkt - der Wert, den die Oberflaechen anzeigen. Die brauchten deshalb keine
Aenderung.
### Die Eichung an zwei Betankungen (verbrauchskorrektur.py)
Vorschlag des Eigentuemers, und er ist besser als meiner:
Faktor = (Tankstand nach A - Tankstand vor B) / Summe der Fahrtverbraeuche
**Dafuer braucht es keinen Tankbeleg.** Zaehler und Nenner sind mit demselben
Geber gemessen; ein Massstabsfehler stuende in beiden und kuerzt sich heraus.
Damit taugt auch eine Teilbetankung - die Voll-zu-voll-Bedingung, die mein
erster Ansatz ("Verbrauch zwischen zwei Betankungen") gebraucht haette,
entfaellt.
Ebenso entfaellt mein Nichtlinearitaets-Vorbehalt: `can_fuel_volume` kommt
schon als Liter, die Kennlinie des Schwimmers hat das Steuergeraet angewandt
(Hinweis des Eigentuemers). Was bleibt, ist die **Daempfung** - der gemeldete
Stand hinkt nach, auf sieben Kilometern anteilig weit mehr als auf
dreihundert. Genau die faengt der Faktor mit ab, weil sie Kraftstoff nur
zwischen Fahrten verschiebt und die Intervallsumme stimmt.
Der Faktor enthaelt auch Verbrauch, der zu KEINER Fahrt gehoert (Standheizung,
nicht erkannte Fahrt, Rangieren unter 0,1 km). Entscheidung des Eigentuemers:
auf die Fahrten verteilen, damit deren Summe dem entspricht, was im Tank
fehlt - eine einzelne Fahrt traegt dafuer einen Anteil, den sie nicht selbst
verbraucht hat.
**Schranke 0,7-1,5**, ausserhalb wird verworfen und protokolliert. Sie hat auf
echten Daten sofort gegriffen: das Intervall 15.08. bis 27.08. ergab **5,616**
- 45 l im Tank verbraucht, aber nur 8 l aus zwei Fahrten gerechnet. Das ist
kein Rechenfehler, sondern der Befund, dass in diesen zwoelf Tagen fast keine
Fahrt erkannt wurde (die Umsteckphase des Dongles). Ohne die Schranke haetten
beide Fahrten das Fuenffache angezeigt.
Gerechnet werden nur Intervalle, in denen noch eine Fahrt ohne Faktor steht -
das Screening laeuft bei jeder Aenderung des Kilometerstands, und jedes
Intervall kostet eine eigene Abfrage des Tankverlaufs.
Eine Fahrt, die ueber eine Betankung hinweggeht, bekommt keinen Faktor: sie
laesst sich keinem der beiden Intervalle zuordnen.
**Noch offen:** in den vorhandenen Daten liefert kein Intervall einen
brauchbaren Faktor. Der Mechanismus ist nachgewiesen, seine Eichung nicht -
dafuer braucht es ein Intervall mit vollstaendig erkannten Fahrten.
## BL. Der Radzaehler summiert Fahrten statt Tacho-Deltas; Raeder statt Reifen (2026.9.1.29)
### 375.962 km auf Raedern eines Autos mit 61.823 km
Gemeldet als "der Zaehler rechnet die Gesamtkilometer des Fahrzeugs auf die
Raeder". Nachgesehen: Sommerraeder 375.962 km, Tacho 61.823.
`fortschreiben()` zaehlte jedes POSITIVE Delta zum zuletzt verarbeiteten
Kilometerstand. Ein Sprung nach unten fiel schon immer heraus - beim Umstecken
des Dongles zwischen zwei Fahrzeugen gibt es aber beide Richtungen, und
61.823 -> 209.177 ist ein positives Delta von 147.354 km.
**Erster Ansatz, vom Eigentuemer verworfen:** ein Sprungschutz am Delta, der
nicht die Groesse pruefte, sondern die daraus folgende Geschwindigkeit
(`UNPLAUSIBLE_KMH`, wie bei den Fahrten) - noetig, weil der Modulkopf
verspricht, dass Kilometer aus einem HA-Ausfall noch mitzaehlen. Dafuer haette
das Profil den Zeitpunkt des Referenzstands mitfuehren muessen. Gebaut, an
sechs Faellen belegt, dann auf die Rueckfrage "warum so kompliziert? einfach
die Trip distance addieren?" wieder entfernt.
### Der Radzaehler summiert jetzt Fahrten
Die Rueckfrage war berechtigt, und die Messung stuetzt sie. Summe der
Fahrtstrecken gegen die Tacho-Spanne im selben Zeitraum, am echten Bestand:
| Tacho-Spanne | Summe der Fahrten | erfasst |
|---|---|---|
| 61.816 -> 61.823 (7 km) | 6,9 km | 99 % |
| 209.156 -> 209.281 (125 km) | 125,0 km | 100 % |
| 21.188 -> 21.325 (137 km) | 141,0 km | 103 % |
| 20.500 -> 20.580 (80 km) | 205,0 km | 256 % |
Der Ausreisser stammt aus dem Bestand von vor den Plausibilitaetspruefungen,
nicht aus dem Verfahren. In den sauberen Bereichen trifft die Fahrtsumme den
Tacho auf ein bis drei Prozent - und sie ist genauer, weil die Fahrten seit
der GNSS-Verfeinerung auf 100 m messen statt auf ganze Kilometer (6,9 gegen 7).
Damit entfallen `referenz_odo_km`, die Delta-Buchhaltung und der eben erst
gebaute Sprungschutz: die Fahrten pruefen Rueckwaertssprunge, unmoegliches
Tempo und Mindestdauer laengst selbst.
**Die Form hat der Eigentuemer festgelegt**, nachdem ich zuerst einen Sockel
mit Rueckrechnung vorgeschlagen hatte ("nein. Ich moechte: Manuell jederzeit
einen neuen Wert eintragen koennen und jeder gefahrene Kilometer wird einfach
dazuaddiert"). Also: `saetze.<satz>.km` IST der Stand, von Hand direkt
gesetzt, und jede Fahrt legt ihre Strecke oben drauf.
Zwei neue Felder an der Fahrt tragen das:
* `reifensatz` - welcher Satz montiert war. Der Satz steht an der FAHRT, nicht
am Zeitpunkt; ein Satzwechsel braucht deshalb keine Sonderbehandlung.
* `reifen_gezaehlt_km` - wieviel davon schon gutgeschrieben ist. Verfeinert das
Screening die Strecke spaeter (7,0 -> 6,896 km ist real vorgekommen), wird
nur die Differenz nachgetragen. Ohne diesen Merker waere entweder jede
Korrektur verloren oder jede Fahrt doppelt gezaehlt.
**Der Rueckblick stempelt bewusst KEINEN Radsatz.** Er rekonstruiert
Vergangenes, und das steckt im gespeicherten Radstand laengst drin - ein
erneuter Import schriebe dieselben Kilometer sonst ein zweites Mal auf die
Raeder. Gleiches gilt fuer den Altbestand: Fahrten ohne `reifensatz` zaehlen
nie mit, der gespeicherte Stand bleibt bei der Umstellung also unveraendert.
**Was das kostet, vom Eigentuemer ausdruecklich akzeptiert:** was die
Fahrterkennung nicht sieht, landet nie auf den Raedern. Der Tacho ist die
Wahrheit des Fahrzeugs und holt sich alles zurueck, eine Fahrtsumme nicht.
Live nachgewiesen in `audi_ha_test`: von Hand auf 1000 gesetzt -> 1000,0;
Fahrt ueber 12,3 km angelegt -> 1012,3; Screening erneut ausgeloest -> 1012,3
(keine zweite Gutschrift). Protokollzeilen: `Laufleistung sommer von Hand auf
1000.0 km gesetzt` und `Fahrt t-bb1ca655...: 12.3 km auf sommer
gutgeschrieben, neuer Stand 1012.3 km`.
**Der falsche Bestand bleibt stehen.** Die 375.962 km korrigiert der
Eigentuemer auf der realen Instanz selbst ueber den Stift, unabhaengig von der
Entwicklung.
### Drei Oberflaechen-Aenderungen, beide Codebasen
* **"Termin vereinbart" samt Schalter entfernt** (Wunsch des Eigentuemers).
Damit entfaellt auch der Zusatz "vereinbart" unter dem Datum auf "Mein
Audi". Das Feld `reifen.wechsel.vereinbart` selbst bleibt im Profil stehen -
es zu loeschen waere eine Datenaenderung, keine Anzeigeaenderung.
* **"Reifen" heisst "Raeder"** in allem Sichtbaren: Seitentitel, Kachel auf
"Mein Audi", Kalendereintrag ("Radwechsel auf ..."), Loeschabfrage im Archiv,
Backup-Beschreibung. NICHT umbenannt: Route, Profilschluessel, Dienstnamen,
Funktionsnamen - dieselbe Trennung wie bei "Servicebuch"/"Wartungsplan"
(Abschnitt AB).
* **Markenlogo 20 % groesser**, 16px -> 19,2px, an allen vier Stellen
(`.marke-logo img` und die eine feste Angabe im Panel, `.dm-marke img` und
`.dm-markenlogo` in der App). Gemessen: 19,19px im Browser, drei Logos auf
der Tanken-Seite.
## BM. Der Verbrauchsfaktor kommt aus dem Tankbeleg, nicht aus dem Geber (2026.9.1.30)
### Die Formel ist die des Eigentuemers
Faktor = getankte Liter laut Beleg / Summe der Liter aus den Einzelfahrten
Genau die Rechnung, die er ohnehin von Hand macht. Die Annahme dahinter ist
seine und traegt: die Ungenauigkeit des Gebers trifft jede Fahrt
gleichermassen, ist also ein Faktor und kein Summand.
**Meine erste Fassung rechnete anders** - Tankstand nach A minus Tankstand vor
B, geteilt durch die Fahrtsumme. Die braucht keinen Beleg (der Maszstab steht
in Zaehler und Nenner und kuerzt sich heraus) und funktioniert auch bei
Teilbetankung. Sie korrigiert dafuer aber NUR die Verteilung, nicht den
absoluten Maszstab des Gebers. Die Belegvariante kann beides und ist deshalb
die richtige - der Preis ist die Voll-zu-Voll-Bedingung.
### Drei Bedingungen, jede aus einem konkreten Fehler heraus
1. **Volltankung an BEIDEN Enden.** Nur dann gilt "eingefuellte Liter = seit
dem letzten Mal verbrauchte Liter". Erkannt am Tankstand eine Minute nach
der Betankung: mindestens `VOLL_ANTEIL` (95 %) des `tankvolumen_liter` aus
dem Profil. Die eine Minute ist die Vorgabe des Eigentuemers; zu kurz zu
greifen ist die harmlose Richtung (der Stand faellt unter die Grenze, es
entsteht kein Faktor), zu lang die gefaehrliche - nach der Abfahrt sinkt der
Stand wieder und eine echte Volltankung saehe nachtraeglich unvoll aus.
2. **Die Liter muessen aus einem Beleg stammen.** Ein automatisch erkannter
Tankvorgang traegt in `liters` den vom GEBER gemessenen Anstieg. Den gegen
die ebenfalls aus dem Geber gerechneten Fahrten zu stellen, ist ein
Zirkelschluss und ergaebe immer ungefaehr 1,0 - der Faktor koennte gar
nichts korrigieren. `beleg_liter()` laesst deshalb nur durch, was einen
`receipt_key` hat, von Hand bearbeitet wurde oder `source == "manual"`
traegt. An fuenf Faellen geprueft.
3. **Die Fahrten muessen die gefahrene Strecke abdecken** (`DECKUNG_MIN`/
`DECKUNG_MAX`, 90-110 %). Nicht der Faktor wird begrenzt, sondern der
Nenner geprueft - siehe unten.
### Warum nicht der Faktor begrenzt wird
Erst stand hier eine feste Schranke 0,7-1,5. Der Eigentuemer hat sie
hinterfragt ("unsere Rechnung ist gut genug"), und die Pruefung der Frage gab
ihm halb recht:
* **Am Faktor** war sie tatsaechlich am falschen Ort. Die beiden Tore oben
sichern den Zaehler; ein echter Geberfehler liegt bei ein paar Prozent und
waere an 0,7-1,5 nie angeschlagen. Eine groessere, aber echte Abweichung
haette sie dagegen abgeklemmt.
* **Der Nenner** bleibt aber verwundbar: fehlt im Intervall eine Fahrt, ist
die Summe zu klein und der Faktor zu gross - und er multipliziert jede
Fahrt des Zeitraums mit. Genau das war der 5,616-Fall (45 l im Tank, zwei
erkannte Fahrten ueber zwoelf Tage); Beleg und Volltankung haetten dort
beide gestimmt.
Geprueft wird deshalb die Vollstaendigkeit der Fahrtmenge, an einem Mass, das
beim Radzaehler-Umbau desselben Tages schon belegt wurde:
Deckung = Summe der Fahrtstrecken / Tacho-Spanne des Intervalls
Liegt sie bei 90-110 %, gilt der Faktor - ohne obere oder untere Klemme.
Sonst wird das Intervall uebersprungen und protokolliert. Die Grenze gilt in
BEIDE Richtungen: 256 % (Altbestand mit doppelten Fahrten) ist genauso ein
Zeichen fuer einen unbrauchbaren Nenner wie 15 %.
An sechs Faellen mit den echten Zahlen geprueft, `_deckung()` direkt
aufgerufen: 100 % / 99 % / 103 % angewandt, 256 % und 15 % verworfen, ein
Rueckwaertssprung (Fahrzeugwechsel) als "nicht bestimmbar" behandelt statt
als Deckung 0.
### Standlauf verzerrt den Faktor nicht
Frage des Eigentuemers, und die Antwort ist beruhigend: laeuft der Motor im
Stand bei eingeschalteter Zuendung, bleibt die Fahrt offen. Der Verbrauch
steht damit in der Summe der Einzelfahrten UND in der getankten Menge - er
kuerzt sich heraus. Die betroffene Fahrt zeigt zu Recht einen hohen Verbrauch.
Verzerrend ist nur Verbrauch OHNE offene Fahrt (Standheizung bei Zuendung
aus): der fehlt im Nenner und verteilt sich ueber den Faktor auf alle Fahrten
des Zeitraums - genau das, was der Eigentuemer bewusst so wollte.
### Zwei Ausloeser, jeder mit eigenem Grund
Der Eigentuemer hat den wunden Punkt benannt: der Faktor steht erst
rueckwirkend fest, eine Korrektur "nach einer Fahrt" kann nicht funktionieren.
Stimmt - und die Rechnung hielt sich schon daran (die Schleife laeuft ueber
PAARE aufeinanderfolgender Tankvorgaenge, das laufende Intervall hat kein
Gegenstueck). Die Frage legte aber eine echte Luecke frei: **ein Tankvorgang
loeste gar nichts aus.** Der einzige Aufruf hing am Screening, gerechnet wurde
also erst bei der naechsten Fahrt.
| Ausloeser | wofuer |
|---|---|
| `koordinator.tankvorgang_nachbereiten()` | das Intervall schliesst sich - hier entsteht der Faktor |
| `screening.durchfuehren()` | eine Fahrt im Intervall bekommt ihren Rohwert erst spaeter |
`tankvorgang_nachbereiten()` ersetzt `tankvorgaenge_veroeffentlichen()` an
allen sechs Schreibstellen (`belege.py` 4x, `tankerkennung.py`,
`historienimport.py`). Der 60-Sekunden-Takt ruft bewusst weiter nur
`tankvorgaenge_veroeffentlichen()`: dort aendert sich nichts, was einen neuen
Faktor ergeben koennte.
Gerechnet werden nur Intervalle, in denen noch eine Fahrt ohne Faktor steht -
sonst liefen dieselben Verlaufsabfragen bei jedem Fahrtende erneut. Die teure
Voll-Pruefung kommt zuletzt, nach den billigen Ausschluessen.
**Noch nicht belegt:** in den Testdaten gibt es keinen Tankvorgang mit
Beleg-Litern, also auch kein gerechnetes Intervall. Der Mechanismus ist
geprueft, seine Anwendung auf echte Zahlen nicht.
### Uebersicht/Zuletzt zeigt Ort -> Ort
Die Zeile "Letzte Fahrt" zeigte die vollen Anschriften. Sie benutzt jetzt
dieselbe `strecke()` wie die Fahrtenliste - eine Quelle, damit die beiden
nicht auseinanderlaufen koennen; in der App dafuer aus `Fahrten.tsx`
exportiert statt ein zweites Mal gebaut. Dabei fielen in der App zwei
Abweichungen zum Panel auf und sind mitgezogen: die Strecke lief noch ueber
`de()` statt `streckeText()` (also ohne die gestaffelte Aufloesung), und im
Wertzusatz stand das Statuswort, das der Eigentuemer aus der Liste hatte
entfernen lassen - jetzt der Verbrauch wie im Panel.
## BN. Null heisst unbekannt, und der Momentanwert gehoert nur zu "jetzt" (2026.9.2.1-.3)
### Der Kilometerstand darf fehlen duerfen
Wunsch des Eigentuemers: ein Weg, einen Tankvorgang ohne Kilometerstand
einzutragen - "eine 0 waere hilfreich, bzw. ein leeres Feld wenn alle Zahlen
geloescht werden".
Beides geht jetzt, und beides landet als **None** im Datensatz, nicht als 0.
Das ist der eigentliche Punkt: **sechs Stellen** lesen `odometer_km` und
behandeln ausschliesslich None als unbekannt. Eine gespeicherte 0 waere fuer
sie ein Fahrzeug mit Tachostand null - `distanz_seit_letzter_tankung()` haette
den gesamten Tachostand als "Strecke seit der letzten Tankung" geliefert, und
der Langzeitverbrauch rechnete gegen eine Spanne, die es nie gab.
`verlauf.als_kilometerstand()` faengt das an der Eingabe ab (0, leer, negativ,
unlesbar -> None). Eine Sonderbehandlung an sechs Lesestellen waere sechsmal
die Gelegenheit gewesen, sie zu vergessen. An acht Faellen geprueft.
Beide Formulare senden bei geleertem Feld ohnehin schon `null` - geprueft, nicht
angenommen (`kmFeld === "" ? null : ...` im Panel, `Number.isFinite(kmZahl) &&
km ? ... : null` in der App).
### Ein Momentanwert gehoert zu dem Moment, in dem er gemessen wurde
Beim Pruefen fiel ein aelterer Fehler auf, den die 0 sonst wirkungslos gemacht
haette: `tankvorgang_manuell()` ersetzte einen fehlenden Kilometerstand durch
den **aktuellen** Tacho - unabhaengig vom eingetragenen Zeitpunkt. Fuer eine
nachgetragene Tankung von vorgestern war das doppelt falsch: die gefahrene
Distanz wurde gegen einen zu hohen Stand gerechnet, und die neue
Datumspruefung haette das Intervall verworfen.
`_km_aus_sensor_wenn_jetzt()` setzt den Sensorwert nur noch ein, wenn der
eingetragene Zeitpunkt hoechstens `SENSOR_ERSATZ_FENSTER` (2 h) von jetzt
entfernt liegt. Dasselbe gilt fuer `distance_km`: `distanz_seit_tankung()`
nimmt `TANK_DISTANZ_SENSOR` nur noch mit `jetzt=True` und faellt sonst auf die
Kilometerstand-Subtraktion zurueck, die zeitunabhaengig ist.
Live nachgewiesen ueber den echten Dienst, beide Faelle in einem Durchgang:
| Zeitpunkt | `km` eingegeben | gespeicherter Kilometerstand |
|---|---|---|
| jetzt | 0 (unbekannt) | **61.823** - vom Tacho, Normalfall ohne Tippen |
| vorgestern | 0 (unbekannt) | **leer** - der Tacho von heute passt nicht dazu |
### Datum und Kilometerstand muessen zusammenpassen
Die Fahrten werden ueber die ZEIT zugeordnet, nicht ueber den Tacho. Ein
falsch datierter Tankvorgang laesst die Liter deshalb auf den falschen Fahrten
landen - und die Deckungspruefung merkt es nicht, weil sie den Tacho zu
denselben verschobenen Zeitpunkten liest.
`_datum_passt()` vergleicht deshalb den eigenen `odometer_km` des
Tankvorgangs mit dem Tacho zu seinem Zeitstempel, Toleranz
`ODOMETER_TOLERANZ_KM = 5.0` (Vorgabe des Eigentuemers). Ein automatisch
erkannter Tankvorgang besteht sie zwangslaeufig - beide Werte stammen aus
demselben Datensatz. Ohne Kilometerstand (die 0 von oben) gibt es nichts zu
vergleichen, dann gilt der Zeitpunkt wie eingetragen; der Waechter faellt fuer
diesen Tankvorgang aus, was unvermeidlich und immer noch besser ist, als eine
erfundene Zahl zu verlangen. An sechs Faellen geprueft.
### Getippte Liter zaehlen als Beleg
`beleg_liter()` liess nur `receipt_key`, `source == "manual"` oder einen
Vermerk in `edited_fields` durch. Ein automatisch erkannter Tankvorgang, dessen
Liter der Nutzer nachtippt, hatte nichts davon - die Zahl galt weiter als
Geberwert und wurde verworfen. `belege.tankvorgang_aktualisieren()` traegt
jetzt `"liters"` in `edited_fields` ein, wenn Liter hereinkommen.
Das ist zuverlaessig, weil die automatische Erkennung Tankvorgaenge nur
ANLEGT und nie aktualisiert: was durch diese Funktion an Litern kommt, stammt
konstruktionsbedingt von einem Menschen (Formular oder bearbeitetes CSV).
Nebenwirkung, bewusst: `edited_fields` schuetzt das Feld vor der Automatik.
Wird spaeter doch ein PDF zu demselben Tankvorgang hochgeladen, bleibt die
getippte Zahl stehen.
Live nachgewiesen: getippte Liter akzeptiert, Korrektur lief in derselben
Millisekunde wie "Tankvorgang aktualisiert" (nicht erst bei der naechsten
Fahrt), und die Deckungspruefung verwarf das Intervall korrekt mit "0 % der
gefahrenen Strecke" - dort liegt ein Fahrzeugwechsel.
### Das Uebersicht-Symbol war das kleinste der fuenf
Gemeldet, live per `getBBox()` nachgemessen: 18,0 x 10,3 von 24 - **43 %** der
Hoehe, waehrend die vier Nachbarn zwischen 65 % (Tanken) und 89 % (Fahrten)
liegen.
**Der erste Versuch war falsch und wurde vom Eigentuemer sofort bemerkt** ("das
Uebersicht Icon ist jetzt abgeschnitten"). Faktor 1,33 auf volle Rasterbreite
schob es von x=3,99 bis x=27,9 - knapp vier Einheiten ueber den rechten Rand.
Ich hatte Breite und Hoehe gemessen und als Erfolg gemeldet, ohne die
**Position** zu pruefen: die 23,9 waren die Breite eines ueberstehenden
Symbols.
**Regel daraus, allgemein:** bei jeder Skalierung eines Symbols die Raender
messen (`bb.x`, `bb.x + bb.width`), nicht nur `bb.width`/`bb.height`. Eine
Groesse allein sagt nichts darueber, ob sie ins Raster passt.
Danach zweimal um 10 % vergroessert, auf Faktor **1,21** gegenueber dem
Original. Beim zweiten Schritt wird das Symbol zusaetzlich auf die Rastermitte
gelegt: seine eigene Mitte liegt bei x=13, nicht 12, und ohne diese
Verschiebung stuende es bei 1,21 rechts wieder ueber (24,10 statt 22,89).
`stroke-width` 1,240 als Ausgleich (1,5 / 1,21), gerendert also wieder 1,5.
Nachgemessen, alle fuenf Symbole, mit Raendern und inklusive der Strichstaerke
(sie ragt 0,75 ueber die Geometrie hinaus - aber nur bei den Kontur-Symbolen;
"Mein Audi" und "Fahrten" sind Flaechen und haben keinen Strich):
| Tab | x | y | Groesse | im Raster |
|---|---|---|---|---|
| Uebersicht | 1,11 - 22,89 | 5,77 - 18,23 | 21,8 x 12,5 | ja (mit Strich bis 23,64) |
| Mein Audi | 0,0 - 24,0 | 3,8 - 21,0 | 24,0 x 17,2 | ja |
| Fahrten | 1,0 - 23,0 | 2,0 - 23,4 | 22,0 x 21,4 | ja |
| Statistik | 3,5 - 20,5 | 2,8 - 21,2 | 16,9 x 18,5 | ja |
| Tanken | 3,0 - 17,0 | 4,5 - 20,0 | 14,0 x 15,5 | ja |
**Bewusst nicht weiter:** das Symbol ist ein Halbkreis und damit bauartbedingt
halb so hoch wie breit. Echte Hoehengleichheit mit den Nachbarn gibt es nur
durch Neuzeichnen des Bogens mit groesserem Winkel - dem Eigentuemer angeboten,
nicht unaufgefordert gemacht.
## BO. "Geparkt seit 20698 Tg." - die Meldezeit war nur nach vorn geprueft (2026.9.2.7)
Gemeldet von der realen Instanz. Nachgerechnet: 20698 Tage und 9:39 vor dem
Ablesezeitpunkt ergeben den Sekundenwert **0** - die Unix-Epoche.
`geraetezeit()` prueft seit Abschnitt AY, ob die Meldezeit in der ZUKUNFT
liegt (falsch gestellte Geraeteuhr). Nach hinten gab es keine Grenze. Ein
Sensor, der 0 meldet, rutschte damit ungehindert durch, und
`fromtimestamp(0)` ergibt 1970.
### Warum "der Dongle liefert nicht" die falsche Erklaerung war
Auf die Rueckfrage des Eigentuemers nachgesehen: im Testcontainer steht
`message_timestamp` auf 1788342429, also exakt jetzt. Beide Instanzen haengen
an derselben flespi-Quelle - am Geraet kann es also nicht liegen.
Die wahrscheinlichere Ursache ist die Zuordnung. Das flespi-Geraet hat FUENF
aehnlich klingende Sensoren, aber nur zwei sind Zeitstempel:
| Entitaet | Wert | tauglich |
|---|---|---|
| `…_message_timestamp` | 1788342429 | **ja - die richtige** |
| `…_timestamp_when_server_received_a_message` | 1788342433 | nein, Ankunftszeit |
| `…_id_of_channel_that_received_a_message` | 1421491 | nein, eine ID |
| `…_id_of_device_that_received_a_message` | 8767678 | nein, eine ID |
| `…_id_of_device_type_of_device…` | 1304 | nein, eine ID |
Eine faelschlich zugeordnete ID ergibt als Unix-Zeit ebenfalls einen Zeitpunkt
kurz nach 1970 - dieselbe Anzeige, andere Ursache. Beim Eigentuemer noch
offen, welcher der beiden Faelle vorliegt.
### Der Riegel sitzt an drei Stellen, nicht an einer
1. `geraetezeit_plausibel()` (neu in `verlauf.py`): nicht aus der Zukunft
(`MELDEZEIT_VORLAUF`, 5 min) und nicht aelter als `MELDEZEIT_RUECKLAUF`.
2. `geparkt_seit_setzen()` prueft mit - so kommt ein solcher Wert gar nicht
erst in den Laufzeit-Store.
3. **Beim LADEN**, und das ist der Teil, der die reale Instanz betrifft: der
bereits gespeicherte Epochenwert haette jedes Update ueberlebt. Geprueft
wurde dort nur, ob sich das Datum lesen laesst, und
`_parkbeginn_nachholen()` springt nicht ein, solange etwas gespeichert ist.
`MELDEZEIT_RUECKLAUF = 365 Tage` (Vorgabe des Eigentuemers, zuerst 30). Sehr
weit gefasst und genau deshalb ungefaehrlich: das Geraet puffert real nur
Stunden bis Tage, die Grenze kann also keine echte gepufferte Meldung
verwerfen - was sie fangen soll, liegt Jahrzehnte daneben.
An acht Faellen geprueft: Epoche, beide ID-Verwechslungen und 366 Tage
verworfen; jetzt, 2 Tage und 364 Tage angenommen.
## Working conventions (observed — keep them)
- German is the project language: identifiers, comments, commits, UI texts. Exceptions:
`design-system/` uses English props/JSDoc (Claude Design audience) — and **this file**, which is
English by rule.
- Decisions are logged, including rejected ones (see the Traccar section of the architecture
doc); superseded sections stay in place marked "ÜBERHOLT" rather than being deleted.
- Numbers in `de-DE` format; font weights 300/400 only, never ≥600 (only those cuts exist).
- Real vehicle/movement data stays local: it lives in Home Assistant under
`/config/audi_dashboard/`, never in this repo. Only the placeholder template
(`custom_components/audi_dashboard/vorlage/fahrzeugprofil.json`) is versioned; the real receipt
PDFs under `tests/belegparser/belege/` are gitignored.
- **There is no second copy of the code to keep in sync any more.** Until 2026-08-23,
`installationspaket/` duplicated `pyscript/`, `www/` and `data/`, and every change had to be
mirrored by hand — the older entries below still say so. The installer now reads
`custom_components/audi_dashboard/` directly. Do not reintroduce a copy.
- Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker,
proxy allowlist) may be exposed, each by explicit decision.
---
## AR. Teilen-Blatt, Kalender und der Rest des Owner-Stapels vom 2026-08-31
Alles unter `companion-app/`, sofern nicht anders vermerkt; Panel-Anteile nach der Paritätsregel.
### Share-Erweiterung: alles versioniert, damit `cap add ios` sie nicht wieder frisst
Ein PDF aus Mail über **Teilen → DataMetric360** landet als Tankbeleg in der App. Der Weg im
Ganzen steht in `companion-app/docs/SHARE_EXTENSION.md`; das Wesentliche hier:
- Eine Erweiterung ist ein eigener Prozess. Der einzige gemeinsame Boden mit der App ist eine
**App-Gruppe** (`group.app.datametric360`). Das PDF reist Base64-kodiert durch die geteilten
`UserDefaults`, weil `@capacitor/preferences` genau die liest, sobald man ihm die Gruppe nennt —
eine Datei im Gruppen-Container bräuchte eigenen nativen Code auf der App-Seite, also ein
zusätzliches Plugin für einen Weg, den es sonst nicht gäbe. Preis: 4-MB-Grenze, sauber gemeldet.
- **`companion-app/ios/` ist gitignored.** Alles Native liegt deshalb unter
`companion-app/native/` und wird von `scripts/ios-teilen-einrichten.mjs` bei jedem Bau wieder
eingespielt — dieselbe Regel, die schon die Team-Kennung und den Standort-Schlüssel in
`ios-signieren.sh` getrieben hat. Das Skript ist idempotent und in `ios-signieren.sh`
eingehängt.
- **Der pbxproj-Teil ist auf keinem Mac erprobt** (geschrieben ohne Zugriff auf macOS). Er bricht
vor dem Schreiben ab, wenn etwas nicht passt, und `docs/SHARE_EXTENSION.md` beschreibt dieselben
Handgriffe für Xcode. Nicht als „fertig verifiziert" behandeln, bevor es auf dem Mac gelaufen ist.
- Einmalig im Apple-Portal nötig: App-Gruppe anlegen, `app.datametric360.teilen` anlegen, beide
App-IDs der Gruppe zuordnen. Ohne das schlägt das Signieren fehl.
- Die App-Seite ist geprüft (6 Tests in `src/daten/geteilterBeleg.test.ts`): Eintrag → `File`,
genau einmal abgeholt, liegengebliebene und unlesbare Einträge verworfen.
### „In den Kalender übernehmen" ging in der App nie
`<a download>` mit Blob-Adresse — **WKWebView ignoriert `download`**, der Klick lief ins Leere.
Auf dem Gerät jetzt `@capacitor/filesystem` (Cache) plus `@capacitor/share`; im Browser und im
Panel bleibt der Download. Betrifft Reifen-Wechseltermin und Service-Werkstatttermin.
### Ein Wert, viele Kopien: `useTheme`/`useBoden` waren pro Aufrufer eigener Zustand
`useTheme()` legte bei jedem Aufruf ein eigenes `useState` an — fünf unabhängige Kopien
(`App.tsx`, `Einstellungen.tsx`, `Typenschild.tsx` ×2). Der Schalter änderte nur seine eigene;
`data-theme` in `App.tsx` blieb stehen, die Umschaltung wirkte erst nach einem Neustart. Der
Boden-Schalter hatte denselben Fehler. Jetzt `useSyncExternalStore` über einen Speicher je
Einstellung in `theme.ts` — Reacts eigener Weg für Zustand außerhalb des Komponentenbaums, ohne
Kontext-Anbieter und ohne geänderte Aufrufstellen. Zwei Regressionstests, gegen den alten Stand
als fehlschlagend nachgewiesen.
**Regel:** eine Einstellung, die mehr als eine Komponente liest, gehört nicht in `useState` je
Aufrufer.
### `uebersichtsbild`: Panel und App speicherten Verschiedenes ins selbe Feld
Panel schreibt den **Namen** („Seitenansicht"), die App verglich gegen den **Dateinamen**. Der
Vergleich konnte nie zutreffen — die App zeigte immer den ersten Platz. Kanonisch ist der Name
(die ältere, bereits gespeicherte Form; dieselbe Entscheidung wie beim Wartungsplan, Abschnitt AE).
Korrigiert in `Einstellungen.tsx`, `Uebersicht.tsx` und `profilAdapter.ts`.
### Popups nach Apple HIG
Neu im Design-System: `Sheet` (Formularblatt) und `ActionSheet` (Auswahlblatt, „Abbrechen"
abgesetzt in eigener Karte). Beide blickdicht, mit abgedunkeltem Schleier, sichere Zone unten frei,
Escape schließt. Umgestellt: SmartDeal, Tankbeleg, Bildmenü, Datensatz, Historien-Import — und im
Panel `.bildmenu`, `.sdpopup`, `.beleg-popup` samt Schleier. Das Setup-Fenster bleibt: es füllt
ohnehin die volle Höhe und ist damit bereits die HIG-Form „Vollbild".
`menueLage` („ansicht"/„rad") ist entfallen — ein Aktionsblatt kommt immer von unten.
### `Popup` portalierte nach `document.body` — außerhalb des Themes
Die Farbwerte hängen an `[data-theme]` auf `.ads-root`. Alles, was nach `document.body` portaliert
wird, erbt deshalb **immer** den Nachtmodus: im Tagmodus war jedes Fenster eine zu 5 % deckende
weiße Fläche auf weißem Grund. Betraf alle vier bestehenden Dialoge. `Popup`/`Sheet`/`ActionSheet`
zielen jetzt auf `.ads-root`.
**Regel:** kein Portal nach `document.body`. `position: fixed` bleibt davon unberührt — `.ads-root`
setzt kein `transform`.
### `ImagePlaceholder` merkte sich „fehlgeschlagen" ohne die Adresse
In der Galerie unter „Mein Audi" bleibt dieselbe Komponente stehen und bekommt nur eine neue
`src`. Ein einmal gesetztes `true` galt damit für alle folgenden Bilder — nach einem leeren Platz
zeigten auch die mit Foto nur noch den Platzhalter. Jetzt wird die Adresse gemerkt. Das Panel hatte
die Falle nie: `bildWeiter()` nimmt `bild-fehlt` ausdrücklich wieder weg. Vier Regressionstests,
gegen den alten Stand als fehlschlagend nachgewiesen.
### Bildadressen ohne Cache-Brecher
HA liefert `/local/` mit `Cache-Control: public, max-age=2678400` — **31 Tage** (nachgemessen).
Das Panel hängt seit jeher `?v=` an, die App nicht: ein im Panel eingefügtes Radfoto erschien in
der App nicht, ein ersetztes blieb das alte, ein 404 blieb ein 404. `bildUrl()` trägt jetzt einen
Bildstand, erneuert bei Start und nach jedem Hochladen/Löschen.
### Datumsfelder zeigten ihren Wert erst nach dem ersten Antippen
Alle sieben hängen an Werten, die asynchron eintreffen; WKWebView zieht den inneren Aufbau eines
`<input type="date">` bei einer späteren Wertänderung nicht zuverlässig nach. Neue gemeinsame
Komponente `Datumsfeld`, deren Schlüssel genau einmal wechselt (leer → gefüllt). Bewusst nicht der
Wert selbst als Schlüssel: dann entstünde das Feld bei jeder Auswahl neu und die Datumsauswahl
schlösse mitten in der Bedienung. **Auf dem Gerät zu bestätigen** — WebKit-spezifisch, im Chromium
der Vorschau nicht nachstellbar.
### Setup: „(z. B. …)" bleibt stehen
Der Hinweis kam aus der gerade zugeordneten Entität und änderte sich mit jeder Auswahl mit.
Vorgeschichte: bis 2026-08-28 stand dort ein ausgedachtes Beispiel, das für zwei Felder falsch war.
Beide Beanstandungen zusammen: **fest, aber nicht ausgedacht** — die 22 Beispiele stehen jetzt als
`beispiel` im `FELDER`-Katalog und sind die abgelesenen Namen der laufenden Integration.
`entitaetIdKurz()` und zwei Helfer (rund 60 Zeilen) haben keinen Aufrufer mehr und sind entfallen.
### Weiteres aus demselben Stapel
Fahrtrichtung als Dreiecke statt Punkte auf der Streckenlinie (Abstand nach Strecke, nicht nach
Stützpunkten — die Punktdichte hängt an der Geschwindigkeit); Art-Pille auf der Einzelfahrt ist
wieder ein Knopf (im Panel seit jeher, in der App ein totes `<span>`); Kopf-Gegengewicht, damit der
Titel auf Unterseiten wirklich mittig steht (gemessen: 17 px zu weit rechts, beide Codebasen);
Standort-Blatt ohne `min-height`, 34 px Fußabstand (18 war zu wenig, 7689 zu viel); Nebel über dem
Fahrzeugfoto von 42 %/78 % auf 26 %/96 %; FIN statt „Fahrgestellnummer"; Solldruckfelder auf 58 px
(die Klasse stand im Markup, hatte aber keine Regel); Markenlogos einheitlich auf Zeilenhöhe 16 px;
`watchPosition` statt `getCurrentPosition` für die Live-Ortung, mit Abmeldung beim Verlassen der
Seite; `NSLocationWhenInUseUsageDescription` in `ios-signieren.sh` (in der ausgelieferten `.ipa`
fehlte jeder Location-Schlüssel — deshalb fragte iOS nie).
### App-Symbol (2026-08-31)
Gewaehlt: Entwurf 3 bei 54 % Fahrzeuggroesse — **DataMetric** oben, das CI-Fahrzeug in der Mitte,
**360** in Audi-Rot unten, auf Nacht-Grund. Zwoelf Varianten wurden dafuer nebeneinander gezeigt,
jede zweimal: gross und bei 60 pt, der Groesse auf dem Homescreen. Genau daran entschied es sich —
gestapelte Elemente teilen sich dort 60 Punkte, und was gross elegant wirkt, ist klein oft ein Fleck.
- Vektorquelle: `companion-app/native/logo/AppIcon-{hell,dunkel,getoent}.svg`, erzeugt von
`scripts/logo-bauen.cjs`. Das Fahrzeug liest das Skript aus `ICONS.audi` im Panel — eine zweite
Kopie des Pfades waere eine zweite Quelle fuer dieselbe Form. Die Schriften stecken als
data:-URI in der SVG: ohne das haengt das Ergebnis davon ab, welche Fonts auf dem
rasternden Rechner installiert sind, und das faellt erst am fertigen Symbol auf.
- Fertige Vorlage: `companion-app/native/AppIcon.appiconset/` (drei 1024er-PNG plus
`Contents.json`). `ios-signieren.sh` legt sie ueber die von Capacitor erzeugte — `ios/` ist
gitignored, ein in Xcode eingesetztes Symbol waere beim naechsten `cap add ios` wieder weg.
- Drei Ausfuehrungen, wie iOS 18 sie erwartet: **hell** mit eigenem Grund, **dunkel** und
**getoent** ohne. Bei den letzten beiden legt das System seinen eigenen Grund darunter; ein
mitgeliefertes Schwarz bliebe darauf als Kasten sichtbar. Getoent wertet iOS die Helligkeit aus,
deshalb wird die 360 dort ein helleres Grau statt Rot — abgesetzt, ohne eine Farbe zu behaupten,
die es getoent nicht gibt.
- Die PNG sind in Chromium aus den SVG gerastert (die eingebettete Schrift steht nur dort
zuverlaessig zur Verfuegung). Bei einer Aenderung: `node scripts/logo-bauen.cjs`, dann neu
rastern — die SVG sind die Quelle, die PNG das Ergebnis.
### Der Xcode-Lauf braucht jetzt keine Handgriffe mehr
`ios-signieren.sh` erledigt der Reihe nach: Git-Stand pruefen, Webbuendel bauen, `cap sync`,
Standort-Berechtigung, Share-Erweiterung, App-Symbol, Versionsnummer aus der `manifest.json`,
alte Bereitstellungsprofile dieser App wegraeumen, archivieren, exportieren, Luftweg vorbereiten.
Was zwangslaeufig manuell bleibt: die App-Gruppe und die App-ID `app.datametric360.teilen` im
Apple-Portal (einmalig), das Keychain-„Immer erlauben" beim ersten Signieren (einmalig pro
Schluesselbund; der nicht-interaktive Weg verlangt das Anmeldepasswort auf der Kommandozeile und
scheidet damit aus), und `git push` der fertigen `.ipa`, weil das Telefon sie aus dem Repo laedt.
## AS. Fahrten, Statistik und der Trip-Timer des FMM003 (2026.8.31.3)
### Der 15-Minuten-Timer war doppelt
Die App wartete nach „Zündung aus" 15 Minuten, bevor sie eine Fahrt beendete — und der FMM003 tut
dasselbe: in seiner Konfiguration steht unter **Trip \ Odometer** ein **Ignition OFF Timeout von
900 s**. Zusammen eine halbe Stunde. Entfallen sind deshalb `pausenzeit_sekunden()`, die abbrechbare
Wartezeit in `zuendung_geaendert()`, `_ende_bestaetigen()`, die Verschmelzung in
`historienimport._fahrtfenster()` und die Einstellung in beiden Oberflächen.
**Zurückholen**, falls die Erkennung je wieder ohne das Gerät auskommen muss: eine abbrechbare
Wartezeit vor `fahrt_beenden()` legen (`k.warte_ende_ab` / `k.warte_ende_ab_abbrechen` sind dafür
noch da) und `fahrten_pausenzeit_min` wieder anbieten. Der Schwellwert „ab wann ist es ein
Parkplatz" hing an derselben Einstellung, hat damit aber nichts zu tun und steht jetzt als eigene
Zahl (`PARKPLATZ_AB_MIN = 15`) in beiden Codebasen.
### Höchst- und Durchschnittsgeschwindigkeit
- **Durchschnitt** braucht keinen Sensor: Strecke ÷ Dauer, `durchschnitt_kmh()` gab es schon.
`avg_speed_kmh` war seit jeher im Schema und wurde nie gefüllt — jetzt in `screening.durchfuehren()`
für jede Fahrt mit Strecke und Dauer.
- **vmax** braucht einen: neue Rolle `GESCHWINDIGKEIT_SENSOR`. Laut Konfiguration liest der FMM003
die Geschwindigkeit vom **OBD/CAN**, nicht aus GPS (System → Speed Source) — Beispielname im
Katalog entsprechend `vehicle_speed_read_from_can`.
- Es ist der **größte gemeldete** Wert, nicht die tatsächliche Spitze: das Gerät schreibt während
der Fahrt alle zehn Sekunden einen Datensatz (Data Acquisition → Moving → Min Period = 10 s). So
ist es auch beschriftet.
### Der Langzeitverbrauch war um einen ganzen Tankinhalt zu hoch
Gerechnet wurde `alle Liter ÷ (odo_letzter odo_erster)`. Die Strecke liegt aber **zwischen**
erstem und letztem Tankstop, und verbraucht wurde darauf genau das, was ab dem **zweiten** Tankstop
hineinkam — der erste füllte die Strecke davor. Bei 13 Tankvorgängen sind das rund 8 % zu viel; im
Testfall 16,7 statt 8,3 l/100 km. Korrigiert in beiden Codebasen, der Test hatte die falsche
Rechnung festgeschrieben und ist mitkorrigiert (plus zwei neue Fälle).
Dazu zwei Absicherungen: eine **negative Strecke** (falscher Kilometerstand an einem Tankvorgang,
in diesem Projekt schon vorgekommen — Abschnitt AF) wird auf 0 gesetzt statt als Zahl angezeigt, und
sie gilt jetzt als *unplausibel*, damit statt eines kommentarlosen „0 km / " der erklärende Hinweis
erscheint.
### Verbrauch je Fahrt: Grenzen statt Vertrauen
`verbrauch_aus_literstaenden()` hatte weder Unter- noch Obergrenze. Am echten Sensor nachgemessen:
`can_fuel_volume` löst **0,1 l** auf — ein Quantisierungsschritt entspricht also 10/Strecke l/100 km
Fehler. Unter **3 km** (±3,3) sagt die Differenz nichts mehr, über **60 l/100 km** ist es kein
Verbrauch, sondern ein Messfehler. Beides jetzt als benannte Konstante mit dieser Herleitung.
### Statistik: „Absolut" und Einheiten in eckigen Klammern
Die Einheit stand vier Mal am Zeitraum („Jahr h", „Monat €") und fehlte in der Überschrift, die man
zuerst liest. Jetzt an der Rubrik: **Gefahrene Distanz [km]**, **Fahrzeit [h]**, **Getankte Liter
[l]**, **Kraftstoffkosten [€]**, **Näherung je Fahrt, je Zeitraum [l/100 km]**. Die Zeiträume heißen
nur noch Jahr / Monat / Woche / Tag / **Absolut**.
Fünf Spalten passen auf einem Telefon nicht nebeneinander: bei rund 300 px Innenbreite blieben je
51 px, und „209.177" braucht bei 16 px Audi Type Wide etwa 60. Das Raster bricht deshalb um
(`auto-fit, minmax(84px, 1fr)`) — auf dem Telefon drei plus zwei, auf einem breiten Bildschirm alle
fünf in einer Reihe.
### Radfoto war nicht antippbar
In `Reifen.tsx` stand ein reines `<Bild>`; das Panel hat an derselben Stelle seit jeher
`data-bildklick`. Ersetzen und Löschen waren von dort aus gar nicht erreichbar — jetzt
`BildMitMenue`.