398 KiB
AGENTS.md — Project state, review findings, open items, and working rules
Last updated: 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
1–10 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,
behavioral rules derived from Andrej Karpathy's observations on LLM coding failures. They bias
toward caution over speed — for trivial tasks, use judgment.
- 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.
- 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.
- 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.
- 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/*.mdfile 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:
- This file (overview + open items)
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 phaseSPECIFICATION.md— authoritative for the HA panel, incl. §7 "Known Gaps"COMPANION_APP_ARCHITECTURE.md— authoritative for DataMetric360 (decided, phases 1–10 built)AUDIT_2026-08-10.md— accessibility/platform audit of the panel (partly done, rest below)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
hassobject). - External access: Cloudflare Tunnel + reverse proxy with a path allowlist; HA itself stays
unreachable. Domain
datametric360.deregistered (all-inkl, 2026-08-11; originally.app, switched to.de2026-08-28 — every doc reference updated the same day, including the note inCOMPANION_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/lonandrouteon individual trips were dead schema — onlyhistorienimport.pyever populated them, the live detection path never did, and the trip-detail map drew a fabricated line viafakeTrack()— not a real track. Both are now populated (screening.py's new_position_screenen()for live trips,routeadded to the import path too), andfakeTrack()is removed — the map now draws the real route, or an honest straight line between known points, never an invented curve.addresses/avg_speed_kmhon 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) inpyscript/modules/einstellungen.py.www/bilder/has no vehicle photos (all slots show placeholders);steuer.faelligunset. (BATTERIE_SENSORwas the same kind of gap until 2026-08-12 — now wired to the FMM003'sexternal_power_voltage, see section B/C below. Several other fields are newly inactive since the same date for a different reason — theTommiG1/HA_VAG-EU-Data-Actintegration 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()inmodules/profil.pydoes not handle a missing/corruptfahrzeugprofil.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 commentaudi-dashboard-app.js:13-15claim the statistics view shows sample numbers — false;vStat()computes real values fromTRIPS/FILLS.homeassistant/README.md:110mentions the removed 97% full-tank rule (removal documented inbelegverarbeitung.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/uiinto the app as a real dependency. - Both packages: no
node_modules, nodist— smoke tests neednpm installfirst (design-system additionallynpm run build;scripts/smoke.mjsimports 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 indesign/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:
- The data layer declared field names the backend never sends.
Fahrzeugstatushadtank_prozent/sicher_abgestellt/sicherheit; the backend writestankprozent/gesichert/sicherheitscheck. Every screen would have readundefinedwithout anything failing.technik/ausstattungwere typed as objects but are arrays. - 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.
design-systemstill 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:
- The vehicle block on the home screen was invisible.
overflow: hiddensets 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 }. - The model name appeared twice — once as the badge image, once as text beside it.
- 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 1–13). 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)
- Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
then re-export to
design/ companion-app: set up Vite + React + Capacitor scaffold; wire@audi-dash/uias a real dependency (possibly add a workspace/monorepo root)- Implement the screens from the design draft on top of the existing
DataMetricApilayer - Add Audi assets (fonts/rings/badges) at implementation time from
homeassistant/www/— never intodesign-system/ - Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
ablageSetzen()hook already exists) - Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
locally under HA
/local/, architecture §3) - Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
against
audi_ha_testwith a real token - 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. Ursprünglicher Plan aus
UMSETZUNGSPLAN.mdPhase 10 Stufe 2, Schritt 6: Barcode-Plugin (@capacitor-mlkit/barcode-scanning) + Kamera-Berechtigung in der App, Gegenstück eine kleine statische Seite unter HA/local/dm360-qr.html, die den Token clientseitig (Offline-JS-QR-Bibliothek, keine Netzabfrage) als QR anzeigt — Inhalt{"url": "...", "token": "..."}. Noch nicht scoped/umgesetzt.
B) Infrastructure / commissioning (partly waits for FMM003 hardware)
- Switch
datametric360.denameservers at all-inkl to Cloudflare ("full setup") — 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(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. - 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.dealone covers both API calls and the app's own OTA bundle. SeeCOMPANION_APP_ARCHITECTURE.md§5 item 4. - Choose reverse proxy — decided 2026-08-28: Nginx Proxy Manager, not Traefik. See
COMPANION_APP_ARCHITECTURE.md§5 item 3. - 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). Seehomeassistant/REVERSE_PROXY.md. - Install/wire the FMM003; record firmware version (Codec JSON is firmware-dependent)
- Generate TLS certificates for Mosquitto + device (small private CA) — done 2026-08-11, 10-year
validity; Mosquitto configured (
certfile/keyfile/cafile/require_certificate: true). Remaining: upload root/client cert/key to the FMM003 Security tab — filenames must end in.pem/.pem.crt/.pem.key(Configurator rejects plain.crt/.key, content-agnostic check) - Decide broker reachability for the vehicle — reopened 2026-08-11 evening: port-forward
8883 on the Speedport Smart 4 Plus looked correctly configured (rule present, right internal
IP, right port) and internal reachability was confirmed (
homeassistant.local:8883open from the LAN, Mosquitto TLS listener genuinely up, TLS cert chain end-to-end verified byte-for-byte against the FMM003's uploaded client cert), but the port stayed closed from outside (confirmed via external port checker, both before and after a router reboot that changed the dynamic WAN IP — DNS/DuckDNS matched correctly each time, so not a DNS or CGNAT issue). Community reports (ComputerBase, Telekom Hilft) describe this as a known, Telekom-acknowledged firmware bug on this router model; a full disable-the-firewall workaround doesn't exist on this model either. Port-forward rule has been removed again. New direction: route the FMM003 through flespi instead (native Teltonika/Codec8 channel, IMEI-based auth, no certs, no inbound port needed at all — flespi has a stable public endpoint; HA pulls data back out via flespi's REST API or MQTT, outbound-only). Free flespi tier (10 devices/2 channels) is enough for one vehicle. Next step: user creates the flespi account + Teltonika channel; aflespicustom integration is already present in this HA instance (unconfigured) — check what it needs once flespi-side setup exists. Unrelated but still valid from the same session: DuckDNS hostnamedatametric360.duckdns.orgreliably updating; Let's Encrypt for HA's own local UI works (root cause of the earlier DNS-01 failures was a strayaliasesentry in the DuckDNS add-on config, not DNS/network — see COMPANION_APP_ARCHITECTURE.md §5 item 2a). HA's SSL config now lives in Settings → System → Network (UI), notconfiguration.yaml's oldhttp:block, which was removed after HA started migrating/ignoring it. The Mosquitto TLS setup itself (cert chain,require_certificate: true,certfile/keyfile/cafile) is verified correct and can be reused as-is if a self-hosted broker is ever revisited. - MQTT Client Type on the FMM003 ("Custom server" not selectable in practice, "AWS IoT Custom" pointed at a self-hosted broker was confirmed working by two independent community reports) — moot for now given the flespi pivot above: flespi uses the device's native Codec8/TCP channel, not MQTT at all, so Server Settings should switch to Protocol: TCP against the flespi channel host/port instead, and Codec set to "Codec 8 Extended". Revisit this item only if a self-hosted broker is picked back up later.
- Capture the first real Codec JSON message (
mosquitto_sub/MQTT Explorer) and build the field mapping from it — do not guess beforehand (explicit decision) - Move trip detection to FMM003 ignition — done 2026-08-12.
fahrterkennung.pyrewritten: trigger is noweinstellungen.ZUENDUNG_SENSOR(binary_sensor.testzone_fmm003_engine_ignition_or_acc_status, on = trip running) instead of the iPhone WLAN sensor; sametask.unique()pause-tolerance pattern kept unchanged.sensor.iphone_wifi_connectionand thefahrzeug.wlan_nameprofile field are fully removed (backend and frontend). At the same time, all entity IDs sourced from the now-abandonedTommiG1/HA_VAG-EU-Data-ActHACS integration were blanked ineinstellungen.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 existingzustand_oder_none()convention, rather than being deleted;fahrtabschluss.py/tankerkennung.py/reifenzaehler.pynow guard their@state_trigger(f"{einstellungen.KM_SENSOR}")-style decorators withif einstellungen.KM_SENSOR:(untested previously whether pyscript tolerates an empty trigger string — not worth relying on).STANDORT_TRACKERnow points at the realdevice_tracker.testzone_fmm003andBATTERIE_SENSORatsensor.testzone_fmm003_external_power_voltage(the vehicle's 12V bus voltage as measured by the tracker — NOTsensor.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'sEntiaeten.csvexport (OneDrive, not in this repo). Verified inaudi_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 nodevice_tracker.testzone_fmm003registered, so Standort still shows "Kein GPS-Signal" there — expected, the real HA instance has it).INSTALL.mdstep 4 and its troubleshooting table have since been rewritten (2026-08-12) to point at the Setup menu instead of manualeinstellungen.pyedits, and to use the current FMM003 field names (ZUENDUNG_SENSOR/STANDORT_TRACKER/BATTERIE_SENSOR) instead ofWLAN_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)
- Wide-screen/desktop layout (
@container (min-width:860px)inaudi-dashboard-ios.css, turns the same.tabbarinto a 264px side-nav via CSS only, no JS/markup duplication) — done 2026-08-11 from the Claude Design "DataMetric360 Board" draft (claude.ai/designprojectc28a8d4d-ec4e-4178-9e49-ab5b90c02097). Found and fixed a real regression while verifying it live inaudi_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@containerblock, which broke the normal mobile layout too — HA doesn't reliably propagate a real height down throughpanel_custom, soheight:100%collapsedmain#viewto ~0px (tabbar rendered right under the header, content invisible). Fix: kept the outer wrapper viewport-anchored (min(880px, calc(100dvh - 24px)), matchingaudi-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. - 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 ownaudi-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 viaDesignSync get_file+ byte-diff:audi-dashboard-app.jswas already fully in sync (identical), butaudi-dashboard-ios.csshad drifted — the Design project had since added a.navmarkesidebar 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 inaudi_ha_testat 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. - 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_TRACKERentity-ID setting ineinstellungen.py(device_ tracker with lat/lon attributes), published via a new_standort()helper infrontend_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 inaudi_ha_testwith a manually createddevice_tracker.test_fahrzeugtest 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/.tilestyling); Claude Design's job is polish, not structure. One known rough edge: reverse-geocoded address didn't resolve within a few seconds in theaudi_ha_testcontainer (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. - 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 modulepyscript/modules/entitaeten.py: aFELDERcatalog (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 asprofil.py) andoverrides_anwenden(), which doessetattr(einstellungen, key, value)on the already-importedeinstellungenmodule — every existing consumer doesimport einstellungen+ live attribute access (verified: none usefrom einstellungen import X), so this needs no changes to any consumer file. New serviceaudi_dashboard_entitaeten_schreiben(frontend_api.py) and publicationentitaeten_veroeffentlichen()(frontend_veroeffentlichung.py) follow the project's existing read-via-state/write-via-service pattern. Frontend: new popup (vSetupPopup(), modeled on the existing.sheetpattern) with a from-scratch searchable-combobox component (none existed in the repo before) — client-side only, built directly fromHASS.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_triggerbakes 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 oninput, matching the same fix the existingdata-tankpreisfeldhandler already uses for the identical reason; (2) the popup background used--tile-2, which Design had since changed to a translucentrgba(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.standortmenufor the same reason), withvar(--canvas)as a defensive fallback. - Fresh-install packaging +
update.ps1fix — done 2026-08-12. While preparing a short quick-install checklist, found thatupdate.ps1never copiedaudi-dashboard-ios.cssorwww/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 everyupdate.ps1run 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-mergefiles fromwww/(already gitignored at repo root, never committed, just local clutter from an earlier merge). Newinstallationspaket/— 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 shortANLEITUNG.mdchecklist for copying to a fresh HA instance;INSTALL.mdstays 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. - Setup-menu polish: 5 additions on top of the existing Setup popup — done 2026-08-12.
Backend (
pyscript/modules/entitaeten.py): a_STANDARDWERTEsnapshot taken at module-load time (beforeoverrides_anwenden()ever runs), exposed viaaktueller_stand()asstandardwerte— needed becausesetattr()on the liveeinstellungenmodule is permanent for the process, so "reset to default" has to write the original value back explicitly rather than just omitting the override. New serviceaudi_dashboard_neustart(frontend_api.py) wrappinghomeassistant.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, readsHASS.states[e.id].state); (2) inline warning under a field if its currently assigned entity isunavailable/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 fromstandardwerte), 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 inaudi_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 intodata/entitaeten.json(byte-inspected) and into the publishedpyscript.audi_dashboard_fahrzeugstatusstate; 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. - 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 inAUDI_CI_ARCHIV_2026-08-13.md, which also documents why reverting later needs no restore:audi-dashboard-ios.cssis a purely additive overlay loaded afteraudi-dashboard.cssin_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>. - 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.aktionno longer fills every action red; base.aktionis now a neutral--ios-fillsecondary button, a new.aktion.primaerrule is the only one filled with--ios-tint(red), matching the.aktion/.primaer/.loeschensemantics 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-fillinstead of a red tint, red stays only as the accent text/icon color; two chart-bar segments inaudi-dashboard-app.jsthat used--redfor a neutral data category ("Arbeitsweg" trips, first segment of the insurance contribution bar) now use--fglike their sibling segments. Radius inconsistency:.setup-popupand.standortmenuinaudi-dashboard.cssnow referencevar(--r-tile)instead of a hardcoded20pxliteral, so both track whichever design (iOS 16px or, if reverted, Audi-CI 20px) is actually active. Verified live inaudi_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. - Clean up remaining "EU Data Act" residue from the FMM003 pivot (2026-08-13) — the
Einstellungen "Version" tile in
audi-dashboard-app.jshardcodedFahrzeugdaten: EU Data Act / Abruf alle 15 MinutenandPosition: iPhone / Companion App, both stale: vehicle data and position now come exclusively from the FMM003 (ZUENDUNG_SENSOR,BATTERIE_SENSOR,STANDORT_TRACKERineinstellungen.py), not the abandoned HACS integration or the iPhone. Both rows now readFMM003. Also fixedtestumgebung/konfiguration.yaml+testumgebung/README.md: theinput_text.test_wlan/iPhone WiFi Connectiontemplate sensor simulated WLAN-based trip detection, fully removed 2026-08-12 (fahrzeug.wlan_ssidno longer exists anywhere) — its "Fahrt auslösen" example was non-functional. Replaced withinput_boolean.test_zuendungdriving abinary_sensor.testzone_fmm003_engine_ignition_or_acc_statustemplate sensor matching the realZUENDUNG_SENSORentity ID, so trip detection stays testable. The remaining KM/Tank/Range/door/window/lock template sensors correctly still describe the abandonedTommiG1/HA_VAG-EU-Data-Actintegration (pereinstellungen.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. - Fix oversized toggle switches ("Slider") across the app (2026-08-13) —
.feld labelinaudi-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 labelhas 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 inaudi_ha_testat mobile (375px) and desktop widths — switches render at the correct ~51px iOS size, no console errors. - Fix desktop (≥860px container) layout audit findings (2026-08-13, see
DESIGN_AUDIT_2026-08-13.mdfor 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 asmain#view) had nomax-width:860pxwhilemain#viewdid — the track itself isminmax(0,1fr)and stretches to the full remaining window width, so.head{flex:1}pushed.profilbtnto the track's far edge, ~450px past wheremain#viewvisually ends. Fixed by giving.topbar/.phone:has(.back.on) .topbar/.ptrthe samemax-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.standortmenuare allposition:absolutewithleft/rightrelative to the full.phonebox (sidebar included), not the content column, and had no desktop max-width either —.setup-popupmeasured 1644px wide on a 1920px window (10px margins only). Fixed withmax-width:560px(.setup-popup/.sdpopup/.sheet) or640px(.standortmenu) plusmargin-left/right:autoin the same container query — deliberately nottransform:translateX(-50%), which would fight these elements' own transform-based entry animations. Centering is relative to the full.phonewidth (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 inaudi_ha_testat 375px and 1920px, no console errors. - 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 —.backreserves 44px+gap in.topbareven when inactive (opacity:0, notdisplay:none), fixed with.back:not(.on){width:0;...}in the desktop container query, root pages only (detail pages with.back.onunaffected, 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 basetop: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 selectand 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 inputs deliberately left alone. (4) the expand/collapse.markchevron 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;.markwas 6×10px (non-square, sized for the unrelated SVG-path.chev/.goicons), 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.5xtransform: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. - 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_SENSORfromentitaeten.py'sFELDERcatalog (no longer offered in the Setup menu) and fromeinstellungen.py(attributes deleted, not just blanked), and removed their entries from_sicherheitscheck()infrontend_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-orphanedinput_boolean.test_entriegelthelper and the fourtest_audi_lock_*template sensors fromtestumgebung/konfiguration.yaml(door-open/window fixtures forTUER_SENSOREN/FENSTER_SENSORENare untouched), and updatedtestumgebung/README.mdaccordingly. (2) "Doppelt zugeordnet" (and every other confirm-sheet) was see-through —.sheet-gruppe/.sheet-abbrecheninaudi-dashboard.cssused--tile-2, which the iOS overlay's night theme redefines asrgba(255,255,255,.10)(translucent, meant for tile-on-tile layering) — the same root cause already fixed for.setup-popup/.standortmenuon 2026-08-13, just not carried over to the generic confirm-sheet used bybestaetigen()/hinweis(). Switched both tovar(--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-deckendis 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()infrontend_veroeffentlichung.pyonly ever readlatitude/longitudeas attributes of a singleSTANDORT_TRACKERdevice_tracker entity (HA's usual convention) — but some integrations (confirmed live inaudi_ha_test's own simulated FMM003 device, which exposes separate "Latitude coordinate value"/"Longitude coordinate value" sensors) publish them as two independentsensorentities instead, which_standort()had no way to consume. AddedSTANDORT_LAT_SENSOR/STANDORT_LON_SENSOR(new optional fields ineinstellungen.py+entitaeten.py's Setup-menu catalog, group "standort") as a second path:_standort()now triesSTANDORT_TRACKERfirst (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 thenachladeAnstossenheader comment,audi-dashboard-app.js): on a slow/cold backend start,render()no-ops untilDATEN_GELADENflips true, and the previously observed manual fix was a HA panel remount. Root cause of why the click helps was not fully reproducible inaudi_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 callsdatenLaden(false)proactively ifDATEN_GELADENis 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()inhomeassistant/data/shell_beleg_parser.py, wired as a fallback inmain()(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 populateddiscountas 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 useshashlib.sha1(not the built-inhash(), 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 inaudi_ha_test(version1786752000): 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 toinstallationspaket/. - Follow-up round the same day (2026-08-16), three more items:
(1) Removed
STANDORT_TRACKERentirely — 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 fromeinstellungen.pyandentitaeten.py's Setup catalog, and simplified_standort()infrontend_veroeffentlichung.pydown 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 staleSTANDORT_TRACKERentries already sitting indata/entitaeten.json— confirmed live: this container's own leftover override file still hadSTANDORT_TRACKERfrom earlier testing (plus staleTUERSCHLOSS_SENSOREN/HAUBENSCHLOSS_SENSORfrom 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 persistedSTANDORT_LAT_SENSOR/STANDORT_LON_SENSOR. Redid the Setup save live (mapped both tosensor.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>→ syntheticFile/DataTransferdispatch →FileReader→hass.callService) via browser instrumentation: the happy path works completely (backend parses, publishespyscript.audi_dashboard_beleg_ergebnis, frontend fills the form) —belegverarbeitung.py's existingtry/exceptaround_parser_aufrufen()already publishes a visiblefehlerfor parser failures specifically. The actual gap:base64.b64decode()and_pdf_speichern()inaudi_dashboard_beleg_hochladen()were outside that try block — any failure there (corrupted upload, disk full, ...) raised an unhandled exception with zero UI feedback, sinceserviceRufen()'s generic.catch()only doesconsole.error(). Widened the try block to cover all three steps. Also stopped usingserviceRufen()for this one call site specifically (audi-dashboard-app.js, theeingabe.onchangehandler) — it now callsHASS.callService()directly with its own.catch()that setsbelegFehlerand 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 — addedCI.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) toaudi-dashboard-app.js, replacing the single-colorpoiCarteardrop previously used byfahrzeugMarkerSVG(). Updated both marker call sites'iconAnchorto the new pin's actual tip position and simplified.fahrzeug-pininaudi-dashboard.css(no morecolor: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 inaudi_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 toaudi_ha_test(version1786758000) and synced toinstallationspaket/. - 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()appendedleaflet.min.csstodocument.head, but the panel renders insidethis.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 intoROOTand awaiting it before the map is built; the script tag stays indocument.head(it must,window.Lis global). The CSS injection is deliberately re-checked on everyleafletLaden()call rather than guarded by thewindow.Lcheck, because a HA panel remount produces a fresh shadow root whilewindow.Lis 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 CARTOdark_allin Nacht mode; replaced by a single themelessTILE_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 CIpoi-caricons (poi-car-l.svg/poi-car-s.svgfrom the delivered icon set) instead of the hand-built two-tone pin from the previous round:CI.poiCarL(48-grid) at ≥34 px, the existingCI.poiCar(24-grid) below, anchors recomputed per variant. Since these are single-color outline forms incurrentColor,.fahrzeug-pinregained 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-deletebehind 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 awischtclass set on the first movement of the gesture (and the existingswipedclass 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)}withbackground: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 headingSUMME-EURand 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 carriesSHELL TANKSTELLE, 6450 SÖLDENon one line): it finds the first postcode line in the header and takes the name from the line above.Preis/Lwithout 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 toaudi_ha_test(version1786765000) and synced toinstallationspaket/. - 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 selectinaudi-dashboard-ios.cssnow all getbackground: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 inputrule became redundant and was removed (its width/alignment still come fromaudi-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 inshell_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_addresskeeps the full street + postcode + city as before. The regression suite'stest_bekannte_stationenexpectations 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 fromhomeassistant/.gitignore. It carries no real vehicle data, onlydata/fahrzeugprofil.example.jsonwith empty FIN/Kennzeichen placeholders; verified before un-ignoring. Keep it in sync wheneverpyscript/orwww/changes — that was already the working rule, but it is now visible in the repo when it is forgotten. Deployed toaudi_ha_test(version1786766000). - Fifth round the same day (2026-08-16) — one-click installer and the Einzelbeleg map:
(1)
installationspaket/Installieren.cmd+install.ps1automate steps 1–3 ofANLEITUNG.md. The.cmdwrapper exists because double-clicking a.ps1opens 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 overwritesfahrzeugprofil.json,fahrten.jsonl,tankvorgaenge.jsonl,entitaeten.json,ha_token.txt; backsconfiguration.yamlup to.bakand replaces its own marker-delimited block instead of appending twice; refuses to touch the file at all whenpyscript:/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 noconfiguration.yaml.-Pruefenis a dry run.install.ps1is 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 redcircleMarkeris now the CIpoipin (CI.poiL/CI.poiSfrompoi-l.svg/poi-s.svg, viatankstellenMarkerSVG()) — deliberately the plain pin, notpoi-car, so vehicle and station stay distinguishable. Found while testing: nothing in the backend ever writesstation_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 existingstandortAdresseAufloesen()), with results cached inlocalStorage(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 deliveredgps-s.svg), disabled until the coordinates arrive..mapboxgainedposition:relativeso the control anchors to the card. Only there, per explicit request — the Standort fullscreen already has its own centre controls. Deployed toaudi_ha_test(version1786769000). Note for testing map behaviour in this app:initMap()runs on everyrender(), 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 onL.Map.prototype.flyToinstead. - 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(andpoiL/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'sdattribute 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 newCI.poiCarLSil/poiCarSil/poiLSil/poiSSilconstants, fixed-color-filled (notcurrentColor), andfahrzeugMarkerSVG()/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.autoCSS 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_Vinaudi-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()recomputedyMin/yMaxfrom 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 invBatterieverlauf()(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-unusedliterlocal was removed with it. (7) Active/primary buttons are grey again, not red —.aktion.primaerinaudi-dashboard-ios.cssfilled with--ios-tint(red) since the 2026-08-13 iOS-overlay decision; explicit user request to drop that and match the base stylesheet's neutralvar(--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 hadappearance:none(native arrow removed) but no replacement indicator at all — a real gap, not cosmetic. Added a CSSbackground-imagechevron (light/dark variants) in both stylesheets; baseaudi-dashboard.csskept 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'sget_page_textworked and was used instead) and cross-checked against actual measurements in this codebase, then asked the user viaAskUserQuestionwhich 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::beforewith 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…") fromvEinst(), both per explicit request. (11) Fuel bar color now matches the headline (var(--fg)) instead of a red/yellow/green status gradient invHome(); thebalkenFarbelocal was removed with it. (12) Selected menu icon is white (headline color), not red, in the desktop side-nav (.tab.oninside the@container (min-width:860px)block inaudi-dashboard-ios.css) — the mobile tab bar already usedvar(--fg)there, this brought the desktop variant in line. (13) Reifenfoto (Sommerrad/Winterrad) now crops from the right edge, not the center —.radbildgainedobject-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.montiertclass, 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 (bothvar(--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.ongets the highlight background directly (whole button), with the inner.tabpillebackground suppressed in that case; the icon-only mode (.tabbar.ohne) keeps the original tight icon-pill unchanged. Deployed toaudi_ha_test(version1786901000); 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. - 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 theradbildclass on both the wrapping.bildboxdiv 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.bildboxis declared later in the file, so it silently won — the box stayed a full-width 4:3 rectangle the whole time,object-position:right centerjust 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);.radbildalone now only carriesobject-position. Verified live:.bildbox.radbildnow 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 acolor-schemehint. 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 unlesscolor-schemesays otherwise — this is what made "Modell" and every other dropdown "flash" bright white against the dark app on open. Addedcolor-scheme: darkunder:host([data-theme="nacht"])andcolor-scheme: lightunder:host([data-theme="tag"])inaudi-dashboard-ios.css. (4) Fixed a real JS crash in the "Mein Audi" image-cycle click handler.bildWeiter()(fires on tapping the hero photo) triedbox.querySelector(".platzhalter-datei").textContent = ...— that class was never emitted bybildMitPlatzhalter()(the actual class is.platzhalter- aktion, and in gallery mode it deliberately shows a static "Foto hinzufügen" prompt, not a filename).querySelectorreturnednull, the assignment threw, and the exception aborted the function before reaching the line that updates the.dotspage 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.dotsonclass 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 genericdata-bildklick/bildMenuOffenpopup plumbing already used by the wheel-photo tiles, not a new mechanism.bildInfo()'s existing winter-side-view special case (swaps inseitenansicht-winter.webpwhile 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-unusedBILDER_UPLOAD_SLOTSconstant and the.bildgrid/.bildslot/.bildslot-label/.carfix.miniCSS were removed as dead code; a new.bildmenu.ansichtpositions 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) inaudi-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 toaudi_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 thecolor-schemefix 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-schemeonly gets partial credit there; the reliable lever is that Chrome/Firefox/Edge do honor explicitbackground-color/colorset 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 viagetComputedStyleon 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 staysleft-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#tabbarand.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 selectorspandoes not care about class, so the rule hid the icon's wrapper too.getBoundingClientRect()on the<svg>read{w:0,h:0}andgetComputedStyle(.tabpille).displayread"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 bothaudi-dashboard.cssandaudi-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. - 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()'ssatz(); the singular "Sommerrad"/"Winterrad" photo label and thedata-satzvalue compared againstCAR.reifen.aktivelsewhere 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-headlineclass (20px,var(--fg), same typographic family as the page.titleone step down) replacing the small, muted.labelstyling 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;.montiertdroppedposition: absolute(no longer needs to float over the photo now that the photo isn't in that space) and became a normal-flow pill withmargin-topunder the headline block — simpler than the previous overlay hack, same 44×44pt invisible tap target via::before..bildbox.radbild'smargin- 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 toaudi_ha_test, confirmed live via screenshot for both Sommerräder (mounted) and Winterräder (unmounted) tiles. - 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 inbild-fehltstate, and.bildbox.bild-fehlt img{display:none}makes the<img id="carimg">itself un-hit-testable — a real tap lands on the.platzhalterdiv instead, which the old handler (e.target.id === "carimg") never matched, so nothing happened. First fix wrapped the gallery image in adata-bildgaleriecontainer 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 ofbild-fehltstate) but always callsbildWeiter(), 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-kopfflex row, same geometry as the header's.syncicon+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 realbattery-12v-l.svg/battery-12v-s.svgfiles 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 asCI.battery12vL/CI.battery12vS(verbatim paths,lon the 48-grid,son the 24-grid, matching the existingpoi-l/poi-ssize-variant convention); only thesvariant is used here viaciSVG()since this is a small 15px inline label icon. Deployed toaudi_ha_test, confirmed live via screenshot. Also this round (user then supplied the realedit-l.svg/edit-s.svgtoo): added a manual km-correction control to the Reifen tiles, per explicit request — a small pencil icon (Audi CIedit-s, newCI.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 stripskmbefore 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 mutatingCAR.reifen[k].kmand callingprofilSpeichern()like every other Reifen field does would silently drop the edit. Added a dedicated backend service instead,audi_dashboard_reifen_km_setzen(satz, km)inreifenzaehler.py, that only overwrites the stored counter itself and leavesreifen.referenz_odo_km(the last-processed odometer reference) untouched - the existingkilometerstand_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. NewkmOffenstate (which satz's correction field is open, mirroring the existingnmOffen/bildMenuOffensingle-open-popup pattern) plusdata-kmauf/data-kmspeichernhandlers, styled like the existing.montiert/.zahnradcontrols (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. - 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-motionsupport, confirm-sheet button ordering all already correct). Three real findings surfaced, all fixed and user-approved viaAskUserQuestionbefore 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, ...) — wasasyncwith notry/catchat 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 insideprofilSpeichern()itself intry/catch, reusing the existinghinweis()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-patchinghass.callServiceto reject inside the running app (viahost.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-spinnerCSS, 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 globalprefers-reduced-motionoverride (*, *::before, *::afterblock already forcesanimation-duration:.01msapp-wide) with no extra code. (3) Anzugsmoment and km-correction popups now use.aktion.primaeron 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 inaudi_ha_test(version1786916894): spinner CSS confirmed viagetComputedStyle(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 toinstallationspaket/. - 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
PointerEvents through the exactpointerdown/pointermove/pointeruphandlers inereignisseVerdrahten()(theptrY0/ptrDraglogic) — 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 nooverscroll-behaviorset 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 withoverscroll-behavior-y: containonmain#viewplusoverscroll-behavior: noneon the outer:host > divwrapper, so a gesture leavingmain#viewnever 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 viagetComputedStylethatoverscroll-behavior-ycomputes tocontain; no visual regressions, no new console errors. Synced toinstallationspaket/. 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. - 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 (
belegPopupstate,vBelegPopup(), rendered into the existing#overlayalongsidesheetMarkup()/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 intobelegDateiVerarbeiten()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:dragovermustpreventDefault()or the browser just opens the PDF itself and nodropever fires; the drag/drop/paste listeners hang ondocument, not on the drop zone, because the zone lives in the shadow DOM and a drag crossing its edge would otherwise be lost — and becauserender()replaces the overlay'sinnerHTML, so per-element listeners wouldn't survive. Phone pasting usesnavigator.clipboard.read()(iOS shows its own paste prompt) with the keyboardpasteevent as the desktop path. Non-PDFs are rejected with an in-dialog message instead of being uploaded. Verified live inaudi_ha_testby instrumentinghass.callServiceand driving realDragEvent/ClipboardEvent/KeyboardEvents: 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 toinstallationspaket/. 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: daspaste-Ereignis trägt die Datei inclipboardData.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", pertastenkuerzelEinfuegen()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 leeremtypean und wurde dadurch abgelehnt. Neue HilfsfunktionistPdf()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". Derpaste-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. - 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)aufde(..., 2)umgestellt: Jahres- und Monatsschnitt in der Tankliste, Zeilenwert je Tankvorgang, "Preis je Liter" im Einzelbeleg, die Live-Vorschau in beiden Tankformularen (statisch und imdata-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 - dereneur()rundet ohnehin schon auf 2 Stellen. Live geprüft: keinede(..., 3)-Aufrufe mehr in der Datei, Liste und Einzelbeleg zeigen "2,07 €/l", "1,99 €/l" usw. - Twelfth round (2026-08-17) — six user-reported items, one of them a real dead-end in the
bootstrap:
(1)
HECKKLAPPENSCHLOSS_SENSORremoved for good, the same treatment Türschloss/ Haubenschloss got on 2026-08-16: gone fromeinstellungen.py(attribute deleted, not blanked), fromentitaeten.py's Setup catalog, and from_sicherheitscheck()infrontend_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 132pxoverflow:hiddenbox. 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, whoseposition: absolute;inset:0is over-constrained by an explicit height (the height wins,bottom:0is ignored). Scoped both rules to.bildbox.carfix; image and placeholder fill the box via their own100%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 KernservicePrognose(stichwort, km, monate)zurückgeführt;inspektionPrognose()nutzt dieselben 30.000 km / 24 Monate wieintervalle()/termine(). Angezeigt in der Service-Kachel auf "Mein Audi" (auch ohne Fahrzeugmeldung, anders als beim Ölwechsel - dort nenntterminText()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 gemeinsamesfahrtFelder()/fahrtFormularWerte()(Datum, Startzeit, Dauer, Art, Start-/Zielort, Start-/Endkilometer, Distanz) nach demselben Muster wietankFelder()/tankFormularWerte(), benutzt vom Erfassungsformular und vom neuen "Werte bearbeiten" auf der Einzelfahrt-Seite (fahrtBearbeitenOffen, VorbildvFill()). Backend:audi_dashboard_fahrt_manuell_anlegenum dieselben Felder erweitert, neuer Serviceaudi_dashboard_fahrt_aktualisieren. Zwei Punkte, die dabei zählen: von Hand gesetzte Felder landen inedited_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 jetztprofil.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, annext((f for f in …), None)real gesehen) - Listen-Abstraktionen dagegen schon. Live end-to-end geprüft: anlegen, bearbeiten, erneut bearbeiten, jeweils infahrten.jsonlnachgesehen 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:.backist auf Wurzelseiten unsichtbar (opacity:0), belegt aber Breite plus Gap. Die Regel.back:not(.on){width:0;…}gilt jetzt auf allen Breiten und hebt permargin-right:-4pxauch den Gap auf;.topbarbekam links 16px statt 20px, passend zu--seitenrandvonmain#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. - 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.callWSohne neuen Timer aus, unddatenLaden()bei!HASSebenso - danach lief nichts mehr von selbst, und der einzige Ausweg war ein zufälliger hass-Push oder ein Menüklick (go()ruftdatenLaden()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 inaudi_ha_testgezeigt (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. - 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 byfahrtZeitraum()/dauerText()and kept live via a newdata-fahrtzeitfeldinput handler (same "patch the one node, don't call render()" pattern the existingdata-tankpreisfeldhandler 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()derivests_start/ts_endfrom 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 recalculatedduration_s(7200s for a 10:00→12:00 edit, checked infahrten.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 newartText()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-unusedworkclass 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--tileis 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,.24in Nacht) - the same token.feld select/.aktionalready 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 toaudi_ha_test(version1787009000) and synced toinstallationspaket/; console showed only the pre-existing service-worker/404 noise from the missing vehicle photos, nothing new. - 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:.rowist ein Flex mitdt/dd, undddbekommt 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) nebendt/ddund umbricht perflex: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. Dasflex-wrap:wrapist 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 einemhatMeldung-Zweig für den Wert plus einem separat berechnetenfussnote-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.rowauf "Mein Audi" weiterhin zweispaltig; keine neuen Konsolenfehler. Deployed als Version1787003000, synchron ininstallationspaket/. - 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 inhomeassistant/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/uicomponents) and found six real, mechanically distinct gaps, all fixed: 1.AGM_RUHE_MAX_Vwas still 13.2 inBatterie.tsx- lowered to 12.8, matching the panel's 2026-08-16 fix (same rationale: above that is already alternator voltage). 2. Deadwlan_namefield -profilAdapter.tsstill read/wrote it andEinstellungen.tsxshowed 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.tsonly hadoelwechselPrognose(). Refactored the shared math into aservicePrognose()core (keptoelwechselPrognose()'s public signature/behavior identical - all 9 existing tests still pass unchanged) and addedinspektionPrognose()(fixed 30.000 km / 24 Monate, no customizable interval like the oil change has), wired intoService.tsxas a new "Eigene Prognose (Inspektion)" row. 3 new tests. 4. Arbeitsweg pill was red -Pill variant="work"inFahrten.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 sharedsrc/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.tsxwas entirely read-only - no edit, no Art toggle after creation. Added a "Bearbeiten" toggle reusingFahrtFelder.tsx, backed by a newDataMetricApi .fahrtAktualisieren()(mirrors the existingtankvorgangAktualisieren()pattern) calling the backend'saudi_dashboard_fahrt_aktualisierenservice (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'seur()already always renders 2 decimals - the 3-decimal bug the panel had never existed here); pull-to-refreshoverscroll-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 inTankDetail.tsxgets 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 forinspektionPrognose()),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 frommain.tsx) and only the nativeios//android/platform folders themselves (deliberately gitignored, regenerated vianpx cap add) are the missing piece, and that step needs a Mac. - Ü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 kmgroß,vsl. 04/2027als leisere Zweitzeile), echte Einheit statt „tkm", und die Kachel ist jetzt einbutton.tile.tilebtnmit.go-Chevron nachservice:x— vorher eine Sackgasse, obwohl die Service-Seite existiert.mmjj()wurde dadurch verwaist (nur noch das volle Jahr wird gebraucht) und ist durchmmjjjj()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, antippbareTile variant="button" chevron→service. Geprüft: typecheck sauber, 95/95 Tests, Build erfolgreich. - Ü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.tilemit 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 samtART_BIS-Zuordnung für den richtigen Artikel („bis zum Ölwechsel“, aber „bis zur 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 Version1787007000(zwischenzeitlich auf Nutzerwunsch von.tile.flatauf eine normale.tilemit Hintergrund umgestellt —.serviceblock:active {opacity:.55}als Tipp-Rückmeldung ergänzt, da der übliche.tilebtn-Hintergrundwechsel hier nicht geht), synchron ininstallationspaket/. Entwurf und Begründung als Artefakt: „Zwei Kacheln, drei Antworten“. - 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, ausC:\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 bekamCI.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 +.figinvHome()'s Serviceblock; die Nebenzeile zeigt jetzt nur nochvsl. DATUM. Companion-app bekam die identischen Pfade als neueSymbolOelwechsel/SymbolInspektion-Komponenten insymbole.tsx(eigenerRahmenFlaeche-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 inscreens.css;Uebersicht.tsxwählt das Symbol über eine kleineSERVICE_SYMBOL-Tabelle nachservice.art. Wichtig: die Icons blieben bewusst außerhalb vondesign-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 gemeinsamenFig-Komponente. Die Hauptuntersuchung hat kein eigenes Icon (kein Restkilometer-Ziel,restKmist dort immernull) und behältbisText()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 buildsauber. Live im Docker-Testcontainer geprüft (Version1787012001, synchron ininstallationspaket/): Ölwechsel-Icon rendert links der "8.999 km"- Kennzahl, Unterzeile zeigt nur noch "vsl. 18.04.2027", keine neuen Konsolenfehler. - 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.
zahlGroesseist jetzt fest 40 (PanelvHome()und companion-appUebersicht.tsx,Fig size={40}unabhängig vonrestKm). DabisText()/ART_BIS(Panel) undbisText()/BIS_TEXT(companion-appservice.ts) dadurch ihre einzige Aufrufstelle verloren, wurden beide als Orphans entfernt (inkl. des zugehörigen companion-app-Tests inservice.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 buildin companion-app sauber. Deployed als Panel-Version1787012002, synchron ininstallationspaket/. - "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 — pergetBBox()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-chipfür einen vereinbarten Werkstatttermin, optionale.tg-vorgabe-Zeile,.tg-wertals 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) vonvHome()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 nutztvar(--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 Version1787013000, synchron ininstallationspaket/, 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 gemeinsamenWerteliste; jetzt "Anstehende Termine" wie im Panel, mit denselben drei.dm-termingruppe- Blöcken. NeueSymbolOelwechsel/SymbolInspektion-Komponenten insymbole.tsx(bereits für die Übersicht angelegt, hier wiederverwendet). Neuer ExportletzterInspektion()inservice.ts(Gegenstück zuletzterOelwechsel()) 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 keinevereinbart-Datenfelder für Service-Termine — der Werkstatt- Chip entfällt deshalb ersatzlos, es gibt nichts anzuzeigen. (2) companion-app hat kein Gegenstück zuintervalle()/dem generischen Herstellerintervall-Fallback des Panels; die Herstellervorgabe-Zeile wird stattdessen direkt ausfahrzeug.oel.modusabgeleitet (Ölwechsel: nur beimodus === "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 typechecksauber,npm run test99/99,npm run builderfolgreich; 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 aniv.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 aufmodus: "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, sobaldCAR.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-Version1787013001, synchron ininstallationspaket/, live per DOM-Abfrage geprüft: Ölwechsel-Block zeigt jetzt "Herstellervorgabe", keine neuen Konsolenfehler. companion-app:npm run typechecksauber,npm run test99/99,npm run builderfolgreich. - 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 nachcompanion-app/portiert (sieheUebersicht.tsx):naechsterService()indaten/service.tsneu — Gegenstück zunaechsterTermin()im Panel, wählt über Ölwechsel-/Inspektions-Prognose und die von Hand gepflegte Hauptuntersuchung hinweg den zeitlich nächsten Termin,bisText()liefert denselben Artikel wieART_BISim 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 typecheckblieb sauber,npm run testauf 100/100 (von 95 — vier neue Tests fürnaechsterService()/bisText()inservice.test.ts, einer inscreens.test.tsx, der mit einemvi.fn()alsgeheZuwirklich belegt, dass ein Klick auf den Serviceblock navigiert und ein Klick auf die Kilometerstand-Zeile es nicht tut — dafür musstezeige()inscreens.test.tsxerst einen injizierbarengeheZu-Parameter bekommen, vorher war er hart auf() => {}verdrahtet),npm run builderfolgreich. - 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.mdandREVIEW_main_2026-08-13.mdlist further drift not yet fixed (WLAN references still inSPECIFICATION.md,homeassistant/README.md, the profile template) - Harden
profil_lesen()against missing/corruptfahrzeugprofil.json— done on theumsetzung-datametric360branch (2026-08-11), brought over by the 2026-08-13 merge. ReturnsNoneon 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 forNone— verified by grep across the merged tree, no caller left unguarded (this was flagged as the merge's main risk inREVIEW_main_2026-08-13.md§"Was den Branch betrifft"). - Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 — decided
2026-08-11 (
UMSETZUNGSPLAN.mdPhase 2): swipe-delete keyboard fallback, popup keyboard access, and self-hosting Leaflet stay unfixed in this panel and are addressed only in DataMetric360. - Optional: persist the RAM-only states (trip start, fuel low-water-mark) — deliberately deferred; may become moot with the FMM003 switch
- Upload vehicle photos to
www/bilder/, setsteuer.faellig(operational data, not code)
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.notizCSS class pattern used elsewhere (seefeld.notizinaudi-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.jsonlandfahrzeugprofil.jsonunder/config/audi_dashboard/have no purge path anywhere inprofil.py— nothing trims them, ever. That was already true before this change. - HA's raw sensor history did not.
recorder.purge_keep_daysdefaults to 10 days, and the test instance had norecorder: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 1–1,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()infahrterkennung.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 tocustom_components/<domain>/,www/community/<name>/,python_scripts/,themes/,custom_templates/andappdaemon/apps/<name>/(verified inrepositories/*.py→localpath). The app needed/config/pyscript/and/config/audi_dashboard/andconfiguration.yamlentries. This is what the conversion solved. - HACS is GitHub-only, and does not work here — permanently, not just today.
github.com/api.github.comare hardcoded throughout; the repo lives ongitea.nothaft.cloud. Briefly mirrored togithub.com/T130B/DM360on 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.jsonwas removed (dead weight with no GitHub target to serve), andmanifest.json'sdocumentation/issue_tracker/codeownerspoint at the Gitea repo instead.homeassistant/installationspaket/install.ps1is 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 orhacs.jsonwithout 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:
- 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 barestate.set()can't, so every publish wrote the complete trip archive to the DB, every 60 seconds. Confirmed by querying the history API: onlyiconandfriendly_namecome back. Note the mechanism moved: exclusion now comes fromstate_info["unrecorded_attributes"]on the Entity, not from a per-domainrecorder.pyplatform hook (checked indb_schema.pyof the installed 2026.8.0). - A running trip survives a restart.
fahrt_start_tsand the tank low-water mark live in aStoreinstead of only in memory. This was a documented data-loss bug. - Trigger rebinding is live.
@state_triggerbaked 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 theneustartservice 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.ps1run (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/, theconfiguration.yamlblock) 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.bakwas overwritten on every run, so a second run destroyed the pristine original. Backups are now timestamped, and the plain.bakis 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 ~1–1,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
17–21: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"...— andintervalle()returnsnullwhenever there's no Servicebuch basis, full stop. The vehicle-reportedfm/hatMeldungwas 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, ignoringoelHatMeldung/inspHatMeldungcomputed one line above it.src/daten/service.ts'snaechsterService()(feeds the Home-screen "Nächster Service" tile) had the same gap one level up: it only ever built candidates fromoelwechselPrognose()/inspektionPrognose()(both Servicebuch-only), never falling back tofahrzeug.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:
- 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. - The swap is
os.rename, not delete-then-copy: the live folder is renamed toaudi_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. - The downloaded
manifest.jsonis checked fordomain == "audi_dashboard"and aversionfield before either rename runs — matches install.ps1's own "won't touch a folder that isn't provably ours" rule (section G/H). - 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.
- Nothing outside
custom_components/is touched — same invariant as install.ps1. - 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: falsedecision.
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 justmanifest.json, no repo download) andupdate_installieren()(downloads the full repo archive via Gitea's archive endpoint — Gitea has no single-folder download — delegates to the blockingentpacken_pruefen_tauschen()viahass.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 indienste.pyfollowing the existingSCHEMATA/behandler/_als_dienst()pattern; both zero-field, both catchaktualisierung.AktualisierungsFehlerand turn it into a user-readable message instead of an unhandled exception. koordinator.py:alles_veroeffentlichen()'sE_APP_VERSIONpublish was factored out into its ownapp_version_veroeffentlichen()method and extended withintegration_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+IntegrationUpdateAngabetype inapi/types.ts+integrationUpdateinDatenKontext.tsx; panel: new tile next to the existing Version tile inaudi-dashboard-app.js, backed by a newINTEGRATION_UPDATEglobal read fromdaten.integration_update, wired to the existingdata-*/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 astests/belegparser/test_shell_beleg_parser.py) covering: successful swap with the old version preserved in the backup folder,__pycache__cleanup, wrong domain rejected, missingversionfield rejected, missingmanifest.jsonrejected, 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 throwawayghcr.io/home-assistant/home-assistant:stabletest container (audi_ha_test) viadocker 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 eingerichtetwith zero errors or tracebacks — confirms theOptionsFlow/service registration/coordinator wiring is structurally correct against a real, current (2026.8.0) Home Assistant. - companion-app:
tsc --noEmitclean, 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()inaudi-dashboard-app.js, wired intovWerkstatt()'s new hiddendata-vcard-importfile input via the existing centralchangelistener — same parse-then-hinweis()-on-error pattern as the existing JSON profile/backup import handlers. UpdatesCAR.service.autohausfields directly and calls the existingprofilSpeichern()(saves immediately, matching this field's existing per-keystroke-save behavior). Same isolated-function shape asentpacken_pruefen_tauschen()in section J's self-updater: parse logic takes no dependency onHASS/global state, so it's directly testable. - companion-app: new
src/screens/vcard.ts(vcardParsen(), 7 unit tests invcard.test.tscovering folding, escaping, ORG fallback, first-wins on repeats, and the two "nothing readable" error cases), wired intoWerkstatt()inService.tsx. Unlike the panel, this only populates the localentwurfdraft 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:
- No feedback during or after the ~15–25 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.
- 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 wheneverDATEN_GELADENis 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 ahomeassistant.restartcall 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-ladezeilepair added to companion-app'sscreens.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
fehlerset — 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 (atry/finallywith nocatchre-throws into an unhandled promise rejection). Added explicitcatchblocks 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), thedata-updateclick handler rewritten fromserviceRufen()(fire-and-forget) to directHASS.callService()promise chains so the loading flag can wrap them;vEinst()'s tile rewritten with the three states above. -
companion-app:
updateNeustartLaeuft/updateAktionFehlerstate andupdateNeustartAusloesen()inEinstellungen.tsx; newhomeAssistantNeuStarten()inapi/index.ts(dienstAufrufen("homeassistant", "restart")— note the domain isn'tDIENST_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:
-
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 fromletzterEintrag()(a real Servicebuch entry) — with none,km/datum/zielall stayednull, sovHome()(Übersicht) rendered a broken/empty service block, whilevService()already had its own separatemeldungsPrognose()fallback and displayed correctly. companion-app'snaechsterService()did already callmeldungsPrognose()as a fallback, but paired the raw signedfahrzeug.oelwechselFaelligKmwith a computed projected date, which could read inconsistently withService.tsx's own primary line. Fixed by adding the same meldung-based fallback totermine()(new block right afteroelTermin/inspTerminare built:if (termin.basis) return;then readsoelMeldungsPrognose()/meldungsPrognose()), and by havingmeldungsPrognose()always returnrestKm(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'sservice.bucharray (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. -
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 readsoel.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 inservice.ts(meldungsPrognoseEigenesIntervall()+oelMeldungsPrognose(), the latter dispatching to the existingmeldungsPrognose()unchanged whenmodus === "hersteller") and mirrored 1:1 in the panel (same two functions, same math,Date-based month arithmetic instead ofservice.ts's ISO-stringmonatePlus()since the panel's ownmonatePlus()only acceptsTT.MM.JJJJstrings and the vehicle meldung's timestamp is ISO). BothService.tsx/vService()'s footnote for this fallback case now showrestKmalongside 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/herstellerMonateare 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 newSensorzuordnungfields:TUERSCHLOSS_SENSOREN(4 positions, mirrorsTUER_SENSOREN),HECKKLAPPE_SCHLOSS_SENSOR,HAUBE_SCHLOSS_SENSOR(paired with the existing open-sensors),DACH_SENSOR,LICHT_SENSOR. MatchingFELDERcatalog entries under the existing"sicherheit"group — the Setup-Menü is entirelyFELDER-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_SENSORfield itself is unchanged, so an existing assignment isn't silently broken by the relabel),"fenster_dach"(4 windows + Dach),"licht"(Standlicht alone).gesichertis 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.
- the renamed "Kofferraum" — was "Heckklappe"; the underlying
- 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_compileon both changed modules, clean restart in the test container with a singleaudi_dashboardloader warning (no duplicate-domain regression from section J/M's fix). - companion-app:
tsc --noEmitclean, 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 thebeispieldaten.ts/profilAdapter.test.tsfixtures for the now-requiredgruppefield onSicherheitsPunkt). - Panel:
node --checkclean. - 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_SENSORentirely (owner: central locking means individual locks can't be operated separately, so checking them adds nothing), andTUERSCHLOSS_SENSOREN(4 positions) collapsed to a singleTUERSCHLOSS_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 — bothsicherheitsGruppe()/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
erstzulassungalso blank - the owner's own test container ended up in exactly this state):naechsterTermin()'s comparator,(a, b) => a.ziel - b.ziel, silently coerces anullzielto0(the Unix epoch) in JS arithmetic. A candidate with genuinely no data (no Servicebuch entry, no vehicle meldung, and for Hauptuntersuchung specifically noerstzulassungto 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 absentzielto surface. Fixed by filtering outziel === nullcandidates before sorting, returningnullonly when literally none of the three have a usable date (matchingvHome()'s existing, previously-deadif (!t)"kein Eintrag im Servicebuch" branch, which can now actually be reached). companion-app'snaechsterService()was checked and confirmed unaffected: it only ever pushes a candidate when a real computed value exists (no null-datumcandidates ever enter the array) and sorts ISO date strings vialocaleCompare(), not raw arithmetic — structurally immune to this class of bug. - Root cause of the browser not showing my fixes at first:
audi-dashboard-app.jsis 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 viaperformance.getEntriesByType('resource')showingtransferSize: 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 oneTUERSCHLOSS_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'snavigation.tsalready called this page (TITEL.sicherheit) — the two frontends had drifted apart on this one label. TheFELDERcatalog'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.zielcoerces anullzielto0(the Unix epoch) under JS arithmetic. On this owner's test container, both the Servicebuch anderstzulassungended up empty at once, so the Hauptuntersuchung candidate'szielwas genuinelynull(no entry, noerstzulassungfallback 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 absentzielto expose it. Fixed by filtering outziel === nullcandidates before sorting, returningnullonly when literally none of the three have a usable date (the existingif (!t)"kein Eintrag im Servicebuch" branch invHome()can now actually be reached — it never could be before, sincetermine()always returns exactly three entries and the old comparator always produced some truthy result). companion-app'snaechsterService()was checked and confirmed structurally immune: it only ever pushes a candidate when a real computed value exists, and sorts ISO date strings withlocaleCompare(), never raw arithmetic on a possibly-nullvalue. - "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 showedtransferSize: 0foraudi-dashboard-app.jsafterward, 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 atfont-size:26px— the owner described this as "bold" in wide/desktop mode versus "not bold, centered" on the phone layout. There is no actualfont-weightbump anywhere in this stylesheet (.titleisfont-weight:400everywhere, confirmed by grepping the whole file) — the 26px size alone, against an app that mostly uses weight-300 body text (seeVERSIONIERUNG.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 flagMein 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 tofont-size:17px— the exact same value already used for these same pages in the narrow/phone layout (.phone:has(.back.on) .titleat the file's un-scoped, phone-default rule) — for genuine cross-breakpoint consistency, not just a smaller number. Lefttext-align:leftas-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.onand 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 actual1280×800viewport (the container-query breakpoint needs a real wide render, not just the browser tool'sdesktoppreset, 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:
- "Update installieren" needs a manual page refresh to see the restart button. Traced to
datenLaden(): it unconditionally readssensor.audi_dashboard_app_version'sdaten.integration_updateinto the module-levelINTEGRATION_UPDATEvariable on every tick, but only callsrender()inside thegeaendertblock — which only comparesprofilState/fahrtenState/tankState/statusState/battStateidentity, neverversionState. A version-only state change (exactly whatupdate_pruefen/update_installierenproduce) 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 trackingletzterVersionStatethe same way the other five states are tracked, and callingrender()directly wheneverversionState's identity changes and nothing else already triggered one (a smallif (versionGeaendert && DATEN_GELADEN) { render(); return; }right after the existinggeaendertblock, left untouched). - The "Home Assistant startet neu …" spinner also needs a manual page refresh after the restart
actually completes. Root cause was different:
INTEGRATION_UPDATE_LAEUFTis set to"neustart"right before calling thehomeassistant.restartservice, and the only place that ever cleared it back tonullwas 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 (HASSwas never nulled out while disconnected, so this isn't theerster/first-connect path), but no code ever reset the stuck"neustart"flag, so the spinner stayed forever. Fixed by listening once for the underlyinghome-assistant-js-websocketconnection's own"ready"event (fires on every successful (re)connect, confirmed live viaHASS.connection.addEventListener) — wired inset hass(), guarded by a new module-levelVERBINDUNG_UEBERWACHTflag so it's only attached once rather than once perhasspush — and clearingINTEGRATION_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 --noEmitclean, 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 Wertzeiles 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:
- Archive edit fields visually truncated ("Continenta", "SportConta", "275/30 R2").
vReifenArchiv()'sfeld()helper unconditionally wrapped every input in<span class="mitEinheit">— a class meant for short number+unit pairs (km, Nm, bar) that hard-codesinput{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 droppingmitEinheitfrom 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. - Owner follow-up, also confirmed live before and after: the archive accordion's collapsed summary
line squeezed
4.542 kminto 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--breitin the panel CSS; a bypassed-Feldblock 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,Nonebelow two points.screening.py'sdurchfuehren()now also runs a_position_screenen()pass over every trip still missingstart_lat/end_lat/route- decoupled from the odometerstatusfield (offen/vollständig), since GPS and odometer data can each arrive late independently; a trip alreadyvollständigon 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 writesroute(previously onlystart_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_compiled 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/.5yet (needs the self-update button + restart, orinstall.ps1, plus a swipe-delete of the offending day exactly like.2item 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 equivalenty()inVerlaufsdiagrammgot 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 exactly1), axis labelgetComputedStylefont-size11px, screenshot confirmed normal-sized labels and a compact chart using the full column. companion-app:tsc --noEmitclean. Manifest bumped to2026.8.27.5,npm run otarerun,audi_ha_testrestart 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:01UTC = 11:55–14:05 local) - the wrongly-merged trip.t-8be105166025(12:28:05–12:29:12UTC = 14:28–14:29 local),distance_km: 22.0over 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 45–64-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)fading0.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 addedstroke-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
.5entry), 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.26repeated) - 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:
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.9collided with each other because their text stretched while their anchor spacing did not. This is the same class of bug as section U's.5entry (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 setsviewBox="0 0 <gemessene Breite> 170", so one coordinate unit is exactly one pixel and nothing can distort at any width;preserveAspectRatiois removed entirely.BV_Wis now only a starting value (bvBreiteholds the live width),bvSkalaX()/the gridline width usebvBreite, andbvPlotRahmen()collapsed to plain pixel arithmetic. AResizeObserveron 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).- The chart filtered out every day without a resting-voltage reading.
bvPunktewas built withe.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.08–25.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, separatebvRuhePunkte- the SOC readout (letzter) andbvSohBewertung()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 --noEmitclean, 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)inverlauf.py- same nearest-match algorithm as the existingnaechster_wert(), but returnsNoneinstead 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 fromnaechster_wert()towert_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' ownlast_updatedtimestamps (viahass.states.get()directly, bypassingzustand_oder_none()'s attribute-stripping for this one lookup) against each other - not against wall-clock "now", since both are read from the currenthass.statessnapshot but can carry different ages. SameNEBENWERT_MAX_ABSTAND_Sbound, imported fromverlauf.py(no circular-import risk:verlauf.pyhas no local imports of its own, unlike theSPANNUNG_MIN_Vsituation 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 returnNone.
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 ownmargin-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 offalign-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 identical584.08pxY-coordinate. - Archiv-Icon soll ein Knopf mit Hintergrund sein: new
.archivbtnclass (panel), permanentbackground: 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 explicitappearance:nonereset or Safari/WKWebView (this app's iOS shell) keeps its own heavily-rounded native chrome regardless of author CSS onbackground/border/border-radius. Neither.dm-eingabenor the shared@audi-dash/uiFeldcomponent reset it. Fixed locally incompanion-app/src/stile/screens.css(not in the shared design-system package - no other field type shows this, scoped toinput[type="date"].dm-eingabeonly) with-webkit-appearance:none/appearance:nonepluswidth:fit-content(owner's explicit ask: shrink to the actual date text instead of the sharedFeld'smin-width:132px). Not live-verified - no running companion-app session with backend auth this round; verified viatsc --noEmitand the full suite only, same limitation as several other companion-app-only changes this session. - Mein Audi/Reifen-Box restructured: was
Reifen · Sommer montiertas 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 readsmontiert: 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-lineNaviKachel(${aktiv} · ${km} km), not the panel's multi-row.rowslist - 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
ServicebuchEintragTS type, theServicebuch()component/function names (panel and companion-app), thesbuchroute 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"atprofilZuConfig()(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 realAPP_VERSIONfrom the backend) remains.
- a frontend constant hardcoded to
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/refuelKM_SENSORreading minus the previous refuel's own recordedodometer_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 sametrip_id(1 aktualisiert,distance_km22→60) whileodo_end/start_lat/route/sourceall stayed byte-identical - the core guarantee, proven, not assumed. - Tankvorgänge: created with de-DE-formatted
"45,00"/"1,75"/"78,75"(parsed correctly to45/1.75/78.75), then updatedstation_namebytank_idwhileodometer_kmstayed untouched - the exact casebelege.tankvorgang_aktualisieren()'s fix was for. - Wartungsplan: created, then updated by Datum+Art match (
km/Werkstatt/Kostenchanged,buchLengthstayed 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 Wertzeiles (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:
- 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). - 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. NewsetupGemeinsamesPraefix()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 tosensor.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, newfeldEinheitAnzeige(feld)derives a static "kind of value" label straight from the field's ownFELDERcatalog entry (einstellungen.py) -"on/off"for everybinary_sensorrole (HA's binary_sensor domain always reports the raw state as on/off regardless ofdevice_class- that only changes how other HA frontends translate it for display, not the raw value this app reads), the first unit inunitspreferring a lowercase-distinct variant when more than one is listed (picks"l"over"L"forTANK_LITER_SENSOR,"km"over"mi"elsewhere),"Datum"fordate/timestampdevice_classes, nothing for abuttonrole 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:
- "Nur passende Sensoren anzeigen" only hard-filtered by domain/unit, not by the "Datum" output shown
next to a field's headline (
feldEinheitAnzeige()). Fields withdevice_classes: ["date", "timestamp"]and nounits(the two Ölwechsel/Inspektion date roles) fell through both the domain check (only restricts tosensordomain, which is nearly every entity) and the unit check (emptyunitsarray skips it entirely) - so with the filter ON, everysensor.*entity in the whole instance was offered, not just date/timestamp ones.entitaetKandidaten()now mirrorsfeldEinheitAnzeige()'s own decision exactly: when a field has nounitsbut itsdevice_classesincludesdate/timestamp, a candidate must itself havedevice_classdateortimestampto 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' realdevice_classagainsthass.states- all 14 were genuinedate/timestampsensors, zero false positives. Neuer Tankvorgang(tankFelder()): "Kilometerstand" and "Getankte Liter" were the only two fields in the form without the.mitEinheitwrapper 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.- Real bug: "Gefahrene Distanz" showed
-98when opening a new Tankvorgang.distanzVorschlag()subtracts the last recorded fill'sodometer_kmfrom the live odometer with no floor - live-inspected the actual data inaudi_ha_testand found the most recent stored fill (f-c7b0e818451e,source: "auto", 2026-08-27T17:37) carriesodometer_km: 21400, while the vehicle's current live odometer (sensor.audi_dashboard_fahrzeugstatus'skm) reads21302- 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'sodometer_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. - Also handled the owner's report "there is still the Text with the round collums in the Sensor Page"
(referring to the
.7/.8ellipsis 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, defaulttrue- added tovorlage/fahrzeugprofil.jsonand defaulted inprofilZuCar()via!== falsefor profiles from before this field existed). identitaet.fin_ableiten_und_uebernehmen()rewritten: returns immediately iffin_automatischisfalse- before even calling
fin_kandidaten_ermitteln(), so the multi-sensor comparison/mismatch-warning never runs either, exactly as asked. Whentrue, the derived value is now treated as authoritative and overwrites whatever is currently infin(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. Afin == kandidatcheck avoids a pointless rewrite/republish when nothing actually changed.
- before even calling
- Panel (
vEinst()): a<label class="switch">reading "automatisch" placed between the "Fahrgestellnummer (FIN)" label and the text input, in the same.feldrow (owner's exact layout request) - the input getsdisabledwhenever 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 samedata-*/central-change-listener idiom as every other switch in this file (mirrorsdata-steuerlast).CAR.finAutomatisch/p.fahrzeug.fin_automatischadded to both directions of the profile↔CAR mapping. dienste.py'sprofil_schreibenhandler (not justentitaeten_schreiben) now also callsidentitaet.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-writtenfin_automatischflag 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 signing prepared, and the one thing that blocks it (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: everything except the signature itself is done and
verified; the signature is blocked on a fact about the Apple account, not on this repo.
What was verified on this machine (Xcode 26.4, Node 22.20, main at 2026.8.28.18):
npm run typecheckclean,npm test146/146 green,npm run buildclean,npx cap sync iosclean (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 MBApp.app. So the code, the plugins and the Swift-package graph are all fine on device; nothing but provisioning is missing.
The blocker, verbatim from Apple: Communication with Apple failed: Your team has no devices from which to generate a provisioning profile. The account authenticates 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.appwithout an embedded profile will not install either.
Correction, same day: this is a FREE account, not a paid membership. Xcode's own cache says so
— defaults read com.apple.dt.Xcode → IDEProvisioningTeamByIdentifier:
teamName = "Paul Nothaft (Personal Team)", teamType = "Personal Team",
isFreeProvisioningTeam = 1. The note further up this file claiming "Paul has an Apple
Developer Program" (section on the 2026-08-17 port) is therefore wrong and must not be relied
on. Consequences, all confirmed against Apple's membership comparison and the free-provisioning
limits:
- There is no portal route to register a device. Certificates, Identifiers & Profiles device
management is a paid-membership feature. A free team registers a device only by having it
physically connected to this Mac and trusted; Xcode then does it via
-allowProvisioningUpdates. So "add the UDID on the website" is not an option here. - Everything expires after 7 days — provisioning profile, App ID and device registration alike. The app stops launching and has to be rebuilt and reinstalled, forever, every week.
- Ceilings: 3 devices per platform, 10 App IDs per 7 days.
- Whether
-exportArchiveeven yields a usable.ipafor a free team is untested and doubtful — free provisioning is built around "Run straight onto the connected device" from Xcode, not around exporting a redistributable archive. If the export step fails once a phone is attached, install directly instead of debugging the export.
What to do (owner): connect the iPhone by cable, tap "Trust", then run the script below. That
is the only path with this account type. If the weekly re-install turns out to be intolerable, the
paid Apple Developer Program (99 €/year) is what buys the 1-year signature, portal-side UDID
registration without the phone present, and 100 devices — UMSETZUNGSPLAN.md Phase 10 already
flagged this as an owner decision, and it is now a decision with a known answer on one side.
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; output lands in ios/build/App.ipa (already
covered by the ios/ ignore, no build artifact enters git). Verified by running it end to end: it
passes build, sync and archive setup and stops exactly at Apple's device requirement, nowhere else.
Deliberately not done: committing an unsigned .ipa as a stand-in. It is not what was asked
for, it cannot be installed as-is, and a per-build ~6 MB binary would sit in the git history
forever. The OTA path already covers "new UI without Xcode" (section above) — Xcode is only needed
when native code or the signature itself changes.
Phase 10 step 7 therefore stays unchecked in UMSETZUNGSPLAN.md: the app is not on the phone
yet, and marking it done would be exactly the silent staleness this project keeps designing against.
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-DEformat; 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 undertests/belegparser/belege/are gitignored. - There is no second copy of the code to keep in sync any more. Until 2026-08-23,
installationspaket/duplicatedpyscript/,www/anddata/, and every change had to be mirrored by hand — the older entries below still say so. The installer now readscustom_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.