Entscheidung nach DESIGN_REVIEW_2026-08-13.md §A, Option 1: die additive iOS-Auflage (audi-dashboard-ios.css) wird zur verbindlichen Optik erklaert, statt die App auf reine Audi-CI zurueckzuschneiden. Der bisherige SPECIFICATION.md-§2-Wortlaut (Audi-CI-Farben, 20px-Radius) ist wortgleich in AUDI_CI_ARCHIV_2026-08-13.md gesichert, inklusive Anleitung fuer den Rueckweg - der Code selbst braucht dafuer kein Backup, da die iOS-Auflage rein additiv ist und die Audi-CI-Basiswerte in audi-dashboard.css unveraendert erhalten bleiben. SPECIFICATION.md §2 beschreibt jetzt die tatsaechlichen iOS-Werte aus audi-dashboard-ios.css. bauauftrag.md §8 bleibt unangetastet (Projekt- konvention: historisches Dokument), gilt aber ab hier als ueberholt. Zwei aus demselben Review noch offene Implementierungsfehler (Rot-Semantik invertiert, 20px/16px-Radius-Inkonsistenz bei Popups) sind unabhaengig von dieser Richtungsentscheidung und bleiben offen. Zusaetzlich: Versionsbump nach dem Redeploy in audi_ha_test.
36 KiB
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:
- Fahrterkennung läuft über den Zündungssensor des FMM003, nicht mehr über den iPhone-WLAN-Sensor.
sensor.iphone_wifi_connectionundfahrzeug.wlan_namesind entfernt.- Datenquelle ist der FMM003 über flespi (Codec8/TCP), nicht mehr die HACS-Integration
TommiG1/HA_VAG-EU-Data-Act.- Entity-IDs werden im Setup-Menü der App zugeordnet (Einstellungen → Fahrzeug einrichten → Setup) und in
data/entitaeten.jsonabgelegt.einstellungen.pyenthä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 inAUDI_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 (sieheAGENTS.md).Bekannter, noch offener Folgefehler aus demselben Review: die Rot-Semantik ist in der iOS-Auflage invertiert (
.aktionfü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: 999pxunverändert — Buttons, Schalter, Segmente, Fortschrittsbalken..setup-popup/.standortmenuschreiben weiterhinborder-radius: 20pxals 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 —.figclass, used for large numeric figures (odometer, range, tank %, prices, service countdowns)."Audi Type Extended", italic —.sportclass 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 viacustomElements.define(guarded against double-registration). - Loaded through a tiny stable loader stub,
audi-dashboard-panel.js, referenced fromconfiguration.yaml'spanel_customblock. The stub fetches/local/audi-dashboard-version.json(uncached), then dynamicallyimport()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 touchingconfiguration.yamlor restarting HA. It works around a documented HA quirk whereha-panel-customonly cache-bustsjs_url-type panels, notmodule_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 ofthislet 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). hassis injected automatically by HA'spanel_customintegration. The constructor works around a real timing bug:ha-panel-customcan calldocument.createElement(tag)beforecustomElements.define()has registered the class, so an early.hass =assignment lands as a plain instance property instead of hitting theset 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)setsrouteand callsrender(). - 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()), nativeconfirm()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; nativealert()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_hochladenservice call; the parsed result comes back asynchronously via a dedicated hass entity rather than the service call's return value (a documented pattern, seefrontend_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_sensors 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:
.icsfile 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, plusrad-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 onlyshell-logo.svgis 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:
- 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.pyhas an explicitWICHTIGER VORBEHALTcomment: trips that never get an odometer match staystatus="offen"forever.km_quelle="gps"is a value the schema supports but no code path ever produces. - GPS fields are dead schema.
start_lat/lon,end_lat/lon,start_address,end_address,avg_speed_kmh,routeall 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. - Full-tank derivation was removed. Bauauftrag §7.4/§6.4 describe a 97%-fill-level threshold deriving a
full_tankflag and consumption.belegverarbeitung.py's own header comment confirms this was explicitly removed per a later change request; the currenttankvorgaenge.jsonlschema has nofull_tank/level_before_pct/level_after_pctfields. - 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. - 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 inaudi-dashboard-app.js. INSTALL.mdnames entity variables that don't matcheinstellungen.py. The doc's step 4 referencesDOORS_SENSOR/WINDOWS_SENSOR/LOCK_ENTITY/BATTERY_VOLTAGE_SENSOR; the actual variables areTUER_SENSOREN/FENSTER_SENSOREN/TUERSCHLOSS_SENSOREN(each a 4-element list) and singularBATTERIE_SENSOR.- Two in-memory-only state machines lose data across an HA restart, by explicit design-doc admission: trip start (
_fahrt_start_tsinfahrterkennung.py) and fill-up low-water-mark (_tiefststand_pctintankerkennung.py). Both are flagged in code comments as deferred hardening, not accidental bugs. - The bauauftrag §2/§8 spacing scale (
--sp-1..--sp-5) and--r-func: 8pxtoken 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. - A stale header comment in
audi-dashboard-app.jsstill claims the Statistik page shows placeholder numbers with no real evaluation. This is now false —vStat()computes genuine period-based statistics fromTRIPS/FILLS(fahrtenSeit,tankSeit,verbrauch, day/night split viaistNachtZeit). fahrten.jsonlandtankvorgaenge.jsonlare empty in this checkout — no trips or fill-ups have been recorded through this install yet.BATTERIE_SENSORis 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.- 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)