diff --git a/AGENTS.md b/AGENTS.md
index c1d2493..e9e7a42 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,7 @@
# AGENTS.md — Project state, review findings, open items, and working rules
-**Last updated: 2026-08-13** (design/CI review added, see `DESIGN_REVIEW_2026-08-13.md`). This file is the entry point for every new agent
+**Last updated: 2026-08-13** (merged the `umsetzung-datametric360` branch — companion app phases
+1–10 done, see `UMSETZUNGSPLAN.md`). 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.
@@ -73,19 +74,22 @@ assets. Never mix these the other way around.
**Reading order for a new session:**
1. This file (overview + open items)
-2. `SPECIFICATION.md` — authoritative for the HA panel, incl. §7 "Known Gaps"
-3. `COMPANION_APP_ARCHITECTURE.md` — authoritative for DataMetric360 (decided, barely built)
-4. `AUDIT_2026-08-10.md` — accessibility/platform audit of the panel (partly done, rest below)
-5. `bauauftrag.md` — **historical only**; code has diverged (see SPECIFICATION.md §7)
+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 |
|---|---|---|
-| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** |
+| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** — to be replaced by the app |
+| `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 (Capacitor iOS/Android + HA iframe); will **replace** the panel | 🚧 data layer only (~930 lines TS), **no UI** |
-| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim (wrong model, main screens only) |
+| `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
@@ -103,12 +107,12 @@ Fahrzeug einrichten → Setup), stored as overrides in `data/entitaeten.json`;
`pyscript/modules/einstellungen.py` holds only the built-in defaults. Deploy: `update.ps1`
(robocopy to Samba share) or — still inactive — self-update from git.
-**`companion-app/` — what exists:** dependency-free, strictly typed TS data layer (`src/api/`):
-REST client (`rest.ts`), WebSocket client with auth flow + reconnect backoff (`live.ts`),
-persistent offline write queue (`warteschlange.ts`, strict FIFO), environment detection
-capacitor/iframe/browser (`umgebung.ts`), types + entity table (`types.ts`), facade
-`DataMetricApi` (`index.ts`). Smoke test ran 7/7 green against Docker HA `audi_ha_test`
-(localhost:18123) — unauthenticated only; token-authenticated reads/writes untested.
+**`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), all 21 screens
+(`src/screens/`), Audi assets (`src/assets/audi/`), PWA manifest and icons. Verified by 90 unit
+and render tests plus 9 checks against a live Home Assistant. `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
@@ -170,7 +174,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
frontend: no tests, no CI.
- **Leaflet via CDN:** trip map needs public internet in addition to the Tailscale tunnel.
-### Documentation drift (small fixes; align docs to code)
+### 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**;
@@ -179,19 +183,21 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
`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 (`AUDIT_2026-08-10.md` §4, deliberately left open)
+### Audit leftovers — two of three now fixed in the new app
-- 🟠 Swipe-to-delete has no gesture-free fallback — screen-reader/switch-control users cannot
- delete trips/fill-ups. Needs a design decision (long-press vs. "…" button vs. action sheet).
-- 🟡 Popup close-by-tap-outside is not keyboard-reachable (needs a quick manual check).
-- 🟡 Self-host Leaflet JS/CSS (tiles necessarily stay remote).
+- ✅ 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
+### companion-app / design-system ✅ RESOLVED 2026-08-11
-- **Not wired together:** `companion-app` does not reference `@audi-dash/ui` anywhere (no
- dependency, no import, no workspace root). The link exists only in prose.
+- **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).
@@ -201,24 +207,100 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
---
+### 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) plans the move from pyscript to a **native HA integration** with a config
+flow — UI-only setup, no YAML, and updates via a self-reporting `UpdateEntity` against the Gitea
+repo (HACS is GitHub-only, so it is not an option). All APIs in it were verified against the
+running 2026.8.1 instance. It also records which review findings that move eliminates by design.
+
+A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the
+three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole
+mapping; "reset" has no effect on 15 of 17 fields; all four list positions get the same sensor,
+which makes "securely parked" report safe while three doors were never checked).
+
+`main` has moved 18 commits ahead of `umsetzung-datametric360` (FMM003 switch, sensor-mapping setup
+menu, iOS/large-screen overlay from Claude Design). **Deliberate decision: do not merge yet** — the
+owner keeps working on `main` first.
+
+Three files conflict (`AGENTS.md`, `homeassistant/INSTALL.md`,
+`homeassistant/pyscript/fahrterkennung.py`); four more are touched by both sides but merge cleanly.
+
+**One hazard that a clean merge will not catch.** This branch changed `profil_lesen()` in
+`homeassistant/pyscript/modules/profil.py` to return `None` when the profile file is missing or
+corrupt, and guarded all seven call sites that existed here. `profil.py` is untouched on `main`, so
+it merges silently — but `main`'s call sites (`fahrterkennung.py`, `tankerkennung.py`,
+`reifenzaehler.py`, `modules/frontend_veroeffentlichung.py`) do **not** guard against `None` and
+would raise `AttributeError` on a missing profile instead of logging a clear error.
+
+When merging: take `main`'s FMM003 version of `fahrterkennung.py`, then re-apply the `None` guard to
+every remaining `profil.profil_lesen()` call site.
+
## Open items
+Execution order, exact steps, and acceptance criteria for every item below live in
+`UMSETZUNGSPLAN.md` (phases 1–13). Additional decisions of 2026-08-11: **no Electron** (Capacitor
+wraps the web app for iPhone; a PWA home-screen install is the accepted intermediate step) and
+**no separate backend** (the app talks to the HA REST/WebSocket API directly).
+
### A) Build DataMetric360 (the big block)
-- [ ] Fix the Claude Design draft (RS 4, not RS 6) and extend it by the 16 missing sub-pages;
+- [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/`
-- [ ] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
+- [x] `companion-app`: set up Vite + React + Capacitor scaffold; wire `@audi-dash/ui` as a real
dependency (possibly add a workspace/monorepo root)
-- [ ] Implement the screens from the design draft on top of the existing `DataMetricApi` layer
-- [ ] Add Audi assets (fonts/rings/badges) at implementation time from `homeassistant/www/` —
+- [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/`
-- [ ] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
+- [x] Secure storage for the LLAT (iOS Keychain / Android Keystore via Capacitor plugin; the
`ablageSetzen()` hook already exists)
-- [ ] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
+- [x] Onboarding: manual token paste (required); QR scan only if it stays simple (QR generated
locally under HA `/local/`, architecture §3)
-- [ ] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
+- [x] Authenticated smoke tests of the data layer (reads, service calls, queue round-trip)
against `audi_ha_test` with a real token
-- [ ] Offline UX per design brief (offline marker, visible pending queue)
+- [x] Offline UX per design brief (offline marker, visible pending queue)
### B) Infrastructure / commissioning (partly waits for FMM003 hardware)
@@ -227,7 +309,7 @@ formatting hold everywhere; `design-system/` is verifiably brand-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
-- [ ] Define the reverse-proxy path allowlist (depends on final entity/service names)
+- [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`).
@@ -420,9 +502,20 @@ formatting hold everywhere; `design-system/` is verifiably brand-free.
was left unexercised beyond confirming it registers without error at pyscript load time.
- [ ] 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)
-- [ ] Harden `profil_lesen()` against missing/corrupt `fahrzeugprofil.json`
-- [ ] Decide whether the 3 audit leftovers get fixed here or only in DataMetric360
+ 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)
diff --git a/UMSETZUNGSPLAN.md b/UMSETZUNGSPLAN.md
new file mode 100644
index 0000000..378afcc
--- /dev/null
+++ b/UMSETZUNGSPLAN.md
@@ -0,0 +1,600 @@
+# DataMetric360 — Umsetzungsplan
+
+**Stand: 2026-08-11.** Dieser Plan führt von heute (fertiges HA-Panel, fertige Datenschicht,
+halbfertiger Design-Entwurf) bis zur fertigen DataMetric360-App inklusive aller offenen Punkte aus
+`AGENTS.md`. Er ist bewusst kleinteilig geschrieben, damit jede Phase auch von einem schwächeren
+KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann.
+
+**Beschlossene Rahmenbedingungen (nicht neu diskutieren):**
+- Alle Daten liegen in Home Assistant. Es gibt **kein eigenes Backend** — die App spricht direkt
+ mit der HA-REST-/WebSocket-API und ruft die vorhandenen `pyscript.audi_dashboard_*`-Services auf.
+- **Kein Electron.** Fürs iPhone wird die Web-App mit **Capacitor** verpackt (nur Sideload, nie
+ App Store). Optionaler Zwischenschritt: als PWA auf den Homescreen (Phase 10).
+- Die App ersetzt später das HA-Panel; bis dahin bleibt das Panel unangetastet in Betrieb.
+
+---
+
+## Für das ausführende Modell: Arbeitsregeln
+
+1. **Lies zuerst:** `AGENTS.md` (Regeln + Stand), dann die Phase, die du bearbeitest — nichts
+ anderes. Hole dir Detailwissen erst, wenn ein Schritt es verlangt (die Schritte nennen die
+ Quelldatei).
+2. **Eine Phase pro Session.** Arbeite die Schritte in Reihenfolge ab. Jeder Schritt hat eine
+ Prüfung („✅ Fertig wenn"). Erfülle sie, bevor du weitergehst. Wenn eine Prüfung fehlschlägt,
+ behebe das, bevor du den nächsten Schritt beginnst.
+3. **Nach jeder abgeschlossenen Phase:** Haken in diesem Plan setzen, betroffene Checkboxen in
+ `AGENTS.md` abhaken, „Last updated" in `AGENTS.md` aktualisieren (englisch!), committen mit
+ deutscher Commit-Message.
+4. **Harte Verbote:**
+ - Audi-Assets (Schriften, Ringe, Typenschilder) niemals nach `design-system/` kopieren oder in
+ irgendetwas einbauen, das veröffentlicht/hochgeladen wird. Quelle für die App:
+ `homeassistant/www/` und `design/uploads/`.
+ - Keine Schriftgewichte ≥ 600, keine Schatten, keine Verläufe. Nur Gewichte 300/400.
+ - `homeassistant/` nicht umbauen (nur die explizit genannten Pflege-Schritte in Phase 2).
+ - Keine zusätzlichen npm-Pakete einführen, die der Plan nicht nennt, ohne es zu begründen und
+ in `AGENTS.md` zu dokumentieren.
+ - Home Assistant niemals direkt ins Internet stellen. Nur die in Phase 12 beschriebenen Wege.
+5. **Sprache:** Code-Bezeichner, Kommentare, UI-Texte, Commits auf Deutsch (Ausnahme: `AGENTS.md`
+ englisch; `design-system/` behält englische Props).
+6. **Zahlenformat** immer `de-DE` (1.234,5). Übernimm die Helfer `de()`/`eur()` aus dem alten
+ Panel (`homeassistant/www/audi-dashboard-app.js`), statt eigene zu erfinden.
+
+## Phasenübersicht und Abhängigkeiten
+
+| Phase | Inhalt | Hängt ab von | Braucht Hardware/Extern? |
+|---|---|---|---|
+| 1 | Arbeitsumgebung herstellen und verifizieren | — | nein |
+| 2 | Pflege HA-Panel (Doku-Drift, Härtung) | — | nein |
+| 3 | Design-Entwurf vervollständigen | — | Claude Design (Besitzer) |
+| 4 | Workspace + React-Gerüst in `companion-app/` | 1 | nein |
+| 5 | App-Shell: Theme, Router, responsives Layout | 4 | nein |
+| 6 | Onboarding + Datenanbindung + Offline-UX | 5 | nein |
+| 7 | Alle Screens umsetzen | 3, 6 | nein |
+| 8 | Audi-Assets einbauen | 7 | nein |
+| 9 | Tests (authentifiziert + Komponenten) | 6 | nein |
+| 10 | PWA + Capacitor/iOS-Sideload | 7 | erledigt bis auf das Signieren aufs Geraet |
+| 11 | HA-Einbettung, Ablösung + Archivierung des Panels | 7–10 | HA-Produktivinstanz |
+| 12 | Externer Zugriff: Cloudflare Tunnel + Reverse Proxy | teils unabhängig | Domain/DNS, Besitzer |
+| 13 | FMM003: MQTT, Zertifikate, Mapping, Fahrterkennung | Hardware | **ja: FMM003 verbaut** |
+
+Phasen 2, 3 und 12 (Teilschritte) sind unabhängig und können vorgezogen werden. Phase 13 ist die
+einzige, die zwingend auf Hardware wartet.
+
+---
+
+## Phase 1 — Arbeitsumgebung herstellen und verifizieren ✅ ERLEDIGT (2026-08-11)
+
+> Ergebnis: design-system baut und besteht 21/21, companion-app typecheckt und besteht 7/7.
+> Die verlorene Testinstanz wurde als reproduzierbares Skript neu aufgebaut — siehe
+> `testumgebung/` (Abweichung vom Plan, bewusst: das Original ging genau deshalb verloren,
+> weil es nur als Anleitung existierte).
+
+**Ziel:** Beide npm-Pakete bauen, beide Smoke-Tests laufen, die HA-Testinstanz ist erreichbar.
+
+1. Node-Version prüfen: `node --version` — muss ≥ 20 sein (companion-app nutzt
+ `--experimental-strip-types`, ab Node 22 stabil; wenn < 20: mit nvm/Volta Node 22 installieren).
+2. `cd design-system && npm install && npm run build`
+ ✅ Fertig wenn: `design-system/dist/index.js` und `dist/styles.css` existieren, Build ohne Fehler.
+3. `npm run smoke` (im selben Ordner)
+ ✅ Fertig wenn: 21/21 Fälle „ok" melden.
+4. `cd ../companion-app && npm install && npm run typecheck`
+ ✅ Fertig wenn: `tsc --noEmit` fehlerfrei durchläuft.
+5. HA-Testinstanz prüfen: `curl -s -o /dev/null -w '%{http_code}' http://localhost:18123/api/` —
+ erwartet: `401` (API da, Token fehlt — das ist richtig so). Wenn keine Antwort: Der
+ Docker-Container heißt `audi_ha_test` (`docker start audi_ha_test`). Existiert er nicht, siehe
+ `homeassistant/README.md` (Testbericht-Abschnitt) für den Aufbau; notfalls Phase-1-Schritt 6
+ überspringen und in Phase 9 nachholen.
+6. `npm run smoke` in `companion-app/`
+ ✅ Fertig wenn: 7/7 Prüfungen grün.
+
+**Abschluss Phase 1:** nichts committen (nur `node_modules`/`dist`, beides gitignored).
+
+---
+
+## Phase 2 — Pflege des bestehenden HA-Panels ✅ ERLEDIGT (2026-08-11)
+
+> Doku-Drift behoben, `profil_lesen()` gehärtet und an der Testinstanz belegt.
+> Offen bleibt nur, was Betriebsdaten sind: Fahrzeugfotos und `steuer.faellig`.
+
+**Ziel:** Doku stimmt wieder mit dem Code überein; ein Crash-Risiko ist beseitigt. Reine Pflege —
+**keine** Funktionsänderungen.
+
+1. **Statistik-Behauptung korrigieren** (3 Stellen, gleiche Falschaussage „zeigt Beispielzahlen"):
+ - `homeassistant/www/audi-dashboard-app.js` Kopfkommentar Zeilen ~13–15
+ - `homeassistant/README.md` Zeile ~99
+ - `homeassistant/INSTALL.md` Zeile ~204
+ Neue Aussage sinngemäß: „Die Statistik-Seite berechnet echte Werte aus Fahrten und
+ Tankvorgängen (Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg)."
+2. **INSTALL.md Schritt 4** (Zeilen ~106–109): falsche Variablennamen ersetzen.
+ Falsch: `DOORS_SENSOR`, `WINDOWS_SENSOR`, `LOCK_ENTITY`, `BATTERY_VOLTAGE_SENSOR`.
+ Richtig (siehe `homeassistant/pyscript/modules/einstellungen.py`): `TUER_SENSOREN`,
+ `FENSTER_SENSOREN`, `TUERSCHLOSS_SENSOREN` (je 4er-Listen) und `BATTERIE_SENSOR` (einzeln).
+3. **README.md:** Erwähnung der „97-%-Volltankungsregel" (~Zeile 110) streichen (Regel wurde
+ entfernt, siehe Kommentar `pyscript/belegverarbeitung.py:18-20`); in der Dateiübersicht die
+ fehlenden Skripte ergänzen: `tankerkennung.py`, `batterieverlauf.py`, `bilderverwaltung.py`,
+ `backup.py`, `updateverwaltung.py`.
+4. **Obsoleten Kommentar entfernen:** `pyscript/belegverarbeitung.py:41` — den Zusatz
+ „TODO: Datei ablegen" streichen (Datei existiert längst), Konstante selbst unverändert lassen.
+5. **`profil_lesen()` härten** (`pyscript/modules/profil.py`, ~Zeile 51–55): fehlende oder
+ nicht parsebare `fahrzeugprofil.json` abfangen. Verhalten: Fehler ins Log (`log.error` ist in
+ pyscript global verfügbar), Rückgabe `None`; Aufrufer prüfen. Vorher alle Aufrufer suchen
+ (`grep -rn "profil_lesen" homeassistant/pyscript/`) und sicherstellen, dass jeder mit `None`
+ umgehen kann — wo nicht, dort eine frühe Rückkehr einbauen. **pyscript-Eigenheit beachten:**
+ Datei-I/O nur über `task.executor(io.open, …)`-Muster wie im Bestand, kein nacktes `open()`.
+ ✅ Fertig wenn: `python3 -c "import ast; ast.parse(open('homeassistant/pyscript/modules/profil.py').read())"`
+ fehlerfrei ist und jeder Aufrufer den `None`-Fall behandelt.
+6. **Bewusst NICHT tun:** Swipe-Delete-Fallback, Popup-Tastaturzugang, Leaflet-Selbsthosting
+ (Audit-Reste) — Entscheidung laut Audit: kommt in DataMetric360, nicht ins alte Panel.
+ RAM-only-Zustände (Fahrtstart, Tank-Tiefststand) ebenfalls nicht anfassen — wird mit dem
+ FMM003-Umstieg hinfällig.
+7. Committen: „Doku an Codestand angleichen und profil_lesen gegen fehlende Datei härten".
+ `AGENTS.md`: die erledigten Punkte in Block C abhaken.
+
+Offen bleiben in Block C nur: Fahrzeugfotos hochladen + `steuer.faellig` setzen (macht der
+Besitzer in der App/UI, kein Code).
+
+---
+
+## Phase 3 — Design-Entwurf vervollständigen ⏭️ ÜBERSPRUNGEN (2026-08-11)
+
+> Entscheidung des Besitzers: Die Screens wurden direkt aus den Views des alten
+> Panels abgeleitet, statt auf den Claude-Design-Entwurf zu warten. Dieser
+> Abschnitt bleibt stehen, falls der Entwurf später auf den Stand der
+> Umsetzung gebracht werden soll.
+
+**Ziel:** Der Entwurf zeigt das richtige Fahrzeug und alle Seiten, die das alte Panel hat.
+Diese Phase läuft im Claude-Design-Projekt
+(https://claude.ai/design/p/c28a8d4d-ec4e-4178-9e49-ab5b90c02097) — der Besitzer stößt sie an;
+ein Agent kann den Auftragstext vorbereiten und den Export danach einpflegen.
+
+1. **Korrektur Modell:** überall „Audi RS 4 Avant competition" statt „RS 6 Avant"; Typenschild
+ `rs4` (negative/positive) statt `rs6`.
+2. **Fehlende Seiten ergänzen** (16 Stück; Vorlage ist jeweils die View im alten Panel —
+ Funktionsnamen aus `homeassistant/www/audi-dashboard-app.js`, Beschreibung in
+ `SPECIFICATION.md` §3 „Views"):
+ | # | Seite | Vorlage (alte View) |
+ |---|---|---|
+ | 1 | Fahrzeugstatus-Detail (12-Punkte Türen/Fenster/Schlösser) | `vSicherheit()` |
+ | 2 | Fahrzeugdaten/Technik/Ausstattung | `vIdent()` |
+ | 3 | Batterieverlauf (SVG-Kurve, SoC/SoH) | `vBatterieverlauf()` |
+ | 4 | Service (Termine, Terminanfrage, Servicebuch-Liste) | `vService()` |
+ | 5 | Werkstatt bearbeiten | `vWerkstatt()` |
+ | 6 | Servicebuch-Eintrag (ansehen/bearbeiten/neu) | `vSbuch(i)` |
+ | 7 | Versicherung & Steuer (Hub) | `vVers()` |
+ | 8 | Beitrag bearbeiten | `vBeitrag()` |
+ | 9 | Vertragsdetails | `vVertragsdetails()` |
+ | 10 | Schutzbrief (nur lesen) | `vSchutz()` |
+ | 11 | Notrufnummern bearbeiten | `vNotrufBearbeiten()` |
+ | 12 | Kfz-Steuer (Ansicht + Bearbeiten) | `vSteuer()`/`vSteuerBearbeiten()` |
+ | 13 | Reifen (Sätze, km-Zähler, Wechsel, Drehmoment) | `vReifen()` |
+ | 14 | Fahrt-Detail (Karte, Daten, Bearbeiten) | `vTrip(id)` |
+ | 15 | Tankvorgang-Detail (inkl. Beleg-Upload/-Ersetzen) | `vFill(id)` |
+ | 16 | Einstellungen in voller Tiefe (Fahrzeug-Setup, Fotos, SmartDeal, Pausenzeit, Export/Import, Backup, Version/Update) | `vEinst()` |
+ Für jede Seite beide Layoutvarianten (schmal mit Tab-Leiste unten, breit ≥1000px mit
+ Seitenleiste) — wie im Brief `DESIGN_BRIEF_DATAMETRIC360.md` gefordert.
+3. **Export einpflegen:** kompletten Ordner `design/` durch den neuen Export ersetzen
+ (`design/README.md` sagt selbst: „wird beim nächsten Export komplett ersetzt"), README-Abschnitt
+ „Stand dieses Exports" aktualisieren.
+ ✅ Fertig wenn: `grep -ci "rs 6\|rs6" design/DM360.dc.html` = 0 und alle 16 Seiten im Export
+ auffindbar sind.
+4. Committen; `AGENTS.md` Block A Punkt 1 abhaken.
+
+**Wichtig:** Phase 7 kann pro Screen schon vor Abschluss dieser Phase beginnen — die 8 vorhandenen
+Hauptscreens sind entworfen; die 16 Unterseiten folgen dann dem gleichen Muster.
+
+---
+
+## Phase 4 — Workspace und React-Gerüst ✅ ERLEDIGT (2026-08-11)
+
+**Ziel:** `companion-app/` ist ein lauffähiges Vite+React+TS-Projekt, das `@audi-dash/ui` als
+echte Dependency nutzt. Die bestehende Datenschicht (`src/api/`) bleibt unverändert liegen.
+
+1. **npm-Workspace-Root anlegen** — neue Datei `package.json` im Repo-Root:
+ ```json
+ {
+ "name": "audi-app",
+ "private": true,
+ "workspaces": ["design-system", "companion-app"]
+ }
+ ```
+ Dazu Repo-Root-`.gitignore` um `node_modules/` ergänzen (falls nicht abgedeckt).
+2. **Dependencies in `companion-app/package.json` ergänzen** (bestehende Felder beibehalten!):
+ `dependencies`: `react ^18`, `react-dom ^18`, `@audi-dash/ui` (Versionsangabe `*` — kommt über
+ den Workspace); `devDependencies` zusätzlich: `vite`, `@vitejs/plugin-react`,
+ `@types/react`, `@types/react-dom`. Scripts ergänzen: `"dev": "vite"`,
+ `"build": "vite build"`, `"preview": "vite preview"`.
+3. `npm install` **im Repo-Root** ausführen (verlinkt design-system in die companion-app).
+ Danach einmal `npm run build --workspace design-system` (die App importiert aus `dist/`).
+4. **Vite-Konfiguration** `companion-app/vite.config.ts`:
+ ```ts
+ import { defineConfig } from "vite"
+ import react from "@vitejs/plugin-react"
+ export default defineConfig({
+ plugins: [react()],
+ server: { port: 5173 },
+ build: { outDir: "dist", target: "es2022" },
+ })
+ ```
+5. **tsconfig anpassen:** Das bestehende `companion-app/tsconfig.json` ist auf die reine
+ Datenschicht zugeschnitten (`noEmit`, sehr streng — so lassen). Ergänzen: `"jsx": "react-jsx"`,
+ `"lib": ["ES2022", "DOM", "DOM.Iterable"]` und `"moduleResolution": "bundler"`, falls nicht
+ gesetzt. Die strengen Flags (`exactOptionalPropertyTypes` usw.) NICHT abschwächen.
+6. **Einstieg anlegen:**
+ - `companion-app/index.html` — Minimal-HTML mit `
+ }
+ ```
+ **Pflicht:** Das äußerste Element trägt immer `className="ads-root"` und `data-theme` — ohne
+ diese Wurzelklasse greifen die Design-Tokens nicht (Regel aus
+ `design-system/.design-sync/conventions.md`).
+7. ✅ Fertig wenn: `npm run dev --workspace companion-app` startet, http://localhost:5173 zeigt
+ dunklen Hintergrund (`#161b23`) mit Text; `npm run typecheck --workspace companion-app` und
+ `npm run build --workspace companion-app` laufen fehlerfrei.
+8. Committen: „companion-app: Vite+React-Gerüst und Workspace-Anbindung an @audi-dash/ui".
+ `AGENTS.md` Block A Punkt 2 abhaken, Statustabelle aktualisieren.
+
+---
+
+## Phase 5 — App-Shell: Theme, Navigation, responsives Layout ✅ ERLEDIGT (2026-08-11)
+
+**Ziel:** Navigationsgerüst, das beide Layouts beherrscht (schmal: Tab-Leiste unten; breit ≥1000px:
+Seitenleiste), mit Tag/Nacht-Theme und Zurück-Navigation. Noch ohne echte Daten.
+
+**Vorbild für Verhalten:** das alte Panel (`audi-dashboard-app.js`) — flacher Routenzustand,
+`ZURUECK`-Tabelle. Vorbild fürs Aussehen: `design/DM360.dc.html` (dort existiert der
+`wide`-Umschalter mit 240px-Seitenleiste bereits).
+
+1. **Kein Router-Paket installieren.** Eigener flacher Zustand wie im alten Panel:
+ `src/navigation.ts` mit `type Route = { name: RouteName; id?: string }`, React-State im
+ App-Root, `geheZu(name, id?)`, plus `ZURUECK`-Tabelle (aus dem alten Panel übernehmen und um
+ die neuen Routen ergänzen; 3-stufige Verschachtelung `schutz → vertragsdetails → vers` beachten).
+2. **Theme:** `src/theme.ts` — Zustand `"nacht" | "tag"`, Persistenz in `localStorage`
+ (Schlüssel `dm360.theme`), Initialwert: gespeicherter Wert, sonst `prefers-color-scheme`,
+ Standard `nacht`. Gesetzt wird ausschließlich `data-theme` auf dem `ads-root`-Element.
+ `localStorage`-Zugriffe in try/catch (Muster aus dem alten Panel, Private-Mode-Browser).
+3. **Layout:** `src/Shell.tsx` — CSS-Grid/Flex mit Breakpoint bei 1000px
+ (`window.matchMedia("(min-width: 1000px)")` + Listener):
+ - schmal: Inhalt + `TabBar`-Komponente (aus `@audi-dash/ui`) unten, `env(safe-area-inset-*)`
+ als Padding (iPhone-Notch).
+ - breit: 240px-Seitenleiste links (Navigationspunkte + Ringe-Platzhalter oben), Inhalt rechts;
+ Listen-Detail nebeneinander wo der Entwurf es vorsieht.
+ Die 5 Hauptbereiche: Übersicht, Mein Audi, Fahrten, Statistik, Tanken. Einstellungen über
+ Zahnrad oben rechts (nicht in der Tab-Leiste) — wie im alten Panel und im Entwurf.
+4. **Platzhalter-Screens:** je Hauptbereich eine Datei unter `src/screens/` (z. B.
+ `Uebersicht.tsx`), die vorerst nur Titel per `ads-eyebrow`/`Tile` zeigt.
+5. ✅ Fertig wenn: Im Browser (a) bei < 1000px die Tab-Leiste unten erscheint und alle 5 Bereiche
+ wechselbar sind, (b) bei ≥ 1000px stattdessen die Seitenleiste erscheint, (c) der Theme-Wechsel
+ sichtbar Tag/Nacht umschaltet und einen Reload überlebt.
+6. Committen: „companion-app: App-Shell mit responsivem Layout, Navigation und Theme".
+
+---
+
+## Phase 6 — Onboarding, Datenanbindung, Offline-UX ✅ ERLEDIGT (2026-08-11)
+
+**Ziel:** Die App verbindet sich echt mit Home Assistant über die vorhandene Datenschicht.
+
+**Die Datenschicht ist fertig — nur benutzen, nicht neu bauen.** Einstieg:
+`companion-app/src/api/index.ts` (`DataMetricApi`). Sie liefert: REST-Reads
+(`profilLesen`, `fahrtenLesen`, `tankvorgaengeLesen`, `fahrzeugstatusLesen`), Live-Updates
+(`HassLive` mit Reconnect), Schreibzugriffe über die Offline-Queue (`profilSchreiben`,
+`belegHochladen`, `fahrtLoeschen`, `tankvorgangLoeschen`, `jetztAktualisieren`) und
+Zugangsdaten-Verwaltung (`umgebung.ts`).
+
+1. **Ersteinrichtungs-Screen** (`src/screens/Einrichtung.tsx`) nach dem Entwurf in
+ `design/DM360.dc.html` (dort der `isSetup`-Block): Felder Server-Adresse + Zugangs-Token,
+ Knopf „Verbinden" ruft `verbindungPruefen()` aus der Datenschicht; Fehler unterscheiden nach
+ `ApiFehler.istAnmeldeproblem` (Token falsch) vs. `istNetzproblem` (Adresse/Netz) und in
+ verständlichem Deutsch anzeigen. QR-Knopf: vorerst ausblenden oder deaktiviert mit Hinweis
+ (kommt mit Capacitor, Phase 10). Bei Erfolg Zugangsdaten über die `Ablage` der Datenschicht
+ speichern und in die App wechseln.
+2. **Datenkontext:** `src/datenkontext.tsx` — ein React-Context, der beim Start `DataMetricApi`
+ instanziiert, initial alle vier Reads lädt, sich bei `aufZustand()` für Live-Updates
+ registriert und `{profil, fahrten, tankvorgaenge, status, verbindung}` bereitstellt.
+ **Übernahme-Pflicht aus dem alten Panel:** die Umrechnungen `profilZuConfig()`/`profilZuCar()`
+ (inkl. SmartDeal-Gültigkeit: aktiv wenn `laeuft_ab` fehlt oder ≥ heute, Ablaufdatum
+ **einschließlich**) aus `audi-dashboard-app.js` nach TypeScript portieren — Logik exakt
+ beibehalten.
+3. **Offline-UX** (Entwurf: Offline-Marker in der Kopfzeile):
+ - Verbindungszustand aus `aufVerbindung()` der Datenschicht → dezenter „Offline"-Hinweis im
+ Kopf, Daten bleiben sichtbar.
+ - Warteschlange aus `aufAenderung()` → sichtbare Zahl wartender Änderungen („2 Änderungen
+ warten auf Übertragung"), wie im Design-Brief gefordert.
+4. **Stale-Anzeige:** `last_updated` des Fahrzeugstatus mitführen (Muster: Staleness-Indikator des
+ alten Panels).
+5. ✅ Fertig wenn: Gegen `audi_ha_test` (Token siehe Phase 9 Schritt 1): Einrichtung mit falschem
+ Token zeigt Anmeldefehler, mit richtigem Token lädt die App Profil + Status; HA-Container
+ stoppen → Offline-Marker erscheint; Container starten → verschwindet wieder (Reconnect testet
+ die Backoff-Logik der Datenschicht).
+6. Committen; `AGENTS.md` Block A Punkte Onboarding/Offline teilweise abhaken (QR bleibt offen).
+
+---
+
+## Phase 7 — Alle Screens umsetzen ✅ ERLEDIGT (2026-08-11)
+
+> Alle 21 Seiten stehen. Die Entwurfsvorlage kam für die 16 Unterseiten aus den
+> Views des alten Panels statt aus Claude Design (Entscheidung des Besitzers,
+> 2026-08-11) — Phase 3 ist damit gegenstandslos, solange der Entwurf nicht
+> nachgezogen werden soll.
+
+**Ziel:** Funktionsgleichheit mit dem alten Panel plus die neuen Live-Ansichten. Pro Screen:
+Entwurf aus `design/` nachbauen (mit `@audi-dash/ui`-Komponenten), Logik aus der alten View
+portieren, an den Datenkontext anschließen.
+
+**Reihenfolge und Zuordnung** (Logik-Vorlage = Funktion in `audi-dashboard-app.js`;
+Datenquelle = Feld im Datenkontext):
+
+| Screen | Logik-Vorlage | Datenquelle / Services | Hinweise |
+|---|---|---|---|
+| Übersicht | `vHome()` | status, profil, fahrten, tankvorgaenge | Foto, Status „sicher abgestellt", Reichweite, km, nächster Service, Teaser letzte Fahrt/Tankung; Pull-to-Refresh → `jetztAktualisieren()` |
+| Fahrten-Liste | `vTrips()` | fahrten | Jahr→Monat-Akkordeon (`Accordion`); Lösch-Aktion: **nicht nur Swipe** — zusätzlich „…"-Menü pro Zeile (behebt Audit-Finding barrierefreies Löschen); manuelles Anlegen (`vTripsFormular()`) über Service `audi_dashboard_fahrt_manuell_anlegen` |
+| Fahrt-Detail | `vTrip(id)` | fahrten | Karte: Leaflet **lokal bündeln** (`npm i leaflet`, Audit-Finding CDN), Tile-Layer nach Theme; **kein `fakeTrack()` portieren** — ohne echte Route nur Start/Ende-Marker zeigen, mit ehrlichem Leerhinweis |
+| Tanken-Liste | `vFuel()` | tankvorgaenge | volumengewichteter Durchschnittspreis; Lösch-„…"-Menü wie bei Fahrten |
+| Tankvorgang-Detail | `vFill(id)` | tankvorgaenge | Beleg-Upload: Datei → Base64 → `belegHochladen()`; Ergebnis kommt asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis` (Muster im alten Panel: `belegErgebnisVerarbeiten()`) |
+| Statistik | `vStat()` | fahrten, tankvorgaenge | Rechenfunktionen (`fahrtenSeit`, `tankSeit`, `verbrauch`, `istNachtZeit`) 1:1 portieren |
+| Mein Audi (Hub) | `vAudi()` | profil | Fotogalerie + Kachel-Links auf die Unterseiten |
+| Fahrzeugstatus-Detail | `vSicherheit()` | status | 16-Punkte-Liste; `ok === null` heißt „unbekannt", nie grün raten |
+| Fahrzeugdaten | `vIdent()` | profil (technik, ausstattung) | statisch aus dem Profil |
+| Batterieverlauf | `vBatterieverlauf()` | eigener Read der Entität `pyscript.audi_dashboard_batterieverlauf` | SVG-Chart portieren (Pinch/Pan/Tap); Leerzustand prominent — Sensor ist derzeit unbesetzt |
+| Service + Werkstatt + Servicebuch | `vService()`, `vWerkstatt()`, `vSbuch()` | profil.service | Öl-/Service-Prognose (`oelwechselPrognose()`, `intervalle()`, `termine()`) exakt portieren; ICS-Export (`ics()`-Helfer) übernehmen |
+| Versicherung/Steuer-Gruppe | `vVers()`, `vBeitrag()`, `vVertragsdetails()`, `vSchutz()`, `vNotrufBearbeiten()`, `vSteuer()` | profil.versicherung, profil.steuer | reine Formular-/Anzeige-Seiten; Schreiben immer als ganzes Profil über `profilSchreiben()` (es gibt keinen Feld-einzeln-Service!) |
+| Reifen | `vReifen()` | profil.reifen + Entitäten `pyscript.reifen_*` | Wechsel über Service `audi_dashboard_reifen_wechseln` (nur `"sommer"`/`"winter"`); Wechseldatum → ICS |
+| Einstellungen | `vEinst()` | profil, alle | volle Tiefe: Fahrzeug-Setup, Theme, Fotos (Upload über `audi_dashboard_bild_hochladen`, nur die 10 erlaubten Dateinamen!), SmartDeal, Pausenzeit, Profil-Export/Import, Backup, CSV-Export, Version |
+| **Neu: Live-Fahrt** | — (neu) | status (künftig FMM003-Felder) | Entwurf `screen === 'live'`: Karte, Geschwindigkeit groß, Strecke/Dauer; bis Phase 13 hinter einem Feature-Schalter `LIVE_VERFUEGBAR = false` in einer zentralen Konfigurationsdatei — Screen bauen, Schalter erst mit echten Daten umlegen |
+| Leerzustände | — | — | für Fahrten/Tanken/Statistik je einen freundlichen „noch keine Daten"-Zustand (Design-Brief), wird real gebraucht: beide JSONL sind aktuell leer |
+
+**Regeln für alle Screens:**
+- Nur `@audi-dash/ui`-Komponenten + Tokens (`var(--…)`), keine neuen Hex-Farben, keine neuen
+ Komponenten ohne sie zuerst in `design-system/` anzulegen (dann dort mit CSS+Preview, danach
+ hier verwenden).
+- Destruktive Aktionen immer mit Bestätigung; Fehler sichtbar in rot inline (Muster altes Panel).
+- Jede Zahl durch `de()`/`eur()`.
+- Nach jedem Screen: `npm run typecheck` + Sichtprüfung schmal UND breit.
+
+✅ Fertig wenn: jeder Screen der Tabelle existiert, mit Testdaten befüllbar ist und in beiden
+Layouts bedienbar aussieht wie der Entwurf. Committen pro Screen-Gruppe (z. B. „Fahrten-Screens"),
+nicht ein Riesencommit.
+
+---
+
+## Phase 8 — Audi-Assets einbauen ✅ ERLEDIGT (2026-08-11)
+
+**Ziel:** Die App sieht aus wie das Original — Schriften, Ringe, Typenschilder.
+
+1. Quellen: Schriften aus `design/uploads/*.ttf` (oder die woff2-Base64-Blöcke aus
+ `homeassistant/www/audi-dashboard.css`), Ringe `design/assets/audi-rings-{white,black}.svg`,
+ Typenschilder aus `homeassistant/www/badges/` (alle 14).
+2. Ablage: `companion-app/src/assets/audi/` + `@font-face`-Deklarationen in einer eigenen
+ `src/audi-schrift.css` (Familien exakt wie im Bestand: `"Audi Type"` 400, `"Audi Type Wide"`
+ 300/400, `"Audi Type Extended"` italic). Der `--font-stack` wird per CSS-Variable auf dem
+ `ads-root` überschrieben — `design-system/`-Dateien bleiben unberührt.
+3. Typenschild-Auswahl nach Modell aus dem Profil (Logik aus `vEinst()`/Badge-Mapping des alten
+ Panels übernehmen; Theme-abhängig positive/negative-Variante).
+4. **Kontrolle:** `git log --stat` des Commits zeigt KEINE Änderung unter `design-system/`;
+ Gewichte ≥ 600 kommen nirgends vor (`grep -rn "font-weight" companion-app/src | grep -vE "300|400"`
+ liefert nichts).
+5. Committen; `AGENTS.md` Block A Assets-Punkt abhaken. Hinweis im Commit: Repo bleibt privat
+ (Lizenz).
+
+---
+
+## Phase 9 — Tests ✅ ERLEDIGT (2026-08-11)
+
+> 90 Unit- und Rendertests, dazu 9 Prüfungen gegen die laufende Instanz.
+
+**Ziel:** Die ungeteste Hälfte der Datenschicht ist abgedeckt; die Kern-Rechenlogik hat Unit-Tests.
+
+1. **Token für die Testinstanz erzeugen:** `audi_ha_test` im Browser öffnen
+ (http://localhost:18123), anmelden, Profil → Sicherheit → langlebiges Zugriffstoken erstellen.
+ Token NUR in `companion-app/.env.local` ablegen (`VITE_TEST_TOKEN=…`), Datei in `.gitignore`
+ aufnehmen. **Niemals committen.**
+2. **Authentifizierter Smoke** (`scripts/smoke-auth.ts`, Aufbau wie `scripts/smoke.ts`): mit Token
+ (a) `zustandLesen("pyscript.audi_dashboard_profil")` liefert Daten, (b) `dienstAufrufen` mit
+ einem harmlosen Service (`audi_dashboard_jetzt_aktualisieren`) gibt 200, (c) Queue-Round-Trip:
+ Eintrag einstellen bei gestopptem Container, Container starten, Queue leert sich. Script in
+ `package.json` als `smoke:auth`; ohne gesetztes Token bricht es mit klarer Meldung ab
+ (Exit-Code 0, „übersprungen").
+3. **Unit-Tests** mit `vitest` (einzige neue Dev-Dependency): portierte Rechenlogik testen —
+ Statistik (`verbrauch`, `istNachtZeit`, Zeiträume), Service-Prognose (`oelwechselPrognose`:
+ Neustart nach Ölwechsel, 180-Tage-Gewichtung, Kappung), SmartDeal-Gültigkeit (Ablauftag
+ einschließlich!), `de()`/`eur()`-Formatierung. Testdaten als Fixtures aus realistischen
+ JSONL-Zeilen (Schema: `SPECIFICATION.md` §5).
+4. ✅ Fertig wenn: `npm run smoke:auth` 3/3 grün (mit Token), `npx vitest run` grün, und beide in
+ `companion-app/README.md` dokumentiert sind.
+5. Committen; `AGENTS.md` Block A Test-Punkt abhaken.
+
+---
+
+## Phase 10 — PWA und Capacitor ✅ ERLEDIGT (2026-08-11)
+
+> PWA fertig. Native Hülle gebaut und in Betrieb: Capacitor 8 für iOS und
+> Android, Build im Simulator (iPhone 17 Pro, iOS 26.4) erfolgreich, App zeigt
+> echte Daten. `CapacitorHttp` umgeht die CORS-Beschränkung der WebView.
+> Offen bleibt allein das Signieren aufs eigene Gerät — das braucht das
+> angeschlossene iPhone und die Apple-ID des Besitzers.
+
+**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
+(Keychain + QR-Scan).
+
+**Stufe 1 — PWA (kein neues Werkzeug):**
+1. `companion-app/public/manifest.webmanifest` (Name „DataMetric360", `display: "standalone"`,
+ Themenfarbe `#161b23`, neutrale Icons 192/512px — NICHT die Audi-Ringe als App-Icon, der
+ Homescreen ist „außen"; neutrales DM360-Monogramm erzeugen) + Verweis im `index.html`.
+2. Erreichbarkeit fürs Handy: solange Phase 12 nicht fertig ist, über Tailscale
+ (`npm run build`, Ausgabe z. B. über HA `/local/` oder `vite preview --host` im LAN testen).
+3. ✅ Fertig wenn: „Zum Home-Bildschirm" auf dem iPhone die App randlos startet und Login +
+ Datenanzeige funktionieren.
+
+**Stufe 2 — Capacitor:**
+4. `npm i -D @capacitor/cli && npm i @capacitor/core @capacitor/ios @capacitor/android`
+ (im Workspace für companion-app), `npx cap init DataMetric360 app.datametric360 --web-dir dist`,
+ `npx cap add ios`, `npx cap add android`.
+5. **Sichere Token-Ablage:** Capacitor-Secure-Storage-Plugin (z. B.
+ `@aparajita/capacitor-secure-storage`) als neue `Ablage`-Implementierung; über den vorhandenen
+ Hook `ablageSetzen()` der Datenschicht einhängen, wenn `umgebungErkennen()` „capacitor" meldet.
+ Browser-Fall bleibt unverändert `BrowserAblage`.
+6. **QR-Scan:** Barcode-Plugin (`@capacitor-mlkit/barcode-scanning`) + Kamera-Berechtigung;
+ Gegenstück: kleine statische Seite unter HA `/local/dm360-qr.html`, die den Token **clientseitig**
+ (Offline-JS-QR-Bibliothek, keine Netzabfrage) als QR anzeigt. Inhalt des QR: JSON
+ `{"url": "...", "token": "..."}`. Wenn das zusammen > 1 Tag Aufwand wird: weglassen —
+ manuelles Einfügen ist die beschlossene, ausreichende Lösung.
+7. **iOS-Sideload:** `npx cap open ios`, in Xcode Signing mit eigener Apple-ID, aufs Gerät bauen.
+ ⚠️ Entscheidungspunkt für den Besitzer: mit kostenlosem Apple-Konto läuft die Signatur nach
+ **7 Tagen** ab (App neu aufspielen); ein bezahltes Entwicklerkonto (99 €/Jahr) macht 1 Jahr.
+ Bei 7-Tage-Schmerz ist die PWA-Stufe die Alltagslösung, Capacitor das Extra für Keychain/QR.
+8. ✅ Fertig wenn: App startet nativ auf dem iPhone, Token liegt im Keychain (Test: App löschen und
+ neu installieren → Token weg; Backup/Restore-Verhalten notieren), QR-Einrichtung funktioniert
+ oder ist dokumentiert entfallen.
+9. Committen; `AGENTS.md` Block A Punkte QR/Secure-Storage abhaken.
+
+---
+
+## Phase 11 — HA-Einbettung und Ablösung des alten Panels
+
+**Ziel:** Die neue App läuft im HA-Seitenmenü; das alte Panel ist archiviert.
+
+1. **Build in HA ausliefern:** `npm run build`, Ausgabe nach `/config/www/dm360/` der
+ Produktivinstanz (Weg analog `update.ps1`; das Script um diesen Ordner erweitern).
+2. `configuration.yaml`: `panel_iframe`-Eintrag (Titel „Mein Audi", `mdi:car-sports`,
+ URL `/local/dm360/index.html`). Der `panel_custom`-Block des alten Panels bleibt zunächst
+ parallel bestehen (zweiter Menüpunkt „Mein Audi (alt)").
+3. **Parallelbetrieb mindestens 2 Wochen:** beide Panels zeigen dieselben Daten (gleiche
+ Entitäten/Services — Abweichungen sind Portierungsfehler; prüfen: Übersichtszahlen, eine
+ Fahrt anlegen/löschen, ein Beleg-Upload).
+4. **Ablösung:** alten `panel_custom`-Block entfernen; Dateien NICHT löschen, sondern
+ verschieben: `homeassistant/www/audi-dashboard-app.js` → `homeassistant/archiv/` (plus
+ Panel-Stub und CSS), README-Hinweis dort ablegen. Beschlossene Regel: **archivieren, nie
+ löschen** (`COMPANION_APP_ARCHITECTURE.md` §1).
+5. ✅ Fertig wenn: Neues Panel im HA-Menü, altes entfernt aber archiviert, `SPECIFICATION.md`
+ bekommt eine Kopfnotiz „beschreibt das archivierte Panel; aktuell ist companion-app/".
+6. Committen; `AGENTS.md` Block D abhaken, Statustabelle umstellen.
+
+---
+
+## Phase 12 — Externer Zugriff: Cloudflare Tunnel + Reverse Proxy 🟡 VORBEREITET
+
+> Fertige Freigabeliste samt Prüfbefehlen: `homeassistant/REVERSE_PROXY.md`.
+> Ausführung braucht Cloudflare-Konto und die Nameserver-Umstellung.
+
+**Ziel:** Die App funktioniert von unterwegs (Mobilfunk), ohne dass HA selbst erreichbar ist.
+Referenz: `COMPANION_APP_ARCHITECTURE.md` §4/§5. Teilweise Besitzer-Aufgaben (Konten/DNS).
+
+1. **[Besitzer] Nameserver umstellen:** `datametric360.app` bei all-inkl auf die
+ Cloudflare-Nameserver zeigen lassen (Cloudflare-Konto → Site hinzufügen → „Full setup";
+ Domain trägt sonst nichts, Umstellung ist folgenlos). ✅ wenn `dig NS datametric360.app` die
+ Cloudflare-Server liefert.
+2. **Hostnamen festlegen** (Empfehlung aus dem Architekturdokument übernehmen): App unter
+ `https://datametric360.app`, API unter `https://api.datametric360.app` — getrennter
+ API-Hostname hält die Allowlist übersichtlich. Entscheidung in `AGENTS.md` protokollieren.
+3. **Cloudflared-Add-on** in HA installieren, Tunnel erstellen, zwei öffentliche Hostnamen:
+ `datametric360.app` → Reverse-Proxy-Port (statische App-Dateien + nichts weiter),
+ `api.datametric360.app` → Reverse-Proxy-Port (API-Pfade). Kein Router-Port wird geöffnet.
+4. **Reverse Proxy:** Nginx Proxy Manager-Add-on (Standard-Empfehlung; Traefik nur, falls NPM
+ an Grenzen stößt — Entscheidung dokumentieren). Allowlist ausschließlich:
+ - `GET /api/states/pyscript.audi_dashboard_*` und `GET /api/states/pyscript.reifen_*`
+ - `POST /api/services/pyscript/audi_dashboard_*`
+ - `GET /api/` (Verbindungsprüfung) und der WebSocket-Pfad `/api/websocket`
+ Alles andere (v. a. `/auth`, `/lovelace`, `/config`, `/api/config`) wird geblockt.
+ ⚠️ Ehrliche Einschränkung dokumentieren: `/api/websocket` lässt sich nicht pfadgenau
+ beschneiden — nach `auth_ok` sind darüber alle States lesbar. Schutzschicht bleibt der LLAT
+ (ohne Token keine Verbindung); das ist die im Architekturdokument akzeptierte Abwägung.
+5. **Prüfen von außen** (Mobilfunk, VPN aus): App lädt, Login klappt, `curl` auf einen
+ Nicht-Allowlist-Pfad (z. B. `/auth/authorize`) liefert 403/404; HA-Port 8123 ist von außen
+ nicht erreichbar.
+6. ✅ Fertig wenn: Schritt 5 vollständig besteht. Die endgültige Allowlist wortwörtlich in
+ `COMPANION_APP_ARCHITECTURE.md` §5.1 eintragen (dort als offener Punkt vorgemerkt) und in
+ `AGENTS.md` Block B abhaken.
+
+Schritte 2–4 können gegen die bestehende HA-API **vor** der FMM003-Hardware erledigt werden.
+
+---
+
+## Phase 13 — FMM003-Inbetriebnahme ✅ ÜBERHOLT — Hardware lief bereits über flespi
+
+> **Diese Phase ist gegenstandslos.** Die FMM003-Hardware ist seit 2026-08-13 in Betrieb, aber über
+> den in `AGENTS.md` (§ FMM003/Datenpfad) beschriebenen Weg: **flespi (natives Codec8/TCP, IMEI-
+> Auth), nicht MQTT/Mosquitto.** Die Schritte unten (eigene CA, Mosquitto-Add-on, Zertifikate,
+> `mosquitto_sub`-Mitschnitt) beschreiben die zuerst geplante, dann verworfene Variante — stehen
+> nur noch als Entscheidungsprotokoll. `homeassistant/FMM003_MAPPING.md` (unten referenziert)
+> wurde beim Merge 2026-08-13 deshalb bewusst nicht übernommen. Maßgeblich ist ausschließlich
+> `AGENTS.md`.
+
+**Ziel (nicht mehr aktuell):** Live-Daten vom Fahrzeug fließen nach HA; Fahrterkennung läuft über
+die Zündung. Referenz: `COMPANION_APP_ARCHITECTURE.md` §2b. **Nichts hiervon vorab raten oder
+simulieren.**
+
+1. **Mosquitto-Add-on** in HA installieren, Benutzer für das Gerät anlegen.
+2. **Zertifikate:** kleine eigene CA (drei `openssl`-Befehle: CA-Schlüssel+Zertifikat,
+ Server-Zertifikat für den Broker, Client-Zertifikat fürs Gerät); Broker auf 8883/TLS;
+ CA-/Client-Dateien im Teltonika Configurator unter *Security* hinterlegen. Ohne Zertifikate
+ verweigert der FMM003 MQTT.
+3. **[Besitzer] Broker-Erreichbarkeit entscheiden:** Portfreigabe 8883 (einfach; TLS+Login
+ davor) **oder** VPS-Broker mit Mosquitto-Bridge über Tailscale (keine Freigabe, mehr Aufwand).
+ Entscheidung + Begründung in `COMPANION_APP_ARCHITECTURE.md` §5.2a und `AGENTS.md` nachtragen.
+4. **Gerät konfigurieren:** Firmware-Version notieren (Codec JSON ist firmwareabhängig!);
+ *System → Data Protocol → Codec JSON*; *GPRS → MQTT* mit Broker-IP/Port/Login.
+5. **Erste echte Nachricht mitschneiden:** `mosquitto_sub -h -p 8883 --cafile ca.crt
+ -u -P -t '#' -v > mitschnitt.txt` — mit Zündung an/aus, kurzer Fahrt.
+ Aus dem Mitschnitt die **Feldzuordnungstabelle** bauen (JSON-Feld → Bedeutung → Einheit) und
+ als `homeassistant/FMM003_MAPPING.md` einchecken (ohne echte Positionsdaten im Beispiel!).
+6. **HA-MQTT-Integration:** aus der Tabelle `mqtt:`-Sensoren/`device_tracker` in der
+ HA-Konfiguration definieren (Zündung, Geschwindigkeit, Drehzahl, Tankfüllstand, Position, km).
+7. **Fahrterkennung umstellen:** neues pyscript `fahrterkennung_fmm003.py` nach dem Muster von
+ `fahrterkennung.py` — Trigger ist die Zündungs-Entität (1→0 mit Pausentoleranz via
+ `task.unique()`/`task.sleep()`, Ende-Zeitpunkt auf den echten Zündung-aus-Moment rückdatieren;
+ `fahrten_pausenzeit_min` aus dem Profil weiterverwenden). Erst parallel laufen lassen
+ (schreibt in eine Test-Datei), vergleichen, dann scharf schalten und
+ `sensor.iphone_wifi_connection` aus `einstellungen.py` entfernen.
+8. **Live-Screen aktivieren:** Feature-Schalter aus Phase 7 umlegen, Live-Felder anbinden;
+ GPS-Route der laufenden Fahrt aufzeichnen und im Fahrt-Detail echte Tracks statt der
+ Start/Ende-Marker zeigen (damit stirbt der letzte Rest von `fakeTrack()` endgültig).
+9. ✅ Fertig wenn: eine echte Fahrt automatisch erkannt, mit km und Route gespeichert und in der
+ App (Liste, Detail mit echter Route, Statistik) korrekt angezeigt wird; VAG-Integration und
+ WLAN-Trigger sind entfernt; `AGENTS.md` Block B komplett abgehakt.
+
+---
+
+## Anhang A — Nachschlagereferenz für das ausführende Modell
+
+**Entitäten (lesen):** `pyscript.audi_dashboard_profil`, `…_fahrten`, `…_tankvorgaenge`,
+`…_fahrzeugstatus`, `…_batterieverlauf`, `…_beleg_ergebnis`, `…_update_status`,
+`pyscript.reifen_sommer_km`, `…_winter_km`, `…_aktiver_satz`. Nutzdaten stets im Attribut `daten`.
+
+**Services (schreiben, Domain `pyscript`):** `audi_dashboard_profil_schreiben`,
+`…_beleg_hochladen`, `…_tankvorgang_manuell`, `…_tankvorgang_aktualisieren`,
+`…_tankvorgang_loeschen`, `…_fahrt_manuell_anlegen`, `…_fahrt_loeschen`, `…_bild_hochladen`,
+`…_bild_loeschen`, `…_reifen_wechseln`, `…_backup_jetzt`, `…_backup_wiederherstellen`,
+`…_jetzt_aktualisieren`, `…_screening_jetzt`, `…_update_pruefen`, `…_update_installieren`.
+Signaturen: `SPECIFICATION.md` §5. **Service-Aufrufe geben keine Werte zurück** — Ergebnisse
+kommen asynchron über Entitäten (Muster: Beleg-Upload).
+
+**Stolperfallen:**
+- Profil wird immer **als Ganzes** geschrieben — nie Teil-Updates erfinden.
+- `edited_fields` eines Datensatzes schützt Felder vor automatischen Überschreibungen — beim
+ Portieren von Bearbeiten-Formularen beibehalten.
+- Sensorwerte `"unknown"`/`"unavailable"` = `None` behandeln, nie raten (Muster
+ `zustand_oder_none()`).
+- pyscript: kein nacktes `open()`/`with` — `task.executor`-Muster aus `modules/profil.py`.
+- HA-Attribute haben ~16 KB-Grenze (`frontend_veroeffentlichung.py`) — keine großen Blobs in
+ Entitäten stopfen.
+- `fahrten.jsonl`/`tankvorgaenge.jsonl` sind aktuell **leer** — Leerzustände sind der erste
+ Eindruck der App, nicht ein Randfall.
+
+## Anhang B — Definition of Done (gesamt)
+
+Die App gilt als fertig, wenn: alle Screens aus Phase 7 in beiden Layouts funktionieren; Onboarding,
+Offline-Queue und Beleg-Upload gegen die Produktiv-HA laufen; die App als PWA und (falls Capacitor
+umgesetzt) nativ auf dem iPhone installiert ist; der externe Zugriff über `datametric360.app`
+funktioniert, ohne dass HA exponiert ist; das alte Panel archiviert ist; und `AGENTS.md` den
+Endstand widerspiegelt (alle Blöcke A–D abgehakt oder begründet gestrichen).
diff --git a/companion-app/.gitignore b/companion-app/.gitignore
index b518b92..f879a22 100644
--- a/companion-app/.gitignore
+++ b/companion-app/.gitignore
@@ -2,3 +2,9 @@ node_modules/
dist/
*.local
.DS_Store
+
+# Native Huellen: von `npx cap add` erzeugt, aus dem Webbuendel jederzeit
+# wiederherstellbar. Die Projektdateien selbst (capacitor.config.ts) sind
+# getrackt.
+ios/
+android/
diff --git a/companion-app/README.md b/companion-app/README.md
index 0cb91d5..ae33d4f 100644
--- a/companion-app/README.md
+++ b/companion-app/README.md
@@ -1,81 +1,76 @@
-# DataMetric360 — companion app
+# companion-app — DataMetric360
-Private vehicle app for one car. Ships three ways from one codebase: native iOS and Android via
-Capacitor, and embedded as a plain iframe in the Home Assistant dashboard. Architecture and the
-decisions behind it: [`../COMPANION_APP_ARCHITECTURE.md`](../COMPANION_APP_ARCHITECTURE.md).
+Private Fahrzeug-App für einen Audi RS 4 Avant competition. Läuft als Web-App
+im Browser, als PWA auf dem Homescreen, später über Capacitor nativ auf
+iOS/Android und eingebettet als Iframe im Home-Assistant-Dashboard.
-## Status
+**Kein eigenes Backend.** Die App spricht direkt mit der REST- und
+WebSocket-Schnittstelle von Home Assistant und ruft dieselben
+`pyscript.audi_dashboard_*`-Dienste auf wie das bestehende Panel. Alle Daten
+liegen auf dem eigenen Server; das Gerät hält nur den Zugangstoken und einen
+Zwischenspeicher für die Offline-Anzeige.
-**Data layer only.** No UI yet — the screens come from the Claude Design draft
-([`../DESIGN_BRIEF_DATAMETRIC360.md`](../DESIGN_BRIEF_DATAMETRIC360.md)), and get implemented on top
-of `design-system/`'s React components once that draft settles. This package was written first on
-purpose: how the app talks to Home Assistant doesn't depend on what the screens look like, so it
-survives every design iteration untouched.
+Architektur: `../COMPANION_APP_ARCHITECTURE.md` · Umsetzungsschritte:
+`../UMSETZUNGSPLAN.md` · Projektstand: `../AGENTS.md`
-Not yet added (deliberately, they'd be guesses today): React/Vite, Capacitor, the native secure-storage
-adapter, and the Audi brand assets (fonts/rings/badges — those come from `homeassistant/www/` at
-implementation time, never into `design-system/`, see the licence note in the architecture doc).
+## Loslegen
-## Layout
-
-```
-src/api/
-├── types.ts HA state shapes + domain types (Fahrt, Tankvorgang, …) + the entity-ID table
-├── umgebung.ts runtime detection (capacitor/iframe/browser), credential storage, URL helpers
-├── rest.ts REST client — replaces hass.states / hass.callService
-├── live.ts WebSocket client — push updates, auto-reconnect with backoff
-├── warteschlange.ts offline queue for writes made without a connection
-└── index.ts DataMetricApi — ties the three together, exposes the domain operations
-scripts/smoke.ts verification against a running HA instance
+```bash
+npm install # im Repo-Wurzelverzeichnis, nicht hier (npm-Workspace)
+npm run build:ds # Design-System bauen, die App importiert aus dessen dist/
+npm run dev --workspace datametric360
```
-Comments are in German, matching the rest of the project.
+Beim ersten Start fragt die App nach Server-Adresse und Zugangstoken. Für die
+Entwicklung setzt `../testumgebung/aufsetzen.sh` eine vollständige
+Home-Assistant-Instanz mit dem echten Backend auf.
-## What replaces what
+## Aufbau
-The old panel got a `hass` object injected by `panel_custom`. That object only exists inside the HA
-frontend, which is exactly why the old panel can't run as a standalone app. The mapping:
-
-| Old panel | Here |
+| Ordner | Inhalt |
|---|---|
-| `hass.states[id].attributes.daten` | `rest.datenLesen(id)` / `DataMetricApi.profilLesen()` etc. |
-| `hass.callService(...)` | `warteschlange.einreihen(...)` via the `DataMetricApi` methods |
-| automatic re-render on state push | `live.aufZustand(...)` |
+| `src/api/` | Datenschicht: REST, WebSocket mit Wiederverbindung, Offline-Warteschlange, Zugangsdaten |
+| `src/daten/` | Fachlogik: Profil-Umrechnung, Statistik, Service-Prognose, Datenkontext |
+| `src/screens/` | Die 21 Seiten der App |
+| `src/stile/` | Layout, Bildschirmbausteine, Audi-Schrift |
+| `src/assets/audi/` | Schrift, Vier-Ringe, Typenschilder — **lizenzpflichtig, siehe unten** |
+| `src/tests/` | Testaufbau und Beispieldaten |
-Same entities, same pyscript services, same backend files written — only the transport changes.
+Gestaltung kommt vollständig aus `@audi-dash/ui` (`../design-system/`). Neue
+Komponenten entstehen dort, nicht hier.
-Every **write** goes through the queue rather than straight to REST. That's what makes offline edits
-behave the same as online ones, just delayed: a receipt photographed in a dead zone is persisted and
-sent when the connection returns, surviving an app restart in between.
-
-## Commands
-
-Node is installed at `C:\Program Files\nodejs` but is **not on PATH** — prefix it:
+## Prüfen
```bash
-$env:Path = "C:\Program Files\nodejs;" + $env:Path
+npm run typecheck # TypeScript, sehr streng (exactOptionalPropertyTypes u. a.)
+npm run test # 90 Tests: Rechenlogik, Layoutwechsel, alle Seiten gerendert
+npm run smoke # Adressbildung und Endpunkte, ohne Token
+npm run smoke:auth # gegen die laufende Testinstanz, mit Token
+npm run build # Produktionsbündel
```
-```bash
-npm run typecheck
-```
+`smoke:auth` liest Adresse und Token aus `.env.local` (schreibt
+`../testumgebung/aufsetzen.sh`, gitignored). Ohne die Datei überspringt der
+Test sich selbst, statt fehlzuschlagen.
-```bash
-npm run smoke
-```
+Die Rendertests decken alle 21 Seiten in beiden Layouts ab — schmal mit
+Tab-Leiste, breit mit Seitenleiste.
-`smoke` targets `http://localhost:18123` (the `audi_ha_test` Docker container) by default; pass a
-different base URL as the first argument.
+## Rechtlicher Hinweis
-## Verification status (2026-08-10)
+`src/assets/audi/` enthält die Hausschrift Audi Type, das Vier-Ringe-Zeichen
+und die Modell-Typenschilder. Diese sind lizenz- beziehungsweise
+markenrechtlich geschützt und ausschließlich für diese eine private,
+unveröffentlichte Installation freigegeben (`../bauauftrag.md` §8/§12).
-`tsc --noEmit` clean under `strict` plus `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`.
-All 7 smoke checks pass against the running test container, covering URL normalisation, the
-http→ws/https→wss mapping, that `GET /api/` exists and rejects an unauthenticated request with 401,
-and that the WebSocket opens with `auth_required` — which is the message `live.ts`'s whole auth flow
-is built around.
+Daraus folgt: **Die App wird nur seitlich installiert, nie in einem App Store
+veröffentlicht**, und dieses Repository bleibt privat. `../design-system/`
+kommt bewusst ohne diese Dateien aus, weil es nach außen hochgeladen wird —
+dort niemals Markendateien ablegen.
-**Not yet verified — needs a token:** authenticated reads, service calls, and the queue's real
-round-trip. Those need a Long-Lived Access Token, which is a deliberate manual step (see the LLAT
-provisioning decision in the architecture doc). To do it: create a token in the HA profile page, then
-extend `scripts/smoke.ts` with authenticated cases.
+## Noch offen
+
+- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore)
+- QR-Einrichtung als Alternative zum Einfügen des Tokens
+- Live-Ansicht der laufenden Fahrt: gebaut, aber über
+ `src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert
diff --git a/companion-app/capacitor.config.ts b/companion-app/capacitor.config.ts
new file mode 100644
index 0000000..7604cff
--- /dev/null
+++ b/companion-app/capacitor.config.ts
@@ -0,0 +1,44 @@
+import type { CapacitorConfig } from "@capacitor/cli"
+
+/**
+ * Capacitor-Konfiguration für die native Hülle.
+ *
+ * Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
+ * ausschließlich für diese eine private Installation freigegeben.
+ *
+ * Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
+ * Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
+ */
+const konfiguration: CapacitorConfig = {
+ appId: "app.datametric360",
+ appName: "DataMetric360",
+ webDir: "dist",
+ server: {
+ // https, damit die App als sicherer Ursprung gilt — sonst verweigern
+ // Browser-Bausteine wie die Zwischenablage ihren Dienst.
+ androidScheme: "https",
+ iosScheme: "https",
+ },
+ ios: {
+ // Die App zeichnet bis unter die Statusleiste und hält den sicheren
+ // Bereich selbst über env(safe-area-inset-*) frei.
+ contentInset: "never",
+ backgroundColor: "#161b23",
+ },
+ android: {
+ backgroundColor: "#161b23",
+ },
+ plugins: {
+ // Leitet fetch() über die native HTTP-Schicht statt über die WebView.
+ //
+ // Das ist hier nicht Feinschliff, sondern notwendig: Die native Hülle
+ // liefert die Oberfläche unter einem eigenen Ursprung aus, jede Anfrage an
+ // Home Assistant ist damit ursprungsübergreifend, und die WebView lehnt sie
+ // ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine
+ // CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
+ // Server. Die WebSocket-Verbindung läuft unberührt weiter.
+ CapacitorHttp: { enabled: true },
+ },
+}
+
+export default konfiguration
diff --git a/companion-app/index.html b/companion-app/index.html
new file mode 100644
index 0000000..8862b87
--- /dev/null
+++ b/companion-app/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DataMetric360
+
+
+
+
+
+
diff --git a/companion-app/package.json b/companion-app/package.json
index f5337e3..765b1dd 100644
--- a/companion-app/package.json
+++ b/companion-app/package.json
@@ -2,14 +2,38 @@
"name": "datametric360",
"version": "0.1.0",
"private": true,
- "description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Dieses Paket enthält vorerst nur die Datenschicht; die Oberfläche folgt aus dem Claude-Design-Entwurf.",
+ "description": "DataMetric360 — private Fahrzeug-App (iOS/Android via Capacitor, zusätzlich als Iframe im Home-Assistant-Dashboard). Datenschicht plus Oberfläche auf Basis von @audi-dash/ui.",
"type": "module",
"scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
"typecheck": "tsc --noEmit",
- "smoke": "node --experimental-strip-types scripts/smoke.ts"
+ "smoke": "node --experimental-strip-types scripts/smoke.ts",
+ "smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@aparajita/capacitor-secure-storage": "^8.0.0",
+ "@audi-dash/ui": "*",
+ "@capacitor/android": "^8.5.0",
+ "@capacitor/core": "^8.5.0",
+ "@capacitor/ios": "^8.5.0",
+ "leaflet": "^1.9.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
},
"devDependencies": {
+ "@capacitor/cli": "^8.5.0",
+ "@testing-library/react": "^16.3.2",
+ "@types/leaflet": "^1.9.12",
"@types/node": "^24.13.3",
- "typescript": "^5.7.2"
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "jsdom": "^29.1.1",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7",
+ "vitest": "^2.1.8"
}
}
diff --git a/companion-app/public/apple-touch-icon.png b/companion-app/public/apple-touch-icon.png
new file mode 100644
index 0000000..29cdfe1
Binary files /dev/null and b/companion-app/public/apple-touch-icon.png differ
diff --git a/companion-app/public/manifest.webmanifest b/companion-app/public/manifest.webmanifest
new file mode 100644
index 0000000..aae28cb
--- /dev/null
+++ b/companion-app/public/manifest.webmanifest
@@ -0,0 +1,33 @@
+{
+ "name": "DataMetric360",
+ "short_name": "DM360",
+ "description": "Fahrzeugdaten, Fahrtenbuch und Tankvorgänge vom eigenen Server.",
+ "lang": "de",
+ "dir": "ltr",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "background_color": "#161b23",
+ "theme_color": "#161b23",
+ "icons": [
+ {
+ "src": "./symbol.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "./symbol-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "./symbol-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/companion-app/public/symbol-192.png b/companion-app/public/symbol-192.png
new file mode 100644
index 0000000..108e79b
Binary files /dev/null and b/companion-app/public/symbol-192.png differ
diff --git a/companion-app/public/symbol-512.png b/companion-app/public/symbol-512.png
new file mode 100644
index 0000000..8e4b1c2
Binary files /dev/null and b/companion-app/public/symbol-512.png differ
diff --git a/companion-app/public/symbol.svg b/companion-app/public/symbol.svg
new file mode 100644
index 0000000..764a621
--- /dev/null
+++ b/companion-app/public/symbol.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/companion-app/scripts/smoke-auth.ts b/companion-app/scripts/smoke-auth.ts
new file mode 100644
index 0000000..f8b11fc
--- /dev/null
+++ b/companion-app/scripts/smoke-auth.ts
@@ -0,0 +1,189 @@
+/* Rauchtest mit echtem Zugangstoken gegen die laufende HA-Testinstanz.
+ Ergänzt scripts/smoke.ts um alles, was ohne Anmeldung nicht prüfbar ist:
+ echte Lesevorgänge, ein echter Dienstaufruf und ein vollständiger Umlauf
+ durch die Warteschlange.
+
+ Zugangsdaten kommen aus companion-app/.env.local (schreibt
+ testumgebung/aufsetzen.sh). Ohne sie bricht der Test mit einem Hinweis ab,
+ ohne zu scheitern - damit er in einer Umgebung ohne Testinstanz nicht
+ fälschlich rot wird.
+
+ Aufruf: npm run smoke:auth */
+
+import { readFileSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+import { dirname, resolve } from "node:path"
+
+import { ENTITAETEN } from "../src/api/types.ts"
+import { HassRest } from "../src/api/rest.ts"
+import { Warteschlange } from "../src/api/warteschlange.ts"
+import { ablageSetzen, websocketUrl, type Ablage } from "../src/api/umgebung.ts"
+
+/* ------------------------------------------------------ Zugangsdaten */
+
+const hier = dirname(fileURLToPath(import.meta.url))
+const umgebungsdatei = resolve(hier, "../.env.local")
+
+function umgebungLesen(): Record {
+ try {
+ const inhalt = readFileSync(umgebungsdatei, "utf8")
+ const werte: Record = {}
+ for (const zeile of inhalt.split("\n")) {
+ const treffer = /^([A-Z_]+)=(.*)$/.exec(zeile.trim())
+ if (treffer?.[1] && treffer[2] !== undefined) werte[treffer[1]] = treffer[2]
+ }
+ return werte
+ } catch {
+ return {}
+ }
+}
+
+const umgebung = umgebungLesen()
+const BASIS = umgebung["VITE_TEST_URL"] ?? "http://localhost:18123"
+const TOKEN = umgebung["VITE_TEST_TOKEN"] ?? process.env["VITE_TEST_TOKEN"] ?? ""
+
+if (!TOKEN) {
+ console.log("übersprungen: kein Token in companion-app/.env.local")
+ console.log(" testumgebung/aufsetzen.sh ausführen, dann erneut versuchen.")
+ process.exit(0)
+}
+
+/* ------------------------------------------------------------ Rahmen */
+
+let fehler = 0
+function pruefe(bezeichnung: string, bedingung: boolean, zusatz = ""): void {
+ console.log(`${bedingung ? "PASS" : "FAIL"} ${bezeichnung}${zusatz ? ` (${zusatz})` : ""}`)
+ if (!bedingung) fehler += 1
+}
+
+/** Ablage im Arbeitsspeicher — Node hat kein localStorage. */
+class SpeicherAblage implements Ablage {
+ #werte = new Map()
+ async lesen(schluessel: string) {
+ return this.#werte.get(schluessel) ?? null
+ }
+ async schreiben(schluessel: string, wert: string) {
+ this.#werte.set(schluessel, wert)
+ }
+ async loeschen(schluessel: string) {
+ this.#werte.delete(schluessel)
+ }
+}
+ablageSetzen(new SpeicherAblage())
+
+const zugang = { basisUrl: BASIS, token: TOKEN }
+const rest = new HassRest(zugang)
+
+/* ------------------------------------------------------------- Lesen */
+
+try {
+ const antwort = await rest.verbindungPruefen()
+ pruefe("verbindungPruefen mit Token", typeof antwort.message === "string", antwort.message)
+} catch (e) {
+ pruefe("verbindungPruefen mit Token", false, e instanceof Error ? e.message : String(e))
+}
+
+try {
+ const profil = await rest.datenLesen>(ENTITAETEN.profil)
+ pruefe(
+ "Profil liefert die erwarteten Abschnitte",
+ Boolean(profil["fahrzeug"] && profil["einstellungen"] && profil["reifen"]),
+ Object.keys(profil).join(", "),
+ )
+} catch (e) {
+ pruefe("Profil lesen", false, e instanceof Error ? e.message : String(e))
+}
+
+try {
+ const status = await rest.datenLesen>(ENTITAETEN.fahrzeugstatus)
+ /* Die Feldnamen sind der Grund für diesen Test: ein früherer Entwurf der
+ Typen hatte tank_prozent/sicher_abgestellt/sicherheit angenommen, das
+ Backend schreibt aber tankprozent/gesichert/sicherheitscheck. */
+ pruefe(
+ "Fahrzeugstatus benutzt die erwarteten Feldnamen",
+ "tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status,
+ Object.keys(status).slice(0, 6).join(", "),
+ )
+ pruefe(
+ "Sicherheitscheck liefert alle 16 Einzelprüfungen",
+ Array.isArray(status["sicherheitscheck"]) && (status["sicherheitscheck"] as unknown[]).length === 16,
+ `${(status["sicherheitscheck"] as unknown[] | undefined)?.length ?? 0} Punkte`,
+ )
+} catch (e) {
+ pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e))
+}
+
+for (const [name, id] of [
+ ["Fahrten", ENTITAETEN.fahrten],
+ ["Tankvorgänge", ENTITAETEN.tankvorgaenge],
+] as const) {
+ try {
+ const liste = await rest.datenLesen(id)
+ pruefe(`${name} sind eine Liste`, Array.isArray(liste), `${liste.length} Einträge`)
+ } catch (e) {
+ pruefe(`${name} lesen`, false, e instanceof Error ? e.message : String(e))
+ }
+}
+
+/* -------------------------------------------------------- Dienstaufruf */
+
+try {
+ await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren")
+ pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true)
+} catch (e) {
+ pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e))
+}
+
+/* ------------------------------------------------------ Warteschlange */
+
+try {
+ const warteschlange = new Warteschlange(rest)
+ await warteschlange.einreihen("pyscript", "audi_dashboard_jetzt_aktualisieren", {}, "Rauchtest")
+
+ /* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein
+ sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf
+ ist schon aktiv). Also warten, bis die Schlange tatsächlich leer ist. */
+ const frist = Date.now() + 10_000
+ while (warteschlange.anzahl > 0 && Date.now() < frist) {
+ await new Promise((weiter) => setTimeout(weiter, 100))
+ }
+ pruefe(
+ "Warteschlange arbeitet einen Auftrag vollständig ab",
+ warteschlange.anzahl === 0,
+ `${warteschlange.anzahl} verblieben`,
+ )
+} catch (e) {
+ pruefe("Warteschlangen-Umlauf", false, e instanceof Error ? e.message : String(e))
+}
+
+/* ----------------------------------------- WebSocket mit Anmeldung */
+
+try {
+ const ergebnis = await new Promise((aufloesen, ablehnen) => {
+ const ws = new WebSocket(websocketUrl(BASIS))
+ const frist = setTimeout(() => {
+ ablehnen(new Error("Zeitüberschreitung"))
+ ws.close()
+ }, 10_000)
+ ws.onmessage = (nachricht) => {
+ const daten = JSON.parse(String(nachricht.data)) as { type: string }
+ if (daten.type === "auth_required") {
+ ws.send(JSON.stringify({ type: "auth", access_token: TOKEN }))
+ } else if (daten.type === "auth_ok" || daten.type === "auth_invalid") {
+ clearTimeout(frist)
+ aufloesen(daten.type)
+ ws.close()
+ }
+ }
+ ws.onerror = () => {
+ clearTimeout(frist)
+ ablehnen(new Error("WebSocket-Fehler"))
+ }
+ })
+ pruefe("WebSocket meldet sich mit dem Token an", ergebnis === "auth_ok", ergebnis)
+} catch (e) {
+ pruefe("WebSocket-Anmeldung", false, e instanceof Error ? e.message : String(e))
+}
+
+console.log(fehler === 0 ? "\nAlle Prüfungen bestanden." : `\n${fehler} Prüfung(en) fehlgeschlagen.`)
+process.exitCode = fehler === 0 ? 0 : 1
diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx
new file mode 100644
index 0000000..5efa43b
--- /dev/null
+++ b/companion-app/src/App.tsx
@@ -0,0 +1,86 @@
+import { useCallback, useEffect, useMemo, useState } from "react"
+
+import { DataMetricApi, zugangLesen } from "./api"
+import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
+import { Shell } from "./Shell"
+import type { Route, SeitenName } from "./navigation"
+import { useTabBeschriftung, useTheme } from "./theme"
+import { Einrichtung } from "./screens/Einrichtung"
+import { Hinweisleiste } from "./screens/Hinweisleiste"
+import { SeiteFuer } from "./screens/register"
+import { zugangMerken } from "./screens/zugang"
+
+import "./stile/rahmen.css"
+import "./stile/screens.css"
+
+type Startzustand = "pruefen" | "einrichten" | "bereit"
+
+/**
+ * Wurzel der App. Entscheidet zuerst, ob Zugangsdaten vorliegen — ohne sie
+ * gibt es nichts anzuzeigen außer der Ersteinrichtung. Danach hängt alles
+ * unter dem Datenkontext.
+ */
+export function App() {
+ const [theme] = useTheme()
+ const [start, setzeStart] = useState("pruefen")
+
+ // Genau eine Instanz für die Lebensdauer der App: sie hält die
+ // WebSocket-Verbindung und die Warteschlange.
+ const api = useMemo(() => new DataMetricApi(), [])
+
+ useEffect(() => {
+ void zugangLesen().then(async (zugang) => {
+ // Adresse für Bild-URLs merken, bevor der erste Screen rendert.
+ if (zugang) await zugangMerken()
+ setzeStart(zugang ? "bereit" : "einrichten")
+ })
+ }, [])
+
+ return (
+
+ Der Ladezustand wird aus dem Tagesminimum geschätzt — das ist die Spannung kurz nach dem
+ Start, bevor die Lichtmaschine sie anhebt. Der Trend vergleicht die frühere mit der
+ späteren Hälfte des Zeitraums; er ist ein Hinweis, keine Kapazitätsmessung.
+
+
+ >
+ )
+}
+
+/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
+function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
+ const breite = 320
+ const hoehe = 120
+ const rand = 4
+
+ const alleWerte = werte.flatMap((t) => [t.min, t.max].filter((v): v is number => v != null))
+ const kleinster = Math.min(...alleWerte) - 0.1
+ const groesster = Math.max(...alleWerte) + 0.1
+ const spanne = Math.max(0.2, groesster - kleinster)
+
+ const x = (i: number) =>
+ rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
+ const y = (v: number) => hoehe - rand - ((v - kleinster) / spanne) * (hoehe - 2 * rand)
+
+ const linie = (auswahl: (t: Tageswert) => number | null) =>
+ werte
+ .map((t, i) => {
+ const v = auswahl(t)
+ return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
+ })
+ .filter(Boolean)
+ .join(" ")
+
+ return (
+
+ )
+}
diff --git a/companion-app/src/screens/Bild.tsx b/companion-app/src/screens/Bild.tsx
new file mode 100644
index 0000000..89589ec
--- /dev/null
+++ b/companion-app/src/screens/Bild.tsx
@@ -0,0 +1,38 @@
+/**
+ * Fahrzeugbild mit Platzhalter.
+ *
+ * Kapselt eine Reibung zwischen App und Bibliothek: Die App läuft mit
+ * `exactOptionalPropertyTypes`, weshalb `src={undefined}` an einem `src?: string`
+ * nicht zulässig ist. Statt an jeder Aufrufstelle bedingt zu verteilen, passiert
+ * es hier einmal.
+ */
+
+import { ImagePlaceholder } from "@audi-dash/ui"
+
+import { bildUrl } from "./bilder"
+
+export function Bild({
+ datei,
+ beschreibung,
+ name,
+ groesse,
+}: {
+ datei: string | null | undefined
+ beschreibung: string
+ name: string
+ groesse?: "default" | "mini" | "round"
+}) {
+ const url = bildUrl(datei)
+ // Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten
+ // Miniaturansicht liefe er über den Rand hinaus.
+ const mitDateiname = groesse !== "mini" && groesse !== "round"
+ return (
+
+ )
+}
diff --git a/companion-app/src/screens/Einrichtung.tsx b/companion-app/src/screens/Einrichtung.tsx
new file mode 100644
index 0000000..2c40d07
--- /dev/null
+++ b/companion-app/src/screens/Einrichtung.tsx
@@ -0,0 +1,114 @@
+/**
+ * Ersteinrichtung: Die App holt ihre Daten vom eigenen Server, also braucht
+ * sie einmalig dessen Adresse und ein Zugriffstoken.
+ *
+ * Entwurf: `design/DM360.dc.html`, Abschnitt `isSetup`. Fehlermeldungen sind
+ * bewusst nach Ursache getrennt — „Token abgelehnt" und „Server nicht
+ * erreichbar" verlangen völlig verschiedene Reaktionen des Benutzers.
+ */
+
+import { useState } from "react"
+
+import { ActionButton, Feld, Tile } from "@audi-dash/ui"
+
+import { ApiFehler, HassRest, basisUrlNormalisieren, zugangSpeichern } from "../api"
+import { zugangMerken } from "./zugang"
+
+export function Einrichtung({ fertig }: { fertig: () => void }) {
+ const [adresse, setzeAdresse] = useState("")
+ const [token, setzeToken] = useState("")
+ const [laeuft, setzeLaeuft] = useState(false)
+ const [fehler, setzeFehler] = useState(null)
+
+ const verbinden = async () => {
+ setzeFehler(null)
+ if (!adresse.trim()) {
+ setzeFehler("Bitte die Adresse deines Servers eintragen.")
+ return
+ }
+ if (!token.trim()) {
+ setzeFehler("Bitte den Zugangs-Token einfügen.")
+ return
+ }
+
+ setzeLaeuft(true)
+ try {
+ const zugang = { basisUrl: basisUrlNormalisieren(adresse), token: token.trim() }
+ // Erst prüfen, dann speichern — sonst merkt sich die App Zugangsdaten,
+ // die nachweislich nicht funktionieren.
+ await new HassRest(zugang).verbindungPruefen()
+ await zugangSpeichern(zugang)
+ await zugangMerken()
+ fertig()
+ } catch (ursache) {
+ if (ursache instanceof ApiFehler && ursache.istAnmeldeproblem) {
+ setzeFehler("Der Server hat den Token abgelehnt. Wurde er vollständig eingefügt?")
+ } else if (ursache instanceof ApiFehler && ursache.istNetzproblem) {
+ setzeFehler(
+ "Der Server ist unter dieser Adresse nicht erreichbar. Stimmen Adresse und Port, und ist das Gerät im richtigen Netz?",
+ )
+ } else {
+ setzeFehler(ursache instanceof Error ? ursache.message : String(ursache))
+ }
+ } finally {
+ setzeLaeuft(false)
+ }
+ }
+
+ return (
+
+
+ DataMetric360
+
Willkommen
+
+ Die App holt ihre Daten von deinem eigenen Server. Trag einmal die Adresse ein und füge
+ deinen Zugangs-Token ein. Danach läuft alles von selbst.
+
+ Diese Fahrt wartet noch auf den Kilometerstand. Das Fahrzeug meldet ihn oft erst bei der
+ nächsten Fahrt — die Strecke wird dann automatisch ergänzt.
+
+ )}
+
+
+ Strecke
+ {hatPositionen ? (
+
+ ) : (
+
+ )}
+ {(fahrt.start_address || fahrt.end_address) && (
+
+
+
+ >
+ }
+ />
+ )}
+
+ >
+ )
+}
diff --git a/companion-app/src/screens/Fahrten.tsx b/companion-app/src/screens/Fahrten.tsx
new file mode 100644
index 0000000..4eb0f99
--- /dev/null
+++ b/companion-app/src/screens/Fahrten.tsx
@@ -0,0 +1,174 @@
+/**
+ * Fahrtenliste, nach Jahr und Monat aufklappbar. Vorlage: `vTrips()` und
+ * `vTripsFormular()` im alten Panel.
+ */
+
+import { useState } from "react"
+
+import { Accordion, ActionButton, Feld, Pill, Tile } from "@audi-dash/ui"
+
+import { useDaten } from "../daten/DatenKontext"
+import { nachJahrUndMonat } from "../daten/statistik"
+import { datum, dauer, de, deOderStrich, monatJahr, summe, uhrzeit } from "../format"
+import type { SeitenName } from "../navigation"
+import { Leerzustand } from "./bausteine"
+import { Zeilenmenue } from "./Zeilenmenue"
+
+export function Fahrten({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
+ const { fahrten, api } = useDaten()
+ const [formularOffen, setzeFormularOffen] = useState(false)
+
+ const gruppen = nachJahrUndMonat(fahrten, (f) => f.ts_start)
+
+ return (
+ <>
+
+ {unbekannt === 1 ? "Eine Prüfung ist" : `${unbekannt} Prüfungen sind`} unbekannt, weil das
+ Fahrzeug dazu nichts meldet. Solange das so ist, gilt die Gesamtaussage bewusst als
+ unbekannt statt als sicher.
+
+ )}
+ >
+ )
+}
diff --git a/companion-app/src/screens/Hinweisleiste.tsx b/companion-app/src/screens/Hinweisleiste.tsx
new file mode 100644
index 0000000..b3712ea
--- /dev/null
+++ b/companion-app/src/screens/Hinweisleiste.tsx
@@ -0,0 +1,48 @@
+/**
+ * Offline-Hinweis und Warteschlange über der Kopfzeile.
+ *
+ * Entscheidung aus COMPANION_APP_ARCHITECTURE.md §3: Ohne Verbindung bleiben
+ * die zuletzt geladenen Daten sichtbar, sind aber erkennbar nicht aktuell —
+ * nie ein leerer Bildschirm und nie eine stille Veraltung.
+ */
+
+import type { Verbindungszustand, WartenderAuftrag } from "../api"
+import { SymbolWarnung } from "../symbole"
+
+export function Hinweisleiste({
+ verbindung,
+ warteschlange,
+}: {
+ verbindung: Verbindungszustand
+ warteschlange: readonly WartenderAuftrag[]
+}) {
+ const offline = verbindung !== "verbunden"
+ const wartend = warteschlange.length
+
+ // "verbindet" gleich beim Start nicht als Offline melden — das würde bei
+ // jedem App-Start kurz aufblitzen.
+ if (!offline && wartend === 0) return null
+ if (verbindung === "verbindet" && wartend === 0) return null
+
+ const gescheitert = warteschlange.filter((a) => a.fehlversuche > 0).length
+
+ return (
+
+ Die gefahrene Strecke schreibt der Server bei jeder Änderung des Kilometerstands fort und
+ rechnet sie immer dem gerade montierten Satz zu.
+
+
+ >
+ )
+}
diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx
new file mode 100644
index 0000000..55e7a86
--- /dev/null
+++ b/companion-app/src/screens/Service.tsx
@@ -0,0 +1,311 @@
+/**
+ * Service, Werkstatt und Servicebuch. Vorlagen: `vService()`, `vWerkstatt()`,
+ * `vSbuch()`.
+ *
+ * Die vom Fahrzeug gemeldete Fälligkeit und die aus dem Servicebuch
+ * gerechnete Prognose stehen bewusst nebeneinander statt sich zu ersetzen:
+ * Die Logik im Werkscomputer ist nicht bekannt und kann von den selbst
+ * eingestellten Intervallen abweichen.
+ */
+
+import { useState } from "react"
+
+import { ActionButton, Feld, Tile } from "@audi-dash/ui"
+
+import { useDaten } from "../daten/DatenKontext"
+import { oelwechselPrognose } from "../daten/service"
+import { datum, de, isoTag } from "../format"
+import type { SeitenName } from "../navigation"
+import { Leerzustand, NaviKachel, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
+import { kalenderDateiLaden } from "./kalender"
+
+export interface ServicebuchEintrag {
+ datum?: string
+ km?: number
+ art?: string
+ betrieb?: string
+ notiz?: string
+ [weitere: string]: unknown
+}
+
+interface Autohaus {
+ name?: string
+ adresse?: string
+ tel?: string
+ mail?: string
+}
+
+export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
+ const { fahrzeug } = useDaten()
+ if (!fahrzeug) return null
+
+ const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
+ const autohaus = (fahrzeug.service["autohaus"] ?? {}) as Autohaus
+ const prognose = oelwechselPrognose(fahrzeug, buch)
+
+ return (
+ <>
+
+ Nächster Ölwechsel
+
+
+
+
+
+ >
+ }
+ />
+ {prognose && (
+
+
+ kalenderDateiLaden("Ölwechsel", prognose.datum, "Ölwechsel laut eigener Prognose")
+ }
+ >
+ In den Kalender eintragen
+
+
+ )}
+
+ Die eigene Prognose rechnet ab dem letzten Ölwechsel im Servicebuch neu und nutzt die
+ seither tatsächlich gefahrene Strecke. Sie ersetzt die Meldung des Fahrzeugs nicht,
+ sondern ergänzt sie.
+
+ Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und
+ Kilometerstände vollständig vorliegen, erscheint er hier.
+
+ )}
+
+ Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten
+ Tankstop — nicht Liter und Kilometer desselben Zeitraums, weil das unmögliche Werte
+ ergibt, sobald eine Fahrt fehlt.
+
+
+
+
+ Tag und Nacht {a.jahr}
+ 0 ? de(Math.round((a.tagKm / a.kmJahr) * 100)) : "—"}
+ unit="% bei Tag"
+ size={40}
+ />
+
+
+ Nacht = {einstellungen.nachtVon} bis {einstellungen.nachtBis} Uhr
+
+ Den Preis je Liter rechnet die App selbst aus Betrag und Menge — er wird nie aus einem Beleg
+ übernommen. Ohne Kilometerstand nimmt sie den des Fahrzeugs.
+
+
+ )
+}
diff --git a/companion-app/src/screens/Typenschild.tsx b/companion-app/src/screens/Typenschild.tsx
new file mode 100644
index 0000000..a454945
--- /dev/null
+++ b/companion-app/src/screens/Typenschild.tsx
@@ -0,0 +1,59 @@
+/**
+ * Modell-Typenschild und Vier-Ringe-Zeichen.
+ *
+ * Beide wechseln mit dem Erscheinungsbild: auf dunklem Grund die negative,
+ * auf hellem die positive Fassung. Es gibt nur die Modelle, für die ein
+ * Typenschild vorliegt — für alles andere zeigt die App nichts statt eines
+ * falschen Schildes.
+ */
+
+import ringeHell from "../assets/audi/audi-rings-white.svg"
+import ringeDunkel from "../assets/audi/audi-rings-black.svg"
+import { useTheme } from "../theme"
+
+const SCHILDER = import.meta.glob("../assets/audi/typenschilder/*.svg", {
+ eager: true,
+ import: "default",
+ query: "?url",
+})
+
+/**
+ * Erkennt aus dem Modellnamen das passende Typenschild und trennt ab, was
+ * darüber hinaus im Namen steht.
+ *
+ * Das ist die Regel aus dem alten Panel (`badge()`): Der Modellname selbst
+ * steckt schon im Schild — daneben gehört nur, was danach kommt („Avant
+ * competition"). Sonst stünde „RS 4" zweimal da.
+ */
+export function schildErkennen(
+ titel: string,
+): { kuerzel: string; zusatz: string } | null {
+ const treffer = /^\s*(RS\s?[34567]|S\s?Q7|S\s?3)\b/i.exec(titel)
+ if (!treffer?.[1]) return null
+ const kuerzel = treffer[1].replace(/\s+/g, "").toLowerCase()
+ return { kuerzel, zusatz: titel.slice(treffer[0].length).trim() }
+}
+
+export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) {
+ const [theme] = useTheme()
+ const erkannt = schildErkennen(modell)
+ if (!erkannt) return null
+
+ const variante = theme === "nacht" ? "negative" : "positive"
+ const quelle = SCHILDER[`../assets/audi/typenschilder/${erkannt.kuerzel}-${variante}.svg`]
+ if (!quelle) return null
+
+ return
+}
+
+export function VierRinge({ hoehe = 20 }: { hoehe?: number }) {
+ const [theme] = useTheme()
+ return (
+
+ )
+}
diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx
new file mode 100644
index 0000000..6640833
--- /dev/null
+++ b/companion-app/src/screens/Uebersicht.tsx
@@ -0,0 +1,221 @@
+/**
+ * Übersicht — die Startseite. Vorlage: `vHome()` im alten Panel.
+ *
+ * Fahrzeugbild, Status „sicher abgestellt", Reichweite und Tankfüllstand,
+ * Kilometerstand, nächster Service, Vorschau auf letzte Fahrt und letzte
+ * Tankung. Ziehen zum Aktualisieren stößt eine Neuabfrage beim Fahrzeug an.
+ */
+
+import { useState } from "react"
+
+import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
+
+import { useDaten } from "../daten/DatenKontext"
+import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
+import type { SeitenName } from "../navigation"
+import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
+import { Bild } from "./Bild"
+import { Typenschild, schildErkennen } from "./Typenschild"
+
+export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
+ const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
+ useDaten()
+ const [aktualisiert, setzeAktualisiert] = useState(false)
+
+ if (!fahrzeug || !einstellungen) return null
+
+ // Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
+ // plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
+ const schild = schildErkennen(einstellungen.fahrzeugtitel)
+ const ausfuehrung = einstellungen.ausfuehrung.trim()
+ // Die Ausführung steht oft schon im Modellnamen („RS 4 Avant competition"
+ // plus Ausführung „competition") — dann nicht doppelt anhängen.
+ const zusatz = [
+ schild?.zusatz,
+ ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase())
+ ? ausfuehrung
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .trim()
+
+ const letzteFahrt = fahrten
+ .slice()
+ .sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0]
+ const letzteTankung = tankvorgaenge
+ .slice()
+ .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0]
+
+ const sicherheitsstatus =
+ fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"
+ const sicherheitstext =
+ fahrzeug.gesichert === true
+ ? "Sicher abgestellt"
+ : fahrzeug.gesichert === false
+ ? "Nicht vollständig verschlossen"
+ : "Zustand unbekannt"
+
+ const aktualisieren = async () => {
+ setzeAktualisiert(true)
+ try {
+ await jetztAktualisieren()
+ } finally {
+ setzeAktualisiert(false)
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
+ das Modell selbst zeigt bereits das Schild. Ohne erkanntes
+ Schild tritt der volle Name an dessen Stelle. */}
+
+ )
+}
diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx
new file mode 100644
index 0000000..24f034b
--- /dev/null
+++ b/companion-app/src/screens/bausteine.tsx
@@ -0,0 +1,96 @@
+/**
+ * Kleine, über mehrere Seiten geteilte Bausteine. Alles, was mehr als einmal
+ * gebraucht wird, aber zu speziell für das Design-System ist.
+ */
+
+import type { ReactNode } from "react"
+
+import { ActionButton, Tile } from "@audi-dash/ui"
+
+/**
+ * Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
+ * Hinweis, was als Nächstes passiert — nie nur „keine Daten".
+ * Wird real gebraucht: Fahrten und Tankvorgänge sind zu Beginn leer.
+ */
+export function Leerzustand({
+ titel,
+ text,
+ aktion,
+}: {
+ titel: string
+ text: string
+ aktion?: { beschriftung: string; beiKlick: () => void }
+}) {
+ return (
+
+
{titel}
+
{text}
+ {aktion && (
+
+ {aktion.beschriftung}
+
+ )}
+
+ )
+}
+
+/** Abschnittsüberschrift zwischen Kacheln. */
+export function Abschnitt({ titel, kinder }: { titel: string; kinder?: ReactNode }) {
+ return (
+
,
+ )
+}
+
+describe("Alle Seiten rendern mit Beispieldaten", () => {
+ beforeEach(() => {
+ bildschirmbreiteSetzen(390)
+ })
+
+ for (const name of ALLE_SEITEN) {
+ it(`${name} (${TITEL[name]})`, async () => {
+ const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
+ const { container } = zeige(name, id)
+ // Nach der Erstladung darf kein Ladehinweis mehr stehen.
+ await waitFor(() => {
+ expect(container.querySelector(".dm-laden")).toBeNull()
+ })
+ expect(container.textContent?.length ?? 0).toBeGreaterThan(20)
+ })
+ }
+
+ it("rendert jede Seite auch im Flächenlayout", async () => {
+ bildschirmbreiteSetzen(1280)
+ for (const name of ALLE_SEITEN) {
+ const id = name === "trip" ? "t1" : name === "fill" ? "f1" : undefined
+ const { container, unmount } = zeige(name, id)
+ await waitFor(() => {
+ expect(container.querySelector(".dm-laden")).toBeNull()
+ })
+ expect(container.querySelector(".dm-seitenleiste")).not.toBeNull()
+ unmount()
+ }
+ })
+})
+
+describe("Inhalte kommen wirklich aus den Daten", () => {
+ beforeEach(() => {
+ bildschirmbreiteSetzen(390)
+ })
+
+ it("zeigt Kilometerstand und Reichweite auf der Übersicht", async () => {
+ zeige("home")
+ await waitFor(() => expect(screen.getByText("48.250")).toBeTruthy())
+ expect(screen.getByText("385")).toBeTruthy()
+ })
+
+ it("zeigt einen unbekannten Prüfpunkt als unbekannt, nicht als sicher", async () => {
+ const { container } = zeige("sicherheit")
+ await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())
+ expect(container.querySelectorAll(".dm-punkt--unbekannt").length).toBe(1)
+ expect(screen.getByText("unbekannt")).toBeTruthy()
+ })
+
+ it("gruppiert Fahrten nach Jahr", async () => {
+ zeige("trips")
+ await waitFor(() => expect(screen.getByText("2026")).toBeTruthy())
+ expect(screen.getByText("42,5 km")).toBeTruthy()
+ })
+
+ it("zeigt bei einer Fahrt ohne Position keine erfundene Route", async () => {
+ const { container } = zeige("trip", "t1")
+ await waitFor(() => expect(screen.getByText("Keine Route aufgezeichnet")).toBeTruthy())
+ expect(container.querySelector(".dm-karte")).toBeNull()
+ })
+
+ it("rechnet den Tankvorgang mit mengengewichtetem Preis", async () => {
+ zeige("fill", "f1")
+ await waitFor(() => expect(screen.getByText("48,70")).toBeTruthy())
+ expect(screen.getByText("1,83 €")).toBeTruthy()
+ })
+})
diff --git a/companion-app/src/screens/zugang.ts b/companion-app/src/screens/zugang.ts
new file mode 100644
index 0000000..011c96a
--- /dev/null
+++ b/companion-app/src/screens/zugang.ts
@@ -0,0 +1,23 @@
+/**
+ * Synchroner Zugriff auf die Basis-Adresse.
+ *
+ * Die Datenschicht liest Zugangsdaten bewusst asynchron (die native Ablage
+ * unter iOS/Android ist es auch). Für Bild-URLs im Rendern wird die Adresse
+ * aber synchron gebraucht — deshalb wird sie einmal beim Start gemerkt.
+ */
+
+import { zugangLesen, type Zugang } from "../api"
+
+let gemerkt: Zugang | null = null
+
+export async function zugangMerken(): Promise {
+ gemerkt = await zugangLesen()
+}
+
+export function zugangLesenSynchron(): Zugang | null {
+ return gemerkt
+}
+
+export function zugangVergessen(): void {
+ gemerkt = null
+}
diff --git a/companion-app/src/stile/audi-schrift.css b/companion-app/src/stile/audi-schrift.css
new file mode 100644
index 0000000..05f1c8e
--- /dev/null
+++ b/companion-app/src/stile/audi-schrift.css
@@ -0,0 +1,63 @@
+/*
+ * Hausschrift Audi Type.
+ *
+ * RECHTLICH: Schrift, Ringe und Typenschilder sind lizenz- beziehungsweise
+ * markenrechtlich geschützt und ausschließlich für diese eine private,
+ * nicht veröffentlichte Installation freigegeben (bauauftrag.md §8/§12).
+ * Deshalb wird die App nur seitlich installiert und nie in einen App Store
+ * gegeben — und deshalb enthält design-system/ diese Dateien bewusst nicht,
+ * weil es nach außen hochgeladen wird.
+ *
+ * Die Dateien stammen aus homeassistant/www/audi-dashboard.css, wo sie als
+ * base64 eingebettet lagen. Als eigene woff2-Dateien sind sie zwischenspeicherbar,
+ * statt bei jedem Laden des Stylesheets erneut übertragen zu werden.
+ *
+ * Es gibt nur die Schnitte 300 und 400. Niemals fetter setzen — der Browser
+ * würde die Schrift künstlich verdicken, was sichtbar anders aussieht.
+ */
+
+@font-face {
+ font-family: "Audi Type";
+ src: url("../assets/audi/schriften/audi-type-400.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Audi Type Wide";
+ src: url("../assets/audi/schriften/audi-type-wide-300.woff2") format("woff2");
+ font-weight: 300;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Audi Type Extended";
+ src: url("../assets/audi/schriften/audi-type-extended-400-italic.woff2") format("woff2");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+/*
+ * Überschreibt den neutralen Ersatzstapel aus @audi-dash/ui. Die Bibliothek
+ * selbst bleibt unberührt — sie kennt diese Schrift nicht und darf es nicht.
+ */
+.ads-root {
+ --font-stack: "Audi Type", Helvetica, Arial, sans-serif;
+ --font-wide: "Audi Type Wide", "Audi Type", Helvetica, Arial, sans-serif;
+}
+
+/* Große Zahlen (Kilometerstand, Reichweite, Beträge) in der breiten Schnitt-
+ variante, wie im Panel. */
+.ads-fig,
+.dm-detail__zahl {
+ font-family: var(--font-wide);
+ font-weight: 300;
+}
+
+.ads-sport {
+ font-family: "Audi Type Extended", var(--font-stack);
+ font-style: italic;
+}
diff --git a/companion-app/src/stile/grundlage.css b/companion-app/src/stile/grundlage.css
new file mode 100644
index 0000000..4e93324
--- /dev/null
+++ b/companion-app/src/stile/grundlage.css
@@ -0,0 +1,78 @@
+/*
+ * Grundlayout der App. Alles Gestalterische kommt aus @audi-dash/ui
+ * (Tokens + Komponenten) — hier steht nur, was eine App braucht und eine
+ * Komponentenbibliothek nicht mitbringen kann: Vollbild, sichere Bereiche,
+ * Scrollverhalten, Fokusring.
+ */
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ overscroll-behavior-y: none;
+}
+
+body {
+ background: var(--canvas);
+}
+
+#wurzel {
+ height: 100%;
+}
+
+/* Wurzel der App: setzt Schrift, Farben und Zahlendarstellung (.ads-root aus
+ der Bibliothek) und spannt sich über die volle Höhe. */
+.dm-wurzel {
+ height: 100%;
+ display: flex;
+ overflow: hidden;
+ font-weight: 400;
+}
+
+/* Tastaturbedienung: im alten Panel als Audit-Befund nachgerüstet, hier von
+ Anfang an. :focus-visible statt :focus lässt Maus und Touch unberührt. */
+:focus-visible {
+ outline: 2px solid var(--red);
+ outline-offset: 2px;
+}
+
+.dm-scroll {
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+}
+
+.dm-scroll::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+.dm-scroll::-webkit-scrollbar-thumb {
+ background: var(--line-strong);
+ border-radius: var(--r-pill);
+}
+
+/* Formularfelder: mindestens 16px, sonst zoomt iOS Safari beim Fokus die
+ ganze Seite (Audit-Befund aus dem alten Panel). */
+input,
+select,
+textarea,
+button {
+ font-family: inherit;
+ font-size: 16px;
+ color: inherit;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/companion-app/src/stile/rahmen.css b/companion-app/src/stile/rahmen.css
new file mode 100644
index 0000000..9e9ca74
--- /dev/null
+++ b/companion-app/src/stile/rahmen.css
@@ -0,0 +1,209 @@
+/*
+ * Rahmen der App (Shell.tsx): Kopfzeile, Seitenleiste, Inhaltsfläche.
+ * Farben und Radien ausschließlich aus den Tokens von @audi-dash/ui.
+ */
+
+.dm-spalte {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+/* ------------------------------------------------------------- Kopfzeile */
+
+.dm-kopf {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: calc(env(safe-area-inset-top, 0px) + var(--sp-4)) var(--sp-5) var(--sp-3);
+ border-bottom: 1px solid var(--line);
+ background: var(--canvas);
+}
+
+.dm-kopf__titel {
+ flex: 1;
+ min-width: 0;
+}
+
+.dm-kopf__zurueck,
+.dm-kopf__aktion {
+ flex: none;
+ width: 44px;
+ height: 44px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: var(--r-pill);
+ background: transparent;
+ color: var(--fg2);
+ cursor: pointer;
+}
+
+.dm-kopf__zurueck:hover,
+.dm-kopf__aktion:hover {
+ background: var(--shade);
+ color: var(--fg);
+}
+
+.dm-kopf__aktion[aria-current="page"] {
+ color: var(--red);
+}
+
+/* ---------------------------------------------------------- Inhaltsfläche */
+
+.dm-inhalt {
+ flex: 1;
+ padding: var(--sp-4) var(--sp-5) calc(env(safe-area-inset-bottom, 0px) + var(--sp-5));
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-3);
+}
+
+/* Inhalte dürfen nie gestaucht werden — die Fläche scrollt stattdessen.
+ Ohne das quetscht Flex jeden Block mit `overflow: hidden` auf Höhe 0,
+ sobald die Seite länger ist als der Bildschirm: `overflow: hidden` setzt
+ die automatische Mindesthöhe auf 0, und dann greift flex-shrink ungebremst.
+ Genau so verschwand das Fahrzeugbild samt Modellname und Kennzeichen. */
+.dm-inhalt > * {
+ flex: none;
+}
+
+.dm-inhalt--breit {
+ padding: var(--sp-5) 32px 32px;
+}
+
+/* Mehrspaltige Anordnung auf großen Flächen: Kacheln nebeneinander statt
+ untereinander. Die Screens setzen die Klasse dort, wo es sinnvoll ist. */
+.dm-raster {
+ display: grid;
+ gap: var(--sp-3);
+ grid-template-columns: 1fr;
+}
+
+@media (min-width: 1000px) {
+ .dm-raster {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
+ }
+
+ .dm-raster--drei {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .dm-raster__breit {
+ grid-column: 1 / -1;
+ }
+}
+
+/* Listen- und Detailansicht nebeneinander (Fahrten, Tanken auf großer Fläche) */
+.dm-zweispalter {
+ display: grid;
+ gap: var(--sp-4);
+ grid-template-columns: 1fr;
+ align-items: start;
+}
+
+@media (min-width: 1000px) {
+ .dm-zweispalter {
+ grid-template-columns: minmax(0, 4fr) minmax(0, 5fr);
+ }
+}
+
+/* ----------------------------------------------------------- Seitenleiste */
+
+.dm-seitenleiste {
+ flex: 0 0 240px;
+ width: 240px;
+ height: 100%;
+ box-sizing: border-box;
+ border-right: 1px solid var(--line);
+ padding: 24px 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ background: var(--canvas);
+}
+
+.dm-seitenleiste__marke {
+ padding: 0 10px var(--sp-4);
+}
+
+.dm-seitenleiste__punkt {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ width: 100%;
+ padding: 11px 10px;
+ border: 0;
+ border-radius: var(--r-func);
+ background: transparent;
+ color: var(--fg2);
+ font-size: 14px;
+ font-weight: 300;
+ text-align: left;
+ cursor: pointer;
+}
+
+.dm-seitenleiste__punkt:hover {
+ background: var(--shade);
+ color: var(--fg);
+}
+
+.dm-seitenleiste__punkt--an {
+ background: var(--tile);
+ color: var(--fg);
+}
+
+.dm-seitenleiste__fuss {
+ margin-top: auto;
+ padding-top: var(--sp-3);
+ border-top: 1px solid var(--line);
+}
+
+/* Zurück-Weg auf großer Fläche: als Brotkrume über dem Inhalt statt als
+ Pfeil in der Kopfzeile — auf dem Handy nimmt der Pfeil den Platz ein. */
+.dm-brotkrume {
+ padding: var(--sp-4) 32px 0;
+}
+
+.dm-brotkrume button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 0;
+ background: transparent;
+ color: var(--fg2);
+ font-size: 13px;
+ font-weight: 300;
+ cursor: pointer;
+ padding: 6px 0;
+}
+
+.dm-brotkrume button:hover {
+ color: var(--fg);
+}
+
+/* ------------------------------------------------------------ Tab-Leiste */
+
+.dm-tableiste {
+ flex: none;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ border-top: 1px solid var(--line);
+ background: var(--canvas);
+}
+
+.dm-seitenleiste__marke {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 0 10px var(--sp-4);
+}
+
+.dm-szene__marke {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ flex-wrap: wrap;
+}
diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css
new file mode 100644
index 0000000..aa37eee
--- /dev/null
+++ b/companion-app/src/stile/screens.css
@@ -0,0 +1,595 @@
+/*
+ * Screen-übergreifende Bausteine: Eingabefelder, Hinweisleiste,
+ * Ersteinrichtung, Leerzustände, Ladeanzeige.
+ */
+
+/* -------------------------------------------------------- Eingabefelder */
+
+.dm-eingabe,
+.dm-auswahl {
+ font: inherit;
+ font-weight: 300;
+ font-size: 16px;
+ color: var(--fg);
+ background: none;
+ border: 0;
+ text-align: right;
+ outline: none;
+ min-width: 120px;
+ max-width: 60%;
+ padding: 0;
+}
+
+.dm-eingabe::placeholder {
+ color: var(--fg3);
+}
+
+.dm-auswahl {
+ text-align: right;
+ appearance: none;
+ cursor: pointer;
+}
+
+.dm-auswahl option {
+ color: var(--fg);
+ background: var(--tile);
+}
+
+.dm-notiz {
+ font: inherit;
+ font-weight: 300;
+ font-size: 16px;
+ color: var(--fg);
+ background: var(--tile-2);
+ border: 0;
+ border-radius: var(--r-func);
+ padding: 10px 12px;
+ width: 100%;
+ min-height: 88px;
+ resize: vertical;
+ outline: none;
+}
+
+/* --------------------------------------------------------- Hinweisleiste */
+
+.dm-hinweis {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 9px var(--sp-5);
+ padding-top: calc(env(safe-area-inset-top, 0px) + 9px);
+ font-size: 12.5px;
+ font-weight: 300;
+ color: var(--fg);
+ background: var(--tile-2);
+ border-bottom: 1px solid var(--line);
+}
+
+.dm-hinweis--offline {
+ color: var(--warn);
+}
+
+.dm-hinweis svg {
+ flex: none;
+}
+
+/* -------------------------------------------------------- Ersteinrichtung */
+
+.dm-einrichtung {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ overflow-y: auto;
+}
+
+.dm-einrichtung__karte {
+ width: 100%;
+ max-width: 420px;
+}
+
+.dm-einrichtung__titel {
+ font-size: 28px;
+ font-weight: 300;
+ letter-spacing: -0.02em;
+ margin: 10px 0 12px;
+}
+
+.dm-einrichtung__text {
+ font-size: 14px;
+ font-weight: 300;
+ line-height: 1.55;
+ color: var(--fg2);
+ margin: 0 0 22px;
+ text-wrap: pretty;
+}
+
+.dm-einrichtung__fehler {
+ font-size: 13px;
+ font-weight: 300;
+ line-height: 1.5;
+ color: var(--bad);
+ margin: 14px 0 0;
+}
+
+.dm-einrichtung__knoepfe {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: var(--sp-4);
+}
+
+.dm-einrichtung__hinweis {
+ font-size: 11.5px;
+ font-weight: 300;
+ line-height: 1.5;
+ color: var(--fg3);
+ margin: 18px 0 0;
+}
+
+/* ------------------------------------------------- Leer- und Ladezustände */
+
+.dm-leer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: var(--sp-2);
+ padding: 44px 24px;
+ color: var(--fg2);
+}
+
+.dm-leer__titel {
+ font-size: 16px;
+ font-weight: 300;
+ color: var(--fg);
+}
+
+.dm-leer__text {
+ font-size: 13.5px;
+ font-weight: 300;
+ line-height: 1.55;
+ max-width: 34ch;
+ margin: 0;
+ text-wrap: pretty;
+}
+
+.dm-laden {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--sp-2);
+ padding: 44px 24px;
+ color: var(--fg2);
+ font-size: 13.5px;
+ font-weight: 300;
+}
+
+/* ------------------------------------------------------------ Übersicht */
+
+.dm-szene {
+ position: relative;
+ border-radius: var(--r-tile);
+ overflow: hidden;
+ background: var(--tile);
+}
+
+.dm-szene .ads-bild,
+.dm-szene img {
+ width: 100%;
+ display: block;
+}
+
+.dm-szene__text {
+ padding: var(--sp-4) var(--sp-4) var(--sp-5);
+}
+
+.dm-tanktext {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--sp-3);
+ margin-top: var(--sp-2);
+ font-size: 12.5px;
+ font-weight: 300;
+ color: var(--fg2);
+}
+
+.dm-navikachel__wert {
+ margin-top: var(--sp-1);
+ font-size: 15px;
+ font-weight: 300;
+ color: var(--fg);
+}
+
+.dm-navikachel__zahl {
+ font-size: 20px;
+ font-weight: 300;
+}
+
+.dm-navikachel__wert small,
+.dm-wertzeile small {
+ display: block;
+ margin-top: 2px;
+ font-size: 11.5px;
+ color: var(--fg3);
+}
+
+.dm-aktionen {
+ display: flex;
+ justify-content: center;
+ padding: var(--sp-2) 0 var(--sp-4);
+}
+
+.dm-textknopf {
+ border: 0;
+ background: transparent;
+ color: var(--fg2);
+ font-size: 13px;
+ font-weight: 300;
+ padding: 10px 14px;
+ border-radius: var(--r-pill);
+ cursor: pointer;
+}
+
+.dm-textknopf:hover:not(:disabled) {
+ background: var(--shade);
+ color: var(--fg);
+}
+
+.dm-textknopf:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+/* --------------------------------------------------------- Werteliste */
+
+.dm-werteliste {
+ margin: var(--sp-3) 0 0;
+}
+
+.dm-wertzeile {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--sp-4);
+ padding: 10px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.dm-wertzeile:last-child {
+ border-bottom: 0;
+}
+
+.dm-wertzeile dt {
+ font-size: 13px;
+ font-weight: 300;
+ color: var(--fg2);
+}
+
+.dm-wertzeile dd {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 300;
+ text-align: right;
+}
+
+.dm-abschnitt {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--sp-3);
+ padding: var(--sp-3) 2px 0;
+}
+
+.dm-fussnote {
+ margin: var(--sp-3) 0 0;
+ font-size: 11.5px;
+ font-weight: 300;
+ line-height: 1.5;
+ color: var(--fg3);
+ text-wrap: pretty;
+}
+
+/* ------------------------------------------------------ Fahrzeugstatus */
+
+.dm-pruefliste {
+ list-style: none;
+ margin: var(--sp-3) 0 0;
+ padding: 0;
+}
+
+.dm-pruefliste__zeile {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: 10px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.dm-pruefliste__zeile:last-child {
+ border-bottom: 0;
+}
+
+.dm-pruefliste__label {
+ flex: 1;
+ font-size: 14px;
+ font-weight: 300;
+}
+
+.dm-pruefliste__wert {
+ font-size: 12.5px;
+ font-weight: 300;
+ color: var(--fg2);
+}
+
+.dm-punkt {
+ flex: none;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+}
+
+.dm-punkt--ok {
+ background: var(--ok);
+}
+
+.dm-punkt--offen {
+ background: var(--bad);
+}
+
+/* Unbekannt bewusst als hohler Ring: sichtbar anders als grün und als rot,
+ damit "keine Meldung" nie wie eine Aussage aussieht. */
+.dm-punkt--unbekannt {
+ border: 1.5px solid var(--fg3);
+}
+
+/* ------------------------------------------------------------ Galerie */
+
+.dm-galerie {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
+ gap: var(--sp-2);
+ margin-top: var(--sp-3);
+}
+
+/* ------------------------------------------------------- Listenzeilen */
+
+.dm-zeile {
+ display: flex;
+ align-items: stretch;
+ gap: 2px;
+}
+
+.dm-zeile__wisch {
+ flex: 1;
+ min-width: 0;
+}
+
+/* Immer sichtbarer Zugang zu denselben Aktionen wie die Wischgeste — ohne ihn
+ waere Loeschen fuer Sprachausgabe und Schaltersteuerung unerreichbar. */
+.dm-zeile__menue {
+ flex: none;
+ width: 44px;
+ border: 0;
+ background: transparent;
+ color: var(--fg3);
+ border-radius: var(--r-func);
+ cursor: pointer;
+}
+
+.dm-zeile__menue:hover {
+ background: var(--shade);
+ color: var(--fg);
+}
+
+.dm-listenzeile {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ width: 100%;
+ padding: 12px 2px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.dm-listenzeile__haupt {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.dm-listenzeile__titel {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-2);
+ font-size: 16px;
+ font-weight: 300;
+}
+
+.dm-listenzeile__neben {
+ font-size: 13px;
+ color: var(--fg2);
+}
+
+.dm-listenzeile__unter {
+ font-size: 12px;
+ font-weight: 300;
+ color: var(--fg3);
+ /* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
+ wichtig, und "11.08.2026 · 12:27 Uhr · 3…" verliert genau die Angabe,
+ die man sucht. */
+ line-height: 1.4;
+}
+
+/* -------------------------------------------------------- Detailseiten */
+
+.dm-detail__kopf {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--sp-3);
+ margin: var(--sp-2) 0 var(--sp-3);
+ flex-wrap: wrap;
+}
+
+.dm-detail__zahl {
+ font-size: 40px;
+ font-weight: 300;
+ line-height: 1;
+ letter-spacing: -0.02em;
+}
+
+.dm-detail__einheit {
+ font-size: 15px;
+ color: var(--fg2);
+ margin-left: 6px;
+}
+
+.dm-detail__marken {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.dm-karte {
+ height: 240px;
+ width: 100%;
+ border-radius: var(--r-func);
+ overflow: hidden;
+ margin-top: var(--sp-3);
+ background: var(--tile-2);
+}
+
+/* ------------------------------------------------------------ Meldungen */
+
+.dm-fehler {
+ margin: var(--sp-3) 0 0;
+ font-size: 13px;
+ font-weight: 300;
+ line-height: 1.5;
+ color: var(--bad);
+}
+
+.dm-erfolg {
+ margin: var(--sp-3) 0 0;
+ font-size: 13px;
+ font-weight: 300;
+ line-height: 1.5;
+ color: var(--ok);
+}
+
+.dm-fussnote--warnung {
+ color: var(--warn);
+}
+
+/* --------------------------------------------------------- Listen, Text */
+
+.dm-liste {
+ list-style: none;
+ margin: var(--sp-2) 0 0;
+ padding: 0;
+ font-size: 13.5px;
+ font-weight: 300;
+ line-height: 1.7;
+}
+
+.dm-liste li {
+ padding-left: 14px;
+ position: relative;
+}
+
+.dm-liste li::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.72em;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--fg3);
+}
+
+.dm-liste__serie {
+ color: var(--fg2);
+}
+
+.dm-diagramm {
+ width: 100%;
+ height: 120px;
+ margin: var(--sp-3) 0;
+ display: block;
+}
+
+.dm-knopfreihe {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--sp-2);
+ margin-top: var(--sp-4);
+}
+
+.dm-waehlen {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--sp-2);
+ margin-top: var(--sp-3);
+}
+
+.dm-waehlen__knopf {
+ display: inline-flex;
+ align-items: center;
+ padding: 10px 16px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-pill);
+ font-size: 13.5px;
+ font-weight: 300;
+ color: var(--fg);
+ text-decoration: none;
+}
+
+.dm-waehlen__knopf:hover {
+ background: var(--shade);
+}
+
+/* Bleibt beim Scrollen unten stehen, damit ungespeicherte Aenderungen nicht
+ aus dem Blick geraten. */
+.dm-speicherleiste {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: var(--sp-3) 0;
+ background: var(--canvas);
+ border-top: 1px solid var(--line);
+}
+
+.dm-szene__zusatz {
+ min-width: 0;
+}
+
+/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit
+ Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */
+.dm-szene__zusatz {
+ min-width: 0;
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ font-size: 19px;
+ line-height: 1.2;
+}
+
+/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
+ damit die Kacheln nicht auseinandergezogen werden und ihre Beschriftung
+ innerhalb des Rahmens bleibt. */
+.dm-galerie {
+ grid-template-columns: repeat(auto-fill, 78px);
+ justify-content: space-between;
+ gap: var(--sp-3);
+}
diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx
new file mode 100644
index 0000000..c4d9263
--- /dev/null
+++ b/companion-app/src/symbole.tsx
@@ -0,0 +1,163 @@
+/**
+ * Symbole als React-Bausteine. Aus dem alten Panel übernommen (dort als
+ * SVG-Fragmente in Zeichenketten), damit App und Panel identisch aussehen.
+ * Bewusst keine Symbolbibliothek: es sind eine Handvoll, und jede Bibliothek
+ * wäre größer als der gesamte Rest.
+ */
+
+import type { ReactElement } from "react"
+
+// Ausdrücklich `| undefined`, weil exactOptionalPropertyTypes eingeschaltet ist:
+// ein durchgereichtes `groesse={undefined}` muss zulässig bleiben.
+interface SymbolProps {
+ groesse?: number | undefined
+}
+
+function Rahmen({ groesse = 24, kinder }: SymbolProps & { kinder: ReactElement }) {
+ return (
+
+ )
+}
+
+export function SymbolUebersicht({ groesse }: SymbolProps) {
+ return (
+
+
+
+
+
+ }
+ />
+ )
+}
+
+export function SymbolAudi({ groesse }: SymbolProps) {
+ return (
+ }
+ />
+ )
+}
+
+export function SymbolFahrten({ groesse }: SymbolProps) {
+ return (
+
+
+
+
+
+ }
+ />
+ )
+}
+
+export function SymbolStatistik({ groesse }: SymbolProps) {
+ return } />
+}
+
+export function SymbolTanken({ groesse }: SymbolProps) {
+ return (
+
+
+
+
+
+
+
+ }
+ />
+ )
+}
+
+export function SymbolZurueck({ groesse }: SymbolProps) {
+ return } />
+}
+
+export function SymbolZahnrad({ groesse }: SymbolProps) {
+ return (
+
+
+
+
+ }
+ />
+ )
+}
+
+export function SymbolSonne({ groesse }: SymbolProps) {
+ return (
+
+
+
+
+ }
+ />
+ )
+}
+
+export function SymbolMond({ groesse }: SymbolProps) {
+ return (
+ }
+ />
+ )
+}
+
+export function SymbolMehr({ groesse }: SymbolProps) {
+ return (
+
+
+
+
+
+ }
+ />
+ )
+}
+
+export function SymbolWarnung({ groesse }: SymbolProps) {
+ return (
+
+
+
+
+
+ }
+ />
+ )
+}
diff --git a/companion-app/src/tests/aufbau.ts b/companion-app/src/tests/aufbau.ts
new file mode 100644
index 0000000..7f0b4e2
--- /dev/null
+++ b/companion-app/src/tests/aufbau.ts
@@ -0,0 +1,31 @@
+/**
+ * Testaufbau. jsdom kennt matchMedia nicht — die App fragt es aber ab, um
+ * zwischen Handy- und Flächenlayout zu wählen. `bildschirmbreiteSetzen()`
+ * stellt eine steuerbare Fassung bereit.
+ */
+
+let aktuelleBreite = 390
+
+export function bildschirmbreiteSetzen(breite: number): void {
+ aktuelleBreite = breite
+}
+
+function passt(abfrage: string): boolean {
+ const min = /\(min-width:\s*(\d+)px\)/.exec(abfrage)
+ if (min?.[1]) return aktuelleBreite >= Number(min[1])
+ const max = /\(max-width:\s*(\d+)px\)/.exec(abfrage)
+ if (max?.[1]) return aktuelleBreite <= Number(max[1])
+ if (abfrage.includes("prefers-color-scheme: light")) return false
+ return false
+}
+
+window.matchMedia = ((abfrage: string) => ({
+ matches: passt(abfrage),
+ media: abfrage,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+})) as unknown as typeof window.matchMedia
diff --git a/companion-app/src/tests/beispieldaten.ts b/companion-app/src/tests/beispieldaten.ts
new file mode 100644
index 0000000..f7addbb
--- /dev/null
+++ b/companion-app/src/tests/beispieldaten.ts
@@ -0,0 +1,213 @@
+/**
+ * Beispieldaten für Rendertests. Aufgebaut wie das, was das Backend
+ * tatsächlich liefert (an der laufenden Testinstanz abgelesen), nicht wie es
+ * laut Spezifikation aussehen sollte.
+ */
+
+import type { DataMetricApi, Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "../api"
+
+export const beispielStatus: Fahrzeugstatus = {
+ km: 48250,
+ tankprozent: 62,
+ reichweite_km: 385,
+ batteriespannung: null,
+ gesichert: true,
+ sicherheitscheck: [
+ { label: "Tür vorne links", ok: true },
+ { label: "Tür vorne rechts", ok: true },
+ { label: "Fenster vorne links", ok: false },
+ { label: "Motorhaube", ok: null },
+ ],
+ oelwechsel_faellig_ts: "2027-03-14T00:00:00+00:00",
+ oelwechsel_faellig_km: 11750,
+ inspektion_faellig_ts: "2027-03-14T00:00:00+00:00",
+ inspektion_faellig_km: 11750,
+}
+
+export const beispielProfil: Profil = {
+ fahrzeug: {
+ fin: "WUAZZZF48PA902804",
+ titel: "RS 4 Avant",
+ zusatz: "competition",
+ untertitel: "quattro · 331 kW",
+ details: "2.9 TFSI quattro competition",
+ kennzeichen: "M AB 1234",
+ erstzulassung: "2023-04-01",
+ hauptuntersuchung_faellig: "2027-04-01",
+ tankvolumen_liter: 58,
+ wlan_name: "Audi_MMI",
+ ausfuehrung: "competition",
+ },
+ einstellungen: {
+ nacht_von: "22:00",
+ nacht_bis: "06:00",
+ fahrten_pausenzeit_min: 15,
+ uebersichtsbild: "seitenansicht.webp",
+ kraftstoffanbieter: "Shell",
+ smartdeal: { aktiv: true, laeuft_ab: null },
+ oelwechsel_intervall: {
+ modus: "hersteller",
+ km: 15000,
+ monate: 12,
+ hersteller_km: 30000,
+ hersteller_monate: 24,
+ },
+ backup_intervall: "taeglich",
+ letztes_backup: "2026-08-10T04:00:00",
+ },
+ versicherung: {
+ gesellschaft: "Beispiel Versicherung",
+ vertrag: "Vollkasko",
+ nummer: "V-123456",
+ service: "0800 111111",
+ notfall: "0800 222222",
+ beitrag: 840,
+ zahlweise: "jährlich",
+ gueltigAb: "2026-01-01",
+ teile: [
+ { bez: "Haftpflicht", betrag: 320 },
+ { bez: "Vollkasko", betrag: 520 },
+ ],
+ selbst: [["Vollkasko", "500 €"]],
+ leistungen: [{ bez: "Werkstattbindung", wert: "nein" }],
+ schutzbrief: {
+ stand: "2026-01",
+ gilt: "Europa",
+ versichert: "Fahrzeug und Insassen",
+ ausloeser: ["Panne", "Unfall"],
+ entweder: [],
+ gruppen: ["Abschleppen"],
+ nicht: [],
+ pflichten: ["Unverzüglich melden"],
+ },
+ },
+ steuer: {
+ betrag: 320,
+ zeitraum: "jährlich",
+ faellig: "2027-04-01",
+ grundlage: "Hubraum und CO₂-Ausstoß",
+ lastschrift: true,
+ finanzamt: "München",
+ },
+ reifen: {
+ aktiv: "sommer",
+ anzugsmoment_nm: 140,
+ wechsel: { datum: "2026-10-15", vereinbart: false },
+ referenz_odo_km: 48250,
+ saetze: {
+ sommer: { km: 12000, mass: "275/30 R20", marke: "Michelin", druck_vorne: "2,5", druck_hinten: "2,4" },
+ winter: { km: 8000, mass: "265/35 R19", marke: "Continental", druck_vorne: "2,6", druck_hinten: "2,5" },
+ },
+ },
+ service: {
+ vereinbart: {},
+ autohaus: { name: "Audi Zentrum", adresse: "Musterweg 1", tel: "089 1234", mail: "a@b.de" },
+ buch: [
+ { datum: "2026-02-10", km: 40000, art: "Ölwechsel", betrieb: "Audi Zentrum" },
+ { datum: "2025-03-05", km: 25000, art: "Inspektion", betrieb: "Audi Zentrum" },
+ ],
+ },
+ technik: [
+ { gruppe: "Motor", posten: [["Bauart", "V6 Biturbo"], ["Leistung", "331 kW"]] },
+ ],
+ ausstattung: [{ gruppe: "RS-Paket", sonder: ["RS competition"], serie: ["Klimaautomatik"] }],
+}
+
+export const beispielFahrten: Fahrt[] = [
+ {
+ trip_id: "t1",
+ ts_start: "2026-08-10T08:00:00+02:00",
+ ts_end: "2026-08-10T08:45:00+02:00",
+ duration_s: 2700,
+ distance_km: 42.5,
+ km_quelle: "odometer",
+ odo_start: 48200,
+ odo_end: 48242.5,
+ art: "arbeitsweg",
+ source: "ha",
+ status: "vollständig",
+ },
+ {
+ trip_id: "t2",
+ ts_start: "2026-08-09T23:10:00+02:00",
+ ts_end: "2026-08-09T23:40:00+02:00",
+ duration_s: 1800,
+ distance_km: 18.2,
+ km_quelle: "odometer",
+ odo_start: 48180,
+ odo_end: 48198.2,
+ art: "privat",
+ source: "manual",
+ status: "offen",
+ },
+]
+
+export const beispielTankvorgaenge: Tankvorgang[] = [
+ {
+ tank_id: "f1",
+ ts: "2026-08-08T17:20:00+02:00",
+ liters: 48.7,
+ fuel_total_eur: 89.12,
+ price_per_l: 1.83,
+ station_name: "Shell München Ost",
+ station_address: "Musterstraße 5",
+ product_name: "V-Power",
+ odometer_km: 48100,
+ distance_km: 520,
+ receipt_file: "beleg-2026-08-08.pdf",
+ receipt_no: "4711",
+ discount: 3.4,
+ source: "beleg",
+ status: "vollständig",
+ },
+]
+
+/** Datenschicht-Ersatz für Tests: liefert Beispieldaten, schreibt ins Leere. */
+export function beispielApi(): DataMetricApi {
+ const horcher = { verbindung: [] as ((z: string) => void)[] }
+ return {
+ rest: {
+ zustandLesen: async () => ({
+ entity_id: "pyscript.audi_dashboard_fahrzeugstatus",
+ state: "aktuell",
+ attributes: { daten: beispielStatus },
+ last_changed: "2026-08-11T09:00:00+00:00",
+ last_updated: "2026-08-11T09:00:00+00:00",
+ }),
+ datenLesen: async () => [],
+ dienstAufrufen: async () => null,
+ verbindungPruefen: async () => ({ message: "API running." }),
+ zugangSetzen: () => {},
+ },
+ live: {
+ aufVerbindung: (h: (z: string) => void) => {
+ horcher.verbindung.push(h)
+ h("verbunden")
+ return () => {}
+ },
+ aufZustand: () => () => {},
+ starten: async () => {},
+ beenden: () => {},
+ },
+ warteschlange: {
+ laden: async () => {},
+ aufAenderung: (h: (a: unknown[]) => void) => {
+ h([])
+ return () => {}
+ },
+ anzahl: 0,
+ },
+ profilLesen: async () => beispielProfil,
+ fahrtenLesen: async () => beispielFahrten,
+ tankvorgaengeLesen: async () => beispielTankvorgaenge,
+ fahrzeugstatusLesen: async () => beispielStatus,
+ profilSchreiben: async () => null,
+ belegHochladen: async () => null,
+ fahrtAnlegen: async () => null,
+ fahrtLoeschen: async () => null,
+ tankvorgangAnlegen: async () => null,
+ tankvorgangAktualisieren: async () => null,
+ tankvorgangLoeschen: async () => null,
+ jetztAktualisieren: async () => null,
+ } as unknown as DataMetricApi
+}
diff --git a/companion-app/src/theme.test.ts b/companion-app/src/theme.test.ts
new file mode 100644
index 0000000..5263d9b
--- /dev/null
+++ b/companion-app/src/theme.test.ts
@@ -0,0 +1,23 @@
+import { beforeEach, describe, expect, it } from "vitest"
+
+import { themeStartwert } from "./theme"
+
+describe("Theme", () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it("startet ohne gespeicherte Wahl im Nachtmodus", () => {
+ expect(themeStartwert()).toBe("nacht")
+ })
+
+ it("übernimmt eine gespeicherte Wahl", () => {
+ localStorage.setItem("dm360.theme", "tag")
+ expect(themeStartwert()).toBe("tag")
+ })
+
+ it("ignoriert unsinnige gespeicherte Werte", () => {
+ localStorage.setItem("dm360.theme", "irgendwas")
+ expect(themeStartwert()).toBe("nacht")
+ })
+})
diff --git a/companion-app/src/theme.ts b/companion-app/src/theme.ts
new file mode 100644
index 0000000..69cf26b
--- /dev/null
+++ b/companion-app/src/theme.ts
@@ -0,0 +1,81 @@
+/**
+ * Tag/Nacht-Umschaltung, übernommen aus dem alten Panel (A6 des Design-Audits):
+ * gespeicherte Wahl schlägt Systemeinstellung, Standard ist "nacht".
+ * Alle localStorage-Zugriffe sind abgesichert, weil sie im Privatmodus mancher
+ * Browser werfen.
+ */
+
+import { useCallback, useEffect, useState } from "react"
+
+export type Theme = "nacht" | "tag"
+
+const SCHLUESSEL = "dm360.theme"
+
+function gespeichertLesen(): Theme | null {
+ try {
+ const wert = localStorage.getItem(SCHLUESSEL)
+ return wert === "nacht" || wert === "tag" ? wert : null
+ } catch {
+ return null
+ }
+}
+
+function gespeichertSchreiben(theme: Theme): void {
+ try {
+ localStorage.setItem(SCHLUESSEL, theme)
+ } catch {
+ // Privatmodus — die Wahl gilt dann nur für diese Sitzung
+ }
+}
+
+export function themeStartwert(): Theme {
+ const gespeichert = gespeichertLesen()
+ if (gespeichert) return gespeichert
+ const hell =
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-color-scheme: light)").matches
+ return hell ? "tag" : "nacht"
+}
+
+export function useTheme(): [Theme, (theme: Theme) => void] {
+ const [theme, setzeTheme] = useState(themeStartwert)
+
+ const setzen = useCallback((neu: Theme) => {
+ setzeTheme(neu)
+ gespeichertSchreiben(neu)
+ }, [])
+
+ // Farbe der Systemleisten mitziehen, damit auf dem Handy kein heller
+ // Balken über einer dunklen App steht.
+ useEffect(() => {
+ const marke = document.querySelector('meta[name="theme-color"]')
+ if (marke) marke.setAttribute("content", theme === "nacht" ? "#161b23" : "#ffffff")
+ }, [theme])
+
+ return [theme, setzen]
+}
+
+/** Sichtbarkeit der Tab-Beschriftungen, ebenfalls dauerhaft gespeichert. */
+const BESCHRIFTUNG_SCHLUESSEL = "dm360.tabbeschriftung"
+
+export function useTabBeschriftung(): [boolean, (an: boolean) => void] {
+ const [an, setzeAn] = useState(() => {
+ try {
+ return localStorage.getItem(BESCHRIFTUNG_SCHLUESSEL) !== "aus"
+ } catch {
+ return true
+ }
+ })
+
+ const setzen = useCallback((neu: boolean) => {
+ setzeAn(neu)
+ try {
+ localStorage.setItem(BESCHRIFTUNG_SCHLUESSEL, neu ? "an" : "aus")
+ } catch {
+ // Privatmodus
+ }
+ }, [])
+
+ return [an, setzen]
+}
diff --git a/companion-app/tsconfig.json b/companion-app/tsconfig.json
index a252206..67eec57 100644
--- a/companion-app/tsconfig.json
+++ b/companion-app/tsconfig.json
@@ -4,6 +4,8 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "types": ["vite/client"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
@@ -16,5 +18,5 @@
"skipLibCheck": true,
"isolatedModules": true
},
- "include": ["src/**/*.ts", "scripts/**/*.ts"]
+ "include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts", "vite.config.ts"]
}
diff --git a/companion-app/vite.config.ts b/companion-app/vite.config.ts
new file mode 100644
index 0000000..eef373b
--- /dev/null
+++ b/companion-app/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "vite"
+import react from "@vitejs/plugin-react"
+
+// Basispfad relativ: die App wird sowohl unter einer eigenen Domain als auch
+// aus einem Unterordner heraus ausgeliefert (Home Assistant: /local/dm360/),
+// und in der Capacitor-Hülle direkt vom Dateisystem.
+export default defineConfig({
+ base: "./",
+ plugins: [react()],
+ server: { port: 5173 },
+ build: {
+ outDir: "dist",
+ target: "es2022",
+ sourcemap: true,
+ },
+})
diff --git a/companion-app/vitest.config.ts b/companion-app/vitest.config.ts
new file mode 100644
index 0000000..2fbdb68
--- /dev/null
+++ b/companion-app/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vitest/config"
+import react from "@vitejs/plugin-react"
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
+ setupFiles: ["./src/tests/aufbau.ts"],
+ },
+})
diff --git a/design-system/package-lock.json b/design-system/package-lock.json
index b3f1b31..3b4a58d 100644
--- a/design-system/package-lock.json
+++ b/design-system/package-lock.json
@@ -509,9 +509,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -613,9 +610,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -630,9 +624,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -647,9 +638,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -664,9 +652,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -681,9 +666,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -698,9 +680,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -715,9 +694,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -732,9 +708,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -749,9 +722,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -766,9 +736,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -783,9 +750,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -800,9 +764,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -817,9 +778,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -930,6 +888,7 @@
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1066,6 +1025,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -1278,6 +1238,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -1356,6 +1317,7 @@
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1613,6 +1575,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/design-system/src/tokens/tokens.css b/design-system/src/tokens/tokens.css
index 00fb91e..653a144 100644
--- a/design-system/src/tokens/tokens.css
+++ b/design-system/src/tokens/tokens.css
@@ -29,7 +29,10 @@
--line-strong: rgba(255, 255, 255, .20);
--fg: #FFFFFF;
--fg2: #9aa1ad;
- --fg3: #657081;
+ /* Design-Audit A2: von #657081 (nur 3,0:1 auf --tile, WCAG AA verfehlt) auf
+ 4,9:1 angehoben. Das Panel hat diesen Wert bereits, hier war er noch der
+ alte — nachgezogen, damit beide dasselbe zeigen. */
+ --fg3: #8a94a3;
--ok: #15da15;
--warn: #ffaa00;
--bad: #fd2c4e;
diff --git a/homeassistant/REVERSE_PROXY.md b/homeassistant/REVERSE_PROXY.md
new file mode 100644
index 0000000..3a32b94
--- /dev/null
+++ b/homeassistant/REVERSE_PROXY.md
@@ -0,0 +1,126 @@
+# Reverse-Proxy: Pfad-Freigabeliste für DataMetric360
+
+**Vorbereitung, noch nicht in Betrieb.** Gehört zu Phase 12 des
+`../UMSETZUNGSPLAN.md`; die Entscheidung dahinter steht in
+`../COMPANION_APP_ARCHITECTURE.md` §4.
+
+Ziel: Die App erreicht Home Assistant von unterwegs, **ohne dass Home Assistant
+selbst im Internet steht**. Dazu ein Cloudflare-Tunnel (baut nur nach außen auf,
+kein Port am Router) auf einen Reverse Proxy, der ausschließlich die unten
+aufgeführten Pfade durchlässt.
+
+## Was die App wirklich braucht
+
+Aus dem tatsächlichen Code der Datenschicht (`companion-app/src/api/`)
+abgeleitet, nicht geschätzt:
+
+| Methode | Pfad | Wofür |
+|---|---|---|
+| `GET` | `/api/` | Verbindungsprüfung bei der Ersteinrichtung |
+| `GET` | `/api/states/pyscript.audi_dashboard_*` | Profil, Fahrten, Tankvorgänge, Status, Batterieverlauf, Belegergebnis, Updatestatus |
+| `GET` | `/api/states/pyscript.reifen_*` | Reifensatz-Kilometerstände |
+| `POST` | `/api/services/pyscript/audi_dashboard_*` | alle schreibenden Vorgänge |
+| `GET` (Upgrade) | `/api/websocket` | Live-Aktualisierung |
+
+Alles andere wird geblockt, insbesondere `/auth/*`, `/lovelace*`, `/config*`,
+`/api/config`, `/api/history*`, `/developer-tools*` und die Oberfläche selbst.
+
+## Ehrliche Einschränkung
+
+**Der WebSocket lässt sich nicht pfadgenau beschneiden.** Nach `auth_ok` kann
+über `/api/websocket` grundsätzlich jeder Zustand gelesen werden, nicht nur die
+`pyscript.*`-Entitäten. Die Freigabeliste ist an dieser Stelle also grobkörniger
+als beim REST-Zugriff.
+
+Was bleibt: Ohne gültigen Token kommt gar keine Verbindung zustande, und ein
+verlorenes Gerät wird durch Zurückziehen genau seines Tokens ausgesperrt. Das
+ist die im Architekturdokument bewusst akzeptierte Abwägung — sie sollte nur
+nicht in Vergessenheit geraten.
+
+Wer sie nicht eingehen will, hat eine Alternative: den WebSocket weglassen und
+die App auf regelmäßiges Abfragen umstellen. Kostet Akku und Datenvolumen,
+verkleinert die Angriffsfläche aber auf die exakt aufgeführten REST-Pfade.
+
+## Nginx Proxy Manager
+
+Im Add-on unter *Hosts → Proxy Hosts → Edit → Advanced* eintragen. Ziel ist der
+interne Name der Home-Assistant-Instanz (im Supervisor-Netz `homeassistant:8123`).
+
+```nginx
+# Reihenfolge zählt: die erlaubenden Blöcke stehen vor dem pauschalen Verbot.
+
+location = /api/ {
+ proxy_pass http://homeassistant:8123;
+ include conf.d/include/proxy.conf;
+}
+
+location ~ ^/api/states/pyscript\.(audi_dashboard_[a-z_]+|reifen_[a-z_]+)$ {
+ limit_except GET { deny all; }
+ proxy_pass http://homeassistant:8123;
+ include conf.d/include/proxy.conf;
+}
+
+location ~ ^/api/services/pyscript/audi_dashboard_[a-z_]+$ {
+ limit_except POST { deny all; }
+ proxy_pass http://homeassistant:8123;
+ include conf.d/include/proxy.conf;
+}
+
+location = /api/websocket {
+ proxy_pass http://homeassistant:8123;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_read_timeout 3600s;
+ include conf.d/include/proxy.conf;
+}
+
+# Die Fotos der Fahrzeuge, die die App anzeigt. Nur Lesen, nur Bilder.
+location ~ ^/local/bilder/[a-z0-9_-]+\.(webp|png|jpg|svg)$ {
+ limit_except GET { deny all; }
+ proxy_pass http://homeassistant:8123;
+ include conf.d/include/proxy.conf;
+}
+
+# Alles Übrige: nicht durchlassen.
+location / {
+ return 404;
+}
+```
+
+Wird die App selbst unter derselben Adresse ausgeliefert, braucht sie einen
+eigenen Block auf ihr Verzeichnis — sauberer ist ein getrennter Hostname
+(`datametric360.app` für die App, `api.datametric360.app` für die
+Schnittstelle), dann bleibt diese Liste unverändert.
+
+## Nach der Einrichtung prüfen
+
+Von einem Netz ohne VPN, etwa über Mobilfunk:
+
+```bash
+API=https://api.datametric360.app
+T=
+
+# muss gehen
+curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/"
+curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
+ "$API/api/states/pyscript.audi_dashboard_profil"
+
+# muss 404 oder 403 liefern
+curl -s -o /dev/null -w '%{http_code}\n' "$API/auth/authorize"
+curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/config"
+curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
+ "$API/api/states/sensor.audi_rs_4_avant_mileage"
+
+# ohne Token: 401, nicht 200
+curl -s -o /dev/null -w '%{http_code}\n' "$API/api/states/pyscript.audi_dashboard_profil"
+```
+
+Zusätzlich: Port 8123 darf von außen **gar nicht** antworten.
+
+## Offen bis zur Einrichtung
+
+- Nameserver von `datametric360.app` auf Cloudflare umstellen („Full setup")
+- Entscheidung Nginx Proxy Manager gegen Traefik (die Liste oben ist für NPM
+ geschrieben und für Traefik sinngemäß zu übertragen)
+- Endgültige Hostnamen festlegen und hier eintragen
diff --git a/homeassistant/pyscript/backup.py b/homeassistant/pyscript/backup.py
index 4ebf61a..afb421a 100644
--- a/homeassistant/pyscript/backup.py
+++ b/homeassistant/pyscript/backup.py
@@ -53,6 +53,8 @@ def _backup_jetzt():
def _letztes_backup_vermerken():
p = profil.profil_lesen()
+ if p is None:
+ return
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
profil.profil_schreiben(p)
frontend_veroeffentlichung.profil_veroeffentlichen()
@@ -69,6 +71,8 @@ def audi_dashboard_backup_jetzt():
@time_trigger("cron(0 4 * * *)")
def _automatisches_backup():
p = profil.profil_lesen()
+ if p is None:
+ return
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
heute = datetime.date.today()
faellig = (
diff --git a/homeassistant/pyscript/belegverarbeitung.py b/homeassistant/pyscript/belegverarbeitung.py
index 81b32dc..0abb859 100644
--- a/homeassistant/pyscript/belegverarbeitung.py
+++ b/homeassistant/pyscript/belegverarbeitung.py
@@ -38,7 +38,7 @@ import einstellungen
import frontend_veroeffentlichung
import profil
-PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py" # TODO: Datei ablegen
+PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
# --------------------------------------------------------------- Hilfsfunktionen
diff --git a/homeassistant/pyscript/fahrterkennung.py b/homeassistant/pyscript/fahrterkennung.py
index 2d7ce9d..b445a4c 100644
--- a/homeassistant/pyscript/fahrterkennung.py
+++ b/homeassistant/pyscript/fahrterkennung.py
@@ -42,6 +42,8 @@ _fahrt_start_ts = None
def _pausenzeit_sekunden():
p = profil.profil_lesen()
+ if p is None:
+ return 15 * 60
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py
index a5be5b5..0160b7b 100644
--- a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py
+++ b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py
@@ -25,7 +25,10 @@ import profil
def profil_veroeffentlichen():
- state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": profil.profil_lesen()})
+ daten = profil.profil_lesen()
+ if daten is None:
+ return
+ state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten})
def fahrten_veroeffentlichen():
diff --git a/homeassistant/pyscript/modules/profil.py b/homeassistant/pyscript/modules/profil.py
index 62d7fe7..4631d86 100644
--- a/homeassistant/pyscript/modules/profil.py
+++ b/homeassistant/pyscript/modules/profil.py
@@ -49,10 +49,29 @@ def ordner_sicherstellen():
# ----------------------------------------------------------- Fahrzeugprofil
def profil_lesen():
+ """Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
+
+ Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig,
+ siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das
+ Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder
+ Aufrufer muss den None-Fall abfangen."""
+ if not os.path.exists(PROFIL_PFAD):
+ log.error(
+ f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — "
+ "bis dahin bleiben alle Funktionen aus, die das Profil brauchen."
+ )
+ return None
f = task.executor(io.open, PROFIL_PFAD, "r")
inhalt = f.read()
f.close()
- return json.loads(inhalt)
+ try:
+ return json.loads(inhalt)
+ except ValueError as fehler:
+ log.error(
+ f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). "
+ "Letztes Backup aus audi_dashboard/backups/ zurückspielen."
+ )
+ return None
def profil_schreiben(profil):
diff --git a/homeassistant/pyscript/reifenzaehler.py b/homeassistant/pyscript/reifenzaehler.py
index b415c3c..1615d5c 100644
--- a/homeassistant/pyscript/reifenzaehler.py
+++ b/homeassistant/pyscript/reifenzaehler.py
@@ -60,6 +60,8 @@ def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None):
Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
p = profil.profil_lesen()
+ if p is None:
+ return
reifen = p.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
aktiv = reifen.get("aktiv", "sommer")
diff --git a/homeassistant/pyscript/tankerkennung.py b/homeassistant/pyscript/tankerkennung.py
index 56ecd71..cbd212d 100644
--- a/homeassistant/pyscript/tankerkennung.py
+++ b/homeassistant/pyscript/tankerkennung.py
@@ -57,7 +57,10 @@ def _als_zahl(wert):
def _schwelle_prozent():
p = profil.profil_lesen()
- tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
+ if p is None:
+ tankvolumen = STANDARD_TANKVOLUMEN_LITER
+ else:
+ tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100
return min(liter_als_prozent, PROZENT_SCHWELLE)
diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js
index c8debcf..f62bd32 100644
--- a/homeassistant/www/audi-dashboard-app.js
+++ b/homeassistant/www/audi-dashboard-app.js
@@ -11,8 +11,6 @@
DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten).
Bekannte Lücken in diesem Stand:
- - Statistik-Seite (vStat/ST) zeigt weiterhin Beispielzahlen, keine echte
- Auswertung aus TRIPS/FILLS - eigenes Arbeitspaket.
- Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits
vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als
gebrochene Bilder, bis bilder/ tatsächlich befüllt ist.
diff --git a/homeassistant/www/dm360-qr.html b/homeassistant/www/dm360-qr.html
new file mode 100644
index 0000000..d1189b6
--- /dev/null
+++ b/homeassistant/www/dm360-qr.html
@@ -0,0 +1,115 @@
+
+
+
+
+
+ DataMetric360 — Gerät verbinden
+
+
+
+
+
+
+
Gerät verbinden
+
+ Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein.
+ Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/homeassistant/www/dm360-qrcode-lib.js b/homeassistant/www/dm360-qrcode-lib.js
new file mode 100644
index 0000000..df13f82
--- /dev/null
+++ b/homeassistant/www/dm360-qrcode-lib.js
@@ -0,0 +1,2297 @@
+//---------------------------------------------------------------------
+//
+// QR Code Generator for JavaScript
+//
+// Copyright (c) 2009 Kazuhiko Arase
+//
+// URL: http://www.d-project.com/
+//
+// Licensed under the MIT license:
+// http://www.opensource.org/licenses/mit-license.php
+//
+// The word 'QR Code' is registered trademark of
+// DENSO WAVE INCORPORATED
+// http://www.denso-wave.com/qrcode/faqpatent-e.html
+//
+//---------------------------------------------------------------------
+
+var qrcode = function() {
+
+ //---------------------------------------------------------------------
+ // qrcode
+ //---------------------------------------------------------------------
+
+ /**
+ * qrcode
+ * @param typeNumber 1 to 40
+ * @param errorCorrectionLevel 'L','M','Q','H'
+ */
+ var qrcode = function(typeNumber, errorCorrectionLevel) {
+
+ var PAD0 = 0xEC;
+ var PAD1 = 0x11;
+
+ var _typeNumber = typeNumber;
+ var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel];
+ var _modules = null;
+ var _moduleCount = 0;
+ var _dataCache = null;
+ var _dataList = [];
+
+ var _this = {};
+
+ var makeImpl = function(test, maskPattern) {
+
+ _moduleCount = _typeNumber * 4 + 17;
+ _modules = function(moduleCount) {
+ var modules = new Array(moduleCount);
+ for (var row = 0; row < moduleCount; row += 1) {
+ modules[row] = new Array(moduleCount);
+ for (var col = 0; col < moduleCount; col += 1) {
+ modules[row][col] = null;
+ }
+ }
+ return modules;
+ }(_moduleCount);
+
+ setupPositionProbePattern(0, 0);
+ setupPositionProbePattern(_moduleCount - 7, 0);
+ setupPositionProbePattern(0, _moduleCount - 7);
+ setupPositionAdjustPattern();
+ setupTimingPattern();
+ setupTypeInfo(test, maskPattern);
+
+ if (_typeNumber >= 7) {
+ setupTypeNumber(test);
+ }
+
+ if (_dataCache == null) {
+ _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList);
+ }
+
+ mapData(_dataCache, maskPattern);
+ };
+
+ var setupPositionProbePattern = function(row, col) {
+
+ for (var r = -1; r <= 7; r += 1) {
+
+ if (row + r <= -1 || _moduleCount <= row + r) continue;
+
+ for (var c = -1; c <= 7; c += 1) {
+
+ if (col + c <= -1 || _moduleCount <= col + c) continue;
+
+ if ( (0 <= r && r <= 6 && (c == 0 || c == 6) )
+ || (0 <= c && c <= 6 && (r == 0 || r == 6) )
+ || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) {
+ _modules[row + r][col + c] = true;
+ } else {
+ _modules[row + r][col + c] = false;
+ }
+ }
+ }
+ };
+
+ var getBestMaskPattern = function() {
+
+ var minLostPoint = 0;
+ var pattern = 0;
+
+ for (var i = 0; i < 8; i += 1) {
+
+ makeImpl(true, i);
+
+ var lostPoint = QRUtil.getLostPoint(_this);
+
+ if (i == 0 || minLostPoint > lostPoint) {
+ minLostPoint = lostPoint;
+ pattern = i;
+ }
+ }
+
+ return pattern;
+ };
+
+ var setupTimingPattern = function() {
+
+ for (var r = 8; r < _moduleCount - 8; r += 1) {
+ if (_modules[r][6] != null) {
+ continue;
+ }
+ _modules[r][6] = (r % 2 == 0);
+ }
+
+ for (var c = 8; c < _moduleCount - 8; c += 1) {
+ if (_modules[6][c] != null) {
+ continue;
+ }
+ _modules[6][c] = (c % 2 == 0);
+ }
+ };
+
+ var setupPositionAdjustPattern = function() {
+
+ var pos = QRUtil.getPatternPosition(_typeNumber);
+
+ for (var i = 0; i < pos.length; i += 1) {
+
+ for (var j = 0; j < pos.length; j += 1) {
+
+ var row = pos[i];
+ var col = pos[j];
+
+ if (_modules[row][col] != null) {
+ continue;
+ }
+
+ for (var r = -2; r <= 2; r += 1) {
+
+ for (var c = -2; c <= 2; c += 1) {
+
+ if (r == -2 || r == 2 || c == -2 || c == 2
+ || (r == 0 && c == 0) ) {
+ _modules[row + r][col + c] = true;
+ } else {
+ _modules[row + r][col + c] = false;
+ }
+ }
+ }
+ }
+ }
+ };
+
+ var setupTypeNumber = function(test) {
+
+ var bits = QRUtil.getBCHTypeNumber(_typeNumber);
+
+ for (var i = 0; i < 18; i += 1) {
+ var mod = (!test && ( (bits >> i) & 1) == 1);
+ _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod;
+ }
+
+ for (var i = 0; i < 18; i += 1) {
+ var mod = (!test && ( (bits >> i) & 1) == 1);
+ _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod;
+ }
+ };
+
+ var setupTypeInfo = function(test, maskPattern) {
+
+ var data = (_errorCorrectionLevel << 3) | maskPattern;
+ var bits = QRUtil.getBCHTypeInfo(data);
+
+ // vertical
+ for (var i = 0; i < 15; i += 1) {
+
+ var mod = (!test && ( (bits >> i) & 1) == 1);
+
+ if (i < 6) {
+ _modules[i][8] = mod;
+ } else if (i < 8) {
+ _modules[i + 1][8] = mod;
+ } else {
+ _modules[_moduleCount - 15 + i][8] = mod;
+ }
+ }
+
+ // horizontal
+ for (var i = 0; i < 15; i += 1) {
+
+ var mod = (!test && ( (bits >> i) & 1) == 1);
+
+ if (i < 8) {
+ _modules[8][_moduleCount - i - 1] = mod;
+ } else if (i < 9) {
+ _modules[8][15 - i - 1 + 1] = mod;
+ } else {
+ _modules[8][15 - i - 1] = mod;
+ }
+ }
+
+ // fixed module
+ _modules[_moduleCount - 8][8] = (!test);
+ };
+
+ var mapData = function(data, maskPattern) {
+
+ var inc = -1;
+ var row = _moduleCount - 1;
+ var bitIndex = 7;
+ var byteIndex = 0;
+ var maskFunc = QRUtil.getMaskFunction(maskPattern);
+
+ for (var col = _moduleCount - 1; col > 0; col -= 2) {
+
+ if (col == 6) col -= 1;
+
+ while (true) {
+
+ for (var c = 0; c < 2; c += 1) {
+
+ if (_modules[row][col - c] == null) {
+
+ var dark = false;
+
+ if (byteIndex < data.length) {
+ dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1);
+ }
+
+ var mask = maskFunc(row, col - c);
+
+ if (mask) {
+ dark = !dark;
+ }
+
+ _modules[row][col - c] = dark;
+ bitIndex -= 1;
+
+ if (bitIndex == -1) {
+ byteIndex += 1;
+ bitIndex = 7;
+ }
+ }
+ }
+
+ row += inc;
+
+ if (row < 0 || _moduleCount <= row) {
+ row -= inc;
+ inc = -inc;
+ break;
+ }
+ }
+ }
+ };
+
+ var createBytes = function(buffer, rsBlocks) {
+
+ var offset = 0;
+
+ var maxDcCount = 0;
+ var maxEcCount = 0;
+
+ var dcdata = new Array(rsBlocks.length);
+ var ecdata = new Array(rsBlocks.length);
+
+ for (var r = 0; r < rsBlocks.length; r += 1) {
+
+ var dcCount = rsBlocks[r].dataCount;
+ var ecCount = rsBlocks[r].totalCount - dcCount;
+
+ maxDcCount = Math.max(maxDcCount, dcCount);
+ maxEcCount = Math.max(maxEcCount, ecCount);
+
+ dcdata[r] = new Array(dcCount);
+
+ for (var i = 0; i < dcdata[r].length; i += 1) {
+ dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset];
+ }
+ offset += dcCount;
+
+ var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount);
+ var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1);
+
+ var modPoly = rawPoly.mod(rsPoly);
+ ecdata[r] = new Array(rsPoly.getLength() - 1);
+ for (var i = 0; i < ecdata[r].length; i += 1) {
+ var modIndex = i + modPoly.getLength() - ecdata[r].length;
+ ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0;
+ }
+ }
+
+ var totalCodeCount = 0;
+ for (var i = 0; i < rsBlocks.length; i += 1) {
+ totalCodeCount += rsBlocks[i].totalCount;
+ }
+
+ var data = new Array(totalCodeCount);
+ var index = 0;
+
+ for (var i = 0; i < maxDcCount; i += 1) {
+ for (var r = 0; r < rsBlocks.length; r += 1) {
+ if (i < dcdata[r].length) {
+ data[index] = dcdata[r][i];
+ index += 1;
+ }
+ }
+ }
+
+ for (var i = 0; i < maxEcCount; i += 1) {
+ for (var r = 0; r < rsBlocks.length; r += 1) {
+ if (i < ecdata[r].length) {
+ data[index] = ecdata[r][i];
+ index += 1;
+ }
+ }
+ }
+
+ return data;
+ };
+
+ var createData = function(typeNumber, errorCorrectionLevel, dataList) {
+
+ var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel);
+
+ var buffer = qrBitBuffer();
+
+ for (var i = 0; i < dataList.length; i += 1) {
+ var data = dataList[i];
+ buffer.put(data.getMode(), 4);
+ buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
+ data.write(buffer);
+ }
+
+ // calc num max data.
+ var totalDataCount = 0;
+ for (var i = 0; i < rsBlocks.length; i += 1) {
+ totalDataCount += rsBlocks[i].dataCount;
+ }
+
+ if (buffer.getLengthInBits() > totalDataCount * 8) {
+ throw 'code length overflow. ('
+ + buffer.getLengthInBits()
+ + '>'
+ + totalDataCount * 8
+ + ')';
+ }
+
+ // end code
+ if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) {
+ buffer.put(0, 4);
+ }
+
+ // padding
+ while (buffer.getLengthInBits() % 8 != 0) {
+ buffer.putBit(false);
+ }
+
+ // padding
+ while (true) {
+
+ if (buffer.getLengthInBits() >= totalDataCount * 8) {
+ break;
+ }
+ buffer.put(PAD0, 8);
+
+ if (buffer.getLengthInBits() >= totalDataCount * 8) {
+ break;
+ }
+ buffer.put(PAD1, 8);
+ }
+
+ return createBytes(buffer, rsBlocks);
+ };
+
+ _this.addData = function(data, mode) {
+
+ mode = mode || 'Byte';
+
+ var newData = null;
+
+ switch(mode) {
+ case 'Numeric' :
+ newData = qrNumber(data);
+ break;
+ case 'Alphanumeric' :
+ newData = qrAlphaNum(data);
+ break;
+ case 'Byte' :
+ newData = qr8BitByte(data);
+ break;
+ case 'Kanji' :
+ newData = qrKanji(data);
+ break;
+ default :
+ throw 'mode:' + mode;
+ }
+
+ _dataList.push(newData);
+ _dataCache = null;
+ };
+
+ _this.isDark = function(row, col) {
+ if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) {
+ throw row + ',' + col;
+ }
+ return _modules[row][col];
+ };
+
+ _this.getModuleCount = function() {
+ return _moduleCount;
+ };
+
+ _this.make = function() {
+ if (_typeNumber < 1) {
+ var typeNumber = 1;
+
+ for (; typeNumber < 40; typeNumber++) {
+ var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel);
+ var buffer = qrBitBuffer();
+
+ for (var i = 0; i < _dataList.length; i++) {
+ var data = _dataList[i];
+ buffer.put(data.getMode(), 4);
+ buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
+ data.write(buffer);
+ }
+
+ var totalDataCount = 0;
+ for (var i = 0; i < rsBlocks.length; i++) {
+ totalDataCount += rsBlocks[i].dataCount;
+ }
+
+ if (buffer.getLengthInBits() <= totalDataCount * 8) {
+ break;
+ }
+ }
+
+ _typeNumber = typeNumber;
+ }
+
+ makeImpl(false, getBestMaskPattern() );
+ };
+
+ _this.createTableTag = function(cellSize, margin) {
+
+ cellSize = cellSize || 2;
+ margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+ var qrHtml = '';
+
+ qrHtml += '
';
+ qrHtml += '';
+
+ for (var r = 0; r < _this.getModuleCount(); r += 1) {
+
+ qrHtml += '
';
+
+ for (var c = 0; c < _this.getModuleCount(); c += 1) {
+ qrHtml += '