f4222b715f
Der RAM-only Fallback fuer die Zustand-Kachel wurde bei jedem Neustart geleert und zeigte "unbekannt", solange seit dem Neustart keine echte Live-Messung eintraf (Dongle bei ausgeschaltetem Fahrzeug erwartungsgemaess immer offline). spannung_cache_vorladen() laedt ihn jetzt beim Start aus der gespeicherten Historie vor, begrenzt auf den vereinbarten Bereich 10,0-13,0 V (SPANNUNG_MIN_V bis AGM_RUHE_MAX_V) - eine Generatorspannung (z.B. 15,4 V) soll dort nie als Batteriespannung erscheinen. Version 2026.8.27.21, im Testcontainer verifiziert (12,149 V statt "unbekannt", trotz Dongle weiterhin 0 V live). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3932 lines
334 KiB
Markdown
3932 lines
334 KiB
Markdown
# AGENTS.md — Project state, review findings, open items, and working rules
|
||
|
||
**Last updated: 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`](https://github.com/multica-ai/andrej-karpathy-skills),
|
||
behavioral rules derived from Andrej Karpathy's observations on LLM coding failures. They bias
|
||
toward caution over speed — for trivial tasks, use judgment.
|
||
|
||
1. **Think before coding.** Don't assume. Don't hide confusion. Surface tradeoffs. State
|
||
assumptions explicitly; if multiple interpretations exist, present them — don't pick silently.
|
||
If a simpler approach exists, say so. Push back when warranted. If something is unclear, stop
|
||
and ask.
|
||
2. **Simplicity first.** Minimum code that solves the problem. Nothing speculative: no features
|
||
beyond what was asked, no abstractions for single-use code, no unrequested configurability, no
|
||
error handling for impossible scenarios. If 200 lines could be 50, rewrite.
|
||
3. **Surgical changes.** Touch only what you must. Don't "improve" adjacent code, comments, or
|
||
formatting; don't refactor what isn't broken; match existing style. Remove only orphans **your**
|
||
change created; mention (don't delete) pre-existing dead code. Every changed line should trace
|
||
directly to the request.
|
||
4. **Goal-driven execution.** Define success criteria, loop until verified. "Fix the bug" → write a
|
||
test that reproduces it, then make it pass. For multi-step tasks, state a brief plan with a
|
||
verify step per item.
|
||
|
||
### Claude Code practices (current guidance, 2026)
|
||
|
||
- Keep this file **concise and specific** — instructions concrete enough to verify ("run X before
|
||
Y"), markdown headers/bullets, no dense prose. Context files reduce adherence as they grow;
|
||
target roughly ≤200 lines of actual instruction.
|
||
- Don't record here what the code or git history already shows (directory dumps, dependency
|
||
lists) — record pitfalls, rationale, decisions, and conventions that differ from defaults.
|
||
- Conflicting instructions get picked arbitrarily — when updating, **remove** superseded rules
|
||
rather than stacking corrections.
|
||
- Verify loading with `/context` (file must appear under **Memory files**); edit via `/memory`.
|
||
- **This file (plus the session's task list) is the source of truth for what's done — not a leftover
|
||
plan-mode file.** A `~/.claude/plans/*.md` file surfaced by the system as "not yet complete" can be
|
||
stale: plan files aren't marked complete when their work finishes. Before claiming a feature is
|
||
unbuilt (e.g. "the Setup menu isn't implemented yet"), check the `[x]`/`[ ]` items below and/or the
|
||
task list — trust those over an old plan file's own claim about its status. (Caused a real mistake
|
||
2026-08-12: told the user the Setup menu wasn't built yet, when it had shipped hours earlier in
|
||
the same project — see the "Setup menu" `[x]` entry further down.)
|
||
|
||
---
|
||
|
||
## What this project is
|
||
|
||
Private replacement for the Audi *connect plug & play* app (discontinued end of 2026), for exactly
|
||
one vehicle (**Audi RS 4 Avant competition**), single user, self-hosted on Home Assistant, access
|
||
via Tailscale only. Read-only toward the vehicle — no remote control. Covers: vehicle status, trip
|
||
log, fuel log with Shell receipt parsing, service forecasting, insurance/tax, tire management.
|
||
|
||
**License rule (hard):** Audi Type fonts, four-rings SVG, and model-badge SVGs are cleared **only
|
||
for this one private, unpublished installation**. Therefore `design-system/` is brand-free (it gets
|
||
uploaded to Claude Design), while the HA panel and the future sideload-only app may use the real
|
||
assets. Never mix these the other way around.
|
||
|
||
**Reading order for a new session:**
|
||
1. This file (overview + open items)
|
||
2. `UMSETZUNGSPLAN.md` — the step-by-step execution plan for all open items (13 phases with
|
||
commands and acceptance criteria); when working on an open item, follow the plan's phase
|
||
3. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
|
||
4. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, phases 1–10 built)
|
||
5. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
|
||
6. `bauauftrag.md` — **historical only**; code has diverged (see SPECIFICATION.md §7)
|
||
|
||
## The three projects in this repo
|
||
|
||
| Area | What | Status |
|
||
|---|---|---|
|
||
| `custom_components/audi_dashboard/` | The HA integration: backend, panel, OTA app bundle. HACS is not used and cannot be — private repo, categorical refusal on HACS's side, no GitHub mirror any more; `install.ps1` is the permanent, only install path (section H) | ✅ **finished, in use** — replaced the pyscript backend on 2026-08-23 (section H) |
|
||
| `homeassistant/` | Install package, recorder snippet, guides — **no code any more** | ✅ |
|
||
| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible |
|
||
| `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) |
|
||
| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data |
|
||
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) |
|
||
|
||
Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only),
|
||
`bauauftrag.md`/`.html` = original build brief (historical), `DESIGN_BRIEF_DATAMETRIC360.md` = the
|
||
prompt for the Claude Design project.
|
||
|
||
**`custom_components/audi_dashboard/` in one paragraph:** a normal HA integration with a config
|
||
flow, one `sensor` platform and 18 services. `koordinator.py` owns the runtime state and the
|
||
wiring; the domain modules beside it carry the logic: trip detection via the FMM003 ignition
|
||
sensor with pause tolerance (`fahrterkennung.py`), two-stage trip completion via recorder history
|
||
(`screening.py` — the odometer often updates only on the next trip), fill-up detection on
|
||
fuel-level rise (`tankerkennung.py`), Shell PDF parser (`shell_beleg_parser.py`, in-process via
|
||
`pypdf`, the only tested part of the repo), tire km counter, backup, image management, retroactive
|
||
history import. `ablage.py` is the only place that touches files. Frontend: `frontend/audi-dashboard-app.js`
|
||
(~4,900 lines, custom element, no framework/bundler), 5 tabs + ~19 detail routes, served by the
|
||
integration itself under `/audi_dashboard_static/` with the manifest version as the cache breaker.
|
||
Entity IDs are assigned **in the UI** (Settings → Fahrzeug einrichten → Setup), stored as overrides
|
||
in `/config/audi_dashboard/entitaeten.json`; `einstellungen.py` holds only the built-in defaults
|
||
(all empty). Deploy: HACS, or `homeassistant/installationspaket/install.ps1` where HACS can't reach
|
||
the repo.
|
||
|
||
**`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with
|
||
reconnect backoff, persistent offline write queue, credential storage), domain logic
|
||
(`src/daten/`: profile adapter, statistics, service forecast, data context, OTA update flow — see
|
||
section H), all 21 screens (`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and
|
||
icons. Verified by 117 unit and render tests (`npm test`) plus the two smoke suites against a live
|
||
Home Assistant (`npm run smoke`, `npm run smoke:auth`). `npm run dev` in the repo root starts it;
|
||
`testumgebung/aufsetzen.sh` provides the server side.
|
||
|
||
**DataMetric360 architecture (short — details in `COMPANION_APP_ARCHITECTURE.md`):**
|
||
- Future data source: **Teltonika FMM003** on the CAN bus, fully replacing the iPhone WLAN sensor
|
||
and the VAG integration.
|
||
- **Current data path (since 2026-08-12): FMM003 → flespi (native Codec8/TCP, IMEI auth) → HA.**
|
||
Both earlier directions are dropped: Traccar first, then the self-hosted MQTT/TLS broker — the
|
||
router's port-forward turned out to be unusable. The decision logs deliberately remain in the
|
||
architecture doc, marked ÜBERHOLT. Details and open steps in section B below.
|
||
- Frontend access: HA REST + WebSocket with a long-lived access token (no `hass` object).
|
||
- External access: Cloudflare Tunnel + reverse proxy with a path allowlist; HA itself stays
|
||
unreachable. Domain **`datametric360.app`** registered (all-inkl, 2026-08-11).
|
||
- Distribution: **sideload only** (license reason) — hence real Audi assets are allowed there.
|
||
- Trip detection moves to FMM003 ignition; the pause-tolerance feature
|
||
(`fahrten_pausenzeit_min`) is a deliberate product decision and must be preserved.
|
||
|
||
---
|
||
|
||
## Review findings (2026-08-11)
|
||
|
||
### Design/CI review of `main` (2026-08-13) — see `DESIGN_REVIEW_2026-08-13.md`
|
||
|
||
Code review of all UI layers plus headless-browser screenshots of the panel (mock `hass`),
|
||
checked against the repo's own Audi-CI rules. Headlines: the iOS overlay
|
||
(`audi-dashboard-ios.css`) overrides the documented palette/radii, adds shadows/blur and turns
|
||
red into a fill color **without any recorded decision** (visible e.g. as a red-filled "Abbrechen"
|
||
next to a black "Speichern" in the setup popup); `design-system/` still carries pre-audit state
|
||
(old low-contrast `--fg3 #657081`, no focus styles at all, 13.5px inputs, 34px icon buttons);
|
||
the `design/` export's RS-6 error extends into the technical sample data (73 l tank, 285/30 R22,
|
||
FIN series 4G). Four browser-verified rendering defects: switches stretched by
|
||
`.feld label{flex:1 1 auto}` (state display becomes ambiguous), double "offen" in the trip list,
|
||
empty oil-change tile when the service book is empty, and fragmented Leaflet rendering in all
|
||
three maps (✅ **fixed 2026-08-16** — root cause was Leaflet's stylesheet being loaded into
|
||
`document.head`, which never reaches the panel's shadow root; see the third-round entry in
|
||
"Open items"). Positives: the 300/400 font-weight rule and de-DE
|
||
formatting hold everywhere; `design-system/` is verifiably brand-free.
|
||
|
||
### HA panel — known gaps (most also in SPECIFICATION.md §7)
|
||
|
||
- ✅ **FIXED 2026-08-27 (section U):** `start_lat/lon`, `end_lat/lon` and `route` on individual
|
||
trips were dead schema — only `historienimport.py` ever populated them, the live detection path
|
||
never did, and the trip-detail map drew a **fabricated** line via `fakeTrack()` — not a real
|
||
track. Both are now populated (`screening.py`'s new `_position_screenen()` for live trips,
|
||
`route` added to the import path too), and `fakeTrack()` is removed — the map now draws the real
|
||
route, or an honest straight line between known points, never an invented curve.
|
||
`addresses`/`avg_speed_kmh` on individual trips remain unpopulated (a separate, still-open gap —
|
||
reverse geocoding is its own work package, see historienimport.py's own note).
|
||
- **No GPS fallback in trip completion:** trips without an odometer match stay `status="offen"`
|
||
forever (`modules/fahrtabschluss_logik.py:16-20`).
|
||
- **RAM-only state:** running trip (`_fahrt_start_ts`) and fuel low-water-mark
|
||
(`_tiefststand_pct`) do not survive an HA restart. Deliberately deferred hardening.
|
||
- **Inactive features:** `UPDATE_REPO_URL = ""` (self-update inactive) in
|
||
`pyscript/modules/einstellungen.py`. `www/bilder/` has no vehicle photos (all slots show
|
||
placeholders); `steuer.faellig` unset. (`BATTERIE_SENSOR` was the same kind of gap until
|
||
2026-08-12 — now wired to the FMM003's `external_power_voltage`, see section B/C below. Several
|
||
*other* fields are newly inactive since the same date for a different reason — the
|
||
`TommiG1/HA_VAG-EU-Data-Act` integration they depended on was dropped: `KM_SENSOR`,
|
||
`TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, all door/window/lock sensors, oil-change/
|
||
inspection sensors. All are configurable again via the new Setup menu, see section C.)
|
||
- **Robustness:** `profil_lesen()` in `modules/profil.py` does not handle a missing/corrupt
|
||
`fahrzeugprofil.json` — all callers throw.
|
||
- **Tests:** only the receipt parser is tested (`data/tests/test_shell_beleg_parser.py`, 10 real
|
||
receipts) — and its test PDFs are gitignored, so a fresh clone can't run it. Backend and
|
||
frontend: no tests, no CI.
|
||
- **Leaflet via CDN:** trip map needs public internet in addition to the Tailscale tunnel.
|
||
|
||
### Documentation drift ✅ FIXED 2026-08-11 (kept as a record of what was wrong)
|
||
|
||
- `homeassistant/README.md:99`, `INSTALL.md:204`, and the header comment
|
||
`audi-dashboard-app.js:13-15` claim the statistics view shows sample numbers — **false**;
|
||
`vStat()` computes real values from `TRIPS`/`FILLS`.
|
||
- `homeassistant/README.md:110` mentions the removed 97% full-tank rule (removal documented in
|
||
`belegverarbeitung.py:18-20`); the README file list omits 5 pyscript files.
|
||
- Obsolete comment `belegverarbeitung.py:41` ("TODO: Datei ablegen" — file has long existed).
|
||
|
||
### Audit leftovers — two of three now fixed in the new app
|
||
|
||
- ✅ Swipe-to-delete without a gesture-free fallback — **fixed in the app**: every list row also
|
||
carries an always-visible "…" menu (`companion-app/src/screens/Zeilenmenue.tsx`). Still open in
|
||
the old panel, which is being replaced anyway.
|
||
- ✅ Leaflet from a CDN — **fixed in the app**: bundled from node_modules as a lazy chunk.
|
||
- 🟡 Popup close-by-tap-outside in the old panel: still unchecked (the app uses the library's
|
||
Popup, which is keyboard-reachable).
|
||
- Audit's own note: these three may be better done in DataMetric360 than retrofitted — the panel
|
||
gets replaced anyway.
|
||
|
||
### companion-app / design-system ✅ RESOLVED 2026-08-11
|
||
|
||
- **Not wired together** — fixed: an npm workspace in the repo root links `@audi-dash/ui` into the
|
||
app as a real dependency.
|
||
- Both packages: no `node_modules`, no `dist` — smoke tests need `npm install` first
|
||
(design-system additionally `npm run build`; `scripts/smoke.mjs` imports from `../dist/`).
|
||
- No unit tests, no Storybook (substitute: SSR smoke over 21 cases in design-system).
|
||
- `design/` export (2026-08-11) has two known, already-commissioned fixes not yet in the export:
|
||
shows **RS 6 instead of RS 4**, and **16 sub-pages** the HA panel already has are missing
|
||
(list in `design/README.md`).
|
||
|
||
---
|
||
|
||
### Found while building (2026-08-11) — all fixed
|
||
|
||
Three defects that only surfaced by running against a real Home Assistant, not by reading code:
|
||
|
||
1. **The data layer declared field names the backend never sends.** `Fahrzeugstatus` had
|
||
`tank_prozent`/`sicher_abgestellt`/`sicherheit`; the backend writes
|
||
`tankprozent`/`gesichert`/`sicherheitscheck`. Every screen would have read `undefined` without
|
||
anything failing. `technik`/`ausstattung` were typed as objects but are arrays.
|
||
2. **The profile adapter handed out live references into the raw profile.** Editing a form would
|
||
have silently mutated the baseline and broken the promise never to overwrite the
|
||
backend-maintained tire odometer. Sections are copied now.
|
||
3. **`design-system` still carried the pre-audit `--fg3: #657081`** (3.0:1 on `--tile`, fails WCAG
|
||
AA) that the panel had already fixed to `#8a94a3`. The new app would have inherited a
|
||
already-solved contrast defect.
|
||
|
||
Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is
|
||
what the smoke scripts run on — rewritten as plain fields.
|
||
|
||
Three more that only screenshots revealed — nothing failed, the pixels were simply wrong:
|
||
|
||
4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's
|
||
automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed
|
||
the block to zero height and took model name, badge and plate with it. Fixed with
|
||
`.dm-inhalt > * { flex: none }`.
|
||
5. **The model name appeared twice** — once as the badge image, once as text beside it.
|
||
6. **Filenames overflowed the gallery thumbnails.**
|
||
|
||
And one in the backend, found by watching the log against a current Home Assistant: the odometer
|
||
screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip
|
||
stayed without a distance, visible only as a warning. Now runs through `task.executor`.
|
||
|
||
**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA
|
||
2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served
|
||
from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the
|
||
native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not
|
||
apply. Verify this again at commissioning if the app ever moves to a separate hostname.
|
||
|
||
**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239
|
||
of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a
|
||
vendored MIT library served from Home Assistant itself, which satisfies the actual requirement
|
||
(no network call, token never leaves the local network) and round-trips correctly.
|
||
|
||
## Pending: this branch has diverged from `main` (noted 2026-08-13)
|
||
|
||
`ha_install.md` (root) planned the move from pyscript to a **native HA integration** with a config
|
||
flow — UI-only setup, no YAML. **That move happened on 2026-08-23; see section H.** The plan
|
||
document is now historical: it still argued for a self-reporting `UpdateEntity` against the Gitea
|
||
repo because HACS is GitHub-only, and that part was dropped — the built-in updater was removed
|
||
rather than reimplemented, and updates go through HACS (or the install script where HACS cannot
|
||
reach the repo). All APIs in it were verified against the running 2026.8.1 instance. It also
|
||
records which review findings that move eliminates by design.
|
||
|
||
A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the
|
||
three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole
|
||
mapping; "reset" has no effect on 15 of 17 fields; all four list positions get the same sensor,
|
||
which makes "securely parked" report safe while three doors were never checked).
|
||
|
||
`main` has moved 18 commits ahead of `umsetzung-datametric360` (FMM003 switch, sensor-mapping setup
|
||
menu, iOS/large-screen overlay from Claude Design). **Deliberate decision: do not merge yet** — the
|
||
owner keeps working on `main` first.
|
||
|
||
Three files conflict (`AGENTS.md`, `homeassistant/INSTALL.md`,
|
||
`homeassistant/pyscript/fahrterkennung.py`); four more are touched by both sides but merge cleanly.
|
||
|
||
**One hazard that a clean merge will not catch.** This branch changed `profil_lesen()` in
|
||
`homeassistant/pyscript/modules/profil.py` to return `None` when the profile file is missing or
|
||
corrupt, and guarded all seven call sites that existed here. `profil.py` is untouched on `main`, so
|
||
it merges silently — but `main`'s call sites (`fahrterkennung.py`, `tankerkennung.py`,
|
||
`reifenzaehler.py`, `modules/frontend_veroeffentlichung.py`) do **not** guard against `None` and
|
||
would raise `AttributeError` on a missing profile instead of logging a clear error.
|
||
|
||
When merging: take `main`'s FMM003 version of `fahrterkennung.py`, then re-apply the `None` guard to
|
||
every remaining `profil.profil_lesen()` call site.
|
||
|
||
## Open items
|
||
|
||
Execution order, exact steps, and acceptance criteria for every item below live in
|
||
`UMSETZUNGSPLAN.md` (phases 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)
|
||
|
||
- [x] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
|
||
then re-export to `design/`
|
||
- [x] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
|
||
dependency (possibly add a workspace/monorepo root)
|
||
- [x] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
|
||
- [x] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/` —
|
||
**never** into `design-system/`
|
||
- [x] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
|
||
`ablageSetzen()` hook already exists)
|
||
- [x] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
|
||
locally under HA `/local/`, architecture §3)
|
||
- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
|
||
against `audi_ha_test` with a real token
|
||
- [x] Offline UX per design brief (offline marker, visible pending queue)
|
||
|
||
### B) Infrastructure / commissioning (partly waits for FMM003 hardware)
|
||
|
||
- [ ] Switch `datametric360.app` nameservers at all-inkl to Cloudflare ("full setup") —
|
||
prerequisite for the tunnel; domain carries nothing else, so this is consequence-free
|
||
- [ ] Decide hostname split (app on apex + API on `api.` subdomain, or vice versa)
|
||
- [ ] Choose reverse proxy (Nginx Proxy Manager vs. Traefik) — **can be done before hardware**
|
||
against the existing HA API
|
||
- [x] Define the reverse-proxy path allowlist (depends on final entity/service names)
|
||
- [ ] Install/wire the FMM003; record firmware version (Codec JSON is firmware-dependent)
|
||
- [x] Generate TLS certificates for Mosquitto + device (small private CA) — done 2026-08-11, 10-year
|
||
validity; Mosquitto configured (`certfile`/`keyfile`/`cafile`/`require_certificate: true`).
|
||
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:8883` open 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; a `flespi`
|
||
custom 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 hostname
|
||
`datametric360.duckdns.org` reliably updating; Let's Encrypt for HA's own local UI works (root
|
||
cause of the earlier DNS-01 failures was a stray `aliases` entry 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), not `configuration.yaml`'s old `http:` 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)
|
||
- [x] Move trip detection to FMM003 ignition — done 2026-08-12. `fahrterkennung.py` rewritten:
|
||
trigger is now `einstellungen.ZUENDUNG_SENSOR`
|
||
(`binary_sensor.testzone_fmm003_engine_ignition_or_acc_status`, on = trip running) instead
|
||
of the iPhone WLAN sensor; same `task.unique()` pause-tolerance pattern kept unchanged.
|
||
`sensor.iphone_wifi_connection` and the `fahrzeug.wlan_name` profile field are fully removed
|
||
(backend and frontend). At the same time, all entity IDs sourced from the now-abandoned
|
||
`TommiG1/HA_VAG-EU-Data-Act` HACS integration were blanked in `einstellungen.py`
|
||
(`KM_SENSOR`, `TANK_SENSOR`, `RANGE_SENSOR`, `REFRESH_BUTTON`, door/window/lock sensors,
|
||
oil-change/inspection sensors) — the consuming features (Übersicht tiles, Reifenzähler,
|
||
automatic Tankerkennung, Fahrtabschluss-Screening) are untouched and degrade gracefully to
|
||
"unbekannt"/inactive per the existing `zustand_oder_none()` convention, rather than being
|
||
deleted; `fahrtabschluss.py`/`tankerkennung.py`/`reifenzaehler.py` now guard their
|
||
`@state_trigger(f"{einstellungen.KM_SENSOR}")`-style decorators with
|
||
`if einstellungen.KM_SENSOR:` (untested previously whether pyscript tolerates an empty
|
||
trigger string — not worth relying on). `STANDORT_TRACKER` now points at the real
|
||
`device_tracker.testzone_fmm003` and `BATTERIE_SENSOR` at
|
||
`sensor.testzone_fmm003_external_power_voltage` (the vehicle's 12V bus voltage as measured
|
||
by the tracker — NOT `sensor.testzone_fmm003_battery_voltage`, which is the tracker's own
|
||
internal backup cell and unrelated to the car). Real entity list for this device: the user's
|
||
`Entiaeten.csv` export (OneDrive, not in this repo). Verified in `audi_ha_test`: pyscript
|
||
reload (`pyscript.reload`) produced no errors/warnings for any of the changed files; the
|
||
Übersicht correctly shows "Status unbekannt" and em-dash placeholders for the now-blank VAG
|
||
fields instead of crashing (that container itself has no `device_tracker.testzone_fmm003`
|
||
registered, so Standort still shows "Kein GPS-Signal" there — expected, the real HA instance
|
||
has it). `INSTALL.md` step 4 and its troubleshooting table have since been rewritten
|
||
(2026-08-12) to point at the Setup menu instead of manual `einstellungen.py` edits, and to
|
||
use the current FMM003 field names (`ZUENDUNG_SENSOR`/`STANDORT_TRACKER`/`BATTERIE_SENSOR`)
|
||
instead of `WLAN_SENSOR`/TommiG1 — not live-tested against a real install (INSTALL.md itself
|
||
was never run end-to-end in this project, per its own opening note), but consistent with the
|
||
actual code.
|
||
|
||
### C) Maintain the existing HA panel (low priority — being replaced)
|
||
|
||
- [x] Wide-screen/desktop layout (`@container (min-width:860px)` in `audi-dashboard-ios.css`,
|
||
turns the same `.tabbar` into a 264px side-nav via CSS only, no JS/markup duplication) — done
|
||
2026-08-11 from the Claude Design "DataMetric360 Board" draft (`claude.ai/design` project
|
||
`c28a8d4d-ec4e-4178-9e49-ab5b90c02097`). Found and fixed a real regression while verifying it
|
||
live in `audi_ha_test`: the overlay's `:host > div{height:100%}` (meant to drop the old
|
||
412px-cap for the sidebar layout) was applied unconditionally instead of only inside the
|
||
`@container` block, which broke the **normal mobile layout too** — HA doesn't reliably
|
||
propagate a real height down through `panel_custom`, so `height:100%` collapsed `main#view` to
|
||
~0px (tabbar rendered right under the header, content invisible). Fix: kept the outer wrapper
|
||
viewport-anchored (`min(880px, calc(100dvh - 24px))`, matching `audi-dashboard.css`'s original
|
||
reasoning) at all widths, and only drop the 880px cap (`calc(100dvh - 24px)`, still
|
||
viewport-anchored, not parent-relative) inside `@container (min-width:860px)`. Verified live at
|
||
both narrow (mobile tab bar, full content) and wide (1144px container, grid side-nav,
|
||
navigation clicks, settings back-arrow) — screenshots taken, no regressions found.
|
||
- [x] Desktop-Sidebar-Markenklon (`.navmarke`) nachgezogen — done 2026-08-12. The user compared the
|
||
deployed app against the live Claude Design "Board" (the two-viewport comparison page, which
|
||
genuinely iframes this project's own `audi-dashboard-app.js`/`-ios.css`, not a static mockup)
|
||
and asked why the desktop Audi-rings placement differed: Design showed the rings pinned above
|
||
the sidebar nav list, ours showed them in the content header next to the title. Root cause,
|
||
confirmed via `DesignSync get_file` + byte-diff: `audi-dashboard-app.js` was already fully in
|
||
sync (identical), but `audi-dashboard-ios.css` had drifted — the Design project had since
|
||
added a `.navmarke` sidebar clone of the rings (only visible in the `@container
|
||
(min-width:860px)` block; `.rings{display:none!important}` hides the header copy there so the
|
||
mark appears once) plus a filled-pill active-tab style (`.tabpille`), neither pulled back
|
||
locally. Diffed the two full files first to confirm every change was additive/CSS-only, then
|
||
replaced the local file wholesale. Verified in `audi_ha_test` at both 402px and 1400px: rings
|
||
now sit above the nav list on desktop, gear icon alone in the content header, mobile view
|
||
unaffected, no console errors beyond the pre-existing missing-vehicle-photo 404 gap.
|
||
- [x] Standort-Kachel (live vehicle GPS position on the Übersicht, above "Zuletzt") — done
|
||
2026-08-12. New tile with a real (non-fake) Leaflet mini-map, opens a fullscreen "Standort"
|
||
route on tap; fullscreen has 4 floating controls (map style, center-on-vehicle,
|
||
center-on-user, fit-both) and a draggable myAudi-style bottom sheet (peek/expand/close by
|
||
drag or tap, opens on vehicle-marker click) showing distance-to-user, address (client-side
|
||
reverse geocoding via the public Nominatim API — same "public API, no key, low single-
|
||
vehicle volume" reasoning as the existing Leaflet-CDN gap below), fährt/steht/"Letzter
|
||
Parkplatz" state, fuel/range, and Route (Google Maps deep link) / Teilen (Web Share API)
|
||
actions. Backend: new `STANDORT_TRACKER` entity-ID setting in `einstellungen.py` (device_
|
||
tracker with lat/lon attributes), published via a new `_standort()` helper in
|
||
`frontend_veroeffentlichung.py`. The built-in default points at the author's test device
|
||
(`device_tracker.testzone_fmm003`); until the FMM003 actually delivers positions (section B),
|
||
that entity does not exist and the tile shows "Kein GPS-Signal vom Fahrzeug". Verified end-to-end in `audi_ha_test` with a manually
|
||
created `device_tracker.test_fahrzeug` test entity (tile, fullscreen map, and menu all
|
||
confirmed rendering/populating correctly); the device's own location (browser Geolocation
|
||
API) and the Route/Teilen deep links work independently of the vehicle-GPS gap. Pushed to
|
||
the Claude Design project (`c28a8d4d-ec4e-4178-9e49-ab5b90c02097`, "DataMetric360 Board")
|
||
for visual refinement — the JS/CSS ship functionally complete but visually plain
|
||
(placeholder vehicle-marker glyph, default `.aktion`/`.tile` styling); Claude Design's job is
|
||
polish, not structure. One known rough edge: reverse-geocoded address didn't resolve within
|
||
a few seconds in the `audi_ha_test` container (no crash, stays on "Adresse wird ermittelt …"
|
||
indefinitely) — likely just that container's outbound network to Nominatim specifically;
|
||
unconfirmed whether this reproduces on the real HA instance.
|
||
- [x] Setup menu (Einstellungen → Fahrzeug einrichten → last item when expanded) — done
|
||
2026-08-12. Lets the user map every sensor role the app uses (~18 roles, 3 of them 4-item
|
||
door/window/lock lists) to real HA entities from a searchable dropdown, instead of hand-
|
||
editing `einstellungen.py`. New backend module `pyscript/modules/entitaeten.py`: a
|
||
`FELDER` catalog (label, hint, expected domain/device_class/unit, keywords, whether it's
|
||
one of the trigger-bound fields) plus JSON override I/O (`data/entitaeten.json`, same
|
||
atomic-write pattern as `profil.py`) and `overrides_anwenden()`, which does
|
||
`setattr(einstellungen, key, value)` on the already-imported `einstellungen` module — every
|
||
existing consumer does `import einstellungen` + live attribute access (verified: none use
|
||
`from einstellungen import X`), so this needs no changes to any consumer file. New service
|
||
`audi_dashboard_entitaeten_schreiben` (`frontend_api.py`) and publication
|
||
`entitaeten_veroeffentlichen()` (`frontend_veroeffentlichung.py`) follow the project's
|
||
existing read-via-state/write-via-service pattern. Frontend: new popup (`vSetupPopup()`,
|
||
modeled on the existing `.sheet` pattern) with a from-scratch searchable-combobox component
|
||
(none existed in the repo before) — client-side only, built directly from `HASS.states`, no
|
||
new backend read service; a global "Nur passende Sensoren anzeigen" switch hard-filters
|
||
candidates by domain/unit, a keyword+domain+unit score ranks and auto-suggests for empty
|
||
fields. Deliberately does NOT auto-apply changes for the three trigger-bound fields
|
||
(`ZUENDUNG_SENSOR`/`KM_SENSOR`/`TANK_SENSOR` — `@state_trigger` bakes the entity ID in at
|
||
module-load time) — the UI shows an inline "wirkt erst nach Neustart" hint instead of
|
||
attempting a risky self-`pyscript.reload()` from inside a running service call. Two real
|
||
bugs found and fixed during live verification, both now also pushed to the Claude Design
|
||
project: (1) a full popup re-render on every keystroke fought the search input for focus —
|
||
fixed by patching just the candidate-list DOM node on `input`, matching the same fix the
|
||
existing `data-tankpreisfeld` handler already uses for the identical reason; (2) the popup
|
||
background used `--tile-2`, which Design had since changed to a translucent
|
||
`rgba(255,255,255,.1)` in the night theme (for the frosted-glass tile look elsewhere) — made
|
||
the whole popup partially see-through against the app content behind it. Fixed by switching
|
||
to `--tile-deckend` (Design's own opaque-surface token, already used by `.standortmenu` for
|
||
the same reason), with `var(--canvas)` as a defensive fallback.
|
||
- [x] Fresh-install packaging + `update.ps1` fix — done 2026-08-12. While preparing a short
|
||
quick-install checklist, found that `update.ps1` never copied `audi-dashboard-ios.css` or
|
||
`www/badges/*` — both are loaded by the running app (the ios overlay is injected by the app
|
||
itself via JS, badges by the model picker since item #51), so every `update.ps1` run since
|
||
those were added silently left the deployed copy stale on those two. Fixed (script now copies
|
||
both). Also removed two stray `*.bak-before-audit-merge` files from `www/` (already gitignored
|
||
at repo root, never committed, just local clutter from an earlier merge). New
|
||
`installationspaket/` — a generated, gitignored bundle (pyscript/, www/ minus the two backup
|
||
files, data/ with only the example profile + receipt parser, `configuration_snippet.yaml`,
|
||
`update.ps1`) plus a short `ANLEITUNG.md` checklist for copying to a fresh HA instance;
|
||
`INSTALL.md` stays the authoritative, detailed reference. Regenerate on demand, don't keep it
|
||
permanently in sync — it's a deployment snapshot, not a second source of truth.
|
||
- [x] Setup-menu polish: 5 additions on top of the existing Setup popup — done 2026-08-12.
|
||
Backend (`pyscript/modules/entitaeten.py`): a `_STANDARDWERTE` snapshot taken at module-load
|
||
time (before `overrides_anwenden()` ever runs), exposed via `aktueller_stand()` as
|
||
`standardwerte` — needed because `setattr()` on the live `einstellungen` module is permanent
|
||
for the process, so "reset to default" has to write the original value back explicitly rather
|
||
than just omitting the override. New service `audi_dashboard_neustart` (`frontend_api.py`)
|
||
wrapping `homeassistant.restart()`, called only from an explicit user-confirmed button, never
|
||
automatically. Frontend (`audi-dashboard-app.js`): (1) live current-value preview next to each
|
||
candidate in the search dropdown (`entitaetZeilenMarkup`, reads `HASS.states[e.id].state`);
|
||
(2) inline warning under a field if its currently assigned entity is `unavailable`/`unknown`/
|
||
missing (`entitaetStatusWarnung`); (3) duplicate-assignment check on save
|
||
(`setupDuplikate`) — blocks with a confirm-sheet ("trotzdem speichern?") rather than silently
|
||
allowing the same entity in two roles; (4) per-field reset button (only shown when the current
|
||
value differs from `standardwerte`), reverts to the built-in default; (5) after saving, if any
|
||
of the 3 trigger-bound fields actually changed (`setupGeaenderteTriggerFelder`, compares
|
||
against a snapshot taken when the popup opened), a confirm-sheet offers "Jetzt neu starten"
|
||
calling the new service. Verified live in `audi_ha_test`: value preview and unavailable-warning
|
||
confirmed correct (`ZUENDUNG_SENSOR`'s configured entity genuinely doesn't exist in this test
|
||
container → "Entität nicht gefunden." shown as designed); duplicate-check correctly caught both
|
||
a deliberately-forced KM/TANK collision and pre-existing door/window/lock auto-suggestion
|
||
collisions (a real side effect of this container's limited matching entities, not a bug);
|
||
reset button appeared/disappeared correctly as values diverged from/matched the default;
|
||
save round-tripped through the real service into `data/entitaeten.json` (byte-inspected) and
|
||
into the published `pyscript.audi_dashboard_fahrzeugstatus` state; restart-needed sheet named
|
||
exactly the changed trigger-bound field(s) and no others. Did not actually trigger a restart
|
||
during verification (would have restarted the shared test container) — the service call itself
|
||
was left unexercised beyond confirming it registers without error at pyscript load time.
|
||
- [x] Decide the iOS-vs-Audi-CI conflict from `DESIGN_REVIEW_2026-08-13.md` §A — decided
|
||
2026-08-13, owner's call: **iOS look made official** (Option 1 of the two the review offered).
|
||
`SPECIFICATION.md` §2 rewritten to describe the iOS values (colors, 16px radius) as canonical;
|
||
`bauauftrag.md` §8 now counts as superseded (left untouched, per this project's convention of
|
||
never editing that file — see its own "historical only" note above). The pre-decision Audi-CI
|
||
spec text is preserved verbatim in [`AUDI_CI_ARCHIV_2026-08-13.md`](AUDI_CI_ARCHIV_2026-08-13.md),
|
||
which also documents *why reverting later needs no restore*: `audi-dashboard-ios.css` is a
|
||
purely additive overlay loaded after `audi-dashboard.css` in `_aufbauen()`
|
||
(`audi-dashboard-app.js` ~line 3990) — the Audi-CI base values are still fully intact in the
|
||
unmodified base stylesheet, untouched by this decision. Reverting is just: stop loading the
|
||
overlay `<link>`.
|
||
- [x] Fix the two implementation bugs from the same review, independent of the direction decision
|
||
above — done 2026-08-13. **§B, red-semantics inversion:** `audi-dashboard-ios.css` `.aktion`
|
||
no longer fills every action red; base `.aktion` is now a neutral `--ios-fill` secondary
|
||
button, a new `.aktion.primaer` rule is the only one filled with `--ios-tint` (red), matching
|
||
the `.aktion`/`.primaer`/`.loeschen` semantics the base stylesheet already defined (outline
|
||
secondary / filled primary / destructive). Also fixed as part of the same finding: switch
|
||
on-state now uses `--ok` (iOS system green) instead of red; the desktop-sidebar active-tab
|
||
background (`.tab.on`) is now the neutral `--ios-fill` instead of a red tint, red stays only
|
||
as the accent text/icon color; two chart-bar segments in `audi-dashboard-app.js` that used
|
||
`--red` for a neutral data category ("Arbeitsweg" trips, first segment of the insurance
|
||
contribution bar) now use `--fg` like their sibling segments. **Radius inconsistency:**
|
||
`.setup-popup` and `.standortmenu` in `audi-dashboard.css` now reference `var(--r-tile)`
|
||
instead of a hardcoded `20px` literal, so both track whichever design (iOS 16px or, if
|
||
reverted, Audi-CI 20px) is actually active. Verified live in `audi_ha_test`: Setup-popup
|
||
Abbrechen/Speichern and the "Einrichten"/"Setup"/"Fertig" buttons in Einstellungen render with
|
||
the corrected fills at both mobile and desktop widths, switches render green, no console
|
||
errors.
|
||
- [x] Clean up remaining "EU Data Act" residue from the FMM003 pivot (2026-08-13) — the
|
||
Einstellungen "Version" tile in `audi-dashboard-app.js` hardcoded `Fahrzeugdaten: EU Data
|
||
Act / Abruf alle 15 Minuten` and `Position: iPhone / Companion App`, both stale: vehicle data
|
||
and position now come exclusively from the FMM003 (`ZUENDUNG_SENSOR`, `BATTERIE_SENSOR`,
|
||
`STANDORT_TRACKER` in `einstellungen.py`), not the abandoned HACS integration or the iPhone.
|
||
Both rows now read `FMM003`. Also fixed `testumgebung/konfiguration.yaml` +
|
||
`testumgebung/README.md`: the `input_text.test_wlan` / `iPhone WiFi Connection` template
|
||
sensor simulated WLAN-based trip detection, fully removed 2026-08-12 (`fahrzeug.wlan_ssid` no
|
||
longer exists anywhere) — its "Fahrt auslösen" example was non-functional. Replaced with
|
||
`input_boolean.test_zuendung` driving a `binary_sensor.testzone_fmm003_engine_ignition_or_acc_status`
|
||
template sensor matching the real `ZUENDUNG_SENSOR` entity ID, so trip detection stays
|
||
testable. The remaining KM/Tank/Range/door/window/lock template sensors correctly still
|
||
describe the abandoned `TommiG1/HA_VAG-EU-Data-Act` integration (per `einstellungen.py`) and
|
||
were left in place — those fields are blank by default but stay manually mappable via the
|
||
Setup menu, so the fixtures remain useful for testing that path; comments updated to make
|
||
this distinction explicit.
|
||
- [x] Fix oversized toggle switches ("Slider") across the app (2026-08-13) — `.feld label` in
|
||
`audi-dashboard.css` (`flex: 1 1 auto`, meant to let the *text* label grow so the control
|
||
lands at the row's right edge) unintentionally also matched `<label class="switch">`, since
|
||
the toggle switch is itself implemented as a `<label>` — its own `.switch { flex-shrink: 0;
|
||
width: 46px }` rule lost because `.feld label` has equal-or-higher specificity and comes
|
||
later in the file. Every switch inside a `.feld` (Einstellungen, Tanken, Reifen, ...)
|
||
stretched to fill the row (~260-300px measured live, instead of 46-51px). Fixed by excluding
|
||
switches from the selector: `.feld label:not(.switch)`. Verified live in `audi_ha_test` at
|
||
mobile (375px) and desktop widths — switches render at the correct ~51px iOS size, no
|
||
console errors.
|
||
- [x] Fix desktop (≥860px container) layout audit findings (2026-08-13, see
|
||
`DESIGN_AUDIT_2026-08-13.md` for full detail) — user-reported: (1) the settings gear button
|
||
sat far past the right edge of the visible content column on wide screens because `.topbar`
|
||
(grid-column:2, same track as `main#view`) had no `max-width:860px` while `main#view` did —
|
||
the track itself is `minmax(0,1fr)` and stretches to the full remaining window width, so
|
||
`.head{flex:1}` pushed `.profilbtn` to the track's far edge, ~450px past where `main#view`
|
||
visually ends. Fixed by giving `.topbar`/`.phone:has(.back.on) .topbar`/`.ptr` the same
|
||
`max-width:860px`. (2) In phone width the Audi rings (`#marke`/`.rings`) are, per existing
|
||
code comment, the *only* access to Einstellungen (the gear icon is hidden there) but were
|
||
42×24px — well under the 44px minimum touch target every sibling icon-button uses. Bumped to
|
||
58×32px. (3) Found while verifying (1): `.setup-popup`, `.sdpopup`, `.sheet`, and
|
||
`.standortmenu` are all `position:absolute` with `left`/`right` relative to the *full*
|
||
`.phone` box (sidebar included), not the content column, and had no desktop max-width either
|
||
— `.setup-popup` measured 1644px wide on a 1920px window (10px margins only). Fixed with
|
||
`max-width:560px` (`.setup-popup`/`.sdpopup`/`.sheet`) or `640px` (`.standortmenu`) plus
|
||
`margin-left/right:auto` in the same container query — deliberately not
|
||
`transform:translateX(-50%)`, which would fight these elements' own transform-based entry
|
||
animations. Centering is relative to the full `.phone` width (sidebar included), not the
|
||
860px content column exactly — a ~138px rightward offset from true content-center remains at
|
||
1920px; fixing that precisely would require moving these overlays into grid-column 2 in the
|
||
markup, deferred as a larger structural change. All three verified live in `audi_ha_test` at
|
||
375px and 1920px, no console errors.
|
||
- [x] Fix 4 more user-reported design findings (2026-08-13, same session, see
|
||
`DESIGN_AUDIT_2026-08-13.md` "Runde 2" for full detail with before/after measurements):
|
||
(1) page title sat ~30px right of the content tiles below it on wide screens — `.back`
|
||
reserves 44px+gap in `.topbar` even when inactive (`opacity:0`, not `display:none`), fixed
|
||
with `.back:not(.on){width:0;...}` in the desktop container query, root pages only (detail
|
||
pages with `.back.on` unaffected, verified back-navigation still works). (2) the chevron on
|
||
the 5 `.go`-tiles (Fahrzeug/Service/Versicherung/Reifen/Schutzbrief on Mein Audi/Versicherung)
|
||
was vertically centered on the *entire* multi-row tile (`top:50%` in the iOS overlay) instead
|
||
of next to the title, overlapping wrapped row text — removed the override, falls back to the
|
||
base `top:var(--sp-5)` next-to-title position. (3) the Ölwechsel-Intervall `<select>` values
|
||
("10.000 km"/"1 Jahr") looked like inert text — iOS overlay strips border/background from
|
||
`.feld select` and colors it the same grey as any other secondary text; added
|
||
`.feld select{color:var(--ios-tint)}` so picker fields read as editable the way iOS
|
||
conventionally signals it (accent color), free-text `.feld input`s deliberately left alone.
|
||
(4) the expand/collapse `.mark` chevron on group headers (Fahrten "2026"/"August") looked
|
||
like a checkmark — it uses the classic "2 borders + rotate(45deg)" CSS chevron technique,
|
||
which requires a square box; `.mark` was 6×10px (non-square, sized for the unrelated SVG-path
|
||
`.chev`/`.go` icons), made it 8×8px. Two more reported findings ("grey box around all menu
|
||
icons", "red partial frame on Fahrten/Tanken rows") were investigated (computed-style
|
||
diffing across all 5 tabs in both themes, a temporary 2.5x `transform:scale()` zoom on the
|
||
live swipe-row DOM) but could not be reproduced — documented in the audit file as open,
|
||
pending a screenshot from the real device.
|
||
- [x] Fix five user-reported bugs (2026-08-16):
|
||
(1) **Türschloss-/Haubenschloss-Erkennung entfernt** — user asked for these two checks to be
|
||
permanently removed (not just left blank/unmapped like the rest of the retired VAG-
|
||
integration fields). Removed `TUERSCHLOSS_SENSOREN`/`HAUBENSCHLOSS_SENSOR` from
|
||
`entitaeten.py`'s `FELDER` catalog (no longer offered in the Setup menu) and from
|
||
`einstellungen.py` (attributes deleted, not just blanked), and removed their entries from
|
||
`_sicherheitscheck()` in `frontend_veroeffentlichung.py` (no longer listed under "Geprüfte
|
||
Punkte"). `HECKKLAPPENSCHLOSS_SENSOR` (tailgate lock) was deliberately left untouched — user
|
||
named only the two door/hood locks. Also removed the now-orphaned
|
||
`input_boolean.test_entriegelt` helper and the four `test_audi_lock_*` template sensors from
|
||
`testumgebung/konfiguration.yaml` (door-open/window fixtures for `TUER_SENSOREN`/
|
||
`FENSTER_SENSOREN` are untouched), and updated `testumgebung/README.md` accordingly.
|
||
(2) **"Doppelt zugeordnet" (and every other confirm-sheet) was see-through** — `.sheet-gruppe`/
|
||
`.sheet-abbrechen` in `audi-dashboard.css` used `--tile-2`, which the iOS overlay's night
|
||
theme redefines as `rgba(255,255,255,.10)` (translucent, meant for tile-on-tile layering) —
|
||
the same root cause already fixed for `.setup-popup`/`.standortmenu` on 2026-08-13, just not
|
||
carried over to the generic confirm-sheet used by `bestaetigen()`/`hinweis()`. Switched both
|
||
to `var(--tile-deckend, var(--canvas))`, matching the existing fallback pattern already used
|
||
elsewhere in this file (base CSS must not go blank if the purely-additive iOS overlay is ever
|
||
reverted, since `--tile-deckend` is an overlay-only token).
|
||
(3) **FMM003 coordinates arrive as two separate sensors, not one device_tracker** — user
|
||
reported "coordinates are split into latitude and longitude". Root cause: `_standort()` in
|
||
`frontend_veroeffentlichung.py` only ever read `latitude`/`longitude` as *attributes of a
|
||
single `STANDORT_TRACKER` device_tracker entity* (HA's usual convention) — but some
|
||
integrations (confirmed live in `audi_ha_test`'s own simulated FMM003 device, which exposes
|
||
separate "Latitude coordinate value"/"Longitude coordinate value" sensors) publish them as
|
||
two independent `sensor` entities instead, which `_standort()` had no way to consume. Added
|
||
`STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR` (new optional fields in `einstellungen.py` +
|
||
`entitaeten.py`'s Setup-menu catalog, group "standort") as a second path: `_standort()` now
|
||
tries `STANDORT_TRACKER` first (unchanged behavior when it works), and falls back to reading
|
||
the two plain sensors' states directly when the tracker is unset or has no coordinates.
|
||
(4) **App can show only "Lädt …" until a tab is clicked** — known, already-mitigated race
|
||
(see the `nachladeAnstossen` header comment, `audi-dashboard-app.js`): on a slow/cold backend
|
||
start, `render()` no-ops until `DATEN_GELADEN` flips true, and the previously observed
|
||
manual fix was a HA panel remount. Root cause of *why* the click helps was not fully
|
||
reproducible in `audi_ha_test` (this is a genuine uncertainty, flagged rather than guessed
|
||
around) — but as a safe, low-risk hardening, `go()` (the tab/menu click handler) now calls
|
||
`datenLaden(false)` proactively if `DATEN_GELADEN` is still false, so any click that happens
|
||
to occur after data has actually become available is guaranteed to pick it up immediately
|
||
instead of depending on timing.
|
||
(5) **Fuel-receipt parser only understood Shell's exact layout** — user asked for a generic
|
||
algorithm for other/unknown fuel stations: find the address, find the total ("Gesamt"/
|
||
"Absolut"), find the liters ("Menge"/"Amount"), divide to get the paid price/liter, compare
|
||
against a printed "Preis/Liter" — a mismatch means a discount, found on the receipt as a
|
||
minus-marked amount. Implemented exactly this as `_parsen_generisch()` in
|
||
`homeassistant/data/shell_beleg_parser.py`, wired as a fallback in `main()` (Shell-specific
|
||
`_parsen()` tried first, unchanged — verified the existing 10-receipt regression suite still
|
||
passes byte-for-byte; `_parsen_generisch()` verified against a synthetic non-Shell receipt
|
||
with a known discount, all fields and the discount math correct). No separate "SmartDeal"
|
||
flag was needed — the frontend already treats any populated `discount` as SmartDeal-eligible
|
||
(gated only by the user's own "SmartDeal aktiv" switch, not by station name), so a generically
|
||
parsed Shell receipt behaves identically to one parsed via the strict path. Receipt-key
|
||
generation uses `hashlib.sha1` (not the built-in `hash()`, which is randomly salted per
|
||
process and would have broken the §7.7 duplicate-detection dedup across the parser's
|
||
per-receipt subprocess invocations).
|
||
All five deployed and verified live in `audi_ha_test` (version `1786752000`): Sicherheit-
|
||
Liste and Setup-Katalog confirmed to no longer offer Türschloss/Haubenschloss; the new
|
||
Breitengrad/Längengrad Setup fields confirmed present and correctly auto-suggesting the
|
||
container's real split lat/lon sensors; "Doppelt zugeordnet" confirmed rendering on an opaque
|
||
card by deliberately triggering it (this container's limited entity set causes a real
|
||
duplicate auto-suggestion); pyscript reload clean, no new console errors beyond the
|
||
pre-existing placeholder-image 404s. Synced to `installationspaket/`.
|
||
- [x] Follow-up round the same day (2026-08-16), three more items:
|
||
(1) **Removed `STANDORT_TRACKER` entirely** — user confirmed the combined device_tracker
|
||
field is no longer needed (flespi only ever provides the split lat/lon sensors added earlier
|
||
that day). Deleted the field from `einstellungen.py` and `entitaeten.py`'s Setup catalog, and
|
||
simplified `_standort()` in `frontend_veroeffentlichung.py` down to the single split-sensor
|
||
path (no more two-branch fallback). `overrides_schreiben()`'s existing "only known keys
|
||
survive" behavior (see its docstring) meant no explicit migration was needed for stale
|
||
`STANDORT_TRACKER` entries already sitting in `data/entitaeten.json` — confirmed live: this
|
||
container's own leftover override file still had `STANDORT_TRACKER` from earlier testing
|
||
(plus stale `TUERSCHLOSS_SENSOREN`/`HAUBENSCHLOSS_SENSOR` from before finding (1) of the
|
||
previous entry), all silently dropped on the next Setup save, which is exactly why the GPS
|
||
tile was still showing "Kein GPS-Signal" after the user's own remap attempt earlier — that
|
||
attempt happened to save while the old combined field was still in the catalog, so nothing
|
||
ever actually persisted `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`. Redid the Setup save
|
||
live (mapped both to `sensor.testcar_b9_fmm003_testintegratoin_lat/longitude_coordinate_value`)
|
||
and confirmed the Übersicht/fullscreen Standort map switched from the "no signal" placeholder
|
||
to a real rendered map at the correct coordinates (South Tyrol terrain, "Burgleralm" label
|
||
visible).
|
||
(2) **Fuel-receipt "silently does nothing", root cause found** — reproduced the exact
|
||
real upload flow (button click → real `<input type=file>` → synthetic `File`/`DataTransfer`
|
||
dispatch → `FileReader` → `hass.callService`) via browser instrumentation: the happy path
|
||
works completely (backend parses, publishes `pyscript.audi_dashboard_beleg_ergebnis`, frontend
|
||
fills the form) — `belegverarbeitung.py`'s existing `try/except` around `_parser_aufrufen()`
|
||
already publishes a visible `fehler` for parser failures specifically. The actual gap:
|
||
`base64.b64decode()` and `_pdf_speichern()` in `audi_dashboard_beleg_hochladen()` were
|
||
*outside* that try block — any failure there (corrupted upload, disk full, ...) raised an
|
||
unhandled exception with zero UI feedback, since `serviceRufen()`'s generic `.catch()` only
|
||
does `console.error()`. Widened the try block to cover all three steps. Also stopped using
|
||
`serviceRufen()` for this one call site specifically (`audi-dashboard-app.js`, the
|
||
`eingabe.onchange` handler) — it now calls `HASS.callService()` directly with its own
|
||
`.catch()` that sets `belegFehler` and re-renders, so a rejected service call (network,
|
||
timeout — anything that bypasses the backend's own state-publish) is visible too, not just
|
||
logged. Verified against the real 10-receipt Shell suite (still passing) both before and
|
||
after this change.
|
||
(3) **Vehicle map marker replaced with a two-tone pin+car icon** — added `CI.pinCar` (a
|
||
balloon/pin path + white badge circle + a simplified car-front glyph built from rects, not
|
||
circles, after an isolated-DOM-injection test round showed a circles-for-headlights version
|
||
read as a face/animal rather than a car) to `audi-dashboard-app.js`, replacing the single-color
|
||
`poiCar` teardrop previously used by `fahrzeugMarkerSVG()`. Updated both marker call sites'
|
||
`iconAnchor` to the new pin's actual tip position and simplified `.fahrzeug-pin` in
|
||
`audi-dashboard.css` (no more `color:var(--red)`/white-halo-filter hack, since the new icon
|
||
carries its own fixed colors). Verified the icon markup renders correctly via an isolated
|
||
DOM-injection test (bypassing Leaflet); could **not** get a final on-map screenshot in
|
||
`audi_ha_test` — the Standort map tiles themselves only ever partially load in this container
|
||
(reproduced on a fully fresh reload, unrelated to this change), consistent with a previously
|
||
documented network limitation of this specific sandbox (see the Standort-Kachel entry above:
|
||
reverse-geocoding via Nominatim had the same kind of container-specific network issue).
|
||
Structurally verified only; needs a look on a real device/network to confirm final visual
|
||
placement.
|
||
All three deployed to `audi_ha_test` (version `1786758000`) and synced to `installationspaket/`.
|
||
- [x] Third round the same day (2026-08-16) — **the "fragmented Leaflet rendering" defect is solved
|
||
at its root**, plus four smaller items:
|
||
(1) **Leaflet CSS never reached the shadow root.** `leafletLaden()` appended
|
||
`leaflet.min.css` to `document.head`, but the panel renders inside `this.shadowRoot` — and
|
||
document stylesheets do not cross a shadow boundary. Inside the panel, `.leaflet-tile
|
||
{position:absolute}` and friends therefore never applied: the tiles laid themselves out as
|
||
ordinary in-flow `<img>` elements (two per row, ~1040 px of stacked height inside a 190 px
|
||
box), so only a thin strip of map was ever visible, and the marker pane ended up ~1009 px
|
||
below the visible area — which is exactly why the new vehicle pin appeared "missing". This
|
||
also explains the earlier misdiagnosis as a container/network limitation: every tile request
|
||
succeeded (`complete:true`, `naturalWidth:512`), the geometry was the problem. Fixed by
|
||
injecting the stylesheet into `ROOT` and awaiting it before the map is built; the script tag
|
||
stays in `document.head` (it must, `window.L` is global). The CSS injection is deliberately
|
||
re-checked on every `leafletLaden()` call rather than guarded by the `window.L` check, because
|
||
a HA panel remount produces a fresh shadow root while `window.L` is already set. Verified live:
|
||
both the Übersicht preview map and the fullscreen Standort map now fill their containers and
|
||
show the pin at the vehicle position.
|
||
(2) **Maps are now always light.** `TILES[theme]` switched to CARTO `dark_all` in Nacht mode;
|
||
replaced by a single themeless `TILE_URL` (`light_all`), matching how Google Maps & co. keep
|
||
the standard road map light regardless of app theme.
|
||
(3) **Vehicle marker now uses the real CI `poi-car` icons** (`poi-car-l.svg` / `poi-car-s.svg`
|
||
from the delivered icon set) instead of the hand-built two-tone pin from the previous round:
|
||
`CI.poiCarL` (48-grid) at ≥34 px, the existing `CI.poiCar` (24-grid) below, anchors recomputed
|
||
per variant. Since these are single-color outline forms in `currentColor`, `.fahrzeug-pin`
|
||
regained a stacked white drop-shadow halo so the outline stays readable on map tiles.
|
||
(4) **Red partial frame on Fahrten/Tanken list rows** (open since the 2026-08-13 design audit,
|
||
finding 3) — reproduced and confirmed by recoloring the delete button live. Row heights are
|
||
fractional (66.28 px), so `.swipe-content`'s edges miss the device-pixel grid and the red
|
||
`.swipe-delete` behind it bled through as a hairline around every row. Fixed by only painting
|
||
the delete button while a swipe is actually happening: `.swipe-delete{visibility:hidden}` plus
|
||
a `wischt` class set on the first movement of the gesture (and the existing `swiped` class for
|
||
the open row). Verified the full gesture still works — button appears at the first pixel of
|
||
drag and the row settles open at −84 px.
|
||
(5) **Select fields now show the grey background box** (design audit finding 1) — replaced
|
||
`.feld select{color:var(--ios-tint)}` with `background:var(--ios-fill);padding:8px 10px`,
|
||
matching `.mitEinheit input` ("Pause bis [15] Minuten"), as the user asked. Verified on
|
||
Modell / Ölwechsel-Intervall / Bildposition; free text fields stay plain, so the box now
|
||
genuinely marks "there is a choice here".
|
||
(6) **Generic receipt parser: total and station name** — a real Austrian non-Shell receipt
|
||
returned the right litres but 6,00 € and no station. Two causes, both fixed: the free-running
|
||
total pattern matched the *column heading* `SUMME-EUR` and read the next line's article number
|
||
as the amount, and bon printers letter-space headings (`G E S A M T BETRAG EUR: 92,60`), which
|
||
the keyword never matched. Total detection is now line-based — keyword checked against the
|
||
whitespace-stripped line, amount must be a real money value (`_GELD`, decimals required), tax
|
||
lines (`MWST`/`UST`/`VAT`/`STEUER`/`NETTO`) excluded, largest remaining candidate wins. The
|
||
address heuristic no longer demands three fixed lines with a 5-digit postcode (AT uses 4
|
||
digits, and this receipt carries `SHELL TANKSTELLE, 6450 SÖLDEN` on one line): it finds the
|
||
first postcode line in the header and takes the name from the line above. `Preis/L` without a
|
||
spelled-out "Liter" now matches too. Result on that receipt: 42,13 l · 92,60 € · 2,198 €/l ·
|
||
no discount · `AUTO B. FRISCHMANN GMBH`. The 10-receipt Shell regression suite still passes.
|
||
All deployed to `audi_ha_test` (version `1786765000`) and synced to `installationspaket/`.
|
||
- [x] Fourth round the same day (2026-08-16):
|
||
(1) **The "grey box around the icons" report is resolved — it was never a defect.** After five
|
||
fruitless reproduction attempts across two sessions, the user clarified what they actually
|
||
meant: the grey box seen on "10.000 km", "1 Jahr", "15" is the *desired* pattern, and **every
|
||
value the user can change should carry it** to signal editability. `.feld input`/`.feld select`
|
||
in `audi-dashboard-ios.css` now all get `background:var(--ios-fill);padding:8px 10px`;
|
||
inputs with their own visual language (`checkbox`/`radio`/`range`/`file`) are excluded and keep
|
||
the transparent base rule, so the three switches stay untouched. The separate
|
||
`.mitEinheit input` rule became redundant and was removed (its width/alignment still come from
|
||
`audi-dashboard.css`). Verified live: FIN, Kennzeichen, Erstzulassung, Ausführung, Modell,
|
||
both Ölwechsel selects, Ansicht, Pause bis and Autom. Backup all boxed; the three checkboxes
|
||
not. **Do not re-open this as a bug.**
|
||
(2) **Station display name is now "Marke, Straße, Ort"** (e.g. `Shell, Pascalstr. 8,
|
||
Ingolstadt`) instead of the bare operator name (`A. Zrenner GmbH`) — the brand and place say
|
||
more when scrolling the Tankvorgänge list. New `_marke()`/`_ist_strasse()`/`_tankstelle()`
|
||
helpers in `shell_beleg_parser.py`, shared by both parser paths so Shell and non-Shell
|
||
receipts format identically. The brand is matched only against the first 8 lines (the receipt
|
||
header) — searching the whole text would let "Total" hit a totals line. Missing parts are
|
||
dropped rather than left as empty comma slots (the Austrian receipt prints no street:
|
||
`Shell, SÖLDEN`), and without a recognizable brand the operator name takes the brand's place.
|
||
`station_address` keeps the full street + postcode + city as before. The regression suite's
|
||
`test_bekannte_stationen` expectations were updated to the new format (intended change, not a
|
||
break); all 8 tests / 164 subtests pass.
|
||
(3) **`installationspaket/` is versioned from now on** (user request) — removed from
|
||
`homeassistant/.gitignore`. It carries no real vehicle data, only
|
||
`data/fahrzeugprofil.example.json` with empty FIN/Kennzeichen placeholders; verified before
|
||
un-ignoring. **Keep it in sync whenever `pyscript/` or `www/` changes** — that was already the
|
||
working rule, but it is now visible in the repo when it is forgotten.
|
||
Deployed to `audi_ha_test` (version `1786766000`).
|
||
- [x] Fifth round the same day (2026-08-16) — one-click installer and the Einzelbeleg map:
|
||
(1) **`installationspaket/Installieren.cmd` + `install.ps1`** automate steps 1–3 of
|
||
`ANLEITUNG.md`. The `.cmd` wrapper exists because double-clicking a `.ps1` opens it in an
|
||
editor and the execution policy blocks it; the bypass is per-call, the machine policy is not
|
||
touched. Safe to re-run: never overwrites `fahrzeugprofil.json`, `fahrten.jsonl`,
|
||
`tankvorgaenge.jsonl`, `entitaeten.json`, `ha_token.txt`; backs `configuration.yaml` up to
|
||
`.bak` and replaces its own marker-delimited block instead of appending twice; refuses to
|
||
touch the file at all when `pyscript:`/`panel_custom:` are already there from another source
|
||
(duplicate top-level keys are invalid YAML — HA would not start); aborts before writing if
|
||
the target has no `configuration.yaml`. `-Pruefen` is a dry run. **`install.ps1` is stored
|
||
UTF-8 *with* BOM on purpose** — PowerShell 5.1 reads scripts as ANSI otherwise and mangles
|
||
every umlaut; the files it *writes* stay BOM-less (YAML). Verified against a fake config
|
||
tree: fresh install, re-run idempotency, data preservation, foreign-key refusal,
|
||
wrong-directory abort, and the merged YAML parses.
|
||
(2) **Tankstellenposition im Einzelbeleg.** The red `circleMarker` is now the CI `poi` pin
|
||
(`CI.poiL`/`CI.poiS` from `poi-l.svg`/`poi-s.svg`, via `tankstellenMarkerSVG()`) — deliberately
|
||
the plain pin, not `poi-car`, so vehicle and station stay distinguishable.
|
||
**Found while testing: nothing in the backend ever writes `station_lat`/`station_lon`**, so
|
||
this marker had never actually been reachable. Fixed by geocoding the receipt address in the
|
||
frontend (`adresseAufloesen()`, Nominatim `/search` — the forward counterpart to the existing
|
||
`standortAdresseAufloesen()`), with results cached in `localStorage` (`audi_dashboard_geocache`)
|
||
including negative results, since a station address does not move and Nominatim's terms ask
|
||
for caching. Confirmed live: `Shell, SÖLDEN` → 46.9756/11.0111.
|
||
(3) **Zentrieren-Knopf** in the Einzelbeleg map card (`mapBoxMitZentrieren()`, `CI.gps` —
|
||
byte-identical to the delivered `gps-s.svg`), disabled until the coordinates arrive. `.mapbox`
|
||
gained `position:relative` so the control anchors to the card. Only there, per explicit
|
||
request — the Standort fullscreen already has its own centre controls.
|
||
Deployed to `audi_ha_test` (version `1786769000`).
|
||
Note for testing map behaviour in this app: `initMap()` runs on **every** `render()`, and the
|
||
app re-renders on each backend push (~30 s), rebuilding the map and re-centring it. Panning
|
||
the map from the console and checking later therefore proves nothing — spy on
|
||
`L.Map.prototype.flyTo` instead.
|
||
- [x] Sixth round the same day (2026-08-16) — a large batch of user-reported UI polish items, plus
|
||
the map pins' real visibility root cause:
|
||
(1) **Map pins were genuinely near-invisible, not just a styling nit.** The user reported the
|
||
vehicle's map pin looked "transparent"; reproduced empirically (not by eye) by rasterizing the
|
||
live marker's SVG to a canvas and sampling alpha — only ~13% of the icon's own bounding box had
|
||
any ink, and the exact center pixel was fully transparent. Root cause: `CI.poiCarL`/`poiCar`
|
||
(and `poiL`/`poiS`, used for the Einzelbeleg station pin) are pure **contour** forms — the
|
||
balloon outline is drawn as two nested, oppositely-wound paths that cancel to a 1px ring under
|
||
the default nonzero fill rule, plus thin interior detail lines; there is no filled area at all.
|
||
A first fix (solid navy circle behind just the icon's round "head", via `::before`) was
|
||
explicitly rejected by the user ("I want the whole icon solid, not just the car" / "make the
|
||
needle opaque") — correct, since it only covered ~36% of the icon and left the pointed tip as a
|
||
bare outline. Real fix: each icon's `d` attribute is multiple sub-paths (split at top-level
|
||
M/m); the **first** sub-path alone is already the complete, correct outer balloon silhouette
|
||
(verified by rasterizing it in isolation — solid center pixel, 36-41% fill ratio, matching a
|
||
normal teardrop-in-square ratio). Extracted that first sub-path verbatim (no redrawing) into
|
||
four new `CI.poiCarLSil`/`poiCarSil`/`poiLSil`/`poiSSil` constants, fixed-color-filled (not
|
||
`currentColor`), and `fahrzeugMarkerSVG()`/`tankstellenMarkerSVG()` now render it as the bottom
|
||
layer behind the original detailed icon; `.fahrzeug-pin`'s color switched from dark navy to
|
||
white so the original outline/detail lines read against the new solid fill, exactly like the
|
||
white-glyph-on-solid-teardrop convention of Google/Apple Maps pins. Applies uniformly to both
|
||
marker types now (station pin included, per explicit user request), so the earlier per-variant
|
||
`.auto` CSS modifier and its circle-position math were removed again as dead weight.
|
||
(2) **Battery voltage statistic cutoff lowered 13.2V → 12.8V** (`AGM_RUHE_MAX_V` in
|
||
`audi-dashboard-app.js`) — user's point: anything above ~12.8V (full-charge resting voltage per
|
||
the existing in-code research citation) is already alternator output, not the battery's own
|
||
state, so admitting up to 13.2V let charging-tail values distort the charge-state stats.
|
||
(3) **Battery chart Y-axis no longer "stretches."** `bvZeichnen()` recomputed `yMin`/`yMax` from
|
||
whatever points were currently zoomed into (min 0.4V span + 15% pad) on every redraw, so the
|
||
axis rescaled on every pan/zoom gesture. Now computed once from the full dataset in
|
||
`vBatterieverlauf()` (`bvYDomain`) and reused unchanged across zoom/pan.
|
||
(4) Removed two explanatory paragraphs from the Batteriespannung detail view ("Näherung auf
|
||
Basis…" and "Ziehen zum Verschieben…") per explicit request.
|
||
(5) **Tankfüllung now shows "X % / X l"** (`vAudi()`) instead of a literal-litre primary value
|
||
with a redundant "100 % von 58 l" sub-line.
|
||
(6) Removed the "· noch etwa X l im Tank" suffix from the Übersicht's "Reichweite" label
|
||
(`vHome()`); the now-unused `liter` local was removed with it.
|
||
(7) **Active/primary buttons are grey again, not red** — `.aktion.primaer` in
|
||
`audi-dashboard-ios.css` filled with `--ios-tint` (red) since the 2026-08-13 iOS-overlay
|
||
decision; explicit user request to drop that and match the base stylesheet's neutral
|
||
`var(--fg)`/`var(--canvas)` fill. Destructive actions (`.aktion.loeschen`) intentionally stay
|
||
red — Apple's own HIG reserves red specifically for destructive actions, so this one use is
|
||
correct per the guideline, not just left over.
|
||
(8) **`<select>` fields now carry an Apple-HIG pull-down chevron.** They already had
|
||
`appearance:none` (native arrow removed) but no replacement indicator at all — a real gap, not
|
||
cosmetic. Added a CSS `background-image` chevron (light/dark variants) in both stylesheets;
|
||
base `audi-dashboard.css` kept independently correct (own light/dark variant via
|
||
`:host([data-theme=tag])`) per this repo's "purely additive overlay" convention for the ios
|
||
file.
|
||
(9) **Apple HIG research round.** Read Color/Typography/Layout/Buttons/Materials directly off
|
||
developer.apple.com (the WebFetch tool returned only page titles for this JS-rendered site: the
|
||
in-app Browser tool's `get_page_text` worked and was used instead) and cross-checked against
|
||
actual measurements in this codebase, then asked the user via `AskUserQuestion` which findings
|
||
to apply. Accepted: text below the 11pt iOS legibility minimum (battery-chart axis labels were
|
||
8px, the header's "Sync" line was 10.5px) raised to 11px; the new compact "Montiert" pill's
|
||
*invisible* hit area (not its visual size) expanded to the 44×44pt minimum via a `::before`
|
||
with negative insets, same technique applied to future compact controls. Declined, deliberately:
|
||
switching the app's few weight-300 ("Light") text spots to 400 — Audi Type only ships 300/400,
|
||
and the user chose to keep Audi CI's own weight over HIG's general "avoid Light" guidance;
|
||
making popups/sheets translucent to match the Materials guidance — the app already restricts
|
||
translucency to the nav layer only (tab bar), which *is* the HIG-correct pattern, and popups
|
||
were deliberately made opaque on 2026-08-16 earlier the same day to fix a real see-through bug,
|
||
so translucency there would be a regression, not an improvement.
|
||
(10) Removed "Aus dem Fahrzeug gelesen" and the Einrichten intro paragraph ("Fahrgestellnummer,
|
||
Kennzeichen, Erstzulassung…") from `vEinst()`, both per explicit request.
|
||
(11) **Fuel bar color now matches the headline** (`var(--fg)`) instead of a red/yellow/green
|
||
status gradient in `vHome()`; the `balkenFarbe` local was removed with it.
|
||
(12) **Selected menu icon is white (headline color), not red**, in the desktop side-nav
|
||
(`.tab.on` inside the `@container (min-width:860px)` block in `audi-dashboard-ios.css`) — the
|
||
mobile tab bar already used `var(--fg)` there, this brought the desktop variant in line.
|
||
(13) **Reifenfoto (Sommerrad/Winterrad) now crops from the right edge**, not the center —
|
||
`.radbild` gained `object-position: right center`; the photo is typically wider than the 96px
|
||
square slot and the wheel itself tends to sit toward the right of the source photo.
|
||
(14) **"Montiert" button rebuilt as a compact top-right pill** (new `.montiert` class,
|
||
replacing the old full-width `.seg`-wrapped button) per explicit layout instructions: top edge
|
||
aligned with the wheel photo's top (`top:var(--sp-5)`, same offset the photo already has from
|
||
the tile's own padding), right inset equal to the tile's standard left content inset (both
|
||
`var(--sp-5)`) so left/right margins match. Markup reordered so the button renders before
|
||
`.radkopf` (position is absolute either way, but keeps DOM order sensible).
|
||
(15) **Tab-bar active highlight now covers icon *and* label when the label is visible**, not
|
||
just a tight pill behind the icon — `.tabbar:not(.ohne) .tab.on` gets the highlight background
|
||
directly (whole button), with the inner `.tabpille` background suppressed in that case; the
|
||
icon-only mode (`.tabbar.ohne`) keeps the original tight icon-pill unchanged.
|
||
Deployed to `audi_ha_test` (version `1786901000`); pin fix visually confirmed live (solid
|
||
teardrop pin with white car glyph, clearly over the map's "Obergurgl" label text) and the
|
||
tab-bar highlight confirmed covering both icon and "Übersicht" label.
|
||
- [x] Seventh round the same day (2026-08-16) — more user-reported layout/interaction bugs:
|
||
(1) **Wheel photo (Sommerrad/Winterrad) was never actually a square, despite the fifth round's
|
||
crop-direction fix.** Root cause: `bildMitPlatzhalter()` puts the `radbild` class on *both* the
|
||
wrapping `.bildbox` div and the `<img>`; the old `.radbild{width:96px;height:96px;...}` rule and
|
||
`.bildbox{width:100%;aspect-ratio:4/3;...}` have equal specificity (one class each), and
|
||
`.bildbox` is declared later in the file, so it silently won — the box stayed a full-width 4:3
|
||
rectangle the whole time, `object-position:right center` just changed which slice of that wide
|
||
box showed. Fixed with a `.bildbox.radbild{width:96px;height:96px;aspect-ratio:1/1}` compound
|
||
selector (two classes, unambiguously higher specificity than plain `.bildbox`); `.radbild` alone
|
||
now only carries `object-position`. Verified live: `.bildbox.radbild` now measures exactly
|
||
96×96px.
|
||
(2) **"Montiert" moved from top-right to top-left**, per explicit request — this was the visible
|
||
symptom of (1): the pill was anchored top-right assuming it overlaid the (accidentally) full-
|
||
width photo; once the photo is a real 96px square on the left, top-left is where it actually
|
||
needs to sit to stay over the picture.
|
||
(3) **Native `<select>` popups now get a `color-scheme` hint.** The closed `<select>` box itself
|
||
was already themed correctly, but the *opened* native option list ignores app CSS and falls back
|
||
to the browser/OS default palette unless `color-scheme` says otherwise — this is what made
|
||
"Modell" and every other dropdown "flash" bright white against the dark app on open. Added
|
||
`color-scheme: dark` under `:host([data-theme="nacht"])` and `color-scheme: light` under
|
||
`:host([data-theme="tag"])` in `audi-dashboard-ios.css`.
|
||
(4) **Fixed a real JS crash in the "Mein Audi" image-cycle click handler.** `bildWeiter()` (fires
|
||
on tapping the hero photo) tried `box.querySelector(".platzhalter-datei").textContent = ...` —
|
||
that class was never emitted by `bildMitPlatzhalter()` (the actual class is `.platzhalter-
|
||
aktion`, and in gallery mode it deliberately shows a static "Foto hinzufügen" prompt, not a
|
||
filename). `querySelector` returned `null`, the assignment threw, and the exception aborted the
|
||
function *before* reaching the line that updates the `.dots` page indicator — so the photo
|
||
itself advanced (that line ran first) but the dots never moved, reading as "doesn't work."
|
||
Removed the dead line entirely; verified live that the `.dots` `on` class now correctly moves
|
||
with each click and no exception is thrown.
|
||
(5) **Merged the separate "Fahrzeugbilder" upload grid into "Bild der Übersicht"**, per explicit
|
||
request: pick a view from the existing "Ansicht" dropdown, then tap the (now also click-to-
|
||
upload) preview below it to upload/replace/delete that specific photo — reusing the same
|
||
generic `data-bildklick`/`bildMenuOffen` popup plumbing already used by the wheel-photo tiles,
|
||
not a new mechanism. `bildInfo()`'s existing winter-side-view special case (swaps in
|
||
`seitenansicht-winter.webp` while winter tires are marked mounted) means the winter variant
|
||
stays uploadable through the same control, gated by current tire season instead of a permanently
|
||
visible separate slot. The now-unused `BILDER_UPLOAD_SLOTS` constant and the `.bildgrid`/
|
||
`.bildslot`/`.bildslot-label`/`.carfix.mini` CSS were removed as dead code; a new
|
||
`.bildmenu.ansicht` positions the ersetzen/löschen popup anchored to the tile's *bottom* edge
|
||
(not a fixed top offset like the other two menu variants) since this preview's height varies
|
||
with the tile's own content instead of being a fixed small thumbnail.
|
||
(6) **Back arrow changed from red (`--ios-tint`) to the neutral headline color (`--fg`)** in
|
||
`audi-dashboard-ios.css`, matching every other navigation-color decision made this project (red
|
||
stays reserved for destructive actions per Apple's HIG).
|
||
Initially investigated but could not reproduce (see below for the real fix, found later the same
|
||
day): user reported that hiding tab labels ("Beschriftung in der Menüleiste" off) makes the
|
||
*entire* tab bar disappear. First pass traced the CSS cascade and the commit that added the sixth
|
||
round's icon+label highlight and found it correctly scoped to `:not(.ohne)`; live DOM/computed-
|
||
style testing (toggle, click the label, navigate away/back, both container widths) checked
|
||
`#tabbar`'s and each `.tab`'s own box — all stayed visible, sized, and classed correctly — so the
|
||
bug looked unreproducible and was provisionally left alone.
|
||
Deployed to `audi_ha_test`; wheel-photo squareness, Montiert position, back-arrow color, and the
|
||
image-cycle dots all confirmed live via direct DOM/computed-style checks (no visual screenshot
|
||
tool available this session — see running note below).
|
||
Follow-up same day: user reported the `color-scheme` fix alone wasn't enough — dropdowns were
|
||
"still very bright." Correct: a `<select>`'s *opened* option list is rendered by the browser/OS
|
||
as a separate surface outside the page's render tree entirely — confirmed firsthand when a
|
||
screenshot tool call actually hung for 30s trying to capture it, and even a real click-to-open
|
||
attempt afterward produced no visible change in any screenshot. `color-scheme` only gets partial
|
||
credit there; the reliable lever is that Chrome/Firefox/Edge *do* honor explicit
|
||
`background-color`/`color` set directly on `<option>` elements for the popup rows. Added
|
||
`.feld select option{background-color:var(--canvas);color:var(--fg)}` — deliberately `--canvas`,
|
||
not `--tile-2` (translucent in the night theme, would let the browser's own light backdrop show
|
||
through). Verified via `getComputedStyle` on the actual `<option>` nodes
|
||
(`background-color: rgb(12,16,20)`, `color: rgb(255,255,255)` in night theme) since the popup
|
||
itself is provably outside what any screenshot in this tool can capture — user should confirm
|
||
visually on their own device.
|
||
Also: "Montiert" and the wheel photo were requested swapped — photo now sits on the tile's right
|
||
edge (`.bildbox.radbild{margin-left:auto}` inside its full-width wrapper), Montiert stays
|
||
`left`-anchored, unchanged from the seventh round.
|
||
**Found the real tab-bar bug right after, from the user's own screenshot** ("in this view i dont
|
||
see any menu items" — a live repro of icon-only mode, this time actually screenshotted since the
|
||
browser pane had recovered). The earlier live-testing pass checked `#tabbar` and `.tab`'s own
|
||
boxes and both looked fine — the actual defect was one level deeper and only visible by measuring
|
||
the icon itself: `.tabbar.ohne .tab span{display:none}` (in both stylesheets) was meant to hide
|
||
only the trailing label `<span>${t.label}</span>`, but the icon is *also* wrapped in a `<span
|
||
class="tabpille">` — a bare type selector `span` does not care about class, so the rule hid the
|
||
icon's wrapper too. `getBoundingClientRect()` on the `<svg>` read `{w:0,h:0}` and
|
||
`getComputedStyle(.tabpille).display` read `"none"` once actually checked — that was the missing
|
||
measurement in the first pass. This is exactly the reported "whole menu bar disappears": in icon-
|
||
only mode every tab lost both its label *and* its icon, leaving five empty buttons in an otherwise
|
||
correctly-visible bar. Fixed with `.tabbar.ohne .tab span:not(.tabpille){display:none}` in both
|
||
`audi-dashboard.css` and `audi-dashboard-ios.css` (the base file's copy was already shadowed by
|
||
the ios one at matching specificity, but fixed too rather than left as a landmine). Verified live
|
||
via screenshot: all 5 tab icons render correctly in icon-only mode, active tab still shows its
|
||
filled pill.
|
||
- [x] Eighth round the same day (2026-08-16) — Reifen tile relabel + reflow, per explicit request:
|
||
"Sommer"/"Winter" → **"Sommerräder"/"Winterräder"** (only the section headline text in
|
||
`vReifen()`'s `satz()`; the singular "Sommerrad"/"Winterrad" photo label and the `data-satz`
|
||
value compared against `CAR.reifen.aktiv` elsewhere were deliberately left untouched — the first
|
||
is grammatically correct as-is for a single wheel photo, the second is an internal state key, not
|
||
display text, and changing it would break the mount-toggle matching in the `[data-satz]` click
|
||
handler). That headline is now an actual **headline** — new `.tile-headline` class (20px,
|
||
`var(--fg)`, same typographic family as the page `.title` one step down) replacing the small,
|
||
muted `.label` styling every other in-tile section title uses.
|
||
Reflowed so the headline+km sit **above** "Montiert" instead of the wheel photo overlaying it:
|
||
new `.radkopf-row` (flex, `justify-content:space-between`) puts the headline/km block and the
|
||
wheel photo side by side at the tile's top, both top-aligned; `.montiert` dropped `position:
|
||
absolute` (no longer needs to float over the photo now that the photo isn't in that space) and
|
||
became a normal-flow pill with `margin-top` under the headline block — simpler than the previous
|
||
overlay hack, same 44×44pt invisible tap target via `::before`. `.bildbox.radbild`'s `margin-
|
||
left:auto` (added last round to right-align it under its old block-flow parent) was removed since
|
||
`.radkopf-row`'s flex layout does that job now. Deployed to `audi_ha_test`, confirmed live via
|
||
screenshot for both Sommerräder (mounted) and Winterräder (unmounted) tiles.
|
||
- [x] Ninth round the same day (2026-08-16) — "Mein Audi" image-cycle tap, still broken after the
|
||
seventh round's `bildWeiter()` crash fix: reproduced with a genuine mouse click (not a JS
|
||
`.click()` call) via screenshot and found the actual cause was one level upstream. This docker
|
||
test container has zero uploaded car photos, so every gallery image is in `bild-fehlt` state,
|
||
and `.bildbox.bild-fehlt img{display:none}` makes the `<img id="carimg">` itself un-hit-testable
|
||
— a real tap lands on the `.platzhalter` div instead, which the old handler
|
||
(`e.target.id === "carimg"`) never matched, so nothing happened. First fix wrapped the gallery
|
||
image in a `data-bildgalerie` container that opened the upload picker when empty and advanced
|
||
when a photo was loaded; the user then clarified uploads should stay exclusively in Einstellungen
|
||
("Bild der Übersicht") — Mein Audi should only ever cycle. Settled on: the wrapper stays (needed
|
||
so taps land on something clickable regardless of `bild-fehlt` state) but always calls
|
||
`bildWeiter()`, no upload branch. Verified live with a real click (screenshot → click → recheck
|
||
`.dots`): the active dot correctly advances, no console errors beyond this environment's
|
||
pre-existing service-worker/404 noise.
|
||
Also this round: replaced the "Farbskala Ladezustand (AGM, Ruhespannung)" text-only label above
|
||
the battery voltage color scale with a small battery icon + "Ruhespannung" (new `.bv-skala-kopf`
|
||
flex row, same geometry as the header's `.sync` icon+text pattern). First attempt hand-drew a
|
||
generic outline battery glyph since no "battery" icon exists in this repo's local CI subset
|
||
(`design/assets/icons/`); the user then supplied the real `battery-12v-l.svg`/`battery-12v-s.svg`
|
||
files from the actual Audi CI icon set (not something this session has fetch access to — asked
|
||
the user rather than guessing a URL or fabricating an icon and calling it authentic). Added both
|
||
as `CI.battery12vL`/`CI.battery12vS` (verbatim paths, `l` on the 48-grid, `s` on the 24-grid,
|
||
matching the existing `poi-l`/`poi-s` size-variant convention); only the `s` variant is used here
|
||
via `ciSVG()` since this is a small 15px inline label icon.
|
||
Deployed to `audi_ha_test`, confirmed live via screenshot.
|
||
Also this round (user then supplied the real `edit-l.svg`/`edit-s.svg` too): added a manual
|
||
km-correction control to the Reifen tiles, per explicit request — a small pencil icon (Audi CI
|
||
`edit-s`, new `CI.editS`/`CI.editL`) to the left of each Sommerräder/Winterräder km figure, "the
|
||
counter shall add continuously to these km even if the user changes the km". The tricky part
|
||
was persistence: `configCarZuProfil()` *deliberately* strips `km` before every profile write
|
||
(see its own comment - km is "vom Backend fortgeschrieben", a stale browser copy must never
|
||
clobber a since-elapsed automatic increment), so simply mutating `CAR.reifen[k].km` and calling
|
||
`profilSpeichern()` like every other Reifen field does would silently drop the edit. Added a
|
||
dedicated backend service instead, `audi_dashboard_reifen_km_setzen(satz, km)` in
|
||
`reifenzaehler.py`, that only overwrites the stored counter itself and leaves
|
||
`reifen.referenz_odo_km` (the last-processed odometer reference) untouched - the existing
|
||
`kilometerstand_geaendert()` trigger always reads the counter fresh off disk before adding the
|
||
next delta, so continuing to accumulate from the corrected value required no other backend
|
||
change at all. New `kmOffen` state (which satz's correction field is open, mirroring the
|
||
existing `nmOffen`/`bildMenuOffen` single-open-popup pattern) plus `data-kmauf`/
|
||
`data-kmspeichern` handlers, styled like the existing `.montiert`/`.zahnrad` controls (44×44pt
|
||
invisible tap target via `::before`). Verified end-to-end: edited Sommerräder from 150→4200 km,
|
||
confirmed the pyscript service ran via the container's HA log, then did a full page reload
|
||
(forces a real backend re-fetch, not just optimistic local UI state) and the corrected value
|
||
was still there.
|
||
- [x] Tenth round the same day (2026-08-16) — a real full Apple HIG audit, not just the sixth
|
||
round's 5-page pass: read Tab Bars, Sidebars, Sheets, Alerts, Action Sheets, Toolbars/
|
||
Navigation Bars, Buttons, Pickers, Loading, Feedback, Privacy, and Undo/Redo directly off
|
||
developer.apple.com and cross-checked each against the actual code (not just CSS) — most of
|
||
the app already held up (5-tab bar, destructive-button placement, 44pt tap targets,
|
||
`prefers-reduced-motion` support, confirm-sheet button ordering all already correct). Three
|
||
real findings surfaced, all fixed and user-approved via `AskUserQuestion` before implementing:
|
||
(1) **A second, larger silent-failure gap than the one already fixed for receipt upload.**
|
||
`profilSpeichern()` — the shared save path for roughly 25 editable fields across the app
|
||
(Anzugsmoment, service book, SmartDeal, tax, insurance, oil change, pause time, backup
|
||
interval, start image, ...) — was `async` with no `try`/`catch` at any of its ~25 call sites,
|
||
so a failed backend write was an unhandled promise rejection with zero user feedback; the
|
||
popup had already closed and the UI already updated optimistically, so the change looked
|
||
saved when it silently wasn't. Wrapped the one call inside `profilSpeichern()` itself in
|
||
`try`/`catch`, reusing the existing `hinweis()` error-sheet pattern — one central fix instead
|
||
of ~25 call-site patches. `serviceRufen()` (the separate wrapper used by km-correction, manual
|
||
trip/fuel entry, tire-set switching, deletions, restart, ...) got the identical treatment.
|
||
Verified live by monkey-patching `hass.callService` to reject inside the running app (via
|
||
`host.hass.callService = () => Promise.reject(...)`), then triggering a real Anzugsmoment save
|
||
through the actual UI: before the fix, the popup silently closed with a stale "Uncaught (in
|
||
promise)" console entry and no user-visible sign anything went wrong; after the fix, a
|
||
"Speichern fehlgeschlagen" sheet appears with the real error text. A follow-up full reload
|
||
confirmed the failed test write never reached the backend (value reverted to the real stored
|
||
120 Nm) — the fix only adds feedback, it doesn't change persistence behavior.
|
||
(2) **"Lädt …" bootstrap screen got a progress indicator** (new `.lade-spinner` CSS, a plain
|
||
rotating ring) instead of bare static text, per HIG Loading guidance to show something moving
|
||
while content loads — notable here because this exact screen has a documented history of
|
||
genuinely hanging, so a spinner also helps a user tell "still loading" apart from "stuck".
|
||
Respects the existing global `prefers-reduced-motion` override (`*, *::before, *::after`
|
||
block already forces `animation-duration:.01ms` app-wide) with no extra code.
|
||
(3) **Anzugsmoment and km-correction popups now use `.aktion.primaer` on Speichern**, matching
|
||
the Setup and SmartDeal popups, which already distinguished their primary action — these two
|
||
were the only popups in the app stacking two visually-identical plain buttons.
|
||
All three verified live in `audi_ha_test` (version `1786916894`): spinner CSS confirmed via
|
||
`getComputedStyle` (16×16px, `border-radius:50%`, `animation-name:lade-spin`) since a genuine
|
||
race-condition-timed screenshot proved too fast to catch reliably; Speichern/Abbrechen
|
||
hierarchy confirmed via screenshot on both popups; error-sheet fix confirmed via the
|
||
monkey-patch test above. Synced to `installationspaket/`.
|
||
- [x] Eleventh round (2026-08-17) — "why can't I pull to refresh on Übersicht", investigated as a
|
||
real bug report rather than assumed working: live-tested the gesture by dispatching real
|
||
`PointerEvent`s through the exact `pointerdown`/`pointermove`/`pointerup` handlers in
|
||
`ereignisseVerdrahten()` (the `ptrY0`/`ptrDrag` logic) — every step fired correctly (drag
|
||
threshold, "Loslassen zum Aktualisieren" label swap, spinner, `datenAktualisieren()` call,
|
||
collapse), so the app's own JS/CSS was never the defect. Root cause instead: `main#view` (the
|
||
actual scroll container) had no `overscroll-behavior` set at all, so on a **real** touchscreen
|
||
the browser's own native overscroll/pull gesture can intercept or compete with the custom drag
|
||
before it reaches the threshold — this can't reproduce via desktop testing or synthetic pointer
|
||
events, only real touch hardware, which is exactly why the live test above passed while the
|
||
user's real phone didn't work. Fixed with `overscroll-behavior-y: contain` on `main#view` plus
|
||
`overscroll-behavior: none` on the outer `:host > div` wrapper, so a gesture leaving `main#view`
|
||
never chains up into the underlying Home Assistant page either. Re-ran the same synthetic
|
||
pointer-event test after deploying — gesture still fires identically end-to-end, confirmed via
|
||
`getComputedStyle` that `overscroll-behavior-y` computes to `contain`; no visual regressions,
|
||
no new console errors. Synced to `installationspaket/`. Not yet confirmed fixed on the user's
|
||
actual device — the code-level fix is the standard remedy for this exact symptom class, but ask
|
||
if it's still reproducing before assuming this is fully closed.
|
||
- [x] Beleg-Upload: Ziehen und Einfügen (2026-08-17) — "Beleg hochladen" opened the native file
|
||
picker directly, which is the wrong affordance for both of the user's actual routes: at the
|
||
computer the PDF should be draggable in, on the phone it arrives as a PDF copied out of a mail
|
||
and needs pasting. Replaced the immediate picker with a small dialog (`belegPopup` state,
|
||
`vBelegPopup()`, rendered into the existing `#overlay` alongside `sheetMarkup()`/
|
||
`vSetupPopup()`): a dashed drop area plus "Aus Zwischenablage einfügen" and "Datei auswählen"
|
||
(the old path, kept as the third route). The upload itself was extracted out of the click
|
||
handler into `belegDateiVerarbeiten()` so all three sources share one code path — including the
|
||
deliberate non-`serviceRufen()` error handling whose reasoning is unchanged (see its comment).
|
||
Notes worth keeping: `dragover` **must** `preventDefault()` or the browser just opens the PDF
|
||
itself and no `drop` ever fires; the drag/drop/paste listeners hang on `document`, not on the
|
||
drop zone, because the zone lives in the shadow DOM and a drag crossing its edge would
|
||
otherwise be lost — and because `render()` replaces the overlay's `innerHTML`, so per-element
|
||
listeners wouldn't survive. Phone pasting uses `navigator.clipboard.read()` (iOS shows its own
|
||
paste prompt) with the keyboard `paste` event as the desktop path. Non-PDFs are rejected with
|
||
an in-dialog message instead of being uploaded. Verified live in `audi_ha_test` by
|
||
instrumenting `hass.callService` and driving real `DragEvent`/`ClipboardEvent`/`KeyboardEvent`s:
|
||
drop → zone highlights (`darueber`), dialog closes, service called with the right filename and
|
||
base64 payload; paste → same; a dropped PNG → "Das war kein PDF." and no service call; Escape →
|
||
closes. Synced to `installationspaket/`.
|
||
**Nachbesserung am selben Tag, nach Fehlermeldung des Nutzers** ("copy paste from pc brings
|
||
error no pdf but it is the latest bill") — zwei echte Fehler, beide bestätigt:
|
||
(1) `navigator.clipboard.read()` (der "Aus Zwischenablage einfügen"-Knopf) gibt laut
|
||
Spezifikation **nur Text, HTML und Bilder** heraus. Eine im Explorer/Finder kopierte PDF-Datei
|
||
taucht dort grundsätzlich nicht auf - der Knopf konnte am Rechner also gar nicht funktionieren
|
||
und behauptete dann fälschlich, es liege kein PDF vor. Der Weg, der dort funktioniert, ist
|
||
Strg+V: das `paste`-Ereignis trägt die Datei in `clipboardData.files`. Die Meldung nennt jetzt
|
||
genau das, statt die Datei zu beschuldigen; die Ablagefläche sagt es von vornherein
|
||
("oder mit Strg+V einfügen", per `tastenkuerzelEinfuegen()` auf Mac Cmd+V).
|
||
(2) Die Erkennung hing allein am MIME-Typ (`type === "application/pdf"`) - eine aus dem
|
||
Explorer kopierte oder gezogene Datei kommt je nach Browser und Quelle aber mit **leerem**
|
||
`type` an und wurde dadurch abgelehnt. Neue Hilfsfunktion `istPdf()` prüft zusätzlich die
|
||
Dateiendung; live nachgestellt mit genau diesem Fall (`new File([...], "Tankbeleg_August.pdf",
|
||
{})`, `type === ""`) - vorher abgelehnt, jetzt korrekt hochgeladen.
|
||
(3) Auf Rückfrage des Nutzers ("why is it that way? computer has copy paste function") noch
|
||
einen Schritt weiter: Der Einfügen-Knopf wird am Rechner jetzt **gar nicht mehr angeboten**
|
||
(`zwischenablageKnopfSinnvoll()`, Prüfung auf `(pointer: coarse)`), weil er dort prinzipiell
|
||
nicht funktionieren kann - ein Knopf, der zwangsläufig in eine Fehlermeldung läuft, ist
|
||
schlechter als keiner. "Datei auswählen" wird dort zur primären Aktion; auf dem Telefon bleibt
|
||
alles wie zuvor. Der Unterschied dahinter, kurz: Strg+V ist eine bewusste Nutzergeste an die
|
||
Seite, deshalb bekommt die Seite den vollen Inhalt inklusive Dateien - `clipboard.read()` ist
|
||
die Seite, die von sich aus die Zwischenablage ausliest, und das wird aus Datenschutzgründen
|
||
stark eingeschränkt (sonst könnte jede Seite still mitlesen, was zuletzt kopiert wurde).
|
||
Live geprüft: Desktop zeigt nur noch "Datei auswählen [primaer]"/"Abbrechen", Mobil (375px,
|
||
`pointer: coarse`) weiterhin alle drei Knöpfe.
|
||
(4) **Auch Strg+V half am Rechner nicht** - vom Nutzer getestet und zurückgemeldet ("ctrl+v
|
||
doesnt work either, pc doesnt know where to paste"). Stimmt: Chrome stellt eine Einfüge-Geste
|
||
nur zu, wenn es ein Einfüge-Ziel gibt (Eingabefeld/contenteditable), und das Fenster hat
|
||
keines. Der Hinweis ist deshalb überall entfernt; am Rechner heißt es jetzt schlicht "PDF
|
||
hierher ziehen / oder unten auswählen", am Telefon "Beleg einfügen / das aus der Mail
|
||
kopierte PDF". Der `paste`-Listener bleibt als stille Zugabe für Browser, die das Ereignis
|
||
doch zustellen, wird aber nirgends mehr beworben; `tastenkuerzelEinfuegen()` wurde damit
|
||
verwaist und ist entfernt. **Am Rechner also bewusst nur zwei Wege: Ziehen und Dateiauswahl.**
|
||
- [x] Preis je Liter überall auf 2 Nachkommastellen (2026-08-17, Nutzerwunsch: "€/l for gas is
|
||
common to be displayed as x,xx €/l") — alle sieben Stellen von `de(..., 3)` auf `de(..., 2)`
|
||
umgestellt: Jahres- und Monatsschnitt in der Tankliste, Zeilenwert je Tankvorgang, "Preis je
|
||
Liter" im Einzelbeleg, die Live-Vorschau in beiden Tankformularen (statisch und im
|
||
`data-tankpreisfeld`-Handler) sowie die CSV-Spalte "€/l". Im CSV kein Informationsverlust: der
|
||
Wert ist aus den ebenfalls exportierten Spalten Liter und Kosten jederzeit neu berechenbar.
|
||
`companion-app` (Nachfolger-App) brauchte keine Änderung - deren `eur()` rundet ohnehin schon
|
||
auf 2 Stellen. Live geprüft: keine `de(..., 3)`-Aufrufe mehr in der Datei, Liste und
|
||
Einzelbeleg zeigen "2,07 €/l", "1,99 €/l" usw.
|
||
- [x] Twelfth round (2026-08-17) — six user-reported items, one of them a real dead-end in the
|
||
bootstrap:
|
||
(1) **`HECKKLAPPENSCHLOSS_SENSOR` removed for good**, the same treatment Türschloss/
|
||
Haubenschloss got on 2026-08-16: gone from `einstellungen.py` (attribute deleted, not
|
||
blanked), from `entitaeten.py`'s Setup catalog, and from `_sicherheitscheck()` in
|
||
`frontend_veroeffentlichung.py`. `HECKKLAPPE_SENSOR` (the lid itself) stays. Verified live:
|
||
"Geprüfte Punkte" no longer lists it, the Setup catalog no longer offers it.
|
||
(2) **Bild-Platzhalter in Einstellungen war halb abgeschnitten** — reproduced by measurement,
|
||
not by eye: the placeholder rendered 260px tall inside a 132px `overflow:hidden` box. Cause:
|
||
`bildMitPlatzhalter()` puts the size class (`carfix`) on the box, the `<img>` **and** the
|
||
placeholder `<div>`, and the desktop container query said `.carfix,.bildbox.carfix
|
||
{height:260px}` — the bare selector therefore also hit the placeholder, whose `position:
|
||
absolute;inset:0` is over-constrained by an explicit height (the height wins, `bottom:0` is
|
||
ignored). Scoped both rules to `.bildbox.carfix`; image and placeholder fill the box via
|
||
their own `100%` anyway. Verified: placeholder now measures exactly the box height.
|
||
(3) **Inspektion bekommt eine "voraussichtlich am …"-Zeile** wie der Ölwechsel, aber auf
|
||
Wunsch nur mit dem Datum. `oelwechselPrognose()` wurde dafür auf einen gemeinsamen Kern
|
||
`servicePrognose(stichwort, km, monate)` zurückgeführt; `inspektionPrognose()` nutzt
|
||
dieselben 30.000 km / 24 Monate wie `intervalle()`/`termine()`. Angezeigt in der
|
||
Service-Kachel auf "Mein Audi" (auch ohne Fahrzeugmeldung, anders als beim Ölwechsel - dort
|
||
nennt `terminText()` den generischen Termin schon "voraussichtlich", die Zahl stünde sonst
|
||
doppelt) und auf der Service-Seite. Live geprüft: beide Stellen zeigen "voraussichtlich am
|
||
20.10.2027", Ölwechsel unverändert.
|
||
(4)+(5) **"Neue Fahrt" hat jetzt alle Felder der Einzelfahrt, und die Einzelfahrt lässt sich
|
||
bearbeiten.** Neues gemeinsames `fahrtFelder()`/`fahrtFormularWerte()` (Datum, Startzeit,
|
||
Dauer, Art, Start-/Zielort, Start-/Endkilometer, Distanz) nach demselben Muster wie
|
||
`tankFelder()`/`tankFormularWerte()`, benutzt vom Erfassungsformular und vom neuen
|
||
"Werte bearbeiten" auf der Einzelfahrt-Seite (`fahrtBearbeitenOffen`, Vorbild `vFill()`).
|
||
Backend: `audi_dashboard_fahrt_manuell_anlegen` um dieselben Felder erweitert, neuer Service
|
||
`audi_dashboard_fahrt_aktualisieren`. Zwei Punkte, die dabei zählen: von Hand gesetzte Felder
|
||
landen in `edited_fields`, damit das Kilometerstand-Screening (§7.2) sie nicht später
|
||
überschreibt - und weil `_datensatz_aktualisieren()` genau diese Liste schützt, hätte ein
|
||
zweites Bearbeiten sonst nichts mehr bewirkt; dafür gibt es jetzt `profil.fahrt_bearbeiten()`
|
||
(`schutz=False`), der Schutz gilt gegen die Automatik, nicht gegen den Menschen. Fehlt die
|
||
Distanz, ergibt sie sich aus den beiden Kilometerständen (`km_quelle: "manuell"`, Status
|
||
"vollständig"). Gleich mitgezogen: die Art-Pille auf der Einzelfahrt schaltete bisher nur
|
||
lokal um und war nach dem nächsten Backend-Push wieder weg - sie ruft jetzt denselben
|
||
Service. **Merkposten pyscript:** Generator-Ausdrücke gibt es dort nicht
|
||
(`NotImplementedError: not implemented ast ast_generatorexp`, an `next((f for f in …), None)`
|
||
real gesehen) - Listen-Abstraktionen dagegen schon. Live end-to-end geprüft: anlegen,
|
||
bearbeiten, erneut bearbeiten, jeweils in `fahrten.jsonl` nachgesehen und nach Reload wieder
|
||
im Formular.
|
||
(6) **Titelzeile stand auf dem Handy 34px rechts von den Kacheln.** Derselbe Befund wie im
|
||
Design-Audit 2026-08-13, dort aber nur in der Desktop-Container-Query behoben: `.back` ist
|
||
auf Wurzelseiten unsichtbar (`opacity:0`), belegt aber Breite plus Gap. Die Regel
|
||
`.back:not(.on){width:0;…}` gilt jetzt auf allen Breiten und hebt per `margin-right:-4px`
|
||
auch den Gap auf; `.topbar` bekam links 16px statt 20px, passend zu `--seitenrand` von
|
||
`main#view`. Gemessen: Titel und Kachel beginnen auf dem Handy beide bei 16px, auf dem
|
||
Desktop beide bei 564px (dort blieben vorher 4px Rest). Detailseiten (`.back.on`) unberührt.
|
||
- [x] Ladebildschirm: der Takt darf nicht abreißen (2026-08-17) — Nutzerbericht "nach dem Neuladen
|
||
dreht sich nur 'Lädt …', erst ein Klick auf einen Menüpunkt zeigt die App". Beim Lesen des
|
||
Bootstraps fanden sich zwei echte Sackgassen, die genau dieses Verhalten erklären:
|
||
`nachladeAnstossen()` stieg bei `!HASS || !HASS.callWS` **ohne** neuen Timer aus, und
|
||
`datenLaden()` bei `!HASS` ebenso - danach lief nichts mehr von selbst, und der einzige
|
||
Ausweg war ein zufälliger hass-Push oder ein Menüklick (`go()` ruft `datenLaden()` mit auf,
|
||
genau die Abmilderung vom 2026-08-16). Beide planen jetzt die nächste Runde, statt
|
||
auszusteigen; `disconnectedCallback()` stoppt den Timer dafür sauber, `connectedCallback()`
|
||
stößt ihn wieder an. **Nicht reproduziert:** weder ein einfacher Reload noch ein
|
||
Container-Neustart mit sofortigem Reload haben den Hänger in `audi_ha_test` gezeigt (die App
|
||
war jedes Mal binnen ~2s da) - die Sackgassen sind im Code belegt, dass sie *die* Ursache
|
||
beim Nutzer waren, ist damit aber nicht bewiesen. Tritt es erneut auf: Browser-Konsole
|
||
erfragen, bevor weiter geraten wird.
|
||
- [x] Thirteenth round (2026-08-17, same day) — five more user-reported items on the trip form and
|
||
one theme bug found while verifying it:
|
||
(1) **"Dauer" replaced by "Ankunftszeit"; duration is no longer editable.** `fahrtFelder()`
|
||
now has Startzeit + Ankunftszeit inputs and a plain (non-input) duration display next to
|
||
them, computed by `fahrtZeitraum()`/`dauerText()` and kept live via a new
|
||
`data-fahrtzeitfeld` input handler (same "patch the one node, don't call render()" pattern
|
||
the existing `data-tankpreisfeld` handler uses, for the same reason: a full re-render would
|
||
steal focus from the field being typed in). An arrival time not later than the start time is
|
||
treated as crossing midnight (`+1 Tag`), matching how `_fahrt_beenden()` already handles a
|
||
real overnight trip. `fahrtFormularWerte()` derives `ts_start`/`ts_end` from the same
|
||
calculation - the backend never sees a separate duration field, so it can't disagree with
|
||
the times. Verified live: 09:00→10:45 shows "1 Std. 45 Min.", 23:30→00:15 shows "45 Min."
|
||
(crossed midnight correctly), and editing an existing trip's arrival time on the Einzelfahrt
|
||
page persisted the recalculated `duration_s` (7200s for a 10:00→12:00 edit, checked in
|
||
`fahrten.jsonl`).
|
||
(2) **Art values now display capitalized** ("Privat"/"Arbeitsweg") wherever shown to the user
|
||
- the trip-edit `<select>`, the trip-list row, and the Einzelfahrt pill - via a new
|
||
`artText()` helper; the stored value stays lowercase (`"privat"`/`"arbeitsweg"`), since it's
|
||
compared in several places (the pill, the "Arbeitsweg" statistic, the CSV export) and already
|
||
exists that way in every trip recorded so far. The CSV export itself was deliberately left
|
||
showing the raw value - a data file, not UI copy.
|
||
(3) **Arbeitsweg pill no longer red.** `.pill.work` (`color:var(--red)` / a translucent red
|
||
fill in the iOS overlay) is gone from both stylesheets along with the now-unused `work` class
|
||
on the button; the pill uses the same neutral styling regardless of Art, matching the request
|
||
that Arbeitsweg should look like Privat, not draw attention to itself.
|
||
(4) **"tippen zum Umschalten" hint removed** from under the Einzelfahrt pill, per explicit
|
||
request - the pill is still clickable, just without the caption.
|
||
(5) **Found while re-verifying the placeholder fix from round twelve: it broke daylight mode.**
|
||
`.bildbox.bild-fehlt .platzhalter{background:var(--tile)}` (added round twelve) reads fine in
|
||
Nacht theme, where `--tile` is a faint white overlay on a near-black canvas - but the iOS
|
||
overlay's Tag-theme block redefines `--tile:#FFFFFF`, identical to the canvas/most tiles
|
||
around it, so the placeholder box became invisible in daylight mode (measured live:
|
||
`background-color: rgb(255,255,255)` on both the placeholder and its surroundings). Switched
|
||
to `--ios-fill` (`rgba(120,120,128,.16)` in Tag theme, `.24` in Nacht) - the same token
|
||
`.feld select`/`.aktion` already use to mark "this is a surface", visibly grey in both themes.
|
||
Verified live in Tag theme: `background-color: rgba(120,120,128,.16)`, clearly a distinct box
|
||
against the white canvas (see screenshot taken during verification).
|
||
All five deployed to `audi_ha_test` (version `1787009000`) and synced to `installationspaket/`;
|
||
console showed only the pre-existing service-worker/404 noise from the missing vehicle photos,
|
||
nothing new.
|
||
- [x] Service-Seite: Prognose-Fussnote bricht nicht mehr um (2026-08-17) — Nutzermeldung, das Datum
|
||
der Ölwechsel-Prognose stehe in einer eigenen Zeile. Nachgemessen statt geschätzt: das
|
||
`<small>` in der rechten Spalte war 29px hoch bei 217px Breite (zwei Zeilen), die
|
||
Inspektions-Fussnote darunter mit kürzerem Text 14px (eine). Ursache ist nicht die Textlänge
|
||
allein, sondern der Platz: `.row` ist ein Flex mit `dt`/`dd`, und `dd` bekommt nur den Rest
|
||
neben dem Titel - für "9.029 km, voraussichtlich am 14.04.2027" bei 12,5px reicht das nicht.
|
||
Statt an Schriftgröße oder Text zu drehen, wandert die Fussnote jetzt als eigenes Element
|
||
(`.row-fussnote`) neben `dt`/`dd` und umbricht per `flex:1 0 100%` auf eine eigene Zeile über
|
||
die **volle** Kachelbreite - dieselbe Stelle, an der sie vorher schon optisch stand, nur ohne
|
||
die künstliche Breitenbegrenzung. Das `flex-wrap:wrap` ist bewusst mit `:has(.row-fussnote)`
|
||
auf genau diese Zeilen eingegrenzt, damit keine der übrigen `.row`-Verwendungen (17 Zeilen
|
||
allein auf "Mein Audi") ihr zweispaltiges Layout verliert. Die vorher verschachtelte
|
||
fm-/nicht-fm-Verzweigung im Markup ist dabei zu einem `hatMeldung`-Zweig für den Wert plus
|
||
einem separat berechneten `fussnote`-String zusammengefallen - die Anzeigelogik selbst
|
||
(Ölwechsel-Fussnote nur bei Fahrzeugmeldung, Inspektion immer) ist unverändert. Live geprüft
|
||
bei 375px und 1280px: beide Fussnoten 14px hoch (einzeilig), 307px bzw. 636px breit, linksbündig
|
||
auf der Kachelkante; alle 17 `.row` auf "Mein Audi" weiterhin zweispaltig; keine neuen
|
||
Konsolenfehler. Deployed als Version `1787003000`, synchron in `installationspaket/`.
|
||
- [x] **Gap found while auditing "what's needed for a real iPhone app" (2026-08-17), then closed
|
||
the same day:** `companion-app/src/screens/` hadn't been touched since 2026-08-11 — every
|
||
panel fix and feature from the six days since (HIG audit rounds, receipt upload, trip
|
||
editing, Inspektion forecast, ...) existed only in `homeassistant/www/audi-dashboard-app.js`.
|
||
User decision: port everything now, before any native build work (Android build itself
|
||
deprioritized/uncertain per the user - Paul has an Apple Developer Program and will help with
|
||
iOS signing). Audited both codebases field-by-field (not by diffing the panel's CSS/markup,
|
||
which doesn't transfer to `@audi-dash/ui` components) and found six real, mechanically
|
||
distinct gaps, all fixed:
|
||
1. **`AGM_RUHE_MAX_V` was still 13.2** in `Batterie.tsx` - lowered to 12.8, matching the
|
||
panel's 2026-08-16 fix (same rationale: above that is already alternator voltage).
|
||
2. **Dead `wlan_name` field** - `profilAdapter.ts` still read/wrote it and `Einstellungen.tsx`
|
||
showed it as an editable "WLAN des Fahrzeugs" field, even though WLAN-based trip detection
|
||
was fully removed from the backend on 2026-08-12 (replaced by FMM003 ignition). Editing it
|
||
had zero effect. Removed from the type, both directions of the adapter, the settings
|
||
screen, and both test fixtures.
|
||
3. **No Inspektion forecast** - `service.ts` only had `oelwechselPrognose()`. Refactored the
|
||
shared math into a `servicePrognose()` core (kept `oelwechselPrognose()`'s public
|
||
signature/behavior identical - all 9 existing tests still pass unchanged) and added
|
||
`inspektionPrognose()` (fixed 30.000 km / 24 Monate, no customizable interval like the oil
|
||
change has), wired into `Service.tsx` as a new "Eigene Prognose (Inspektion)" row. 3 new
|
||
tests.
|
||
4. **Arbeitsweg pill was red** - `Pill variant="work"` in `Fahrten.tsx`/`FahrtDetail.tsx`;
|
||
`@audi-dash/ui`'s own source comment says the variant literally "recolors border+text to
|
||
the accent red". Stopped passing the variant in both spots - same fix as the panel, no
|
||
change needed to the shared design-system component itself.
|
||
5. **Trip creation only took Beginn/Ende/Art** - the panel's fuller field set (Startort,
|
||
Zielort, Start-/Endkilometer, Distanz) didn't exist here. New shared
|
||
`src/screens/FahrtFelder.tsx` (type, defaults, backend-payload conversion, and the
|
||
presentational field group) used by both the create form (`Fahrten.tsx`) and a **new**
|
||
edit capability.
|
||
6. **`FahrtDetail.tsx` was entirely read-only** - no edit, no Art toggle after creation. Added
|
||
a "Bearbeiten" toggle reusing `FahrtFelder.tsx`, backed by a new `DataMetricApi
|
||
.fahrtAktualisieren()` (mirrors the existing `tankvorgangAktualisieren()` pattern) calling
|
||
the backend's `audi_dashboard_fahrt_aktualisieren` service (built this session on the panel
|
||
side, so the backend contract already existed and needed no changes).
|
||
**Explicitly investigated and found NOT to apply** (worth recording so it isn't re-attempted):
|
||
price-per-litre rounding (companion-app's `eur()` already always renders 2 decimals - the
|
||
3-decimal bug the panel had never existed here); pull-to-refresh `overscroll-behavior` (no
|
||
native pull gesture implementation here to fix); the panel's drag/paste/file-picker receipt
|
||
dialog (solves a **desktop browser clipboard** problem that doesn't exist in a Capacitor
|
||
native app - the plain `<input type=file>` already in `TankDetail.tsx` gets iOS's native
|
||
Files/Photos picker for free, which already covers "a PDF saved from Mail"); every
|
||
Setup-menu/entity-mapping feature (HA-panel-only concept, the companion app consumes the
|
||
backend's already-resolved published state and has no equivalent screen, nor should it);
|
||
the many purely cosmetic HIG-audit CSS fixes (icon fills, chevrons, tab-bar highlight
|
||
bugs, ...) - those target the panel's own hand-written markup/CSS, not
|
||
`@audi-dash/ui`'s components, and don't transfer.
|
||
Verified after every change: `npm run typecheck` (clean throughout), `npm run test` (95/95
|
||
passing, up from 90 - added interaction tests for the new create-form fields and the
|
||
edit-toggle pre-fill, plus 3 for `inspektionPrognose()`), `npm run build` (succeeds). Also
|
||
corrected a stale README claim ("Capacitor-Hülle und sichere Ablage" both listed as open) -
|
||
the secure-storage adapter is actually fully wired (`ablageNativ.ts`, activated from
|
||
`main.tsx`) and only the native `ios/`/`android/` platform folders themselves (deliberately
|
||
gitignored, regenerated via `npx cap add`) are the missing piece, and that step needs a Mac.
|
||
- [x] Übersicht: Kilometerstand-/Service-Kacheln neu gestaltet (2026-08-17) — Nutzer fragte, wie
|
||
Apple HIG und die Audi-CI das lösen würden. Befund: die Ölwechsel-Kachel presste zwei
|
||
verschiedene Größen in **ein** Zahlenfeld (`teile.join("/ ")` → `9,0 tkm/ 04/27`) — zwei
|
||
Einheiten, davon eine erfundene (**„tkm" schreibt niemand**, und die Mein-Audi-Servicebox
|
||
nannte dieselbe Zahl schon immer „9.029 km", d. h. dieselbe Tatsache war an zwei Stellen
|
||
unterschiedlich formatiert), zwei Schrägstriche mit zwei Bedeutungen, und auf 375 px
|
||
zweizeilig umgebrochen. Beide Regelwerke laufen hier auf dieselbe Auflösung hinaus, live
|
||
nachgelesen: Apple HIG *Layout* („Make essential information easy to find by giving it
|
||
sufficient space… don't obscure it by crowding it with nonessential details") und
|
||
*Typography* („Adjust font weight, size, and color… to help people visualize hierarchy");
|
||
die Audi-CI definiert `.fig` (Audi Type Wide 300) ausdrücklich als *„large numeric
|
||
figures"* — **eine** Zahl, nicht zwei. Umgesetzt: Wert und Kontext getrennt
|
||
(`9.029 km` groß, `vsl. 04/2027` als leisere Zweitzeile), echte Einheit statt „tkm", und
|
||
die Kachel ist jetzt ein `button.tile.tilebtn` mit `.go`-Chevron nach `service:x` — vorher
|
||
eine Sackgasse, obwohl die Service-Seite existiert. `mmjj()` wurde dadurch verwaist (nur
|
||
noch das volle Jahr wird gebraucht) und ist durch `mmjjjj()` ersetzt. Die HU-/Inspektions-
|
||
Zweige sind mit dem Ölwechsel-Zweig zusammengefallen, weil sie nach der Trennung dieselbe
|
||
Form haben. Live geprüft (Tag und Nacht, 375 px): beide Kacheln einzeilig, Tippen öffnet
|
||
Service mit aktivem Zurück-Pfeil.
|
||
**companion-app hatte diesen Fehler nicht** — dort standen die Termine schon als
|
||
Label/Wert/Zusatz-Zeilen. Stattdessen dort der benachbarte Gruppierungsfehler behoben: die
|
||
zwei Service-Zeilen hingen *innerhalb* der Kilometerstand-Kachel (zwei Termine unter einer
|
||
Überschrift, die nichts mit ihnen zu tun hat) und führten nirgendwohin. Jetzt eigene,
|
||
antippbare `Tile variant="button" chevron` → `service`. Geprüft: typecheck sauber, 95/95
|
||
Tests, Build erfolgreich.
|
||
- [x] Übersicht: Kilometerstand/Service statt zwei gleichrangiger Kacheln (2026-08-17) — Nutzer
|
||
mochte das Kachelpaar nicht und fragte, wie Apple HIG bzw. die Audi-CI es lösen würden. Beide
|
||
Regelwerke wurden dafür belegt, nicht aus dem Gedächtnis zitiert: die HIG-Seiten „Layout“
|
||
(„Differentiate controls from content“; Wichtigkeit über Position und Größe) und „Widgets“
|
||
(dynamische statt statischer Inhalte; „sparse layouts can make the widget seem unnecessary“)
|
||
live von developer.apple.com gelesen, die CI-Regeln aus dem projekteigenen
|
||
`AUDI_CI_ARCHIV_2026-08-13.md` (flache Flächen mit Haarlinien statt gestapelter Karten;
|
||
`.fig`/Audi Type Wide als eigene typografische Klasse für Zahlen; Hierarchie nur über Größe
|
||
und Farbe, da nur die Schnitte 300/400 existieren). Gemessener Befund: beide Kacheln
|
||
166×113px, gleiche 29px-Zahl, gleiche Farbe — obwohl nur die rechte ein Knopf war (erkennbar
|
||
allein an einem 12px-Chevron) und die beiden Zahlen Grundverschiedenes meinen (Ist-Wert vs.
|
||
täglich schrumpfender Countdown). Beide Schulen sagen dazu dasselbe, sie unterscheiden sich
|
||
nur im Mittel. Ergebnis nach drei Iterationen mit dem Nutzer (Zwischenstände jeweils als
|
||
Mockup gezeigt, bevor Code angefasst wurde): das `.duo`-Raster entfällt, stattdessen eine
|
||
führende Kennzahl in einer normalen `.tile` mit Hintergrund (erst flach entworfen, auf
|
||
Nutzerwunsch dann doch als Box) — Zeile „Nächster Service:“, Chevron oben rechts, 40px-Zahl,
|
||
Unterzeile „bis zum Ölwechsel · vsl. 15.04.2027“, Haarlinie, darunter der Kilometerstand als
|
||
`.servicezeile`. Der Chevron sitzt bewusst nur am `.serviceblock`-Knopf, nicht über der ganzen
|
||
Fläche: damit ist der obere Teil das Bedienelement und die Kilometerstand-Zeile reine
|
||
Anzeige (live geprüft — Klick auf den Block öffnet „Service“, Klick auf die Zeile navigiert
|
||
nicht). „Nächster Service“ meint auf Nutzerwunsch **jede** Serviceart; `naechsterTermin()`
|
||
wählte ohnehin schon über Ölwechsel/Inspektion/Hauptuntersuchung hinweg aus, neu ist nur die
|
||
Beschriftung samt `ART_BIS`-Zuordnung für den richtigen Artikel („bis zu**m** Ölwechsel“, aber
|
||
„bis zu**r** Inspektion“). Fällt kein Restkilometerwert an (Hauptuntersuchung hat kein
|
||
km-Ziel), rückt das Datum selbst ins Zahlenfeld, dann bei 30px statt 40px, damit „15.04.2027“
|
||
auf 375px nicht umbricht. Von der Änderung verwaist und deshalb mitentfernt: `mmjjjj()` (nur
|
||
hier benutzt) und die `.duo`-Regeln in beiden Stylesheets. **Bewusst offen gelassen:** der
|
||
Statuspunkt aus der HIG-Variante („alles ruhig“ vs. „bald fällig“) — der Nutzer hat ihn nicht
|
||
übernommen. **Bewusst nicht entschieden von mir:** das volle Tagesdatum statt „04/2027“ war
|
||
Nutzerwunsch; angemerkt wurde dabei, dass die Prognose an einem einzigen Arbeitstag von
|
||
12.04. über 14.04. auf 15.04.2027 wanderte (`oelwechselPrognose()` extrapoliert aus km/Tag,
|
||
jede Odometer-Aktualisierung verschiebt sie), das Tagesdatum also mehr Genauigkeit verspricht,
|
||
als der Wert hat. Live geprüft bei 375px und 1280px: Block und Wertezeile fluchten mit der
|
||
Kachelkante (16→359 bzw. 564→1236), Chevron bündig rechts, Tippfläche 88px hoch, keine neuen
|
||
Konsolenfehler. Deployed als Version `1787007000` (zwischenzeitlich auf Nutzerwunsch von
|
||
`.tile.flat` auf eine normale `.tile` mit Hintergrund umgestellt — `.serviceblock:active
|
||
{opacity:.55}` als Tipp-Rückmeldung ergänzt, da der übliche `.tilebtn`-Hintergrundwechsel
|
||
hier nicht geht), synchron in `installationspaket/`. Entwurf und Begründung als Artefakt:
|
||
„Zwei Kacheln, drei Antworten“.
|
||
- [x] Nächster-Service-Box: CI-Icon statt "bis zum/zur ..."-Wortlaut (2026-08-18) — der Nutzer
|
||
lehnte zunächst einen Folge-Entwurf ab, der dieselbe HIG/CI-Systematik auf die "Mein Audi"-
|
||
Service-Vorschau und "Anstehende Termine" auf der Service-Seite anwenden sollte ("I don't like
|
||
it" → per Rückfrage: "Lost information", also zu viel weggelassen) — dieser Vorschlag wurde
|
||
**nicht** umgesetzt, keine Codeänderung daraus übernommen. Stattdessen ein präziser, neuer
|
||
Wunsch für die bereits bestehende Nächster-Service-Box auf der Übersicht: das Wort
|
||
("bis zum Ölwechsel"/"bis zur Inspektion") vor der Kennzahl entfällt zugunsten der echten Audi-
|
||
CI-Icons `inspection-l/-s`/`oil-change-l/-s` (vom Nutzer als SVG geliefert, aus
|
||
`C:\Users\tobia\Downloads\Icons 2026-08-09 13_04_03\`, verbatim übernommen wie die übrigen
|
||
CI-Icons dieses Projekts — l im 48er-, s im 24er-Grid), links der Zahl platziert; auf
|
||
Nutzerwunsch anschließend nochmal um 30% vergrößert (24px → 31px). Umgesetzt in beiden
|
||
Codebasen: Panel bekam `CI.oilChangeL/-S`/`CI.inspectionL/-S` (neben den bestehenden CI-
|
||
Icon-Konstanten) und ein neues `.fig-zeile`-Wrapper-Div (`display:flex;align-items:center`)
|
||
um Icon + `.fig` in `vHome()`'s Serviceblock; die Nebenzeile zeigt jetzt nur noch
|
||
`vsl. DATUM`. Companion-app bekam die identischen Pfade als neue `SymbolOelwechsel`/
|
||
`SymbolInspektion`-Komponenten in `symbole.tsx` (eigener `RahmenFlaeche`-Wrapper, da diese
|
||
zwei — anders als die generischen Tab-Icons dort — gefüllte Flächen-Icons sind, kein
|
||
stroke-Wrapper) und eine neue `.dm-servicefig`-CSS-Klasse in `screens.css`; `Uebersicht.tsx`
|
||
wählt das Symbol über eine kleine `SERVICE_SYMBOL`-Tabelle nach `service.art`. **Wichtig:** die
|
||
Icons blieben bewusst außerhalb von `design-system/` (Lizenzregel oben — echte Audi-CI-Assets
|
||
dürfen nur in Panel/companion-app, nicht in das markenfreie `@audi-dash/ui`) — die Verdrahtung
|
||
liegt deshalb direkt im jeweiligen Screen, nicht in der gemeinsamen `Fig`-Komponente. Die
|
||
Hauptuntersuchung hat kein eigenes Icon (kein Restkilometer-Ziel, `restKm` ist dort immer
|
||
`null`) und behält `bisText()` als einzige Kennzeichnung — das deckt sich in beiden Codebasen
|
||
genau mit der Bedingung, unter der ein Icon überhaupt existiert. `npm run typecheck`/
|
||
`npm run test` (100/100)/`npm run build` sauber. Live im Docker-Testcontainer geprüft (Version
|
||
`1787012001`, synchron in `installationspaket/`): Ölwechsel-Icon rendert links der "8.999 km"-
|
||
Kennzahl, Unterzeile zeigt nur noch "vsl. 18.04.2027", keine neuen Konsolenfehler.
|
||
- [x] Nächster-Service-Box: Hauptuntersuchung-Datum in Zahlengröße + Art-Name statt "bis zur ..."
|
||
(2026-08-18) — Folgewunsch zum Icon-Umbau: das Datum im Zahlenfeld war für die
|
||
Hauptuntersuchung (kein Restkilometer-Ziel) bisher kleiner gesetzt (30px statt 40px), extra
|
||
damit lange Daten wie "15.04.2027" auf 375px nicht umbrechen; der Nutzer wollte stattdessen
|
||
dieselbe 40px-Größe wie bei Ölwechsel/Inspektion, plus darunter statt "bis zur
|
||
Hauptuntersuchung" nur noch das Wort "Hauptuntersuchung" selbst. Vor der Umsetzung am
|
||
laufenden Panel nachgemessen (nicht angenommen): "15.04.2027" misst bei 40px/Audi Type Wide
|
||
182px, der Kachel-Container hat 322px — die ursprüngliche Wrap-Sorge, die zur 30px-Regel
|
||
geführt hatte, bestätigt sich bei den tatsächlichen Container-Maßen nicht. `zahlGroesse` ist
|
||
jetzt fest 40 (Panel `vHome()` und companion-app `Uebersicht.tsx`, `Fig size={40}`
|
||
unabhängig von `restKm`). Da `bisText()`/`ART_BIS` (Panel) und `bisText()`/`BIS_TEXT`
|
||
(companion-app `service.ts`) dadurch ihre einzige Aufrufstelle verloren, wurden beide als
|
||
Orphans entfernt (inkl. des zugehörigen companion-app-Tests in `service.test.ts` — von 100 auf
|
||
99 Tests, kein Funktionsverlust, nur der jetzt toten Funktion beraubt). Live im
|
||
Docker-Testcontainer per DOM-Patch simuliert (Testfixture hat aktuell Ölwechsel als
|
||
nächstgelegenen Termin, nicht die Hauptuntersuchung) und bei 390px Breite screenshotet:
|
||
"06.08.2028" einzeilig bei 40px, "Hauptuntersuchung" darunter, kein Icon (wie vorgesehen — es
|
||
gibt kein CI-Icon für die Hauptuntersuchung). `npm run typecheck`/`npm run test`
|
||
(99/99)/`npm run build` in companion-app sauber. Deployed als Panel-Version `1787012002`,
|
||
synchron in `installationspaket/`.
|
||
- [x] "Anstehende Termine"-Box (Service-Seite) umgebaut: drei sichtbar getrennte Blöcke
|
||
(2026-08-18) — auf Wunsch des Nutzers ein neuer Entwurf (Apple HIG + Audi CI, wie beim
|
||
Übersicht-Umbau), mit expliziten Vorgaben: Herstellervorgabe bleibt für Ölwechsel und
|
||
Inspektion möglich, "voraussichtlich ..." bleibt für beide erhalten, alle drei Arten werden
|
||
sichtbar getrennt, die Hauptuntersuchung bekommt bewusst weder Herstellervorgabe noch
|
||
Prognosezeile. Durchlief drei Mockup-Runden als Artefakt ("Drei Termine, drei Blöcke") vor der
|
||
Umsetzung: **Runde 1** abgelehnt ("Major Information is missing"). **Runde 2** ergänzte ein
|
||
vollständiges Feld-Inventar (Tabelle: jedes Element aus `vService()` mit Fundstelle im
|
||
Vorschlag), integrierte den vereinbarten Werkstatttermin in den Hauptvergleich statt als
|
||
Anhang, zeigte den "kein Eintrag im Servicebuch"-Zustand. **Runde 3** (auf drei
|
||
Inline-Kommentare des Nutzers im Artefakt): Ölwechsel-Icon von 20px auf 26px angehoben (füllt
|
||
in seinem 24×24-Raster nur eine 24×10-Fläche statt der fast vollen Höhe des
|
||
Inspektion-Icons — per `getBBox()` nachgemessen, kein exakter Ausgleich möglich ohne das Icon
|
||
unverhältnismäßig breit zu machen), Herstellervorgabe-Zeile auch am Ölwechsel-Beispiel gezeigt
|
||
(vorher nur bei Inspektion), der Hauptuntersuchungs-Block von einem Icon-Platzhalter-Abstand
|
||
auf echt linksbündig geändert. Danach vom Nutzer freigegeben ("Ok, implement it").
|
||
**Umsetzung, beide Codebasen:**
|
||
Panel (`vService()`, `audi-dashboard-app.js`): `<dl class="rows">` durch drei
|
||
`<div class="termingruppe">` ersetzt (Kopfzeile mit Icon + Art-Name + optionalem
|
||
`.tg-chip` für einen vereinbarten Werkstatttermin, optionale `.tg-vorgabe`-Zeile, `.tg-wert`
|
||
als 22px/Gewicht-300-Zahl, optionale `.tg-prognose`-Fußzeile) — Auswahllogik für Wert/
|
||
Fußzeile/Herstellervorgabe unverändert aus dem bisherigen Code übernommen, nur die Markup-
|
||
Struktur geändert. `ART_ICON`/`ART_ICON_GROESSE` (26px Ölwechsel, 20px Inspektion) von
|
||
`vHome()` auf Modulebene gehoben, da jetzt von zwei Stellen genutzt — `vHome()`s eigene
|
||
Icon-Größe (31px, beide gleich) bewusst unverändert gelassen, das ist eine offene, dem Nutzer
|
||
gestellte Rückfrage. Der Werkstatt-Chip nutzt `var(--red)` nur als Text-/Rahmenfarbe, keine
|
||
Füllung — deckt sich mit der bereits bestehenden Projektregel "Rot ist Akzent, nie Fläche"
|
||
(siehe iOS-Overlay-Fix vom 2026-08-13). Deployed als Version `1787013000`, synchron in
|
||
`installationspaket/`, live im Docker-Testcontainer per direkter DOM-Abfrage geprüft (JS statt
|
||
Screenshot, da die Browser-Pane in dieser Sitzung zeitweise nicht kompositierte): alle drei
|
||
Blöcke rendern mit korrektem Text, Icons in 26px/20px, Hauptuntersuchung ohne Icon, keine
|
||
neuen Konsolenfehler.
|
||
companion-app (`Service.tsx`): dieselbe Drei-Blöcke-Struktur, aber eine **echte
|
||
Neustrukturierung, kein 1:1-Kopieren** — die bisherige Kachel hieß "Nächster Ölwechsel" und
|
||
mischte Ölwechsel-, Inspektions- und Hauptuntersuchungs-Zeilen fachfremd in einer gemeinsamen
|
||
`Werteliste`; jetzt "Anstehende Termine" wie im Panel, mit denselben drei `.dm-termingruppe`-
|
||
Blöcken. Neue `SymbolOelwechsel`/`SymbolInspektion`-Komponenten in `symbole.tsx` (bereits für
|
||
die Übersicht angelegt, hier wiederverwendet). Neuer Export `letzterInspektion()` in
|
||
`service.ts` (Gegenstück zu `letzterOelwechsel()`) für den "kein Eintrag"-Zustand der
|
||
Inspektion. **Zwei bewusste, dokumentierte Vereinfachungen gegenüber dem Panel** (vorbestehende
|
||
Lücken, nicht durch diese Änderung verursacht): (1) companion-app hat keine "Termin
|
||
vereinbaren"-Funktion und keine `vereinbart`-Datenfelder für Service-Termine — der Werkstatt-
|
||
Chip entfällt deshalb ersatzlos, es gibt nichts anzuzeigen. (2) companion-app hat kein
|
||
Gegenstück zu `intervalle()`/dem generischen Herstellerintervall-Fallback des Panels; die
|
||
Herstellervorgabe-Zeile wird stattdessen direkt aus `fahrzeug.oel.modus` abgeleitet (Ölwechsel:
|
||
nur bei `modus === "hersteller"`, Inspektion: immer, da sie kein einstellbares Intervall hat —
|
||
deckt sich inhaltlich mit dem Panel-Verhalten, auch ohne dieselbe Funktion zu teilen). Beide
|
||
Punkte sind funktional gleichwertig zum bisherigen companion-app-Stand, nicht neu eingeführte
|
||
Lücken. `npm run typecheck` sauber, `npm run test` 99/99, `npm run build` erfolgreich; die
|
||
bestehenden "Alle Seiten rendern mit Beispieldaten"-Tests rendern die Service-Seite weiterhin
|
||
ohne Absturz. Kein Live-Screenshot in dieser Sitzung (bräuchte einen laufenden Dev-Server samt
|
||
Backend-Anbindung) — strukturell verifiziert über Typecheck/Tests/Build.
|
||
**Zwei Nachbesserungen direkt nach der ersten Live-Prüfung, beide vom Nutzer gemeldet:**
|
||
(1) "Herstellervorgabe" fehlte beim Ölwechsel, obwohl der angezeigte Wert (29.100 km /
|
||
25.07.2028) vom Fahrzeug selbst gemeldet wird (`hatMeldung`/`fahrzeug.oelwechselFaelligTs`).
|
||
Root Cause: die Zeile war bisher ausschließlich an `iv.hersteller` (Panel) bzw.
|
||
`fahrzeug.oel.modus === "hersteller"` (companion-app) gekoppelt — dem *App-eigenen*
|
||
Intervall-Modus aus "Einrichten". Der Bordcomputer kennt diesen App-Modus aber gar nicht;
|
||
eine Fahrzeugmeldung stammt immer aus dem werkseigenen Wartungsprogramm und ist damit per
|
||
Definition Herstellervorgabe, unabhängig vom gewählten App-Modus. Das Testprofil hat den
|
||
Ölwechsel aktuell auf `modus: "eigen"` stehen (10.000 km/12 Monate statt 30.000 km/24
|
||
Monate) — das war korrektes Verhalten der alten Bedingung, nur die Bedingung selbst war zu
|
||
eng. Fix: `herstellervorgabe = hatMeldung || iv.hersteller` (Panel) bzw.
|
||
`oelHatMeldung || fahrzeug.oel.modus === "hersteller"` (companion-app) — dieselbe alte
|
||
Bedingung bleibt für den Fall ohne Fahrzeugmeldung erhalten (App-eigener Fallback auf das
|
||
Herstellerintervall), kommt jetzt aber zusätzlich bei einer echten Fahrzeugmeldung zum
|
||
Tragen. (2) Die Prognose-Fußzeile zeigte "8.999 km, voraussichtlich am 18.04.2027" auch dann,
|
||
wenn in "Einrichten" tatsächlich Herstellervorgabe gewählt ist — dort rechnet die eigene
|
||
Prognose ohnehin schon mit dem Herstellerintervall, die Restkilometerzahl wäre also nur eine
|
||
zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert direkt darüber. Fix: die "X km,"-
|
||
Vorsilbe entfällt, sobald `CAR.oel.modus`/`fahrzeug.oel.modus === "hersteller"` ist — nur beim
|
||
eigenen (kürzeren) Intervall bleibt sie stehen, weil sie dort echte Zusatzinformation ist
|
||
(matcht damit exakt, wie die Inspektion-Fußzeile schon immer aussah, da sie kein einstellbares
|
||
Intervall hat). Mit dem Testprofil (`modus: "eigen"`) bleibt die Kilometerzahl also weiterhin
|
||
sichtbar — korrekt, das ist genau der Fall, für den sie gedacht ist; umschalten auf
|
||
Herstellervorgabe in "Einrichten" würde sie ausblenden. Beide Fixes deployed als Panel-Version
|
||
`1787013001`, synchron in `installationspaket/`, live per DOM-Abfrage geprüft: Ölwechsel-Block
|
||
zeigt jetzt "Herstellervorgabe", keine neuen Konsolenfehler. companion-app: `npm run typecheck`
|
||
sauber, `npm run test` 99/99, `npm run build` erfolgreich.
|
||
- [x] **Parity rule eingeführt (2026-08-17):** der Nutzer stellte klar, dass Panel und
|
||
`companion-app/` immer denselben funktionalen Stand haben sollen — auch wenn eine Anfrage nur
|
||
das Panel nennt, das ist reiner Testkomfort (Docker-Instanz), keine Scope-Entscheidung. Regel
|
||
als verbindlicher Absatz direkt unter der Maintenance-Regel oben in dieser Datei verankert.
|
||
Als erste Anwendung sofort im selben Zug die Übersicht-Neugestaltung von oben nach
|
||
`companion-app/` portiert (siehe `Uebersicht.tsx`): `naechsterService()` in `daten/service.ts`
|
||
neu — Gegenstück zu `naechsterTermin()` im Panel, wählt über Ölwechsel-/Inspektions-Prognose
|
||
und die von Hand gepflegte Hauptuntersuchung hinweg den zeitlich nächsten Termin, `bisText()`
|
||
liefert denselben Artikel wie `ART_BIS` im Panel. Die beiden bisherigen Kacheln
|
||
"Reichweite"/"Kilometerstand" nebeneinander plus eine separate "Service"-Kachel mit
|
||
Werteliste wichen einer Reichweiten-Kachel plus einer Service-Kachel mit `.dm-serviceblock`
|
||
(Knopf, nur der obere Teil) und `.dm-servicezeile` (reine Anzeige) darunter — dieselbe
|
||
Control/Content-Trennung wie im Panel. `npm run typecheck` blieb sauber, `npm run test` auf
|
||
100/100 (von 95 — vier neue Tests für `naechsterService()`/`bisText()` in `service.test.ts`,
|
||
einer in `screens.test.tsx`, der mit einem `vi.fn()` als `geheZu` wirklich belegt, dass ein
|
||
Klick auf den Serviceblock navigiert und ein Klick auf die Kilometerstand-Zeile es nicht tut
|
||
— dafür musste `zeige()` in `screens.test.tsx` erst einen injizierbaren `geheZu`-Parameter
|
||
bekommen, vorher war er hart auf `() => {}` verdrahtet), `npm run build` erfolgreich.
|
||
- [ ] Fix remaining documentation drift (statistics claim, README gaps, obsolete TODO comment) —
|
||
text-only changes; INSTALL.md's WLAN/TommiG1 drift and stale variable names were fixed
|
||
2026-08-12 (see section B); `DESIGN_REVIEW_2026-08-13.md` and `REVIEW_main_2026-08-13.md`
|
||
list further drift not yet fixed (WLAN references still in `SPECIFICATION.md`,
|
||
`homeassistant/README.md`, the profile template)
|
||
- [x] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json` — done on the
|
||
`umsetzung-datametric360` branch (2026-08-11), brought over by the 2026-08-13 merge. Returns
|
||
`None` on a missing or malformed file instead of raising; all 5 call sites
|
||
(`backup.py` ×2, `fahrterkennung.py`, `frontend_veroeffentlichung.py`, `reifenzaehler.py`,
|
||
`tankerkennung.py`) already guard for `None` — verified by grep across the merged tree, no
|
||
caller left unguarded (this was flagged as the merge's main risk in `REVIEW_main_2026-08-13.md`
|
||
§"Was den Branch betrifft").
|
||
- [x] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360 — decided
|
||
2026-08-11 (`UMSETZUNGSPLAN.md` Phase 2): swipe-delete keyboard fallback, popup keyboard
|
||
access, and self-hosting Leaflet stay unfixed in this panel and are addressed only in
|
||
DataMetric360.
|
||
- [ ] 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/`, set `steuer.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 `.notiz` CSS class
|
||
pattern used elsewhere (see `feld.notiz` in `audi-dashboard.css`) that could apply here
|
||
- [ ] Redesign the "Versicherung/Steuer" Übersicht/summary box — **owner explicitly said they don't
|
||
yet know what they want here**; needs a requirements conversation (what should be surfaced at a
|
||
glance vs. moved to detail screens) before any mockup, not just a straight implementation
|
||
|
||
Not planned/scoped in either codebase yet — start with clarifying questions on the PDF storage model
|
||
and the summary-box redesign goals before touching code, per this project's mockup-first convention
|
||
for open-ended redesign asks (see the "Anstehende Termine" 3-round mockup process, section C).
|
||
|
||
### F) Retroactive data import from the HA recorder (built 2026-08-23)
|
||
|
||
**The retention answer, because it was the owner's actual question.** Two different lifetimes were
|
||
being conflated:
|
||
|
||
- **The app's own records live forever already.** `fahrten.jsonl`, `tankvorgaenge.jsonl`,
|
||
`batteriespannung.jsonl` and `fahrzeugprofil.json` under `/config/audi_dashboard/` have no purge
|
||
path anywhere in `profil.py` — nothing trims them, ever. That was already true before this change.
|
||
- **HA's raw sensor history did not.** `recorder.purge_keep_days` defaults to **10 days**, and the
|
||
test instance had no `recorder:` block at all. That default was the real ceiling: it caps how far
|
||
back *any* reconstruction can ever reach, because deleted recorder rows are gone permanently.
|
||
|
||
`homeassistant/recorder_snippet.yaml` (new) raises it to 365 days with `auto_repack: true`. Measured
|
||
on the test instance rather than guessed: 90.858 state rows over 17 days ≈ 5.300/day, 64,7 MB DB →
|
||
roughly 1,9 M rows and 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()` in `fahrterkennung.py`) after the fact; odometer, GPS start/end filled from the
|
||
other histories; stretches under 60 s dropped as ignition-without-a-drive
|
||
- refuels from the tank sensor using the same low-water-mark logic and the same thresholds as
|
||
`tankerkennung.py` (5 l / 9 pp, whichever is more sensitive)
|
||
- daily min/max battery voltage, same shape as `batterieverlauf.py`
|
||
|
||
Re-running is safe by design: trips whose window overlaps an existing trip are skipped (whatever
|
||
their origin), refuels dedupe on a 90-minute window, battery days merge in `profil`'s own
|
||
min/max update. Created records carry `source: "import"` alongside the existing `ha`/`manual`/`auto`.
|
||
|
||
**How the history is read, and why.** Via HA's own
|
||
`homeassistant.components.recorder.history.get_significant_states` through `task.executor()`, not by
|
||
opening `home-assistant_v2.db` with sqlite3 directly. Direct SQL was the tempting option
|
||
(`allow_all_imports` is already on, no config change needed) but the recorder schema is HA-internal
|
||
and changes between versions; the function is the interface HA itself uses. The cost is one new
|
||
config line, **`hass_is_global: true`** in the pyscript block (`configuration_snippet.yaml`), without
|
||
which the `hass` name does not exist in pyscript — everything else in the app works fine without it,
|
||
only the import reports that it cannot read the history. `significant_changes_only=False` matters:
|
||
the default mode drops exactly the small numeric steps that odometer distance and refuel detection
|
||
are built from.
|
||
|
||
**Frontend, both codebases.** Panel: `vImportPopup()` reusing the existing `.beleg-popup` overlay
|
||
pattern, opened from a new button under Einstellungen → Einrichten, with Von/Bis
|
||
`datetime-local` fields defaulting to the last 30 days, "Importieren" primary and "Abbrechen"
|
||
second. The dialog stays open and shows the result rather than closing optimistically — the one
|
||
place in the app where that is right, because the result *is* the point of the action. Progress
|
||
comes from polling `pyscript.audi_dashboard_import_status` (same pattern as the update status),
|
||
since pyscript services return immediately while the work continues in the background.
|
||
Companion: `screens/HistorienImport.tsx` using the design system's `Popup variant="form"`, plus
|
||
`api.historieImportieren()` / `api.importStatusLesen()`. Deliberately **not** routed through the
|
||
offline queue like every other write: the import is a long-running operation whose result is the
|
||
reason you invoked it, and a queued run firing later from a dead session would surprise the user —
|
||
offline, it simply should not be offered.
|
||
|
||
**One real bug found and fixed while testing:** `profil.batterieverlauf_tageswert_aktualisieren()`
|
||
appended in insertion order. That was invisible while only the live path wrote it (it always adds
|
||
today, i.e. always the newest), but the import adds *past* days — they landed after newer ones and
|
||
the frontend chart, which draws in file order, would have run backwards. It now sorts by date,
|
||
restoring the invariant its own docstring already promised.
|
||
|
||
**Verification.** The test container is not running while the car is driven (owner's note), so its
|
||
recorder holds no real trips. Instead a synthetic history was seeded straight into the recorder DB —
|
||
two drives, one with a 5-minute mid-drive ignition gap, one 30-second ignition burst, a two-step
|
||
refuel, and two days of voltage — and the import reproduced it exactly: 3 trips (the gap correctly
|
||
merged into one 50-minute/40 km trip), 1 rejected as too short, 1 refuel, 2 battery days, distances
|
||
matching the seeded odometer to the kilometre. A second run over the same window created 0 and
|
||
reported 3 as already present, confirming dedup. Both runs were driven through the real UI in the
|
||
browser, not by calling the service directly. `tsc --noEmit` clean, companion tests 106/106 (6 new
|
||
for the result-sentence pluralisation), `vite build` clean, HA config check and startup clean.
|
||
|
||
**Still open / worth knowing:** the import has only ever run against seeded data, never against a
|
||
real multi-week recorder history on the production instance — the first real run is the honest test,
|
||
especially for how long a full year takes (the UI waits up to two minutes before saying it continues
|
||
in the background). Address reverse-geocoding is not attempted for imported trips (`start_address`/
|
||
`end_address` stay null, same as live-detected ones before the user edits them).
|
||
|
||
### H) HACS: the pyscript backend became a real integration (2026-08-23)
|
||
|
||
Owner asked for it directly ("build the hacs") after deferring it earlier the same day. The
|
||
deferral reasoning still held — converting first means debugging the rewrite and the real
|
||
environment at once — but the owner overrode it, so the conversion happened.
|
||
|
||
**What was actually blocking HACS.** Two independent things, both checked against the installed
|
||
HACS 2.0.5 rather than from memory:
|
||
|
||
- **No HACS category installs to `/config/pyscript/`.** The six categories resolve to
|
||
`custom_components/<domain>/`, `www/community/<name>/`, `python_scripts/`, `themes/`,
|
||
`custom_templates/` and `appdaemon/apps/<name>/` (verified in `repositories/*.py` → `localpath`).
|
||
The app needed `/config/pyscript/` **and** `/config/audi_dashboard/` **and** `configuration.yaml`
|
||
entries. This is what the conversion solved.
|
||
- **HACS is GitHub-only, and does not work here — permanently, not just today.** `github.com`/
|
||
`api.github.com` are hardcoded throughout; the repo lives on `gitea.nothaft.cloud`. Briefly
|
||
mirrored to `github.com/T130B/DM360` on 2026-08-24, but that changes nothing: **HACS
|
||
categorically refuses private repositories**, confirmed against its own docs
|
||
(hacs.xyz/docs/faq/private_repositories: "Private GitHub repositories can not be used with HACS
|
||
at all... HACS can only get publicly available information") — no token, no signed-in-account
|
||
exception. An earlier note in this file claimed the opposite ("HACS must be signed in with the
|
||
account that owns it"); that was wrong and is corrected here. The repo is **private on purpose**
|
||
(it carries the Audi typeface and the model badges, licensed for this one private install only),
|
||
so making it public to satisfy HACS is not on the table — and since the mirror bought nothing
|
||
(HACS still can't see a private repo, mirrored or not), the owner decommissioned it the same day.
|
||
**Development is Gitea-only from here on; there is no GitHub presence.** `hacs.json` was removed
|
||
(dead weight with no GitHub target to serve), and `manifest.json`'s `documentation`/
|
||
`issue_tracker`/`codeowners` point at the Gitea repo instead.
|
||
`homeassistant/installationspaket/install.ps1` is not a fallback — it is **the only installation
|
||
path**, and the docs (`README.md`, `homeassistant/INSTALL.md`, `installationspaket/ANLEITUNG.md`,
|
||
`VERSIONIERUNG.md`, the script's own header) say so. Don't reintroduce a GitHub mirror or
|
||
`hacs.json` without the owner raising it again — the private/public constraint that killed this
|
||
path hasn't changed and isn't expected to.
|
||
|
||
**The shape.** `custom_components/audi_dashboard/` at repo root — not for HACS's benefit (see
|
||
above, HACS is off the table), just where `install.ps1` and Home Assistant itself expect a
|
||
`custom_components` entry to live. Config flow, one
|
||
`sensor` platform, 18 services, ~2,900 lines of pyscript ported to ~2,400 lines of integration.
|
||
`koordinator.py` holds the runtime state and does the wiring that `@service`/`@state_trigger`/
|
||
`@time_trigger` used to do implicitly; the domain modules keep their old names and structure so the
|
||
diff stays readable. `modules/` was flattened (it only existed because pyscript treats that folder
|
||
specially). Three renames earned their keep: `profil.py` → `ablage.py` (it handles trips and
|
||
fill-ups too, the old name was actively wrong), `entitaeten.py` → `zuordnung.py` (avoids collision
|
||
with HA "entities"), `frontend_veroeffentlichung.py` → `veroeffentlichung.py`.
|
||
|
||
**Five install steps disappeared, and none of them by hand-waving:**
|
||
|
||
| Was | Now |
|
||
|---|---|
|
||
| `pyscript:` block with `allow_all_imports` + `hass_is_global` | gone — no YAML at all |
|
||
| `panel_custom:` block | the integration registers its own panel |
|
||
| copy the frontend to `/config/www/` | served from `frontend/` under `/audi_dashboard_static/` |
|
||
| long-lived token in `audi_dashboard/ha_token.txt` | recorder API read directly (`verlauf.py`) |
|
||
| `pip install pypdf` in the container | `requirements` in `manifest.json`, HA installs it |
|
||
| copy `fahrzeugprofil.example.json` by hand | seeded from `vorlage/` on first setup |
|
||
|
||
Verified on a genuinely fresh throwaway container, not reasoned about: 12 entities, zero trips,
|
||
zero fill-ups, zero battery days, profile created from the template, and **pypdf 6.16.2 installed
|
||
by HA itself**.
|
||
|
||
**The naming contract changed, and both frontends had to follow.** `pyscript.audi_dashboard_x` →
|
||
`sensor.audi_dashboard_x`, `pyscript.audi_dashboard_y` → `audi_dashboard.y`. The prefix only ever
|
||
existed because pyscript owned the domain. Both frontends now carry the names in one table each
|
||
(`const.py` ↔ `frontend/audi-dashboard-app.js` `E`/`DOMAIN` ↔ `companion-app/src/api/types.ts`
|
||
`ENTITAETEN`/`DIENST_DOMAIN`). **A companion app from before this change finds nothing after the
|
||
upgrade** — it must be rebuilt in the same step. The panel can't drift: it ships inside the
|
||
integration.
|
||
|
||
**Three long-standing weaknesses fixed, because the conversion made them cheap:**
|
||
|
||
1. **The payload no longer hits the recorder database.** `_unrecorded_attributes = {"daten"}` on the
|
||
entity. Only a real entity belonging to an integration can do this — pyscript's bare `state.set()`
|
||
can't, so every publish wrote the complete trip archive to the DB, every 60 seconds. Confirmed by
|
||
querying the history API: only `icon` and `friendly_name` come back. Note the mechanism moved:
|
||
exclusion now comes from `state_info["unrecorded_attributes"]` on the Entity, **not** from a
|
||
per-domain `recorder.py` platform hook (checked in `db_schema.py` of the installed 2026.8.0).
|
||
2. **A running trip survives a restart.** `fahrt_start_ts` and the tank low-water mark live in a
|
||
`Store` instead of only in memory. This was a documented data-loss bug.
|
||
3. **Trigger rebinding is live.** `@state_trigger` baked the entity ID in at module load, so
|
||
changing ZUENDUNG/KM/TANK_SENSOR needed an HA restart — the setup dialog had a warning and a
|
||
restart button for exactly that. `trigger_neu_binden()` replaces all three; the warning, the
|
||
button and the `neustart` service are gone.
|
||
|
||
**Fixing (2) exposed a new failure mode, and it was found by testing rather than reasoning.** If the
|
||
ignition goes off *while HA is down*, nobody sees the transition — the resumed trip would stay open
|
||
forever and the next real trip would be appended to its start. `nach_neustart_fortsetzen()` closes
|
||
it at the last recorded ignition timestamp (not "now" — HA may have been down for days), and runs
|
||
via `async_at_started` so a not-yet-restored source can't be mistaken for "off". Verified live: an
|
||
orphaned trip was closed at 21:12:43, the last value the recorder held.
|
||
|
||
**The self-updater is gone.** `updateverwaltung.py` cloned a git repo into a staging folder and
|
||
copied `pyscript/` and `www/` over the running code — necessary only as long as the app had no
|
||
install shape Home Assistant knows. HACS is that shape: it checks, downloads, installs, shows a
|
||
changelog and can roll back. A second, homemade update mechanism beside it is more attack surface
|
||
than value. The Settings tile now shows the installed version and points at HACS.
|
||
|
||
**Versioning collapsed to one number.** `manifest.json` `version` is the single source: HA lists it,
|
||
HACS compares it, the integration publishes it as `sensor.audi_dashboard_app_version`, it hangs on
|
||
the panel URL as `?v=…`, and vite reads it for `__APP_VERSION__` (hard-failing if absent). The
|
||
`VERSION` file and the Unix-seconds cache-buster in `audi-dashboard-version.json` are both gone —
|
||
two sources for one fact would have drifted, and the comparison would have gone silently wrong. The
|
||
new cache breaker is strictly better: it changes only on real changes. `VERSIONIERUNG.md` rewritten.
|
||
|
||
**Installer got smaller and much safer.** It copies one folder and touches nothing else. The
|
||
`configuration.yaml` manipulation — the one way an installer can stop HA from starting, and the
|
||
reason the old script needed timestamped backups, a conflict refusal and a read-back rollback — is
|
||
gone with the YAML it used to write. What remains: refuses to delete a folder whose `manifest.json`
|
||
doesn't claim `domain: audi_dashboard`, reads the manifest back after copying. Tested on a mock
|
||
config dir: dry run, first install, update run, and the refusal path.
|
||
|
||
**Removed from the repo** (recoverable via `git log`): `homeassistant/pyscript/`,
|
||
`homeassistant/www/audi-dashboard-*` + `badges/`, `homeassistant/data/`, `homeassistant/install.sh`,
|
||
`homeassistant/update.ps1`, the `installationspaket/` copy of everything (the duplicate-and-sync
|
||
arrangement is gone — the installer reads the real folder), `configuration_snippet.yaml` (nothing
|
||
left to add), `ANLEITUNG.pdf` (stale and not regenerable here; the `.md` beside it is current).
|
||
`data/tests/` → `tests/belegparser/`, pointing at the integration's parser copy; 8/8 still pass.
|
||
|
||
**Do not resurrect the pyscript tree.** If something is missing, port it — running both backends
|
||
writes two sets of trips into the same files.
|
||
|
||
**Don't re-litigate HACS.** Confirmed dead-end, not a gap — see the H) intro above. Nothing about
|
||
Gitea-vs-mirrored-GitHub changes it: HACS refuses private repos regardless of where they're
|
||
mirrored, and the private/public constraint (licensed Audi assets in the repo) isn't going away.
|
||
`install.ps1` is the permanent, only install path, not a stand-in.
|
||
|
||
**Stale HACS claims found and fixed in code comments/UI text (2026-08-24).** The docs-level cleanup
|
||
above happened the same day as this section was written, but a full-repo grep afterward (prompted by
|
||
the owner spotting live UI text still claiming "Updates laufen über HACS") turned up leftovers the
|
||
first pass missed, because it focused on the standalone docs (`README.md`, `INSTALL.md`, etc.) and
|
||
not source comments or UI strings. Fixed: the panel's Einstellungen → Version tile literally told the
|
||
user HACS would notice and install updates itself — replaced with the actual mechanism
|
||
(`install.ps1`, panel ships inside the integration and can't go stale on its own). Also fixed,
|
||
all now pointing at `install.ps1`/"jeder Auslieferung" instead of "HACS"/"HACS-Update": comments in
|
||
`audi-dashboard-app.js` (the version-tracking block and the retired-updater history note),
|
||
`companion-app/vite.config.ts` (manifest-version header comment), `const.py` (`STATIK_URL`,
|
||
`BUENDEL_ORDNER`, `BILDER_ORDNER` comments), `bilder.py` (module docstring), `__init__.py`
|
||
(`cache_headers=False` rationale), and a diagram branch in `VERSIONIERUNG.md` that stated "HACS
|
||
vergleicht sie gegen das Repository und meldet Updates" as flat fact, contradicting the boxed note
|
||
one screen above it. Left alone: `install.ps1`'s own header (already correctly explains the
|
||
exclusion), `ota-paket.ps1`'s comment (already correct), and one debugging note in
|
||
`audi-dashboard-app.js` (~line 4720) that recalls what was actually running in a specific test
|
||
container during a past incident — a historical fact about that container, not a claim about how
|
||
this project works, so not in scope for this cleanup. Also out of scope, a different topic entirely:
|
||
mentions of the *retired vehicle-data-source* HACS integration `TommiG1/HA_VAG-EU-Data-Act` in older
|
||
planning docs (`SPECIFICATION.md`, `COMPANION_APP_ARCHITECTURE.md`, `bauauftrag.md/html`,
|
||
`testumgebung/`) — that HACS integration is unrelated to whether HACS can install *this* app; it was
|
||
FMM003's predecessor as a data source, already documented as retired elsewhere in those same files.
|
||
Manifest bumped to `2026.8.24.3` and `npm run ota` rerun (the panel text change is user-visible).
|
||
|
||
### The deployed-parity problem, and what was built for it (2026-08-23)
|
||
|
||
The parity rule (binding, above) guarantees *source* parity: both codebases change in the same
|
||
session. It guarantees nothing about what is *running*. After the conversion the two sides are
|
||
wired differently in a new way:
|
||
|
||
- the panel ships **inside** the integration — one `install.ps1` run (the only delivery path — HACS
|
||
is a dead end here, see section H) moves backend and panel together, and it cannot go stale at all
|
||
- the companion app is a Capacitor shell with `webDir: "dist"` and **bundled** assets, sideloaded
|
||
through Xcode. It stays on whatever was bundled at signing time, silently, indefinitely
|
||
|
||
The version comparison above is what makes that visible: the app compares its compiled-in stamp
|
||
against `sensor.audi_dashboard_app_version` and says so in the existing `Hinweisleiste`, whose
|
||
stated principle is already "nie eine stille Veraltung".
|
||
|
||
Deliberate choices worth keeping: the comparison is **equality only**, never greater/less — the
|
||
version is an identifier, not a number, and ordering it would be false precision that breaks
|
||
silently on a format change. Either side missing yields `"unbekannt"` and **no** warning, so an
|
||
older backend or an offline start cannot produce a false alarm. And the vite build **fails hard** if
|
||
the manifest version is absent rather than emitting an app that cannot detect its own staleness.
|
||
|
||
**OTA delivery: built and verified end to end, 2026-08-24.** `@capgo/capacitor-updater` 8.51.14
|
||
installed (MPL-2.0, peer `@capacitor/core: ^8.0.0` against our `^8.5.0`). Manual mode, not
|
||
`autoUpdate` (that stays `false` on purpose — an update must never swap the UI out from under
|
||
someone mid-entry): `companion-app/src/daten/ota.ts` wraps `download({url, version, checksum})` +
|
||
`set({id})`, wired to a button in the Einstellungen screen (`otaUpdateVerfuegbar` /
|
||
`otaAusloesen`). `notifyAppReady()` runs from a `useEffect` in `AngemeldeteApp` (`App.tsx`) —
|
||
deliberately there and not in `main.tsx`, so a crash before React actually renders never gets
|
||
confirmed and the plugin's `appReadyTimeout` rollback (`capacitor.config.ts`, 20s, more generous
|
||
than the 10s default because first load also fetches profile/trips/refuels) takes over.
|
||
|
||
The bundle itself lives **inside the integration**, not under `/local/` as first sketched:
|
||
`custom_components/audi_dashboard/frontend/app/{bundle.zip,bundle.json}`, built by
|
||
`companion-app/scripts/ota-paket.ps1`, served at `/audi_dashboard_static/app/bundle.zip`. Reason
|
||
for the relocation: `/local/` would have needed its own delivery step that `install.ps1` doesn't
|
||
touch and could be forgotten; inside the integration folder it travels with every install/update
|
||
automatically, same as the panel. `sensor.audi_dashboard_app_version`'s `daten` gained a `buendel`
|
||
field (`koordinator._buendel_lesen()`) carrying `{version, sha256, bytes, gebaut, url}` — `null`
|
||
when no bundle is published, a normal state, not an error.
|
||
|
||
One real bug the build caught: `[IO.Compression.ZipFile]::CreateFromDirectory` on Windows
|
||
PowerShell 5.1 (.NET Framework, not Core) writes **backslashes** as path separators for nested
|
||
entries — a spec-violating zip that iOS would not have unpacked correctly. Caught by inspecting the
|
||
actual zip contents after the first build, not assumed; fixed by writing entries by hand via
|
||
`ZipFile::Open` + `CreateEntryFromFile` with `\` replaced by `/`. The plugin verifies SHA-256 over
|
||
the *downloaded zip itself* before unpacking (`CapgoUpdater.swift` `calcChecksum`, confirmed by
|
||
reading the plugin source, not the README) — `ota-paket.ps1` hashes the same file it writes, so the
|
||
two can't drift.
|
||
|
||
Verified against the running test container: entry reload picks up code changes but **not** a
|
||
manifest version bump (HA caches the `Integration` object — needs a full restart, same lesson as
|
||
the pyscript-removal step in section G); after a real restart, `sensor.audi_dashboard_app_version`
|
||
published the new manifest version and a complete `buendel` block; the zip served over HTTP hashed
|
||
identically to `bundle.json`'s `sha256`; `unzip -l`/`-t` confirmed 13 entries, `index.html` at the
|
||
root, no backslashes, no corruption.
|
||
|
||
**A second, quieter bug surfaced 2026-08-24 when a manifest-only metadata change (the GitHub-removal
|
||
commit) prompted the question of whether it needed a version bump.** It didn't — but the question
|
||
exposed that nothing enforced `frontend/app/bundle.json`'s version staying in sync with
|
||
`manifest.json`'s on a *real* bump. Trace the failure: bump manifest to V2, ship via `install.ps1`,
|
||
forget `npm run ota` → backend publishes `app: "V2"` live, but `bundle.json` (frozen at build time)
|
||
still says V1, and the already-installed app is still V1. `Hinweisleiste` compares app-vs-server and
|
||
correctly says "you're outdated, update" (V1 ≠ V2) — but the Update screen's `buendelPasst()`
|
||
compares app-vs-*bundle* and says "you're current" (V1 = V1, the bundle's stale number). Two
|
||
contradictory messages, and no way to actually reach V2 via OTA until someone notices and reruns
|
||
`npm run ota` — the exact kind of silent-until-discovered coordination bug this project has fixed
|
||
repeatedly elsewhere (trip status, tire counter, `edited_fields`). Fixed at the one chokepoint every
|
||
delivery already passes through: `install.ps1` now reads `frontend/app/bundle.json`'s version right
|
||
after reading the manifest's, warns loudly and adds the mismatch as the **first** Restliste item if
|
||
they differ (a missing bundle file is not a mismatch — OTA is optional, silence is correct then).
|
||
Verified both ways on a mock config dir: matching versions print "OTA-Bündel passt zur Integration"
|
||
and add nothing to the Restliste; a deliberately bumped manifest (tested via a temporary edit,
|
||
reverted byte-for-byte after, diffed against `HEAD` to confirm) produces the warning and lands it as
|
||
Restliste item 1. `VERSIONIERUNG.md`'s "Was du tun musst" now lists the OTA rebuild as its own
|
||
numbered step (2, before shipping) instead of omitting it entirely.
|
||
|
||
Capacitor's `server.url` pointed at HA was considered and **rejected**: it would make the app as
|
||
current as the panel, but the shell then cannot boot without reaching HA, gutting the deliberately
|
||
built offline queue (`api/warteschlange.ts`).
|
||
|
||
**`install.ps1` couldn't authenticate to the Samba share, and gave a misleading error (found and
|
||
fixed 2026-08-24, during the owner's first real deployment attempt).** The owner's Samba add-on was
|
||
running and reachable — confirmed via its own log (`smbd`/`nmbd` started cleanly) and via
|
||
`Test-Path`/`net use` from an interactive PowerShell session, which worked. Yet `install.ps1` kept
|
||
reporting "Ziel nicht erreichbar" for the exact same path. Root cause: `Test-Path` on a UNC path
|
||
**never prompts for credentials** — without an already-authenticated SMB session to that server, a
|
||
password-protected share just returns `false`, with no error and no password prompt. The script had
|
||
no credential handling at all; it only ever worked by accident, piggybacking on a session the user
|
||
happened to have already established by hand (Explorer, or a manual `net use`). Once that session
|
||
dropped — confirmed happening organically here when the Samba add-on itself restarted mid-session —
|
||
the next run failed again with the same unhelpful message, even though path and password were both
|
||
correct. Two side findings along the way, also worth remembering: (1) `Test-Path` against a UNC path
|
||
can fail silently for reasons other than credentials, e.g. process elevation — a `net use` session
|
||
established in a non-elevated shell is invisible to a process launched via "Run as administrator",
|
||
because they're different logon contexts; `Installieren.cmd` needs no elevation and should always be
|
||
run plainly. (2) Add-ons on Home Assistant OS often bind to their **own IP**, separate from HA
|
||
Core's — the Samba add-on here answered on a different address than the one shown on
|
||
Settings → System → Network, confirmed via the add-on's own broadcast log line and by successfully
|
||
loading the HA frontend on that same IP.
|
||
|
||
Fixed at the point of failure: if the target path isn't reachable, `install.ps1` now prompts for a
|
||
username (defaulting to `homeassistant`, the Samba add-on's account) and password, clears any stale
|
||
connection to the same path first (`net use $Ziel /delete`), and authenticates
|
||
(`net use $Ziel /user:... ...`) before retrying `Test-Path`. Only a genuine auth/path failure after
|
||
that throws. No more manual `net use` pre-step required. Verified the script still parses
|
||
(`PSParser]::Tokenize`) after the edit; end-to-end verification pending the owner's next real run.
|
||
|
||
### G) Fresh-install audit + installer hardening (2026-08-23, before the first real deployment)
|
||
|
||
> **Historical from here on.** This describes the pyscript-era package, which section H replaced
|
||
> later the same day. The *findings* still matter — the blank-delivery rule and the safety
|
||
> properties carried over into the new installer — but the files it names (`pyscript/`, `www/`,
|
||
> `data/`, the `configuration.yaml` block) no longer exist. Kept as the record of what was wrong
|
||
> and why the current shape looks the way it does.
|
||
|
||
Owner is about to install on the real HA OS instance and asked for two things: that the one-click
|
||
package produce a genuinely blank app, and that it carry no risk to the existing HA system.
|
||
|
||
**The one real contamination bug.** `pyscript/modules/einstellungen.py` shipped with two hardcoded
|
||
entity IDs from a long-gone test instance — `ZUENDUNG_SENSOR =
|
||
"binary_sensor.testzone_fmm003_engine_ignition_or_acc_status"` and `BATTERIE_SENSOR =
|
||
"sensor.testzone_fmm003_external_power_voltage"` (note `testzone_`, older even than the current test
|
||
container's `testcar_b9_…`). Invisible during all testing, because the test instance's own
|
||
`entitaeten.json` overrides both. On a fresh install with no overrides they would have been the
|
||
*effective* values — and worse than merely dangling: `fahrterkennung.py` guards trigger registration
|
||
with `if einstellungen.ZUENDUNG_SENSOR:`, explicitly built so an unset sensor registers no trigger at
|
||
all. A set-but-nonexistent value defeats exactly that guard. Both are now `""` like every other field,
|
||
and the file header was rewritten — it still claimed the EU-Data-Act integration was gone, which
|
||
stopped being true when `cupra_eu_data_act` appeared.
|
||
|
||
Everything else in the package was already clean, verified rather than assumed: no `fahrten.jsonl`,
|
||
no `tankvorgaenge.jsonl`, no `batteriespannung.jsonl`, no `entitaeten.json`, no real tokens (the
|
||
`ha_token` hits are path constants), and `fahrzeugprofil.example.json` carries only model specs —
|
||
FIN, Kennzeichen, Erstzulassung, HU, insurance numbers and all counters are empty or zero.
|
||
|
||
**Verified by actually doing it**, not by reading the installer: a throwaway `ghcr.io/home-assistant/
|
||
home-assistant:stable` container, the package copied in exactly as `install.ps1` does it, pyscript
|
||
config entry added, restarted. Result: `/config/audi_dashboard/` held only `fahrzeugprofil.json`,
|
||
`shell_beleg_parser.py` and an empty `belege/`; no trip, fuel or battery file existed at all; zero
|
||
ERROR lines in the log.
|
||
|
||
**Installer safety, audited and then hardened.** The audit found the good news first: `install.ps1`
|
||
contains no `Remove-Item` anywhere, no `robocopy /MIR`, never touches `.storage/`, never reads or
|
||
writes `automations.yaml`/`scripts.yaml`/`secrets.yaml`, and never restarts HA. The only file it
|
||
changes outside its own folders is `configuration.yaml` — which is also the only way it could stop HA
|
||
booting. Three gaps closed there:
|
||
|
||
- the fixed `configuration.yaml.bak` was overwritten on every run, so a second run destroyed the
|
||
pristine original. Backups are now timestamped, and the plain `.bak` is written only once
|
||
- after writing, the file is read back and checked (still at least as long as before, marker block
|
||
present exactly once, prior content byte-identical); any deviation **rolls back automatically**
|
||
from the timestamped backup and moves the step to the manual to-do list
|
||
- the header now states the safety properties explicitly, so they can be checked rather than trusted
|
||
|
||
Tested end to end against mock config dirs: `-Pruefen` wrote nothing; a real run preserved a foreign
|
||
`light:` group and left `automations.yaml` byte-identical (SHA-256 compared) while appending the
|
||
block; the output parses as valid HA YAML with all foreign keys intact; a second run left exactly one
|
||
block and preserved `fahrzeugprofil.json`; and against a config that already had its own `pyscript:`
|
||
and `panel_custom:` the file came back byte-identical, as designed.
|
||
|
||
**The genuine remaining risk is disk space, not the installer.** `recorder_snippet.yaml`'s 365 days
|
||
is ~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"...` — and `intervalle()` returns `null` whenever there's no
|
||
Servicebuch basis, full stop. The vehicle-reported `fm`/`hatMeldung` was computed *after* this early
|
||
return, so a mapped sensor with valid data never even got read for a fahrzeug with an empty
|
||
Servicebuch — exactly the state of a freshly set-up car.
|
||
- companion-app (`src/screens/Service.tsx`): same shape, `{oelHatEintrag ? ... : "kein Eintrag im
|
||
Servicebuch"}` — gated purely on a Servicebuch entry existing, ignoring `oelHatMeldung`/
|
||
`inspHatMeldung` computed one line above it. `src/daten/service.ts`'s `naechsterService()` (feeds
|
||
the Home-screen "Nächster Service" tile) had the same gap one level up: it only ever built
|
||
candidates from `oelwechselPrognose()`/`inspektionPrognose()` (both Servicebuch-only), never
|
||
falling back to `fahrzeug.oelwechselFaelligTs`/`inspektionFaelligTs`.
|
||
|
||
**Fixed by reordering, not by adding a new data path** — the sensor data was already being read
|
||
correctly by the backend and delivered to both frontends; only the display-gating ignored it.
|
||
Panel: `fm`/`hatMeldung` now computed *before* the early-return check, which now reads `if (!iv &&
|
||
!hatMeldung)`; `anzeige`/`herstellervorgabe` guarded for `iv` possibly being `null` (both remain safe
|
||
because the code paths that dereference `iv` only run when `hatMeldung` is false, and the new guard
|
||
guarantees `iv` is non-null whenever that's the case). companion-app: `oelHatEintrag ||
|
||
oelHatMeldung` / `inspHatEintrag || inspHatMeldung` as the render condition; `naechsterService()`
|
||
gained an `else if (fahrzeug.oelwechselFaelligTs)` / `...inspektionFaelligTs` fallback branch per
|
||
service type, added only when the Servicebuch-based prognose is `null` — a real Servicebuch entry
|
||
still wins when both exist (own forecast is finer-grained, factoring actual driven km/day). The
|
||
Hauptuntersuchung path is unaffected either way: `fahrzeugMeldungRoh("Hauptuntersuchung")` always
|
||
returns `{ts: null, km: null}`, so `hatMeldung` is always `false` for it — same behavior as before.
|
||
|
||
Verified: `tsc --noEmit` clean, all 120 companion-app tests pass (including 3 new regression tests in
|
||
`service.test.ts` covering the meldung-only fallback for both service types and confirming a real
|
||
Servicebuch prognose still takes priority when both are present), panel JS re-checked with `node
|
||
--check`. `manifest.json` bumped to `2026.8.24.2` and `npm run ota` rerun (own rule from section H,
|
||
enforced by `install.ps1` since the same day) since this touched both the panel and the companion-app.
|
||
|
||
### J) Self-update: the integration pulls its own updates from Gitea (built 2026-08-24)
|
||
|
||
**Why.** `install.ps1` (section H) stopped being a reliable update path: Windows Smart App Control
|
||
blocks its execution, and unlike SmartScreen there is no "run anyway" override — once fully
|
||
enabled, Smart App Control can only be turned off by reinstalling Windows. The owner's actual daily
|
||
pain wasn't the block itself but the whole cycle it forced: open Gitea, download a zip, unpack it,
|
||
run the (now-blocked) installer, re-enter the Samba password every time (see section H's Samba-auth
|
||
fix earlier the same day). Owner chose, from three options laid out (free workarounds first / code-sign
|
||
the installer / have Home Assistant pull its own updates), the third: **the integration now updates
|
||
itself.** `install.ps1` remains necessary for the *first* install only — the integration has to
|
||
already be running before it can update itself — which is the rare case; the daily-update tedium is
|
||
what this closes.
|
||
|
||
**Safety design — stricter than install.ps1, because it runs inside what it replaces.**
|
||
`install.ps1` is an external process against a foreign HA instance; this feature's code lives
|
||
*inside* `custom_components/audi_dashboard/` and replaces that very folder while its own service
|
||
call is still executing. `aktualisierung.py`'s module docstring carries the full reasoning; the
|
||
shape:
|
||
1. Download and verification happen entirely in a sibling staging folder
|
||
(`custom_components/audi_dashboard_update_staging/`) — the live folder is never touched until the
|
||
download is confirmed complete and plausible.
|
||
2. The swap is `os.rename`, not delete-then-copy: the live folder is renamed to
|
||
`audi_dashboard_backup` (kept, not deleted — reversible) before the staging folder is renamed into
|
||
its place. If the second rename fails, the first is undone automatically rather than leaving a
|
||
half-replaced folder.
|
||
3. The downloaded `manifest.json` is checked for `domain == "audi_dashboard"` and a `version` field
|
||
before either rename runs — matches install.ps1's own "won't touch a folder that isn't
|
||
provably ours" rule (section G/H).
|
||
4. **No automatic reload or restart.** A reload triggered from inside the same service call that's
|
||
still running would risk being interrupted by its own reload. The UI shows a "restart Home
|
||
Assistant" message instead — the same pattern install.ps1's Restliste already uses.
|
||
5. Nothing outside `custom_components/` is touched — same invariant as install.ps1.
|
||
6. The check step (a Gitea network call) runs only on explicit button press, never on the
|
||
coordinator's normal publish tick — same "no unsolicited background action" stance as the OTA
|
||
`autoUpdate: false` decision.
|
||
|
||
Renaming the very folder the running code was imported from is safe on Linux (this project runs on
|
||
Home Assistant OS / Docker): Python holds no open file handles on `.py` sources after import
|
||
completes, and `os.rename` only operates on path strings, not live handles.
|
||
|
||
**Token storage.** No prior mechanism existed for secrets in this integration — `config_flow.py`'s
|
||
`entry.data` was always `{}`, no `OptionsFlow` existed. Added `AudiDashboardOptionsFlow`
|
||
(Settings → Devices & Services → Audi Dashboard → Configure), one masked field
|
||
(`CONF_GITEA_TOKEN` in `const.py`), stored in `entry.options` — HA's own encrypted config-entry
|
||
store, never `configuration.yaml`. A read-only-scoped Gitea token is enough; the form text says so.
|
||
|
||
**What was built:**
|
||
- `custom_components/audi_dashboard/aktualisierung.py` — `version_pruefen()` (one lightweight Gitea
|
||
Contents-API call for just `manifest.json`, no repo download) and `update_installieren()`
|
||
(downloads the full repo archive via Gitea's archive endpoint — Gitea has no single-folder
|
||
download — delegates to the blocking `entpacken_pruefen_tauschen()` via
|
||
`hass.async_add_executor_job`). The blocking function takes all paths as parameters rather than
|
||
reading module constants directly, specifically so it's testable against a temp directory without
|
||
touching the real integration folder.
|
||
- Two new services (`update_pruefen`, `update_installieren`), registered in `dienste.py` following
|
||
the existing `SCHEMATA`/`behandler`/`_als_dienst()` pattern; both zero-field, both catch
|
||
`aktualisierung.AktualisierungsFehler` and turn it into a user-readable message instead of an
|
||
unhandled exception.
|
||
- `koordinator.py`: `alles_veroeffentlichen()`'s `E_APP_VERSION` publish was factored out into its
|
||
own `app_version_veroeffentlichen()` method and extended with `integration_update`. This holds the
|
||
*last* check/install result (`self.letzte_update_pruefung`) — deliberately **not** recomputed on
|
||
every publish tick, only ever set by the two new service handlers, so normal operation triggers zero
|
||
Gitea calls.
|
||
- Both frontends got a new "Integration-Update" tile (companion-app: `Einstellungen.tsx` +
|
||
`IntegrationUpdateAngabe` type in `api/types.ts` + `integrationUpdate` in `DatenKontext.tsx`; panel:
|
||
new tile next to the existing Version tile in `audi-dashboard-app.js`, backed by a new
|
||
`INTEGRATION_UPDATE` global read from `daten.integration_update`, wired to the existing
|
||
`data-*`/`serviceRufen()`/`ereignisseVerdrahten()` click-dispatch pattern) — not an extension of the
|
||
existing OTA "App-Update" tile, which is a different concept (the Capacitor iOS shell going stale)
|
||
that doesn't apply to a browser-rendered panel.
|
||
|
||
**Verified, honestly scoped by what this session's sandbox could actually run:**
|
||
- The safety-critical pure logic (`entpacken_pruefen_tauschen()`) has 7 unit tests
|
||
(`tests/aktualisierung/test_aktualisierung.py`, `unittest`, same style as
|
||
`tests/belegparser/test_shell_beleg_parser.py`) covering: successful swap with the old version
|
||
preserved in the backup folder, `__pycache__` cleanup, wrong domain rejected, missing `version`
|
||
field rejected, missing `manifest.json` rejected, corrupt zip rejected, archive missing the
|
||
expected path rejected — and every rejection case asserts the live folder is byte-for-byte
|
||
untouched. Run inside the project's existing throwaway `ghcr.io/home-assistant/home-assistant:stable`
|
||
test container (`audi_ha_test`) via `docker exec`, since no Python interpreter exists in the
|
||
session sandbox itself.
|
||
- The complete integration (config flow, both services, koordinator changes, both frontends) was
|
||
deployed into that same container and restarted from a clean state, twice, ending on
|
||
`Audi Dashboard 2026.8.24.4 eingerichtet` with zero errors or tracebacks — confirms the
|
||
`OptionsFlow`/service registration/coordinator wiring is structurally correct against a real,
|
||
current (2026.8.0) Home Assistant.
|
||
- companion-app: `tsc --noEmit` clean, all 127 tests pass.
|
||
- **Not verified**: an actual successful download+swap against the real private Gitea repo, since
|
||
that needs the owner's own token — entering it is the owner's action via the HA UI, not something
|
||
to hand to an assistant in chat. The failure paths (missing token, bad token, unreachable Gitea) are
|
||
covered by the unit tests and code review, not by a live call against Gitea. First real end-to-end
|
||
use is the owner's to do once a token is configured — worth a first careful try on the test
|
||
container before the real instance, same caution as every other install.ps1-era change in this
|
||
project.
|
||
|
||
**Docs updated to stop calling install.ps1 "the only way":** `README.md`, `VERSIONIERUNG.md` — both
|
||
now say install.ps1 is the only way *to install*; the self-update is now the preferred way *to
|
||
update* an already-running instance.
|
||
|
||
**Two fixes found during the owner's own first live test (2026-08-24, same day).** First attempt
|
||
against the test container ("nothing changed") turned out to be a real user error caught by log
|
||
inspection, not a bug: the check was clicked before the token had actually been saved in the
|
||
Options Flow — `dienste.py`'s log showed exactly why (`Kein Gitea-Token hinterlegt`), confirming the
|
||
error path works. But this surfaced a genuine observability gap: `update_pruefen`/
|
||
`update_installieren` only ever logged on *failure* — a successful check was silent, so "nothing in
|
||
the log" was ambiguous between "not clicked yet" and "worked fine." Fixed by adding an `_LOGGER.info`
|
||
line on the success path of both services. Separately, the owner asked for the panel's idle-state
|
||
text ("Ein Gitea-Zugriffstoken muss zuvor... eingetragen sein") to only appear as part of an actual
|
||
error, not as a permanent reminder cluttering the tile before the first check — removed from the
|
||
idle branch; the same information already surfaces via `integration_update.fehler` when it's
|
||
actually true. companion-app never had this line in the first place, so no parity fix needed there.
|
||
|
||
**A real bug found the same day, from a genuinely confusing error report.** After a successful
|
||
install-then-restart cycle, a later "Update installieren" attempt failed with:
|
||
`Konnte den bestehenden Ordner nicht sichern: [Errno 2] No such file or directory:
|
||
'/config/custom_components/audi_dashboard_backup' -> '/config/custom_components/audi_dashboard_backup'`
|
||
— note both sides of the `os.rename` are the *same path*, which shouldn't be possible given
|
||
`INTEGRATIONSORDNER`/`_BACKUP_ORDNER` are built from clearly different string literals. Root cause,
|
||
found via `docker logs`: the log line's logger name was `custom_components.audi_dashboard_backup.dienste`,
|
||
not `custom_components.audi_dashboard.dienste`. Home Assistant scans **every** folder directly under
|
||
`custom_components/` for a `manifest.json` and loads whatever `domain` it declares — by folder
|
||
content, not folder name. The `audi_dashboard_backup` folder left behind by a successful swap is a
|
||
byte-for-byte copy of the integration, manifest included, so across a restart HA loaded it as a
|
||
*second* `audi_dashboard` integration. Python then imported its `dienste.py` as its own module
|
||
(`custom_components.audi_dashboard_backup.dienste`), whose `__file__`-derived `INTEGRATIONSORDNER`
|
||
*is* the backup folder — making `_BACKUP_ORDNER` collapse onto the same path for that rogue copy.
|
||
This duplicate-domain state also explained two other symptoms reported the same session: the panel
|
||
intermittently doing nothing visible on "Auf Update prüfen"/"Update installieren" (whichever
|
||
integration's service registration won the race), and the frontend getting stuck on the bootstrap
|
||
"Lädt …" screen after a restart (confirmed live: with the stray `audi_dashboard_backup` folder still
|
||
present, `audi_dashboard` appeared **twice** in the loader's warnings and the panel never finished
|
||
loading; deleting the stray folder and restarting fixed both immediately).
|
||
|
||
**Fix**, in `entpacken_pruefen_tauschen()`: right after the backup rename succeeds, its
|
||
`manifest.json` is itself renamed to `manifest.json.bak` — structurally invalid as an integration, so
|
||
HA's scanner skips it. On the rollback path (second rename fails), the `.bak` is restored to
|
||
`manifest.json` *before* the backup folder is renamed back into place, so a restored live folder is
|
||
never left without a valid manifest. Covered by two new unit tests (manifest absent from the backup
|
||
folder after a successful swap; manifest correctly restored after a forced rollback via a mocked
|
||
`os.rename`) — `tests/aktualisierung/test_aktualisierung.py` now has 8 tests, all passing. Verified
|
||
live: deployed the fix, deleted the stray backup folder from the test container, restarted — single
|
||
`audi_dashboard` loader warning, clean `eingerichtet` line, frontend loads normally. Manifest bumped
|
||
to `2026.8.24.7` (see section M below, same version bump covers both fixes).
|
||
|
||
### K) vCard import for the workshop contact (built 2026-08-24)
|
||
|
||
Service → Autohaus (gear icon) now has a "vCard importieren" button next to the existing form
|
||
fields, opening a file picker for a `.vcf` contact exported from a phone/desktop address book. Reads
|
||
`FN`/`ORG` (name, `FN` preferred), `TEL`, `EMAIL`, and `ADR` (the seven-part vCard address —
|
||
PO-Box/extended/street/locality/region/postal-code/country — joined into one readable line, since
|
||
the form's address field is a single string). Deliberately not a full vCard parser: only the four
|
||
fields the Autohaus form already has. Unfolds RFC 6350 §3.2 folded lines (CRLF + one WSP removed
|
||
entirely, not replaced with a space) and un-escapes `\,`/`\;`/`\n`/`\\` before use. Multiple
|
||
`TEL`/`EMAIL`/`ADR` lines: first one wins.
|
||
|
||
- Panel: `vcardParsen()` in `audi-dashboard-app.js`, wired into `vWerkstatt()`'s new hidden
|
||
`data-vcard-import` file input via the existing central `change` listener — same
|
||
parse-then-`hinweis()`-on-error pattern as the existing JSON profile/backup import handlers.
|
||
Updates `CAR.service.autohaus` fields directly and calls the existing `profilSpeichern()` (saves
|
||
immediately, matching this field's existing per-keystroke-save behavior).
|
||
Same isolated-function shape as `entpacken_pruefen_tauschen()` in section J's self-updater: parse
|
||
logic takes no dependency on `HASS`/global state, so it's directly testable.
|
||
- companion-app: new `src/screens/vcard.ts` (`vcardParsen()`, 7 unit tests in `vcard.test.ts`
|
||
covering folding, escaping, ORG fallback, first-wins on repeats, and the two "nothing readable"
|
||
error cases), wired into `Werkstatt()` in `Service.tsx`. Unlike the panel, this only populates the
|
||
local `entwurf` draft state — the existing explicit "Speichern" button still does the actual save,
|
||
matching this screen's existing edit-then-confirm pattern rather than the panel's live-save one.
|
||
Empty fields from the vCard never overwrite non-empty draft values.
|
||
|
||
Verified: `tsc --noEmit` clean, all 134 companion-app tests pass (127 + 7 new), panel JS re-checked
|
||
with `node --check`. Manifest bumped to `2026.8.24.5`, `npm run ota` rerun (both frontends changed).
|
||
|
||
### L) Self-update UX: loading feedback and a real restart button (2026-08-24, same day)
|
||
|
||
**The self-update feature (section J) got its first genuine live test against the real private
|
||
Gitea repo this same day**, and it worked — confirmed directly in the test container's log across
|
||
several real attempts (`Update installiert: Version 2026.8.24.5`, no manual file copying involved).
|
||
That's the first real end-to-end confirmation beyond the unit tests and structural container-restart
|
||
checks section J could offer on its own.
|
||
|
||
That same live test exposed two real UX gaps, both raised directly by the owner:
|
||
|
||
1. **No feedback during or after the ~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.
|
||
2. **A separate, initially confusing "Lädt …" hang the owner saw and asked about.** Traced by
|
||
reproducing it directly: `.lade-spinner`/"Lädt …" is this project's own **existing** top-level
|
||
bootstrap loading screen (shown whenever `DATEN_GELADEN` is false — i.e., no live connection to
|
||
Home Assistant yet), not anything related to the update button specifically. Clicking the new
|
||
"Jetzt neu starten" button and watching it live in a browser reproduced the *exact* same screen —
|
||
because a `homeassistant.restart` call genuinely does drop the frontend's connection until HA
|
||
finishes rebooting, and this is the correct, expected screen for that. The most likely explanation
|
||
for the owner's *original* sighting (before this button existed): the Docker Desktop restart from
|
||
earlier in the same session dropped the connection the same way. Not a bug — but the fact that it
|
||
was indistinguishable from one was the actual, valid complaint: nothing signposted that a restart
|
||
was in progress versus something being stuck.
|
||
|
||
**Fixed with a three-state UI, researched against Apple HIG's guidance on indeterminate progress**
|
||
(unknown-duration network operations get a running indicator, not just a disabled control; on
|
||
completion the UI leads straight to the next required action instead of leaving the user to find it
|
||
themselves):
|
||
- **Prüfen/Installieren, in progress** — a small spinner + "Prüfe …"/"Installiere …", reusing the
|
||
panel's own pre-existing `.lade-spinner` (originally built, per its own comment, for the exact same
|
||
"don't let 'still loading' look like 'stuck'" reason at app bootstrap) and a new equivalent
|
||
`.dm-spinner`/`.dm-ladezeile` pair added to companion-app's `screens.css` (that side had no spinner
|
||
at all before, only a button-text swap).
|
||
- **On success** — the tile switches directly to "Installation abschließen - Jetzt neu starten",
|
||
which calls `homeassistant.restart` (a core HA service any admin could already trigger via
|
||
Settings → System → Restart — this button doesn't grant a new capability, it surfaces an existing
|
||
one at the point it's actually needed). Its own click shows "Home Assistant startet neu …";
|
||
no `.finally`-style cleanup after that call succeeds, since the connection normally drops before
|
||
the promise would ever resolve — only a genuine rejection (service refused, etc.) reaches the
|
||
catch block.
|
||
- **On a real request-level failure** (not the already-handled "no token"/"bad token" case from
|
||
section J, which returns normally with `fehler` set — this is Gitea unreachable, the websocket
|
||
itself failing, or any other exception that never made it back from Home Assistant at all) — this
|
||
was previously silent in companion-app (a `try/finally` with no `catch` re-throws into an
|
||
unhandled promise rejection). Added explicit `catch` blocks on all three actions in both frontends,
|
||
each surfacing its own message rather than one shared silently-stale error field.
|
||
|
||
- Panel: `INTEGRATION_UPDATE_LAEUFT` (`"pruefen" | "installieren" | "neustart" | null`), the
|
||
`data-update` click handler rewritten from `serviceRufen()` (fire-and-forget) to direct
|
||
`HASS.callService()` promise chains so the loading flag can wrap them; `vEinst()`'s tile rewritten
|
||
with the three states above.
|
||
- companion-app: `updateNeustartLaeuft`/`updateAktionFehler` state and `updateNeustartAusloesen()` in
|
||
`Einstellungen.tsx`; new `homeAssistantNeuStarten()` in `api/index.ts` (`dienstAufrufen("homeassistant",
|
||
"restart")` — note the domain isn't `DIENST_DOMAIN`, this is a core HA service, not one of this
|
||
integration's own).
|
||
|
||
Verified live end-to-end in the test container via browser automation: triggered the real restart
|
||
button, watched the connection drop into the bootstrap "Lädt …" screen exactly as reasoned above,
|
||
confirmed via `docker logs` that Home Assistant actually rebooted
|
||
(`Audi Dashboard 2026.8.24.6 eingerichtet` after restart, version now genuinely current — not just
|
||
the pre-restart file swap). companion-app: `tsc --noEmit` clean, full suite still green. Manifest
|
||
bumped to `2026.8.24.6` and `npm run ota` rerun per the standing rule in `VERSIONIERUNG.md` — both
|
||
frontends changed (new spinner/restart-button behavior is real, visible UI, not internal-only).
|
||
|
||
### M) Übersicht/Service mismatch on a blank install: two real bugs (2026-08-24, same day)
|
||
|
||
The owner tested on a **blank installation** (no Servicebuch entries yet) and found two bugs:
|
||
|
||
1. **Service showed the vehicle's own km/date; Übersicht showed something else (or nothing).** Root
|
||
cause was worse on the panel than in companion-app: `termine()` (panel) built its candidate list
|
||
purely from `letzterEintrag()` (a real Servicebuch entry) — with none, `km`/`datum`/`ziel` all
|
||
stayed `null`, so `vHome()` (Übersicht) rendered a broken/empty service block, while `vService()`
|
||
already had its own separate `meldungsPrognose()` fallback and displayed correctly. companion-app's
|
||
`naechsterService()` did already call `meldungsPrognose()` as a fallback, but paired the *raw*
|
||
signed `fahrzeug.oelwechselFaelligKm` with a *computed* projected date, which could read
|
||
inconsistently with `Service.tsx`'s own primary line. Fixed by adding the same meldung-based
|
||
fallback to `termine()` (new block right after `oelTermin`/`inspTermin` are built: `if
|
||
(termin.basis) return;` then reads `oelMeldungsPrognose()`/`meldungsPrognose()`), and by having
|
||
`meldungsPrognose()` always return `restKm` (`Math.abs(faelligKm)`) so both codebases use one
|
||
consistent value instead of recomputing it differently at each call site. Verified live: emptied
|
||
the test container's `service.buch` array (backed up first, restored after), confirmed Service and
|
||
Übersicht showed byte-identical numbers (8.687 km / 28.03.2027) where Übersicht previously would
|
||
have shown nothing usable at all.
|
||
|
||
2. **The Ölwechsel forecast ignored the owner's own (shorter) interval from "Einrichten" until a
|
||
Servicebuch entry existed** — it silently kept showing the manufacturer's own countdown instead,
|
||
with no restKm shown either. Root cause: `meldungsPrognose()`/`fahrzeugMeldungRoh()` only ever
|
||
relay the vehicle sensor's own manufacturer-interval countdown; nothing about them reads
|
||
`oel.modus`/`oel.km`/`oel.monate`. Without a Servicebuch entry there's no known "last real oil
|
||
change" to anchor a custom-interval projection from — the owner chose (asked directly, see
|
||
conversation) to estimate one anyway: back-calculate an assumed last-service point as
|
||
`(manufacturer's own due point) − (manufacturer's own interval length)`, then project the owner's
|
||
shorter interval from that same assumed point. An approximation, not exact — it can never be as
|
||
good as a real Servicebuch entry — but it's the only way to show a number that respects the
|
||
owner's chosen interval before the first entry exists, instead of silently ignoring the choice.
|
||
Implemented once in `service.ts`
|
||
(`meldungsPrognoseEigenesIntervall()` + `oelMeldungsPrognose()`, the latter dispatching to the
|
||
existing `meldungsPrognose()` unchanged when `modus === "hersteller"`) and mirrored 1:1 in the
|
||
panel (same two functions, same math, `Date`-based month arithmetic instead of `service.ts`'s
|
||
ISO-string `monatePlus()` since the panel's own `monatePlus()` only accepts `TT.MM.JJJJ` strings
|
||
and the vehicle meldung's timestamp is ISO). Both `Service.tsx`/`vService()`'s footnote for this
|
||
fallback case now show `restKm` alongside the date when the owner's interval is shorter than the
|
||
manufacturer's, exactly like the existing Servicebuch-based prognose already does. Inspektion is
|
||
unaffected — it has no owner-selectable interval, always the manufacturer's own.
|
||
|
||
Only Ölwechsel has a selectable interval, so this only applies there. If `oel.herstellerKm`/
|
||
`herstellerMonate` are themselves unknown, both functions degrade to the plain (unchanged)
|
||
`meldungsPrognose()` rather than guessing further.
|
||
|
||
Verified: companion-app — 4 new tests for `meldungsPrognoseEigenesIntervall()`/`oelMeldungsPrognose()`
|
||
(restKm/date math, the "already overdue by the owner's interval" clamp-to-zero case, no-fahrten
|
||
fallback, graceful degradation without hersteller values), full suite green at 142/142, `tsc --noEmit`
|
||
clean. Panel: `node --check` clean; live-verified in the test container using the exact same
|
||
blank-install simulation as bug 1 above — with `service.buch` emptied and the container's real
|
||
`oel` settings (`modus: "eigen"`, `10.000 km`/`12 Monate`, manufacturer `30.000 km`/`24 Monate`), the
|
||
footnote showed **8.700 km, voraussichtlich am 23.07.2027** — the derived number from
|
||
`28.700 − 30.000 + 10.000`, using the vehicle's real raw meldung — closely matching (not identical to,
|
||
as expected for an approximation) the real Servicebuch-based value of 8.687 km / 28.03.2027 seen with
|
||
the Servicebuch restored. Restored the container's real profile afterward; no live data was left
|
||
altered.
|
||
|
||
Manifest bumped to `2026.8.24.7` (covers this section and section J's manifest-neutering fix above —
|
||
one version bump, both landed the same day); superseded by `2026.8.24.8` in section N below the same
|
||
day. `npm run ota` rerun once, covering both this section's and section N's frontend changes together
|
||
(see section N).
|
||
|
||
### N) Sicherheit neu gestaltet: verriegelt / Türen & Klappen / Fenster & Dach / Licht (2026-08-24, same day)
|
||
|
||
The owner asked for a rework of the "Sicherheit" screen (`vSicherheit()`/`Fahrzeugstatus.tsx`), which
|
||
until now showed one flat list of every door/window/heckklappe/haube check with no grouping and no
|
||
lock, roof, or light status at all. Requested: four named collector rows — **Fahrzeug verriegelt**
|
||
(new lock sensors, state "abgeschlossen"), **Türen und Klappen geschlossen** (with an arrow to a
|
||
detail view of every door, Motorhaube, and Kofferraum individually), **Fenster und Dach geschlossen**
|
||
(Dach = Schiebedach), **Licht ausgeschaltet** (Standlicht, showing the text **"Kein Licht"** for the
|
||
good state, not a generic "in Ordnung"). The owner also supplied a concrete mockup: green
|
||
checkmark-circles instead of the old plain-color dot, only the second row carrying a chevron.
|
||
|
||
Went through plan mode given the cross-codebase, backend+frontend scope. Before planning, checked the
|
||
real entities available in the test container's `core.entity_registry` (`cupra_eu_data_act` platform)
|
||
to ground the sensor-role design in what actually exists rather than guessing: per-door
|
||
`binary_sensor.*_door_lock` (`device_class: lock`), `..._tailgate_lock`, `..._hood_lock`, `..._sunroof`
|
||
(`device_class: window`), `..._parking_lights` (`device_class: light`). HA's own convention for all of
|
||
these is `off` = the good state (locked/closed/no light) — the exact same `state == "off"` → `ok=True`
|
||
check this integration already uses for door/window sensors. No new check pattern needed, only new
|
||
sensor roles.
|
||
|
||
**Backend:**
|
||
- `einstellungen.py` — five new `Sensorzuordnung` fields: `TUERSCHLOSS_SENSOREN` (4 positions, mirrors
|
||
`TUER_SENSOREN`), `HECKKLAPPE_SCHLOSS_SENSOR`, `HAUBE_SCHLOSS_SENSOR` (paired with the existing
|
||
open-sensors), `DACH_SENSOR`, `LICHT_SENSOR`. Matching `FELDER` catalog entries under the existing
|
||
`"sicherheit"` group — the Setup-Menü is entirely `FELDER`-driven (confirmed: its group headers come
|
||
straight from this list), so the new roles appeared there with zero extra frontend work.
|
||
- `veroeffentlichung.py`'s `_sicherheitscheck()` — extended with the five new checks, each entry now
|
||
additionally tagged `"gruppe"`: `"verriegelt"` (all 6 locks), `"tueren_klappen"` (4 doors + Motorhaube
|
||
+ the renamed "Kofferraum" — was "Heckklappe"; the underlying `HECKKLAPPE_SENSOR` field itself is
|
||
unchanged, so an existing assignment isn't silently broken by the relabel), `"fenster_dach"` (4
|
||
windows + Dach), `"licht"` (Standlicht alone). `gesichert` is untouched — `all()` over the full flat
|
||
list is mathematically identical to "AND of all four group results," so no logic change, just more
|
||
entries in the same list.
|
||
- **Consequence flagged to the owner**: because every entry in the flat list (including brand-new,
|
||
unassigned ones) counts toward `gesichert`, any existing installation shows "Zustand nicht
|
||
vollständig bekannt" for the overall status until the three new roles are assigned in Setup — same
|
||
"unknown beats assumed-good" principle this project has followed throughout, but worth knowing before
|
||
it looks like a regression.
|
||
|
||
**Frontend (both codebases):** a small grouping helper (`sicherheitsGruppe()` in the panel,
|
||
`gruppenErgebnis()` + filter in companion-app) turns the flat list into a group's aggregate `ok`
|
||
(`null` the moment one entry is unknown, or if the group has zero entries at all — never `true` via an
|
||
empty-array vacuous truth). Both `vSicherheit()`/`Fahrzeugstatus.tsx` rewritten to the four-row layout;
|
||
each row's own label text changes with its state (e.g. "Kein Licht" / "Licht eingeschaltet" / "Licht
|
||
unbekannt") rather than pairing a fixed label with a separate status word — matches the mockup, which
|
||
shows no secondary value text at all. New checkmark-circle indicator (`statusKreis()` in the panel,
|
||
`StatusKreis` component in companion-app's `bausteine.tsx`) — green check / red cross / amber
|
||
question-mark SVG in a filled circle, replacing the old plain dot for this screen only; the rest of the
|
||
app keeps `.dot`/`.dm-punkt` unchanged. New route `"tuerenklappen"` in both routing tables
|
||
(`ZURUECK`/`navigation.ts`), reachable only from the "Türen und Klappen geschlossen" row; its detail
|
||
page is the old flat-list style filtered to just that group (`vTuerenKlappen()`, new
|
||
`screens/TuerenKlappen.tsx`).
|
||
|
||
**Verified:**
|
||
- Backend: `python3 -m py_compile` on both changed modules, clean restart in the test container with a
|
||
single `audi_dashboard` loader warning (no duplicate-domain regression from section J/M's fix).
|
||
- companion-app: `tsc --noEmit` clean, full suite green at 145/145 (added: grouping/unknown-propagation
|
||
test, chevron-navigates-only-this-row test, detail-page-shows-only-its-group test; updated the one
|
||
pre-existing test and the `beispieldaten.ts`/`profilAdapter.test.ts` fixtures for the now-required
|
||
`gruppe` field on `SicherheitsPunkt`).
|
||
- Panel: `node --check` clean.
|
||
- Live end-to-end in the browser against the test container: before assigning the new sensor roles, all
|
||
three new rows correctly showed "unbekannt" (amber) and the overall status read "Zustand nicht
|
||
vollständig bekannt" — confirming the flagged consequence above is real and correctly wired, not
|
||
theoretical. Opened Setup, scrolled to "Sicherheit": all five new fields were **already
|
||
auto-suggested** to the exact real entities found earlier (the existing stichworte/device_class
|
||
matching worked without any manual searching) — saved (one pre-existing, unrelated "doppelt
|
||
zugeordnet" warning about `Front left door`/`Front left window`, present before this change, dismissed
|
||
via the existing "Trotzdem speichern" override). After saving: "Fahrzeug nicht verriegelt" showed red
|
||
(the demo lock entities report unlocked — correct, not a bug), "Türen und Klappen geschlossen" and
|
||
"Fenster und Dach geschlossen" showed green, "Licht ausgeschaltet" showed green. Clicked the chevron on
|
||
row 2: navigated to "Türen und Klappen" showing all four doors, Kofferraum, and Motorhaube individually,
|
||
all green "zu" — confirmed the detail page and the "Kofferraum" relabel both work.
|
||
|
||
Manifest bumped to `2026.8.24.8`. `npm run ota` rerun (`scripts/ota-paket.ps1 -Bauen`): picked up
|
||
`2026.8.24.8` automatically from the manifest, built clean, `bundle.zip` (233.244 Bytes) + sha256 +
|
||
`bundle.json` written — covers both this section's and section M's frontend changes, since neither had
|
||
triggered a bundle rebuild yet. `Installieren.cmd` picks the bundle up automatically; no separate step
|
||
needed for a self-update-based deployment.
|
||
|
||
**Same-day follow-up from live owner feedback (2026.8.24.9):**
|
||
- **"Kein Licht" reverted to "Licht ausgeschaltet"** — the owner's original wording was right; my
|
||
interpretation of their earlier parenthetical note was wrong. Fixed in both frontends.
|
||
- **Locks simplified to one sensor**: dropped `HECKKLAPPE_SCHLOSS_SENSOR`/`HAUBE_SCHLOSS_SENSOR`
|
||
entirely (owner: central locking means individual locks can't be operated separately, so checking
|
||
them adds nothing), and `TUERSCHLOSS_SENSOREN` (4 positions) collapsed to a single
|
||
`TUERSCHLOSS_SENSOR` — the driver's door lock stands for the whole vehicle, same reasoning. `"Fahrzeug
|
||
verriegelt"` is now one sensor check, not six. No frontend code changes needed for this — both
|
||
`sicherheitsGruppe()`/`gruppenErgebnis()` only ever consume the flat, `gruppe`-tagged list generically,
|
||
never reference specific sensor-role names.
|
||
- **Real, previously-undiscovered bug found via the owner reporting "Nächster Service still empty" on a
|
||
genuinely blank instance** (Servicebuch cleared AND `erstzulassung` also blank - the owner's own test
|
||
container ended up in exactly this state): `naechsterTermin()`'s comparator, `(a, b) => a.ziel -
|
||
b.ziel`, silently coerces a `null` `ziel` to `0` (the Unix epoch) in JS arithmetic. A candidate with
|
||
genuinely no data (no Servicebuch entry, no vehicle meldung, and for Hauptuntersuchung specifically no
|
||
`erstzulassung` to fall back to) therefore sorted as "nearest" ahead of a *different* candidate that
|
||
had a real, computed date — because epoch-1970 always beats any real future date arithmetically. This
|
||
starved out Ölwechsel/Inspektion even when section M's meldung-fallback had correctly computed a real
|
||
number for them; the tile just showed "–" under "Hauptuntersuchung" instead. Not a regression from
|
||
section M — this comparator predates it and was always latent, just needed a candidate with a
|
||
genuinely absent `ziel` to surface. Fixed by filtering out `ziel === null` candidates before sorting,
|
||
returning `null` only when literally none of the three have a usable date (matching `vHome()`'s
|
||
existing, previously-dead `if (!t)` "kein Eintrag im Servicebuch" branch, which can now actually be
|
||
reached). companion-app's `naechsterService()` was checked and confirmed unaffected: it only ever
|
||
pushes a candidate when a real computed value exists (no null-`datum` candidates ever enter the array)
|
||
and sorts ISO date *strings* via `localeCompare()`, not raw arithmetic — structurally immune to this
|
||
class of bug.
|
||
- **Root cause of the browser not showing my fixes at first**: `audi-dashboard-app.js` is fetched by the
|
||
frontend with a cache-busting `?v=<manifest version>` query string. Editing the file on disk without
|
||
also bumping the manifest version left the URL unchanged, so the browser kept serving a cached copy
|
||
(confirmed via `performance.getEntriesByType('resource')` showing `transferSize: 0`) even across a
|
||
forced navigation and a hard-reload keystroke. Bumping the version forced a real refetch. Worth
|
||
remembering for any future panel-JS-only fix: a version bump isn't just the project's documentation
|
||
rule, it's load-bearing for actually seeing the change in a running browser tab.
|
||
|
||
Manifest bumped to `2026.8.24.9` for this follow-up round.
|
||
|
||
**A second same-day follow-up round (2026.8.24.10 / .11), more live owner feedback:**
|
||
- **Locks simplified further to a single sensor**: `TUERSCHLOSS_SENSOREN` (4 positions) collapsed to
|
||
one `TUERSCHLOSS_SENSOR` — owner: the central locking system locks/unlocks every door together, an
|
||
individual lock can't be operated separately, so the driver's door alone represents the whole
|
||
vehicle's lock state. "Fahrzeug verriegelt" is now exactly one sensor check. No frontend changes
|
||
needed (same reason as the Kofferraum/Motorhauben-Schloss removal above: both grouping helpers only
|
||
ever consume the flat, `gruppe`-tagged list, never a specific sensor-role name).
|
||
- **`vSicherheit()`'s route title renamed from "Sicherheit" to "Fahrzeugstatus"** in the panel, matching
|
||
what companion-app's `navigation.ts` already called this page (`TITEL.sicherheit`) — the two frontends
|
||
had drifted apart on this one label. The `FELDER` catalog's `"sicherheit"` *sensor-role group* heading
|
||
(Setup-Menü section, a different concept entirely — sensor categorization, not a page name) is
|
||
untouched.
|
||
- **A real, previously-undiscovered bug in `naechsterTermin()` (panel), found from the owner reporting
|
||
"I deleted everything in Servicebuch, why isn't Ölwechsel shown on Übersicht?"**: the sort comparator
|
||
`(a, b) => a.ziel - b.ziel` coerces a `null` `ziel` to `0` (the Unix epoch) under JS arithmetic. On
|
||
this owner's test container, both the Servicebuch *and* `erstzulassung` ended up empty at once, so the
|
||
Hauptuntersuchung candidate's `ziel` was genuinely `null` (no entry, no `erstzulassung` fallback
|
||
either) — and epoch-1970 sorts before any real future date, so HU won "nearest" over Ölwechsel even
|
||
though section M's meldung-fallback had correctly computed a real number for Ölwechsel. The tile
|
||
rendered "–" under "Hauptuntersuchung" instead of the real Ölwechsel figure. Not a regression from
|
||
section M — this comparator predates it and was always latent, just needed a candidate with a
|
||
genuinely absent `ziel` to expose it. Fixed by filtering out `ziel === null` candidates before
|
||
sorting, returning `null` only when literally none of the three have a usable date (the existing
|
||
`if (!t)` "kein Eintrag im Servicebuch" branch in `vHome()` can now actually be reached — it never
|
||
could be before, since `termine()` always returns exactly three entries and the old comparator always
|
||
produced *some* truthy result). companion-app's `naechsterService()` was checked and confirmed
|
||
structurally immune: it only ever pushes a candidate when a real computed value exists, and sorts ISO
|
||
date *strings* with `localeCompare()`, never raw arithmetic on a possibly-`null` value.
|
||
- **"Licht ausgeschaltet" reverted back from "Kein Licht"** — the owner's original wording was right;
|
||
my read of their earlier parenthetical was wrong. Fixed in both frontends' idle/ok-state label.
|
||
- **Cache-busting gotcha hit again, worse this time**: after the `naechsterTermin()` fix, the browser
|
||
kept showing the *old* broken behavior even after a forced navigation **and** a hard-reload keystroke
|
||
(`ctrl+shift+r`) — `performance.getEntriesByType('resource')` still showed `transferSize: 0` for
|
||
`audi-dashboard-app.js` afterward, meaning even a hard reload didn't bypass this particular cache.
|
||
Only bumping the manifest version (changing the `?v=` query string HA embeds when registering the
|
||
panel's script URL) forced a real refetch. Confirmed this isn't just a documentation nicety: **any**
|
||
panel-JS-only edit needs a version bump to actually be visible in a running browser tab, full stop —
|
||
a hard reload is not a reliable substitute.
|
||
- **Wide/desktop layout headline fixed** (`audi-dashboard-ios.css`, `@container (min-width:860px)`
|
||
block): sub-pages reached via the back arrow (Standort, Service, Fahrzeugstatus, Identität und
|
||
Technik, everything with `.back.on`) were rendering their title at `font-size:26px` — the owner
|
||
described this as "bold" in wide/desktop mode versus "not bold, centered" on the phone layout. There
|
||
is no actual `font-weight` bump anywhere in this stylesheet (`.title` is `font-weight:400`
|
||
everywhere, confirmed by grepping the whole file) — the 26px size alone, against an app that mostly
|
||
uses weight-300 body text (see `VERSIONIERUNG.md`/working-conventions "font weights 300/400 only"),
|
||
reads as heavy at a glance even at the *same* weight as every other title. This also violates the iOS
|
||
Human Interface Guidelines pattern this stylesheet otherwise deliberately follows: a "large title"
|
||
belongs to root/tab pages (`.title{font-size:34px}`, unscoped by `.back.on`, left untouched — the
|
||
owner didn't flag `Mein Audi`'s own big top-level title as wrong), while pushed/detail pages keep a
|
||
small, standard title regardless of screen size. Fixed by dropping the back-button-page override to
|
||
`font-size:17px` — the exact same value already used for these same pages in the narrow/phone layout
|
||
(`.phone:has(.back.on) .title` at the file's un-scoped, phone-default rule) — for genuine
|
||
cross-breakpoint consistency, not just a smaller number. Left `text-align:left` as-is: that's a
|
||
property of the wide layout's sidebar+column grid (`.topbar{justify-content:flex-start}`,
|
||
`.head{align-items:flex-start}`, both still scoped to `.back.on` and untouched), consistent with how
|
||
the *also-left-aligned* top-level large titles behave in this same layout — normal desktop-app
|
||
convention, not the trait the owner was actually flagging. Verified live at an actual `1280×800`
|
||
viewport (the container-query breakpoint needs a real wide render, not just the browser tool's
|
||
`desktop` preset, which turned out to stay narrow) — before the fix, "Service"/"Standort" rendered
|
||
visibly larger than their phone-layout counterparts; after, both match.
|
||
|
||
Manifest bumped to `2026.8.24.11` across this round. `npm run ota` rerun once more at the end to keep
|
||
the OTA bundle's embedded version aligned with the manifest (no further companion-app source changes
|
||
after the "Licht ausgeschaltet" text fix earlier in this same round, so this rebuild only updates the
|
||
stamped version number, not the bundle contents).
|
||
|
||
**A third same-day fix (2026.8.24.12): the update-button scroll jump, specifically in wide/desktop
|
||
mode.** The owner reported the page visibly jumps when pressing "Auf Update prüfen"/"Update
|
||
installieren" on a large screen. Reproduced live at `1280×800`: clicking the button while scrolled deep
|
||
into "Einstellungen" (`scrollTop` ~1773) landed at `scrollTop` ~1517 after the click resolved — a real,
|
||
non-zero shift, not the element being replaced (`main#view` stayed `isConnected: true` throughout, so
|
||
it wasn't a full re-mount either) and not simply `scrollTop` reset to 0 the way section L's
|
||
narrow-mode work had assumed for this kind of bug. `render()`'s own scroll-preservation
|
||
(`v.scrollTop = gleicheAnsicht ? merkeScroll : 0`) should apply here (same route, same id) and normally
|
||
does — the update flow specifically still lost position, most likely because the service call also
|
||
triggers a *separate*, second re-render path outside `render()`'s own bookkeeping (`sensor.audi_dashboard_app_version`
|
||
changing state reaches the embedded panel through Home Assistant's own `set hass()` plumbing, not
|
||
through this app's `route`/`letzteAnsicht` mechanism at all) — plausible but not conclusively traced
|
||
before the owner asked to move on rather than keep chasing the exact mechanism. Fixed pragmatically
|
||
instead of surgically: the `data-update` click handler for `pruefen`/`installieren` now captures
|
||
`$("#view").scrollTop` once before acting, and re-asserts it via `requestAnimationFrame` after *each*
|
||
of its own `render()` calls (both the immediate one and the one in `.finally()`) — this corrects the
|
||
position regardless of what else might be moving it, without needing to identify or touch whatever the
|
||
second path turns out to be. Verified live at `1280×800`: repeated the exact same scroll-deep-then-click
|
||
sequence that reproduced the bug: **no visible jump**, page stayed on the same section
|
||
before and after. (Separately noticed, not fixed, not in scope of what was asked: the check's *result*
|
||
only shows up in the currently-open tab after a full page reload, even though it's correctly persisted
|
||
server-side immediately — confirmed via `docker logs` showing the check completed and a subsequent
|
||
fresh page load showing the right "Verfügbare Version" — likely because nothing in this flow re-reads
|
||
`sensor.audi_dashboard_app_version`'s `daten` attribute into `INTEGRATION_UPDATE` and re-renders when
|
||
that entity's state changes out from under the open tab. Pre-existing, not caused by this fix, not
|
||
reported by the owner as a problem — worth a look if it ever is.)
|
||
|
||
Manifest bumped to `2026.8.24.12` for this fix.
|
||
|
||
**A fourth same-day fix (2026.8.24.13): the two "needs a manual page refresh" reports the owner filed
|
||
right after — turned out to be the exact gap flagged (but not investigated) at the end of the previous
|
||
paragraph.** Two distinct reports, one shared root cause on the panel side:
|
||
|
||
1. **"Update installieren" needs a manual page refresh to see the restart button.** Traced to
|
||
`datenLaden()`: it unconditionally reads `sensor.audi_dashboard_app_version`'s `daten.integration_update`
|
||
into the module-level `INTEGRATION_UPDATE` variable on every tick, but only calls `render()` inside the
|
||
`geaendert` block — which only compares `profilState`/`fahrtenState`/`tankState`/`statusState`/`battState`
|
||
identity, never `versionState`. A version-only state change (exactly what `update_pruefen`/
|
||
`update_installieren` produce) updated the in-memory value but never triggered a re-render on its own —
|
||
the UI only caught up whenever some unrelated entity happened to change next, which in practice meant
|
||
"never, until a manual reload re-initializes everything from scratch." Fixed by tracking `letzterVersionState`
|
||
the same way the other five states are tracked, and calling `render()` directly whenever `versionState`'s
|
||
identity changes and nothing else already triggered one (a small `if (versionGeaendert && DATEN_GELADEN) {
|
||
render(); return; }` right after the existing `geaendert` block, left untouched).
|
||
2. **The "Home Assistant startet neu …" spinner also needs a manual page refresh after the restart
|
||
actually completes.** Root cause was different: `INTEGRATION_UPDATE_LAEUFT` is set to `"neustart"`
|
||
right before calling the `homeassistant.restart` service, and the *only* place that ever cleared it
|
||
back to `null` was the `.catch()` error handler on that same call — deliberate, per section L's original
|
||
comment, since a successful restart normally disconnects the page before the success path could run
|
||
anyway. But nothing ever picked it back up on the other side: once the websocket reconnects after HA
|
||
comes back, `set hass()` fires again (`HASS` was never nulled out while disconnected, so this isn't the
|
||
`erster`/first-connect path), but no code ever reset the stuck `"neustart"` flag, so the spinner stayed
|
||
forever. Fixed by listening once for the underlying `home-assistant-js-websocket` connection's own
|
||
`"ready"` event (fires on every successful (re)connect, confirmed live via
|
||
`HASS.connection.addEventListener`) — wired in `set hass()`, guarded by a new module-level
|
||
`VERBINDUNG_UEBERWACHT` flag so it's only attached once rather than once per `hass` push — and clearing
|
||
`INTEGRATION_UPDATE_LAEUFT`/re-rendering there if it was still `"neustart"`.
|
||
|
||
**Checked companion-app for the same two bugs (parity rule) rather than assuming React's own re-render
|
||
model made it immune:** bug 1 does *not* reproduce there — `updatePruefenAusloesen()`/
|
||
`updateInstallierenAusloesen()` in `Einstellungen.tsx` already `await neuLaden()` right after the service
|
||
call resolves, which re-fetches `appVersionAngabeLesen()` directly over REST and updates React state
|
||
synchronously, no push-event dependency. Bug 2, however, **does** reproduce identically: `updateNeustartLaeuft`
|
||
is local `useState` in `Einstellungen.tsx`, deliberately left out of the `try/finally` (same reasoning as
|
||
the panel), and nothing else ever reset it — `DatenKontext.tsx`'s own reconnect handling
|
||
(`api.live.aufVerbindung()` → `neuLaden()` on `"verbunden"`) only refreshes the *shared* `integrationUpdate`
|
||
context value, which this screen-local boolean doesn't derive from. Fixed with a small `useEffect` in
|
||
`Einstellungen.tsx` watching the already-available `verbindung` context field, clearing
|
||
`updateNeustartLaeuft` when it flips to `"verbunden"` while still `true`.
|
||
|
||
**Verified (panel, first pass):** `node --check` clean. Live in the test container at `2026.8.24.13`:
|
||
clicked "Update installieren" (Gitea test remote genuinely offered `.12`, since that's what had last been
|
||
pushed there) — the tile transitioned on its own from "Installiere …" straight to "Version 2026.8.24.12
|
||
installiert … Jetzt neu starten" with **no page reload**, confirming bug 1 fixed. Clicked "Jetzt neu
|
||
starten" and waited for the container to fully restart — this is where a third, previously-undiscovered
|
||
bug surfaced (below), which blocked a clean confirmation of bug 2 until it was also fixed.
|
||
|
||
**A third bug found while verifying bug 2 for real (2026.8.24.14), not a new report — an already-known,
|
||
already-commented race in the code that a genuine backend restart finally exercised end to end.** After
|
||
the real `homeassistant.restart` completed, the browser tab reconnected but the panel stayed stuck on the
|
||
static "Lädt …" placeholder indefinitely — not the Einstellungen page with a lingering spinner (bug 2's
|
||
literal symptom), but back on Übersicht, showing nothing. Traced to: Home Assistant's `panel_custom`
|
||
mechanism recreates the `<audi-dashboard-panel>` custom element from scratch after a real backend restart
|
||
(confirmed live — a fresh `_aufbauen()` run, fresh skeleton DOM), but the JS *module* itself is not
|
||
reloaded, so all its module-level state (`CONFIG`/`CAR`/`DATEN_GELADEN`/etc.) survives untouched from
|
||
before the restart. The fresh element's first `datenLaden()` call didn't reliably produce a `render()`
|
||
(the exact scenario `go()`'s own existing comment already describes: *"Bekanntes Race … auf einem langsam
|
||
startenden Backend kann die App auf 'Lädt …' hängen bleiben … der bislang einzige beobachtete Ausweg war
|
||
ein Tabwechsel"*), and — unlike `go()`, which unconditionally calls `render()` on every tab click regardless
|
||
of this race — nothing about `_aufbauen()` itself ever painted anything beyond the static skeleton.
|
||
Confirmed live: clicking any tab immediately showed full correct data (proving `CONFIG`/`CAR` were fine in
|
||
memory all along — only the *paint* was missing), exactly matching `go()`'s own workaround comment.
|
||
Fixed the same way `go()` already does it, just made unconditional instead of click-triggered: `_aufbauen()`
|
||
now calls `render()` immediately after building a fresh skeleton, if `DATEN_GELADEN` is already `true` (i.e.
|
||
this is a rebuild after already having loaded once, not the very first paint — that case is correctly left
|
||
to `datenLaden()`'s own arrival). This is the real, complete fix for "the app doesn't recover cleanly after
|
||
HA restarts without user interaction" — bug 2's spinner-not-clearing report was one visible symptom of it;
|
||
this fix (plus the earlier `"ready"`-listener reset of `INTEGRATION_UPDATE_LAEUFT`) is what makes the whole
|
||
class of "just restarted, tab open, nobody touches it" scenario self-heal.
|
||
|
||
**Verified (panel, final pass) at `2026.8.24.14`:** `node --check` clean. Triggered a real
|
||
`homeassistant.restart` directly (`HASS.callService("homeassistant", "restart", {})` from the open tab, no
|
||
button click, to isolate this from the update-install flow specifically), confirmed via `docker logs` that
|
||
the integration set up a second time end to end (`s6-rc` stop/start sequence, "Audi Dashboard 2026.8.24.14
|
||
eingerichtet" logged twice). The browser tab — never manually reloaded, never clicked — automatically showed
|
||
full real content (vehicle name, range, "Nächster Service", …) the moment it reconnected. No stuck "Lädt …",
|
||
no manual refresh, no tab click needed. This also confirms bug 2 properly this time (the spinner-clearing
|
||
`"ready"` listener from the first pass runs as part of the same reconnect path this test exercises).
|
||
- companion-app: `tsc --noEmit` clean, full suite still green at 145/145 (no behavior change needed
|
||
testing beyond the existing suite for its own bug-2 fix — a reconnect-driven reset of already-tested
|
||
local state, not new business logic; the deeper element-recreation race is panel-specific, since
|
||
companion-app is a normal React SPA that doesn't get its component tree torn down and rebuilt by an
|
||
external host on backend restart).
|
||
|
||
Manifest bumped to `2026.8.24.13`, then `2026.8.24.14` once the deeper race above was found and fixed in
|
||
the same live-testing session. `npm run ota` not rerun yet for this round — only the `useEffect` addition
|
||
in `Einstellungen.tsx` touches companion-app source (the panel-side fixes don't affect the OTA bundle,
|
||
which ships companion-app only), no OTA bundle rebuild triggered until the owner asks or the next round
|
||
bundles it together with something else.
|
||
|
||
## O. Single-trip duration formatting, and a defensive error boundary for the companion-app (2026.8.24.15/.16)
|
||
|
||
**Bug report: the single-trip detail view ("Einzelfahrt") showed raw minutes ("75 min") instead of an
|
||
hours-and-minutes breakdown.** Traced to `vTrip()` in the panel using `Math.round(t.duration_s / 60)}
|
||
min` directly instead of the already-existing `dauerText()` helper, which every *other* duration display
|
||
in the panel already goes through (the trip-edit form's live duration field, `fahrtDauerText()`). Fixed by
|
||
routing `vTrip()`'s "Dauer" row through `dauerText()` too. Manifest bumped to `2026.8.24.15`.
|
||
|
||
**Follow-up correction (2026.8.24.16): the owner explicitly rejected `dauerText()`'s own output format.**
|
||
`dauerText()` produced `"1 Std. 15 Min."` — technically consistent with the rest of the panel at the time,
|
||
but not what was wanted. Explicit ask: `"1 h 10 min"` exactly. This happens to be the *exact* format
|
||
companion-app's own `format.ts` `dauer()` helper already produces (`"2 h 14 min"` / `"14 min"`, 2-digit
|
||
padded minutes) — so `dauerText()` was changed to match it precisely (`std ? "${std} h
|
||
${String(min % 60).padStart(2, "0")} min" : "${min} min"`), rather than inventing a third convention.
|
||
Since `dauerText()` is the shared helper, this fixes both the trip detail view and the trip-edit form's
|
||
live duration display in one change. Verified live in the test container (real restart to pick up the new
|
||
manifest version, not just a copied file — the panel's module URL is versioned from the *in-memory*
|
||
version read at integration setup, so a filesystem-only change isn't visible until either a restart or an
|
||
integration reload): opened a real 7200 s trip, confirmed the row reads exactly `"Dauer 2 h 00 min"`.
|
||
|
||
**Separate, unrelated report investigated the same session: "Update prüfen" in the companion-app leads to
|
||
a black page; only navigating away and back to Einstellungen shows the result.** Reviewed the whole call
|
||
path in detail — `updatePruefenAusloesen()`/`api.updatePruefen()`/`DatenKontext.neuLaden()` — and found
|
||
nothing that should throw: every state field the Einstellungen screen reads off `integrationUpdate` is
|
||
already optional-chained, `neuLaden()` catches its own errors, `ActionButton`'s default `type="button"`
|
||
rules out an accidental form submit/navigation. Could **not** reproduce live this session: reproducing
|
||
needs a real Home Assistant long-lived access token to connect the companion-app dev server to the test
|
||
backend, and generating one (even for the disposable local test container) was refused by the standing
|
||
"never handle auth/secret creation" rule — this needed the owner's own action, not a workaround.
|
||
|
||
**What was found instead, and fixed regardless of the exact root cause: the app had zero React error
|
||
boundaries anywhere.** `main.tsx` rendered `<App />` directly. In React, an uncaught exception during
|
||
render anywhere in the tree unmounts the *entire* app — `#wurzel` goes empty, and what's left on screen is
|
||
just the page's own background, which in Nacht/dark theme is close to black. That matches the "black page"
|
||
report closely: not a stuck loading state (which has its own distinct `.dm-laden` visual and no plausible
|
||
trigger tied to a version-check REST call), but a silent full unmount with no error surfaced anywhere —
|
||
consistent with "only navigating away and back fixes it" too, since re-entering the screen remounts the
|
||
tree from scratch and the triggering state is gone. Added `src/ErrorGrenze.tsx` (a class component — error
|
||
boundaries require `componentDidCatch`/`getDerivedStateFromError`, hooks can't do this), wired around
|
||
`<App />` in `main.tsx`. On a caught error it now shows a visible "Etwas ist schiefgelaufen" tile with the
|
||
error message and a "Neu laden" button, and logs the error (plus component stack) to the console — instead
|
||
of a silent blank screen. This does not identify the original root cause (if the report reproduces again,
|
||
the console will now show what actually threw, which is the fastest path to the real fix), but it directly
|
||
addresses the reported symptom either way: a future crash is visible and recoverable, not indistinguishable
|
||
from a hang. `tsc --noEmit` clean, full suite still green at 145/145 (behavior of the happy path is
|
||
unchanged; the boundary only activates on an actual render exception, which no existing test triggers).
|
||
|
||
## P. Trip splitting/data-quality bugs traced to the FMM003 losing signal mid-drive (2026.8.25.1)
|
||
|
||
**Bug report: "gaps within one trip" after `Daten importieren aus HA`, and separately a trip with
|
||
physically impossible data (implausible average speed).** Both traced to the same underlying cause: the
|
||
FMM003 (the Teltonika GPS tracker that has been the ignition/GPS/km source since the 2026.8.13 switch away
|
||
from the iPhone-WiFi detector, see `SPECIFICATION.md`'s "Teilweise überholt" banner) drops its cellular/GPS
|
||
connection intermittently while the car is still being driven — parking garages, tunnels, dead zones. When
|
||
that happens the ignition entity (`ZUENDUNG_SENSOR`) goes to Home Assistant's `unavailable` state for a
|
||
while, then reports `on` again once the FMM003 reconnects, even though the ignition was never actually off.
|
||
|
||
**Root cause 1 — live trip detection treated "no signal" as "ignition off":**
|
||
`Koordinator._zuendung_geaendert()` (`koordinator.py`) fires on *every* `state_changed` event for the
|
||
ignition entity, `unavailable` included, and passed the raw state straight into
|
||
`fahrterkennung.zuendung_geaendert()`. That function computed `an_vorher = alt == "on"`; when the entity
|
||
resumed reporting `on` after an `unavailable` blip, `alt` was `"unavailable"`, not `"on"`, so
|
||
`an_vorher` was `False` — the code read this as "ignition just turned on", i.e. a *new* trip, splitting
|
||
one continuous drive into two the moment an FMM003 dropout outlasted the configured pause window
|
||
(`fahrten_pausenzeit_min`, 15 min default). Fixed in `fahrterkennung.py`: `zuendung_geaendert()` now
|
||
returns immediately (no-op) when the new state isn't literally `"on"` or `"off"` — `unavailable`/`unknown`
|
||
carry no information about the ignition and must not be treated as either. The "is a trip currently
|
||
running" check was also switched from `alt == "on"` to the coordinator's own `fahrt_start_ts is not None`,
|
||
which is the actual ground truth and no longer depends on what the entity's state happened to be during a
|
||
dropout.
|
||
|
||
Historical import (`historienimport.py`'s `_fahrtfenster()`) was **not** affected by this — it reads
|
||
`verlauf_lesen()`, which already drops `unavailable`/`unknown` points entirely (`verlauf.py`'s
|
||
`_rohverlauf()`), so a dropout never registers as an "off" transition there. That's also why re-running
|
||
the import over an already-affected period doesn't repair it: the already-split live trips exist as two
|
||
separate stored records, and the import's overlap check (`_ueberschneidet()`) skips anything that overlaps
|
||
an existing trip rather than merging. Going forward (with the live fix above) new drives won't split in
|
||
the first place; already-split historical entries would need a manual merge (not built — out of scope for
|
||
this round, flag if it comes up again).
|
||
|
||
**Root cause 2 — no plausibility check on imported trip distance:** separately, a physically-impossible
|
||
average speed was possible whenever `wert_bei()` matched a stale odometer reading across a data gap
|
||
(sensor outage, sparse reporting) at one edge of a trip window. Added `UNPLAUSIBLE_KMH = 300` in
|
||
`historienimport.py`: if a trip's computed average speed exceeds it, the distance/odometer fields are
|
||
dropped (trip still gets created, `status` falls back to `"offen"` — same "data missing, not zero"
|
||
semantics already used everywhere else in this file) and a warning is logged with the trip window and the
|
||
rejected numbers, rather than silently showing an impossible figure. `screening.py` (the live-trip
|
||
km-completion path) has no equivalent guard yet — not touched this round since it wasn't what was reported,
|
||
but worth applying the same idea there if a similar impossible-speed trip shows up on a live-detected trip.
|
||
|
||
Verified: both files `py_compile` clean, copied into the `audi_ha_test` container, manifest bumped to
|
||
`2026.8.25.1`, container restarted, confirmed `"Audi Dashboard 2026.8.25.1 eingerichtet"` in the log with
|
||
no setup errors. Live reproduction of an actual FMM003 dropout wasn't attempted (needs a real signal
|
||
outage while driving, not reproducible in the test container) — this is a static-analysis-grounded fix,
|
||
not one confirmed against a captured dropout event.
|
||
|
||
## Q. Optional direct-liter fuel sensor, for more accurate refill detection (2026.8.25.2)
|
||
|
||
**Feature request: the automatic refill detection (`tankerkennung.py`) only ever had the tank's percent
|
||
sensor to work with**, converting the "+5 L" threshold into percentage points via
|
||
`fahrzeug.tankvolumen_liter` from the vehicle profile. Accurate enough to *detect* a refill, but it can
|
||
never tell the app how many liters were actually put in — the auto-created tank record's `liters` field
|
||
stayed `None` until a receipt was uploaded or the owner typed it in by hand. The FMM003 (see section P)
|
||
reads the tank volume directly from CAN in liters on this instance, alongside the existing percent reading
|
||
— a second, independent signal that doesn't need the profile-based conversion and, being already in
|
||
liters, hands over a real estimate of the refilled amount for free.
|
||
|
||
Added `TANK_LITER_SENSOR` as a new, optional role in `einstellungen.py` (same `FELDER` catalog pattern as
|
||
every other role — the setup menu picks it up automatically, no frontend work needed, confirmed by the
|
||
same reasoning as the pending Sicherheit-Neugestaltung plan). Wired into `koordinator.py` exactly like
|
||
`TANK_SENSOR`: its own state-change observer, its own persisted low-point (`tiefststand_liter`, stored and
|
||
restored across restarts the same way `tiefststand_pct` already was).
|
||
|
||
`tankerkennung.py` gained `tankvolumen_geaendert()`, the liter-based twin of the existing
|
||
`fuellstand_geaendert()` — same low-point-tracking logic (a refill often arrives in several small reported
|
||
steps, so the comparison is against the last-seen low point, not the immediately previous value), but the
|
||
threshold is `LITER_SCHWELLE` (5 L) directly, no percentage conversion. **Whichever sensor is assigned,
|
||
only one of the two paths ever creates a tank record for the same event**: `fuellstand_geaendert()` now
|
||
checks `TANK_LITER_SENSOR` and stays silent if it's assigned, since the liter path is strictly more
|
||
accurate and would otherwise double up the same refill into two records. Unassigned, behavior is
|
||
unchanged — pure percent-based detection exactly as before, zero-risk to any existing setup. When the
|
||
liter path fires, the detected increase is written into the new record's `liters` field (rounded to 0.1 L)
|
||
as a rough starting value — the record still lands as `status: "unvollständig"` (price/station still
|
||
unknown), same as always; the owner corrects/confirms it via the existing inline edit form (panel) or a
|
||
receipt upload (both codebases), no new UI needed for this either.
|
||
|
||
Extended `historienimport.py`'s `_tankvorgaenge_importieren()` the same way, for the same reason section P
|
||
called out for trips: leaving the historical import on the old percent-only path while live detection uses
|
||
the more accurate liter path would have reintroduced exactly the kind of live/import inconsistency that
|
||
was just fixed there. It now reads `TANK_LITER_SENSOR`'s history too (added to the `verlaeufe` dict in
|
||
`importieren()`) and prefers it under the identical condition as the live path.
|
||
|
||
Verified: all four touched files (`einstellungen.py`, `koordinator.py`, `tankerkennung.py`,
|
||
`historienimport.py`) `py_compile` clean in the `audi_ha_test` container; manifest bumped to `2026.8.25.2`,
|
||
restarted, confirmed `"Audi Dashboard 2026.8.25.2 eingerichtet"` with no errors. Did not verify the new
|
||
setup-menu entry by clicking through it live (a browser-tooling networking issue blocked reaching the test
|
||
instance's UI this round) — relying on the FELDER catalog being genuinely code-driven, per the same
|
||
confirmed mechanism the pending Sicherheit plan already documented. `screening.py`/companion-app's
|
||
`TankDetail.tsx` were deliberately left untouched: the estimate flows through existing display/edit paths
|
||
without needing either to change.
|
||
|
||
## R. Five owner-requested features in one round: battery measurement list, swipe-delete, diagram-width fix,
|
||
CI icon substitutions, wheel-set archiving (2026.8.25.3/.4)
|
||
|
||
**1. Battery measurement list.** The battery-voltage tile only ever showed a hand-drawn chart. Added a
|
||
"list" corner button (CI `list-s`) that opens a new list view — one row per day (the existing daily
|
||
min/max data, unchanged storage shape) showing date, time, voltage, and now also outside air temperature.
|
||
Backend: new optional `AUSSENTEMP_SENSOR` role (`einstellungen.py`); `batterie.py`'s 5-minute check now also
|
||
reads it and passes it to `ablage.batterieverlauf_tageswert_aktualisieren()`, which gained an `aussentemp`
|
||
parameter — stored as `min_temp_c`, **only updated when a measurement also sets that day's minimum voltage**
|
||
(the temperature is tied to that specific reading, not a daily average). Older entries and setups without
|
||
the sensor simply carry `min_temp_c: None` — no backfill, no migration. New screens: companion-app
|
||
`BatterieListe.tsx` (route `battliste`, reached from an `IconButton`/`SymbolListe` in `Batterie.tsx`'s
|
||
header) and panel `vBatterieliste()` (route `battliste`, reached via a new `.iconbtn`/`ciSVG(CI.listS)`
|
||
button in `vBatterieverlauf()`'s tile header).
|
||
|
||
**2. Swipe-to-delete for the new list, per entry.** Reused the existing pattern in both codebases rather
|
||
than inventing a new one: companion-app's `Zeilenmenue` (wipe + accessible menu button, see its own
|
||
docstring) and the panel's `swipeZeile()`/`eintragLoeschen()`/`LOESCH_TEXT`/`LOESCH_HINWEIS` machinery
|
||
already used for trips/tank vorgaenge, extended with a `"batt"` art. New backend service
|
||
`batterieverlauf_loeschen` (field: `datum`) → `ablage.batterieverlauf_eintrag_loeschen()` (new) →
|
||
`batterie.eintrag_loeschen()` (new, wired in `dienste.py`).
|
||
|
||
**3. Battery diagram "still stretched" on a large monitor — panel only (confirmed with the owner; the
|
||
diagram in question doesn't exist as a separate wide layout in companion-app).** Root cause found in
|
||
`audi-dashboard-ios.css`: past 860px viewport width the whole app switches to a wide two-column layout
|
||
(`@container (min-width:860px)`) where `main#view`'s content column is 772px wide (860 − 2×44px side
|
||
padding). The battery chart's SVG (`#bvSvg`, `audi-dashboard-app.js`) is drawn on a fixed `viewBox="0 0 320
|
||
170"` with `preserveAspectRatio="none"` and `width:100%` — at 772px wide against a native 320-unit design,
|
||
that's a ~2.4× horizontal stretch with the height staying fixed at 170px, turning the chart into an
|
||
unnaturally wide, flat band. Fixed the same way the existing popups in that file already solve the identical
|
||
problem one section up: `#bvChart{max-width:400px;margin-left:auto;margin-right:auto}` inside the same
|
||
`@container` block, no changes to the SVG's own drawing logic (`BV_W`/`BV_H`/`bvZeichnen()`), which section
|
||
O of this file already spent real effort getting right for a *different* stretch bug (the Y-axis
|
||
rescaling one) — deliberately left untouched here.
|
||
|
||
**4. CI icon substitutions — `list-l/-s`, `polls-l/-s`, `package-l/-s`, all requested by the owner
|
||
(SVG source supplied directly from their local Audi icon export folder, same as every other CI icon in this
|
||
codebase — see the recurring "vom Nutzer als SVG geliefert, verbatim uebernommen" comment convention).**
|
||
Added to the `CI` catalog in `audi-dashboard-app.js`. `polls-s` replaces the Statistik tab's hand-drawn bar
|
||
icon (`ICONS.stat`) in the panel's tab bar — needed adding `"stat"` to `ICONS_VOLL` (the existing mechanism
|
||
for filled-vs-outline icon rendering; CI paths carry their own `fill="currentColor"` but would otherwise
|
||
also inherit the tab bar's `stroke:currentColor` from `.tab svg`, doubling the line weight around the
|
||
shape). Companion-app's `symbole.tsx` had no filled-icon mechanism at all yet (its `SymbolAudi` etc. are
|
||
plain stroked outlines, unlike the panel's CI-icon-based tab bar) — added a `voll` prop to the shared
|
||
`Rahmen` wrapper (`fill`/`stroke` swap) as the equivalent mechanism, then `SymbolStatistik` (parity with
|
||
the panel), plus `SymbolListe`/`SymbolPaket` for the other two icon uses below. `list-s`/`package-s` are the
|
||
corner/inline buttons described in points 1 and 5; the `-l` (48-grid) variants are stored for parity with
|
||
every other CI icon pair in the catalog but aren't wired to a render path yet, same as the pre-existing
|
||
`editL`/`oilChangeL`/etc.
|
||
|
||
**5. Wheel-set archiving ("Neue Räder anlegen").** A `package-s` button sits between the wheel photo and
|
||
the editable Hersteller/Modell fields on the Reifen page in both codebases (panel: inline always-editable
|
||
`data-rf` inputs, matching that page's existing convention; companion-app: read-only `Wertzeile`s with a
|
||
separate edit form, matching *its* existing convention — no new interaction pattern introduced in either).
|
||
Pressing it asks "Neue Räder anlegen?" (panel: the existing `bestaetigen()` action sheet; companion-app: the
|
||
existing `window.confirm`-based `bestaetigen()` already used by `wechseln()` on the same page) before doing
|
||
anything. On confirmation, `reifen.py`'s new `archivieren(k, satz)`: snapshots the season's *entire* current
|
||
record (km, marke, modell, dot, mass, druck_vorne/hinten, kommentar, plus a generated id and today's date as
|
||
`ersetzt_am`) into `reifen.archiv` (newest first), then resets the live record — **km to 0 (explicit owner
|
||
decision) and marke/modell/dot/kommentar to blank** (a replacement tire hasn't been characterized yet),
|
||
**but mass/druck_vorne/druck_hinten/bild carried over** (size and target pressure don't usually change for
|
||
a same-fitment replacement — a judgment call, not something the owner specified; flag if wrong). Three new
|
||
services (`reifen_archivieren`, `reifen_archiv_aktualisieren`, `reifen_archiv_loeschen`), all deliberately
|
||
**bypassing `profilSchreiben`/`profilSpeichern`** for the same reason `reifen_wechseln`/`reifen_km_setzen`
|
||
already do (see `reifenzaehler.py`'s own docstring) — a full-profile round trip from a stale browser copy
|
||
could clobber a km value the backend fortgeschrieben in the meantime.
|
||
|
||
Overview UI: an "Archiv" tile at the bottom of the Reifen page, one row per archived set. Companion-app uses
|
||
the design system's `Accordion` (title = "Sommerräder"/"Winterräder", summary = "Ersetzt am ... · N km"),
|
||
expanding to a read-only `Werteliste` with a "Bearbeiten" button that swaps in an edit form (mirrors
|
||
`TankFormular`'s style) — plus a destructive "Löschen" button. The panel reuses its own existing
|
||
`acc-h`/`acc-b`/`data-acc` accordion mechanism (same as the trips/tankvorgaenge year/month lists) with
|
||
always-inline-editable fields (`data-rfa="id|feld"`, parallel to `data-rf`) that call
|
||
`reifen_archiv_aktualisieren` directly on change, plus a `data-reifenarchivloeschen` button.
|
||
`profilAdapter.ts` gained a `ReifenArchivEintrag` type and `Fahrzeug.reifen.archiv`; `profilZuFahrzeug()`
|
||
reads it straight off the profile (`?? []`), and `zusammenfuehren()` needed no change — it clones the raw
|
||
profile first and only ever touches `aktiv`/`anzugsmoment_nm`/`wechsel`/`saetze`, so `archiv` survives
|
||
untouched exactly like it already did for every other field this function doesn't know about.
|
||
|
||
Verified: `py_compile` clean on all six touched backend files (`einstellungen.py`, `ablage.py`,
|
||
`batterie.py`, `const.py`, `dienste.py`, `reifen.py`) plus `services.yaml` entries added for all four new
|
||
services; `node --check` clean on the panel JS; companion-app `tsc --noEmit` clean and full suite green at
|
||
**146/146** (one new test — the existing "every registered page renders with sample data" smoke test picked
|
||
up `battliste` automatically); `npm run build` and `npm run ota` both succeeded (OTA bundle rebuilt at
|
||
`2026.8.25.4`, sha256 `9d5ebc2a62da4bbddcd766a462726bb51a213ea4095e367e88a8e5d321a9f8e4`). Manifest bumped
|
||
`2026.8.25.2` → `.3` (backend) → `.4` (frontend + OTA), both restarts in `audi_ha_test` confirmed clean via
|
||
`"Audi Dashboard ... eingerichtet"` with no tracebacks, including the periodic battery-check log line
|
||
firing without error against the new `AUSSENTEMP_SENSOR` code path. **Not verified live in a browser** —
|
||
the setup-menu entry for `AUSSENTEMP_SENSOR`, the new list screens, the icon substitutions' actual pixel
|
||
rendering, and the archive accordion/edit flow were none of them clicked through in this round (a
|
||
browser-tooling networking issue blocked reaching the test instance's UI, same blocker noted in section Q).
|
||
This entire round rests on static analysis, compiler/test-suite checks, and clean service-registration logs
|
||
— not on having seen any of it render.
|
||
|
||
## S. Live browser access to the test instance, a "Generatorspannung" remark, and two real bugs section R's
|
||
static-only verification missed (2026.8.25.5–.8)
|
||
|
||
**The owner logged this session into the test instance's browser at `http://localhost:18123` (not the
|
||
`:8123` used earlier - a different, working port for this environment).** This unblocked live verification
|
||
for the first time since the FMM003 investigation's `/profile/security` refusal (section P) — worth noting
|
||
for future sessions: try `:18123` before assuming the browser tooling can't reach this project's test
|
||
container.
|
||
|
||
**Generator-voltage remark (2026.8.25.5):** `AGM_RUHE_MAX_V = 12.8` (the resting-vs-alternator-voltage
|
||
threshold, already used by the diagram's own point filter and the SOC estimate) was not applied to the new
|
||
measurement list from section R — a day whose minimum was never actually measured at rest still displayed
|
||
next to a date/time as if it were one. Companion-app: exported the constant from `Batterie.tsx`, added a
|
||
`Pill` reading "Generatorspannung" in `BatterieListe.tsx` when `min > AGM_RUHE_MAX_V`. Panel: same threshold
|
||
already in scope in `vBatterieliste()`, added a `.pill`-styled badge under the date in the `.v` column.
|
||
|
||
**Live verification then immediately found two real defects that every static check in section R had
|
||
missed** — `py_compile`/`tsc`/`node --check`/the test suite all passed because none of them render CSS:
|
||
|
||
1. **Archive edit fields visually truncated ("Continenta", "SportConta", "275/30 R2").** `vReifenArchiv()`'s
|
||
`feld()` helper unconditionally wrapped every input in `<span class="mitEinheit">` — a class meant for
|
||
short number+unit pairs (km, Nm, bar) that hard-codes `input{width:96px}`. None of the fields that
|
||
actually used this helper (marke/modell/mass/dot/kommentar) needed a unit at all, so the wrapper was pure
|
||
accident, not a deliberate width choice. Fixed by dropping `mitEinheit` from that helper entirely,
|
||
matching the plain `<input>` the live (non-archived) season fields already use one screen up. Confirmed
|
||
live: all three previously-clipped fields now render in full.
|
||
2. **Owner follow-up, also confirmed live before and after:** the archive accordion's collapsed summary
|
||
line squeezed `4.542 km` into a `<small>` immediately after the "Ersetzt am" date, and separately asked
|
||
for the Kommentar/Notiz field specifically to get more room. Both fixed the same way in both codebases:
|
||
Kommentar/Notiz now renders label-above-input at full width (`.feld--breit` in the panel CSS; a
|
||
bypassed-`Feld` block in companion-app) instead of sharing a row with its label like every other short
|
||
field — and the collapsed summary now shows the km figure at title size/color (`.acc-h .k`/
|
||
`.ads-acc-h__k`-equivalent styling) with the date dropped to its own smaller line below, instead of the
|
||
two being run together.
|
||
|
||
**Lesson for this project going forward, worth remembering deliberately:** a clean compile/test pass is not
|
||
evidence a layout is correct — none of the tooling in this repo renders CSS or checks visual width. Section
|
||
R shipped a real, user-visible truncation bug behind a fully green test suite. When browser access is
|
||
available (now confirmed reachable at `:18123`), prefer at least one live screenshot of any new or changed
|
||
input-heavy screen over relying on compiler output alone.
|
||
|
||
Verified: panel `node --check` clean at every step; companion-app `tsc --noEmit` clean, suite green at
|
||
146/146, `npm run build`/`npm run ota` succeeded. Manifest `2026.8.25.4` → `.5` (Generatorspannung) → `.6`
|
||
(mitEinheit fix) → `.7` (Kommentar width + archive header restyle, panel) → `.8` (same two, companion-app
|
||
parity + final OTA rebuild, sha256 `464c294c6c07dcb636a8ea3bd93c7e525b21722defb41f573e2b31fefade49ea`).
|
||
Every restart in `audi_ha_test` confirmed clean via log, **and this time also confirmed live in the browser**
|
||
at each step from `.6` onward — screenshots taken of the battery list (Generatorspannung pill rendering
|
||
correctly, including a real entry below the threshold correctly *not* tagged), the Statistik tab's `polls-s`
|
||
icon, and the Reifen archive section before and after both fixes.
|
||
|
||
## T. Battery plausibility floor, and the archive button moved onto the "Montiert" row (2026.8.25.9)
|
||
|
||
**Owner-set plausibility floor: `SPANNUNG_MIN_V = 8.0` in `batterie.py`.** A 12V lead-acid/AGM battery's
|
||
resting voltage doesn't fall meaningfully below this even deeply discharged — a reading under it is a
|
||
sensor/connection fault, not a real measurement, and per the owner's explicit instruction it must not be
|
||
tracked or listed at all (not just hidden at display time, unlike the existing `AGM_RUHE_MAX_V` upper
|
||
bound in `Batterie.tsx`/`vBatterieverlauf()`, which still stores out-of-range readings and only filters them
|
||
when computing SOC/trend/the chart's own point set). `pruefen()` now rejects a reading below the floor
|
||
before it ever reaches `ablage.batterieverlauf_tageswert_aktualisieren()`, logging a warning instead.
|
||
`historienimport.py`'s `_batterie_importieren()` got the identical floor (imports `SPANNUNG_MIN_V` from
|
||
`batterie.py` rather than duplicating the number) — the recurring live/import parity rule from sections P
|
||
and Q: a stray sub-8V blip in the recorder history must not become a "tracked" day-minimum on import just
|
||
because the live path would have rejected it.
|
||
|
||
**Reifen page: the package-s "Neue Räder anlegen" button now sits on the same row as "Montiert"** (panel
|
||
only — companion-app has no equivalent "Montiert" button in this tile to align it with; its own
|
||
Fahrzeugwechsel button lives in a separate tile entirely, so there was nothing to move there). Was its own
|
||
row below `Montiert` with `justify-content:flex-end`; now both buttons share one
|
||
`justify-content:space-between` flex row. Confirmed live — the two align correctly on one line at their
|
||
respective ends.
|
||
|
||
Verified: `py_compile` clean on `batterie.py`/`historienimport.py`, `node --check` clean on the panel JS,
|
||
manifest bumped to `2026.8.25.9`, `audi_ha_test` restart confirmed clean via log **and confirmed live in the
|
||
browser** (screenshot of the Reifen page showing `Montiert` and the package icon sharing one row).
|
||
|
||
## U. Fahrten start/end position and a real track, live-detected trips included; map tile provider fixed;
|
||
Start/Ziel restructured; Status line removed; battery diagram uses the full column width (2026.8.27.1)
|
||
|
||
**Bug report: "Start and Endposition as well as Track is not visible at Fahrten - even if the Sensors for GPS
|
||
provides Data."** Root cause: `start_lat`/`start_lon`/`end_lat`/`end_lon`/`route` were only ever populated by
|
||
`historienimport.py` (added when `STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR` were introduced, 2026-08-16) -
|
||
the **live** trip-detection path (`fahrterkennung.py`'s `leere_fahrt()`/`fahrt_beenden()`) never filled them,
|
||
so any trip the FMM003 detected in real time (the normal case) stayed without a map, only trips created via
|
||
"Daten importieren aus Home Assistant" ever had one. This was the documented "GPS is dead schema for trip
|
||
records" gap (see the HA-panel known-gaps list above) - now closed for the live path too, using the same
|
||
GPS sensors that already work for the Standort tile and for import.
|
||
|
||
**Backend**, mirroring the existing odometer-screening pattern (`_fahrt_screenen()`/`naechster_wert()`) rather
|
||
than inventing a new mechanism: `verlauf.py` gained `route_aus_verlauf(lat_verlauf, lon_verlauf, start, ende)`
|
||
- pairs each latitude point inside the trip window with its nearest-in-time longitude point (the two are
|
||
independent sensors on the FMM003, not guaranteed to update together) into a chronological `[[lat, lon], ...]`
|
||
list, `None` below two points. `screening.py`'s `durchfuehren()` now also runs a `_position_screenen()` pass
|
||
over every trip still missing `start_lat`/`end_lat`/`route` - **decoupled from the odometer `status` field**
|
||
(`offen`/`vollständig`), since GPS and odometer data can each arrive late independently; a trip already
|
||
`vollständig` on kilometers can still be missing position, and vice versa. Re-entrant like the odometer path:
|
||
already-filled fields are left alone, so repeated screening passes (every trip end, every KM_SENSOR change)
|
||
converge without re-work. `historienimport.py`'s `_fahrten_importieren()` now also writes `route` (previously
|
||
only `start_lat`/`end_lat`), using the same helper, for the same live/import parity reason section P and Q
|
||
already established for other fields.
|
||
|
||
**A real, live-reproduced timing edge case, found and understood, not worked around with a special case:**
|
||
first-attempt screening immediately after a trip ends can occasionally read the recorder microseconds before
|
||
the just-posted GPS points are committed, landing on a stale nearest-value instead. Reproduced deliberately
|
||
(temporarily set `fahrten_pausenzeit_min` to 0 to make a synthetic trip end instantly, restored afterward) -
|
||
the first screening pass picked a 5-minute-old position; nudging `KM_SENSOR` a few seconds later re-triggered
|
||
`durchfuehren()`, which then read the fully-committed history and filled in the correct, precise points and
|
||
route. Under the real default pause (15 minutes), the recorder has settled long before screening ever runs,
|
||
so this isn't a practical concern - and even under the artificial 0-minute test it self-healed on the very
|
||
next screening trigger, which is the same "eventually consistent, never stuck" property `_lueckenlos_verketten`
|
||
already relies on elsewhere in this file. No code change needed for this - it's an inherent, already-handled
|
||
property of the retry-on-next-trigger design, not a defect.
|
||
|
||
**Frontend, both codebases - stopped fabricating a track, matches an existing project value.** The panel's
|
||
`fakeTrack()` (a seeded-random wiggle between two points, flagged in the known-gaps list as "not a real
|
||
track") is removed; `initMap()`'s trip branch now draws the real `route` polyline when present ("Streckenlinie
|
||
aus dem GPS-Verlauf ... echte Route"), or an honest straight (dashed) line between the two known points when
|
||
only start/end are known but no route - matching the stance `companion-app/src/screens/Karte.tsx` already
|
||
took from the start (its own header comment: "Das ist hier nicht übernommen... zeigt die Seite ehrlich, dass
|
||
keine Route bekannt ist, statt eine glaubwürdig aussehende Erfindung zu zeigen"). `Karte.tsx` gained an
|
||
optional `route` prop (drawn solid when present, falling back to the existing dashed straight line) so it can
|
||
now also render a real track, not just the two endpoints; `FahrtDetail.tsx` passes `fahrt.route` through.
|
||
`api/types.ts`'s `Fahrt.route` was typed `unknown | null` (dead field, per its own header comment) - now
|
||
`[number, number][] | null`, matching what the backend actually sends.
|
||
|
||
**Map tile provider was showing "API key required" watermark tiles - found while verifying the above, a
|
||
separate real bug.** `TILE_URL` (panel) and `KACHELN.tag/.nacht` (companion-app `Karte.tsx`) pointed at
|
||
CARTO's anonymous `basemaps.cartocdn.com` tiles, which now require an account/API key - without one, every
|
||
tile rendered as a plain "API key required" graphic instead of a map, on **every** map in both apps (Standort,
|
||
trip detail, tank receipt), not just the newly-fixed trip track. Switched both to the standard
|
||
`tile.openstreetmap.org` raster tiles, which remain free and keyless. companion-app's `Karte.tsx` also lost
|
||
its per-theme dark/light tile switch in the same change (it had never gotten the panel's 2026-08-16
|
||
"maps are always light" decision) - now themeless like the panel, for the same reason (`useTheme` import and
|
||
the now-unused `theme` dependency removed).
|
||
|
||
**Owner-requested restructure of the Start/Ziel rows** (Einzelfahrt detail page, both codebases): was
|
||
address-first with the date as a small caption (Start row only; Ziel had no date at all). Now both rows show
|
||
date + time on the primary line and the address below as the secondary line - panel via a new `dezeitpunkt()`
|
||
helper (mirrors the existing inline `dedat(x) + " · " + toLocaleTimeString(...) + " Uhr"` pattern used
|
||
elsewhere in the file), companion-app via `Wertzeile`'s existing `zusatz` prop (the same mechanism already
|
||
used for other value+caption pairs).
|
||
|
||
**Owner-requested removal: "Status is not working. It says 'vollständig' but that's not correct. Remove
|
||
Status line."** Removed the panel's `<dt>Status</dt><dd>${t.status}...</dd>` row from `vTrip()` entirely, per
|
||
the explicit instruction - not replaced with a corrected version, removed. companion-app's `FahrtDetail.tsx`
|
||
never had an equivalent explicit "Status:" row (only a conditional "noch offen" `Pill`, which never shows the
|
||
literal text "vollständig") - nothing to change there, a documented non-issue rather than a silent skip.
|
||
|
||
**Owner-requested: "The Diagram for Battery Voltage shall use the max. available screen wide. In
|
||
Large-Screen-Mode it is unnecessary small."** The `max-width:400px` cap added 2026-08-25 (section R) to stop
|
||
the SVG chart stretching into a flat band on wide screens (fixed pixel `height:170px` + `width:100%` +
|
||
`preserveAspectRatio="none"` at 772px column width) had the side effect of capping it far below the available
|
||
772px column, on any screen. Root-fixed instead of re-tuning the cap: the `<svg>` now carries
|
||
`aspect-ratio:${BV_W}/${BV_H}` (320/170) instead of a fixed pixel height, so height scales with width and the
|
||
chart can never distort again regardless of container width - the `max-width:400px` rule is removed outright,
|
||
the chart now uses the full content column. The x-axis zoom/pan pointer handlers (`bvPlotRahmen()`, wheel
|
||
listener) were checked and confirmed unaffected: they derive everything from `rect.width`/`clientX`, never a
|
||
fixed pixel height.
|
||
|
||
**Verified live end-to-end in `audi_ha_test` at `http://localhost:18123`** (not just compiled/typechecked -
|
||
this file's own section S lesson: a clean compile pass doesn't prove a map or a layout is correct). Simulated
|
||
a full trip via direct HA REST state posts (ignition on -> three moving GPS points -> ignition off, pause
|
||
temporarily set to 0 for a fast test, restored after) and confirmed via screenshot: real OpenStreetMap tiles
|
||
render (no "API key required"), a genuine polyline follows the three simulated points with correct start/end
|
||
markers, "Start"/"Ziel" show date+time above the (unknown) address, no "Status" row appears anywhere on the
|
||
page. Battery diagram confirmed at a 1400px viewport: SVG measured 736×391px, ratio 1.882 matching
|
||
320/170 exactly (no distortion), using the full column instead of the previous 400px cap. Deleted the
|
||
synthetic test trip afterward; console showed only the pre-existing ServiceWorker/404 noise (ungenerated
|
||
vehicle photos, PWA service worker in this test environment), nothing new. companion-app: `tsc --noEmit`
|
||
clean, full suite green at 146/146, `npm run build` and `npm run ota` both succeeded (bundle rebuilt at
|
||
`2026.8.27.1`). Manifest bumped `2026.8.25.9` → `2026.8.27.1`.
|
||
|
||
**Same-day follow-up (2026.8.27.2): two smaller items found/requested while verifying the above.**
|
||
(1) A stale `min: 0.0` day-entry sat in `batteriespannung.jsonl`, recorded at 09:46 that day - **before**
|
||
the 15:12 restart that deployed the 8V plausibility floor (`SPANNUNG_MIN_V`, section T). The floor only
|
||
rejects *new* sub-8V readings going forward; it doesn't retroactively purge what was already stored, so the
|
||
0V value kept showing until removed. Deleted via the app's own `batterieverlauf_loeschen` service (the same
|
||
one the measurement list's swipe-to-delete uses) - not a code fix, a one-off data cleanup; the real instance
|
||
would need the same swipe-delete if the same stale-reading symptom ever shows up there.
|
||
(2) **Owner request: "Diagram Axis for Voltage: 8V to 15V"** (both battery voltage diagrams - the panel's
|
||
zoomable `#bvChart` and companion-app's `Verlaufsdiagramm` in `Batterie.tsx`, neither of which had a fixed
|
||
axis before, both computed min/max from the visible data ± padding). Both now draw a fixed Y-axis instead of
|
||
the data-driven range they used before - simpler, and the panel's `bvYDomain` no longer needs computing at
|
||
all when there's data, it's just a fixed pair of endpoints. Two immediate owner follow-ups the same session
|
||
adjusted the endpoints twice more: **8-15V → 10-15V** (`2026.8.27.3`), then **the `SPANNUNG_MIN_V`
|
||
plausibility floor itself raised 8.0 → 10.0 in `batterie.py`** to match (`2026.8.27.4`) - `historienimport.py`
|
||
imports the constant rather than duplicating it, so it picked up the new floor with no separate edit needed,
|
||
consistent with the live/import parity rule elsewhere in this file. The floor's module docstring was softened
|
||
from "physikalisch nicht plausibel" (no longer strictly true at 10V, a real if unlikely reading for a failing
|
||
battery) to "gilt als unplausibel" (an owner-set threshold, not a hard physical limit). Verified live at each
|
||
step: axis labels read `10,0 / 11,7 / 13,3 / 15,0` on the panel after the last change; companion-app confirmed
|
||
via `tsc --noEmit` each time (no dedicated visual check - same fixed-endpoint math, no zoom/pan feature to
|
||
interact with there); `batterie.py` re-`py_compile`d clean after the floor change. Manifest bumped
|
||
`2026.8.27.1` → `.2` → `.3` → `.4`, `npm run ota` rerun for the two frontend-touching steps, `audi_ha_test`
|
||
restart confirmed clean via log at every step (single `audi_dashboard` loader warning each time, no
|
||
duplicate-domain regression).
|
||
|
||
**Third follow-up (2026.8.27.5): a real regression from the `.1` width fix, plus a robustness gap, both
|
||
found from an owner screenshot of the wide-screen chart.** (1) **"Numbers are too large" / "reduce the
|
||
height"** - the `.1` fix (aspect-ratio matching the 320:170 viewBox so height scales with width) meant the
|
||
whole coordinate system, axis text included, scaled up together: at the full ~772px column width the SVG's
|
||
Y-scale grew to ~2.4×, so the `font-size="11"` axis labels rendered at ~26px and the chart itself grew to
|
||
~410px tall - both regressions from the same root cause, not two separate bugs. Reverted to a **fixed pixel
|
||
height** (`height:${BV_H}px` = 170px, dropping `aspect-ratio` entirely) with `width:100%` unchanged - the
|
||
Y-scale is now always exactly 1:1 regardless of container width, so axis text stays a normal ~11px and the
|
||
chart stays a compact, constant 170px tall on any screen; only the X-axis stretches to fill the column,
|
||
which is exactly the wide-and-short shape the owner asked for. companion-app's `Verlaufsdiagramm` never had
|
||
this bug (`.dm-diagramm` already used a fixed `height:120px` with no SVG-embedded text) - nothing to fix
|
||
there, confirmed by inspection.
|
||
(2) **"0V is being respected on the real instance - why isn't it ignored?"** - the screenshot showed a data
|
||
point plotted **below the axis's bottom edge**, past the 10V gridline, and a `0,0 V` SOC readout. Two
|
||
different things bundled in that one report: the underlying data point is almost certainly a pre-`.4`
|
||
reading (the same class of stale-data issue as `.2` item 1 above) sitting in the **real instance's**
|
||
`batteriespannung.jsonl` from before that instance was updated to run the raised `SPANNUNG_MIN_V=10.0` floor
|
||
- this session only ever touched `audi_ha_test`, never the owner's real instance, so nothing here confirms
|
||
the real instance is even running `.4`/`.5` yet (needs the self-update button + restart, or `install.ps1`,
|
||
plus a swipe-delete of the offending day exactly like `.2` item 1). Separately, and fixed here regardless of
|
||
that: **`bvSkalaY()` never clamped its output** - a point outside `[yMin, yMax]` (whatever the reason: a
|
||
stale reading, a not-yet-updated instance, a future edge case) extrapolated linearly past the plot area
|
||
instead of pinning to the axis edge, which is what actually drew the line running off the bottom of the
|
||
chart in the screenshot. Clamped the fraction to `[0, 1]` before scaling, in both codebases (companion-app's
|
||
equivalent `y()` in `Verlaufsdiagramm` got the identical clamp, for the same reason - it had no text-scaling
|
||
bug but shared this gap). This is a defensive rendering fix, not a data fix - it stops a bad point from
|
||
visually breaking the chart, but the owner's real instance still needs updating and the stale entry still
|
||
needs deleting for the *readout* (SOC/current-value tiles) to stop showing 0V too. Verified live: SVG
|
||
measured 736×170px at a 1400px viewport (Y-scale exactly `1`), axis label `getComputedStyle` font-size
|
||
`11px`, screenshot confirmed normal-sized labels and a compact chart using the full column. companion-app:
|
||
`tsc --noEmit` clean. Manifest bumped to `2026.8.27.5`, `npm run ota` rerun, `audi_ha_test` restart confirmed
|
||
clean via log.
|
||
|
||
**Fourth follow-up (2026.8.27.6): the real gap behind the `.5` "0V on the real instance" report - a second,
|
||
unfiltered publish path, not (only) stale/not-yet-updated data.** Owner report: "Mein Audi/Zustand Box"
|
||
still shows `Batteriespannung 0,0V`, while the Batterie-Messwerte list has no such entry. Traced to a second
|
||
code path that was never touched by the `SPANNUNG_MIN_V` floor at all: `veroeffentlichung.py`'s
|
||
`fahrzeugstatus()` publishes `batteriespannung` as the **raw, unfiltered current `BATTERIE_SENSOR` reading**
|
||
(`_zu_zahl(zustand_oder_none(...))`), completely independent of `batterie.py`'s history-recording path that
|
||
the floor actually guards. The panel's "Zustand" tile (`audi-dashboard-app.js` ~line 1611) and
|
||
companion-app's `MeinAudi.tsx`/`Batterie.tsx` "Aktuell gemeldet" line all read this same published field
|
||
directly - so a live sensor glitch (confirmed live: the test container's `BATTERIE_SENSOR` was genuinely
|
||
reporting raw state `"0"`) always showed through unfiltered there, in both codebases, regardless of whether
|
||
the instance had ever been updated. Fixed by applying the identical floor to this publish too: `spannung =
|
||
None` when the raw reading is below `SPANNUNG_MIN_V`, so "Zustand"/`Aktuell gemeldet` now show "unbekannt"/
|
||
"—" for the same implausible readings the history path already silently drops - one backend fix covers both
|
||
frontends, since both read the same published field. Needed a **function-local** import of `SPANNUNG_MIN_V`
|
||
from `batterie.py` (not a module-level one): `batterie.py` already imports `zustand_oder_none` from
|
||
`veroeffentlichung.py`, so a top-level import the other direction risks a circular-import failure depending
|
||
on load order (batterie.py reaches its own `from .veroeffentlichung import zustand_oder_none` line *before*
|
||
its `SPANNUNG_MIN_V = 10.0` assignment, so an eager reverse import can hit a partially-initialized module).
|
||
A local import inside `fahrzeugstatus()` sidesteps this entirely - both modules are always fully loaded by
|
||
the time that function actually runs. Verified live: a real Docker Desktop outage interrupted this fix
|
||
mid-session (confirmed via `docker ps` failing, unrelated to the code) - resumed once Docker was back.
|
||
`py_compile` clean, `audi_ha_test` restart confirmed clean via log **with no circular-import traceback**
|
||
(the actual proof this pattern needed, `py_compile` alone can't catch an import-order bug), and live in the
|
||
browser: raw sensor confirmed still reporting `"0"` via a direct state query, "Zustand" tile now reads
|
||
"Batteriespannung unbekannt" instead of "0,0 V". No companion-app source changes this round (backend-only
|
||
fix), so no OTA rebuild needed. Manifest bumped to `2026.8.27.6`.
|
||
|
||
## V. Per-trip Verbrauch (l/100 km): dead schema closed with a liter-based approximation, real-sensor
|
||
question left open (2026.8.27.7)
|
||
|
||
**The trip detail's "Verbrauch" row (`fahrtVerbrauch()`, `audi-dashboard-app.js`) always showed "liegt nicht
|
||
vor" - traced (owner asked "why is it so? what sensor are you waiting for?") to `t.verbrauch_l_100km` being
|
||
dead schema exactly like `start_lat`/`route` were before section U: it existed **only** in the frontend, no
|
||
backend code path (live or import) had ever written it, and no Setup-mappable sensor role existed for it
|
||
either. The "vom Fahrzeug gemeldet" caption was aspirational leftover, not a description of anything actually
|
||
wired up.**
|
||
|
||
**Owner's decision, to close this properly rather than just deleting the dead row:** two options exist -
|
||
(a) the FMM003 might expose a genuine short-trip l/100km value directly over OBD (the owner is checking), or
|
||
(b) compute an approximation from `TANK_LITER_SENSOR`'s liter-delta over the trip ÷ distance. Built (b) now
|
||
as the working default, with (a) tracked as an explicit follow-up: **if a real short-trip consumption sensor
|
||
turns up, swap to it directly instead of this approximation** - noted here rather than only in a chat
|
||
message, per this file's own "state changes, note it" maintenance rule.
|
||
|
||
**Implementation**, mirroring the existing `route_aus_verlauf()`/position-screening pattern from section U
|
||
for the exact same reason (live vs. import parity, no duplicated math): new
|
||
`verbrauch_aus_literstaenden(start_liter, end_liter, distance_km)` in `verlauf.py` - `None` unless both liter
|
||
readings and a positive distance are available *and* the liter level actually fell (a level that rose or held
|
||
steady means a mid-trip refuel or noise, not a meaningful ratio). `verbrauch_l_100km` added to the trip schema
|
||
(`leere_fahrt()`, was entirely absent before). Live path: new `_verbrauch_screenen()` in `screening.py`,
|
||
wired into `durchfuehren()` exactly like `_position_screenen()` - runs whenever `TANK_LITER_SENSOR` is mapped,
|
||
re-attempted on every screening pass until it succeeds (liter data can lag just like odometer/GPS data).
|
||
Import path: `_fahrten_importieren()` in `historienimport.py` computes it inline from the already-fetched
|
||
`verlaeufe["tank_liter"]` history (was fetched for tank-refill import already, just never used for this).
|
||
Both frontends: panel's caption changed from the false "vom Fahrzeug gemeldet" to "Näherung aus
|
||
Tankfüllstand"; companion-app's `FahrtDetail.tsx` had **no Verbrauch row at all before this** (a pre-existing
|
||
parity gap, not something this change broke) - added one in the same position as the panel's (after
|
||
"Durchschnitt"), same honest caption, `verbrauch_l_100km` added to the `Fahrt` type (was typed `unknown`
|
||
nowhere - simply absent).
|
||
|
||
**A second, real bug found and fixed while live-testing this: `_fahrt_screenen()` (the existing odometer
|
||
screening, unchanged by this feature) never synced its result back onto the in-memory `fahrt` dict, only into
|
||
storage.** `_vollstaendig()` only ever wrote `distance_km`/`km_quelle`/`status` into a local `aenderungen`
|
||
dict, which `_fahrt_screenen()` then persisted via `fahrt_aktualisieren()` - but never applied to the `fahrt`
|
||
object itself, unlike `_position_screenen()`, which already does `fahrt.update(aenderungen)` for exactly this
|
||
reason. Consequence: when a trip's distance was computed for the first time and the *same* `durchfuehren()`
|
||
pass then reached the new `_verbrauch_screenen()` step, the `ohne_verbrauch` filter still saw the stale
|
||
`distance_km: None` from the `fahrten` list fetched at the top of the function - `verbrauch_l_100km` silently
|
||
stayed unfilled for one whole screening cycle, only catching up on the *next* trigger. Fixed by adding the
|
||
same `fahrt.update(aenderungen)` to `_fahrt_screenen()`, matching `_position_screenen()`'s already-correct
|
||
pattern - found by reproducing it directly (a trip's `distance_km` and `verbrauch_l_100km` both stayed empty
|
||
after the first screening pass, filled in only after a second explicit trigger) rather than by reading the
|
||
code and guessing.
|
||
|
||
Verified: `py_compile` clean on all five touched backend files (incl. this `screening.py` fix), `node --check`
|
||
clean on the panel JS, companion-app `tsc --noEmit` clean and full suite green at 146/146. Live end-to-end in
|
||
`audi_ha_test` (`TANK_LITER_SENSOR` was already mapped to the container's real
|
||
`sensor.testcar_b9_fmm003_testintegratoin_can_fuel_volume`, liters - no Setup change needed): ran a synthetic
|
||
20.0→19.0 l trip over a simulated 10 km, confirmed `verbrauch_l_100km: 10.0` computed correctly and rendered
|
||
on the panel's trip detail ("10,0 l/100 km · Näherung aus Tankfüllstand", screenshot taken). companion-app's
|
||
`FahrtDetail` row was **not** live-rendered this round (no running dev server connected to a backend this
|
||
session) - verified only via `tsc --noEmit` and the full test suite, same as several other companion-app-only
|
||
changes this session.
|
||
|
||
**Separately, while pulling data for this: found the container's `ZUENDUNG_SENSOR` mapping had changed since
|
||
last checked** - now `binary_sensor...engine_ignition_or_acc_status` (stable, no chattering observed) instead
|
||
of the `instant_movement_status` entity used earlier this session, which **was** flapping on/off dozens of
|
||
times per minute (test-integration noise, unrelated to any real vehicle behavior). The owner had reconfigured
|
||
the Setup mapping themselves in the meantime. Confirms this container's synthetic test entities remain
|
||
unrelated to the owner's real-vehicle trip-merging report from the same day (section P/the "no clean
|
||
ignition-off signal" conversation) - it needs the real instance's own history, not this container's.
|
||
|
||
Manifest bumped to `2026.8.27.7`, `npm run ota` rerun (companion-app source changed).
|
||
|
||
## W. Correction: `audi_ha_test` is connected to the real vehicle, not synthetic data - and the real
|
||
trip-merging/GPS-jump report, root-caused with real data (2026.8.27.8)
|
||
|
||
**Correction to sections U and V above: the owner confirmed `audi_ha_test` (this session's "test container")
|
||
receives live telemetry from the real car/real FMM003 - the same sensors are wired into both this container
|
||
and the real HA instance in parallel.** Both prior sections state the opposite ("synthetic test-integration
|
||
noise", "unrelated to the owner's real-vehicle... report") - that was wrong, and this project's own
|
||
convention is to correct rather than silently rewrite history, so it stays here rather than editing U/V after
|
||
the fact. The erratic on/off chatter observed on `instant_movement_status` earlier the same day is real
|
||
telemetry from a real accelerometer/movement-based signal, not fabricated test noise - see below for what it
|
||
actually means. **The owner also confirmed creating synthetic test trips in this container (as done
|
||
throughout sections R-V) remains fine** - it's a secondary instance fed by the same sensors, not the sole
|
||
copy of their trip history, so the established "simulate via `hass.callApi` POST states, verify, clean up
|
||
afterward" testing convention stands unchanged going forward.
|
||
|
||
**With that corrected, the real trip-merging/GPS-jump report (originally raised in section P's conversation)
|
||
was root-caused directly from this container's real recorder history - no separate pull from a "production"
|
||
instance was needed after all, since this *is* that instance's real data.** Matched the owner's report
|
||
exactly once UTC/local (`CEST = UTC+2`) is accounted for:
|
||
|
||
- `t-da363d681915` (`09:55:31`–`12:05:01` UTC = **11:55–14:05 local**) - the wrongly-merged trip.
|
||
- `t-8be105166025` (`12:28:05`–`12:29:12` UTC = **14:28–14:29 local**), `distance_km: 22.0` over 67 seconds
|
||
(~1180 km/h) - the "GPS jumps 22 km" report.
|
||
|
||
**Root cause of the merge, confirmed from the raw history (not the "unavailable-instead-of-off" hypothesis
|
||
originally guessed in section P's conversation):** `instant_movement_status` (mapped as `ZUENDUNG_SENSOR` at
|
||
the time) reported sustained, unbroken `on` for 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)`
|
||
fading `0.16 → 0`), purely decorative, no new information, drawn between the gridlines and the line
|
||
so the grid still shows through.
|
||
- The x-axis tick count is now computed from the SVG's actual rendered pixel width
|
||
(`svg.getBoundingClientRect().width`, ~110px minimum spacing per label, clamped 3-6) instead of a
|
||
fixed `[0, 0.5, 1]` - a narrow phone chart still shows 3, the 772px desktop column shows up to 6.
|
||
Also added `stroke-linecap="round" stroke-linejoin="round"` to the polyline for cleaner corners.
|
||
- Verified live at 1400px: SVG measured 736×170px (fixed height preserved, no distortion regression),
|
||
axis text confirmed still 11px (no repeat of the earlier text-scaling bug from section U's `.5`
|
||
entry), 6 tick labels generated, gradient `<defs>`/area `<path>` both present in the rendered markup.
|
||
The test container's actual battery history only spans ~2 days, so several of the 6 labels read the
|
||
same day-precision date (`09.08.26`/`10.08.26` repeated) - a property of this container's narrow
|
||
synthetic dataset, not a defect in the tick logic itself; a real multi-week history would show 6
|
||
distinct dates.
|
||
|
||
Verified: `node --check` clean on the panel JS, companion-app `tsc --noEmit` clean. Manifest bumped to
|
||
`2026.8.27.9`, `audi_ha_test` restart confirmed clean via log (single `audi_dashboard` loader warning),
|
||
both fixes re-verified live in the browser after the restart (icon fill-percentage measurement above,
|
||
chart dimensions/gradient/ticks above). `npm run ota` rerun (companion-app source changed:
|
||
`symbole.tsx`), bundle rebuilt at `2026.8.27.9`, sha256
|
||
`66fef05581b26086d76500d864af5704cd568a39474ece47df88fb7c9c51dc50`.
|
||
|
||
**Follow-up (2026.8.27.10): Statistik-Icon auf Nutzerwunsch 10% größer** - Faktor 0.7 → 0.77
|
||
(`translate(2.76,2.76) scale(0.77)`), identisch in beiden Codebasen.
|
||
|
||
**Follow-up (2026.8.27.11): das Batteriediagramm war immer noch falsch - ein echter Darstellungsfehler
|
||
plus ein Filter, der 7 von 9 Messtagen verschluckt hat.** Owner sent a screenshot ("thats still not
|
||
fine") plus four instructions: no dates under the diagram, every measurement as a dot, date visible on
|
||
click, and zoom must keep working when more dots appear; then a fifth: only the minimum gets a dot, the
|
||
maximum appears as text on click.
|
||
|
||
Two genuine defects behind the screenshot, both found by measuring rather than by eye:
|
||
1. **`preserveAspectRatio="none"` on a fixed 320-unit viewBox distorted everything horizontally.** At
|
||
the 736px desktop column that is a 2.3× horizontal stretch applied to the whole coordinate system -
|
||
the axis numbers rendered visibly wide (measured: "10,0" ≈ 50px wide at font-size 11), and the six
|
||
date labels from `.9` collided with each other because their text stretched while their anchor
|
||
spacing did not. This is the same class of bug as section U's `.5` entry (text scaling with the
|
||
coordinate system), which was fixed there by pinning the height - the horizontal axis had the same
|
||
problem and was missed. Fixed properly this time: `bvZeichnen()` measures the rendered width and
|
||
sets `viewBox="0 0 <gemessene Breite> 170"`, so **one coordinate unit is exactly one pixel** and
|
||
nothing can distort at any width; `preserveAspectRatio` is removed entirely. `BV_W` is now only a
|
||
starting value (`bvBreite` holds the live width), `bvSkalaX()`/the gridline width use `bvBreite`,
|
||
and `bvPlotRahmen()` collapsed to plain pixel arithmetic. A `ResizeObserver` on the SVG redraws on
|
||
window resize, so the viewBox cannot go stale (setting the viewBox does not change the element's
|
||
size, so no feedback loop).
|
||
2. **The chart filtered out every day without a resting-voltage reading.** `bvPunkte` was built with
|
||
`e.min != null && e.min <= AGM_RUHE_MAX_V` - so days where the car never stood still long enough
|
||
(minimum still above 12,8 V, i.e. alternator voltage) vanished from the chart completely. In the
|
||
test container that reduced 9 recorded days to **2**, which is exactly the near-empty two-dot chart
|
||
in the owner's screenshot, and why the range read "09.08.26 – 10.08.26" instead of the real
|
||
09.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, separate `bvRuhePunkte`** - the SOC
|
||
readout (`letzter`) and `bvSohBewertung()` both switched to it, otherwise a generator reading would
|
||
have shown a permanent "100 % · Sehr gut".
|
||
|
||
Per the owner's instructions: the x-axis date labels are gone entirely (the period still stands under
|
||
the chart as `#bvRange`, and a single measurement's date/time comes from tapping its dot); one dot per
|
||
recorded day, drawn at the **minimum** only; the tooltip now reads
|
||
`18.08.2026 · 08:57 Uhr · Min 13,02 V · Max 13,47 V` (plus outside temperature when recorded) - that is
|
||
where the maximum lives now, it has no dot of its own. `BV_MB` dropped 22 → 12 since nothing is written
|
||
under the plot any more. Zoom/pan is untouched and confirmed still working.
|
||
|
||
**companion-app (parity rule):** its chart never had defect 2 (it already filtered only `min != null`,
|
||
no AGM cut) and had no date labels either, so those two needed nothing. It *did* differ on the owner's
|
||
other two points: no dots at all, and the daily maximum drawn as a permanent second grey line. Ported:
|
||
one `<circle>` per measurement, the max line removed, tap-a-dot shows the same
|
||
date/time/Min/Max/temperature line underneath, and the same dynamic-viewBox treatment (measured width +
|
||
`ResizeObserver`, `preserveAspectRatio="none"` removed) - needed there too, because with `none` the new
|
||
dots would have rendered as horizontally stretched ellipses. **Zoom/pan was deliberately NOT ported**:
|
||
the panel's pinch/wheel/drag handling is ~150 lines of pointer bookkeeping (`bvZeiger`/`bvGestStart`/
|
||
`bvDomainKlemmen`) with no counterpart in companion-app, and building it there is its own work package
|
||
rather than part of this fix - flagged here per the parity rule's "say so explicitly" clause instead of
|
||
being left silently undone.
|
||
|
||
Verified live in `audi_ha_test` at a 1400px viewport after the restart (`2026.8.27.11 eingerichtet`,
|
||
clean log): `viewBox="0 0 736.0 170"` against a 736px-wide element (exactly 1:1, `preserveAspectRatio`
|
||
absent), **9 dots** instead of 2, the only remaining `<text>` elements are the four y-axis labels
|
||
(`10,0 / 11,7 / 13,3 / 15,0` - no dates), range label now spans the true `09.08.26 – 25.08.26`, and
|
||
"10,0" measures **21,7 × 12 px** (undistorted at font-size 11; it was ~50px wide before). A synthetic
|
||
pointerdown/up on the 6th dot produced the tooltip quoted above. Five wheel-zoom steps narrowed the
|
||
range to `14.08.26 – 20.08.26` showing 4 dots, and "Zoom zurücksetzen" restored the full range -
|
||
zoom confirmed intact. companion-app: `tsc --noEmit` clean, suite green at 146/146 (the
|
||
render-every-page smoke test covers the reworked chart), `npm run ota` rebuilt at `2026.8.27.11`,
|
||
sha256 `d0f77e854a2664331314ffc64a26712e0982c80e145aed7b25cabc41907e5974`.
|
||
|
||
**Follow-up (2026.8.27.12): the two codebases' SOC (Ladezustand) curves disagreed.** Owner asked "how is
|
||
soc calculated" - answering it surfaced that the panel (`bvSocProzent()`, 11-point linear interpolation)
|
||
and companion-app (`ladezustand()`, a 6-step lookup) genuinely disagreed on the same voltage - worst case
|
||
15 percentage points apart (12.1 V: 40% panel vs 25% app), and the app also only ever looked at the
|
||
*newest* stored day rather than walking back to the last resting reading like the panel does, so it could
|
||
show "—" on a day the panel showed a real number. Owner: "ok use the interpolated for both". Ported the
|
||
panel's exact 11-point table and linear-interpolation math into companion-app's `ladezustand()` -
|
||
`Batterie.tsx`. **Deliberately NOT ported**: the "walk back to the last resting reading" selection logic
|
||
- that's a separate, larger behavior change beyond "align the curve" and wasn't asked for; flagged here so
|
||
it isn't mistaken for done. No dedicated tests referenced the old stepped values (grepped first), so
|
||
nothing else needed updating. Verified: `tsc --noEmit` clean, suite green 146/146.
|
||
|
||
**Follow-up (2026.8.27.13): the battery temperature field, why it was always empty, and two real "match
|
||
must be close in time" gaps found and fixed.** Owner: "i cant see any temperature at the battery" -
|
||
investigated live rather than assumed. Root cause was mundane: `AUSSENTEMP_SENSOR` was correctly mapped
|
||
and healthy (30°C, fresh), but the FMM003 voltage source was offline (dongle down), so every 5-minute
|
||
`pruefen()` tick in `batterie.py` was rejecting the reading at the `SPANNUNG_MIN_V=10.0` plausibility
|
||
floor **before ever reading temperature** - confirmed via the recurring log line
|
||
`Batteriespannung 0.0 V unter der Plausibilitätsgrenze`. Separately, all 9 stored entries predate the
|
||
temperature feature (2026-08-25/section R) entirely, so none carry `min_temp_c` regardless.
|
||
|
||
Owner pushed back on "nothing to backfill" with "i bet there is a temperature recorded the same time when
|
||
the voltage was measured" - correct, and worth the correction: queried the recorder directly rather than
|
||
assuming, and outside-temperature history exists for every one of the 9 stored days (recorder now keeps a
|
||
year, section F). That exposed a **real, previously-unnoticed gap**: `historienimport.py`'s
|
||
`_batterie_importieren()` never fetched `AUSSENTEMP_SENSOR` history at all (`verlaeufe` dict had no
|
||
`"aussentemp"` key) - so even running the historical import over that same window would never have
|
||
backfilled it. Fixed: `verlaeufe["aussentemp"]` added to `importieren()`'s fetch, and each day's minimum
|
||
now looks up the nearest temperature reading via the recorder history, same pattern as the existing
|
||
GPS/liter pairing helpers.
|
||
|
||
**Then the owner set a hard constraint: "temperature must be same time as voltage. 60 seconds delay is
|
||
fine but not longer. is ther always a temperature when voltsge vomes?"** Measured rather than assumed
|
||
(live recorder query over 48h on the container's then-mapped `cupra_eu_data_act` temperature sensor): NO
|
||
- 22.4% of its own update gaps exceeded 60s, with a max gap of ~36.6 hours (duplicate-timestamp template
|
||
sensor, not a real device). That answer justified building a real distance bound rather than trusting an
|
||
unbounded nearest-match:
|
||
- New `wert_in_naehe(zielzeit, verlauf, max_abstand_s=NEBENWERT_MAX_ABSTAND_S)` in `verlauf.py` - same
|
||
nearest-match algorithm as the existing `naechster_wert()`, but returns `None` instead of an arbitrarily
|
||
distant value when even the closest point exceeds the bound. `NEBENWERT_MAX_ABSTAND_S = 60`, the owner's
|
||
own number, documented as owner-set rather than derived.
|
||
- **Import path** (`historienimport.py`): switched from `naechster_wert()` to `wert_in_naehe()` for the
|
||
temperature pairing.
|
||
- **Live path** (`batterie.py`): this had the same latent gap and nobody had asked about it yet -
|
||
`zustand_oder_none()` returns whatever a stale entity's last value was, no matter its age, so a live
|
||
reading could just as easily have paired a many-hours-old temperature with a fresh voltage measurement.
|
||
Fixed by comparing the two sensors' own `last_updated` timestamps (via `hass.states.get()` directly,
|
||
bypassing `zustand_oder_none()`'s attribute-stripping for this one lookup) against each other - not
|
||
against wall-clock "now", since both are read from the current `hass.states` snapshot but can carry
|
||
different ages. Same `NEBENWERT_MAX_ABSTAND_S` bound, imported from `verlauf.py` (no circular-import risk:
|
||
`verlauf.py` has no local imports of its own, unlike the `SPANNUNG_MIN_V` situation elsewhere in this
|
||
file that needed a function-local import).
|
||
- Verified the boundary itself with a deterministic Python check (`wert_in_naehe()` called directly with
|
||
synthetic 3s/60s/61s/36h-offset timestamps) rather than relying on live timing: 3s and 60s pass through
|
||
unchanged, 61s and 36h correctly return `None`.
|
||
|
||
**Then the owner remapped `AUSSENTEMP_SENSOR` from the flaky `cupra_eu_data_act` sensor to the real FMM003
|
||
CAN sensor and asked to check again.** Confirmed live: the FMM003 temperature and voltage entities update
|
||
in the same telemetry batch (~3s median gap between temperature updates, sub-second gap between the two
|
||
sensors' own timestamps) - a fundamentally different reliability profile from the old sensor, so the 60s
|
||
guard should pass consistently going forward. One caveat surfaced and reported honestly rather than
|
||
glossed over: this FMM003 temperature entity's own history only reaches back to ~2026-08-25 (confirmed via
|
||
a recorder query for 2026-08-09: zero points), so a re-import can never backfill temperature for the
|
||
five oldest stored days (08-09 through 08-24) - only 08-25 onward is reachable with this sensor, no matter
|
||
how the import logic is written.
|
||
|
||
Attempted a real live write to confirm end-to-end (temporarily POSTing a plausible voltage value on a
|
||
repeating interval, same technique used earlier this session), but the owner flagged mid-wait that the
|
||
FMM003 itself is currently offline - correctly stopped rather than waiting on a device that was never
|
||
going to answer; the interval was cleared and the sensor reset to `0`, confirmed no stray entry was written
|
||
to `batteriespannung.jsonl` in the meantime.
|
||
|
||
**Owner then asked to "import the available data"** - ran `audi_dashboard.historie_importieren` live
|
||
(`2025-08-28` through now) via the real service call. Result (`sensor.audi_dashboard_import_status`):
|
||
`batterie_tage: 9`, plus one genuinely new entry the live path had never captured on its own
|
||
(`2026-08-27`, min 12.149 V / max 15.439 V - real recorder history from earlier the same day, before the
|
||
dongle went offline). Its `min_temp_c` came back `null`; verified this is correct, not a bug - the nearest
|
||
FMM003 temperature reading to that day's exact minimum timestamp was **180 seconds** away (25 points in a
|
||
60-minute window around it, so the sensor was reporting, just not within the 60s bound at that specific
|
||
moment). The 9 pre-25-08 entries stayed without temperature, exactly as predicted by the FMM003 history-gap
|
||
finding above - not a defect, the data genuinely isn't there for that sensor over that window.
|
||
|
||
Verified throughout: all touched Python files `py_compile` clean via the container (`verlauf.py`,
|
||
`historienimport.py`, `batterie.py`), companion-app `tsc --noEmit` clean and suite green 146/146,
|
||
`audi_ha_test` restarted clean at each step ending on `2026.8.27.13` (single `audi_dashboard` loader
|
||
warning, no traceback). `npm run ota` rebuilt at `2026.8.27.12`, sha256
|
||
`5ef88ee826431b5b2cff5020c83430be88d729b51284459f36a5388963e441f3` (companion-app source changed for the
|
||
SOC curve; `.13`'s backend-only changes needed no OTA rebuild).
|
||
|
||
**Follow-up (2026.8.27.14): `NEBENWERT_MAX_ABSTAND_S` raised from 60s to 300s, with real FMM003 telemetry
|
||
as the reason - not a guess.** Owner suspected voltage and temperature were being compared across the
|
||
wrong sensors; investigated rather than assumed - confirmed via `config/entity_registry/list` that both
|
||
`external_power_voltage` and `can_ambient_air_temperature` belong to the same single FMM003 device
|
||
(`device_id 3667296563855653c6895ed57b6b9030`, platform `flespi`) - no duplicate device, no sensor mixup.
|
||
Also double-checked against a documented past mistake in this file (section B): confirmed `voltage` used
|
||
`external_power_voltage` (the car's 12V bus), not `battery_voltage` (the FMM003's own internal backup
|
||
cell) - both exist on this device and are easy to conflate.
|
||
|
||
Owner then asked why there'd be any delay at all if both come from the same device. Answer, grounded in
|
||
a live-pulled table rather than general knowledge: a GPS/telematics tracker like the FMM003 reads dozens
|
||
of independent CAN parameters (70+ entities on this one device) and transmits them at different rates to
|
||
save cellular data - voltage changes constantly and gets reported often, ambient temperature barely moves
|
||
and gets reported rarely. Measured over 46h: 851 voltage state-changes vs. 187 temperature state-changes
|
||
on the same device. Pulled an actual table for the owner's named 12:00-13:00 local drive (`t-da363d681915`,
|
||
resampled to one row per minute, collapsed into ranges): normal (non-blackout) temperature gaps of 8s,
|
||
19s, 34s, 174s, and **593s** all occurred during otherwise-healthy reporting - not a connectivity problem,
|
||
just the device's own per-parameter reporting cadence. Separately, a real 45-minute stretch
|
||
(10:14-10:59 UTC) showed **both** sensors going completely silent at once - a genuine connectivity
|
||
dropout, the same root cause already documented in section P, not a reporting-cadence issue.
|
||
|
||
Recommended widening the window from 60s to 300s (5 minutes) on three grounds: (1) the worst *normal*
|
||
gap measured was 593s, so 60s was rejecting healthy pairings, not just genuinely stale ones; (2) outside
|
||
air temperature is physically slow-changing, so several minutes of staleness introduces negligible real
|
||
error, unlike GPS position or the voltage reading itself; (3) `batterie.py`'s `pruefen()` only runs every
|
||
5 minutes anyway, so demanding sub-minute precision from the pairing doesn't buy anything the measurement
|
||
itself doesn't already lack. Owner picked 300s over the alternative 600s suggestion. `NEBENWERT_MAX_ABSTAND_S`
|
||
in `verlauf.py` changed `60 → 300`; `wert_in_naehe()`'s own algorithm is unchanged (still "nearest in
|
||
time, `None` if even the closest point exceeds the bound") - only the bound moved, confirmed via the same
|
||
kind of deterministic boundary test as the `.13` entry: 299s/300s pass, 301s and the real 593s/2700s cases
|
||
correctly return `None`.
|
||
|
||
Verified: `py_compile` clean on `verlauf.py`, `audi_ha_test` restarted clean on `2026.8.27.14` (single
|
||
`audi_dashboard` loader warning, no traceback). No companion-app source changed this round (the constant
|
||
lives only in the backend), so no OTA rebuild needed.
|
||
|
||
**Follow-up (2026.8.27.15): a real merge-logic bug meant the `.14` widening could never actually backfill
|
||
anything - re-running the import kept showing `min_temp_c: null` even for entries the new 300s window
|
||
should have caught.** Owner: "i still dont see a single temperature at a measured voltage." Traced to
|
||
`ablage.py`'s `batterieverlauf_tageswert_aktualisieren()`: it only ever wrote `min_temp_c` inside the
|
||
`if spannung < eintrag["min"]:` branch - i.e. only when a measurement set a *new, lower* minimum. Re-running
|
||
the import over an already-recorded day computes the exact same historical minimum it found before (same
|
||
source data, deterministic), so `spannung < eintrag["min"]` is always `False` on a second pass - the branch
|
||
that would apply the newly-reachable temperature never ran, no matter how wide `NEBENWERT_MAX_ABSTAND_S`
|
||
got. This directly contradicts the `.13` entry's own docstring claim ("ein erneuter Import ... ergänzt sie
|
||
dort nachträglich") - that claim was aspirational, not actually true, until this fix. Added an `elif`
|
||
branch: `spannung == eintrag["min"] and eintrag.get("min_temp_c") is None and aussentemp is not None` →
|
||
fill in `min_temp_c` without touching `min`/`min_ts` (they're already correct; only the temperature was
|
||
ever missing). Live and import paths both benefit automatically - no separate change needed in either
|
||
`batterie.py` or `historienimport.py`, both already call through this one shared function.
|
||
|
||
**Verified by actually re-running the import, not just by re-reading the code:** before the fix, all 9
|
||
importable entries still showed the state from the `.13`-era import (only `2026-08-27` even attempted,
|
||
and it alone stayed `null` at 180s > the then-60s bound). After deploying `.15` and re-running
|
||
`audi_dashboard.historie_importieren` over the same window: **6 of the 9 entries now carry a real
|
||
`min_temp_c`** (08-15: 26.0°C, 08-17: 26.0°C, 08-23/24/25/27: 20.0°C) - direct proof the fill-in-gap logic
|
||
works, not just that it compiles. Two entries (08-16, 08-18) still show no temperature - checked why rather
|
||
than assuming a bug: a first ±3h recorder query for both returned suspiciously identical `10800.0s`
|
||
(exactly 3h) distances, which turned out to be an artifact of that query window's own edge, not the true
|
||
nearest point. Widening to ±24h found the real answer: the nearest FMM003 temperature reading is **14.7h**
|
||
away for 08-16 and **13.8h** away for 08-18 - genuinely no temperature data near those specific moments on
|
||
the device's side, unrelated to the 300s bound or this fix. The two oldest entries (08-09, 08-10) remain
|
||
without temperature for the already-documented reason in the `.13` entry (FMM003 history starts ~08-25 for
|
||
this specific test device... **correction**: the temperature history actually reaches back to at least
|
||
08-15, per this round's own findings - the `.13` entry's "history starts ~08-25" claim was based on a
|
||
single spot-check at 08-09 and turned out to be an overgeneralization; the accurate statement is simply
|
||
"no temperature data near this specific moment," checked per-day rather than assumed as a blanket cutoff
|
||
date. Worth remembering: a live spot-check at one date does not establish a device's reporting start date -
|
||
check the specific timestamp that actually matters.
|
||
|
||
Verified: `py_compile` clean on `ablage.py`, `audi_ha_test` restarted clean on `2026.8.27.15` (single
|
||
`audi_dashboard` loader warning, no traceback), re-running the import produced the 6-of-9 result quoted
|
||
above via direct file inspection, not just a trusted service-call return. No companion-app source changed
|
||
(backend-only), no OTA rebuild needed.
|
||
|
||
**Follow-ups (2026.8.27.16-.17), same session:** two corrections from the owner after `.15`, both applied
|
||
to the same three "which voltage counts as what" spots (`audi-dashboard-app.js`, companion-app's
|
||
`Batterie.tsx`): (1) `AGM_RUHE_MAX_V` (classification: "is this a battery reading or already the
|
||
generator") raised from 12.8 → 13.0 V, owner-set directly ("max voltage for AGM Battery is 13V"). This
|
||
also meant the diagram's dot set had to go back to filtering by this threshold - a mid-session detour
|
||
(section U's later rounds) had made the diagram plot *every* measurement including generator readings; the
|
||
owner's "13,01 shall be generator und not part of the diagram" reinstated the filter, just at the new 13.0
|
||
ceiling instead of the old 12.8 one. (2) The owner then separately clarified the SOC curve's own "100% /
|
||
voll" anchor should **stay** at 12.8 V ("12,8v voll was already right") - so `AGM_RUHE_MAX_V` (13.0,
|
||
classification) and `BV_SOC_TABELLE`'s last point (12.8, the SOC-100% anchor) are now two deliberately
|
||
different numbers, not one; a reading between 12.8-13.0 V counts as a valid battery measurement (gets a
|
||
dot, feeds the SOH trend) but still displays 100% SOC via the table's existing clamp-above-last-point
|
||
behavior - no new code needed for that overlap, just careful separation of the two constants and their
|
||
captions/comments. Also added, same round: "Mein Audi"/Zustand's Batteriespannung row now shows the
|
||
**last plausible voltage** instead of "unbekannt" when the live reading is currently implausible (e.g.
|
||
dongle offline reporting 0V) - a small RAM-only cache in `veroeffentlichung.py`
|
||
(`_letzte_gueltige_spannung`), updated whenever a valid reading is observed, read as fallback otherwise.
|
||
Deliberately not persisted (same reasoning as this project's other RAM-only runtime state) - a restart
|
||
shows "unbekannt" again until the next real reading, rather than resurrecting a possibly very old value
|
||
from disk.
|
||
|
||
**A real reachability report that could not be reproduced.** Owner reported the Batteriespannung row in
|
||
"Mein Audi"/Zustand isn't clickable specifically in wide/desktop layout ("cursor changes but click does
|
||
nothing"). Investigated thoroughly and could not reproduce: `document.elementFromPoint()` hit-testing at
|
||
the row's exact screen position at 1400px width correctly resolved to the row itself (not an overlapping
|
||
element); a full pointerdown→pointerup→click sequence dispatched there navigated correctly; a genuine
|
||
OS-level trusted click via the browser tool's `computer` action (not a scripted DOM event - this is the
|
||
closest thing to a real click available for remote testing) also navigated correctly. No `pointer-events`
|
||
rule in the desktop `@container` CSS block touches `.row`. Left open, pending the owner's exact window
|
||
width and a browser-console check at the moment of the failed click - flagging here rather than guessing
|
||
further blindly.
|
||
|
||
**A significant process gap, found and fixed the hard way: the self-update mechanism (section J) silently
|
||
undid this entire session's work.** The owner ran "check for update" / "install" via the Einstellungen
|
||
tile, which is designed to work exactly as documented - it pulled the latest **committed** state from
|
||
Gitea and replaced the live `custom_components/audi_dashboard/` folder with it. The problem: every fix in
|
||
this session (going back through sections U-W and all of this section) had only ever been deployed to
|
||
`audi_ha_test` via direct `docker cp`, never committed - so Gitea's `HEAD` was still sitting at `80d73e8`,
|
||
several rounds behind. The self-update did precisely what it's built to do; the gap was entirely on this
|
||
session's side for not committing incrementally. Confirmed directly: `audi_ha_test`'s live manifest read
|
||
back `2026.8.27.8` immediately after the owner's update, not `.17` - and every symptom the owner reported
|
||
(Batteriespannung showing "unbekannt" again, the diagram's max reading back at 13,99 V, "Ruhespannung"
|
||
back at a stale value) was exactly consistent with the pre-session code, not a new regression. Fixed in
|
||
two steps: re-deployed the current local files to `audi_ha_test` via `docker cp` (restoring `.17`
|
||
behavior for continued testing), then committed and pushed everything to Gitea (`172db54`) so the
|
||
self-update path is no longer stale. **Lesson for future sessions, stated plainly: `docker cp` to
|
||
`audi_ha_test` is a testing convenience, not a delivery mechanism - it is invisible to the self-updater and
|
||
to `install.ps1` alike. Commit and push considerably more often than "when the owner explicitly says
|
||
so," specifically before suggesting or allowing a self-update/install run, or a long session's worth of
|
||
verified, deployed fixes can vanish in one click.** (`git push` needed one retry for the
|
||
already-documented Gitea Credential Manager flakiness - consistent with every other push this project has
|
||
made.)
|
||
|
||
## Y. Full cross-codebase bug audit: six real defects found and fixed (2026.8.27.19)
|
||
|
||
Owner asked for a general audit of the app, not tied to a specific report. Ran three parallel reviews
|
||
(backend Python, panel JS, companion-app) rather than one pass, each grounded in reading the actual current
|
||
code rather than trusting this file's own history. Six real, concrete defects survived; three items the
|
||
reviews raised as *possible* concerns were checked and confirmed correct as-is (documented below so they
|
||
aren't re-flagged). No crash-class bugs (undefined-reference errors, the historical `bvRuhePunkte` class of
|
||
bug) were found anywhere - confirmed via ESLint `no-undef` over the whole panel file plus manual review.
|
||
|
||
**1. (High) companion-app's `Batterie.tsx` showed generator voltage as "Ruhespannung" and skewed the SOH
|
||
trend.** The panel's `bvPunkte` (the single, already-filtered `<= AGM_RUHE_MAX_V` array used for the
|
||
headline, trend, and diagram alike - see section X's `.16`/`.17` history) was never mirrored here:
|
||
companion-app's `messwerte` had no such filter, so the newest day's minimum reading - even a generator
|
||
reading above 13.0 V - could show as the "Ruhespannung" headline, and the trend average wasn't excluding
|
||
those days either. Fixed with the identical one-line filter the panel already applies:
|
||
`verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)` in `Batterie.tsx` - this one array already
|
||
drives the headline, the trend, and the diagram, so a single filter fixes all three. `BatterieListe.tsx`
|
||
(the measurement list) is correctly left unfiltered, exactly like the panel's `vBatterieliste()` - it tags
|
||
generator readings with a pill instead of hiding them, which is a different, already-correct concept.
|
||
|
||
**2. (High) Manually-entered trips/refuels could be silently duplicated by a later import.**
|
||
`historienimport.py`'s own docstring promises every import-created record is checked for overlap against
|
||
*all* existing ones, "egal ob live erkannt, von Hand angelegt oder aus einem früheren Import" - but the
|
||
manual-entry paths (`fahrterkennung.manuell_anlegen()`/`aktualisieren()`, `belege.tankvorgang_manuell()`/
|
||
`tankvorgang_aktualisieren()`) stored whatever timestamp the frontend sent verbatim via a raw
|
||
`datetime.fromisoformat()`, while `historienimport.als_zeit()` normalizes the same kind of input (naive =
|
||
local time, per its own comment) to a timezone-aware UTC datetime. `_ueberschneidet()`'s overlap check and
|
||
the tank-vorgänge dedup window both explicitly skipped any record with a naive timestamp - so every
|
||
manually-created trip or refuel was invisible to a later import's dedup, and could be re-created as a
|
||
duplicate. Fixed by moving the normalization into a shared function, `zeit_normalisiert()` in `verlauf.py`
|
||
(a leaf module with no local imports, chosen specifically to avoid a circular import between
|
||
`fahrterkennung.py`/`belege.py` and `historienimport.py`) - `historienimport.als_zeit` is now an alias for
|
||
it. Every write path (`manuell_anlegen`, `aktualisieren`, `tankvorgang_manuell`, `tankvorgang_aktualisieren`)
|
||
now normalizes through it before storing. `_ueberschneidet()`/`_tankvorgaenge_importieren()`'s
|
||
`bekannte_zeiten` collection were also changed to run existing stored timestamps through the same function
|
||
instead of skipping naive ones - this also correctly protects any already-stored legacy record with a naive
|
||
timestamp from before this fix, not just new writes.
|
||
|
||
**3. (Medium) Battery voltage history had no backup coverage.** `sicherung.py`'s `DATEIEN` tuple (used by
|
||
"Backup jetzt erstellen") listed `fahrzeugprofil.json`/`fahrten.jsonl`/`tankvorgaenge.jsonl`/
|
||
`entitaeten.json` but not `batteriespannung.jsonl` - even though `batterie.py`'s own docstring frames it as
|
||
a deliberately multi-year dataset for detecting battery aging. Added it to `DATEIEN`; `_kopieren()` already
|
||
skips any file that doesn't exist, so this is zero-risk for an install with no battery history yet.
|
||
|
||
**4. (Low/Medium) Tank-vorgang import was all-or-nothing per sensor once `TANK_LITER_SENSOR` was mapped.**
|
||
`_tankvorgaenge_importieren()` used to read *only* the liter sensor's history once one was mapped, silently
|
||
losing any refuel from before the liter sensor's own recorder history started (e.g. it was mapped
|
||
mid-project, per section Q) even though the percent sensor's own history covered that period. Rewrote the
|
||
function to run both passes when a liter sensor is mapped: the percent-based pass now covers the period
|
||
strictly *before* the liter history's first point, and the liter-based pass (more accurate, since it's
|
||
already in liters) covers everything from there on - both share the same `bekannte_zeiten`/`neue` state so
|
||
the same physical refill can't be created twice if the two histories overlap. Unmapped-liter-sensor
|
||
behavior (pure percent-based import) is unchanged.
|
||
|
||
**5. (Moderate) Panel's Statistik page mislabeled a tank-based estimate as vehicle-reported data.** The
|
||
"Vom Fahrzeug gemeldet, je Zeitraum" tile and its caption claimed `t.verbrauch_l_100km` (per-trip fuel
|
||
consumption) came from the vehicle's own OBD reporting - it doesn't; section V documented that this field
|
||
is entirely a backend-computed approximation from `TANK_LITER_SENSOR` liter deltas, and the trip-detail
|
||
page already correctly labels it "Näherung aus Tankfüllstand". Only the Statistik tile and a stale code
|
||
comment (`fahrtVerbrauch`'s own header) still claimed OBD provenance. Fixed both to say what the value
|
||
actually is - a text-only change, the underlying computation was already correct.
|
||
|
||
**6. (Low) companion-app's "Art der Fahrten" chart still colored the Arbeitsweg segment red.** The panel
|
||
fixed this exact chart on 2026-08-13 (section C, the iOS-overlay red-semantics-inversion finding: red is
|
||
reserved for destructive actions, this segment is a neutral data category) but companion-app's
|
||
`Statistik.tsx` never received the port - its Arbeitsweg segment stayed `var(--red)` while its sibling
|
||
Privat segment already used the neutral `var(--fg3)`. Changed to `var(--fg)`, matching the panel's own
|
||
Arbeitsweg color exactly (not `--fg3`, which the panel reserves for the Privat segment).
|
||
|
||
**Checked and confirmed NOT a bug** (raised by one of the three reviews, verified against current code):
|
||
`AGM_RUHE_MAX_V`, `NEBENWERT_MAX_ABSTAND_S=300`, `_letzte_gueltige_spannung`, the `ablage.py` temperature
|
||
backfill branch, `historienimport.py`'s `aussentemp` fetch, and `batterie.py`'s freshness check - all
|
||
already correct per sections T/W/X's history; the route/back-navigation tables (`ZURUECK` vs. the panel's
|
||
dispatcher) - a full cross-check found every route has exactly one matching entry both ways; and the panel's
|
||
`ereignisseVerdrahten()` listener wiring - guarded by the existing idempotency lock, no double-registration
|
||
risk.
|
||
|
||
Verified: all five touched backend files (`verlauf.py`, `historienimport.py`, `fahrterkennung.py`,
|
||
`belege.py`, `sicherung.py`) `py_compile` clean in `audi_ha_test`; panel `node --check` clean; companion-app
|
||
`tsc --noEmit` clean and full suite green at 146/146. Manifest bumped to `2026.8.27.19` (panel text
|
||
changed), `npm run ota` rerun (companion-app source changed, `Batterie.tsx`/`Statistik.tsx`), `audi_ha_test`
|
||
restarted and confirmed clean via log (`Audi Dashboard 2026.8.27.19 eingerichtet`, no traceback; the only
|
||
`ReferenceError: bvRuhePunkte` lines in the post-restart log predate the setup-complete line and come from a
|
||
stale browser tab's cached pre-restart script, not a new regression). Committed and pushed the same day
|
||
(`90fff7b`), per this file's own binding lesson from section W.
|
||
|
||
## Z. Battery-voltage fallback didn't survive a restart - now seeded from disk (2026.8.27.20)
|
||
|
||
Owner report right after the section Y fixes: "batteriespannung is still unbekannt!" - live investigation
|
||
(browser session against `audi_ha_test` at `:18123`, confirmed reachable per section W's note) found this
|
||
was NOT a regression from section Y: the FMM003 dongle was genuinely reporting `external_power_voltage: 0`
|
||
live (confirmed via `hass.states` in the browser console), and `_letzte_gueltige_spannung`
|
||
(`veroeffentlichung.py`, the RAM-only "last known good voltage" fallback added in section W) had been wiped
|
||
by the container restarts done while verifying section Y's fixes - it had no valid reading to fall back to
|
||
since the last restart, so "unbekannt" was the technically-correct result of the existing design.
|
||
|
||
Owner's response: **"wait. I want to see the last measured voltage!"**, then, pre-empting a staleness
|
||
concern before it was raised: **"no lifedate! The dongle will be always offline when car is off"** - i.e.
|
||
the RAM-only, resets-on-restart design (deliberately chosen in section W to avoid "resurrecting a possibly
|
||
very old value") no longer matches what's wanted: the dongle being offline is the *expected* steady state
|
||
whenever the car is parked, so the fallback needs to survive restarts, and its age must never be a reason to
|
||
hide it - an old real reading is still better than "unbekannt".
|
||
|
||
Fixed by seeding the RAM cache from the already-persisted `batteriespannung.jsonl` history once, at startup,
|
||
instead of changing the cache to a Store (would duplicate data already saved elsewhere for a different
|
||
purpose - the day-level battery history). New `spannung_cache_vorladen(verlauf)` in `veroeffentlichung.py`,
|
||
called once from `Koordinator.starten()` (`koordinator.py`) right before the first `alles_veroeffentlichen()`
|
||
so even the very first publish after a restart already carries the seeded value instead of a transient
|
||
"unbekannt". `fahrzeugstatus()` itself (the per-tick publish function) is untouched - it already reads
|
||
`_letzte_gueltige_spannung["wert"]` as its fallback, this just means that value is no longer empty
|
||
immediately after a restart.
|
||
|
||
**First version was wrong - owner caught it: "you got me wrong". "last measured voltage" pulled from the
|
||
raw `min`/`max` of the last day entry regardless of value - which on this container's real data meant
|
||
15,4 V, the day's *max*, i.e. generator/charging voltage (`batterie.py`'s own docstring documents that
|
||
min/max are stored raw, unfiltered by any resting-voltage border - see section N/T history).** A 15,4 V
|
||
"Batteriespannung" reading is misleading - that's not the battery's own state. Owner: **"last measured
|
||
voltage - but within the agreed borders! confirm me the border!"** - confirmed and agreed: **10,0 V
|
||
(`SPANNUNG_MIN_V`, the existing plausibility floor) to 13,0 V (`AGM_RUHE_MAX_V`, the existing
|
||
resting-vs-generator ceiling)** - the same two borders already governing the live reading itself
|
||
(`fahrzeugstatus()`) and the frontend's diagram/trend filtering (section X), just not previously shared
|
||
into the backend as a named constant. Added `AGM_RUHE_MAX_V = 13.0` to `batterie.py` (backend's own copy,
|
||
next to `SPANNUNG_MIN_V` - `veroeffentlichung.py` imports both locally inside the function to avoid the
|
||
same circular-import risk `SPANNUNG_MIN_V` already routes around). `spannung_cache_vorladen()` rewritten to
|
||
scan the ENTIRE stored history (not just the last day), collect every `min`/`max` reading that falls inside
|
||
`[SPANNUNG_MIN_V, AGM_RUHE_MAX_V]`, and seed the cache with the chronologically most recent one - so a day
|
||
where the car was never at rest (both min and max above 13,0 V) is correctly skipped in favor of the most
|
||
recent day that does have a genuine resting-range reading.
|
||
|
||
Verified: `py_compile` clean on all touched files, manifest bumped `2026.8.27.20` → `2026.8.27.21`,
|
||
`audi_ha_test` restarted twice and confirmed clean via log both times (`... eingerichtet`, no traceback) with
|
||
the dongle still reporting `0 V` live throughout. First (unfiltered) version showed **15,4 V** live - correct
|
||
per that version's own logic, wrong per the actual requirement, caught by the owner before being accepted.
|
||
Corrected version confirmed live via the published entity's own `daten.batteriespannung` attribute:
|
||
**12,149 V** - the most recent stored reading that actually falls within 10,0-13,0 V (2026-08-27's daily
|
||
minimum, `12.149` at `09:44:00Z`), correctly skipping that same day's `15.439` maximum and every one of the
|
||
three preceding fully-out-of-border days (`2026-08-23`/`24`/`25`, both min and max pinned at `13.988`, always
|
||
excluded).
|
||
|
||
---
|
||
|
||
## Working conventions (observed — keep them)
|
||
|
||
- German is the project language: identifiers, comments, commits, UI texts. Exceptions:
|
||
`design-system/` uses English props/JSDoc (Claude Design audience) — and **this file**, which is
|
||
English by rule.
|
||
- Decisions are logged, including rejected ones (see the Traccar section of the architecture
|
||
doc); superseded sections stay in place marked "ÜBERHOLT" rather than being deleted.
|
||
- Numbers in `de-DE` format; font weights 300/400 only, never ≥600 (only those cuts exist).
|
||
- Real vehicle/movement data stays local: it lives in Home Assistant under
|
||
`/config/audi_dashboard/`, never in this repo. Only the placeholder template
|
||
(`custom_components/audi_dashboard/vorlage/fahrzeugprofil.json`) is versioned; the real receipt
|
||
PDFs under `tests/belegparser/belege/` are gitignored.
|
||
- **There is no second copy of the code to keep in sync any more.** Until 2026-08-23,
|
||
`installationspaket/` duplicated `pyscript/`, `www/` and `data/`, and every change had to be
|
||
mirrored by hand — the older entries below still say so. The installer now reads
|
||
`custom_components/audi_dashboard/` directly. Do not reintroduce a copy.
|
||
- Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker,
|
||
proxy allowlist) may be exposed, each by explicit decision.
|