# Audi Dashboard — Technical Specification

> ## ⚠️ Teilweise überholt (Stand 2026-08-13)
>
> Dieses Dokument beschreibt den Stand **vor** der FMM003-Umstellung. Drei Dinge haben sich seither
> geändert und gelten in diesem Dokument durchgehend als veraltet:
>
> 1. **Fahrterkennung** läuft über den Zündungssensor des FMM003, nicht mehr über den
>    iPhone-WLAN-Sensor. `sensor.iphone_wifi_connection` und `fahrzeug.wlan_name` sind entfernt.
> 2. **Datenquelle** ist der FMM003 über flespi (Codec8/TCP), nicht mehr die HACS-Integration
>    `TommiG1/HA_VAG-EU-Data-Act`.
> 3. **Entity-IDs** werden im Setup-Menü der App zugeordnet (Einstellungen → Fahrzeug einrichten →
>    Setup) und in `data/entitaeten.json` abgelegt. `einstellungen.py` enthält nur noch die
>    eingebauten Vorgabewerte und ist keine Pflicht-Bearbeitung mehr vor der Installation.
>
> Alles Übrige — Datenmodell, Geschäftsregeln, Frontend-Aufbau, §7 „Known Gaps" — gilt weiterhin.
> Den aktuellen Stand führt `AGENTS.md`, Abschnitt B.

**Audience:** an AI coding agent picking up this codebase with no prior context.
**Scope:** the Home Assistant panel (`homeassistant/`) — the finished, in-use app. A separate sibling project, `design-system/`, is a standalone React component-library replica of this app's visual language, built solely to feed Claude Design (`claude.ai/design`) via the `/design-sync` skill; it is **not** part of this app and deliberately excludes Audi brand assets. `COMPANION_APP_ARCHITECTURE.md` documents a third, **not-yet-built** sibling project — an iOS/Android/HA-iframe companion app fed by a Teltonika FMM003 tracker instead of the WLAN-sensor/VAG-integration data sources described below; its design is decided but no code exists yet. Don't confuse any of the three.

**Source of truth order:** the actual code in `homeassistant/pyscript/` and `homeassistant/www/` is authoritative. `bauauftrag.md` (root, German, dated 2026-08-05) is the original build brief — useful for rationale and history, but the implementation has since diverged and grown well beyond it (86 build tasks completed since). Every place code and brief disagree is called out in **§7 Known Gaps & Discrepancies** — read that section before trusting anything in `bauauftrag.md` as current behavior.

---

## 1. Concept

A private, single-user Home Assistant `panel_custom` dashboard that replaces the Audi *connect plug & play* app (discontinued end of 2026) for one specific vehicle: an **Audi RS 4 Avant competition**, FIN `WUAZZZF48PA902804`. It surfaces vehicle status, a trip log, a fuel log, service/maintenance forecasting, insurance/tax, and tire management — read-only with respect to the vehicle (no remote control).

**Data sources:**
- **HACS integration `TommiG1/HA_VAG-EU-Data-Act`** — odometer, fuel level, range, door/window/lock state, service-due dates. Polled every 15 minutes by the vehicle itself; no GPS, no live telemetry (no speed, no RPM, no OBD fault codes).
- **iPhone + Home Assistant Companion App** — a single sensor reporting the currently-connected WLAN SSID, used as the sole trip-detection trigger (connects to the car's own WLAN hotspot). GPS from the phone was planned but is **not wired in** (see §7).
- **Shell fuel receipts** — PDF uploaded through the app UI, parsed server-side.
- **Manual entry** — everything else (insurance, tax, service book, tire data, trips/fill-ups without a receipt).

**Explicit non-goals** (bauauftrag §12): vehicle remote control, OBD fault codes/live telemetry, driving-style/efficiency scoring, per-trip top speed, multi-user support, redistribution of the Audi brand/font licenses outside this one private installation.

**Access:** Tailscale only ("VPN On Demand", rule bound to the vehicle's WLAN SSID), no port-forwarding, no Funnel — Home Assistant is never exposed publicly.

---

## 2. Concept & Design System

> **iOS-Optik ist seit 2026-08-13 die verbindliche Linie** (Entscheidung nach
> `DESIGN_REVIEW_2026-08-13.md` §A, Option 1: „iOS-Optik als neue verbindliche Linie
> dokumentieren"). Das ursprüngliche, reine Audi-CI-Design unten war bis dahin gültig und bleibt
> wortgleich archiviert in [`AUDI_CI_ARCHIV_2026-08-13.md`](AUDI_CI_ARCHIV_2026-08-13.md) —
> inklusive einer kurzen Anleitung, wie man technisch zurückwechselt (die Auflage ist rein additiv,
> die Basis-Werte unten sind im Code nach wie vor vorhanden). `bauauftrag.md` §8 beschreibt weiterhin
> das alte Design und gilt ab hier als überholt, wie es die Projektkonvention für Entscheidungen
> vorsieht (siehe `AGENTS.md`).
>
> Bekannter, noch offener Folgefehler aus demselben Review: die Rot-Semantik ist in der iOS-Auflage
> invertiert (`.aktion` füllt jede Primäraktion rot statt nur destruktive) — Befund B, noch nicht
> behoben.

### Visual language
iOS-native Formensprache (`audi-dashboard-ios.css`, additive Auflage über der Audi-CI-Basis unten):
große Titel, gruppierte 44-px-Zeilen, transluzente Tab-Leiste mit Blur, iOS-Schalter/-Segmente,
gefüllte Aktionsknöpfe. Dunkel ("Nacht") bleibt Standard, "Tag" als Umschalter. Audi Type und das
Rot `#F50537` bleiben führend (Typografie, Akzentfarbe) — Fläche, Radius und Signalfarben folgen
iOS-Konventionen statt der ursprünglichen Audi-Palette.

### Colors (CSS custom properties, iOS-Auflage überschreibt die Audi-CI-Basis pro Theme)

| Role | Nacht (default) | Tag |
|---|---|---|
| `--canvas` (page background) | `#0C1014` | `#F2F2F7` |
| `--tile` | `rgba(255,255,255,.055)` | `#FFFFFF` |
| `--tile-2` | `rgba(255,255,255,.10)` | `#EFEFF4` |
| `--line` | `rgba(255,255,255,.13)` | `rgba(60,60,67,.13)` |
| `--line-strong` | `rgba(255,255,255,.24)` | `rgba(60,60,67,.24)` |
| `--fg` | `#FFFFFF` | `#000000` |
| `--fg2` | `#BFC4CC` | `#3C3C43` |
| `--fg3` | `#8E8E93` | `#8E8E93` |
| `--ok` | `#30D158` | `#34C759` |
| `--warn` | `#FFD60A` | `#FF9F0A` |
| `--bad` | `#FF453A` | `#FF3B30` |
| `--ios-tint` (Akzent/Aktionsfüllung, theme-unabhängig) | `#F50537` | `#F50537` |

`.aktion` füllt sich vollflächig mit `--ios-tint` — abweichend von der ursprünglichen Regel „Rot nur
als Akzent" (bekannter, offener Befund, siehe Kasten oben).

### Radii
- `--r-tile: 16px` (iOS-Wert; Audi-CI-Basis war 20px, siehe Archiv) — Kacheln, Popups, Bildbereiche.
- `--r-pill: 999px` unverändert — Buttons, Schalter, Segmente, Fortschrittsbalken.
- `.setup-popup`/`.standortmenu` schreiben weiterhin `border-radius: 20px` als Literal
  (Inkonsistenz zum 16px-Token, ebenfalls im Review dokumentiert, noch nicht bereinigt).

### Typography
Three font-family names in play, all under the trademarked "Audi Type" family, embedded inline as base64 `woff2` directly in `audi-dashboard.css` (private-install only — see Licensing below):
- `"Audi Type"` — body/UI text, weight 400.
- `"Audi Type Wide"`, weight 300 — `.fig` class, used for large numeric figures (odometer, range, tank %, prices, service countdowns).
- `"Audi Type Extended"`, italic — `.sport` class and the vehicle-title badge suffix (`.badge .zusatz`).

Only weights 300 and 400 are shipped — never use `font-weight: 600` or higher; the browser would synthetically bold. Numbers are formatted via `de-DE` locale (thousands separator, comma decimals) through the `de()`/`eur()` helpers.

### Licensing constraint (hard rule)
The Audi Type font, the four-rings SVG, and the model badge SVGs (`www/badges/*.svg`) are **licensed/trademarked assets, cleared only for this one private, non-published installation** (bauauftrag §8, §12). Never extract, republish, or reuse them anywhere outside this specific Home Assistant instance — this is exactly why the sibling `design-system/` repo (built for Claude Design sync) uses a generic fallback font stack and ships no Audi branding at all.

---

## 3. Frontend UI/UX

### Architecture
- **One file**, `homeassistant/www/audi-dashboard-app.js` (~2900 lines): defines the custom element `<audi-dashboard-panel>`, registered via `customElements.define` (guarded against double-registration).
- Loaded through a tiny stable loader stub, `audi-dashboard-panel.js`, referenced from `configuration.yaml`'s `panel_custom` block. The stub fetches `/local/audi-dashboard-version.json` (uncached), then dynamically `import()`s `/local/audi-dashboard-app.js?v=<version>` — this is the cache-busting mechanism that lets the app be updated by rewriting one small JSON file (see §5, `updateverwaltung.py`), without touching `configuration.yaml` or restarting HA. It works around a documented HA quirk where `ha-panel-custom` only cache-busts `js_url`-type panels, not `module_url`-type ones.
- **No framework, no bundler.** All module state lives in free (non-class) variables by design — this file is a near-verbatim port of the original static-HTML prototype (`dashboard-muster.html`), and keeping the render functions (`vHome()`, `vTrips()`, `render()`, …) free of `this` let them be reused almost unchanged. For a single-panel app, one instance == "the one interface," same as the prototype's single `<body>`.
- Renders into an **open shadow root**. `$(selector)` = `ROOT.querySelector(selector)`.
- `hass` is injected automatically by HA's `panel_custom` integration. The constructor works around a real timing bug: `ha-panel-custom` can call `document.createElement(tag)` before `customElements.define()` has registered the class, so an early `.hass =` assignment lands as a plain instance property instead of hitting the `set hass()` accessor and is never "upgraded." The constructor re-triggers the setters by deleting and reassigning any such own-properties found at construction time.

### Data flow
Backend pyscript publishes 7 `pyscript.*` state entities; the frontend reads `hass.states[id].attributes.daten`:

| Entity | Frontend variable |
|---|---|
| `pyscript.audi_dashboard_profil` | `PROFIL_ROH` → `CONFIG`/`CAR` (via `profilZuConfig()`/`profilZuCar()`) |
| `pyscript.audi_dashboard_fahrten` | `TRIPS` |
| `pyscript.audi_dashboard_tankvorgaenge` | `FILLS` |
| `pyscript.audi_dashboard_fahrzeugstatus` | merged into `CAR`; its `last_updated` drives the staleness indicator |
| `pyscript.audi_dashboard_batterieverlauf` | `BATTERIEVERLAUF` |
| `pyscript.audi_dashboard_beleg_ergebnis` | routed through `belegErgebnisVerarbeiten()` (receipt-upload async result) |
| `pyscript.audi_dashboard_update_status` | `UPDATE_STATUS` (fetched once via WebSocket, not passive push) |

Most settings mutate `CONFIG`/`CAR` in place in the browser, then call `profilSpeichern()` → `HASS.callService("pyscript", "audi_dashboard_profil_schreiben", {...})`, which round-trips the **entire profile** as one JSON blob. There is no field-level write service for profile data.

A retry-loading system (`nachladeAnstossen()`) handles the case where pyscript hasn't published its entities yet after an HA restart (panel_custom doesn't reliably re-push `hass` afterward): retries at 200ms, then every 2s for 15 attempts, then falls back to every 10s indefinitely — no hard timeout. This replaced an earlier 15-retry/~30s hard cutoff that was observed in production to be too short (task #55).

### Navigation
- 5 bottom tabs (`TABS`): **Übersicht** (home) · **Mein Audi** (audi) · **Fahrten** (trips) · **Statistik** (stat) · **Tanken** (fuel).
- **Einstellungen** (Settings) lives behind the Audi-rings icon top-right — not a tab.
- Navigation state is a flat `route = {name, id}` object, no browser history stack. `go(name, id)` sets `route` and calls `render()`.
- Back-button target per sub-page is a static lookup table, `ZURUECK` — includes 3-level nesting (`schutz` → `vertragsdetails` → `vers`).

### Views
| Route | Function | Notes |
|---|---|---|
| `home` | `vHome()` | vehicle photo, badge, plate, "sicher abgestellt" status, range ring/bar, odometer + next-service tile pair, last-trip/last-fill teaser |
| `sicherheit` | `vSicherheit()` | 12-point door/window/lock detail |
| `audi` | `vAudi()` | "Mein Audi" hub — photo gallery, tiles linking to sub-pages |
| `ident` | `vIdent()` | identity, technical specs, equipment list |
| `battverlauf` | `vBatterieverlauf()` | hand-built SVG battery-voltage chart |
| `service` | `vService()` | upcoming appointments, book-an-appointment form, workshop contact, service book list |
| `werkstatt` | `vWerkstatt()` | workshop contact edit form |
| `sbuch` | `vSbuch(i)` | single service-book entry (view/edit/new-draft) |
| `vers` | `vVers()` | insurance/tax hub |
| `beitrag` | `vBeitrag()` | edit insurance premium line items |
| `vertragsdetails` | `vVertragsdetails()` | contract line items, links to Schutzbrief |
| `schutz` | `vSchutz()` | mobility-protection (Schutzbrief) terms, read-only |
| `notruf` | `vNotrufBearbeiten()` | edit emergency phone numbers |
| `steuer` | `vSteuer()` / `vSteuerBearbeiten()` | vehicle tax summary + edit |
| `reifen` | `vReifen()` | tire sets, wheel-bolt torque, swap toggle, swap-date → calendar |
| `trips` | `vTrips()` / `vTripsFormular()` | trip list (year→month accordion) + manual-add form |
| `trip` | `vTrip(id)` | single trip detail + map |
| `stat` | `vStat()` | statistics (distance/time, fuel, consumption, day/night, trip type — real computed values, see §7) |
| `fuel` | `vFuel()` | fill-up list (year→month, volume-weighted avg price) |
| `fill` | `vFill(id)` / edit form | single fill-up detail, receipt upload/replace |
| `einst` | `vEinst()` | Settings — vehicle setup, theme/labels/ground toggle, overview image, photo upload grid, SmartDeal, trip-merge gap, profile export/import, backup, CSV export, version + self-update |

### Interactive components
- **Swipe-to-delete** — Pointer Events (not Touch Events, deliberately, so mouse works for desktop testing), 84px reveal width, opening one row closes any other (`schliesseAlleSwipes()`), native `confirm()` before delete, then a service call + optimistic local-array filter. Used for trip and fill-up rows; service-book rows use a plain delete button instead.
- **Pull-to-refresh** — home route only, only from scroll-top, iOS-style rubber-band resistance (`min(90, dy*0.5)`), 56px trigger threshold.
- **Popups** — only two: `.bildmenu` (photo replace/delete, used both in Settings' photo grid and on tire-set wheel photos) and `.sdpopup` (SmartDeal activation, asks for an expiry date). No native `<dialog>` anywhere; every other "form" is an inline collapsible section toggled by a boolean state var (`einrichtenOffen`, `nmOffen`, `fahrtOffen`, `tankBearbeitenOffen`, …).
- **Destructive/error UX** — native `confirm()` gates every delete and the self-update install; native `alert()` surfaces import/upload/update failures; receipt-parsing errors render inline in red next to the relevant form.
- **Battery-voltage chart** — hand-drawn SVG, no charting library (no bundler in the project). Pinch-zoom (2-finger)/mouse-wheel, 1-finger pan, tap selects nearest point for a date/time + min/max tooltip.
- **Maps** — Leaflet, loaded from CDN at runtime (not bundled) — requires the client to have internet access in addition to the Tailscale tunnel. Tile source switches with the day/night theme.
- **Receipt upload** — native file picker (PDF only) → base64 → `audi_dashboard_beleg_hochladen` service call; the parsed result comes back asynchronously via a dedicated hass entity rather than the service call's return value (a documented pattern, see `frontend_veroeffentlichung.py`), because pyscript service calls don't return values to the frontend.

### Theming
`data-theme` attribute on the host element (not `:root`). Set from Settings' segmented control (the topbar sun/moon button was retired by Design-Audit finding A6 — it's still in the markup but `hidden`), which then re-runs `themeIcon()`/`tiles()` (map tile layer swap). **Persisted** via `localStorage` (`THEME_KEY`, `themeGewaehlt()`/`themeSetzen()`); with nothing stored yet it follows the OS setting via `prefers-color-scheme`, defaulting to "nacht". The tab-bar-label toggle is persisted the same way (`SCHRIFT_KEY`). All `localStorage` access is wrapped in try/catch for private-mode browsers.

### Icons
All hand-authored inline SVG (no icon font, no external library). A `ICONS`/`ICON` object holds tab and sun/moon fragments; the settings-cog path is duplicated verbatim in two places (`vSteuer()`, `vReifen()`) rather than factored out.

---

## 4. Logic (business rules)

| Rule | Where | Trigger | Summary |
|---|---|---|---|
| **Trip detection** | `fahrterkennung.py` | `@state_trigger` on the Companion App WLAN sensor | Start = sensor value becomes the vehicle's SSID (from profile). End = sensor leaves that SSID **and doesn't return within the pause-tolerance window** (`fahrten_pausenzeit_min`, default 15 min, editable in Settings). Implemented via `task.unique()` cancelling a pending `task.sleep()` if the WLAN reconnects in time — the trip record is only written after the wait, never on the raw disconnect. **In-memory only** (`_fahrt_start_ts`) — an HA restart mid-trip or mid-pause-window silently loses the trip (documented, deferred hardening gap). |
| **Two-stage trip completion** | `modules/fahrtabschluss_logik.py` | odometer sensor state-trigger + post-trip-end call | The odometer often doesn't update until the *next* trip. `screening_durchfuehren()` searches the HA REST history API (48h window, Bearer token from `audi_dashboard/ha_token.txt`) for the odometer reading closest in time to trip start/end; fills `odo_start`/`odo_end`, sets `status="vollständig"` once both are known. Adjacent trips propagate a known odometer value across a gap when the vehicle was simply parked (`_lueckenlos_verketten`). **GPS fallback is NOT implemented** despite being referenced in comments — see §7. |
| **Distance** | derived, everywhere | — | `distance_km = odo_end − odo_start`. GPS (when/if present) supplies route/addresses only, never distance — so gaps in position tracking are harmless by design. |
| **Tire-set km counter** | `reifenzaehler.py` | odometer state-trigger + startup | Rewritten from the original spec: each tire set's `km` is a running cumulative total; `referenz_odo_km` is the last-processed absolute odometer reading. Every odometer change adds the delta to the **currently active** set, then advances the reference. On swap, the old set is settled first, *then* `aktiv` flips — km driven on the old set can never leak onto the new one. Resilient to a temporarily-`unavailable` odometer sensor (reference simply doesn't move; next valid reading computes its delta against the old reference, so no km is lost or double-counted across an outage). |
| **Automatic fill-up detection** | `tankerkennung.py` | fuel-level state-trigger | Fires when the level rises, since the last low-water-mark, by ≥ the *more sensitive* of "5 L converted to %" (using `tankvolumen_liter`, default 58L) or "9 percentage points." Tracks a low-water-mark (not simple prev/current diff) because the integration sometimes reports a fill-up as several small incremental steps. Creates a **placeholder** fill-up (`status="unvollständig"`, no liters/cost/station, `source="auto"`) — the user completes it later via receipt upload. Low-water-mark is in-memory only (same restart caveat as trip detection). |
| **Shell receipt parsing** | `data/shell_beleg_parser.py` (standalone CLI, `pypdf`) | invoked via `subprocess.run` from `belegverarbeitung.py` | All matching is structural regex (`Obj.-Nr.`, `Beleg-Nr.`, pump-line patterns, `#A` article markers), not brand/label text, because brand and discount-label wording vary across receipt printings. `price_per_l` is deliberately never read from the PDF — always recomputed as `fuel_total_eur / liters`. Dedup key: `"{station_id}_{receipt_no}_{ts[:16]}"` (minute granularity). Regression-tested against 10 real receipts from 4 stations. |
| **Receipt-to-fill-up matching** | `belegverarbeitung.py` | `audi_dashboard_beleg_hochladen` service | Same `receipt_key` already attached to a *different* `tank_id` → rejected as duplicate. Patching an existing fill-up requires the receipt's calendar day to match the record's — else rejected as "Falsches Belegdatum." Odometer/distance always come from the vehicle, never overwritten by a receipt. Uploading a receipt with no `tank_id` never creates a record — only feeds the frontend's currently-open draft. Any field listed in a record's `edited_fields` is protected from being overwritten by future automated updates. |
| **Service/oil-change forecast** | split — see §7 | — | The vehicle's own reported due date/km (EU-Data-Act sensors) is republished as-is by the backend. The app's **own** Servicebuch-based forecast (`oelwechselPrognose()`, `intervalle()`, `termine()`) — which mixes the full historical average with a ~180-day-weighted recent rate, restarting from zero after each logged oil change, capped at the configured interval's time limit — is entirely **frontend** logic, not backend, despite the brief describing it as core backend logic. |
| **SmartDeal (fuel discount) validity** | frontend, `profilZuCar()` | recomputed every render | `aktiv = einstellungen.smartdeal.aktiv && (no laeuft_ab OR laeuft_ab >= today)` — the expiry date is **inclusive** (discount still applies on the expiry date itself). No backend cron enforces this; it's a pure computed property re-evaluated on load/render. |
| **Backup** | `backup.py` | daily `@time_trigger("cron(0 4 * * *)")` | Folder-copy (not zip) of the 3 data files into `audi_dashboard/backups/<timestamp>/`. Interval (`aus`/`taeglich`/`woechentlich`/`monatlich`) evaluated inline inside the single daily trigger — weekly fires only on Monday, monthly only on day 1. Restore is the counterpart to a **browser-side** export/import that bundles profile+trips+fill-ups client-side. |
| **Self-update** | `updateverwaltung.py` | manual services only | Check: shallow git clone/fetch of `UPDATE_REPO_URL`, compares `www/audi-dashboard-version.json`'s numeric version, source vs. live. Install: backs up live `pyscript/` + the 3 frontend files to `audi_dashboard/code_backups/<timestamp>/`, then wholesale-replaces `pyscript/`, the 3 www files, and `www/badges/` from the clone. Writes a **fresh** version number (`int(now.timestamp())`, not the source repo's own value) to force every browser to cache-bust. Never touches `www/bilder/` (user photos) or `audi_dashboard/` (user data). No-op entirely if `UPDATE_REPO_URL` is empty. |
| **Battery voltage tracking** | `batterieverlauf.py` + frontend `vBatterieverlauf()` | every 5 min + startup; no-op if `BATTERIE_SENSOR` unset (currently the case) | Backend records only raw daily min/max + timestamps, unfiltered. All interpretation is client-side: SOC % via a voltage→percent lookup table, SOH as a trend (early-window vs. late-window average delta, not a real capacity measurement), using the daily **minimum** as the resting-voltage proxy (occurs shortly after engine start, before the alternator raises voltage) — an `AGM_RUHE_MAX_V` (13.2V) threshold filters out readings taken mid-drive. |
| **"Securely parked" status** | `frontend_veroeffentlichung.py::_sicherheitscheck` | published with every status update | 16 individual door/window/lock/tailgate/hood checks, each `{label, ok}` where `ok` is `None` if the underlying sensor is missing. Aggregate is `None` if *any* check is unknown, else `True` only if *all* are `True` — guarantees the summary dot and the detail list can never disagree, and never shows a false-green from stale/missing data. |

---

## 5. Backend

### pyscript files and their `@service` definitions

| File | Services |
|---|---|
| `frontend_api.py` | `audi_dashboard_jetzt_aktualisieren()`, `audi_dashboard_profil_schreiben(profil_json)` |
| `backup.py` | `audi_dashboard_backup_jetzt()`, `audi_dashboard_backup_wiederherstellen(profil_json, fahrten_json, tankvorgaenge_json)` |
| `belegverarbeitung.py` | `audi_dashboard_beleg_hochladen(pdf_base64, dateiname, tank_id)`, `audi_dashboard_tankvorgang_manuell(...)`, `audi_dashboard_tankvorgang_aktualisieren(...)`, `audi_dashboard_tankvorgang_loeschen(tank_id)` |
| `bilderverwaltung.py` | `audi_dashboard_bild_hochladen(dateiname, daten_base64)`, `audi_dashboard_bild_loeschen(dateiname)` — both gated by a fixed 10-name allow-list (`ERLAUBTE_DATEINAMEN`); no other filename is ever writable/deletable |
| `fahrtabschluss.py` | `audi_dashboard_screening_jetzt()` (manual trigger for the odometer-history screening) |
| `fahrterkennung.py` | `audi_dashboard_fahrt_manuell_anlegen(ts_start, ts_end, art)`, `audi_dashboard_fahrt_loeschen(trip_id)` |
| `reifenzaehler.py` | `audi_dashboard_reifen_wechseln(satz)` (`"sommer"`/`"winter"` only) |
| `updateverwaltung.py` | `audi_dashboard_update_pruefen()`, `audi_dashboard_update_installieren()` |

`batterieverlauf.py` and `tankerkennung.py` expose no services — trigger-only. `modules/profil.py`, `modules/fahrtabschluss_logik.py`, `modules/frontend_veroeffentlichung.py`, `modules/einstellungen.py` are plain importable helper modules with no decorators.

### Triggers
`@time_trigger("startup")` in most files (ensures data folders exist / initial publish); `@time_trigger("period(now, 20 seconds)")` republishes live status; `@time_trigger("period(now, 60 seconds)")` republishes profile/trips/fills as a filesystem-edit safety net; `@time_trigger("cron(0 4 * * *)")` daily backup check; `@time_trigger("period(now, 5 minutes)")` battery check; `@state_trigger` on the WLAN sensor (trip detection), the odometer sensor (tire counter + completion screening), and the fuel-level sensor (fill detection). No `@event_trigger` usage anywhere.

### Data files

**`data/fahrzeugprofil.json`** (single JSON file, gitignored; `.example.json` is the tracked template) — top-level sections: `fahrzeug` (FIN, plate, titles, WLAN SSID, tank volume, Erstzulassung, HU-due), `einstellungen` (night/day hours, trip pause tolerance, overview-image choice, SmartDeal, oil-interval mode, backup interval, last-backup timestamp), `versicherung` (premium line items, contract, Schutzbrief terms, coverage groups), `steuer`, `reifen` (active set, torque spec, swap date, per-set cumulative km + `referenz_odo_km` + spec fields), `service` (appointments, workshop contact, service-book array), `technik` (static spec sheet), `ausstattung` (static equipment list).

**`data/fahrten.jsonl`** (JSON Lines, one trip per line — **currently empty in this checkout**, no trips recorded yet): `trip_id`, `ts_start`, `ts_end`, `duration_s`, `distance_km`, `km_quelle` (`"odometer"` — `"gps"` is defined but never produced), `odo_start`, `odo_end`, `avg_speed_kmh` (defined, never populated), `start_lat`/`start_lon`/`end_lat`/`end_lon` (defined, never populated), `start_address`/`end_address` (defined, never populated), `art` (`"privat"`/`"arbeitsweg"`), `route` (always `null`), `pausen` (always `[]`), `source` (`"ha"`/`"manual"`), `status` (`"offen"`/`"vollständig"`), `edited_fields`.

**`data/tankvorgaenge.jsonl`** (JSON Lines — **currently empty**): `tank_id`, `receipt_key`, `receipt_no`, `tse_beleg_nr`, `ts`, `ts_payment`, `ts_tse`, `station_id/name/address`, `article_no`, `product_name`, `fuel_type`, `liters`, `fuel_total_eur`, `price_per_l` (always computed, never read from receipt), `discount`, `discount_per_l`, `list_price_per_l`, `receipt_total_eur`, `net_eur`, `vat_eur`, `receipt_file`, `odometer_km`, `distance_km`, `source` (`"auto"`/`"manual"`/`"beleg"`), `status` (`"unvollständig"`/`"vollständig"`), `edited_fields`. Note: no `full_tank`/`level_before_pct`/`level_after_pct` fields — that concept was removed (see §7).

**`audi_dashboard/batteriespannung.jsonl`** — one line per calendar day: `{datum, min, min_ts, max, max_ts}`.

**Other runtime paths:** `audi_dashboard/ha_token.txt` (plaintext HA long-lived access token, used only for the history-screening REST calls), `audi_dashboard/backups/<ts>/`, `audi_dashboard/code_backups/<ts>/`, `audi_dashboard/belege/*.pdf`, `/config/.audi_dashboard_update_quelle` (scratch git clone for self-update, deliberately outside both `audi_dashboard/` and `pyscript/`).

### HA entities consumed
All centralized in `pyscript/modules/einstellungen.py` (the one file requiring edits before install), matched to `TommiG1/HA_VAG-EU-Data-Act` v0.6.35 for this vehicle: `sensor.iphone_wifi_connection` (trip trigger), `sensor.audi_rs_4_avant_mileage`, `..._fuel_level`, `..._range_primary`, `button.audi_rs_4_avant_refresh_now`, 4× door/window/door-lock `binary_sensor`s each, tailgate/hood + their locks, `..._oil_change_due`/`_distance`, `..._inspection_due`/`_distance`. `BATTERIE_SENSOR` is currently unset (integration provides none for this vehicle). All reads go through `zustand_oder_none()`, which treats `"unknown"`/`"unavailable"`/a missing entity as `None` rather than guessing.

### Installation / registration
`configuration_snippet.yaml` merges two blocks into `configuration.yaml`: `pyscript: allow_all_imports: true` (required — the scripts use `os`, `subprocess`, `urllib.request`, `base64`, `uuid`, none of which pyscript's sandbox allows by default) and a `panel_custom` entry pointing `module_url` at `/local/audi-dashboard-panel.js?v=1` (sidebar title "Mein Audi", icon `mdi:car-sports`). `INSTALL.md` walks a ~30–40 min manual setup: install pyscript via HACS, copy the 3 folders into `config/`, merge the YAML, edit `einstellungen.py` with real entity IDs, drop a long-lived access token into `ha_token.txt`, restart HA, verify via Developer Tools. pyscript hot-reloads on file change; the frontend's cache-busting is handled entirely by the version-stub pattern described above (§3).

---

## 6. Product surface not covered elsewhere

- **CSV export** (Settings → "Daten ausgeben"): trips, fill-ups, service book — semicolon-delimited, German number format, BOM-prefixed for Excel.
- **iOS calendar export**: `.ics` file generation client-side (`ics()`) for service appointments and tire-swap dates, with a 2-day advance alarm.
- **Vehicle photos**: fixed filenames under `www/bilder/` (`seitenansicht.webp`, `front-schraeg.webp`, `frontansicht.webp`, `heckansicht.webp`, `cockpit.webp`, `scheinwerfer.webp`, `sitze.webp`, `seitenansicht-winter.webp`, plus `rad-sommer.webp`/`rad-winter.webp`), uploadable/deletable per-slot from Settings and the tire page; missing files render a placeholder box with an icon, label, and the exact expected filename (never a broken-image icon), preserving layout dimensions. In this checkout only `shell-logo.svg` is present — all vehicle photo slots are still empty.
- **Model badges**: `www/badges/{rs3,rs4,rs5,rs6,rs7,sq7}-{positive,negative}.svg`, theme-swapped via pure CSS (`:host([data-theme=...])`), model selection in Settings restricted to the 11 names that have a matching badge.

---

## 7. Known Gaps & Discrepancies (read before trusting `bauauftrag.md`)

These are places the shipped code and the original build brief disagree — load-bearing for anyone extending this app:

1. **GPS-distance fallback does not exist.** Bauauftrag §7.2/§7.3 describe falling back to GPS-derived distance when the odometer never resolves. The actual `fahrtabschluss_logik.py` has an explicit `WICHTIGER VORBEHALT` comment: trips that never get an odometer match stay `status="offen"` forever. `km_quelle="gps"` is a value the schema supports but no code path ever produces.
2. **GPS fields are dead schema.** `start_lat/lon`, `end_lat/lon`, `start_address`, `end_address`, `avg_speed_kmh`, `route` all exist on the trip record but are never populated by any reviewed code — the iPhone-GPS integration described in bauauftrag §4.2 was never actually wired in beyond the WLAN trip-trigger.
3. **Full-tank derivation was removed.** Bauauftrag §7.4/§6.4 describe a 97%-fill-level threshold deriving a `full_tank` flag and consumption. `belegverarbeitung.py`'s own header comment confirms this was explicitly removed per a later change request; the current `tankvorgaenge.jsonl` schema has no `full_tank`/`level_before_pct`/`level_after_pct` fields.
4. **SmartDeal expiry is frontend-only.** No backend cron enforces `laeuft_ab`; it's recomputed as a property every render (`profilZuCar()`). Correct in practice (the UI is the only thing that reads it), but there's no server-side source of truth if something else were to consume the flag.
5. **The app's own service/oil forecast lives in the frontend, not the backend.** Bauauftrag §7.6 frames service-date forecasting as core *backend* logic ("Kernlogik"). In the shipped code, the backend only republishes the vehicle's own reported due date/km verbatim; the actual Servicebuch-based forecast math (`oelwechselPrognose()`, `intervalle()`, `termine()`) is JS in `audi-dashboard-app.js`.
6. **`INSTALL.md` names entity variables that don't match `einstellungen.py`.** The doc's step 4 references `DOORS_SENSOR`/`WINDOWS_SENSOR`/`LOCK_ENTITY`/`BATTERY_VOLTAGE_SENSOR`; the actual variables are `TUER_SENSOREN`/`FENSTER_SENSOREN`/`TUERSCHLOSS_SENSOREN` (each a 4-element list) and singular `BATTERIE_SENSOR`.
7. **Two in-memory-only state machines lose data across an HA restart**, by explicit design-doc admission: trip start (`_fahrt_start_ts` in `fahrterkennung.py`) and fill-up low-water-mark (`_tiefststand_pct` in `tankerkennung.py`). Both are flagged in code comments as deferred hardening, not accidental bugs.
8. **The bauauftrag §2/§8 spacing scale (`--sp-1`..`--sp-5`) and `--r-func: 8px` token don't exist in the shipped CSS.** Small-radius elements use hardcoded 6px/12px/14px literals instead — which is exactly what §8's own rule ("nie als Literalwerte in Komponenten") says not to do.
9. **A stale header comment in `audi-dashboard-app.js`** still claims the Statistik page shows placeholder numbers with no real evaluation. This is now false — `vStat()` computes genuine period-based statistics from `TRIPS`/`FILLS` (`fahrtenSeit`, `tankSeit`, `verbrauch`, day/night split via `istNachtZeit`).
10. **`fahrten.jsonl` and `tankvorgaenge.jsonl` are empty in this checkout** — no trips or fill-ups have been recorded through this install yet.
11. **`BATTERIE_SENSOR` is currently unset** — the vehicle integration provides no 12V-voltage sensor at present, so all battery-history logic is a permanent no-op until (if ever) one becomes available.
12. **Backlog, not yet built** (bauauftrag §15, noted 2026-08-09): coolant-level and oil-level measurement/history, mirroring the battery-voltage pattern, contingent on the vehicle integration exposing the right sensors.

---

## 8. File map

```
Audi_app_TG/
├── bauauftrag.md                       original build brief (German, historical — see §7)
├── dashboard-muster.html               the original static prototype (superseded, kept for reference)
├── SPECIFICATION.md                    this document
├── design-system/                      UNRELATED sibling project — Claude Design component library, no brand assets
└── homeassistant/
    ├── INSTALL.md, README.md, configuration_snippet.yaml, update.ps1
    ├── data/                           → deployed to config/audi_dashboard/
    │   ├── fahrzeugprofil.json (+ .example.json)
    │   ├── fahrten.jsonl, tankvorgaenge.jsonl
    │   ├── shell_beleg_parser.py + tests/
    ├── pyscript/                       → deployed to config/pyscript/
    │   ├── frontend_api.py, backup.py, batterieverlauf.py, belegverarbeitung.py,
    │   │   bilderverwaltung.py, fahrtabschluss.py, fahrterkennung.py,
    │   │   reifenzaehler.py, tankerkennung.py, updateverwaltung.py
    │   └── modules/
    │       ├── einstellungen.py        entity-ID config — the one file to edit pre-install
    │       ├── profil.py               file I/O for profile/trips/fills/battery history
    │       ├── fahrtabschluss_logik.py odometer-history screening + HA REST client
    │       └── frontend_veroeffentlichung.py   publishes the pyscript.* state entities
    └── www/                            → deployed/merged to config/www/
        ├── audi-dashboard-panel.js     stable loader stub
        ├── audi-dashboard-app.js       the actual custom element
        ├── audi-dashboard.css          design system
        ├── audi-dashboard-version.json cache-buster, rewritten by updateverwaltung.py
        ├── badges/                     12 static model-badge SVGs
        └── bilder/                     vehicle photo storage (mostly unpopulated in this checkout)
```
