From e1d570992eca8d6b41453aa586c78b57d2c214ef Mon Sep 17 00:00:00 2001 From: Tobi G Date: Tue, 11 Aug 2026 00:10:53 +0200 Subject: [PATCH] Initialer Import: HA-Panel, Design-System, Companion-App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei zusammengehörige Teile in einem Repository: - homeassistant/ Das fertige, im Einsatz befindliche Home-Assistant-Panel (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl, Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage fahrzeugprofil.example.json ist mit dabei. - design-system/ Eigenständige React-Komponentenbibliothek (@audi-dash/ui), die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude Design. War bis hierher ein eigenes Repository und ist in dieses eingeschmolzen worden. - companion-app/ Datenschicht der neuen App DataMetric360 (iOS/Android via Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche: REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur innerhalb des HA-Frontends existiert. Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels), COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App), AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche Bauauftrag. Co-Authored-By: Claude Opus 5 --- .gitignore | 20 + AUDIT_2026-08-10.md | 116 + COMPANION_APP_ARCHITECTURE.md | 264 ++ DESIGN_BRIEF_DATAMETRIC360.md | 86 + SPECIFICATION.md | 261 ++ bauauftrag.html | 649 ++++ bauauftrag.md | 449 +++ companion-app/.gitignore | 4 + companion-app/README.md | 81 + companion-app/package-lock.json | 47 + companion-app/package.json | 15 + companion-app/scripts/smoke.ts | 79 + companion-app/src/api/index.ts | 103 + companion-app/src/api/live.ts | 167 + companion-app/src/api/rest.ts | 137 + companion-app/src/api/types.ts | 136 + companion-app/src/api/umgebung.ts | 141 + companion-app/src/api/warteschlange.ts | 163 + companion-app/tsconfig.json | 20 + dashboard-muster-ohne-bilder.html | 1945 ++++++++++ dashboard-muster.html | 1945 ++++++++++ design-system/.design-sync/NOTES.md | 24 + design-system/.design-sync/config.json | 15 + design-system/.design-sync/conventions.md | 7 + .../.design-sync/previews/Accordion.tsx | 53 + .../.design-sync/previews/ActionButton.tsx | 45 + design-system/.design-sync/previews/Feld.tsx | 46 + design-system/.design-sync/previews/Fig.tsx | 42 + .../.design-sync/previews/IconButton.tsx | 53 + .../previews/ImagePlaceholder.tsx | 34 + .../.design-sync/previews/LeafRow.tsx | 32 + design-system/.design-sync/previews/Pill.tsx | 45 + design-system/.design-sync/previews/Popup.tsx | 35 + .../.design-sync/previews/PopupMenuItem.tsx | 40 + .../.design-sync/previews/ProgressBar.tsx | 46 + .../.design-sync/previews/ProgressRing.tsx | 42 + .../.design-sync/previews/RowList.tsx | 41 + design-system/.design-sync/previews/Seg.tsx | 43 + .../.design-sync/previews/StatGrid.tsx | 43 + .../.design-sync/previews/StatusRow.tsx | 32 + .../.design-sync/previews/SwipeRow.tsx | 34 + .../.design-sync/previews/Switch.tsx | 36 + .../.design-sync/previews/TabBar.tsx | 61 + design-system/.design-sync/previews/Tile.tsx | 45 + design-system/.gitignore | 8 + design-system/README.md | 66 + design-system/package-lock.json | 1632 +++++++++ design-system/package.json | 44 + design-system/scripts/smoke.mjs | 71 + .../src/components/Accordion/Accordion.css | 55 + .../src/components/Accordion/Accordion.tsx | 50 + .../src/components/Accordion/LeafRow.css | 50 + .../src/components/Accordion/LeafRow.tsx | 31 + .../src/components/Accordion/index.ts | 4 + .../components/ActionButton/ActionButton.css | 34 + .../components/ActionButton/ActionButton.tsx | 28 + .../src/components/ActionButton/index.ts | 2 + design-system/src/components/Feld/Feld.css | 46 + design-system/src/components/Feld/Feld.tsx | 40 + design-system/src/components/Feld/index.ts | 2 + design-system/src/components/Fig/Fig.css | 14 + design-system/src/components/Fig/Fig.tsx | 22 + design-system/src/components/Fig/index.ts | 2 + .../src/components/IconButton/IconButton.css | 28 + .../src/components/IconButton/IconButton.tsx | 33 + .../src/components/IconButton/index.ts | 2 + .../ImagePlaceholder/ImagePlaceholder.css | 58 + .../ImagePlaceholder/ImagePlaceholder.tsx | 58 + .../src/components/ImagePlaceholder/index.ts | 2 + design-system/src/components/Pill/Pill.css | 16 + design-system/src/components/Pill/Pill.tsx | 15 + design-system/src/components/Pill/index.ts | 2 + design-system/src/components/Popup/Popup.css | 42 + design-system/src/components/Popup/Popup.tsx | 71 + design-system/src/components/Popup/index.ts | 2 + .../components/ProgressBar/ProgressBar.css | 48 + .../components/ProgressBar/ProgressBar.tsx | 58 + .../src/components/ProgressBar/index.ts | 2 + .../components/ProgressRing/ProgressRing.css | 32 + .../components/ProgressRing/ProgressRing.tsx | 50 + .../src/components/ProgressRing/index.ts | 2 + .../src/components/RowList/RowList.css | 34 + .../src/components/RowList/RowList.tsx | 31 + design-system/src/components/RowList/index.ts | 2 + design-system/src/components/Seg/Seg.css | 23 + design-system/src/components/Seg/Seg.tsx | 37 + design-system/src/components/Seg/index.ts | 2 + .../src/components/StatGrid/StatGrid.css | 17 + .../src/components/StatGrid/StatGrid.tsx | 29 + .../src/components/StatGrid/index.ts | 2 + .../src/components/StatusRow/StatusRow.css | 42 + .../src/components/StatusRow/StatusRow.tsx | 28 + .../src/components/StatusRow/index.ts | 2 + .../src/components/SwipeRow/SwipeRow.css | 25 + .../src/components/SwipeRow/SwipeRow.tsx | 95 + .../src/components/SwipeRow/index.ts | 2 + .../src/components/Switch/Switch.css | 51 + .../src/components/Switch/Switch.tsx | 36 + design-system/src/components/Switch/index.ts | 2 + .../src/components/TabBar/TabBar.css | 40 + .../src/components/TabBar/TabBar.tsx | 40 + design-system/src/components/TabBar/index.ts | 2 + design-system/src/components/Tile/Tile.css | 39 + design-system/src/components/Tile/Tile.tsx | 33 + design-system/src/components/Tile/index.ts | 2 + design-system/src/index.ts | 53 + design-system/src/styles.css | 22 + design-system/src/tokens/tokens.css | 98 + design-system/tsconfig.json | 16 + design-system/tsup.config.ts | 10 + homeassistant/.gitignore | 8 + homeassistant/INSTALL.md | 274 ++ homeassistant/README.md | 158 + homeassistant/configuration_snippet.yaml | 38 + .../data/fahrzeugprofil.example.json | 185 + homeassistant/data/shell_beleg_parser.py | 216 ++ .../data/tests/test_shell_beleg_parser.py | 133 + homeassistant/pyscript/backup.py | 92 + homeassistant/pyscript/batterieverlauf.py | 62 + homeassistant/pyscript/belegverarbeitung.py | 269 ++ homeassistant/pyscript/bilderverwaltung.py | 71 + homeassistant/pyscript/fahrtabschluss.py | 27 + homeassistant/pyscript/fahrterkennung.py | 166 + homeassistant/pyscript/frontend_api.py | 75 + .../pyscript/modules/einstellungen.py | 109 + .../pyscript/modules/fahrtabschluss_logik.py | 187 + .../modules/frontend_veroeffentlichung.py | 152 + homeassistant/pyscript/modules/profil.py | 261 ++ homeassistant/pyscript/reifenzaehler.py | 101 + homeassistant/pyscript/tankerkennung.py | 116 + homeassistant/pyscript/updateverwaltung.py | 184 + homeassistant/update.ps1 | 67 + homeassistant/www/audi-dashboard-app.js | 3130 +++++++++++++++++ homeassistant/www/audi-dashboard-panel.js | 47 + homeassistant/www/audi-dashboard-version.json | 1 + homeassistant/www/audi-dashboard.css | 752 ++++ homeassistant/www/badges/rs3-negative.svg | 1 + homeassistant/www/badges/rs3-positive.svg | 1 + homeassistant/www/badges/rs4-negative.svg | 1 + homeassistant/www/badges/rs4-positive.svg | 1 + homeassistant/www/badges/rs5-negative.svg | 1 + homeassistant/www/badges/rs5-positive.svg | 1 + homeassistant/www/badges/rs6-negative.svg | 1 + homeassistant/www/badges/rs6-positive.svg | 1 + homeassistant/www/badges/rs7-negative.svg | 1 + homeassistant/www/badges/rs7-positive.svg | 1 + homeassistant/www/badges/s3-negative.svg | 1 + homeassistant/www/badges/s3-positive.svg | 1 + homeassistant/www/badges/sq7-negative.svg | 1 + homeassistant/www/badges/sq7-positive.svg | 1 + homeassistant/www/bilder/shell-logo.svg | 8 + 151 files changed, 18162 insertions(+) create mode 100644 .gitignore create mode 100644 AUDIT_2026-08-10.md create mode 100644 COMPANION_APP_ARCHITECTURE.md create mode 100644 DESIGN_BRIEF_DATAMETRIC360.md create mode 100644 SPECIFICATION.md create mode 100644 bauauftrag.html create mode 100644 bauauftrag.md create mode 100644 companion-app/.gitignore create mode 100644 companion-app/README.md create mode 100644 companion-app/package-lock.json create mode 100644 companion-app/package.json create mode 100644 companion-app/scripts/smoke.ts create mode 100644 companion-app/src/api/index.ts create mode 100644 companion-app/src/api/live.ts create mode 100644 companion-app/src/api/rest.ts create mode 100644 companion-app/src/api/types.ts create mode 100644 companion-app/src/api/umgebung.ts create mode 100644 companion-app/src/api/warteschlange.ts create mode 100644 companion-app/tsconfig.json create mode 100644 dashboard-muster-ohne-bilder.html create mode 100644 dashboard-muster.html create mode 100644 design-system/.design-sync/NOTES.md create mode 100644 design-system/.design-sync/config.json create mode 100644 design-system/.design-sync/conventions.md create mode 100644 design-system/.design-sync/previews/Accordion.tsx create mode 100644 design-system/.design-sync/previews/ActionButton.tsx create mode 100644 design-system/.design-sync/previews/Feld.tsx create mode 100644 design-system/.design-sync/previews/Fig.tsx create mode 100644 design-system/.design-sync/previews/IconButton.tsx create mode 100644 design-system/.design-sync/previews/ImagePlaceholder.tsx create mode 100644 design-system/.design-sync/previews/LeafRow.tsx create mode 100644 design-system/.design-sync/previews/Pill.tsx create mode 100644 design-system/.design-sync/previews/Popup.tsx create mode 100644 design-system/.design-sync/previews/PopupMenuItem.tsx create mode 100644 design-system/.design-sync/previews/ProgressBar.tsx create mode 100644 design-system/.design-sync/previews/ProgressRing.tsx create mode 100644 design-system/.design-sync/previews/RowList.tsx create mode 100644 design-system/.design-sync/previews/Seg.tsx create mode 100644 design-system/.design-sync/previews/StatGrid.tsx create mode 100644 design-system/.design-sync/previews/StatusRow.tsx create mode 100644 design-system/.design-sync/previews/SwipeRow.tsx create mode 100644 design-system/.design-sync/previews/Switch.tsx create mode 100644 design-system/.design-sync/previews/TabBar.tsx create mode 100644 design-system/.design-sync/previews/Tile.tsx create mode 100644 design-system/.gitignore create mode 100644 design-system/README.md create mode 100644 design-system/package-lock.json create mode 100644 design-system/package.json create mode 100644 design-system/scripts/smoke.mjs create mode 100644 design-system/src/components/Accordion/Accordion.css create mode 100644 design-system/src/components/Accordion/Accordion.tsx create mode 100644 design-system/src/components/Accordion/LeafRow.css create mode 100644 design-system/src/components/Accordion/LeafRow.tsx create mode 100644 design-system/src/components/Accordion/index.ts create mode 100644 design-system/src/components/ActionButton/ActionButton.css create mode 100644 design-system/src/components/ActionButton/ActionButton.tsx create mode 100644 design-system/src/components/ActionButton/index.ts create mode 100644 design-system/src/components/Feld/Feld.css create mode 100644 design-system/src/components/Feld/Feld.tsx create mode 100644 design-system/src/components/Feld/index.ts create mode 100644 design-system/src/components/Fig/Fig.css create mode 100644 design-system/src/components/Fig/Fig.tsx create mode 100644 design-system/src/components/Fig/index.ts create mode 100644 design-system/src/components/IconButton/IconButton.css create mode 100644 design-system/src/components/IconButton/IconButton.tsx create mode 100644 design-system/src/components/IconButton/index.ts create mode 100644 design-system/src/components/ImagePlaceholder/ImagePlaceholder.css create mode 100644 design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx create mode 100644 design-system/src/components/ImagePlaceholder/index.ts create mode 100644 design-system/src/components/Pill/Pill.css create mode 100644 design-system/src/components/Pill/Pill.tsx create mode 100644 design-system/src/components/Pill/index.ts create mode 100644 design-system/src/components/Popup/Popup.css create mode 100644 design-system/src/components/Popup/Popup.tsx create mode 100644 design-system/src/components/Popup/index.ts create mode 100644 design-system/src/components/ProgressBar/ProgressBar.css create mode 100644 design-system/src/components/ProgressBar/ProgressBar.tsx create mode 100644 design-system/src/components/ProgressBar/index.ts create mode 100644 design-system/src/components/ProgressRing/ProgressRing.css create mode 100644 design-system/src/components/ProgressRing/ProgressRing.tsx create mode 100644 design-system/src/components/ProgressRing/index.ts create mode 100644 design-system/src/components/RowList/RowList.css create mode 100644 design-system/src/components/RowList/RowList.tsx create mode 100644 design-system/src/components/RowList/index.ts create mode 100644 design-system/src/components/Seg/Seg.css create mode 100644 design-system/src/components/Seg/Seg.tsx create mode 100644 design-system/src/components/Seg/index.ts create mode 100644 design-system/src/components/StatGrid/StatGrid.css create mode 100644 design-system/src/components/StatGrid/StatGrid.tsx create mode 100644 design-system/src/components/StatGrid/index.ts create mode 100644 design-system/src/components/StatusRow/StatusRow.css create mode 100644 design-system/src/components/StatusRow/StatusRow.tsx create mode 100644 design-system/src/components/StatusRow/index.ts create mode 100644 design-system/src/components/SwipeRow/SwipeRow.css create mode 100644 design-system/src/components/SwipeRow/SwipeRow.tsx create mode 100644 design-system/src/components/SwipeRow/index.ts create mode 100644 design-system/src/components/Switch/Switch.css create mode 100644 design-system/src/components/Switch/Switch.tsx create mode 100644 design-system/src/components/Switch/index.ts create mode 100644 design-system/src/components/TabBar/TabBar.css create mode 100644 design-system/src/components/TabBar/TabBar.tsx create mode 100644 design-system/src/components/TabBar/index.ts create mode 100644 design-system/src/components/Tile/Tile.css create mode 100644 design-system/src/components/Tile/Tile.tsx create mode 100644 design-system/src/components/Tile/index.ts create mode 100644 design-system/src/index.ts create mode 100644 design-system/src/styles.css create mode 100644 design-system/src/tokens/tokens.css create mode 100644 design-system/tsconfig.json create mode 100644 design-system/tsup.config.ts create mode 100644 homeassistant/.gitignore create mode 100644 homeassistant/INSTALL.md create mode 100644 homeassistant/README.md create mode 100644 homeassistant/configuration_snippet.yaml create mode 100644 homeassistant/data/fahrzeugprofil.example.json create mode 100644 homeassistant/data/shell_beleg_parser.py create mode 100644 homeassistant/data/tests/test_shell_beleg_parser.py create mode 100644 homeassistant/pyscript/backup.py create mode 100644 homeassistant/pyscript/batterieverlauf.py create mode 100644 homeassistant/pyscript/belegverarbeitung.py create mode 100644 homeassistant/pyscript/bilderverwaltung.py create mode 100644 homeassistant/pyscript/fahrtabschluss.py create mode 100644 homeassistant/pyscript/fahrterkennung.py create mode 100644 homeassistant/pyscript/frontend_api.py create mode 100644 homeassistant/pyscript/modules/einstellungen.py create mode 100644 homeassistant/pyscript/modules/fahrtabschluss_logik.py create mode 100644 homeassistant/pyscript/modules/frontend_veroeffentlichung.py create mode 100644 homeassistant/pyscript/modules/profil.py create mode 100644 homeassistant/pyscript/reifenzaehler.py create mode 100644 homeassistant/pyscript/tankerkennung.py create mode 100644 homeassistant/pyscript/updateverwaltung.py create mode 100644 homeassistant/update.ps1 create mode 100644 homeassistant/www/audi-dashboard-app.js create mode 100644 homeassistant/www/audi-dashboard-panel.js create mode 100644 homeassistant/www/audi-dashboard-version.json create mode 100644 homeassistant/www/audi-dashboard.css create mode 100644 homeassistant/www/badges/rs3-negative.svg create mode 100644 homeassistant/www/badges/rs3-positive.svg create mode 100644 homeassistant/www/badges/rs4-negative.svg create mode 100644 homeassistant/www/badges/rs4-positive.svg create mode 100644 homeassistant/www/badges/rs5-negative.svg create mode 100644 homeassistant/www/badges/rs5-positive.svg create mode 100644 homeassistant/www/badges/rs6-negative.svg create mode 100644 homeassistant/www/badges/rs6-positive.svg create mode 100644 homeassistant/www/badges/rs7-negative.svg create mode 100644 homeassistant/www/badges/rs7-positive.svg create mode 100644 homeassistant/www/badges/s3-negative.svg create mode 100644 homeassistant/www/badges/s3-positive.svg create mode 100644 homeassistant/www/badges/sq7-negative.svg create mode 100644 homeassistant/www/badges/sq7-positive.svg create mode 100644 homeassistant/www/bilder/shell-logo.svg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db92b3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Abhängigkeiten und Build-Ergebnisse werden zusätzlich von den .gitignore- +# Dateien in den Unterordnern abgedeckt (companion-app/, design-system/, +# homeassistant/) - die gelten hier weiterhin, auch wenn design-system/ kein +# eigenes Repository mehr ist. +node_modules/ +dist/ + +# Lokale Sicherungskopien vor größeren Umbauten - reine Arbeitsstände. +*.bak-before-audit-merge +*.bak + +# Betriebssystem- und Editor-Kram +.DS_Store +Thumbs.db +*.swp +.idea/ +.vscode/ + +# Protokolle +*.log diff --git a/AUDIT_2026-08-10.md b/AUDIT_2026-08-10.md new file mode 100644 index 0000000..fb38238 --- /dev/null +++ b/AUDIT_2026-08-10.md @@ -0,0 +1,116 @@ +# Audi Dashboard — Full Audit (Code · UI/UX · Cross-Platform) + +**Date:** 2026-08-10 +**Scope:** `homeassistant/www/audi-dashboard-app.js` (3130 lines), `audi-dashboard.css` (715 lines), `audi-dashboard-panel.js`, as currently deployed (post Design-Audit-merge, post scroll-bug fix). +**Method:** static review (Grep/Read across the full source), computed WCAG contrast checks, live verification of prior findings in the `audi_ha_test` Docker container. Not a full manual screen-reader pass (no VoiceOver/TalkBack device in this environment) — flagged explicitly below where that matters. + +--- + +## 1. Platform verdict — can it run on iPhone, Android, Home Assistant? + +| Environment | Verdict | Why | +|---|---|---| +| **Home Assistant** (browser, sidebar panel) | ✅ Works — this is its native environment | Built as a `panel_custom` custom element; `hass` is injected directly by HA's frontend. | +| **iPhone / Android, via the official Home Assistant Companion App** | ✅ Works today, no changes needed | The Companion App is a WebView wrapper around the exact same HA frontend — it opens this panel the identical way a desktop browser does, `hass` injection included. The app is already tuned for this (safe-area insets, iOS-style pull-to-refresh, 44×44px tap targets, `prefers-reduced-motion` support). This is very likely how "iPhone" was meant in the original brief (§4.2 already assumes the Companion App). | +| **iPhone / Android, as a standalone app (Capacitor-wrapped or plain browser tab pointed at a public URL)** | ❌ Does not work, and can't with this file as-is | The entire data layer is `hass.states[...]` / `hass.callService(...)`. There is no REST call, no WebSocket client, no token/auth flow, and no `Capacitor.*` usage anywhere in the file — outside of an HA frontend context, `hass` is simply `undefined` and the app never renders past the loading tile. This is exactly the gap the separately-discussed companion-app architecture (FMM003 → Traccar → HA → REST/WebSocket API client) is meant to fill; that work is a different, not-yet-built app, not a mode of this file. | +| **Generic iframe embed** (e.g. in a non-HA page) | ❌ Same reason | Needs `hass` injected the `panel_custom` way, not a generic postMessage/REST contract. | + +**One-line answer:** yes on Home Assistant and yes on iPhone/Android *through the HA Companion App*; no as an independent native/standalone app — that requires the separate REST/WebSocket-backed companion app already discussed, not a change to this file. + +--- + +## 2. Findings + +Severity: 🔴 real bug/breakage · 🟠 should fix · 🟡 minor/polish + +### ✅ FIXED — 🔴 iOS Safari/WKWebView auto-zoom on form inputs +**Where:** `audi-dashboard.css:462` — `.feld input, .feld select, .notiz { font-size: 15px; }` +**Issue:** iOS Safari (and the Companion App's WKWebView) auto-zooms the viewport when a focused input's computed font-size is under 16px. Every text/number field in the app (trip forms, fill-up forms, settings, service-book entries) is 15px — so tapping any field on an iPhone zooms the page in, and the user has to manually zoom back out after every field. +**Fix:** raise to `font-size: 16px` (or `max(16px, …)` if the 15px look matters, add a `transform: scale(0.9375)` trick — not recommended, adds complexity for no real benefit). Simplest: just bump to 16px; the visual difference is negligible at this size. + +### ❌ RETRACTED (false positive) — Dark-theme label/eyebrow contrast +**Originally claimed:** `--fg3: #657081` on `--canvas: #161b23` ≈ 3.45:1, failing WCAG AA. +**Why it was wrong:** that value came from `SPECIFICATION.md`'s §2 color table, not from the actual CSS. +The shipped value is **`--fg3: #8a94a3`** — the Design Audit's own finding **A2** already fixed exactly +this, and `audi-dashboard.css` documents it inline: *"A2: von #657081 (3,0:1 auf --tile) auf 4,6:1 +angehoben."* Recomputed against the real value: **5.6:1 on `--canvas`, 4.9:1 on `--tile`** — passes AA +on both surfaces. Nothing to fix in the CSS. +**The real defect this exposed:** `SPECIFICATION.md`'s color table was stale (documenting pre-merge +values) — the same doc-staleness class as the theming finding below. **Fixed:** the `--fg3` row in +`SPECIFICATION.md` §2 now carries the shipped value. +**Lesson for future audits of this project:** read `audi-dashboard.css` directly for token values; +`SPECIFICATION.md` predates the Design-Audit merge in several places. + +### ✅ FIXED — 🟠 No visible keyboard-focus indicator outside form fields +**Where:** `audi-dashboard.css:472` is the *only* `:focus` rule in the file, and it's scoped to `input/select/.notiz`. +**Issue:** every button, tile, tab, and swipe-row is focusable (native ``) *after* a horizontal Pointer Event drag reveals it. A screen-reader user (VoiceOver swipe-navigation, not a raw drag gesture) or anyone using switch control has no way to trigger that reveal, so trip/fill-up rows are **not deletable** for them at all. Service-book rows already use a plain always-visible delete button (per SPECIFICATION.md §3) — the same pattern doesn't exist for trips/fills. +**Fix:** smallest viable fix is a long-press or a small always-present "…" affordance per row that reveals the same action sheet already built for other destructive actions (`sheet`/`bestaetigen()`), rather than requiring the drag gesture as the only path. + +### 🟡 Popup dismiss-by-tapping-outside isn't keyboard-reachable +**Where:** `audi-dashboard-app.js:1460, 1477, 1576` — `.bildmenu-catcher` / overlay-close divs. +**Issue:** these are plain `
`s with click handlers, not buttons — fine for touch/mouse, invisible to keyboard/AT navigation. Low severity since these are popups tied to photo management, a small surface, and likely have another (or should have another) explicit close control — worth a quick manual check rather than assumed broken. + +### 🟡 Leaflet map requires public internet, independent of the Tailscale tunnel +**Where:** SPECIFICATION.md §3 confirms — Leaflet is loaded from a CDN at runtime, not bundled. +**Issue:** on a phone connected only via Tailscale with no other route to the public internet (e.g. cellular data off, only the VPN interface up), the trip-detail map silently fails to load tiles. Not a bug in the code as such, but worth knowing as a real-world failure mode on iPhone/Android specifically (desktop Tailscale setups usually have a normal internet route too). +**Fix (optional):** self-host the Leaflet JS/CSS bundle under `/local/`, keep tiles CDN-based (tiles are unavoidably remote) — removes the JS/CSS-loading failure mode at least. + +### ✅ FIXED — 🟡 Stale doc: SPECIFICATION.md says theme has "no persistence" +**Where:** `SPECIFICATION.md:136` — "**No persistence** — theme resets to 'nacht' on every full reload; no `localStorage` use was found." +**Issue:** this is now false — the Design-Audit merge added `localStorage`-backed theme persistence (`THEME_KEY`, `audi-dashboard-app.js:2157-2163`) and tab-label persistence (`SCHRIFT_KEY`, line 1377/2856). Not a code bug — just a doc that predates the merge and will mislead the next person/session reading it. +**Fix:** update SPECIFICATION.md §3 "Theming" to reflect the current `localStorage` behavior. + +### 🟡 Touch targets — no issue found (noted for completeness) +Checked `.themebtn`, `.profilbtn`, `.zahnrad` (all 44×44px, Apple HIG minimum) and the tab bar (icon + label, generous padding). This was clearly already addressed by the Design Audit — nothing to fix here. + +### 🟡 Icon-only buttons — no issue found (noted for completeness) +`#back`, `.profilbtn`, and every `.zahnrad` settings-gear button carry a correct, specific `aria-label`; decorative SVGs inside them are `aria-hidden`. Tab bar buttons pair the icon with a visible text ``. This is solid — better than average for a project this size. + +--- + +## 3. What was done (2026-08-10) + +**Applied and verified live in the `audi_ha_test` container** (CSS version `1786377886`): + +1. **Form-input font-size 15px → 16px** (`audi-dashboard.css`, `.feld input, .feld select, .notiz`). + Verified: all 14 inputs on the Settings/Einrichten form compute to `16px`, none below the iOS + auto-zoom threshold. +2. **Global `:focus-visible` rule added** — `outline: 2px solid var(--red); outline-offset: 2px;`. + Verified: the rule parses into the shadow-root stylesheet (confirmed via CSSOM, not just file + content), a focused button reports `matches(":focus-visible") === true`, and the computed outline + resolves to `solid 2px rgb(245, 5, 55)` at a 2px offset. Using `:focus-visible` rather than `:focus` + keeps mouse/touch interaction visually unchanged. + +Both carry inline German comments explaining *why*, matching the file's existing convention. + +**Verification caveat:** the browser screenshot tool failed throughout this session ("Browser pane is +not displayed"), so confirmation is structural (computed styles, CSSOM, live DOM) rather than visual. +The structural evidence is conclusive for both fixes, but nobody has actually *looked* at the focus +ring yet — worth a glance next time the pane cooperates. + +**Also fixed (documentation):** `SPECIFICATION.md`'s stale `--fg3` color-table value and its stale +"no persistence" theming paragraph — both now match the shipped code. + +## 4. Remaining / not done + +1. **Swipe-to-delete accessible fallback** (🟠) — the only real feature work in this audit, not a + one-liner. Left open deliberately: it needs a decision on *which* affordance (long-press, a + persistent "…" button per row, or routing through the existing Action Sheet). +2. **Popup close-by-tap-outside `
`s** (🟡) — needs a quick manual check before deciding whether + it's genuinely broken for keyboard/AT users. +3. **Self-hosting Leaflet** (🟡) — optional; removes a Tailscale-only-network failure mode. + +Note on how much further to invest here: `COMPANION_APP_ARCHITECTURE.md` records the decision that this +panel is eventually superseded by the responsive companion app. These three items may be better carried +into that app's component work than retrofitted into this file. + +## 5. Platform targets — unchanged + +No changes needed for the Home-Assistant or HA-Companion-App targets; both already work. The +"standalone iPhone/Android app" question is answered by `COMPANION_APP_ARCHITECTURE.md`, not by +anything fixable in this file. diff --git a/COMPANION_APP_ARCHITECTURE.md b/COMPANION_APP_ARCHITECTURE.md new file mode 100644 index 0000000..4bffa9d --- /dev/null +++ b/COMPANION_APP_ARCHITECTURE.md @@ -0,0 +1,264 @@ +# DataMetric360 — Companion App Architecture (design decided, not yet built) + +**App name: `DataMetric360`** (decided 2026-08-10). Public hostname: a subdomain of +**`datametric360.app`**, +a dedicated domain registered at all-inkl solely for this purpose (see §5.4 for why it must be a +separate domain). Both the app name and the domain are deliberately neutral — they don't advertise +"Audi", "car", or "GPS tracker" to anyone who sees the hostname or the app icon, which is a small but +free privacy win for a hostname that will be publicly resolvable. + + +**Status:** architecture decisions locked in during design discussion on 2026-08-10. **No code for this +exists yet** — this is a separate, not-yet-started project sitting alongside the finished HA panel +described in `SPECIFICATION.md`. Implementation is explicitly deferred until the FMM003 hardware is +installed and wired (owner's own framing: "Inbetriebnahme findet separat statt, wenn die HW vorrätig +ist"). This file exists so the decisions already made aren't lost before that point. + +**Relationship to the rest of the repo:** +- `homeassistant/` — the finished, in-use HA panel (`SPECIFICATION.md` is authoritative for it). Its + data source today is the HACS integration `TommiG1/HA_VAG-EU-Data-Act` plus the iPhone Companion App's + WLAN sensor for trip detection. +- `design-system/` — the React component library (no Audi brand assets) built to feed Claude Design. + Becomes directly useful here: it's the intended shared UI foundation for the app described below. +- **This file** — describes a new, third piece: a companion app that will eventually supersede the HA + panel's role, once built. + +--- + +## 1. Concept + +One responsive web app, built with the `design-system/` component library, shipped three ways: + +1. **Native iOS app** — via Capacitor. +2. **Native Android app** — via Capacitor. +3. **Embedded in Home Assistant** — as a plain `panel_iframe` (not `panel_custom`/`hass` injection), + so the same build just gets more screen space and reflows via CSS breakpoints instead of floating a + fixed phone-card layout in the middle of a bigger screen. + +Once this exists, it **supersedes** `homeassistant/www/audi-dashboard-app.js` — the current +panel_custom implementation is not meant to be maintained forever in parallel; it gets replaced, not +duplicated. That's an explicit, confirmed decision, not an assumption. **`audi-dashboard-app.js` gets +archived, not deleted**, once superseded — same safety-net pattern already used in this project +(`.bak-before-audit-merge` files). + +**Distribution: sideload only, no App Store / Play Store.** Confirmed decision. This matters beyond +convenience: the Audi Type font, the four-rings SVG, and the model-badge SVGs are licensed only for +this one private, non-published installation (`SPECIFICATION.md` §2, `bauauftrag.md` §8/§12) — exactly +why `design-system/` strips all of that out, since it gets uploaded to a third party (Claude Design). +A sideloaded app installed directly onto the owner's own two phones, never published to a store, stays +inside that same private-installation scope — so **the companion app can use the real Audi assets**, +matching the "same look and feel" decision already made, no generic-fallback treatment needed. + +**Frontend head start via `design-system/` + Claude Design:** the component-library sync gave Claude +Design the real building blocks (tokens, ~19 components) to design with — it has **not** yet designed +this app's actual screens (Home, Trips, Fuel, Settings, reworked around the REST/WebSocket data model +instead of `hass`). That's a future step, following the same pattern as the original Design-Audit task +this session started with: design in Claude Design using the real components, then implement the result +into actual Capacitor/React code. + +The iPhone stops being a data source entirely — it becomes purely a display device for this app, same +as Android. All vehicle data comes from the FMM003 (see §2), not from the iPhone Companion App. + +--- + +## 2. Data source: FMM003 replaces the iPhone + VAG integration + +**Confirmed, explicit decision** (retiring two existing data sources): + +- The iPhone Companion App's WLAN sensor (`sensor.iphone_wifi_connection`), used today for trip + start/end detection, is **fully retired**. No fallback to it. +- The HACS integration `TommiG1/HA_VAG-EU-Data-Act` is **fully retired** as a data source. +- **All vehicle data comes from the FMM003** (fully wired to the vehicle's CAN/FMS bus), via Codec8, + into Home Assistant as one combined, flexibly-mapped entity (no hardcoded field list — see the + no-hardcoding mapping system discussed separately, not yet written to a file). + +**Decided (2026-08-10, after web research — see rationale below): Traccar, minimal variant.** + +**Research question:** is there something more efficient than Traccar for decoding Codec8 from an +FMM003? Checked and ruled out: +- HA's official "Teltonika" integration — for Teltonika *routers* (RUT series: signal/modem/network + monitoring), unrelated to GPS/AVL trackers. Not applicable. +- A Teltonika-RMS-based HA integration exists (`derliebemarcus/teltonika_rms`) but connects through + Teltonika's own cloud (RMS) — same objection as the earlier Flespi rejection: vehicle/GPS data would + transit an external vendor cloud, which is exactly what this whole redesign was meant to avoid. +- OpenRemote (an alternative fleet platform some Teltonika docs point to) — a full IoT asset-management + platform with a rule engine and dashboards; heavier than Traccar, not lighter. +- Minimal standalone Codec8 parser libraries (e.g. `teltonika-go`, small Python GPS-server scripts) — + genuinely lighter in principle (no bundled platform/Tomcat), but sacrifice exactly what makes Traccar + the safer choice: even Traccar itself, mature and community-maintained for years, still has open + issues about firmware-specific Codec8 decoding quirks per device generation. A standalone parser + would hit the same class of edge cases with none of that accumulated fixing behind it. + +**Conclusion: no better alternative found — Traccar stays**, but in a genuinely minimal shape: + +- **Database: H2 (embedded, file-based), not MariaDB.** This was a wrong assumption in the first draft + of this document — Traccar's own upstream default database is H2, and the HA Community Add-on + (`hassio-addons/addon-traccar`) ships with H2 pre-configured (`jdbc:h2:/data/traccar`) out of the box. + MariaDB is only the add-on docs' recommendation for larger/multi-user production deployments, not a + hard requirement. For one vehicle, one device, one private user, H2 is the right call — **no MariaDB + add-on needed at all**, one fewer service to run and back up. + - Known trade-off (from community reports): occasional H2 corruption/data loss across Traccar version + upgrades. Mitigation: extend the existing daily `backup.py` routine to also copy Traccar's `.mv.db` + file alongside the other data files — same backup pattern already in place, no new mechanism. +- **Disable geofencing and reports/notifications** in Traccar's config — none of that applies to a + single always-known vehicle; keeps the running feature surface to just device ingestion + Position + Forwarding, which is all this project actually uses. +- **Position Forwarding → HA webhook** (as already planned) remains the integration path for CAN/FMS + attributes (fuel, RPM, etc.), since HA's own official `traccar_server` integration is + device_tracker/geofence-focused and wouldn't surface those extra AVL fields on its own. + +Hardware confirmed sufficient for this: Dell OptiPlex 3000 TC, Pentium N6005, 16 GB DDR4, 256 GB NVMe — +comfortably enough for Traccar alone, more so without a MariaDB add-on alongside it. + +### Trip detection (substitutes the old WLAN-based logic) + +- **Start / end trigger:** FMM003 ignition state (CAN), not WLAN connectivity. +- **Pause-tolerance behavior carries over unchanged from the existing design** — this was iterated + three times during design and is settled: + - Ignition never drops during engine start-stop (confirmed technical fact), so no separate hold-time + is needed to filter start-stop out — the `ignition: 1→0` condition already excludes it. + - The existing user-configurable pause-tolerance setting (`fahrten_pausenzeit_min`, today's default + 15 min, editable in Settings) is a real, intentional product feature and **must be preserved**: if + the vehicle restarts within that window after a genuine ignition-off, it's one trip, not two. + - Implementation pattern (unchanged from the current `fahrterkennung.py` approach, just re-triggered + off the FMM003 ignition entity instead of the WLAN sensor): `task.unique()` cancels a pending + `task.sleep()` if ignition comes back on within the pause window; the trip's recorded end + timestamp/odometer is backdated to the actual ignition-off moment, not the timer-expiry moment. +- `sensor.iphone_wifi_connection` drops out of `pyscript/modules/einstellungen.py` entirely once this + ships. + +--- + +## 3. Frontend data access: HA REST/WebSocket API, not `hass` injection + +The new app is not a `panel_custom` element, so it has no injected `hass` object. Instead: + +- **Auth:** a Home Assistant Long-Lived Access Token (LLAT), generated once per install in the HA user + profile. Sufficient for a private single-user install — no need for the full OAuth flow the official + Companion App uses. +- **Reads:** `GET /api/states/` per entity, or subscribe over HA's **WebSocket API** for + push updates instead of polling. +- **Writes:** `POST /api/services//` — calling the **same** `pyscript.audi_dashboard_*` + services the current panel already calls via `hass.callService()` (e.g. + `audi_dashboard_profil_schreiben`, `audi_dashboard_beleg_hochladen`). Same backend function, same + file written server-side — a receipt upload or a FIN/plate edit from the phone is a real round-trip + to the shared dataset, never a local-only change. The phone only caches for offline viewing and holds + the token; it is never the source of truth. +- This same REST/WebSocket client is what makes the iframe-in-HA case (§1.3) work identically to the + native apps — one data-access layer for all three targets, no `hass`-injection special-casing needed + for the iframe case. + +### LLAT provisioning (decided) + +**Primary: manual paste into a one-time setup screen**, saved to secure on-device storage (iOS Keychain +/ Android Keystore, via a Capacitor secure-storage plugin). Always available, no permissions, no extra +infrastructure — and the guaranteed fallback if anything about the QR path misbehaves. + +**Secondary (preferred by the owner, build if it stays simple): QR code scan.** The one real objection +to QR — that generating it would mean pasting a long-lived token into some third-party online QR +generator — is avoidable: generate the QR **locally**, in a small self-hosted page under HA's +`/local/`, using an offline JS QR library, rendering entirely client-side with no network call. The +token then never leaves the local network on its way into the QR. On the app side this needs a +Capacitor barcode/camera plugin and the camera permission. Both halves are small and well-trodden; +if the implementation turns out to be more than a modest amount of work, ship manual paste alone — +it's fully sufficient on its own. + +A URL/deep-link bootstrap was considered and **rejected**: it needs a registered custom URL scheme plus +some channel to send yourself the link, which exposes the token in transit at least as much as pasting +it, for no gain over the two options above. + +### Offline behavior (decided) + +- The app shows the last cached data when it has no route to HA, with a visible **"Offline"** marker in + the header/title area — never a blank screen or silent staleness. +- **All edits made while offline are queued** (receipt uploads, profile edits, manual trip/fill-up + entries, etc.) and flushed once connectivity returns, rather than rejected outright. Given this is a + single-user app, conflict resolution can stay simple (last-write-wins) — there's no concurrent second + editor to reconcile against. + +--- + +## 4. Connectivity: Option B — narrow gateway, chosen over Tailscale-always-on + +**Decision: Option B.** (Option A — making the phone's Tailscale connection always-on instead of +triggered by the car's WLAN SSID — was the lower-effort alternative and remains available if this +turns out to be worth revisiting, but Option B is what's being built.) + +### Goal +The app must be able to read and write data **from any network the phone is on** (cellular, foreign +Wi-Fi, home Wi-Fi) — not just when Tailscale happens to be connected — while **Home Assistant itself +stays exactly as closed/local-only as it is today**: no port-forward, no Funnel, HA's own port/UI/login +never reachable from the public internet. Only a narrow, purpose-built slice of the API is ever exposed. + +### Shape (two existing, maintained HA Community Add-ons — not custom code to maintain) + +1. **Cloudflared add-on** — runs `cloudflared` as an HA add-on. Opens an **outbound-only** encrypted + tunnel from the HAOS host (the OptiPlex) to Cloudflare's edge; no router port ever opens. Confirmed + capable of routing **multiple public hostnames to multiple local ports/services** — so it points at + the reverse-proxy add-on's port below, not at HA's own port 8123. +2. **Nginx Proxy Manager or Traefik add-on** — a path-scoped reverse proxy sitting between the tunnel + and HA's internal API, on the Supervisor's internal Docker network. Configured with an **allowlist** + of only the specific paths the companion app needs (e.g. `/api/states/sensor.audi_*`, + `/api/services/pyscript/audi_dashboard_*` — exact path list still TBD, depends on the final entity/ + service names once the FMM003 mapping is built). Everything else — `/lovelace`, `/config`, `/auth`, + the HA frontend itself — is blocked at this layer, never reaching the tunnel at all. +3. **Auth stays a second, independent layer:** every proxied call still requires the same Bearer LLAT + HA's REST API already demands. Even a path that slipped through the allowlist would be useless + without that token, and any individual LLAT can be revoked from the HA profile if the phone is ever + lost. + +### Why this over Option A +Feels like a normal app to the user — plain HTTPS, no VPN client running on the phone, no "why is my +battery draining" VPN-always-on tradeoff. The cost is two add-ons' worth of configuration (tunnel + +path allowlist) instead of a single phone setting, and the honest caveat that a narrow, purpose-built +surface *is* now internet-facing (even though HA itself never is) — accepted as worth it for the UX. + +### Explicitly not chosen +- **Tailscale Funnel** — ruled out earlier for the raw FMM003→decoder TCP ingress path (port/protocol + restrictions), and not adopted for this gateway either, since it would mean quietly reversing the + existing "no Funnel" rule; not adopted without that being an explicit, separate decision. +- **Direct port-forward** — ruled out, contradicts the standing "Home Assistant is never exposed + publicly" principle from `bauauftrag.md` §3, which continues to hold for HA itself under this design. + +--- + +## 5. Open items (not yet decided) + +Deliberately deferred until hardware arrives / the next architecture review — not blocking anything +else in this document: + +1. **Exact reverse-proxy path allowlist** — depends on the final pyscript entity/service names once the + FMM003 combined-entity mapping is implemented; write these down here once decided. +2. **No-hardcoding mapping system** for the FMM003 combined entity — discussed in an earlier session, + not yet written to a file or finalized in detail. +3. **Reverse-proxy add-on: Nginx Proxy Manager vs. Traefik** — both viable (§4), pick deferred to the + next audit pass. Doesn't depend on the FMM003 — could be set up and tested against HA's existing API + before the hardware arrives, if worth doing ahead of time. +4. **Cloudflare Tunnel domain** — ✅ **DECIDED: `datametric360.app`**, being registered fresh at + all-inkl for this purpose only (no websites, no mail on it). The tunnel will publish a subdomain of + it. Note `.app` is on the HSTS preload list, so browsers force HTTPS on it unconditionally — which + suits a Cloudflare Tunnel (always HTTPS) and removes any chance of an accidental plaintext request. + + The constraint that drove "register a separate domain rather than reuse an existing one": + + Cloudflare Tunnel needs the domain's DNS hosted at Cloudflare — i.e. **"Full setup": the domain's + nameservers get repointed to Cloudflare, moving *all* DNS records for that domain (A, MX/email, + everything) out of all-inkl's DNS management.** The alternative that avoids this — Cloudflare's + "partial / CNAME setup", which keeps DNS at the existing provider — is **Business-plan-only + ($200/month)**, i.e. not realistic here. + + **Therefore: never point this at an existing all-inkl domain carrying live websites or email** — + hence the dedicated `datametric360.app`. Nothing about the owner's existing domains/mail is touched. + + Note the all-inkl "Neue Domain anlegen" dialog asks for a target (Webspace / Redirect / + Webbaukasten) — that choice is irrelevant here, since DNS gets delegated to Cloudflare afterwards + and the all-inkl webspace binding then goes unused. The meaningful step is the **nameserver change + to Cloudflare**, done after registration, not anything in that dialog. + +--- + +## 6. Explicit non-goals for this piece (carried over from `bauauftrag.md`) + +- Home Assistant's own UI/API is never exposed publicly, under either connectivity option. +- No change to the vehicle being read-only (no remote control) — this app remains a viewer/data-entry + tool, same as the existing panel. diff --git a/DESIGN_BRIEF_DATAMETRIC360.md b/DESIGN_BRIEF_DATAMETRIC360.md new file mode 100644 index 0000000..78872a7 --- /dev/null +++ b/DESIGN_BRIEF_DATAMETRIC360.md @@ -0,0 +1,86 @@ +# DataMetric360 — Design brief for Claude Design + +Paste the block below into a **new Claude Design project** that has the **"Audi Dashboard UI"** design +system attached. Then iterate screen by screen. See the step-by-step in the chat/companion doc. + +--- + +## Prompt to paste + +``` +Entwirf eine App namens "DataMetric360" — eine private Fahrzeug-App für ein einzelnes Auto. +Nutze ausschließlich die Komponenten aus dem angehängten Design-System (Audi Dashboard UI). + +WICHTIG — Grundlagen: +- Dunkles Theme ("Nacht") ist Standard, helles Theme ("Tag") existiert als Umschaltung. +- Keine Schatten, keine Verläufe. Flache Kacheln, Pill-Buttons, Haarlinien als Trenner. +- Zahlen im deutschen Format (1.234,5 · Komma als Dezimaltrennzeichen). +- Alle Texte auf Deutsch. +- Schriftgewichte nur 300 und 400 — niemals fett/600+. + +RESPONSIVE — das ist zentral: +Die App läuft in zwei Kontexten und muss beide gleich gut bedienen: +1. Smartphone (iPhone/Android, ca. 390–430px breit) — Hauptkontext, Bedienung mit dem Daumen, + Tab-Leiste unten. +2. Großer Bildschirm (Desktop/Tablet, eingebettet in ein Home-Assistant-Dashboard, ab ca. 1000px). + Hier soll das Layout NICHT einfach eine zentrierte schmale Handy-Spalte sein, sondern die + Fläche sinnvoll nutzen: mehrspaltige Kachel-Anordnung, Navigation seitlich statt unten, + Listen und Detailansicht nebeneinander statt nacheinander. +Zeige für jeden Hauptbildschirm beide Varianten. + +BILDSCHIRME (Hauptnavigation, 5 Bereiche): +1. "Übersicht" — Fahrzeugfoto, Modellbezeichnung, Kennzeichen, Status "sicher abgestellt", + Reichweite + Tankfüllstand als große Zahl mit Fortschrittsbalken, Kilometerstand, + nächster Service, letzte Fahrt und letzte Tankung als Vorschau. +2. "Mein Audi" — Übersichtsseite mit Fotogalerie und Kacheln, die zu Unterseiten führen: + Fahrzeugdaten/Technik, Service, Reifen, Versicherung & Steuer. +3. "Fahrten" — Liste aller Fahrten, gruppiert nach Jahr und Monat (aufklappbar). + Pro Fahrt: Datum, Uhrzeit, Strecke in km, Dauer, Fahrtart (privat/Arbeitsweg). + Plus eine Detailansicht einer einzelnen Fahrt mit Karte, Start-/Zieladresse, + Durchschnitts- und Höchstgeschwindigkeit. +4. "Statistik" — ausgewertete Kennzahlen über wählbare Zeiträume: gefahrene Strecke, + Fahrzeit, Verbrauch, Kosten, Aufteilung Tag/Nacht, Aufteilung privat/Arbeitsweg. +5. "Tanken" — Liste der Tankvorgänge (Jahr/Monat gruppiert), pro Eintrag Datum, Liter, + Preis pro Liter, Gesamtbetrag, Tankstelle. Plus Detailansicht eines Tankvorgangs + mit Beleg-Upload. + +ZUSÄTZLICHE BILDSCHIRME: +6. "Einstellungen" — erreichbar über ein Zahnrad oben rechts, nicht über die Tab-Leiste. + Fahrzeugdaten bearbeiten (FIN, Kennzeichen, Modell), Theme-Umschaltung, + Foto-Verwaltung, Datenexport, Backup. +7. "Ersteinrichtung" (Onboarding, einmalig beim ersten Start) — der Nutzer verbindet die App + mit seinem eigenen Server: Feld für die Server-Adresse, Feld zum Einfügen eines + Zugangs-Tokens, alternativ ein Button "QR-Code scannen". Freundlich und knapp erklärt, + kein technisches Kauderwelsch. + +NEU UND WICHTIG — Live-Daten: +Das Fahrzeug sendet jetzt laufend Live-Telemetrie (Position, Geschwindigkeit, Drehzahl, +Zündung an/aus, Tankfüllstand). Entwirf dafür: +- Eine "Fahrt läuft gerade"-Ansicht: Live-Karte mit aktueller Position, aktuelle + Geschwindigkeit groß, bisherige Strecke und Dauer der laufenden Fahrt. +- Auf der Übersicht: ein deutlich sichtbarer Hinweis, wenn das Fahrzeug gerade fährt, + mit Absprung in diese Live-Ansicht. + +OFFLINE-ZUSTAND: +Die App kann ihren Server nicht immer erreichen. Entwirf einen unaufdringlichen, aber +eindeutigen "Offline"-Hinweis in der Kopfzeile: die zuletzt geladenen Daten bleiben sichtbar, +sind aber erkennbar nicht aktuell. Zeige außerdem, wie Änderungen aussehen, die offline +gemacht wurden und noch auf Übertragung warten (z. B. ein hochgeladener Beleg in einer +Warteschlange). + +LEERE ZUSTÄNDE: +Zeige für Fahrten, Tankvorgänge und Statistik jeweils auch den Zustand "noch keine Daten +vorhanden" — freundlich formuliert, mit einem Hinweis, was als Nächstes passiert. +``` + +--- + +## Notes for the implementation step (me, afterwards) + +- Import via `DesignSync` from the resulting project, same as the Design-Audit task. +- Target repo: a new `companion-app/` (Vite + React + TypeScript + Capacitor), consuming + `@audi-dash/ui` from `design-system/`. +- The design system ships **no Audi brand assets** (licence constraint) — but DataMetric360 is sideload-only + and therefore inside the private-installation scope, so real fonts/rings/badges get added back at + implementation time from `homeassistant/www/`, **not** into `design-system/`. +- Data layer: HA REST + WebSocket, see `COMPANION_APP_ARCHITECTURE.md` §3. diff --git a/SPECIFICATION.md b/SPECIFICATION.md new file mode 100644 index 0000000..9f3f233 --- /dev/null +++ b/SPECIFICATION.md @@ -0,0 +1,261 @@ +# Audi Dashboard — Technical Specification + +**Audience:** an AI coding agent picking up this codebase with no prior context. +**Scope:** the Home Assistant panel (`homeassistant/`) — the finished, in-use app. A separate sibling project, `design-system/`, is a standalone React component-library replica of this app's visual language, built solely to feed Claude Design (`claude.ai/design`) via the `/design-sync` skill; it is **not** part of this app and deliberately excludes Audi brand assets. `COMPANION_APP_ARCHITECTURE.md` documents a third, **not-yet-built** sibling project — an iOS/Android/HA-iframe companion app fed by a Teltonika FMM003 tracker instead of the WLAN-sensor/VAG-integration data sources described below; its design is decided but no code exists yet. Don't confuse any of the three. + +**Source of truth order:** the actual code in `homeassistant/pyscript/` and `homeassistant/www/` is authoritative. `bauauftrag.md` (root, German, dated 2026-08-05) is the original build brief — useful for rationale and history, but the implementation has since diverged and grown well beyond it (86 build tasks completed since). Every place code and brief disagree is called out in **§7 Known Gaps & Discrepancies** — read that section before trusting anything in `bauauftrag.md` as current behavior. + +--- + +## 1. Concept + +A private, single-user Home Assistant `panel_custom` dashboard that replaces the Audi *connect plug & play* app (discontinued end of 2026) for one specific vehicle: an **Audi RS 4 Avant competition**, FIN `WUAZZZF48PA902804`. It surfaces vehicle status, a trip log, a fuel log, service/maintenance forecasting, insurance/tax, and tire management — read-only with respect to the vehicle (no remote control). + +**Data sources:** +- **HACS integration `TommiG1/HA_VAG-EU-Data-Act`** — odometer, fuel level, range, door/window/lock state, service-due dates. Polled every 15 minutes by the vehicle itself; no GPS, no live telemetry (no speed, no RPM, no OBD fault codes). +- **iPhone + Home Assistant Companion App** — a single sensor reporting the currently-connected WLAN SSID, used as the sole trip-detection trigger (connects to the car's own WLAN hotspot). GPS from the phone was planned but is **not wired in** (see §7). +- **Shell fuel receipts** — PDF uploaded through the app UI, parsed server-side. +- **Manual entry** — everything else (insurance, tax, service book, tire data, trips/fill-ups without a receipt). + +**Explicit non-goals** (bauauftrag §12): vehicle remote control, OBD fault codes/live telemetry, driving-style/efficiency scoring, per-trip top speed, multi-user support, redistribution of the Audi brand/font licenses outside this one private installation. + +**Access:** Tailscale only ("VPN On Demand", rule bound to the vehicle's WLAN SSID), no port-forwarding, no Funnel — Home Assistant is never exposed publicly. + +--- + +## 2. Concept & Design System + +### Visual language +Dark ("Nacht") is the default and primary theme; a light ("Tag") theme exists as a toggle. No shadows, no gradients anywhere except the fade under the vehicle photo (`.szene`). Flat tiles, pill-shaped controls, hairline dividers. + +### Colors (CSS custom properties, defined per-theme via `:root` / `[data-theme="tag"]`) + +| Role | Nacht (default) | Tag | +|---|---|---| +| `--canvas` (page background) | `#161b23` | `#FFFFFF` | +| `--tile` | `#1f2733` | `#f2f2f2` | +| `--tile-2` (inputs, hover) | `#2a3341` | `#e5e5e5` | +| `--line` | `rgba(255,255,255,.10)` | `rgba(0,0,0,.10)` | +| `--line-strong` | `rgba(255,255,255,.20)` | `rgba(0,0,0,.22)` | +| `--fg` | `#FFFFFF` | `#000000` | +| `--fg2` (secondary text) | `#9aa1ad` | `#4c4c4c` | +| `--fg3` (labels/eyebrows) | `#8a94a3` | `#666666` | +| `--ok` | `#15da15` | `#0DA20D` | +| `--warn` | `#ffaa00` | `#ffaa00` | +| `--shade` | `rgba(255,255,255,.05)` | `rgba(0,0,0,.04)` | +| `--bad` | `#fd2c4e` | `#eb0d3f` | +| `--red` (accent, theme-independent) | `#F50537` | `#F50537` | + +`--red` is used sparingly (accents, active states, destructive actions), not as a background fill. + +### Radii +- `--r-tile: 20px` — tiles, the vehicle-photo "scene" container. +- `--r-pill: 999px` — buttons, switches, segmented controls, progress bars. +- Small functional elements (form inputs, placeholder/mini image boxes, popups) use **hardcoded literal pixel values** (6px / 12px / 14px respectively) rather than a shared token — see §7 for why this is a discrepancy from the brief. + +### Typography +Three font-family names in play, all under the trademarked "Audi Type" family, embedded inline as base64 `woff2` directly in `audi-dashboard.css` (private-install only — see Licensing below): +- `"Audi Type"` — body/UI text, weight 400. +- `"Audi Type Wide"`, weight 300 — `.fig` class, used for large numeric figures (odometer, range, tank %, prices, service countdowns). +- `"Audi Type Extended"`, italic — `.sport` class and the vehicle-title badge suffix (`.badge .zusatz`). + +Only weights 300 and 400 are shipped — never use `font-weight: 600` or higher; the browser would synthetically bold. Numbers are formatted via `de-DE` locale (thousands separator, comma decimals) through the `de()`/`eur()` helpers. + +### Licensing constraint (hard rule) +The Audi Type font, the four-rings SVG, and the model badge SVGs (`www/badges/*.svg`) are **licensed/trademarked assets, cleared only for this one private, non-published installation** (bauauftrag §8, §12). Never extract, republish, or reuse them anywhere outside this specific Home Assistant instance — this is exactly why the sibling `design-system/` repo (built for Claude Design sync) uses a generic fallback font stack and ships no Audi branding at all. + +--- + +## 3. Frontend UI/UX + +### Architecture +- **One file**, `homeassistant/www/audi-dashboard-app.js` (~2900 lines): defines the custom element ``, registered via `customElements.define` (guarded against double-registration). +- Loaded through a tiny stable loader stub, `audi-dashboard-panel.js`, referenced from `configuration.yaml`'s `panel_custom` block. The stub fetches `/local/audi-dashboard-version.json` (uncached), then dynamically `import()`s `/local/audi-dashboard-app.js?v=` — this is the cache-busting mechanism that lets the app be updated by rewriting one small JSON file (see §5, `updateverwaltung.py`), without touching `configuration.yaml` or restarting HA. It works around a documented HA quirk where `ha-panel-custom` only cache-busts `js_url`-type panels, not `module_url`-type ones. +- **No framework, no bundler.** All module state lives in free (non-class) variables by design — this file is a near-verbatim port of the original static-HTML prototype (`dashboard-muster.html`), and keeping the render functions (`vHome()`, `vTrips()`, `render()`, …) free of `this` let them be reused almost unchanged. For a single-panel app, one instance == "the one interface," same as the prototype's single ``. +- Renders into an **open shadow root**. `$(selector)` = `ROOT.querySelector(selector)`. +- `hass` is injected automatically by HA's `panel_custom` integration. The constructor works around a real timing bug: `ha-panel-custom` can call `document.createElement(tag)` before `customElements.define()` has registered the class, so an early `.hass =` assignment lands as a plain instance property instead of hitting the `set hass()` accessor and is never "upgraded." The constructor re-triggers the setters by deleting and reassigning any such own-properties found at construction time. + +### Data flow +Backend pyscript publishes 7 `pyscript.*` state entities; the frontend reads `hass.states[id].attributes.daten`: + +| Entity | Frontend variable | +|---|---| +| `pyscript.audi_dashboard_profil` | `PROFIL_ROH` → `CONFIG`/`CAR` (via `profilZuConfig()`/`profilZuCar()`) | +| `pyscript.audi_dashboard_fahrten` | `TRIPS` | +| `pyscript.audi_dashboard_tankvorgaenge` | `FILLS` | +| `pyscript.audi_dashboard_fahrzeugstatus` | merged into `CAR`; its `last_updated` drives the staleness indicator | +| `pyscript.audi_dashboard_batterieverlauf` | `BATTERIEVERLAUF` | +| `pyscript.audi_dashboard_beleg_ergebnis` | routed through `belegErgebnisVerarbeiten()` (receipt-upload async result) | +| `pyscript.audi_dashboard_update_status` | `UPDATE_STATUS` (fetched once via WebSocket, not passive push) | + +Most settings mutate `CONFIG`/`CAR` in place in the browser, then call `profilSpeichern()` → `HASS.callService("pyscript", "audi_dashboard_profil_schreiben", {...})`, which round-trips the **entire profile** as one JSON blob. There is no field-level write service for profile data. + +A retry-loading system (`nachladeAnstossen()`) handles the case where pyscript hasn't published its entities yet after an HA restart (panel_custom doesn't reliably re-push `hass` afterward): retries at 200ms, then every 2s for 15 attempts, then falls back to every 10s indefinitely — no hard timeout. This replaced an earlier 15-retry/~30s hard cutoff that was observed in production to be too short (task #55). + +### Navigation +- 5 bottom tabs (`TABS`): **Übersicht** (home) · **Mein Audi** (audi) · **Fahrten** (trips) · **Statistik** (stat) · **Tanken** (fuel). +- **Einstellungen** (Settings) lives behind the Audi-rings icon top-right — not a tab. +- Navigation state is a flat `route = {name, id}` object, no browser history stack. `go(name, id)` sets `route` and calls `render()`. +- Back-button target per sub-page is a static lookup table, `ZURUECK` — includes 3-level nesting (`schutz` → `vertragsdetails` → `vers`). + +### Views +| Route | Function | Notes | +|---|---|---| +| `home` | `vHome()` | vehicle photo, badge, plate, "sicher abgestellt" status, range ring/bar, odometer + next-service tile pair, last-trip/last-fill teaser | +| `sicherheit` | `vSicherheit()` | 12-point door/window/lock detail | +| `audi` | `vAudi()` | "Mein Audi" hub — photo gallery, tiles linking to sub-pages | +| `ident` | `vIdent()` | identity, technical specs, equipment list | +| `battverlauf` | `vBatterieverlauf()` | hand-built SVG battery-voltage chart | +| `service` | `vService()` | upcoming appointments, book-an-appointment form, workshop contact, service book list | +| `werkstatt` | `vWerkstatt()` | workshop contact edit form | +| `sbuch` | `vSbuch(i)` | single service-book entry (view/edit/new-draft) | +| `vers` | `vVers()` | insurance/tax hub | +| `beitrag` | `vBeitrag()` | edit insurance premium line items | +| `vertragsdetails` | `vVertragsdetails()` | contract line items, links to Schutzbrief | +| `schutz` | `vSchutz()` | mobility-protection (Schutzbrief) terms, read-only | +| `notruf` | `vNotrufBearbeiten()` | edit emergency phone numbers | +| `steuer` | `vSteuer()` / `vSteuerBearbeiten()` | vehicle tax summary + edit | +| `reifen` | `vReifen()` | tire sets, wheel-bolt torque, swap toggle, swap-date → calendar | +| `trips` | `vTrips()` / `vTripsFormular()` | trip list (year→month accordion) + manual-add form | +| `trip` | `vTrip(id)` | single trip detail + map | +| `stat` | `vStat()` | statistics (distance/time, fuel, consumption, day/night, trip type — real computed values, see §7) | +| `fuel` | `vFuel()` | fill-up list (year→month, volume-weighted avg price) | +| `fill` | `vFill(id)` / edit form | single fill-up detail, receipt upload/replace | +| `einst` | `vEinst()` | Settings — vehicle setup, theme/labels/ground toggle, overview image, photo upload grid, SmartDeal, trip-merge gap, profile export/import, backup, CSV export, version + self-update | + +### Interactive components +- **Swipe-to-delete** — Pointer Events (not Touch Events, deliberately, so mouse works for desktop testing), 84px reveal width, opening one row closes any other (`schliesseAlleSwipes()`), native `confirm()` before delete, then a service call + optimistic local-array filter. Used for trip and fill-up rows; service-book rows use a plain delete button instead. +- **Pull-to-refresh** — home route only, only from scroll-top, iOS-style rubber-band resistance (`min(90, dy*0.5)`), 56px trigger threshold. +- **Popups** — only two: `.bildmenu` (photo replace/delete, used both in Settings' photo grid and on tire-set wheel photos) and `.sdpopup` (SmartDeal activation, asks for an expiry date). No native `` anywhere; every other "form" is an inline collapsible section toggled by a boolean state var (`einrichtenOffen`, `nmOffen`, `fahrtOffen`, `tankBearbeitenOffen`, …). +- **Destructive/error UX** — native `confirm()` gates every delete and the self-update install; native `alert()` surfaces import/upload/update failures; receipt-parsing errors render inline in red next to the relevant form. +- **Battery-voltage chart** — hand-drawn SVG, no charting library (no bundler in the project). Pinch-zoom (2-finger)/mouse-wheel, 1-finger pan, tap selects nearest point for a date/time + min/max tooltip. +- **Maps** — Leaflet, loaded from CDN at runtime (not bundled) — requires the client to have internet access in addition to the Tailscale tunnel. Tile source switches with the day/night theme. +- **Receipt upload** — native file picker (PDF only) → base64 → `audi_dashboard_beleg_hochladen` service call; the parsed result comes back asynchronously via a dedicated hass entity rather than the service call's return value (a documented pattern, see `frontend_veroeffentlichung.py`), because pyscript service calls don't return values to the frontend. + +### Theming +`data-theme` attribute on the host element (not `:root`). Set from Settings' segmented control (the topbar sun/moon button was retired by Design-Audit finding A6 — it's still in the markup but `hidden`), which then re-runs `themeIcon()`/`tiles()` (map tile layer swap). **Persisted** via `localStorage` (`THEME_KEY`, `themeGewaehlt()`/`themeSetzen()`); with nothing stored yet it follows the OS setting via `prefers-color-scheme`, defaulting to "nacht". The tab-bar-label toggle is persisted the same way (`SCHRIFT_KEY`). All `localStorage` access is wrapped in try/catch for private-mode browsers. + +### Icons +All hand-authored inline SVG (no icon font, no external library). A `ICONS`/`ICON` object holds tab and sun/moon fragments; the settings-cog path is duplicated verbatim in two places (`vSteuer()`, `vReifen()`) rather than factored out. + +--- + +## 4. Logic (business rules) + +| Rule | Where | Trigger | Summary | +|---|---|---|---| +| **Trip detection** | `fahrterkennung.py` | `@state_trigger` on the Companion App WLAN sensor | Start = sensor value becomes the vehicle's SSID (from profile). End = sensor leaves that SSID **and doesn't return within the pause-tolerance window** (`fahrten_pausenzeit_min`, default 15 min, editable in Settings). Implemented via `task.unique()` cancelling a pending `task.sleep()` if the WLAN reconnects in time — the trip record is only written after the wait, never on the raw disconnect. **In-memory only** (`_fahrt_start_ts`) — an HA restart mid-trip or mid-pause-window silently loses the trip (documented, deferred hardening gap). | +| **Two-stage trip completion** | `modules/fahrtabschluss_logik.py` | odometer sensor state-trigger + post-trip-end call | The odometer often doesn't update until the *next* trip. `screening_durchfuehren()` searches the HA REST history API (48h window, Bearer token from `audi_dashboard/ha_token.txt`) for the odometer reading closest in time to trip start/end; fills `odo_start`/`odo_end`, sets `status="vollständig"` once both are known. Adjacent trips propagate a known odometer value across a gap when the vehicle was simply parked (`_lueckenlos_verketten`). **GPS fallback is NOT implemented** despite being referenced in comments — see §7. | +| **Distance** | derived, everywhere | — | `distance_km = odo_end − odo_start`. GPS (when/if present) supplies route/addresses only, never distance — so gaps in position tracking are harmless by design. | +| **Tire-set km counter** | `reifenzaehler.py` | odometer state-trigger + startup | Rewritten from the original spec: each tire set's `km` is a running cumulative total; `referenz_odo_km` is the last-processed absolute odometer reading. Every odometer change adds the delta to the **currently active** set, then advances the reference. On swap, the old set is settled first, *then* `aktiv` flips — km driven on the old set can never leak onto the new one. Resilient to a temporarily-`unavailable` odometer sensor (reference simply doesn't move; next valid reading computes its delta against the old reference, so no km is lost or double-counted across an outage). | +| **Automatic fill-up detection** | `tankerkennung.py` | fuel-level state-trigger | Fires when the level rises, since the last low-water-mark, by ≥ the *more sensitive* of "5 L converted to %" (using `tankvolumen_liter`, default 58L) or "9 percentage points." Tracks a low-water-mark (not simple prev/current diff) because the integration sometimes reports a fill-up as several small incremental steps. Creates a **placeholder** fill-up (`status="unvollständig"`, no liters/cost/station, `source="auto"`) — the user completes it later via receipt upload. Low-water-mark is in-memory only (same restart caveat as trip detection). | +| **Shell receipt parsing** | `data/shell_beleg_parser.py` (standalone CLI, `pypdf`) | invoked via `subprocess.run` from `belegverarbeitung.py` | All matching is structural regex (`Obj.-Nr.`, `Beleg-Nr.`, pump-line patterns, `#A` article markers), not brand/label text, because brand and discount-label wording vary across receipt printings. `price_per_l` is deliberately never read from the PDF — always recomputed as `fuel_total_eur / liters`. Dedup key: `"{station_id}_{receipt_no}_{ts[:16]}"` (minute granularity). Regression-tested against 10 real receipts from 4 stations. | +| **Receipt-to-fill-up matching** | `belegverarbeitung.py` | `audi_dashboard_beleg_hochladen` service | Same `receipt_key` already attached to a *different* `tank_id` → rejected as duplicate. Patching an existing fill-up requires the receipt's calendar day to match the record's — else rejected as "Falsches Belegdatum." Odometer/distance always come from the vehicle, never overwritten by a receipt. Uploading a receipt with no `tank_id` never creates a record — only feeds the frontend's currently-open draft. Any field listed in a record's `edited_fields` is protected from being overwritten by future automated updates. | +| **Service/oil-change forecast** | split — see §7 | — | The vehicle's own reported due date/km (EU-Data-Act sensors) is republished as-is by the backend. The app's **own** Servicebuch-based forecast (`oelwechselPrognose()`, `intervalle()`, `termine()`) — which mixes the full historical average with a ~180-day-weighted recent rate, restarting from zero after each logged oil change, capped at the configured interval's time limit — is entirely **frontend** logic, not backend, despite the brief describing it as core backend logic. | +| **SmartDeal (fuel discount) validity** | frontend, `profilZuCar()` | recomputed every render | `aktiv = einstellungen.smartdeal.aktiv && (no laeuft_ab OR laeuft_ab >= today)` — the expiry date is **inclusive** (discount still applies on the expiry date itself). No backend cron enforces this; it's a pure computed property re-evaluated on load/render. | +| **Backup** | `backup.py` | daily `@time_trigger("cron(0 4 * * *)")` | Folder-copy (not zip) of the 3 data files into `audi_dashboard/backups//`. Interval (`aus`/`taeglich`/`woechentlich`/`monatlich`) evaluated inline inside the single daily trigger — weekly fires only on Monday, monthly only on day 1. Restore is the counterpart to a **browser-side** export/import that bundles profile+trips+fill-ups client-side. | +| **Self-update** | `updateverwaltung.py` | manual services only | Check: shallow git clone/fetch of `UPDATE_REPO_URL`, compares `www/audi-dashboard-version.json`'s numeric version, source vs. live. Install: backs up live `pyscript/` + the 3 frontend files to `audi_dashboard/code_backups//`, then wholesale-replaces `pyscript/`, the 3 www files, and `www/badges/` from the clone. Writes a **fresh** version number (`int(now.timestamp())`, not the source repo's own value) to force every browser to cache-bust. Never touches `www/bilder/` (user photos) or `audi_dashboard/` (user data). No-op entirely if `UPDATE_REPO_URL` is empty. | +| **Battery voltage tracking** | `batterieverlauf.py` + frontend `vBatterieverlauf()` | every 5 min + startup; no-op if `BATTERIE_SENSOR` unset (currently the case) | Backend records only raw daily min/max + timestamps, unfiltered. All interpretation is client-side: SOC % via a voltage→percent lookup table, SOH as a trend (early-window vs. late-window average delta, not a real capacity measurement), using the daily **minimum** as the resting-voltage proxy (occurs shortly after engine start, before the alternator raises voltage) — an `AGM_RUHE_MAX_V` (13.2V) threshold filters out readings taken mid-drive. | +| **"Securely parked" status** | `frontend_veroeffentlichung.py::_sicherheitscheck` | published with every status update | 16 individual door/window/lock/tailgate/hood checks, each `{label, ok}` where `ok` is `None` if the underlying sensor is missing. Aggregate is `None` if *any* check is unknown, else `True` only if *all* are `True` — guarantees the summary dot and the detail list can never disagree, and never shows a false-green from stale/missing data. | + +--- + +## 5. Backend + +### pyscript files and their `@service` definitions + +| File | Services | +|---|---| +| `frontend_api.py` | `audi_dashboard_jetzt_aktualisieren()`, `audi_dashboard_profil_schreiben(profil_json)` | +| `backup.py` | `audi_dashboard_backup_jetzt()`, `audi_dashboard_backup_wiederherstellen(profil_json, fahrten_json, tankvorgaenge_json)` | +| `belegverarbeitung.py` | `audi_dashboard_beleg_hochladen(pdf_base64, dateiname, tank_id)`, `audi_dashboard_tankvorgang_manuell(...)`, `audi_dashboard_tankvorgang_aktualisieren(...)`, `audi_dashboard_tankvorgang_loeschen(tank_id)` | +| `bilderverwaltung.py` | `audi_dashboard_bild_hochladen(dateiname, daten_base64)`, `audi_dashboard_bild_loeschen(dateiname)` — both gated by a fixed 10-name allow-list (`ERLAUBTE_DATEINAMEN`); no other filename is ever writable/deletable | +| `fahrtabschluss.py` | `audi_dashboard_screening_jetzt()` (manual trigger for the odometer-history screening) | +| `fahrterkennung.py` | `audi_dashboard_fahrt_manuell_anlegen(ts_start, ts_end, art)`, `audi_dashboard_fahrt_loeschen(trip_id)` | +| `reifenzaehler.py` | `audi_dashboard_reifen_wechseln(satz)` (`"sommer"`/`"winter"` only) | +| `updateverwaltung.py` | `audi_dashboard_update_pruefen()`, `audi_dashboard_update_installieren()` | + +`batterieverlauf.py` and `tankerkennung.py` expose no services — trigger-only. `modules/profil.py`, `modules/fahrtabschluss_logik.py`, `modules/frontend_veroeffentlichung.py`, `modules/einstellungen.py` are plain importable helper modules with no decorators. + +### Triggers +`@time_trigger("startup")` in most files (ensures data folders exist / initial publish); `@time_trigger("period(now, 20 seconds)")` republishes live status; `@time_trigger("period(now, 60 seconds)")` republishes profile/trips/fills as a filesystem-edit safety net; `@time_trigger("cron(0 4 * * *)")` daily backup check; `@time_trigger("period(now, 5 minutes)")` battery check; `@state_trigger` on the WLAN sensor (trip detection), the odometer sensor (tire counter + completion screening), and the fuel-level sensor (fill detection). No `@event_trigger` usage anywhere. + +### Data files + +**`data/fahrzeugprofil.json`** (single JSON file, gitignored; `.example.json` is the tracked template) — top-level sections: `fahrzeug` (FIN, plate, titles, WLAN SSID, tank volume, Erstzulassung, HU-due), `einstellungen` (night/day hours, trip pause tolerance, overview-image choice, SmartDeal, oil-interval mode, backup interval, last-backup timestamp), `versicherung` (premium line items, contract, Schutzbrief terms, coverage groups), `steuer`, `reifen` (active set, torque spec, swap date, per-set cumulative km + `referenz_odo_km` + spec fields), `service` (appointments, workshop contact, service-book array), `technik` (static spec sheet), `ausstattung` (static equipment list). + +**`data/fahrten.jsonl`** (JSON Lines, one trip per line — **currently empty in this checkout**, no trips recorded yet): `trip_id`, `ts_start`, `ts_end`, `duration_s`, `distance_km`, `km_quelle` (`"odometer"` — `"gps"` is defined but never produced), `odo_start`, `odo_end`, `avg_speed_kmh` (defined, never populated), `start_lat`/`start_lon`/`end_lat`/`end_lon` (defined, never populated), `start_address`/`end_address` (defined, never populated), `art` (`"privat"`/`"arbeitsweg"`), `route` (always `null`), `pausen` (always `[]`), `source` (`"ha"`/`"manual"`), `status` (`"offen"`/`"vollständig"`), `edited_fields`. + +**`data/tankvorgaenge.jsonl`** (JSON Lines — **currently empty**): `tank_id`, `receipt_key`, `receipt_no`, `tse_beleg_nr`, `ts`, `ts_payment`, `ts_tse`, `station_id/name/address`, `article_no`, `product_name`, `fuel_type`, `liters`, `fuel_total_eur`, `price_per_l` (always computed, never read from receipt), `discount`, `discount_per_l`, `list_price_per_l`, `receipt_total_eur`, `net_eur`, `vat_eur`, `receipt_file`, `odometer_km`, `distance_km`, `source` (`"auto"`/`"manual"`/`"beleg"`), `status` (`"unvollständig"`/`"vollständig"`), `edited_fields`. Note: no `full_tank`/`level_before_pct`/`level_after_pct` fields — that concept was removed (see §7). + +**`audi_dashboard/batteriespannung.jsonl`** — one line per calendar day: `{datum, min, min_ts, max, max_ts}`. + +**Other runtime paths:** `audi_dashboard/ha_token.txt` (plaintext HA long-lived access token, used only for the history-screening REST calls), `audi_dashboard/backups//`, `audi_dashboard/code_backups//`, `audi_dashboard/belege/*.pdf`, `/config/.audi_dashboard_update_quelle` (scratch git clone for self-update, deliberately outside both `audi_dashboard/` and `pyscript/`). + +### HA entities consumed +All centralized in `pyscript/modules/einstellungen.py` (the one file requiring edits before install), matched to `TommiG1/HA_VAG-EU-Data-Act` v0.6.35 for this vehicle: `sensor.iphone_wifi_connection` (trip trigger), `sensor.audi_rs_4_avant_mileage`, `..._fuel_level`, `..._range_primary`, `button.audi_rs_4_avant_refresh_now`, 4× door/window/door-lock `binary_sensor`s each, tailgate/hood + their locks, `..._oil_change_due`/`_distance`, `..._inspection_due`/`_distance`. `BATTERIE_SENSOR` is currently unset (integration provides none for this vehicle). All reads go through `zustand_oder_none()`, which treats `"unknown"`/`"unavailable"`/a missing entity as `None` rather than guessing. + +### Installation / registration +`configuration_snippet.yaml` merges two blocks into `configuration.yaml`: `pyscript: allow_all_imports: true` (required — the scripts use `os`, `subprocess`, `urllib.request`, `base64`, `uuid`, none of which pyscript's sandbox allows by default) and a `panel_custom` entry pointing `module_url` at `/local/audi-dashboard-panel.js?v=1` (sidebar title "Mein Audi", icon `mdi:car-sports`). `INSTALL.md` walks a ~30–40 min manual setup: install pyscript via HACS, copy the 3 folders into `config/`, merge the YAML, edit `einstellungen.py` with real entity IDs, drop a long-lived access token into `ha_token.txt`, restart HA, verify via Developer Tools. pyscript hot-reloads on file change; the frontend's cache-busting is handled entirely by the version-stub pattern described above (§3). + +--- + +## 6. Product surface not covered elsewhere + +- **CSV export** (Settings → "Daten ausgeben"): trips, fill-ups, service book — semicolon-delimited, German number format, BOM-prefixed for Excel. +- **iOS calendar export**: `.ics` file generation client-side (`ics()`) for service appointments and tire-swap dates, with a 2-day advance alarm. +- **Vehicle photos**: fixed filenames under `www/bilder/` (`seitenansicht.webp`, `front-schraeg.webp`, `frontansicht.webp`, `heckansicht.webp`, `cockpit.webp`, `scheinwerfer.webp`, `sitze.webp`, `seitenansicht-winter.webp`, plus `rad-sommer.webp`/`rad-winter.webp`), uploadable/deletable per-slot from Settings and the tire page; missing files render a placeholder box with an icon, label, and the exact expected filename (never a broken-image icon), preserving layout dimensions. In this checkout only `shell-logo.svg` is present — all vehicle photo slots are still empty. +- **Model badges**: `www/badges/{rs3,rs4,rs5,rs6,rs7,sq7}-{positive,negative}.svg`, theme-swapped via pure CSS (`:host([data-theme=...])`), model selection in Settings restricted to the 11 names that have a matching badge. + +--- + +## 7. Known Gaps & Discrepancies (read before trusting `bauauftrag.md`) + +These are places the shipped code and the original build brief disagree — load-bearing for anyone extending this app: + +1. **GPS-distance fallback does not exist.** Bauauftrag §7.2/§7.3 describe falling back to GPS-derived distance when the odometer never resolves. The actual `fahrtabschluss_logik.py` has an explicit `WICHTIGER VORBEHALT` comment: trips that never get an odometer match stay `status="offen"` forever. `km_quelle="gps"` is a value the schema supports but no code path ever produces. +2. **GPS fields are dead schema.** `start_lat/lon`, `end_lat/lon`, `start_address`, `end_address`, `avg_speed_kmh`, `route` all exist on the trip record but are never populated by any reviewed code — the iPhone-GPS integration described in bauauftrag §4.2 was never actually wired in beyond the WLAN trip-trigger. +3. **Full-tank derivation was removed.** Bauauftrag §7.4/§6.4 describe a 97%-fill-level threshold deriving a `full_tank` flag and consumption. `belegverarbeitung.py`'s own header comment confirms this was explicitly removed per a later change request; the current `tankvorgaenge.jsonl` schema has no `full_tank`/`level_before_pct`/`level_after_pct` fields. +4. **SmartDeal expiry is frontend-only.** No backend cron enforces `laeuft_ab`; it's recomputed as a property every render (`profilZuCar()`). Correct in practice (the UI is the only thing that reads it), but there's no server-side source of truth if something else were to consume the flag. +5. **The app's own service/oil forecast lives in the frontend, not the backend.** Bauauftrag §7.6 frames service-date forecasting as core *backend* logic ("Kernlogik"). In the shipped code, the backend only republishes the vehicle's own reported due date/km verbatim; the actual Servicebuch-based forecast math (`oelwechselPrognose()`, `intervalle()`, `termine()`) is JS in `audi-dashboard-app.js`. +6. **`INSTALL.md` names entity variables that don't match `einstellungen.py`.** The doc's step 4 references `DOORS_SENSOR`/`WINDOWS_SENSOR`/`LOCK_ENTITY`/`BATTERY_VOLTAGE_SENSOR`; the actual variables are `TUER_SENSOREN`/`FENSTER_SENSOREN`/`TUERSCHLOSS_SENSOREN` (each a 4-element list) and singular `BATTERIE_SENSOR`. +7. **Two in-memory-only state machines lose data across an HA restart**, by explicit design-doc admission: trip start (`_fahrt_start_ts` in `fahrterkennung.py`) and fill-up low-water-mark (`_tiefststand_pct` in `tankerkennung.py`). Both are flagged in code comments as deferred hardening, not accidental bugs. +8. **The bauauftrag §2/§8 spacing scale (`--sp-1`..`--sp-5`) and `--r-func: 8px` token don't exist in the shipped CSS.** Small-radius elements use hardcoded 6px/12px/14px literals instead — which is exactly what §8's own rule ("nie als Literalwerte in Komponenten") says not to do. +9. **A stale header comment in `audi-dashboard-app.js`** still claims the Statistik page shows placeholder numbers with no real evaluation. This is now false — `vStat()` computes genuine period-based statistics from `TRIPS`/`FILLS` (`fahrtenSeit`, `tankSeit`, `verbrauch`, day/night split via `istNachtZeit`). +10. **`fahrten.jsonl` and `tankvorgaenge.jsonl` are empty in this checkout** — no trips or fill-ups have been recorded through this install yet. +11. **`BATTERIE_SENSOR` is currently unset** — the vehicle integration provides no 12V-voltage sensor at present, so all battery-history logic is a permanent no-op until (if ever) one becomes available. +12. **Backlog, not yet built** (bauauftrag §15, noted 2026-08-09): coolant-level and oil-level measurement/history, mirroring the battery-voltage pattern, contingent on the vehicle integration exposing the right sensors. + +--- + +## 8. File map + +``` +Audi_app_TG/ +├── bauauftrag.md original build brief (German, historical — see §7) +├── dashboard-muster.html the original static prototype (superseded, kept for reference) +├── SPECIFICATION.md this document +├── design-system/ UNRELATED sibling project — Claude Design component library, no brand assets +└── homeassistant/ + ├── INSTALL.md, README.md, configuration_snippet.yaml, update.ps1 + ├── data/ → deployed to config/audi_dashboard/ + │ ├── fahrzeugprofil.json (+ .example.json) + │ ├── fahrten.jsonl, tankvorgaenge.jsonl + │ ├── shell_beleg_parser.py + tests/ + ├── pyscript/ → deployed to config/pyscript/ + │ ├── frontend_api.py, backup.py, batterieverlauf.py, belegverarbeitung.py, + │ │ bilderverwaltung.py, fahrtabschluss.py, fahrterkennung.py, + │ │ reifenzaehler.py, tankerkennung.py, updateverwaltung.py + │ └── modules/ + │ ├── einstellungen.py entity-ID config — the one file to edit pre-install + │ ├── profil.py file I/O for profile/trips/fills/battery history + │ ├── fahrtabschluss_logik.py odometer-history screening + HA REST client + │ └── frontend_veroeffentlichung.py publishes the pyscript.* state entities + └── www/ → deployed/merged to config/www/ + ├── audi-dashboard-panel.js stable loader stub + ├── audi-dashboard-app.js the actual custom element + ├── audi-dashboard.css design system + ├── audi-dashboard-version.json cache-buster, rewritten by updateverwaltung.py + ├── badges/ 12 static model-badge SVGs + └── bilder/ vehicle photo storage (mostly unpopulated in this checkout) +``` diff --git a/bauauftrag.html b/bauauftrag.html new file mode 100644 index 0000000..2e2632d --- /dev/null +++ b/bauauftrag.html @@ -0,0 +1,649 @@ + + + + + +Bauauftrag Fahrzeug-Dashboard + + + +
+ +

Bauauftrag: Fahrzeug-Dashboard in Home Assistant

+

Stand 05.08.2026 · privates Projekt, keine Veröffentlichung

+

Überarbeitung: §10 vollständig geklärt; Änderungen in §4.2, §5.6, §6.1, §7.1, §7.2, §7.4, §7.5, §7.7, §9, §10, §13, §14.

+
+

1. Der Auftrag in einem Satz

+

Zu bauen ist ein Fahrzeug-Dashboard in Home Assistant, das die Ende 2026 abgeschaltete App +Audi connect plug & play ersetzt: Fahrzeugstatus, Fahrtenbuch, Tankmonitor, Service, Kosten und +Versicherung für einen Audi RS 4 Avant competition — in einer Oberfläche, die sich an der Audi-App +orientiert.

+
+

2. Was mitgeliefert wird

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateiInhalt
dashboard-muster-ohne-bilder.htmlDieselbe Anwendung mit referenzierten statt eingebetteten Bildern, 184 statt 770 KB — die Fassung, mit der gearbeitet wird
bilder/Die aufbereiteten Bilddateien, elf Stück, zusammen 435 KB
dashboard-muster.htmlVollständig bedienbarer Prototyp. Eine einzelne HTML-Datei, alle Ansichten, alle Interaktionen, Beispieldaten. Das ist die verbindliche Vorlage für Aufbau, Verhalten und Gestaltung
audi-connect-nach-homeassistant.mdProjektbeschreibung mit allen Entscheidungen und ihren Begründungen
shell_beleg_parser.pyGetesteter Parser für Shell-Tankbelege, gegen zwei echte Belege geprüft
Schriften, Ringe, Typenschild, Fahrzeugbilderim Prototyp eingebettet, separat verfügbar
+

Der Prototyp ist keine Skizze. Er läuft, alle Wege sind begehbar. Wo dieser Auftrag und der +Prototyp voneinander abweichen, gilt der Prototyp — außer bei den unter §10 genannten Punkten.

+
+

3. Systemumgebung

+
    +
  • Home Assistant, bestehende Installation
  • +
  • Fahrzeugdaten: HACS-Integration TommiG1/HA_VAG-EU-Data-Act, bereits eingerichtet und liefernd
  • +
  • Positionsdaten: Home Assistant Companion App auf einem iPhone
  • +
  • Zugriff von außen: Tailscale, kein Portforwarding, kein Funnel
  • +
  • Fahrzeug: Audi RS 4 Avant competition, FIN WUAZZZF48PA902804, Erstzulassung 08/2023
  • +
+
+

4. Datenquellen

+

4.1 EU-Data-Act-Portal über die HACS-Integration

+

Liefert Kilometerstand, Tankfüllstand, Reichweite, Serviceintervalle, Türen- und Fensterstatus, +Verriegelung. Kein GPS. Aktualisierung im 15-Minuten-Takt und nur, wenn das Fahrzeug in diesem +Fenster Daten hochgeladen hat. Leere Zyklen sind normal.

+

Konsequenzen, die im Entwurf bereits berücksichtigt sind:

+
    +
  • Keine Live-Telemetrie. Momentanverbrauch, Drehzahl, Geschwindigkeit gibt es nicht.
  • +
  • Der Kilometerstand steht nach dem Abstellen erst verzögert bereit → zweistufiger Fahrtabschluss (§7.2).
  • +
  • Der Sensor ist zeitweise unavailable → betrifft den Reifenzähler (§7.5).
  • +
+

4.2 iPhone

+

Positionen über die Companion App. Fahrtstart und Fahrtende über die WLAN-Verbindung zum Fahrzeug +(Companion-App-Sensor für das verbundene Netz), nicht über CarPlay — CarPlay ist nicht immer +verbunden. Bluetooth wurde verworfen: iOS meldet Bluetooth-Verbindungswechsel im Hintergrund über +Region-Monitoring, das mehrere Minuten verzögern kann; die WLAN-Verbindung zum Fahrzeug baut sich +nach bisheriger Erfahrung zuverlässig sofort auf und ab. Der WLAN-Name (Audi_MMI_2804_5GHz) ist +fahrzeugspezifisch, liegt im Fahrzeugprofil (§6.1) und ist in den Einstellungen änderbar (§5.6).

+

4.3 Shell-Tankbelege

+

PDF per Upload in der App (§5.5, §7.7) — kein Postfachzugriff. Parser liegt bei. Belegnummer ist +nicht eindeutig; Schlüssel ist der minutengenaue Zeitstempel aus der Belegkopfzeile.

+

4.4 Manuelle Eingaben

+

Stammdaten, Versicherung, Steuer, Reifendaten, Servicebuch, Werkstatt, Fahrten und Tankvorgänge +ohne Beleg.

+
+

5. Aufbau der Oberfläche

+

Fünf Menüpunkte in einer festen Leiste unten, Symbole mit Beschriftung:

+
Übersicht · Mein Audi · Fahrten · Statistik · Tanken
+
+

Einstellungen liegen hinter den Audi-Ringen oben rechts.

+

5.1 Übersicht

+

Fahrzeugbild (Frontansicht, fest), Typenschild mit Kennzeichen, Zustandszeile „Sicher abgestellt" +mit grünem oder rotem Punkt, Reichweite als große Zahl mit Balken und Prozentwert, Kilometerstand +und nächster Service als Kachelpaar, darunter letzte Fahrt und letzter Tankvorgang als Anrisse.

+

5.2 Mein Audi

+

Bildergalerie zum Durchtippen, Typenschild, dann Kacheln mit Sprung auf eigene Seiten:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KachelUnterseite
IdentitätFahrzeugdaten, technische Daten, Ausstattung
ZustandKilometerstand, Tankfüllung, Batteriespannung
ServiceTermine, Servicebuch, Autohaus, Terminvereinbarung
KostenJahreskosten, Aufteilung, je Kilometer
Versicherung und SteuerBeitrag, Vertrag, Notruf, Schutzbrief, Kfz-Steuer
Reifenbeide Sätze, Umschalter, Wechseltermin, Anzugsmoment
+

5.3 Fahrten

+

Dreistufig aufklappbar: Jahr → Monat → Fahrt. Detailseite mit Karte, Start, Ziel, Start- und +Endkilometer, Distanz, Dauer, Ø-Geschwindigkeit, Kraftstoffkosten, Art der Fahrt. Erfassung von +Hand über eine Schaltfläche unter der Liste.

+

5.4 Statistik

+

Aufklappbare Kacheln: Distanz und Fahrzeit, getankte Menge und Kosten, Durchschnittsverbrauch, +Tag/Nacht, Art der Fahrten. Aufschlüsselung je Jahr, Monat, Woche, Tag.

+

5.5 Tanken

+

Jahr → Monat → Tankvorgang, mit mengengewichtetem Durchschnittspreis je Ebene. Detailseite mit +Karte der Tankstelle, Kosten, Preis je Liter, SmartDeal-Ersparnis, abgeleiteter Volltankung und +Verbrauch. Erfassung von Hand und Beleg-Upload.

+

5.6 Einstellungen

+

Tag- und Nachtmodus, Beschriftung der Menüleiste, Fahrbahn unter dem Fahrzeug, Übersichtsbild, +WLAN-Name des Fahrzeugs (Textfeld mit Übernehmen-Knopf für das aktuell verbundene Netz), +Fahrten-Pausenzeit, SmartDeal mit Ablaufdatum, Ölwechsel-Intervall, Fahrzeugprofil sichern und +laden, CSV-Ausgabe, Sicherung, Versionsangaben.

+
+

6. Datenhaltung

+

6.1 Drei getrennte Bestände

+ + + + + + + + + + + + + + + + + + + + + + + + + +
BestandFormBegründung
Fahrzeugprofileine JSON-Dateialles Fahrzeugspezifische an einem Ort, für Portierung austauschbar
FahrtenJSON Lines, eine Zeile je Fahrtwächst laufend, überlebt Purge und Neuinstallation
TankvorgängeJSON Linesdito
+

Servicebuch, Versicherung, Steuer, Reifen und Werkstatt gehören ins Profil, nicht in die +Archive — sie beschreiben das Fahrzeug, nicht seine Bewegung. Der WLAN-Name des Fahrzeugs gehört +aus demselben Grund ebenfalls ins Profil.

+

6.2 Portierbarkeit ist Anforderung, nicht Zugabe

+

Die Anwendung muss ohne Codeänderung für ein anderes Fahrzeug nutzbar sein. Das heißt: keine +fahrzeugbezogene Angabe außerhalb des Profils, auch keine Bezeichnungen in Texten. Bilder werden +referenziert, nicht eingebettet.

+

Drei Wege der Bearbeitung sind zu unterstützen: in der Oberfläche, über Sichern und Laden der +Datei, und direkt im Dateisystem.

+

6.3 Fahrten-Schema

+

trip_id, ts_start, ts_end, duration_s, distance_km, km_quelle (odometer | gps), +odo_start, odo_end, avg_speed_kmh, start_lat/lon, end_lat/lon, start_address, +end_address, art (privat | arbeitsweg), route (GeoJSON oder null), pausen, +source (ha | manual | audi_connect), status (offen | vollständig).

+

6.4 Tankvorgang-Schema

+

tank_id, receipt_key (Zeitstempel, minutengenau), receipt_no, tse_beleg_nr, ts, +ts_payment, ts_tse, station_id, station_name, station_address, article_no, +product_name, fuel_type, liters, list_price_per_l, discount, discount_per_l, +fuel_total_eur, receipt_total_eur, price_per_l, net_eur, vat_eur, odometer_km, +level_before_pct, level_after_pct, full_tank, source, status, receipt_file, +edited_fields.

+
+

7. Kernlogik

+

Diese sieben Punkte sind der eigentliche Auftrag. Die Oberfläche ist im Prototyp gelöst; hier liegt +die Arbeit.

+

7.1 Fahrterkennung

+
    +
  • Start: iPhone verbindet sich mit dem hinterlegten Fahrzeug-WLAN (Companion-App-Sensor, + Name in den Einstellungen hinterlegt, §5.6)
  • +
  • Ende: WLAN-Verbindung bricht ab und kommt nicht zurück
  • +
  • Pausenregel: Kommt die Verbindung binnen n Minuten zurück (Vorgabe 15, einstellbar), gilt + die Fahrt als fortgesetzt. Der Datensatz wird deshalb erst nach Ablauf der Wartezeit + geschrieben, nicht beim Abriss. Dieselbe Wartezeit löst danach auch das Kilometerstand-Screening + aus (§7.2).
  • +
+

7.2 Zweistufiger Fahrtabschluss

+
    +
  1. Bei Fahrtende (WLAN-Verbindung länger als die Pausenzeit weg, §7.1): Datensatz aus GPS und + Uhrzeit, Status offen
  2. +
  3. Screening: In der Recorder-Historie des Kilometerstand-Sensors wird nach dem Wert gesucht, + dessen Zeitstempel am nächsten am Verbindungsabbruch liegt — auch aus während der Fahrt + gemeldeten Werten, nicht nur aus dem letzten vor Fahrtende. Wird ein passender Wert gefunden: + Start- und Endkilometer nachtragen, Status vollständig
  4. +
  5. Wird keiner gefunden, bleibt der Status offen. Das Screening läuft erneut, sobald ein neuer + Kilometerstand eintrifft — auch wenn das erst mit der nächsten Fahrt geschieht, da der + Kilometerstand laut Datenquelle nicht sicher mit Fahrtende, sondern teils erst mit Beginn oder + während der folgenden Fahrt übermittelt wird
  6. +
+

Bleibt der Wert dauerhaft aus, greift ersatzweise die GPS-Strecke, kenntlich über km_quelle. Das +betrifft insbesondere Fahrten, die in kurzem Abstand aufeinanderfolgen, bevor ein neuer +Kilometerstand eintrifft: nur die erste und letzte Fahrt einer solchen Kette lassen sich über den +Kilometerstand abgrenzen, die dazwischenliegenden fallen auf GPS zurück.

+

Technische Voraussetzung: Zugriff auf die Recorder-Historie (nicht nur den aktuellen Zustand) und +eine ausreichende Aufbewahrungsdauer des Recorders.

+

7.3 Strecke aus dem Kilometerstand

+

km = Odometer(nach) − Odometer(vor). Das GPS liefert Route und Adressen, nicht die Distanz. +Dadurch ist eine lückenhafte Positionsaufzeichnung unschädlich.

+

7.4 Volltankung ableiten

+

Nicht abfragen. Füllstand nach dem Tanken ≥ 97 % → Volltankung. Bei Teilbetankung wird kein +Verbrauch ausgewiesen. Der Tankgeber meldet den Füllstand bereits unterhalb des Ausdehnungsraums — +100 % sind also tatsächlich 100 %, die Schwelle braucht keine Korrektur dafür. Die Auflösung des +Sensors ist ganzzahlig.

+

7.5 Reifenzähler

+

Kein utility_meter — stattdessen direkte Berechnung: Zähler = aktueller (absoluter) +Kilometerstand − Startwert des aktiven Satzes. Der Startwert je Satz ist als Stammdatum im +Fahrzeugprofil hinterlegt. Das vermeidet die Abhängigkeit von total_increasing und von +utility_meter-internem Zustand, der bei zeitweise unavailable Quellsensoren (§4.1) Strecke +verschlucken kann — die Subtraktion braucht nur den zuletzt bekannten Absolutwert, keinen +fortgeschriebenen Zählerstand.

+

7.6 Serviceprognose

+

Termine werden nicht gespeichert, sondern berechnet: letzter passender Servicebuch-Eintrag plus +Intervall. Gespeichert wird nur der vereinbarte Werkstatttermin.

+

Die Fahrleistung mischt zwei Zeiträume: den gesamten bekannten Verlauf und den Zeitraum seit dem +letzten Service, gewichtet über etwa 180 Tage. Damit wird die Vorhersage genauer, je näher das Ziel +rückt, und startet nach jedem Service neu.

+

Ein eingestelltes Intervall darf die Herstellervorgabe nicht überschreiten.

+

7.7 Belegverarbeitung

+

Beleg-Upload in der App (§5.5) → Parser → Datensatz. Ersetzt den in §4.3 ursprünglich vorgesehenen +Weg über ein Postfach — kein IMAP, keine Zugangsdaten nötig. Regeln:

+
    +
  1. Schlüssel ist der Zeitstempel aus der Kopfzeile, minutengenau
  2. +
  3. Liter, Preis und Betrag kommen vom Beleg, der Kilometerstand vom Fahrzeug
  4. +
  5. Manuell geänderte Felder werden nie von einem Import überschrieben
  6. +
  7. Nicht zuordenbare Fahrzeugereignisse bleiben als beleg_fehlt stehen
  8. +
  9. Der Gesamtbetrag kann Shop-Käufe enthalten — Kraftstoffanteil separat rechnen
  10. +
  11. Ohne Beleg: manuelle Eingabe von Liter und Preis als Fallback, bereits als Erfassungsweg unter + „Tanken" vorgesehen
  12. +
+
+

7a. Bilder

+

Bilder werden nicht eingebettet, sondern referenziert. Sie kommen am Ende dazu; die Anwendung +muss ohne sie vollständig bedienbar sein.

+

Ordner bilder/, feste Dateinamen:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
DateiVerwendung
seitenansicht.webp, front-schraeg.webp, frontansicht.webp, heckansicht.webp, scheinwerfer.webp, cockpit.webp, sitze.webpGalerie in „Mein Audi", eine davon als Übersichtsbild
seitenansicht-winter.webpSeitenansicht mit montierten Winterrädern
rad-sommer.webp, rad-winter.webpRadbilder in der Reifenansicht
fahrbahn-schnee.webpUntergrund bei montierten Winterrädern
+

Drei Bedingungen, damit das Nachreichen ohne Nacharbeit gelingt:

+
    +
  1. Die Flächen behalten ihre Maße. Bildfläche 206 px hoch, auf der Übersicht 165 px, Radbilder + 78 × 78 px. Platzhalter belegen dieselben Maße. Andernfalls springt das Layout, sobald die Bilder + kommen — und zwar in jeder Ansicht gleichzeitig.
  2. +
  3. Die Namen stehen vorher fest. Das Übersichtsbild wird über seinen Namen ausgewählt, nicht + über eine Position. Ein späterer Namenswechsel bricht die Konfiguration.
  4. +
  5. Die Bildaufbereitung ist ein eigenes Arbeitspaket, kein Nebenprodukt. Sie umfasst: + Freistellen des weißen Hintergrunds über eine Flutfüllung vom Rand — nicht über eine + Helligkeitsschwelle, sonst verschwinden Kennzeichen und Tagfahrlicht —, Montage der Winterräder + in die Seitenansicht, kreisrunde Maskierung der Radbilder und Zuschnitt der Fahrbahn mit + ausgeblendeter Oberkante.
  6. +
+

Der beiliegende Prototyp enthält alle Bilder bereits aufbereitet; sie können übernommen werden.

+

8. Gestaltung

+

Verbindlich ist der Prototyp. Die Regeln dahinter:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fläche#161b23, Kacheln #1f2733
Text#FFFFFF, sekundär #9aa1ad, Beschriftungen #657081
AkzentProgressive Red #F50537, sparsam
SignalfarbenGrün #15da15, Gelb #ffaa00, Rot #fd2c4e (Nachtmodus)
RadienKacheln 20 px, Schaltflächen vollrund — dazwischen nichts
SchriftAudi Type: Normal für die Oberfläche, Wide Light für Kennzahlen, Extended Italic für den Fahrzeugtitel
Zahlendurchgehend Tausenderpunkt, Tabellenziffern
+

Tag- und Nachtmodus sind umschaltbar, alle Farben liegen als Rollen vor. Keine Schatten, keine +Verläufe außer der Fahrbahn unter dem Fahrzeug.

+

Rechtlich: Audi Type, Ringe und Typenschild sind lizenz- beziehungsweise markenrechtlich +geschützt und ausschließlich für diese private, nicht veröffentlichte Installation freigegeben.

+
+

9. Nicht-funktionale Anforderungen

+
    +
  • Erreichbarkeit: über Tailscale. Kein Funnel, keine Subnetz-Routen, kein Exit Node. Das iPhone + darf ausschließlich Home Assistant auf Port 8123 erreichen. Schlüsselablauf für das HA-Gerät + deaktivieren — andernfalls fällt der Zugang nach Monaten still aus. Verbindungsaufbau über + „VPN On Demand" (ab Tailscale 1.48 für iOS), Regel „Only On" gebunden an das Fahrzeug-WLAN — + kein dauerhaft aktiver Tunnel, geringerer Akkuverbrauch, und der Tunnel steht genau dann, wenn + auch die Fahrterkennung anspringt. Die WLAN-Zuordnung liegt in der Tailscale-App selbst und muss + bei einer Änderung des WLAN-Namens (§5.6) dort separat nachgezogen werden.
  • +
  • Ausfall der Datenquelle: Veraltete Werte müssen als solche erkennbar sein. Die Anzeige + „Sicher abgestellt" hat drei Zustände: grün, rot und unbekannt mit Zeitstempel. Kein grünes + Häkchen auf Basis alter Daten.
  • +
  • Sicherung: Profil und Archive müssen gesichert und wiederhergestellt werden können, + automatisch und von Hand.
  • +
  • Ausgabe: Fahrten, Tankvorgänge und Servicebuch als CSV mit Semikolon und deutschem + Zahlenformat.
  • +
  • Kalender: Service- und Reifenwechseltermine als Eintrag in den iOS-Kalender, mit Vorwarnung.
  • +
  • Karten: Leaflet mit dem tatsächlich aufgezeichneten Track, nicht mit einer berechneten Route. + Kachelquelle wechselt mit dem Modus.
  • +
  • Notrufnummern müssen auch dann erreichbar sein, wenn die Anwendung nicht lädt.
  • +
+
+

10. Vor Baubeginn zu klären

+

Diese Punkte waren bewusst offen und betreffen die Umsetzung in Home Assistant. Sie sind +inzwischen geklärt; Fragestellung und Entscheidung stehen im Folgenden jeweils zusammen.

+
    +
  1. Einbindung der Oberfläche. Eigenes Panel, eingebettete Seite, eigene Lovelace-Karte oder + Add-on? Der Prototyp ist eine einzelne HTML-Datei — welche Form davon trägt im Dauerbetrieb, und + wie kommt die Oberfläche an Zustände und Dienste?
    + Entscheidung: panel_custom — Home Assistants eingebaute Panel-Integration, per YAML + registriert, kein Add-on und keine eigene Integration nötig. Das JS-Custom-Element aus dem + Prototyp bekommt automatisch das hass-Objekt injiziert (Live-Zustände per WebSocket, + callService() für Aktionen); die bestehenden Render-Funktionen (vHome(), vTrips() usw.) + bleiben erhalten, nur die Datenquelle wechselt von den Beispielarrays auf hass.states.
  2. +
  3. Zurückschreiben von Daten. Wie schreibt die Oberfläche Dateien im Home-Assistant-Dateisystem? + Eigene Integration, AppDaemon, pyscript, Skript im Add-on? Diese Entscheidung bestimmt den + gesamten Aufbau.
    + Entscheidung: pyscript für die gesamte Logik — Fahrterkennung, Fahrtabschluss, + Belegverarbeitung, Dateizugriff. Läuft installationsunabhängig (Core, Container, OS, + Supervised), im HA-Python-Kontext, mit @service für aufrufbare Aktionen und + @time_trigger/task.sleep für die Wartezeit-Logik aus §7.1.
  4. +
  5. Bluetooth- und WLAN-Erkennung unter iOS. Meldet die Companion App die Verbindung zum + Fahrzeug zuverlässig genug für die Fahrterkennung, oder braucht es Kurzbefehle? Das ist die + riskanteste Annahme des Entwurfs — trägt sie nicht, muss die Fahrterkennung anders gelöst + werden.
    + Entscheidung: WLAN statt Bluetooth, Kurzbefehle entfallen. Die WLAN-Verbindung zum + Fahrzeug baut sich nach bisheriger Erfahrung zuverlässig sofort auf und ab; Bluetooth-Region- + Monitoring unter iOS wäre dagegen mit mehrminütiger Verzögerung riskant gewesen. Trigger ist der + Companion-App-Sensor für das verbundene WLAN (§4.2, §7.1).
  6. +
  7. Verzögerung des Kilometerstands. Wie lange dauert es nach dem Abstellen tatsächlich, bis der + Wert nachkommt? Bestimmt die Wartezeit im zweistufigen Abschluss.
    + Entscheidung: kein fester Wartewert — der Kilometerstand kommt nicht sicher mit + Fahrtende, sondern teils erst mit Beginn oder während der nächsten Fahrt. Der Fahrtabschluss + läuft deshalb ereignisgesteuert über ein Screening der Recorder-Historie statt über einen + Timeout (§7.2).
  8. +
  9. Auflösung des Tankfüllstands. Prozentgenau oder gerastert? Bestimmt, ob die 97-Prozent-Regel + für die Volltankung trägt.
    + Entscheidung: ganzzahlig. Der Tankgeber meldet den Stand bereits unterhalb des + Ausdehnungsraums, 100 % sind also tatsächlich 100 % — die 97-Prozent-Schwelle bleibt + unverändert (§7.4).
  10. +
  11. Meldet die Integration den Kilometerstand als total_increasing? Falls nicht, ist ein + vorgeschalteter Template-Sensor nötig, sonst funktioniert der Reifenzähler nicht.
    + Entscheidung: Frage erübrigt sich — kein utility_meter, kein Template-Sensor. Der + Reifenzähler rechnet direkt mit dem absoluten Kilometerstand gegen den im Profil hinterlegten + Startwert je Satz (§7.5).
  12. +
  13. Abholung der Belege aus dem Postfach. Welcher Weg, und wo läuft der Parser?
    + Entscheidung: kein Postfach. Belege werden als PDF direkt in der App hochgeladen (§4.3, + §5.5, §7.7); ohne Beleg ersatzweise manuelle Eingabe von Liter und Preis.
  14. +
  15. Verhalten des Tailscale-Tunnels beim Losfahren. Steht er rechtzeitig für den Fahrtstart?
    + Entscheidung: Tailscale „VPN On Demand" mit Regel „Only On", gebunden an das + Fahrzeug-WLAN (§9). Der Tunnel baut sich damit zeitgleich mit der Fahrterkennung auf, kein + dauerhaft aktiver Tunnel nötig.
  16. +
+
+

11. Abnahmekriterien

+
    +
  1. Eine Fahrt wird ohne Zutun erfasst, mit korrekten Kilometern aus dem Fahrzeug, und erscheint + vollständig in der Liste.
  2. +
  3. Ein Tankstopp mitten in einer Fahrt erzeugt eine Fahrt, nicht zwei.
  4. +
  5. Ein hochgeladener Shell-Beleg erzeugt einen vollständigen Tankvorgang. Derselbe Beleg ein + zweites Mal hochgeladen erzeugt keinen zweiten.
  6. +
  7. Eine manuelle Korrektur überlebt den nächsten Import.
  8. +
  9. Ein Eintrag im Servicebuch verschiebt die zugehörige Fälligkeit.
  10. +
  11. Das Umschalten der Reifen leitet die Kilometer auf den anderen Satz um und verliert bei einem + Neustart nichts.
  12. +
  13. Fällt die Datenquelle aus, zeigt die Oberfläche das an, statt alte Werte als aktuell auszugeben.
  14. +
  15. Ein Fahrzeugprofil lässt sich ausgeben, in einem Texteditor ändern und wieder einlesen.
  16. +
  17. Die Oberfläche ist über Tailscale erreichbar, ohne dass Home Assistant öffentlich steht.
  18. +
+
+

12. Ausdrücklich nicht Bestandteil

+
    +
  • Fernsteuerung des Fahrzeugs — der Data-Act-Zugang ist ausschließlich lesend
  • +
  • Fehlerspeicher und Live-Telemetrie über OBD
  • +
  • Fahrstil- oder Effizienzbewertung
  • +
  • Höchstgeschwindigkeit je Fahrt
  • +
  • Mehrbenutzerbetrieb
  • +
  • Veröffentlichung, Weitergabe oder Verwendung der Marken- und Schriftlizenzen außerhalb dieser + Installation
  • +
+
+

13. Was noch zu liefern ist

+

Vom Auftraggeber, vor oder während des Baus:

+
    +
  • Bilder der Winterräder in höherer Auflösung
  • +
  • Fälligkeitsdatum der Kfz-Steuer
  • +
  • Reifendaten je Satz: Hersteller, Modell, DOT, bisherige Laufleistung
  • +
+

Entfallen gegenüber der ursprünglichen Liste: Zugangsdaten und Postfach für die Belegabholung +(Beleg-Upload statt Mailanbindung, §7.7) und Bestätigung des Bluetooth-Gerätenamens des MMI +(Fahrterkennung läuft über WLAN, §7.1) — der WLAN-Name ist bereits bekannt +(Audi_MMI_2804_5GHz) und in den Einstellungen hinterlegt (§5.6).

+
+

14. Zeitlicher Rahmen

+

Eine Frist ist hart: Die Alt-App wird Ende 2026 abgeschaltet. Der Export des vorhandenen +Fahrtenbuchs muss vorher erfolgen — unabhängig vom Baufortschritt und als Erstes. Was danach +verloren ist, ist nicht wiederherstellbar.

+

Der Import der exportierten Daten in das neue System ist davon unabhängig und wird bewusst +zurückgestellt, bis die App sich im laufenden Betrieb bewährt hat. Die Frist betrifft nur die +Sicherung der Daten, nicht ihre Weiterverarbeitung.

+

Alles Übrige lässt sich auch nach der Abschaltung bauen.

+
+ + diff --git a/bauauftrag.md b/bauauftrag.md new file mode 100644 index 0000000..0c7c5f9 --- /dev/null +++ b/bauauftrag.md @@ -0,0 +1,449 @@ +# Bauauftrag: Fahrzeug-Dashboard in Home Assistant + +**Stand 05.08.2026 · privates Projekt, keine Veröffentlichung** + +*Überarbeitung: §10 vollständig geklärt; Änderungen in §4.2, §5.6, §6.1, §7.1, §7.2, §7.4, §7.5, §7.7, §9, §10, §13, §14.* + +--- + +## 1. Der Auftrag in einem Satz + +Zu bauen ist ein Fahrzeug-Dashboard in Home Assistant, das die Ende 2026 abgeschaltete App +*Audi connect plug & play* ersetzt: Fahrzeugstatus, Fahrtenbuch, Tankmonitor, Service, Kosten und +Versicherung für einen Audi RS 4 Avant competition — in einer Oberfläche, die sich an der Audi-App +orientiert. + +--- + +## 2. Was mitgeliefert wird + +| Datei | Inhalt | +|---|---| +| `dashboard-muster-ohne-bilder.html` | Dieselbe Anwendung mit referenzierten statt eingebetteten Bildern, 184 statt 770 KB — die Fassung, mit der gearbeitet wird | +| `bilder/` | Die aufbereiteten Bilddateien, elf Stück, zusammen 435 KB | +| `dashboard-muster.html` | **Vollständig bedienbarer Prototyp.** Eine einzelne HTML-Datei, alle Ansichten, alle Interaktionen, Beispieldaten. Das ist die verbindliche Vorlage für Aufbau, Verhalten und Gestaltung | +| `audi-connect-nach-homeassistant.md` | Projektbeschreibung mit allen Entscheidungen und ihren Begründungen | +| `shell_beleg_parser.py` | Getesteter Parser für Shell-Tankbelege, gegen zwei echte Belege geprüft | +| Schriften, Ringe, Typenschild, Fahrzeugbilder | im Prototyp eingebettet, separat verfügbar | + +**Der Prototyp ist keine Skizze.** Er läuft, alle Wege sind begehbar. Wo dieser Auftrag und der +Prototyp voneinander abweichen, gilt der Prototyp — außer bei den unter §10 genannten Punkten. + +--- + +## 3. Systemumgebung + +- **Home Assistant**, bestehende Installation +- **Fahrzeugdaten:** HACS-Integration `TommiG1/HA_VAG-EU-Data-Act`, bereits eingerichtet und liefernd +- **Positionsdaten:** Home Assistant Companion App auf einem iPhone +- **Zugriff von außen:** Tailscale, kein Portforwarding, kein Funnel +- **Fahrzeug:** Audi RS 4 Avant competition, FIN WUAZZZF48PA902804, Erstzulassung 08/2023 + +--- + +## 4. Datenquellen + +### 4.1 EU-Data-Act-Portal über die HACS-Integration + +Liefert Kilometerstand, Tankfüllstand, Reichweite, Serviceintervalle, Türen- und Fensterstatus, +Verriegelung. **Kein GPS.** Aktualisierung im 15-Minuten-Takt und nur, wenn das Fahrzeug in diesem +Fenster Daten hochgeladen hat. Leere Zyklen sind normal. + +**Konsequenzen, die im Entwurf bereits berücksichtigt sind:** + +- Keine Live-Telemetrie. Momentanverbrauch, Drehzahl, Geschwindigkeit gibt es nicht. +- Der Kilometerstand steht nach dem Abstellen erst verzögert bereit → zweistufiger Fahrtabschluss (§7.2). +- Der Sensor ist zeitweise `unavailable` → betrifft den Reifenzähler (§7.5). + +### 4.2 iPhone + +Positionen über die Companion App. Fahrtstart und Fahrtende über die WLAN-Verbindung zum Fahrzeug +(Companion-App-Sensor für das verbundene Netz), **nicht** über CarPlay — CarPlay ist nicht immer +verbunden. Bluetooth wurde verworfen: iOS meldet Bluetooth-Verbindungswechsel im Hintergrund über +Region-Monitoring, das mehrere Minuten verzögern kann; die WLAN-Verbindung zum Fahrzeug baut sich +nach bisheriger Erfahrung zuverlässig sofort auf und ab. Der WLAN-Name (`Audi_MMI_2804_5GHz`) ist +fahrzeugspezifisch, liegt im Fahrzeugprofil (§6.1) und ist in den Einstellungen änderbar (§5.6). + +### 4.3 Shell-Tankbelege + +PDF per Upload in der App (§5.5, §7.7) — kein Postfachzugriff. Parser liegt bei. Belegnummer ist +**nicht** eindeutig; Schlüssel ist der minutengenaue Zeitstempel aus der Belegkopfzeile. + +### 4.4 Manuelle Eingaben + +Stammdaten, Versicherung, Steuer, Reifendaten, Servicebuch, Werkstatt, Fahrten und Tankvorgänge +ohne Beleg. + +--- + +## 5. Aufbau der Oberfläche + +Fünf Menüpunkte in einer festen Leiste unten, Symbole mit Beschriftung: + +``` +Übersicht · Mein Audi · Fahrten · Statistik · Tanken +``` + +Einstellungen liegen hinter den Audi-Ringen oben rechts. + +### 5.1 Übersicht + +Fahrzeugbild (Frontansicht, fest), Typenschild mit Kennzeichen, Zustandszeile „Sicher abgestellt" +mit grünem oder rotem Punkt, Reichweite als große Zahl mit Balken und Prozentwert, Kilometerstand +und nächster Service als Kachelpaar, darunter letzte Fahrt und letzter Tankvorgang als Anrisse. + +### 5.2 Mein Audi + +Bildergalerie zum Durchtippen, Typenschild, dann Kacheln mit Sprung auf eigene Seiten: + +| Kachel | Unterseite | +|---|---| +| Identität | Fahrzeugdaten, technische Daten, Ausstattung | +| Zustand | Kilometerstand, Tankfüllung, Batteriespannung | +| Service | Termine, Servicebuch, Autohaus, Terminvereinbarung | +| Kosten | Jahreskosten, Aufteilung, je Kilometer | +| Versicherung und Steuer | Beitrag, Vertrag, Notruf, Schutzbrief, Kfz-Steuer | +| Reifen | beide Sätze, Umschalter, Wechseltermin, Anzugsmoment | + +### 5.3 Fahrten + +Dreistufig aufklappbar: Jahr → Monat → Fahrt. Detailseite mit Karte, Start, Ziel, Start- und +Endkilometer, Distanz, Dauer, Ø-Geschwindigkeit, Kraftstoffkosten, Art der Fahrt. Erfassung von +Hand über eine Schaltfläche unter der Liste. + +### 5.4 Statistik + +Aufklappbare Kacheln: Distanz und Fahrzeit, getankte Menge und Kosten, Durchschnittsverbrauch, +Tag/Nacht, Art der Fahrten. Aufschlüsselung je Jahr, Monat, Woche, Tag. + +### 5.5 Tanken + +Jahr → Monat → Tankvorgang, mit mengengewichtetem Durchschnittspreis je Ebene. Detailseite mit +Karte der Tankstelle, Kosten, Preis je Liter, SmartDeal-Ersparnis, abgeleiteter Volltankung und +Verbrauch. Erfassung von Hand und Beleg-Upload. + +### 5.6 Einstellungen + +Tag- und Nachtmodus, Beschriftung der Menüleiste, Fahrbahn unter dem Fahrzeug, Übersichtsbild, +WLAN-Name des Fahrzeugs (Textfeld mit Übernehmen-Knopf für das aktuell verbundene Netz), +Fahrten-Pausenzeit, SmartDeal mit Ablaufdatum, Ölwechsel-Intervall, Fahrzeugprofil sichern und +laden, CSV-Ausgabe, Sicherung, Versionsangaben. + +--- + +## 6. Datenhaltung + +### 6.1 Drei getrennte Bestände + +| Bestand | Form | Begründung | +|---|---|---| +| **Fahrzeugprofil** | eine JSON-Datei | alles Fahrzeugspezifische an einem Ort, für Portierung austauschbar | +| **Fahrten** | JSON Lines, eine Zeile je Fahrt | wächst laufend, überlebt Purge und Neuinstallation | +| **Tankvorgänge** | JSON Lines | dito | + +Servicebuch, Versicherung, Steuer, Reifen und Werkstatt gehören ins **Profil**, nicht in die +Archive — sie beschreiben das Fahrzeug, nicht seine Bewegung. Der WLAN-Name des Fahrzeugs gehört +aus demselben Grund ebenfalls ins Profil. + +### 6.2 Portierbarkeit ist Anforderung, nicht Zugabe + +Die Anwendung muss ohne Codeänderung für ein anderes Fahrzeug nutzbar sein. Das heißt: **keine +fahrzeugbezogene Angabe außerhalb des Profils**, auch keine Bezeichnungen in Texten. Bilder werden +referenziert, nicht eingebettet. + +Drei Wege der Bearbeitung sind zu unterstützen: in der Oberfläche, über Sichern und Laden der +Datei, und direkt im Dateisystem. + +### 6.3 Fahrten-Schema + +`trip_id`, `ts_start`, `ts_end`, `duration_s`, `distance_km`, `km_quelle` (`odometer` | `gps`), +`odo_start`, `odo_end`, `avg_speed_kmh`, `start_lat/lon`, `end_lat/lon`, `start_address`, +`end_address`, `art` (`privat` | `arbeitsweg`), `route` (GeoJSON oder `null`), `pausen`, +`source` (`ha` | `manual` | `audi_connect`), `status` (`offen` | `vollständig`). + +### 6.4 Tankvorgang-Schema + +`tank_id`, `receipt_key` (Zeitstempel, minutengenau), `receipt_no`, `tse_beleg_nr`, `ts`, +`ts_payment`, `ts_tse`, `station_id`, `station_name`, `station_address`, `article_no`, +`product_name`, `fuel_type`, `liters`, `list_price_per_l`, `discount`, `discount_per_l`, +`fuel_total_eur`, `receipt_total_eur`, `price_per_l`, `net_eur`, `vat_eur`, `odometer_km`, +`level_before_pct`, `level_after_pct`, `full_tank`, `source`, `status`, `receipt_file`, +`edited_fields`. + +--- + +## 7. Kernlogik + +Diese sieben Punkte sind der eigentliche Auftrag. Die Oberfläche ist im Prototyp gelöst; hier liegt +die Arbeit. + +### 7.1 Fahrterkennung + +- **Start:** iPhone verbindet sich mit dem hinterlegten Fahrzeug-WLAN (Companion-App-Sensor, Name + in den Einstellungen hinterlegt, §5.6) +- **Ende:** WLAN-Verbindung bricht ab und kommt nicht zurück +- **Pausenregel:** Kommt die Verbindung binnen *n* Minuten zurück (Vorgabe 15, einstellbar), gilt + die Fahrt als fortgesetzt. Der Datensatz wird deshalb **erst nach Ablauf der Wartezeit** + geschrieben, nicht beim Abriss. Dieselbe Wartezeit löst danach auch das + Kilometerstand-Screening aus (§7.2). + +### 7.2 Zweistufiger Fahrtabschluss + +1. Bei Fahrtende (WLAN-Verbindung länger als die Pausenzeit weg, §7.1): Datensatz aus GPS und + Uhrzeit, Status `offen` +2. Screening: In der Recorder-Historie des Kilometerstand-Sensors wird nach dem Wert gesucht, + dessen Zeitstempel am nächsten am Verbindungsabbruch liegt — auch aus während der Fahrt + gemeldeten Werten, nicht nur aus dem letzten vor Fahrtende. Wird ein passender Wert gefunden: + Start- und Endkilometer nachtragen, Status `vollständig` +3. Wird keiner gefunden, bleibt der Status `offen`. Das Screening läuft erneut, sobald ein neuer + Kilometerstand eintrifft — auch wenn das erst mit der nächsten Fahrt geschieht, da der + Kilometerstand laut Datenquelle nicht sicher mit Fahrtende, sondern teils erst mit Beginn oder + während der folgenden Fahrt übermittelt wird + +Bleibt der Wert dauerhaft aus, greift ersatzweise die GPS-Strecke, kenntlich über `km_quelle`. Das +betrifft insbesondere Fahrten, die in kurzem Abstand aufeinanderfolgen, bevor ein neuer +Kilometerstand eintrifft: nur die erste und letzte Fahrt einer solchen Kette lassen sich über den +Kilometerstand abgrenzen, die dazwischenliegenden fallen auf GPS zurück. + +Technische Voraussetzung: Zugriff auf die Recorder-Historie (nicht nur den aktuellen Zustand) und +eine ausreichende Aufbewahrungsdauer des Recorders. + +### 7.3 Strecke aus dem Kilometerstand + +`km = Odometer(nach) − Odometer(vor)`. Das GPS liefert Route und Adressen, **nicht** die Distanz. +Dadurch ist eine lückenhafte Positionsaufzeichnung unschädlich. + +### 7.4 Volltankung ableiten + +Nicht abfragen. Füllstand nach dem Tanken ≥ 97 % → Volltankung. Bei Teilbetankung wird **kein** +Verbrauch ausgewiesen. Der Tankgeber meldet den Füllstand bereits unterhalb des Ausdehnungsraums — +100 % sind also tatsächlich 100 %, die Schwelle braucht keine Korrektur dafür. Die Auflösung des +Sensors ist ganzzahlig. + +### 7.5 Reifenzähler + +Kein `utility_meter` — stattdessen direkte Berechnung: Zähler = aktueller (absoluter) +Kilometerstand − Startwert des aktiven Satzes. Der Startwert je Satz ist als Stammdatum im +Fahrzeugprofil hinterlegt. Das vermeidet die Abhängigkeit von `total_increasing` und von +`utility_meter`-internem Zustand, der bei zeitweise `unavailable` Quellsensoren (§4.1) Strecke +verschlucken kann — die Subtraktion braucht nur den zuletzt bekannten Absolutwert, keinen +fortgeschriebenen Zählerstand. + +### 7.6 Serviceprognose + +Termine werden **nicht gespeichert, sondern berechnet**: letzter passender Servicebuch-Eintrag plus +Intervall. Gespeichert wird nur der vereinbarte Werkstatttermin. + +Die Fahrleistung mischt zwei Zeiträume: den gesamten bekannten Verlauf und den Zeitraum seit dem +letzten Service, gewichtet über etwa 180 Tage. Damit wird die Vorhersage genauer, je näher das Ziel +rückt, und startet nach jedem Service neu. + +Ein eingestelltes Intervall darf die Herstellervorgabe nicht überschreiten. + +### 7.7 Belegverarbeitung + +Beleg-Upload in der App (§5.5) → Parser → Datensatz. Ersetzt den in §4.3 ursprünglich vorgesehenen +Weg über ein Postfach — kein IMAP, keine Zugangsdaten nötig. Regeln: + +1. Schlüssel ist der Zeitstempel aus der Kopfzeile, minutengenau +2. Liter, Preis und Betrag kommen vom Beleg, der Kilometerstand vom Fahrzeug +3. Manuell geänderte Felder werden nie von einem Import überschrieben +4. Nicht zuordenbare Fahrzeugereignisse bleiben als `beleg_fehlt` stehen +5. Der Gesamtbetrag kann Shop-Käufe enthalten — Kraftstoffanteil separat rechnen +6. Ohne Beleg: manuelle Eingabe von Liter und Preis als Fallback, bereits als Erfassungsweg unter + „Tanken" vorgesehen + +--- + +## 7a. Bilder + +Bilder werden **nicht** eingebettet, sondern referenziert. Sie kommen am Ende dazu; die Anwendung +muss ohne sie vollständig bedienbar sein. + +**Ordner `bilder/`, feste Dateinamen:** + +| Datei | Verwendung | +|---|---| +| `seitenansicht.webp`, `front-schraeg.webp`, `frontansicht.webp`, `heckansicht.webp`, `scheinwerfer.webp`, `cockpit.webp`, `sitze.webp` | Galerie in „Mein Audi", eine davon als Übersichtsbild | +| `seitenansicht-winter.webp` | Seitenansicht mit montierten Winterrädern | +| `rad-sommer.webp`, `rad-winter.webp` | Radbilder in der Reifenansicht | +| `fahrbahn-schnee.webp` | Untergrund bei montierten Winterrädern | + +**Drei Bedingungen, damit das Nachreichen ohne Nacharbeit gelingt:** + +1. **Die Flächen behalten ihre Maße.** Bildfläche 206 px hoch, auf der Übersicht 165 px, Radbilder + 78 × 78 px. Platzhalter belegen dieselben Maße. Andernfalls springt das Layout, sobald die Bilder + kommen — und zwar in jeder Ansicht gleichzeitig. +2. **Die Namen stehen vorher fest.** Das Übersichtsbild wird über seinen Namen ausgewählt, nicht + über eine Position. Ein späterer Namenswechsel bricht die Konfiguration. +3. **Die Bildaufbereitung ist ein eigenes Arbeitspaket**, kein Nebenprodukt. Sie umfasst: + Freistellen des weißen Hintergrunds über eine Flutfüllung vom Rand — nicht über eine + Helligkeitsschwelle, sonst verschwinden Kennzeichen und Tagfahrlicht —, Montage der Winterräder + in die Seitenansicht, kreisrunde Maskierung der Radbilder und Zuschnitt der Fahrbahn mit + ausgeblendeter Oberkante. + +Der beiliegende Prototyp enthält alle Bilder bereits aufbereitet; sie können übernommen werden. + +## 8. Gestaltung + +Verbindlich ist der Prototyp. Die Regeln dahinter: + +| | | +|---|---| +| Fläche | Nacht `#161b23`, Kacheln `#1f2733` — Tag `#FFFFFF`, Kacheln `#f2f2f2` | +| Text | Nacht `#FFFFFF`, sekundär `#9aa1ad`, Beschriftungen `#657081` — Tag `#000000`, sekundär `#4c4c4c`, Beschriftungen `#666666` | +| Akzent | Progressive Red `#F50537`, sparsam (theme-unabhängig) | +| Signalfarben | Nacht: Grün `#15da15`, Gelb `#ffaa00`, Rot `#fd2c4e` — Tag: Grün `#0DA20D`, Gelb `#ffaa00`, Rot `#eb0d3f` | +| Radien | Kacheln 20 px, Schaltflächen vollrund. Ausnahme: kleine funktionale Elemente (Formularfelder, Mini-Bildboxen, Kürzel-Badges) dürfen 6–12 px nutzen, wo die volle Kachelrundung optisch zu grob wirkt | +| Schrift | Audi Type: Normal für die Oberfläche, Wide Light für Kennzahlen, Extended Italic für den Fahrzeugtitel. Nur die Schnitte Light (300) und Normal (400) sind eingebunden — kein `font-weight:600` oder höher verwenden, der Browser würde sonst synthetisch fetten | +| Zahlen | durchgehend Tausenderpunkt, Tabellenziffern | + +Tag- und Nachtmodus sind umschaltbar, alle Farben liegen als Rollen vor (CSS-Variablen), nie als +Literalwerte in Komponenten. Keine Schatten, keine Verläufe außer der Fahrbahn unter dem Fahrzeug. + +**Rechtlich:** Audi Type, Ringe und Typenschild sind lizenz- beziehungsweise markenrechtlich +geschützt und ausschließlich für diese private, nicht veröffentlichte Installation freigegeben. + +--- + +## 9. Nicht-funktionale Anforderungen + +- **Erreichbarkeit:** über Tailscale. Kein Funnel, keine Subnetz-Routen, kein Exit Node. Das iPhone + darf ausschließlich Home Assistant auf Port 8123 erreichen. Schlüsselablauf für das HA-Gerät + deaktivieren — andernfalls fällt der Zugang nach Monaten still aus. Verbindungsaufbau über + „VPN On Demand" (ab Tailscale 1.48 für iOS), Regel „Only On" gebunden an das Fahrzeug-WLAN — + kein dauerhaft aktiver Tunnel, geringerer Akkuverbrauch, und der Tunnel steht genau dann, wenn + auch die Fahrterkennung anspringt. Die WLAN-Zuordnung liegt in der Tailscale-App selbst und muss + bei einer Änderung des WLAN-Namens (§5.6) dort separat nachgezogen werden. +- **Ausfall der Datenquelle:** Veraltete Werte müssen als solche erkennbar sein. Die Anzeige + „Sicher abgestellt" hat drei Zustände: grün, rot und **unbekannt** mit Zeitstempel. Kein grünes + Häkchen auf Basis alter Daten. +- **Sicherung:** Profil und Archive müssen gesichert und wiederhergestellt werden können, + automatisch und von Hand. +- **Ausgabe:** Fahrten, Tankvorgänge und Servicebuch als CSV mit Semikolon und deutschem + Zahlenformat. +- **Kalender:** Service- und Reifenwechseltermine als Eintrag in den iOS-Kalender, mit Vorwarnung. +- **Karten:** Leaflet mit dem tatsächlich aufgezeichneten Track, nicht mit einer berechneten Route. + Kachelquelle wechselt mit dem Modus. +- **Notrufnummern** müssen auch dann erreichbar sein, wenn die Anwendung nicht lädt. + +--- + +## 10. Vor Baubeginn zu klären + +Diese Punkte waren bewusst offen und betreffen die Umsetzung in Home Assistant. Sie sind inzwischen +geklärt; Fragestellung und Entscheidung stehen im Folgenden jeweils zusammen. + +1. **Einbindung der Oberfläche.** Eigenes Panel, eingebettete Seite, eigene Lovelace-Karte oder + Add-on? Der Prototyp ist eine einzelne HTML-Datei — welche Form davon trägt im Dauerbetrieb, und + wie kommt die Oberfläche an Zustände und Dienste? + **Entscheidung:** `panel_custom` — Home Assistants eingebaute Panel-Integration, per YAML + registriert, kein Add-on und keine eigene Integration nötig. Das JS-Custom-Element aus dem + Prototyp bekommt automatisch das `hass`-Objekt injiziert (Live-Zustände per WebSocket, + `callService()` für Aktionen); die bestehenden Render-Funktionen (`vHome()`, `vTrips()` usw.) + bleiben erhalten, nur die Datenquelle wechselt von den Beispielarrays auf `hass.states`. +2. **Zurückschreiben von Daten.** Wie schreibt die Oberfläche Dateien im Home-Assistant-Dateisystem? + Eigene Integration, AppDaemon, pyscript, Skript im Add-on? Diese Entscheidung bestimmt den + gesamten Aufbau. + **Entscheidung:** `pyscript` für die gesamte Logik — Fahrterkennung, Fahrtabschluss, + Belegverarbeitung, Dateizugriff. Läuft installationsunabhängig (Core, Container, OS, + Supervised), im HA-Python-Kontext, mit `@service` für aufrufbare Aktionen und + `@time_trigger`/`task.sleep` für die Wartezeit-Logik aus §7.1. +3. **Bluetooth- und WLAN-Erkennung unter iOS.** Meldet die Companion App die Verbindung zum + Fahrzeug zuverlässig genug für die Fahrterkennung, oder braucht es Kurzbefehle? **Das ist die + riskanteste Annahme des Entwurfs** — trägt sie nicht, muss die Fahrterkennung anders gelöst + werden. + **Entscheidung:** WLAN statt Bluetooth, Kurzbefehle entfallen. Die WLAN-Verbindung zum Fahrzeug + baut sich nach bisheriger Erfahrung zuverlässig sofort auf und ab; Bluetooth-Region-Monitoring + unter iOS wäre dagegen mit mehrminütiger Verzögerung riskant gewesen. Trigger ist der + Companion-App-Sensor für das verbundene WLAN (§4.2, §7.1). +4. **Verzögerung des Kilometerstands.** Wie lange dauert es nach dem Abstellen tatsächlich, bis der + Wert nachkommt? Bestimmt die Wartezeit im zweistufigen Abschluss. + **Entscheidung:** kein fester Wartewert — der Kilometerstand kommt nicht sicher mit Fahrtende, + sondern teils erst mit Beginn oder während der nächsten Fahrt. Der Fahrtabschluss läuft deshalb + ereignisgesteuert über ein Screening der Recorder-Historie statt über einen Timeout (§7.2). +5. **Auflösung des Tankfüllstands.** Prozentgenau oder gerastert? Bestimmt, ob die 97-Prozent-Regel + für die Volltankung trägt. + **Entscheidung:** ganzzahlig. Der Tankgeber meldet den Stand bereits unterhalb des + Ausdehnungsraums, 100 % sind also tatsächlich 100 % — die 97-Prozent-Schwelle bleibt + unverändert (§7.4). +6. **Meldet die Integration den Kilometerstand als `total_increasing`?** Falls nicht, ist ein + vorgeschalteter Template-Sensor nötig, sonst funktioniert der Reifenzähler nicht. + **Entscheidung:** Frage erübrigt sich — kein `utility_meter`, kein Template-Sensor. Der + Reifenzähler rechnet direkt mit dem absoluten Kilometerstand gegen den im Profil hinterlegten + Startwert je Satz (§7.5). +7. **Abholung der Belege aus dem Postfach.** Welcher Weg, und wo läuft der Parser? + **Entscheidung:** kein Postfach. Belege werden als PDF direkt in der App hochgeladen (§4.3, + §5.5, §7.7); ohne Beleg ersatzweise manuelle Eingabe von Liter und Preis. +8. **Verhalten des Tailscale-Tunnels beim Losfahren.** Steht er rechtzeitig für den Fahrtstart? + **Entscheidung:** Tailscale „VPN On Demand" mit Regel „Only On", gebunden an das Fahrzeug-WLAN + (§9). Der Tunnel baut sich damit zeitgleich mit der Fahrterkennung auf, kein dauerhaft aktiver + Tunnel nötig. + +--- + +## 11. Abnahmekriterien + +1. Eine Fahrt wird ohne Zutun erfasst, mit korrekten Kilometern aus dem Fahrzeug, und erscheint + vollständig in der Liste. +2. Ein Tankstopp mitten in einer Fahrt erzeugt **eine** Fahrt, nicht zwei. +3. Ein hochgeladener Shell-Beleg erzeugt einen vollständigen Tankvorgang. Derselbe Beleg ein + zweites Mal hochgeladen erzeugt keinen zweiten. +4. Eine manuelle Korrektur überlebt den nächsten Import. +5. Ein Eintrag im Servicebuch verschiebt die zugehörige Fälligkeit. +6. Das Umschalten der Reifen leitet die Kilometer auf den anderen Satz um und verliert bei einem + Neustart nichts. +7. Fällt die Datenquelle aus, zeigt die Oberfläche das an, statt alte Werte als aktuell auszugeben. +8. Ein Fahrzeugprofil lässt sich ausgeben, in einem Texteditor ändern und wieder einlesen. +9. Die Oberfläche ist über Tailscale erreichbar, ohne dass Home Assistant öffentlich steht. + +--- + +## 12. Ausdrücklich nicht Bestandteil + +- Fernsteuerung des Fahrzeugs — der Data-Act-Zugang ist ausschließlich lesend +- Fehlerspeicher und Live-Telemetrie über OBD +- Fahrstil- oder Effizienzbewertung +- Höchstgeschwindigkeit je Fahrt +- Mehrbenutzerbetrieb +- Veröffentlichung, Weitergabe oder Verwendung der Marken- und Schriftlizenzen außerhalb dieser + Installation + +--- + +## 13. Was noch zu liefern ist + +Vom Auftraggeber, vor oder während des Baus: + +- Bilder der Winterräder in höherer Auflösung +- Fälligkeitsdatum der Kfz-Steuer +- Reifendaten je Satz: Hersteller, Modell, DOT, bisherige Laufleistung + +Entfallen gegenüber der ursprünglichen Liste: Zugangsdaten und Postfach für die Belegabholung +(Beleg-Upload statt Mailanbindung, §7.7) und Bestätigung des Bluetooth-Gerätenamens des MMI +(Fahrterkennung läuft über WLAN, §7.1) — der WLAN-Name ist bereits bekannt +(`Audi_MMI_2804_5GHz`) und in den Einstellungen hinterlegt (§5.6). + +--- + +## 14. Zeitlicher Rahmen + +**Eine Frist ist hart:** Die Alt-App wird Ende 2026 abgeschaltet. Der Export des vorhandenen +Fahrtenbuchs muss vorher erfolgen — unabhängig vom Baufortschritt und als Erstes. Was danach +verloren ist, ist nicht wiederherstellbar. + +Der **Import** der exportierten Daten in das neue System ist davon unabhängig und wird bewusst +zurückgestellt, bis die App sich im laufenden Betrieb bewährt hat. Die Frist betrifft nur die +Sicherung der Daten, nicht ihre Weiterverarbeitung. + +Alles Übrige lässt sich auch nach der Abschaltung bauen. + +--- + +## 15. Backlog + +Punkte, die vorgemerkt, aber noch nicht umgesetzt sind. + +- **Kühlwasserstand- und Ölstand-Messung und -Auswertung** (vorgemerkt 09.08.2026): analog zur + Batteriespannungs-Aufzeichnung (§5.2 „Zustand" → Batteriespannung — Verlauf, Tagesminimum/-maximum + samt Zeitstempel) — sofern die Fahrzeug-Integration entsprechende Sensoren liefert. diff --git a/companion-app/.gitignore b/companion-app/.gitignore new file mode 100644 index 0000000..b518b92 --- /dev/null +++ b/companion-app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.local +.DS_Store diff --git a/companion-app/README.md b/companion-app/README.md new file mode 100644 index 0000000..0cb91d5 --- /dev/null +++ b/companion-app/README.md @@ -0,0 +1,81 @@ +# DataMetric360 — companion app + +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). + +## Status + +**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. + +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). + +## 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 +``` + +Comments are in German, matching the rest of the project. + +## What replaces what + +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 | +|---|---| +| `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(...)` | + +Same entities, same pyscript services, same backend files written — only the transport changes. + +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: + +```bash +$env:Path = "C:\Program Files\nodejs;" + $env:Path +``` + +```bash +npm run typecheck +``` + +```bash +npm run smoke +``` + +`smoke` targets `http://localhost:18123` (the `audi_ha_test` Docker container) by default; pass a +different base URL as the first argument. + +## Verification status (2026-08-10) + +`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. + +**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. diff --git a/companion-app/package-lock.json b/companion-app/package-lock.json new file mode 100644 index 0000000..77f0dd0 --- /dev/null +++ b/companion-app/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "datametric360", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "datametric360", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.7.2" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/companion-app/package.json b/companion-app/package.json new file mode 100644 index 0000000..f5337e3 --- /dev/null +++ b/companion-app/package.json @@ -0,0 +1,15 @@ +{ + "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.", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "smoke": "node --experimental-strip-types scripts/smoke.ts" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.7.2" + } +} diff --git a/companion-app/scripts/smoke.ts b/companion-app/scripts/smoke.ts new file mode 100644 index 0000000..e133647 --- /dev/null +++ b/companion-app/scripts/smoke.ts @@ -0,0 +1,79 @@ +/* Rauchtest gegen die laufende HA-Testinstanz (Docker, Port 18123). + Prüft bewusst nur das, was ohne Zugangsdaten prüfbar ist: + die Adressbildung, die Existenz der Endpunkte und den Beginn des + WebSocket-Handshakes. Damit ist belegt, dass rest.ts und live.ts die + richtigen Pfade und das richtige Protokoll ansprechen - ohne dass dafür + irgendwo ein Token eingesammelt werden müsste. + + Aufruf: node scripts/smoke.ts (Node >= 23, Typen werden entfernt) + node scripts/smoke.ts http://localhost:18123 */ + +import { basisUrlNormalisieren, websocketUrl } from "../src/api/umgebung.ts"; + +const BASIS = basisUrlNormalisieren(process.argv[2] ?? "http://localhost:18123"); + +let fehler = 0; +function pruefe(bezeichnung: string, bedingung: boolean, zusatz = ""): void { + console.log(`${bedingung ? "PASS" : "FAIL"} ${bezeichnung}${zusatz ? ` (${zusatz})` : ""}`); + if (!bedingung) fehler += 1; +} + +/* ------------------------------------------------- reine Funktionen */ + +pruefe("basisUrlNormalisieren schneidet Schrägstriche ab", + basisUrlNormalisieren("https://host/") === "https://host"); +pruefe("basisUrlNormalisieren ergänzt fehlendes Schema", + basisUrlNormalisieren("host.example") === "https://host.example"); +pruefe("basisUrlNormalisieren lässt http stehen", + basisUrlNormalisieren("http://localhost:18123") === "http://localhost:18123"); +pruefe("websocketUrl macht aus https wss", + websocketUrl("https://h") === "wss://h/api/websocket"); +pruefe("websocketUrl macht aus http ws", + websocketUrl("http://h") === "ws://h/api/websocket"); + +/* --------------------------------------------------- REST-Endpunkt */ + +try { + const antwort = await fetch(`${BASIS}/api/`, { headers: { "Content-Type": "application/json" } }); + /* Ohne Token muss HA mit 401 antworten. Genau das beweist zweierlei: + der Pfad /api/ existiert, und er ist nicht offen zugänglich. */ + pruefe("GET /api/ ohne Token antwortet 401", antwort.status === 401, `Status ${antwort.status}`); +} catch (e) { + pruefe("GET /api/ erreichbar", false, e instanceof Error ? e.message : String(e)); +} + +/* ---------------------------------------------- WebSocket-Handshake */ + +const wsErgebnis = await new Promise((fertig) => { + const frist = setTimeout(() => fertig("Zeitüberschreitung"), 8000); + try { + const socket = new WebSocket(websocketUrl(BASIS)); + socket.addEventListener("message", (e: MessageEvent) => { + clearTimeout(frist); + try { + fertig(String(JSON.parse(String(e.data)).type)); + } catch { + fertig("kein JSON"); + } + socket.close(); + }); + socket.addEventListener("error", () => { + clearTimeout(frist); + fertig("Verbindungsfehler"); + }); + } catch (e) { + clearTimeout(frist); + fertig(e instanceof Error ? e.message : String(e)); + } +}); + +/* live.ts wartet als Erstes auf genau diese Nachricht - stimmt sie nicht, + liefe der ganze Anmeldeablauf ins Leere. */ +pruefe("WebSocket meldet sich mit auth_required", wsErgebnis === "auth_required", `erhalten: ${wsErgebnis}`); + +console.log(fehler === 0 ? "\nAlle Prüfungen bestanden." : `\n${fehler} Prüfung(en) fehlgeschlagen.`); + +/* Nur den Rückgabewert setzen, nicht process.exit() aufrufen: unter Windows + stürzt libuv sonst mit einer Assertion ab, weil der eben geschlossene + WebSocket noch nicht vollständig abgebaut ist. So läuft Node normal aus. */ +process.exitCode = fehler === 0 ? 0 : 1; diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts new file mode 100644 index 0000000..b11b69a --- /dev/null +++ b/companion-app/src/api/index.ts @@ -0,0 +1,103 @@ +/* ================================================================ + Datenschicht DataMetric360 - Sammelexport + ================================================================ + Ein Zugang für die gesamte Oberfläche: REST für Lesen und Schreiben, + WebSocket für Push, Warteschlange fürs Funkloch. Welche Bildschirme es + gibt, ist hier bewusst unbekannt - diese Schicht überlebt jeden + Entwurfsdurchlauf unverändert. */ + +export * from "./types.ts"; +export * from "./umgebung.ts"; +export * from "./rest.ts"; +export * from "./live.ts"; +export * from "./warteschlange.ts"; + +import { ENTITAETEN } from "./types.ts"; +import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts"; +import { HassRest } from "./rest.ts"; +import { HassLive } from "./live.ts"; +import { Warteschlange } from "./warteschlange.ts"; + +/** Bündelt die drei Bausteine und bildet die Fachvorgänge ab, die das alte + Dashboard über hass.callService erledigt hat. */ +export class DataMetricApi { + readonly rest: HassRest; + readonly live: HassLive; + readonly warteschlange: Warteschlange; + + constructor() { + this.rest = new HassRest(); + this.live = new HassLive(); + this.warteschlange = new Warteschlange(this.rest); + + /* Sobald die Verbindung wieder steht, wartende Änderungen nachliefern - + ohne dass die Oberfläche etwas anstoßen muss. */ + this.live.aufVerbindung((zustand) => { + if (zustand === "verbunden") void this.warteschlange.abarbeiten(); + }); + } + + /* ------------------------------------------------------------ Lesen */ + + profilLesen(): Promise { + return this.rest.datenLesen(ENTITAETEN.profil); + } + + fahrtenLesen(): Promise { + return this.rest.datenLesen(ENTITAETEN.fahrten); + } + + tankvorgaengeLesen(): Promise { + return this.rest.datenLesen(ENTITAETEN.tankvorgaenge); + } + + fahrzeugstatusLesen(): Promise { + return this.rest.datenLesen(ENTITAETEN.fahrzeugstatus); + } + + /* --------------------------------------------------------- Schreiben + Alle schreibenden Vorgänge laufen über die Warteschlange, nicht direkt + über rest.dienstAufrufen(). Damit verhält sich die App im Funkloch + genauso wie mit Netz - nur eben zeitversetzt. */ + + profilSchreiben(profil: Profil): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_profil_schreiben", + { profil_json: JSON.stringify(profil) }, + "Fahrzeugdaten speichern", + ); + } + + belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_beleg_hochladen", + { pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) }, + `Beleg „${dateiname}" hochladen`, + ); + } + + fahrtLoeschen(tripId: string): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_fahrt_loeschen", + { trip_id: tripId }, + "Fahrt löschen", + ); + } + + tankvorgangLoeschen(tankId: string): Promise { + return this.warteschlange.einreihen( + "pyscript", + "audi_dashboard_tankvorgang_loeschen", + { tank_id: tankId }, + "Tankvorgang löschen", + ); + } + + /** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */ + jetztAktualisieren(): Promise { + return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren"); + } +} diff --git a/companion-app/src/api/live.ts b/companion-app/src/api/live.ts new file mode 100644 index 0000000..e2f7d5f --- /dev/null +++ b/companion-app/src/api/live.ts @@ -0,0 +1,167 @@ +/* ================================================================ + WebSocket-Verbindung: Push statt Abfragen + ================================================================ + Ohne das eingespritzte hass-Objekt gibt es keine automatischen Updates + mehr. Statt im Sekundentakt zu pollen (teuer auf dem Mobilfunknetz und + für den Akku) hängt sich die App an HAs eigenen Ereignisstrom. + + Ablauf laut HA-WebSocket-API: + Server: {type:"auth_required"} + Client: {type:"auth", access_token:"..."} + Server: {type:"auth_ok"} oder {type:"auth_invalid"} + Client: {id:1, type:"subscribe_events", event_type:"state_changed"} + + Der Verbindungszustand ist zugleich die Quelle für den Offline-Hinweis in + der Kopfzeile (COMPANION_APP_ARCHITECTURE.md §3 "Offline behavior"). */ + +import type { HassState } from "./types.ts"; +import { websocketUrl, zugangLesen, type Zugang } from "./umgebung.ts"; + +export type Verbindungszustand = "getrennt" | "verbindet" | "verbunden" | "nicht_angemeldet"; + +export interface ZustandsWechsel { + entity_id: string; + new_state: HassState | null; + old_state: HassState | null; +} + +type ZustandsHorcher = (wechsel: ZustandsWechsel) => void; +type VerbindungsHorcher = (zustand: Verbindungszustand) => void; + +/* Wartezeiten beim Wiederverbinden: schnell beim ersten Versuch (kurze + Funklöcher), dann zunehmend geduldiger, gedeckelt bei 30 s. Ohne Deckel + würde die App nach einer langen Nacht ohne Netz stundenlang schlafen. */ +const WARTEZEITEN_MS = [1_000, 2_000, 5_000, 10_000, 30_000] as const; + +export class HassLive { + #socket: WebSocket | null = null; + #zustand: Verbindungszustand = "getrennt"; + #naechsteId = 1; + #versuch = 0; + #wiederholung: ReturnType | null = null; + #beendet = false; + + #zustandsHorcher = new Set(); + #verbindungsHorcher = new Set(); + + get zustand(): Verbindungszustand { + return this.#zustand; + } + + get istVerbunden(): boolean { + return this.#zustand === "verbunden"; + } + + /** Meldet jede Zustandsänderung einer Entität. Rückgabe: Abmeldefunktion. */ + aufZustand(horcher: ZustandsHorcher): () => void { + this.#zustandsHorcher.add(horcher); + return () => this.#zustandsHorcher.delete(horcher); + } + + /** Meldet Verbindungswechsel - treibt den Offline-Hinweis in der Kopfzeile. */ + aufVerbindung(horcher: VerbindungsHorcher): () => void { + this.#verbindungsHorcher.add(horcher); + horcher(this.#zustand); + return () => this.#verbindungsHorcher.delete(horcher); + } + + #zustandSetzen(neu: Verbindungszustand): void { + if (this.#zustand === neu) return; + this.#zustand = neu; + for (const h of this.#verbindungsHorcher) h(neu); + } + + async starten(zugang?: Zugang): Promise { + this.#beendet = false; + const z = zugang ?? (await zugangLesen()); + if (!z) { + this.#zustandSetzen("nicht_angemeldet"); + return; + } + this.#verbinden(z); + } + + beenden(): void { + this.#beendet = true; + if (this.#wiederholung) { + clearTimeout(this.#wiederholung); + this.#wiederholung = null; + } + this.#socket?.close(); + this.#socket = null; + this.#zustandSetzen("getrennt"); + } + + #verbinden(zugang: Zugang): void { + this.#zustandSetzen("verbindet"); + + let socket: WebSocket; + try { + socket = new WebSocket(websocketUrl(zugang.basisUrl)); + } catch { + this.#erneutVersuchen(zugang); + return; + } + this.#socket = socket; + + socket.addEventListener("message", (ereignis) => { + let nachricht: Record; + try { + nachricht = JSON.parse(String(ereignis.data)); + } catch { + return; + } + + switch (nachricht["type"]) { + case "auth_required": + socket.send(JSON.stringify({ type: "auth", access_token: zugang.token })); + break; + + case "auth_ok": + this.#versuch = 0; + this.#zustandSetzen("verbunden"); + socket.send( + JSON.stringify({ id: this.#naechsteId++, type: "subscribe_events", event_type: "state_changed" }), + ); + break; + + case "auth_invalid": + /* Token ungültig - erneutes Verbinden hilft nicht, es braucht ein + neues Token. Deshalb hier kein Wiederholungsversuch. */ + this.#beendet = true; + this.#zustandSetzen("nicht_angemeldet"); + socket.close(); + break; + + case "event": { + const ereignisDaten = nachricht["event"] as { event_type?: string; data?: ZustandsWechsel } | undefined; + if (ereignisDaten?.event_type === "state_changed" && ereignisDaten.data) { + for (const h of this.#zustandsHorcher) h(ereignisDaten.data); + } + break; + } + + default: + break; + } + }); + + socket.addEventListener("close", () => { + if (this.#socket === socket) this.#socket = null; + if (this.#zustand !== "nicht_angemeldet") this.#erneutVersuchen(zugang); + }); + + socket.addEventListener("error", () => { + /* "error" kommt immer zusammen mit "close" - dort wird wiederholt. */ + }); + } + + #erneutVersuchen(zugang: Zugang): void { + if (this.#beendet) return; + this.#zustandSetzen("getrennt"); + + const wartezeit = WARTEZEITEN_MS[Math.min(this.#versuch, WARTEZEITEN_MS.length - 1)] ?? 30_000; + this.#versuch += 1; + this.#wiederholung = setTimeout(() => this.#verbinden(zugang), wartezeit); + } +} diff --git a/companion-app/src/api/rest.ts b/companion-app/src/api/rest.ts new file mode 100644 index 0000000..2cdc2a5 --- /dev/null +++ b/companion-app/src/api/rest.ts @@ -0,0 +1,137 @@ +/* ================================================================ + REST-Zugriff auf Home Assistant + ================================================================ + Ersetzt exakt das, was im alten panel_custom-Dashboard das eingespritzte + hass-Objekt geleistet hat (SPECIFICATION.md §3 "Data flow"): + hass.states[id].attributes.daten -> zustandLesen()/datenLesen() + hass.callService(...) -> dienstAufrufen() + Dieselben Entitäten, dieselben pyscript-Dienste - nur über HTTP statt über + ein Objekt, das nur innerhalb des HA-Frontends existiert. */ + +import type { DatenEntity, HassState } from "./types.ts"; +import { zugangLesen, type Zugang } from "./umgebung.ts"; + +export class ApiFehler extends Error { + constructor( + message: string, + readonly status: number | null, + readonly ursache?: unknown, + ) { + super(message); + this.name = "ApiFehler"; + } + + /** Fehlt oder taugt das Token nicht mehr - dann muss die Oberfläche zurück + in die Ersteinrichtung, statt es endlos weiterzuversuchen. */ + get istAnmeldeproblem(): boolean { + return this.status === 401 || this.status === 403; + } + + /** Kein Netz erreichbar (status===null) - Offline-Fall, keine echte + Fehlermeldung: die Oberfläche zeigt zwischengespeicherte Daten. */ + get istNetzproblem(): boolean { + return this.status === null; + } +} + +export interface AnfrageOptionen { + /** Bricht die Anfrage ab, statt im Funkloch minutenlang zu hängen. */ + zeitlimitMs?: number; + signal?: AbortSignal; +} + +const ZEITLIMIT_STANDARD_MS = 15_000; + +export class HassRest { + #zugang: Zugang | null = null; + + constructor(zugang?: Zugang) { + this.#zugang = zugang ?? null; + } + + /** Holt den Zugang beim ersten Bedarf aus der Ablage nach. */ + async #zugangHolen(): Promise { + if (this.#zugang) return this.#zugang; + const gelesen = await zugangLesen(); + if (!gelesen) throw new ApiFehler("Nicht eingerichtet: Server-Adresse oder Token fehlt.", 401); + this.#zugang = gelesen; + return gelesen; + } + + zugangSetzen(zugang: Zugang | null): void { + this.#zugang = zugang; + } + + async #anfrage(pfad: string, init: RequestInit, opt: AnfrageOptionen = {}): Promise { + const zugang = await this.#zugangHolen(); + + /* Eigener Abbruch-Zeitgeber, zusätzlich zu einem evtl. übergebenen + Signal - beide sollen abbrechen können. */ + const uhr = new AbortController(); + const frist = setTimeout(() => uhr.abort(), opt.zeitlimitMs ?? ZEITLIMIT_STANDARD_MS); + const abbruch = () => uhr.abort(); + opt.signal?.addEventListener("abort", abbruch); + + try { + const antwort = await fetch(`${zugang.basisUrl}${pfad}`, { + ...init, + signal: uhr.signal, + headers: { + Authorization: `Bearer ${zugang.token}`, + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); + + if (!antwort.ok) { + throw new ApiFehler( + `Home Assistant antwortete mit ${antwort.status} ${antwort.statusText} auf ${pfad}`, + antwort.status, + ); + } + + /* Manche Dienste antworten mit leerem Rumpf - dann kein JSON erzwingen. */ + const text = await antwort.text(); + return (text ? JSON.parse(text) : null) as T; + } catch (fehler) { + if (fehler instanceof ApiFehler) throw fehler; + throw new ApiFehler(`Home Assistant nicht erreichbar (${pfad})`, null, fehler); + } finally { + clearTimeout(frist); + opt.signal?.removeEventListener("abort", abbruch); + } + } + + /** Prüft Adresse und Token - für den letzten Schritt der Ersteinrichtung. */ + async verbindungPruefen(opt?: AnfrageOptionen): Promise<{ message: string }> { + return this.#anfrage<{ message: string }>("/api/", { method: "GET" }, opt); + } + + async zustandLesen>( + entityId: string, + opt?: AnfrageOptionen, + ): Promise> { + return this.#anfrage>(`/api/states/${encodeURIComponent(entityId)}`, { method: "GET" }, opt); + } + + /** Liest eine pyscript-Entität und packt gleich die Nutzlast aus. */ + async datenLesen(entityId: string, opt?: AnfrageOptionen): Promise { + const zustand = await this.zustandLesen<{ daten: T }>(entityId, opt); + return (zustand as DatenEntity).attributes.daten; + } + + /** Ruft einen Dienst auf, z. B. dienstAufrufen("pyscript", + "audi_dashboard_profil_schreiben", { profil_json }). */ + async dienstAufrufen( + bereich: string, + dienst: string, + daten: Record = {}, + opt?: AnfrageOptionen, + ): Promise { + return this.#anfrage( + `/api/services/${encodeURIComponent(bereich)}/${encodeURIComponent(dienst)}`, + { method: "POST", body: JSON.stringify(daten) }, + opt, + ); + } +} diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts new file mode 100644 index 0000000..7db01b3 --- /dev/null +++ b/companion-app/src/api/types.ts @@ -0,0 +1,136 @@ +/* ================================================================ + Typen der Home-Assistant-Schnittstelle und der Fachdaten + ================================================================ + Die Fachtypen (Fahrt, Tankvorgang, ...) sind aus SPECIFICATION.md §5 + "Data files" abgeleitet - also aus dem, was das Backend tatsächlich in + fahrten.jsonl / tankvorgaenge.jsonl schreibt, nicht aus Wunschdenken. + Felder, die laut §7 im Schema stehen, aber von keinem Codepfad je gefüllt + werden (start_lat, avg_speed_kmh, route, ...), sind hier bewusst als + optional markiert - sie können null/undefined sein und die Oberfläche darf + sich nie darauf verlassen. */ + +/** Roher Zustand einer HA-Entität, wie ihn /api/states liefert. */ +export interface HassState> { + entity_id: string; + state: string; + attributes: A; + last_changed: string; + last_updated: string; + context?: { id: string; parent_id: string | null; user_id: string | null }; +} + +/** Die pyscript.*-Entitäten transportieren ihre Nutzlast im Attribut "daten". */ +export interface DatenAttribute { + daten: T; + [weitere: string]: unknown; +} + +export type DatenEntity = HassState>; + +/* ----------------------------------------------------------- Fachdaten */ + +export type Fahrtart = "privat" | "arbeitsweg"; +export type FahrtStatus = "offen" | "vollständig"; +export type FahrtQuelle = "ha" | "manual"; + +export interface Fahrt { + trip_id: string; + ts_start: string; + ts_end: string; + duration_s: number; + distance_km: number | null; + /** "odometer" ist der einzige Wert, den das Backend je erzeugt (§7.1). */ + km_quelle: "odometer" | "gps" | null; + odo_start: number | null; + odo_end: number | null; + art: Fahrtart; + source: FahrtQuelle; + status: FahrtStatus; + edited_fields?: string[]; + /* Ab FMM003 erstmals real befüllbar - bis dahin durchgehend null (§7.2). */ + avg_speed_kmh?: number | null; + start_lat?: number | null; + start_lon?: number | null; + end_lat?: number | null; + end_lon?: number | null; + start_address?: string | null; + end_address?: string | null; + route?: unknown | null; + pausen?: unknown[]; +} + +export type TankStatus = "unvollständig" | "vollständig"; +export type TankQuelle = "auto" | "manual" | "beleg"; + +export interface Tankvorgang { + tank_id: string; + ts: string; + liters: number | null; + fuel_total_eur: number | null; + /** Immer aus fuel_total_eur/liters berechnet, nie aus dem Beleg gelesen. */ + price_per_l: number | null; + station_name?: string | null; + station_id?: string | null; + station_address?: string | null; + fuel_type?: string | null; + product_name?: string | null; + discount?: number | null; + discount_per_l?: number | null; + list_price_per_l?: number | null; + receipt_total_eur?: number | null; + receipt_key?: string | null; + receipt_no?: string | null; + receipt_file?: string | null; + odometer_km?: number | null; + distance_km?: number | null; + source: TankQuelle; + status: TankStatus; + edited_fields?: string[]; +} + +/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt". */ +export interface SicherheitsPunkt { + label: string; + ok: boolean | null; +} + +export interface Fahrzeugstatus { + km?: number | null; + tank_prozent?: number | null; + reichweite_km?: number | null; + sicher_abgestellt?: boolean | null; + sicherheit?: SicherheitsPunkt[]; + [weitere: string]: unknown; +} + +/** Das Profil ist ein großer, frei geformter JSON-Blob (§5). Die Oberfläche + greift gezielt auf Abschnitte zu; ein vollständiger Typ wäre hier nur + scheingenau, solange das Backend ihn nicht erzwingt. */ +export interface Profil { + fahrzeug?: Record; + einstellungen?: Record; + versicherung?: Record; + steuer?: Record; + reifen?: Record; + service?: Record; + technik?: Record; + ausstattung?: Record; + [weitere: string]: unknown; +} + +/* -------------------------------------------------- Entitäts-Verzeichnis */ + +/** Alle vom Backend veröffentlichten pyscript-Entitäten an einer Stelle. + Kein Hardcoding über die App verstreut: wer eine Entität umbenennt, + ändert genau diese Tabelle. */ +export const ENTITAETEN = { + profil: "pyscript.audi_dashboard_profil", + fahrten: "pyscript.audi_dashboard_fahrten", + tankvorgaenge: "pyscript.audi_dashboard_tankvorgaenge", + fahrzeugstatus: "pyscript.audi_dashboard_fahrzeugstatus", + batterieverlauf: "pyscript.audi_dashboard_batterieverlauf", + belegErgebnis: "pyscript.audi_dashboard_beleg_ergebnis", + updateStatus: "pyscript.audi_dashboard_update_status", +} as const; + +export type EntitaetsSchluessel = keyof typeof ENTITAETEN; diff --git a/companion-app/src/api/umgebung.ts b/companion-app/src/api/umgebung.ts new file mode 100644 index 0000000..9c5ecfa --- /dev/null +++ b/companion-app/src/api/umgebung.ts @@ -0,0 +1,141 @@ +/* ================================================================ + Laufzeitumgebung und Ablage von Zugangsdaten + ================================================================ + Die App läuft in drei Umgebungen (COMPANION_APP_ARCHITECTURE.md §1): + nativ per Capacitor (iOS/Android), als Iframe im HA-Dashboard und im + normalen Browser. Der Unterschied betrifft nur zwei Dinge - wo das Token + liegt und welche Basis-URL gilt -, deshalb ist genau das hier gekapselt + und der Rest der Datenschicht umgebungsblind. */ + +export type Umgebung = "capacitor" | "iframe" | "browser"; + +export function umgebungErkennen(): Umgebung { + if (typeof window === "undefined") return "browser"; + + /* Capacitor meldet sich über ein globales Objekt an. Bewusst defensiv + gelesen: das Paket ist hier (noch) keine Abhängigkeit, wir prüfen nur, + ob die Laufzeit es bereitstellt. */ + const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor; + if (cap?.isNativePlatform?.()) return "capacitor"; + + /* window.top !== window heißt: wir stecken in einem Iframe. Der Zugriff + kann bei fremder Herkunft werfen - dann sind wir erst recht eingebettet. */ + try { + if (window.top !== window.self) return "iframe"; + } catch { + return "iframe"; + } + + return "browser"; +} + +/* ------------------------------------------------------------- Ablage */ + +/** Minimale, asynchrone Schlüssel-Wert-Ablage. Async, weil die native + Variante (Keychain/Keystore über Capacitor Preferences) asynchron ist - + lieber alle Aufrufer von Anfang an asynchron als später umbauen. */ +export interface Ablage { + lesen(schluessel: string): Promise; + schreiben(schluessel: string, wert: string): Promise; + loeschen(schluessel: string): Promise; +} + +/** localStorage-Variante für Browser und Iframe. Alle Zugriffe abgesichert: + im privaten Modus und in Iframes mit blockierten Drittanbieter-Daten + wirft schon der bloße Zugriff auf localStorage. */ +export class BrowserAblage implements Ablage { + #speicher = new Map(); + + #ls(): Storage | null { + try { + return window.localStorage; + } catch { + return null; + } + } + + async lesen(schluessel: string): Promise { + const ls = this.#ls(); + if (!ls) return this.#speicher.get(schluessel) ?? null; + try { + return ls.getItem(schluessel); + } catch { + return this.#speicher.get(schluessel) ?? null; + } + } + + async schreiben(schluessel: string, wert: string): Promise { + this.#speicher.set(schluessel, wert); + try { + this.#ls()?.setItem(schluessel, wert); + } catch { + /* Nur im Arbeitsspeicher - besser als ein Absturz. */ + } + } + + async loeschen(schluessel: string): Promise { + this.#speicher.delete(schluessel); + try { + this.#ls()?.removeItem(schluessel); + } catch { + /* siehe oben */ + } + } +} + +/** Wird beim App-Start durch die native Umsetzung ersetzt (Keychain unter + iOS, Keystore unter Android). Bis dahin gilt die Browser-Variante. */ +let aktiveAblage: Ablage = new BrowserAblage(); + +export function ablageSetzen(ablage: Ablage): void { + aktiveAblage = ablage; +} + +export function ablage(): Ablage { + return aktiveAblage; +} + +/* --------------------------------------------------------- Zugangsdaten */ + +const SCHLUESSEL_BASIS = "dm360.basis_url"; +const SCHLUESSEL_TOKEN = "dm360.token"; + +export interface Zugang { + /** Basis-URL ohne abschließenden Schrägstrich, z. B. https://audi.datametric360.app */ + basisUrl: string; + /** Long-Lived Access Token aus dem HA-Benutzerprofil. */ + token: string; +} + +export async function zugangLesen(): Promise { + const a = ablage(); + const [basisUrl, token] = await Promise.all([a.lesen(SCHLUESSEL_BASIS), a.lesen(SCHLUESSEL_TOKEN)]); + if (!basisUrl || !token) return null; + return { basisUrl, token }; +} + +export async function zugangSpeichern(zugang: Zugang): Promise { + const a = ablage(); + await Promise.all([ + a.schreiben(SCHLUESSEL_BASIS, basisUrlNormalisieren(zugang.basisUrl)), + a.schreiben(SCHLUESSEL_TOKEN, zugang.token), + ]); +} + +export async function zugangVerwerfen(): Promise { + const a = ablage(); + await Promise.all([a.loeschen(SCHLUESSEL_BASIS), a.loeschen(SCHLUESSEL_TOKEN)]); +} + +/** Schneidet abschließende Schrägstriche ab und ergänzt fehlendes Schema. + Ohne das entstehen sonst Adressen wie "https://host//api/states". */ +export function basisUrlNormalisieren(eingabe: string): string { + let url = eingabe.trim(); + if (!/^https?:\/\//i.test(url)) url = `https://${url}`; + return url.replace(/\/+$/, ""); +} + +/** WebSocket-Adresse zur Basis-URL: http->ws, https->wss. */ +export function websocketUrl(basisUrl: string): string { + return `${basisUrl.replace(/^http/i, "ws")}/api/websocket`; +} diff --git a/companion-app/src/api/warteschlange.ts b/companion-app/src/api/warteschlange.ts new file mode 100644 index 0000000..f841a8b --- /dev/null +++ b/companion-app/src/api/warteschlange.ts @@ -0,0 +1,163 @@ +/* ================================================================ + Warteschlange für Änderungen ohne Netz + ================================================================ + Entscheidung aus COMPANION_APP_ARCHITECTURE.md §3: Offline gemachte + Änderungen werden nicht abgewiesen, sondern gesammelt und nachgeliefert, + sobald wieder eine Verbindung besteht. Weil es genau einen Benutzer gibt, + reicht "der letzte Schreibvorgang gewinnt" - es gibt keinen zweiten + Bearbeiter, gegen den abgeglichen werden müsste. + + Die Warteschlange liegt in derselben Ablage wie das Token, überlebt also + einen App-Neustart: ein Beleg, der im Funkloch fotografiert wurde, geht + nicht verloren, nur weil die App zwischendurch geschlossen wurde. */ + +import { ablage } from "./umgebung.ts"; +import { ApiFehler, type HassRest } from "./rest.ts"; + +const SCHLUESSEL = "dm360.warteschlange"; + +export interface WartenderAuftrag { + id: string; + /** Zeitpunkt der Erstellung - die Oberfläche zeigt "wartet seit ...". */ + erstellt: string; + bereich: string; + dienst: string; + daten: Record; + /** Kurzer Klartext für die Anzeige, z. B. "Beleg vom 3.8. hochladen". */ + beschreibung: string; + fehlversuche: number; + letzterFehler?: string; +} + +type WarteschlangenHorcher = (auftraege: WartenderAuftrag[]) => void; + +/** Nach so vielen vergeblichen Versuchen gilt ein Auftrag als kaputt und wird + nicht weiter blind wiederholt - sonst blockiert ein einziger fehlerhafter + Auftrag für immer alle nachfolgenden. */ +const MAX_FEHLVERSUCHE = 5; + +export class Warteschlange { + #auftraege: WartenderAuftrag[] = []; + #geladen = false; + #laeuft = false; + #horcher = new Set(); + + constructor(private readonly rest: HassRest) {} + + async laden(): Promise { + if (this.#geladen) return; + const roh = await ablage().lesen(SCHLUESSEL); + if (roh) { + try { + const gelesen: unknown = JSON.parse(roh); + if (Array.isArray(gelesen)) this.#auftraege = gelesen as WartenderAuftrag[]; + } catch { + /* Beschädigter Eintrag: lieber leer starten als beim Start abstürzen. */ + this.#auftraege = []; + } + } + this.#geladen = true; + this.#melden(); + } + + get auftraege(): readonly WartenderAuftrag[] { + return this.#auftraege; + } + + get anzahl(): number { + return this.#auftraege.length; + } + + aufAenderung(horcher: WarteschlangenHorcher): () => void { + this.#horcher.add(horcher); + horcher([...this.#auftraege]); + return () => this.#horcher.delete(horcher); + } + + #melden(): void { + const kopie = [...this.#auftraege]; + for (const h of this.#horcher) h(kopie); + } + + async #sichern(): Promise { + await ablage().schreiben(SCHLUESSEL, JSON.stringify(this.#auftraege)); + this.#melden(); + } + + /** Reiht einen Dienstaufruf ein. Wird direkt danach abgearbeitet, falls + Netz da ist - im Normalfall merkt der Benutzer die Warteschlange also + gar nicht. */ + async einreihen( + bereich: string, + dienst: string, + daten: Record, + beschreibung: string, + ): Promise { + await this.laden(); + const auftrag: WartenderAuftrag = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + erstellt: new Date().toISOString(), + bereich, + dienst, + daten, + beschreibung, + fehlversuche: 0, + }; + this.#auftraege.push(auftrag); + await this.#sichern(); + void this.abarbeiten(); + return auftrag; + } + + async entfernen(id: string): Promise { + await this.laden(); + this.#auftraege = this.#auftraege.filter((a) => a.id !== id); + await this.#sichern(); + } + + /** Arbeitet die Warteschlange der Reihe nach ab. Reihenfolge ist wichtig: + wer erst das Profil ändert und dann einen Beleg hochlädt, erwartet + genau diese Abfolge auf dem Server. Deshalb wird beim ersten + Netzproblem abgebrochen statt weitergesprungen. */ + async abarbeiten(): Promise<{ erledigt: number; verblieben: number }> { + await this.laden(); + if (this.#laeuft) return { erledigt: 0, verblieben: this.#auftraege.length }; + this.#laeuft = true; + + let erledigt = 0; + try { + while (this.#auftraege.length > 0) { + const auftrag = this.#auftraege[0]; + if (!auftrag) break; + + try { + await this.rest.dienstAufrufen(auftrag.bereich, auftrag.dienst, auftrag.daten); + this.#auftraege.shift(); + erledigt += 1; + await this.#sichern(); + } catch (fehler) { + if (fehler instanceof ApiFehler && fehler.istNetzproblem) { + /* Weiterhin offline - später erneut versuchen, nichts verwerfen. */ + break; + } + + auftrag.fehlversuche += 1; + auftrag.letzterFehler = fehler instanceof Error ? fehler.message : String(fehler); + + if (auftrag.fehlversuche >= MAX_FEHLVERSUCHE) { + /* Dauerhaft kaputt: aus dem Weg räumen, damit die übrigen + Aufträge nicht ewig dahinter feststecken. Die Oberfläche kann + den Fehler über aufAenderung() sichtbar machen. */ + this.#auftraege.shift(); + } + await this.#sichern(); + break; + } + } + } finally { + this.#laeuft = false; + } + + return { erledigt, verblieben: this.#auftraege.length }; + } +} diff --git a/companion-app/tsconfig.json b/companion-app/tsconfig.json new file mode 100644 index 0000000..a252206 --- /dev/null +++ b/companion-app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/dashboard-muster-ohne-bilder.html b/dashboard-muster-ohne-bilder.html new file mode 100644 index 0000000..0e8df1d --- /dev/null +++ b/dashboard-muster-ohne-bilder.html @@ -0,0 +1,1945 @@ + + + + + +RS 4 Avant — Oberflächenmuster + + + + + +
+
+ +
Fahrzeug
Übersicht
+
+ + +
+
+ +
+ + + + diff --git a/dashboard-muster.html b/dashboard-muster.html new file mode 100644 index 0000000..c5434dc --- /dev/null +++ b/dashboard-muster.html @@ -0,0 +1,1945 @@ + + + + + +RS 4 Avant — Oberflächenmuster + + + + + +
+
+ +
Fahrzeug
Übersicht
+
+ + +
+
+ +
+ + + + diff --git a/design-system/.design-sync/NOTES.md b/design-system/.design-sync/NOTES.md new file mode 100644 index 0000000..acb8961 --- /dev/null +++ b/design-system/.design-sync/NOTES.md @@ -0,0 +1,24 @@ +# design-sync notes — @audi-dash/ui + +## Repo shape +- Package shape (no Storybook). Build: `npm run build` (tsup) in the repo root produces `dist/index.js` (ESM), `dist/index.cjs` (CJS), `dist/index.d.ts`, `dist/styles.css`. The converter's `--entry` points at `dist/index.js`. +- Converter deps are staged/installed under `.ds-sync/` (isolated npm project, gitignored) — never mixed into the repo's own `package.json`. +- On Windows, `npm install` needs `npm approve-scripts esbuild` once per fresh `.ds-sync/`/`node_modules` (npm 11's allow-scripts gate blocks esbuild's postinstall that downloads its platform binary). + +## Known render warns (triaged, safe to ignore on re-sync) +- `[RENDER_THIN]` on `IconButton`: "mounts have no text and paint nothing" — false positive. IconButton is intentionally icon-only (no text content, `aria-label` only). Screenshot confirms both cells (Default, Disabled) render the icons correctly; the heuristic just measures text content, which is legitimately empty here. + +## cfg.overrides +- `Popup`: `cardMode: "single"` — it's a fixed-position overlay component that portals to `document.body`; grid mode collapsed/escaped its card. +- `Accordion`, `Feld`, `TabBar`, `StatGrid`: `cardMode: "column"` — one or more of their authored stories render wider than a grid cell (multi-field forms / full-width nav bars); column mode gives them the full card width instead of being cropped. + +## Preview authoring conventions +- Every preview wraps its composition in `
` (a local `Root` helper duplicated per file) — the library only applies text color/background via `.ads-root`, and without it previews render with browser-default black-on-white instead of the app's actual dark theme. **This is required for every future preview** — the components' color tokens are on `:root` (theme-independent) but `color`/`background` inheritance only kicks in under `.ads-root`. +- `Popup`'s children portal to `document.body`, escaping any local `.ads-root` wrapper. This doesn't break styling because `Popup`'s own CSS (`.ads-popup`, `.ads-popup-item`) sets `background`/`color` directly from `var(--tile-2)`/`var(--fg)` rather than relying on inherited color — so it renders correctly even though the wrapper technically doesn't reach it. +- `SwipeRow`'s `SwipedOpen` story shows content clipped at the left edge behind the revealed delete button — this is correct, faithful behavior (the row slides left to reveal the delete action), not a rendering defect. + +## Re-sync risks +- All 20 previews are hand-authored (no floor cards) — a future re-sync only needs to touch previews for components whose source or `.tsx` actually changed; everything else carries forward via the grade cache + `_ds_sync.json` anchor. +- The `Root` wrapper pattern (data-theme="nacht") is duplicated per preview file rather than imported from a shared module — if a preview is added later without it, it will silently render unthemed (black text, no background). Check for `data-theme="nacht"` in any new preview before grading it. +- Playwright/Chromium was freshly installed for this sync (`.ds-sync/node_modules/playwright`, chromium cached at `~/AppData/Local/ms-playwright`) — not committed, machine-local. A re-sync on a different machine needs to reinstall it (package-validate.mjs will prompt if missing). +- No `docsDir`/docs were found in this repo (it's a fresh internal design system, no README-per-component docs yet) — all `.prompt.md` files are synthesized from `.d.ts` + the authored preview `.tsx`, not from hand-written docs. If per-component docs are added later, set `cfg.docsDir` to pick them up. diff --git a/design-system/.design-sync/config.json b/design-system/.design-sync/config.json new file mode 100644 index 0000000..a9f72a9 --- /dev/null +++ b/design-system/.design-sync/config.json @@ -0,0 +1,15 @@ +{ + "projectId": "27d66fd4-ed86-43b2-b8bf-61e281a5a1a5", + "shape": "package", + "pkg": "@audi-dash/ui", + "globalName": "AudiDashUI", + "cssEntry": "./dist/styles.css", + "readmeHeader": ".design-sync/conventions.md", + "overrides": { + "Popup": { "cardMode": "single", "viewport": "340x300" }, + "Accordion": { "cardMode": "column" }, + "Feld": { "cardMode": "column" }, + "TabBar": { "cardMode": "column" }, + "StatGrid": { "cardMode": "column" } + } +} diff --git a/design-system/.design-sync/conventions.md b/design-system/.design-sync/conventions.md new file mode 100644 index 0000000..fbbc474 --- /dev/null +++ b/design-system/.design-sync/conventions.md @@ -0,0 +1,7 @@ +## Usage conventions + +- **Always wrap the tree that uses these components in `
` (or `data-theme="tag"` for the light theme).** Color tokens (`--fg`, `--canvas`, `--tile`, etc.) are defined on `:root` and work anywhere, but text `color` and page `background` are only applied via the `.ads-root` class — content rendered outside it falls back to browser-default black-on-white. +- Default theme is **Nacht** (dark) — this is what `:root` sets without any `data-theme` override. Use `data-theme="tag"` for the light variant. +- No Audi brand assets (font, rings, nameplate) are included in this bundle — it uses a neutral `Helvetica, Arial, sans-serif` fallback stack by design. +- `Popup` renders via a React portal into `document.body` by default (`position: fixed`, centers or anchors relative to the viewport) — it doesn't need to sit inside `.ads-root` itself since its own CSS sets `background`/`color` explicitly rather than inheriting them. +- `SwipeRow` is a controlled/uncontrolled swipe-to-reveal row: swiping (or setting `swiped`) slides content left to reveal a delete action on the right, clipping the row's left edge by design — this is the intended interaction, not a layout bug. diff --git a/design-system/.design-sync/previews/Accordion.tsx b/design-system/.design-sync/previews/Accordion.tsx new file mode 100644 index 0000000..62802a4 --- /dev/null +++ b/design-system/.design-sync/previews/Accordion.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { Accordion, LeafRow } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Open() { + return ( + +
+ + + + +
+
+ ); +} + +export function Closed() { + return ( + +
+ + + + +
+
+ ); +} + +export function NestedLevel2() { + return ( + +
+ + + + + + + + +
+
+ ); +} diff --git a/design-system/.design-sync/previews/ActionButton.tsx b/design-system/.design-sync/previews/ActionButton.tsx new file mode 100644 index 0000000..f717962 --- /dev/null +++ b/design-system/.design-sync/previews/ActionButton.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { ActionButton } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + + Speichern + + ); +} + +export function Destructive() { + return ( + + Fahrt löschen + + ); +} + +export function Disabled() { + return ( + + Speichern + + ); +} + +export function Group() { + return ( + +
+ Übernehmen + Verwerfen +
+
+ ); +} diff --git a/design-system/.design-sync/previews/Feld.tsx b/design-system/.design-sync/previews/Feld.tsx new file mode 100644 index 0000000..932c558 --- /dev/null +++ b/design-system/.design-sync/previews/Feld.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Feld, Switch } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function ReadOnlyValue() { + return ( + +
+ + +
+
+ ); +} + +export function WithUnit() { + return ( + +
+ + + +
+
+ ); +} + +export function WithControl() { + const [on, setOn] = React.useState(true); + return ( + +
+ + + +
+
+ ); +} diff --git a/design-system/.design-sync/previews/Fig.tsx b/design-system/.design-sync/previews/Fig.tsx new file mode 100644 index 0000000..0a02ecb --- /dev/null +++ b/design-system/.design-sync/previews/Fig.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { Fig } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + + + + ); +} + +export function LargeHero() { + return ( + + + + ); +} + +export function Compact() { + return ( + + + + ); +} + +export function NoUnit() { + return ( + + + + ); +} diff --git a/design-system/.design-sync/previews/IconButton.tsx b/design-system/.design-sync/previews/IconButton.tsx new file mode 100644 index 0000000..e6f345e --- /dev/null +++ b/design-system/.design-sync/previews/IconButton.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { IconButton } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const GearIcon = ( + + + + +); + +const BackIcon = ( + + + +); + +const CloseIcon = ( + + + +); + +export function Default() { + return ( + +
+ {BackIcon} + {GearIcon} + {CloseIcon} +
+
+ ); +} + +export function Disabled() { + return ( + +
+ + {GearIcon} + +
+
+ ); +} diff --git a/design-system/.design-sync/previews/ImagePlaceholder.tsx b/design-system/.design-sync/previews/ImagePlaceholder.tsx new file mode 100644 index 0000000..95c00f2 --- /dev/null +++ b/design-system/.design-sync/previews/ImagePlaceholder.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { ImagePlaceholder } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Empty() { + return ( + + + + ); +} + +export function Mini() { + return ( + + + + ); +} + +export function Round() { + return ( + + + + ); +} diff --git a/design-system/.design-sync/previews/LeafRow.tsx b/design-system/.design-sync/previews/LeafRow.tsx new file mode 100644 index 0000000..c027102 --- /dev/null +++ b/design-system/.design-sync/previews/LeafRow.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { LeafRow } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + +
+ {}} /> +
+
+ ); +} + +export function List() { + return ( + +
+ {}} /> + {}} /> + {}} /> +
+
+ ); +} diff --git a/design-system/.design-sync/previews/Pill.tsx b/design-system/.design-sync/previews/Pill.tsx new file mode 100644 index 0000000..926be95 --- /dev/null +++ b/design-system/.design-sync/previews/Pill.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { Pill } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + +
+ Elektro + Automatik + 5 Sitze +
+
+ ); +} + +export function Work() { + return ( + +
+ Neu + -15% +
+
+ ); +} + +export function Mixed() { + return ( + +
+ Diesel + SmartDeal + 4x4 +
+
+ ); +} diff --git a/design-system/.design-sync/previews/Popup.tsx b/design-system/.design-sync/previews/Popup.tsx new file mode 100644 index 0000000..887ec7b --- /dev/null +++ b/design-system/.design-sync/previews/Popup.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { Popup, PopupMenuItem } from "@audi-dash/ui"; + +function Root({ children, height }: { children: React.ReactNode; height: number }) { + return ( +
+ {children} +
+ ); +} + +export function Menu() { + return ( + + {}} variant="menu" anchor="center"> + {}}>Foto ändern + {}}>Foto entfernen + {}} destructive> + Fahrzeug löschen + + + + ); +} + +export function Form() { + return ( + + {}} variant="form" anchor="center"> + + + + + ); +} diff --git a/design-system/.design-sync/previews/PopupMenuItem.tsx b/design-system/.design-sync/previews/PopupMenuItem.tsx new file mode 100644 index 0000000..d1fddc9 --- /dev/null +++ b/design-system/.design-sync/previews/PopupMenuItem.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { PopupMenuItem } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + +
+ {}}>Foto ändern + {}}>Details anzeigen +
+
+ ); +} + +export function Destructive() { + return ( + +
+ {}}>Bearbeiten + {}} destructive> + Löschen + +
+
+ ); +} diff --git a/design-system/.design-sync/previews/ProgressBar.tsx b/design-system/.design-sync/previews/ProgressBar.tsx new file mode 100644 index 0000000..11d6ac1 --- /dev/null +++ b/design-system/.design-sync/previews/ProgressBar.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { ProgressBar } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function SingleFill() { + return ( + +
+ +
+
+ ); +} + +export function SegmentedWithLegend() { + return ( + +
+ +
+
+ ); +} + +export function SingleSegmentNoLegend() { + return ( + +
+ +
+
+ ); +} diff --git a/design-system/.design-sync/previews/ProgressRing.tsx b/design-system/.design-sync/previews/ProgressRing.tsx new file mode 100644 index 0000000..44f980a --- /dev/null +++ b/design-system/.design-sync/previews/ProgressRing.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { ProgressRing } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + + + + ); +} + +export function CustomValue() { + return ( + + + + ); +} + +export function Low() { + return ( + + + + ); +} + +export function Small() { + return ( + + + + ); +} diff --git a/design-system/.design-sync/previews/RowList.tsx b/design-system/.design-sync/previews/RowList.tsx new file mode 100644 index 0000000..00edec4 --- /dev/null +++ b/design-system/.design-sync/previews/RowList.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { RowList } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + +
+ +
+
+ ); +} + +export function WithSubValues() { + return ( + +
+ +
+
+ ); +} diff --git a/design-system/.design-sync/previews/Seg.tsx b/design-system/.design-sync/previews/Seg.tsx new file mode 100644 index 0000000..3fefa85 --- /dev/null +++ b/design-system/.design-sync/previews/Seg.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { Seg } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function TwoOptions() { + const [value, setValue] = React.useState<"nacht" | "tag">("nacht"); + return ( + + + + ); +} + +export function ThreeOptions() { + const [value, setValue] = React.useState("woche"); + return ( + + + + ); +} diff --git a/design-system/.design-sync/previews/StatGrid.tsx b/design-system/.design-sync/previews/StatGrid.tsx new file mode 100644 index 0000000..b4c434d --- /dev/null +++ b/design-system/.design-sync/previews/StatGrid.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { StatGrid } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function FourColumns() { + return ( + +
+ +
+
+ ); +} + +export function TwoColumns() { + return ( + +
+ +
+
+ ); +} diff --git a/design-system/.design-sync/previews/StatusRow.tsx b/design-system/.design-sync/previews/StatusRow.tsx new file mode 100644 index 0000000..f6fdc6f --- /dev/null +++ b/design-system/.design-sync/previews/StatusRow.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { StatusRow } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AllStatuses() { + return ( + +
+ + + +
+
+ ); +} + +export function Clickable() { + return ( + +
+ {}} /> +
+
+ ); +} diff --git a/design-system/.design-sync/previews/SwipeRow.tsx b/design-system/.design-sync/previews/SwipeRow.tsx new file mode 100644 index 0000000..f4c9f4a --- /dev/null +++ b/design-system/.design-sync/previews/SwipeRow.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { SwipeRow, LeafRow } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Closed() { + return ( + +
+ {}} deleteLabel="Löschen"> + + +
+
+ ); +} + +export function SwipedOpen() { + return ( + +
+ {}} deleteLabel="Löschen" swiped> + + +
+
+ ); +} diff --git a/design-system/.design-sync/previews/Switch.tsx b/design-system/.design-sync/previews/Switch.tsx new file mode 100644 index 0000000..8f38807 --- /dev/null +++ b/design-system/.design-sync/previews/Switch.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { Switch } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function On() { + const [checked, setChecked] = React.useState(true); + return ( + + + + ); +} + +export function Off() { + const [checked, setChecked] = React.useState(false); + return ( + + + + ); +} + +export function Disabled() { + return ( + + {}} disabled aria-label="Benachrichtigungen" /> + + ); +} diff --git a/design-system/.design-sync/previews/TabBar.tsx b/design-system/.design-sync/previews/TabBar.tsx new file mode 100644 index 0000000..2186cb7 --- /dev/null +++ b/design-system/.design-sync/previews/TabBar.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { TabBar } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const HomeIcon = ( + + + +); +const ListIcon = ( + + + +); +const CarIcon = ( + + + +); +const GearIcon = ( + + + + +); + +const items = [ + { key: "home", label: "Übersicht", icon: HomeIcon }, + { key: "trips", label: "Fahrten", icon: ListIcon }, + { key: "car", label: "Fahrzeug", icon: CarIcon }, + { key: "settings", label: "Einstellungen", icon: GearIcon }, +]; + +export function Default() { + const [active, setActive] = React.useState("home"); + return ( + +
+ +
+
+ ); +} + +export function IconOnly() { + const [active, setActive] = React.useState("trips"); + return ( + +
+ +
+
+ ); +} diff --git a/design-system/.design-sync/previews/Tile.tsx b/design-system/.design-sync/previews/Tile.tsx new file mode 100644 index 0000000..e5202cd --- /dev/null +++ b/design-system/.design-sync/previews/Tile.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { Tile, Fig } from "@audi-dash/ui"; + +function Root({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function Default() { + return ( + +
+ +
Reichweite
+ +
+
+
+ ); +} + +export function Flat() { + return ( + +
+ Zusatzinformation ohne Rahmenschatten +
+
+ ); +} + +export function ButtonWithChevron() { + return ( + +
+ {}}> + Alle Fahrten anzeigen + +
+
+ ); +} diff --git a/design-system/.gitignore b/design-system/.gitignore new file mode 100644 index 0000000..817fc01 --- /dev/null +++ b/design-system/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.ds-sync/ +ds-bundle/ +.design-sync/.cache/ +.design-sync/learnings/ +.design-sync/node_modules diff --git a/design-system/README.md b/design-system/README.md new file mode 100644 index 0000000..8a643a4 --- /dev/null +++ b/design-system/README.md @@ -0,0 +1,66 @@ +# @audi-dash/ui + +Generisches React-Komponenten-Set, das die visuelle Sprache des Audi-Dashboards (Kacheln, +Formularzeilen, Toggle, Segmented Control, Popups, Statusanzeigen, Fortschrittsanzeigen …) +nachbildet. Enthält **keine** Audi-Marken-Assets (Font "Audi Type", Ringe, Typenschild) - diese +sind laut Lizenzvereinbarung ausschließlich für die private Home-Assistant-Installation +freigegeben. Diese Bibliothek nutzt einen neutralen Fallback-Font-Stack (`Helvetica, Arial, +sans-serif`). + +## Verwendung + +Konsumierende Apps legen die `.ads-root`-Klasse um den Baum, der die Bibliothek nutzt, und setzen +`data-theme="nacht"` (Standard) oder `data-theme="tag"`: + +```tsx +import { Tile, Feld, Switch } from "@audi-dash/ui"; +import "@audi-dash/ui/styles.css"; + +function App() { + return ( +
+ + + {}} /> + + +
+ ); +} +``` + +## Design-Tokens + +Alle Farben/Radien/Abstände liegen als CSS-Custom-Properties in `src/tokens/tokens.css` vor - +Komponenten referenzieren ausschließlich `var(--token)`, nie Literal-Hex-Werte. Siehe die Datei +für die vollständige Liste (`--red`, `--canvas`, `--tile`, `--fg`/`--fg2`/`--fg3`, `--ok`/`--warn`/ +`--bad`, `--r-tile`/`--r-pill`/`--r-func`, `--sp-1`…`--sp-5`). + +## Typografie-Utility-Klassen + +Reine Textstil-Hooks ohne eigene Komponentenstruktur (in `tokens.css` definiert) - direkt als +`className` anwenden: + +| Klasse | Verwendung | +|---|---| +| `.ads-eyebrow` | Kleine Überschrift über einem Seitentitel (10px, uppercase, `--fg3`) | +| `.ads-title` | Seitentitel (23px, einzeilig, ellipsis) | +| `.ads-label` | Feld-/Kachel-Label (10px, uppercase, `--fg3`) | +| `.ads-sport` | Kursiver Akzenttext | + +Die einzige Ausnahme mit echter interner Struktur ist die `Fig`-Komponente (großer Zahlenwert + +Einheit) - sie wird intern auch von `ProgressRing` und `StatGrid` genutzt. + +## Komponenten + +Pill, IconButton, Fig, StatusRow, ActionButton, Switch, Seg, RowList, LeafRow, Tile, Feld, +ProgressRing, ProgressBar, StatGrid, Accordion, SwipeRow, TabBar, Popup (+PopupMenuItem), +ImagePlaceholder. Props-Referenz: `.d.ts`-Dateien im Build bzw. die jeweilige `.tsx`. + +## Build + +```bash +npm install +npm run typecheck +npm run build +``` diff --git a/design-system/package-lock.json b/design-system/package-lock.json new file mode 100644 index 0000000..b3f1b31 --- /dev/null +++ b/design-system/package-lock.json @@ -0,0 +1,1632 @@ +{ + "name": "@audi-dash/ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@audi-dash/ui", + "version": "0.1.0", + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.3.0", + "typescript": "^5.6.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/design-system/package.json b/design-system/package.json new file mode 100644 index 0000000..7bb7d28 --- /dev/null +++ b/design-system/package.json @@ -0,0 +1,44 @@ +{ + "name": "@audi-dash/ui", + "version": "0.1.0", + "private": true, + "description": "Generisches React-Komponenten-Set, das die visuelle Sprache des Audi-Dashboards nachbildet.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist" + ], + "sideEffects": [ + "**/*.css" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsup", + "smoke": "node scripts/smoke.mjs" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.3.0", + "typescript": "^5.6.0" + }, + "allowScripts": { + "esbuild@0.27.7": true + } +} diff --git a/design-system/scripts/smoke.mjs b/design-system/scripts/smoke.mjs new file mode 100644 index 0000000..5e0bb07 --- /dev/null +++ b/design-system/scripts/smoke.mjs @@ -0,0 +1,71 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import * as UI from "../dist/index.js"; + +const h = React.createElement; +const noop = () => {}; + +const cases = [ + { name: "Pill", el: h(UI.Pill, {}, "Text") }, + { name: "IconButton", el: h(UI.IconButton, { "aria-label": "x" }, "*") }, + { name: "Fig", el: h(UI.Fig, { value: 42, unit: "km" }) }, + { name: "StatusRow", el: h(UI.StatusRow, { status: "ok", title: "Titel", subtitle: "Sub" }) }, + { name: "ActionButton", el: h(UI.ActionButton, {}, "Aktion") }, + { name: "Switch", el: h(UI.Switch, { checked: true, onChange: noop }) }, + { + name: "Seg", + el: h(UI.Seg, { + options: [{ value: "a", label: "A" }, { value: "b", label: "B" }], + value: "a", + onChange: noop, + }), + }, + { + name: "RowList", + el: h(UI.RowList, { items: [{ key: 1, label: "L", value: "V", subValue: "SV" }] }), + }, + { name: "LeafRow", el: h(UI.LeafRow, { title: "T", value: "V" }) }, + { name: "Tile", el: h(UI.Tile, {}, "Inhalt") }, + { name: "Tile (button)", el: h(UI.Tile, { variant: "button", chevron: true, onClick: noop }, "Klick") }, + { name: "Feld", el: h(UI.Feld, { label: "L", value: "V" }) }, + { name: "ProgressRing", el: h(UI.ProgressRing, { percent: 42, unit: "%" }) }, + { name: "ProgressBar (single)", el: h(UI.ProgressBar, { percent: 50 }) }, + { + name: "ProgressBar (segments)", + el: h(UI.ProgressBar, { segments: [{ value: 3, label: "A" }, { value: 1, label: "B" }] }), + }, + { + name: "StatGrid", + el: h(UI.StatGrid, { items: [{ value: 1, caption: "C" }, { value: 2, caption: "C2" }] }), + }, + { name: "Accordion", el: h(UI.Accordion, { title: "T", defaultOpen: true }, "Body") }, + { name: "SwipeRow", el: h(UI.SwipeRow, { onDelete: noop }, "Zeile") }, + { + name: "TabBar", + el: h(UI.TabBar, { + items: [{ key: "a", label: "A", icon: h("svg") }], + activeKey: "a", + onChange: noop, + }), + }, + { + name: "Popup", + el: h(UI.Popup, { open: true, onClose: noop }, h(UI.PopupMenuItem, {}, "Item")), + }, + { name: "ImagePlaceholder", el: h(UI.ImagePlaceholder, { alt: "a", label: "Label" }) }, +]; + +let failed = 0; +for (const c of cases) { + try { + const html = renderToStaticMarkup(c.el); + if (!html || html.length === 0) throw new Error("empty output"); + console.log(`PASS ${c.name}`); + } catch (err) { + failed++; + console.log(`FAIL ${c.name}: ${err.message}`); + } +} + +console.log(`\n${cases.length - failed}/${cases.length} passed`); +if (failed > 0) process.exit(1); diff --git a/design-system/src/components/Accordion/Accordion.css b/design-system/src/components/Accordion/Accordion.css new file mode 100644 index 0000000..830cc05 --- /dev/null +++ b/design-system/src/components/Accordion/Accordion.css @@ -0,0 +1,55 @@ +.ads-acc-h { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + background: none; + border: none; + font-family: inherit; + text-align: left; + padding: 12px 0; + cursor: pointer; + border-bottom: 1px solid var(--line); +} + +.ads-acc-h__k { + flex: 1; + font-size: 15px; + color: var(--fg); +} + +.ads-acc-h__v { + font-size: 11.5px; + color: var(--fg2); + text-align: right; +} + +.ads-mark { + width: 6px; + height: 10px; + border-right: 1.4px solid var(--fg3); + border-bottom: 1.4px solid var(--fg3); + transform: rotate(-45deg) scaleY(1); + transition: transform .15s, border-color .15s; +} + +.ads-acc-h[aria-expanded="true"] .ads-mark { + border-color: var(--red); + transform: rotate(45deg) scaleY(1); +} + +.ads-acc-b { + display: none; +} + +.ads-acc-b--on { + display: block; +} + +.ads-accordion--lvl2 { + margin-left: 22px; +} + +.ads-accordion--lvl2 .ads-acc-h__k { + font-size: 13px; +} diff --git a/design-system/src/components/Accordion/Accordion.tsx b/design-system/src/components/Accordion/Accordion.tsx new file mode 100644 index 0000000..fe25b02 --- /dev/null +++ b/design-system/src/components/Accordion/Accordion.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; + +export interface AccordionProps { + title: React.ReactNode; + /** Right-aligned header value shown next to the title. */ + summary?: React.ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + /** 2 = nested, indented sub-accordion with smaller header font. */ + level?: 1 | 2; + children: React.ReactNode; + className?: string; +} + +export function Accordion({ + title, + summary, + open, + defaultOpen = false, + onOpenChange, + level = 1, + children, + className, +}: AccordionProps) { + const [internalOpen, setInternalOpen] = React.useState(defaultOpen); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : internalOpen; + + const toggle = () => { + const next = !isOpen; + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }; + + const classes = ["ads-accordion"]; + if (level === 2) classes.push("ads-accordion--lvl2"); + if (className) classes.push(className); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/design-system/src/components/Accordion/LeafRow.css b/design-system/src/components/Accordion/LeafRow.css new file mode 100644 index 0000000..7c84aec --- /dev/null +++ b/design-system/src/components/Accordion/LeafRow.css @@ -0,0 +1,50 @@ +.ads-leaf { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + width: 100%; + background: none; + border: none; + font-family: inherit; + text-align: left; + padding: 12px 0 12px 44px; + cursor: default; + border-bottom: 1px solid var(--line); +} + +button.ads-leaf { + cursor: pointer; +} + +.ads-leaf__k { + font-size: 13.5px; + color: var(--fg); + display: flex; + flex-direction: column; +} + +.ads-leaf__k small { + font-size: 11px; + color: var(--fg3); +} + +.ads-leaf__v { + font-size: 13px; + color: var(--fg2); + text-align: right; + display: flex; + flex-direction: column; +} + +.ads-leaf__v small { + font-size: 11px; + color: var(--fg3); +} + +.ads-chev { + width: 6px; + height: 10px; + color: var(--fg3); + flex: 0 0 auto; +} diff --git a/design-system/src/components/Accordion/LeafRow.tsx b/design-system/src/components/Accordion/LeafRow.tsx new file mode 100644 index 0000000..6469273 --- /dev/null +++ b/design-system/src/components/Accordion/LeafRow.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; + +export interface LeafRowProps { + title: React.ReactNode; + subTitle?: React.ReactNode; + value?: React.ReactNode; + subValue?: React.ReactNode; + onClick?: () => void; + className?: string; +} + +export function LeafRow({ title, subTitle, value, subValue, onClick, className }: LeafRowProps) { + const classes = ["ads-leaf"]; + if (className) classes.push(className); + const Tag: React.ElementType = onClick ? "button" : "div"; + return ( + + + {title} + {subTitle !== undefined && {subTitle}} + + + {value} + {subValue !== undefined && {subValue}} + + + + + + ); +} diff --git a/design-system/src/components/Accordion/index.ts b/design-system/src/components/Accordion/index.ts new file mode 100644 index 0000000..6fcaf25 --- /dev/null +++ b/design-system/src/components/Accordion/index.ts @@ -0,0 +1,4 @@ +export { Accordion } from "./Accordion"; +export type { AccordionProps } from "./Accordion"; +export { LeafRow } from "./LeafRow"; +export type { LeafRowProps } from "./LeafRow"; diff --git a/design-system/src/components/ActionButton/ActionButton.css b/design-system/src/components/ActionButton/ActionButton.css new file mode 100644 index 0000000..98550e9 --- /dev/null +++ b/design-system/src/components/ActionButton/ActionButton.css @@ -0,0 +1,34 @@ +.ads-action { + display: block; + width: 100%; + font-family: inherit; + font-size: 11px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--fg); + background: none; + border: 1px solid var(--line-strong); + border-radius: var(--r-pill); + padding: 14px; + cursor: pointer; +} + +.ads-action:hover:not(:disabled) { + border-color: var(--red); +} + +.ads-action:disabled { + opacity: .4; + cursor: default; +} + +.ads-action--destructive { + color: var(--bad); + border-color: var(--bad); +} + +.ads-action--destructive:hover:not(:disabled) { + background: var(--bad); + color: var(--fg); + border-color: var(--bad); +} diff --git a/design-system/src/components/ActionButton/ActionButton.tsx b/design-system/src/components/ActionButton/ActionButton.tsx new file mode 100644 index 0000000..9f57b71 --- /dev/null +++ b/design-system/src/components/ActionButton/ActionButton.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +export interface ActionButtonProps { + children: React.ReactNode; + onClick?: () => void; + variant?: "default" | "destructive"; + disabled?: boolean; + type?: "button" | "submit"; + className?: string; +} + +export function ActionButton({ + children, + onClick, + variant = "default", + disabled, + type = "button", + className, +}: ActionButtonProps) { + const classes = ["ads-action"]; + if (variant === "destructive") classes.push("ads-action--destructive"); + if (className) classes.push(className); + return ( + + ); +} diff --git a/design-system/src/components/ActionButton/index.ts b/design-system/src/components/ActionButton/index.ts new file mode 100644 index 0000000..62514f9 --- /dev/null +++ b/design-system/src/components/ActionButton/index.ts @@ -0,0 +1,2 @@ +export { ActionButton } from "./ActionButton"; +export type { ActionButtonProps } from "./ActionButton"; diff --git a/design-system/src/components/Feld/Feld.css b/design-system/src/components/Feld/Feld.css new file mode 100644 index 0000000..b492275 --- /dev/null +++ b/design-system/src/components/Feld/Feld.css @@ -0,0 +1,46 @@ +.ads-feld { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--sp-4); + padding: 12px 0; + border-bottom: 1px solid var(--line); +} + +.ads-feld--last { + border-bottom: 0; +} + +.ads-feld label { + font-size: 13px; + color: var(--fg2); +} + +.ads-feld input, +.ads-feld select { + background: var(--tile-2); + border: 1px solid var(--line); + color: var(--fg); + font-family: inherit; + font-size: 13.5px; + padding: 8px 11px; + border-radius: var(--r-func); + text-align: right; + min-width: 132px; +} + +.ads-mit-einheit { + display: inline-flex; + align-items: center; + gap: var(--sp-2); +} + +.ads-mit-einheit input { + min-width: 92px; + text-align: right; +} + +.ads-mit-einheit span:last-child { + font-size: 13px; + color: var(--fg2); +} diff --git a/design-system/src/components/Feld/Feld.tsx b/design-system/src/components/Feld/Feld.tsx new file mode 100644 index 0000000..b7e108c --- /dev/null +++ b/design-system/src/components/Feld/Feld.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; + +export interface FeldProps { + label: React.ReactNode; + /** Read-only display mode - mutually exclusive with `children`. */ + value?: React.ReactNode; + /** Interactive control mode (input/select/Switch/etc.) - mutually exclusive with `value`. */ + children?: React.ReactNode; + /** Wraps `children` with a trailing unit label, e.g. "Minuten" (the "MitEinheit" pattern). */ + unit?: string; + /** Omit the bottom hairline - use on the last field in a group. */ + last?: boolean; + className?: string; +} + +export function Feld({ label, value, children, unit, last, className }: FeldProps) { + const classes = ["ads-feld"]; + if (last) classes.push("ads-feld--last"); + if (className) classes.push(className); + + const control = children ? ( + unit ? ( + + {children} + {unit} + + ) : ( + children + ) + ) : ( + {value} + ); + + return ( +
+ + {control} +
+ ); +} diff --git a/design-system/src/components/Feld/index.ts b/design-system/src/components/Feld/index.ts new file mode 100644 index 0000000..8ea9441 --- /dev/null +++ b/design-system/src/components/Feld/index.ts @@ -0,0 +1,2 @@ +export { Feld } from "./Feld"; +export type { FeldProps } from "./Feld"; diff --git a/design-system/src/components/Fig/Fig.css b/design-system/src/components/Fig/Fig.css new file mode 100644 index 0000000..33541c9 --- /dev/null +++ b/design-system/src/components/Fig/Fig.css @@ -0,0 +1,14 @@ +.ads-fig { + font-weight: 300; + letter-spacing: -.02em; + line-height: .95; + color: var(--fg); +} + +.ads-fig__unit { + font-size: .32em; + font-weight: 400; + letter-spacing: normal; + color: var(--fg2); + margin-left: .32em; +} diff --git a/design-system/src/components/Fig/Fig.tsx b/design-system/src/components/Fig/Fig.tsx new file mode 100644 index 0000000..7e77187 --- /dev/null +++ b/design-system/src/components/Fig/Fig.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +export interface FigProps { + value: React.ReactNode; + unit?: React.ReactNode; + /** Font-size override in px, e.g. 40 for a hero readout, smaller for compact contexts. */ + size?: number | string; + className?: string; +} + +export function Fig({ value, unit, size, className }: FigProps) { + const classes = ["ads-fig"]; + if (className) classes.push(className); + const style: React.CSSProperties | undefined = + size !== undefined ? { fontSize: typeof size === "number" ? `${size}px` : size } : undefined; + return ( + + {value} + {unit !== undefined && {unit}} + + ); +} diff --git a/design-system/src/components/Fig/index.ts b/design-system/src/components/Fig/index.ts new file mode 100644 index 0000000..f2734d1 --- /dev/null +++ b/design-system/src/components/Fig/index.ts @@ -0,0 +1,2 @@ +export { Fig } from "./Fig"; +export type { FigProps } from "./Fig"; diff --git a/design-system/src/components/IconButton/IconButton.css b/design-system/src/components/IconButton/IconButton.css new file mode 100644 index 0000000..488a730 --- /dev/null +++ b/design-system/src/components/IconButton/IconButton.css @@ -0,0 +1,28 @@ +.ads-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: none; + border-radius: 50%; + background: none; + color: var(--fg3); + cursor: pointer; + padding: 0; +} + +.ads-icon-button svg { + width: 18px; + height: 18px; +} + +.ads-icon-button:hover:not(:disabled) { + background: var(--tile-2); + color: var(--fg); +} + +.ads-icon-button:disabled { + opacity: .4; + cursor: default; +} diff --git a/design-system/src/components/IconButton/IconButton.tsx b/design-system/src/components/IconButton/IconButton.tsx new file mode 100644 index 0000000..5b1b5cc --- /dev/null +++ b/design-system/src/components/IconButton/IconButton.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; + +export interface IconButtonProps { + onClick?: () => void; + /** SVG icon element rendered inside the circular hit area. */ + children: React.ReactNode; + /** Required - this is an icon-only control with no visible text label. */ + "aria-label": string; + disabled?: boolean; + className?: string; +} + +export function IconButton({ + onClick, + children, + disabled, + className, + ...rest +}: IconButtonProps) { + const classes = ["ads-icon-button"]; + if (className) classes.push(className); + return ( + + ); +} diff --git a/design-system/src/components/IconButton/index.ts b/design-system/src/components/IconButton/index.ts new file mode 100644 index 0000000..553d46a --- /dev/null +++ b/design-system/src/components/IconButton/index.ts @@ -0,0 +1,2 @@ +export { IconButton } from "./IconButton"; +export type { IconButtonProps } from "./IconButton"; diff --git a/design-system/src/components/ImagePlaceholder/ImagePlaceholder.css b/design-system/src/components/ImagePlaceholder/ImagePlaceholder.css new file mode 100644 index 0000000..5903b21 --- /dev/null +++ b/design-system/src/components/ImagePlaceholder/ImagePlaceholder.css @@ -0,0 +1,58 @@ +.ads-bildbox { + position: relative; + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 12px; + overflow: hidden; +} + +.ads-bildbox img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.ads-bildbox--mini { + width: 78px; + height: 78px; + aspect-ratio: auto; + border-radius: 12px; +} + +.ads-bildbox--round { + border-radius: 50%; +} + +.ads-platzhalter { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px dashed var(--line-strong); + border-radius: inherit; + color: var(--fg3); + text-align: center; + padding: var(--sp-2); +} + +.ads-platzhalter__icon svg { + width: 26px; + height: 26px; + color: var(--fg3); +} + +.ads-platzhalter__label { + font-size: 10.5px; + letter-spacing: .02em; + color: var(--fg3); +} + +.ads-platzhalter__hint { + font-family: ui-monospace, monospace; + font-size: 9px; + color: var(--fg3); +} diff --git a/design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx b/design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx new file mode 100644 index 0000000..6387338 --- /dev/null +++ b/design-system/src/components/ImagePlaceholder/ImagePlaceholder.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; + +export interface ImagePlaceholderProps { + src?: string; + alt: string; + /** Caption shown on the load-error placeholder. */ + label: React.ReactNode; + /** Small monospace hint under the label, e.g. an expected filename. */ + hint?: React.ReactNode; + /** Placeholder icon override - defaults to a simple image glyph. */ + icon?: React.ReactNode; + size?: "default" | "mini" | "round"; + className?: string; +} + +const DEFAULT_ICON = ( + + + +); + +export function ImagePlaceholder({ + src, + alt, + label, + hint, + icon, + size = "default", + className, +}: ImagePlaceholderProps) { + const [failed, setFailed] = React.useState(false); + const classes = ["ads-bildbox"]; + if (size === "mini") classes.push("ads-bildbox--mini"); + if (size === "round") classes.push("ads-bildbox--round"); + if (className) classes.push(className); + + const showPlaceholder = !src || failed; + + return ( +
+ {!showPlaceholder && ( + {alt} setFailed(true)} /> + )} + {showPlaceholder && ( +
+ {icon ?? DEFAULT_ICON} + {label} + {hint !== undefined && {hint}} +
+ )} +
+ ); +} diff --git a/design-system/src/components/ImagePlaceholder/index.ts b/design-system/src/components/ImagePlaceholder/index.ts new file mode 100644 index 0000000..c3a1a26 --- /dev/null +++ b/design-system/src/components/ImagePlaceholder/index.ts @@ -0,0 +1,2 @@ +export { ImagePlaceholder } from "./ImagePlaceholder"; +export type { ImagePlaceholderProps } from "./ImagePlaceholder"; diff --git a/design-system/src/components/Pill/Pill.css b/design-system/src/components/Pill/Pill.css new file mode 100644 index 0000000..634cc9b --- /dev/null +++ b/design-system/src/components/Pill/Pill.css @@ -0,0 +1,16 @@ +.ads-pill { + display: inline-flex; + align-items: center; + font-size: 10px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--fg2); + border: 1px solid var(--line-strong); + border-radius: var(--r-pill); + padding: 6px 12px; +} + +.ads-pill--work { + color: var(--red); + border-color: var(--red); +} diff --git a/design-system/src/components/Pill/Pill.tsx b/design-system/src/components/Pill/Pill.tsx new file mode 100644 index 0000000..e66151d --- /dev/null +++ b/design-system/src/components/Pill/Pill.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +export interface PillProps { + children: React.ReactNode; + /** "work" recolors border+text to the accent red - use for a highlighted tag. */ + variant?: "default" | "work"; + className?: string; +} + +export function Pill({ children, variant = "default", className }: PillProps) { + const classes = ["ads-pill"]; + if (variant === "work") classes.push("ads-pill--work"); + if (className) classes.push(className); + return {children}; +} diff --git a/design-system/src/components/Pill/index.ts b/design-system/src/components/Pill/index.ts new file mode 100644 index 0000000..3059f31 --- /dev/null +++ b/design-system/src/components/Pill/index.ts @@ -0,0 +1,2 @@ +export { Pill } from "./Pill"; +export type { PillProps } from "./Pill"; diff --git a/design-system/src/components/Popup/Popup.css b/design-system/src/components/Popup/Popup.css new file mode 100644 index 0000000..565403c --- /dev/null +++ b/design-system/src/components/Popup/Popup.css @@ -0,0 +1,42 @@ +.ads-popup-catcher { + position: fixed; + inset: 0; + z-index: 1000; + background: none; + border: none; +} + +.ads-popup { + position: fixed; + z-index: 1001; + background: var(--tile-2); + border-radius: 14px; + padding: var(--sp-4); +} + +.ads-popup--menu { + display: flex; + flex-direction: column; + min-width: 180px; +} + +.ads-popup--form { + min-width: 260px; +} + +.ads-popup-item { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + color: var(--fg); + font-family: inherit; + font-size: 13.5px; + padding: 10px 4px; + cursor: pointer; +} + +.ads-popup-item--destructive { + color: var(--bad); +} diff --git a/design-system/src/components/Popup/Popup.tsx b/design-system/src/components/Popup/Popup.tsx new file mode 100644 index 0000000..5093c97 --- /dev/null +++ b/design-system/src/components/Popup/Popup.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; + +export interface PopupAnchor { + top?: number; + left?: number; + right?: number; + bottom?: number; +} + +export interface PopupProps { + open: boolean; + onClose: () => void; + /** "menu" = compact button-list card; "form" = wider inline form card. */ + variant?: "menu" | "form"; + anchor?: PopupAnchor | "center"; + children: React.ReactNode; + className?: string; + /** Portal target - defaults to document.body. Override for iframe/canvas embedding. */ + portalTarget?: Element; +} + +export function Popup({ + open, + onClose, + variant = "menu", + anchor = "center", + children, + className, + portalTarget, +}: PopupProps) { + if (!open) return null; + + const classes = ["ads-popup", `ads-popup--${variant}`]; + if (className) classes.push(className); + + const style: React.CSSProperties = + anchor === "center" + ? { top: "50%", left: "50%", transform: "translate(-50%, -50%)" } + : { ...anchor }; + + const content = ( + <> +
+
+ {children} +
+ + ); + + const target = portalTarget ?? (typeof document !== "undefined" ? document.body : null); + return target ? createPortal(content, target) : content; +} + +export interface PopupMenuItemProps { + onClick?: () => void; + destructive?: boolean; + children: React.ReactNode; +} + +export function PopupMenuItem({ onClick, destructive, children }: PopupMenuItemProps) { + return ( + + ); +} diff --git a/design-system/src/components/Popup/index.ts b/design-system/src/components/Popup/index.ts new file mode 100644 index 0000000..baaaffb --- /dev/null +++ b/design-system/src/components/Popup/index.ts @@ -0,0 +1,2 @@ +export { Popup, PopupMenuItem } from "./Popup"; +export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./Popup"; diff --git a/design-system/src/components/ProgressBar/ProgressBar.css b/design-system/src/components/ProgressBar/ProgressBar.css new file mode 100644 index 0000000..1c40019 --- /dev/null +++ b/design-system/src/components/ProgressBar/ProgressBar.css @@ -0,0 +1,48 @@ +.ads-balken { + height: 4px; + border-radius: var(--r-pill); + background: var(--line); + overflow: hidden; +} + +.ads-balken i { + display: block; + height: 100%; + background: var(--fg); + border-radius: var(--r-pill); +} + +.ads-bar { + display: flex; + height: 4px; + border-radius: var(--r-pill); + overflow: hidden; + gap: 2px; +} + +.ads-bar i { + display: block; + border-radius: var(--r-pill); +} + +.ads-legend { + display: flex; + flex-wrap: wrap; + gap: var(--sp-3); + margin-top: var(--sp-2); + font-size: 11px; + color: var(--fg2); +} + +.ads-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ads-legend i { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} diff --git a/design-system/src/components/ProgressBar/ProgressBar.tsx b/design-system/src/components/ProgressBar/ProgressBar.tsx new file mode 100644 index 0000000..2f477de --- /dev/null +++ b/design-system/src/components/ProgressBar/ProgressBar.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; + +export interface ProgressBarSegment { + value: number; + color?: string; + label?: React.ReactNode; +} + +export interface ProgressBarProps { + /** Simple single-fill mode (uses --fg). Mutually exclusive with `segments`. */ + percent?: number; + /** Stacked multi-fill mode with an optional legend row. */ + segments?: ProgressBarSegment[]; + /** Defaults to true when more than one segment is given. */ + showLegend?: boolean; + className?: string; +} + +export function ProgressBar({ percent, segments, showLegend, className }: ProgressBarProps) { + const classes = ["ads-progress-bar"]; + if (className) classes.push(className); + + if (segments && segments.length > 0) { + const total = segments.reduce((sum, s) => sum + s.value, 0) || 1; + const legend = showLegend ?? segments.length > 1; + return ( +
+
+ {segments.map((seg, i) => ( + + ))} +
+ {legend && ( +
+ {segments.map((seg, i) => ( + + + {seg.label} + + ))} +
+ )} +
+ ); + } + + const clamped = Math.max(0, Math.min(100, percent ?? 0)); + return ( +
+
+ +
+
+ ); +} diff --git a/design-system/src/components/ProgressBar/index.ts b/design-system/src/components/ProgressBar/index.ts new file mode 100644 index 0000000..274dab3 --- /dev/null +++ b/design-system/src/components/ProgressBar/index.ts @@ -0,0 +1,2 @@ +export { ProgressBar } from "./ProgressBar"; +export type { ProgressBarProps, ProgressBarSegment } from "./ProgressBar"; diff --git a/design-system/src/components/ProgressRing/ProgressRing.css b/design-system/src/components/ProgressRing/ProgressRing.css new file mode 100644 index 0000000..1b7fe72 --- /dev/null +++ b/design-system/src/components/ProgressRing/ProgressRing.css @@ -0,0 +1,32 @@ +.ads-ring { + position: relative; + display: inline-block; +} + +.ads-ring svg { + transform: rotate(-90deg); +} + +.ads-ring__track { + fill: none; + stroke: var(--line); + stroke-width: 10; +} + +.ads-ring__arc { + fill: none; + stroke: var(--red); + stroke-width: 10; + stroke-linecap: round; + transition: stroke-dashoffset .3s; +} + +.ads-ring__mid { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} diff --git a/design-system/src/components/ProgressRing/ProgressRing.tsx b/design-system/src/components/ProgressRing/ProgressRing.tsx new file mode 100644 index 0000000..9ce928d --- /dev/null +++ b/design-system/src/components/ProgressRing/ProgressRing.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { Fig } from "../Fig/Fig"; + +export interface ProgressRingProps { + /** 0-100 */ + percent: number; + /** Center figure - defaults to the rounded percent value. */ + value?: React.ReactNode; + unit?: string; + label?: React.ReactNode; + size?: number; + className?: string; +} + +const RADIUS = 64; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +export function ProgressRing({ + percent, + value, + unit, + label, + size = 150, + className, +}: ProgressRingProps) { + const clamped = Math.max(0, Math.min(100, percent)); + const offset = CIRCUMFERENCE * (1 - clamped / 100); + const classes = ["ads-ring"]; + if (className) classes.push(className); + + return ( +
+ + + + + + + {label !== undefined && {label}} + +
+ ); +} diff --git a/design-system/src/components/ProgressRing/index.ts b/design-system/src/components/ProgressRing/index.ts new file mode 100644 index 0000000..8047b47 --- /dev/null +++ b/design-system/src/components/ProgressRing/index.ts @@ -0,0 +1,2 @@ +export { ProgressRing } from "./ProgressRing"; +export type { ProgressRingProps } from "./ProgressRing"; diff --git a/design-system/src/components/RowList/RowList.css b/design-system/src/components/RowList/RowList.css new file mode 100644 index 0000000..acc1f6a --- /dev/null +++ b/design-system/src/components/RowList/RowList.css @@ -0,0 +1,34 @@ +.ads-rows { + margin: 0; +} + +.ads-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--sp-3); + padding: 13px 0; + border-bottom: 1px solid var(--line); +} + +.ads-row:last-child { + border-bottom: 0; +} + +.ads-row dt { + font-size: 13px; + color: var(--fg2); +} + +.ads-row dd { + margin: 0; + font-size: 14px; + color: var(--fg); + text-align: right; +} + +.ads-row dd small { + display: block; + font-size: 11px; + color: var(--fg3); +} diff --git a/design-system/src/components/RowList/RowList.tsx b/design-system/src/components/RowList/RowList.tsx new file mode 100644 index 0000000..43a44d6 --- /dev/null +++ b/design-system/src/components/RowList/RowList.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; + +export interface RowListItem { + key: React.Key; + label: React.ReactNode; + value: React.ReactNode; + subValue?: React.ReactNode; +} + +export interface RowListProps { + items: RowListItem[]; + className?: string; +} + +export function RowList({ items, className }: RowListProps) { + const classes = ["ads-rows"]; + if (className) classes.push(className); + return ( +
+ {items.map((item) => ( +
+
{item.label}
+
+ {item.value} + {item.subValue !== undefined && {item.subValue}} +
+
+ ))} +
+ ); +} diff --git a/design-system/src/components/RowList/index.ts b/design-system/src/components/RowList/index.ts new file mode 100644 index 0000000..098c812 --- /dev/null +++ b/design-system/src/components/RowList/index.ts @@ -0,0 +1,2 @@ +export { RowList } from "./RowList"; +export type { RowListProps, RowListItem } from "./RowList"; diff --git a/design-system/src/components/Seg/Seg.css b/design-system/src/components/Seg/Seg.css new file mode 100644 index 0000000..938d608 --- /dev/null +++ b/design-system/src/components/Seg/Seg.css @@ -0,0 +1,23 @@ +.ads-seg { + display: flex; + gap: var(--sp-2); +} + +.ads-seg button { + flex: 1; + border: 1px solid var(--line-strong); + background: none; + color: var(--fg2); + font-family: inherit; + font-size: 11px; + letter-spacing: .12em; + text-transform: uppercase; + padding: 11px 8px; + border-radius: var(--r-pill); + cursor: pointer; +} + +.ads-seg button.ads-seg__on { + border-color: var(--red); + color: var(--fg); +} diff --git a/design-system/src/components/Seg/Seg.tsx b/design-system/src/components/Seg/Seg.tsx new file mode 100644 index 0000000..12c9a60 --- /dev/null +++ b/design-system/src/components/Seg/Seg.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; + +export interface SegOption { + value: T; + label: React.ReactNode; +} + +export interface SegProps { + options: SegOption[]; + value: T; + onChange: (value: T) => void; + className?: string; +} + +export function Seg({ + options, + value, + onChange, + className, +}: SegProps) { + const classes = ["ads-seg"]; + if (className) classes.push(className); + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/design-system/src/components/Seg/index.ts b/design-system/src/components/Seg/index.ts new file mode 100644 index 0000000..082690d --- /dev/null +++ b/design-system/src/components/Seg/index.ts @@ -0,0 +1,2 @@ +export { Seg } from "./Seg"; +export type { SegProps, SegOption } from "./Seg"; diff --git a/design-system/src/components/StatGrid/StatGrid.css b/design-system/src/components/StatGrid/StatGrid.css new file mode 100644 index 0000000..a497d79 --- /dev/null +++ b/design-system/src/components/StatGrid/StatGrid.css @@ -0,0 +1,17 @@ +.ads-quad { + display: grid; + gap: var(--sp-4); +} + +.ads-quad__n { + display: block; +} + +.ads-quad__l { + display: block; + margin-top: 4px; + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--fg3); +} diff --git a/design-system/src/components/StatGrid/StatGrid.tsx b/design-system/src/components/StatGrid/StatGrid.tsx new file mode 100644 index 0000000..f75d249 --- /dev/null +++ b/design-system/src/components/StatGrid/StatGrid.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { Fig } from "../Fig/Fig"; + +export interface StatGridItem { + value: React.ReactNode; + unit?: string; + caption: React.ReactNode; +} + +export interface StatGridProps { + items: StatGridItem[]; + columns?: number; + className?: string; +} + +export function StatGrid({ items, columns = 4, className }: StatGridProps) { + const classes = ["ads-quad"]; + if (className) classes.push(className); + return ( +
+ {items.map((item, i) => ( +
+ + {item.caption} +
+ ))} +
+ ); +} diff --git a/design-system/src/components/StatGrid/index.ts b/design-system/src/components/StatGrid/index.ts new file mode 100644 index 0000000..fa048c8 --- /dev/null +++ b/design-system/src/components/StatGrid/index.ts @@ -0,0 +1,2 @@ +export { StatGrid } from "./StatGrid"; +export type { StatGridProps, StatGridItem } from "./StatGrid"; diff --git a/design-system/src/components/StatusRow/StatusRow.css b/design-system/src/components/StatusRow/StatusRow.css new file mode 100644 index 0000000..78ccd9f --- /dev/null +++ b/design-system/src/components/StatusRow/StatusRow.css @@ -0,0 +1,42 @@ +.ads-status { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + background: none; + border: none; + padding: 0; + text-align: left; + font-family: inherit; + cursor: default; +} + +button.ads-status { + cursor: pointer; +} + +.ads-dot { + width: 11px; + height: 11px; + border-radius: 50%; + flex: 0 0 auto; +} + +.ads-dot--ok { background: var(--ok); } +.ads-dot--warn { background: var(--warn); } +.ads-dot--bad { background: var(--bad); } + +.ads-status__text { + display: flex; + flex-direction: column; +} + +.ads-status__t { + font-size: 14px; + color: var(--fg); +} + +.ads-status__s { + font-size: 11px; + color: var(--fg3); +} diff --git a/design-system/src/components/StatusRow/StatusRow.tsx b/design-system/src/components/StatusRow/StatusRow.tsx new file mode 100644 index 0000000..68bcae7 --- /dev/null +++ b/design-system/src/components/StatusRow/StatusRow.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +export interface StatusRowProps { + status: "ok" | "warn" | "bad"; + title: React.ReactNode; + subtitle?: React.ReactNode; + onClick?: () => void; + className?: string; +} + +export function StatusRow({ status, title, subtitle, onClick, className }: StatusRowProps) { + const classes = ["ads-status"]; + if (className) classes.push(className); + const Tag: React.ElementType = onClick ? "button" : "div"; + return ( + + + + {title} + {subtitle !== undefined && {subtitle}} + + + ); +} diff --git a/design-system/src/components/StatusRow/index.ts b/design-system/src/components/StatusRow/index.ts new file mode 100644 index 0000000..ecd7294 --- /dev/null +++ b/design-system/src/components/StatusRow/index.ts @@ -0,0 +1,2 @@ +export { StatusRow } from "./StatusRow"; +export type { StatusRowProps } from "./StatusRow"; diff --git a/design-system/src/components/SwipeRow/SwipeRow.css b/design-system/src/components/SwipeRow/SwipeRow.css new file mode 100644 index 0000000..7cef7c3 --- /dev/null +++ b/design-system/src/components/SwipeRow/SwipeRow.css @@ -0,0 +1,25 @@ +.ads-swipe-wrap { + position: relative; + overflow: hidden; +} + +.ads-swipe-delete { + position: absolute; + inset: 0 0 0 auto; + width: 84px; + background: var(--bad); + color: #fff; + border: none; + font-family: inherit; + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase; + cursor: pointer; +} + +.ads-swipe-content { + position: relative; + background: var(--tile); + touch-action: pan-y; + transition: transform .18s; +} diff --git a/design-system/src/components/SwipeRow/SwipeRow.tsx b/design-system/src/components/SwipeRow/SwipeRow.tsx new file mode 100644 index 0000000..14013f5 --- /dev/null +++ b/design-system/src/components/SwipeRow/SwipeRow.tsx @@ -0,0 +1,95 @@ +import * as React from "react"; + +const SWIPE_THRESHOLD = -40; +const SWIPE_OPEN = -84; + +/** Module-wide pub/sub so opening one row closes every other open row (mirrors the source app's schliesseAlleSwipes()). */ +const listeners = new Set<() => void>(); +function closeAllExcept(keep: () => void) { + for (const close of listeners) { + if (close !== keep) close(); + } +} + +export interface SwipeRowProps { + children: React.ReactNode; + onDelete: () => void; + deleteLabel?: string; + /** Controlled swiped-open state. */ + swiped?: boolean; + onSwipedChange?: (swiped: boolean) => void; + className?: string; +} + +export function SwipeRow({ + children, + onDelete, + deleteLabel = "Delete", + swiped, + onSwipedChange, + className, +}: SwipeRowProps) { + const [internalSwiped, setInternalSwiped] = React.useState(false); + const isControlled = swiped !== undefined; + const isSwiped = isControlled ? swiped : internalSwiped; + + const setSwiped = React.useCallback( + (next: boolean) => { + if (!isControlled) setInternalSwiped(next); + onSwipedChange?.(next); + }, + [isControlled, onSwipedChange] + ); + + const close = React.useCallback(() => setSwiped(false), [setSwiped]); + + React.useEffect(() => { + listeners.add(close); + return () => { + listeners.delete(close); + }; + }, [close]); + + const dragStartX = React.useRef(null); + + const onPointerDown = (e: React.PointerEvent) => { + dragStartX.current = e.clientX; + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (dragStartX.current === null) return; + const delta = e.clientX - dragStartX.current; + if (delta < SWIPE_THRESHOLD) { + closeAllExcept(close); + setSwiped(true); + dragStartX.current = null; + } else if (delta > -SWIPE_THRESHOLD) { + setSwiped(false); + } + }; + + const onPointerUp = () => { + dragStartX.current = null; + }; + + const classes = ["ads-swipe-wrap"]; + if (className) classes.push(className); + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/design-system/src/components/SwipeRow/index.ts b/design-system/src/components/SwipeRow/index.ts new file mode 100644 index 0000000..52d950b --- /dev/null +++ b/design-system/src/components/SwipeRow/index.ts @@ -0,0 +1,2 @@ +export { SwipeRow } from "./SwipeRow"; +export type { SwipeRowProps } from "./SwipeRow"; diff --git a/design-system/src/components/Switch/Switch.css b/design-system/src/components/Switch/Switch.css new file mode 100644 index 0000000..77fc36d --- /dev/null +++ b/design-system/src/components/Switch/Switch.css @@ -0,0 +1,51 @@ +.ads-switch { + position: relative; + display: inline-block; + flex-shrink: 0; + width: 46px; + height: 27px; +} + +.ads-switch input { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; + cursor: pointer; + min-width: 0; +} + +.ads-switch__track { + position: absolute; + inset: 0; + background: var(--tile-2); + border: 1px solid var(--line); + border-radius: var(--r-pill); + transition: background .18s; + pointer-events: none; +} + +.ads-switch__track::before { + content: ""; + position: absolute; + left: 2px; + top: 1px; + width: 23px; + height: 23px; + background: #fff; + border-radius: 50%; + transition: transform .18s; +} + +.ads-switch input:checked + .ads-switch__track { + background: var(--red); + border-color: var(--red); +} + +.ads-switch input:checked + .ads-switch__track::before { + transform: translateX(19px); +} + +.ads-switch input:disabled + .ads-switch__track { + opacity: .4; +} diff --git a/design-system/src/components/Switch/Switch.tsx b/design-system/src/components/Switch/Switch.tsx new file mode 100644 index 0000000..1344960 --- /dev/null +++ b/design-system/src/components/Switch/Switch.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +export interface SwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + id?: string; + "aria-label"?: string; + className?: string; +} + +export function Switch({ + checked, + onChange, + disabled, + id, + className, + ...rest +}: SwitchProps) { + const classes = ["ads-switch"]; + if (className) classes.push(className); + return ( + + onChange(e.target.checked)} + {...rest} + /> + + + ); +} diff --git a/design-system/src/components/Switch/index.ts b/design-system/src/components/Switch/index.ts new file mode 100644 index 0000000..ffb3ce2 --- /dev/null +++ b/design-system/src/components/Switch/index.ts @@ -0,0 +1,2 @@ +export { Switch } from "./Switch"; +export type { SwitchProps } from "./Switch"; diff --git a/design-system/src/components/TabBar/TabBar.css b/design-system/src/components/TabBar/TabBar.css new file mode 100644 index 0000000..57c0cda --- /dev/null +++ b/design-system/src/components/TabBar/TabBar.css @@ -0,0 +1,40 @@ +.ads-tabbar { + display: grid; + border-top: 1px solid var(--line); +} + +.ads-tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 0 8px; + background: none; + border: none; + border-top: 2px solid transparent; + margin-top: -1px; + color: var(--fg3); + font-family: inherit; + cursor: pointer; +} + +.ads-tab--on { + border-top-color: var(--red); + color: var(--fg); +} + +.ads-tab__icon svg { + width: 21px; + height: 21px; +} + +.ads-tabbar--ohne .ads-tab__icon svg { + width: 26px; + height: 26px; +} + +.ads-tab__label { + font-size: 9px; + letter-spacing: .08em; + text-transform: uppercase; +} diff --git a/design-system/src/components/TabBar/TabBar.tsx b/design-system/src/components/TabBar/TabBar.tsx new file mode 100644 index 0000000..a472a47 --- /dev/null +++ b/design-system/src/components/TabBar/TabBar.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; + +export interface TabBarItem { + key: string; + label: string; + icon: React.ReactNode; +} + +export interface TabBarProps { + items: TabBarItem[]; + activeKey: string; + onChange: (key: string) => void; + /** Hide labels, enlarge icons (the source app's ".ohne" variant). */ + iconOnly?: boolean; + className?: string; +} + +export function TabBar({ items, activeKey, onChange, iconOnly, className }: TabBarProps) { + const classes = ["ads-tabbar"]; + if (iconOnly) classes.push("ads-tabbar--ohne"); + if (className) classes.push(className); + return ( + + ); +} diff --git a/design-system/src/components/TabBar/index.ts b/design-system/src/components/TabBar/index.ts new file mode 100644 index 0000000..f5da7bf --- /dev/null +++ b/design-system/src/components/TabBar/index.ts @@ -0,0 +1,2 @@ +export { TabBar } from "./TabBar"; +export type { TabBarProps, TabBarItem } from "./TabBar"; diff --git a/design-system/src/components/Tile/Tile.css b/design-system/src/components/Tile/Tile.css new file mode 100644 index 0000000..47d7f1e --- /dev/null +++ b/design-system/src/components/Tile/Tile.css @@ -0,0 +1,39 @@ +.ads-tile { + background: var(--tile); + border-radius: var(--r-tile); + padding: var(--sp-5); + margin-bottom: var(--sp-3); +} + +.ads-tile--flat { + background: none; + padding: 0; +} + +button.ads-tile--button { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border: none; + font-family: inherit; + text-align: left; + cursor: pointer; + position: relative; +} + +button.ads-tile--button:hover { + background: var(--tile-2); +} + +.ads-tile__content { + flex: 1; +} + +.ads-tile__go { + width: 6px; + height: 10px; + color: var(--fg3); + flex: 0 0 auto; + margin-left: var(--sp-3); +} diff --git a/design-system/src/components/Tile/Tile.tsx b/design-system/src/components/Tile/Tile.tsx new file mode 100644 index 0000000..412d075 --- /dev/null +++ b/design-system/src/components/Tile/Tile.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; + +export interface TileProps { + variant?: "default" | "flat" | "button"; + /** Trailing chevron affordance - only meaningful with variant="button". */ + chevron?: boolean; + /** Required when variant="button". */ + onClick?: () => void; + className?: string; + children: React.ReactNode; +} + +export function Tile({ variant = "default", chevron, onClick, className, children }: TileProps) { + const classes = ["ads-tile"]; + if (variant === "flat") classes.push("ads-tile--flat"); + if (variant === "button") classes.push("ads-tile--button"); + if (className) classes.push(className); + + if (variant === "button") { + return ( + + ); + } + + return
{children}
; +} diff --git a/design-system/src/components/Tile/index.ts b/design-system/src/components/Tile/index.ts new file mode 100644 index 0000000..90183ee --- /dev/null +++ b/design-system/src/components/Tile/index.ts @@ -0,0 +1,2 @@ +export { Tile } from "./Tile"; +export type { TileProps } from "./Tile"; diff --git a/design-system/src/index.ts b/design-system/src/index.ts new file mode 100644 index 0000000..d7f7924 --- /dev/null +++ b/design-system/src/index.ts @@ -0,0 +1,53 @@ +export { Pill } from "./components/Pill"; +export type { PillProps } from "./components/Pill"; + +export { IconButton } from "./components/IconButton"; +export type { IconButtonProps } from "./components/IconButton"; + +export { Fig } from "./components/Fig"; +export type { FigProps } from "./components/Fig"; + +export { StatusRow } from "./components/StatusRow"; +export type { StatusRowProps } from "./components/StatusRow"; + +export { ActionButton } from "./components/ActionButton"; +export type { ActionButtonProps } from "./components/ActionButton"; + +export { Switch } from "./components/Switch"; +export type { SwitchProps } from "./components/Switch"; + +export { Seg } from "./components/Seg"; +export type { SegProps, SegOption } from "./components/Seg"; + +export { RowList } from "./components/RowList"; +export type { RowListProps, RowListItem } from "./components/RowList"; + +export { Tile } from "./components/Tile"; +export type { TileProps } from "./components/Tile"; + +export { Feld } from "./components/Feld"; +export type { FeldProps } from "./components/Feld"; + +export { ProgressRing } from "./components/ProgressRing"; +export type { ProgressRingProps } from "./components/ProgressRing"; + +export { ProgressBar } from "./components/ProgressBar"; +export type { ProgressBarProps, ProgressBarSegment } from "./components/ProgressBar"; + +export { StatGrid } from "./components/StatGrid"; +export type { StatGridProps, StatGridItem } from "./components/StatGrid"; + +export { Accordion, LeafRow } from "./components/Accordion"; +export type { AccordionProps, LeafRowProps } from "./components/Accordion"; + +export { SwipeRow } from "./components/SwipeRow"; +export type { SwipeRowProps } from "./components/SwipeRow"; + +export { TabBar } from "./components/TabBar"; +export type { TabBarProps, TabBarItem } from "./components/TabBar"; + +export { Popup, PopupMenuItem } from "./components/Popup"; +export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./components/Popup"; + +export { ImagePlaceholder } from "./components/ImagePlaceholder"; +export type { ImagePlaceholderProps } from "./components/ImagePlaceholder"; diff --git a/design-system/src/styles.css b/design-system/src/styles.css new file mode 100644 index 0000000..22a55c1 --- /dev/null +++ b/design-system/src/styles.css @@ -0,0 +1,22 @@ +/* Einstiegspunkt: Tokens zuerst, danach jede Komponenten-CSS-Datei. */ +@import "./tokens/tokens.css"; + +@import "./components/Pill/Pill.css"; +@import "./components/IconButton/IconButton.css"; +@import "./components/Fig/Fig.css"; +@import "./components/StatusRow/StatusRow.css"; +@import "./components/ActionButton/ActionButton.css"; +@import "./components/Switch/Switch.css"; +@import "./components/Seg/Seg.css"; +@import "./components/RowList/RowList.css"; +@import "./components/Accordion/LeafRow.css"; +@import "./components/Tile/Tile.css"; +@import "./components/Feld/Feld.css"; +@import "./components/ProgressRing/ProgressRing.css"; +@import "./components/ProgressBar/ProgressBar.css"; +@import "./components/StatGrid/StatGrid.css"; +@import "./components/Accordion/Accordion.css"; +@import "./components/SwipeRow/SwipeRow.css"; +@import "./components/TabBar/TabBar.css"; +@import "./components/Popup/Popup.css"; +@import "./components/ImagePlaceholder/ImagePlaceholder.css"; diff --git a/design-system/src/tokens/tokens.css b/design-system/src/tokens/tokens.css new file mode 100644 index 0000000..00fb91e --- /dev/null +++ b/design-system/src/tokens/tokens.css @@ -0,0 +1,98 @@ +/* + * Design-Tokens, aus dem Audi-Dashboard (audi-dashboard.css) übernommen. + * Bewusst OHNE die dort eingebundene "Audi Type"-Schriftfamilie und ohne + * Audi-Marken-Assets (Ringe/Typenschild) - laut bauauftrag.md §"Rechtlich" + * ausschließlich für die private, nicht veröffentlichte HA-Installation + * freigegeben. Hier gilt ein neutraler Fallback-Font-Stack. + */ + +:root { + /* Markenunabhängig */ + --red: #F50537; + --r-tile: 20px; + --r-pill: 999px; + --r-func: 8px; + --rahmen-hg: #111; + + /* Abstandsskala (aus den im Original wiederkehrenden Literalwerten abgeleitet) */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 22px; + + /* Nacht (Default-Theme, entspricht dem :root-Block des Originals) */ + --canvas: #161b23; + --tile: #1f2733; + --tile-2: #2a3341; + --line: rgba(255, 255, 255, .10); + --line-strong: rgba(255, 255, 255, .20); + --fg: #FFFFFF; + --fg2: #9aa1ad; + --fg3: #657081; + --ok: #15da15; + --warn: #ffaa00; + --bad: #fd2c4e; + --shade: rgba(255, 255, 255, .05); + + --font-stack: Helvetica, Arial, sans-serif; +} + +[data-theme="tag"] { + --canvas: #FFFFFF; + --tile: #f2f2f2; + --tile-2: #e5e5e5; + --line: rgba(0, 0, 0, .10); + --line-strong: rgba(0, 0, 0, .22); + --fg: #000000; + --fg2: #4c4c4c; + --fg3: #666666; + --ok: #0DA20D; + --warn: #ffaa00; + --bad: #eb0d3f; + --shade: rgba(0, 0, 0, .04); +} + +/* + * Wurzel-Klasse: von konsumierenden Apps um den Baum zu legen, der die + * Bibliothek nutzt (ersetzt das :host-Scoping, das das Original per + * Shadow DOM kostenlos bekam). Setzt Schriftstack + Zahlendarstellung. + */ +.ads-root { + font-family: var(--font-stack); + font-variant-numeric: tabular-nums; + color: var(--fg); + background: var(--canvas); +} + +/* Reine Typografie-Utility-Klassen (siehe README) - keine eigenen Komponenten */ +.ads-eyebrow { + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--fg3); + display: block; +} + +.ads-title { + font-size: 23px; + letter-spacing: -.015em; + line-height: 1.15; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ads-label { + font-size: 10px; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--fg3); + display: block; +} + +.ads-sport { + font-style: italic; + letter-spacing: .005em; +} diff --git a/design-system/tsconfig.json b/design-system/tsconfig.json new file mode 100644 index 0000000..68a0769 --- /dev/null +++ b/design-system/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "declaration": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["src", "scripts"] +} diff --git a/design-system/tsup.config.ts b/design-system/tsup.config.ts new file mode 100644 index 0000000..0544e39 --- /dev/null +++ b/design-system/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts", styles: "src/styles.css" }, + format: ["esm", "cjs"], + dts: true, + clean: true, + sourcemap: true, + external: ["react", "react-dom"], +}); diff --git a/homeassistant/.gitignore b/homeassistant/.gitignore new file mode 100644 index 0000000..d78e8bb --- /dev/null +++ b/homeassistant/.gitignore @@ -0,0 +1,8 @@ +# Echte Fahrzeug-/Personendaten - nie versionieren (VIN, Kennzeichen, +# Versicherung, Werkstattkontakt, Servicehistorie, Fahrten, Tankvorgänge, +# echte Tankbelege). Vorlage ohne echte Daten bleibt getrackt, siehe +# data/fahrzeugprofil.example.json und README.md. +data/fahrzeugprofil.json +data/fahrten.jsonl +data/tankvorgaenge.jsonl +data/tests/belege/*.pdf diff --git a/homeassistant/INSTALL.md b/homeassistant/INSTALL.md new file mode 100644 index 0000000..419fc1e --- /dev/null +++ b/homeassistant/INSTALL.md @@ -0,0 +1,274 @@ +# Installation — Schritt für Schritt + +Betrifft den kompletten aktuellen Baustand: das pyscript-Backend +(Fahrterkennung, Fahrtabschluss, Reifenzähler, Belegverarbeitung) **und** +das Frontend (`panel_custom`, Sidebar-Eintrag „Mein Audi"). + +**Getestet, nicht nur geprüft.** Beides lief in einer Wegwerf-Testinstanz +(Home Assistant in Docker) wirklich: das Backend über echte Service-Aufrufe +(Reifen wechseln hat die Profildatei tatsächlich umgeschrieben, eine +manuell angelegte Fahrt wurde tatsächlich an `fahrten.jsonl` angehängt), +das Frontend, indem das Custom Element mit genau diesen echten Daten +gefüttert wurde und daraus korrekt die Übersicht und die Fahrtenliste +gerendert hat — inklusive der zuvor testweise umgeschalteten Winterbereifung, +sichtbar an der Fahrbahn-Szene. Wofür die Testinstanz **nicht** reichte: der +Weg über Home Assistants echte WebSocket-Verbindung, mit der der +`panel_custom`-Rahmen (`ha-panel-custom`) das Element normalerweise selbst +erzeugt und mit `hass` versorgt — diese Testumgebung ließ keine +WebSocket-Verbindung zu, unabhängig vom eigenen Code. Dieser letzte Schritt +— dass die Oberfläche beim ganz normalen Draufklicken in der Sidebar +erscheint — ist deshalb der einzige Teil, der sich erst bei euch wirklich +zeigt. `panel_custom` selbst ist Home Assistants eigener, seit Jahren +stabiler Mechanismus, nicht eigener Code, entsprechend gering ist das +Risiko dort. + +**Voraussetzung:** HACS ist bereits installiert (ergibt sich aus der +laufenden Integration `TommiG1/HA_VAG-EU-Data-Act`). + +Zeitaufwand: ca. 30–40 Minuten, größtenteils Warten auf Neustarts. + +--- + +## Schritt 1 — pyscript installieren + +1. In Home Assistant: **HACS → Integrationen → Explore & Download Repositories** +2. Nach „pyscript" suchen, auswählen, **Download** +3. Home Assistant neu starten (**Einstellungen → System → Neu starten**) + +## Schritt 2 — Dateien auf den Home-Assistant-Rechner kopieren + +Drei Ordner müssen in das `config`-Verzeichnis von Home Assistant kopiert +werden. Wählt den Weg, der zu eurer Installation passt: + +**Vorher:** `data/fahrzeugprofil.json` enthält bei einer laufenden Installation +echte Fahrzeug- und Personendaten (VIN, Kennzeichen, Versicherung, Werkstatt, +Servicehistorie) und ist deshalb nicht Teil dieses Repos (siehe `.gitignore`). +Für eine neue Installation zuerst `data/fahrzeugprofil.example.json` nach +`data/fahrzeugprofil.json` kopieren — die Platzhalter darin lassen sich nach +dem ersten Start bequem direkt in der App eintragen: **Einstellungen → +Fahrzeug einrichten** deckt FIN, Kennzeichen, Erstzulassung und Ausführung ab, +Versicherung/Werkstatt/Reifen haben eigene Bearbeiten-Ansichten. + +**Am einfachsten: Samba-Share-Add-on** +1. Falls noch nicht installiert: **Einstellungen → Add-ons → Add-on Store → + „Samba share"** installieren und starten +2. Am Windows-Rechner im Explorer verbinden: `\\\config` +3. Von diesem Projektordner aus kopieren: + - `pyscript\` (der ganze Ordner) → `\\\config\pyscript\` + - `data\` (der ganze Ordner) → `\\\config\audi_dashboard\` + (Ordner beim Kopieren von `data` in `audi_dashboard` umbenennen) + - `www\` (der ganze Ordner, Frontend) → `\\\config\www\` + (Inhalt zusammenführen, falls dort schon eine `www`-Ablage existiert) + +**Alternative: Studio Code Server Add-on** +1. **Einstellungen → Add-ons → Add-on Store → „Studio Code Server"** + installieren, starten, öffnen +2. Im Dateibaum links Ordner `pyscript`, `audi_dashboard` und `www` unter + `/config` anlegen, falls sie fehlen +3. Dateien einzeln per Drag & Drop aus dem Explorer in den Browser ziehen, + oder Rechtsklick → „Upload" + +**Alternative: SSH & Terminal Add-on**, falls bereits eingerichtet — dann +reicht `scp`/`rsync` vom gewohnten Terminal aus. + +## Schritt 3 — configuration.yaml ergänzen + +`configuration_snippet.yaml` aus diesem Ordner öffnen. Die zwei Blöcke +(`pyscript:` und `panel_custom:`) in die bestehende `configuration.yaml` +übernehmen — **nicht** die Datei komplett ersetzen. Falls dort schon ein +`pyscript:`-Block existiert, nur die Zeile `allow_all_imports: true` darin +ergänzen statt einen zweiten Block anzulegen. + +Der `panel_custom:`-Block kann schon jetzt mit rein; er wird erst mit dem +Frontend-Baustein wirksam und stört bis dahin nicht. + +## Schritt 4 — die Entity-IDs eintragen + +Das ist die einzige Stelle, die inhaltlich angepasst werden muss — Datei +`pyscript/modules/einstellungen.py`. + +**Zwingend fürs Backend** (ohne diese drei läuft die Kernlogik nicht): + +1. In Home Assistant: **Entwicklerwerkzeuge → Zustände** +2. Dort suchen und die genauen Entity-IDs notieren für: + - den Kilometerstand-Sensor (aus `TommiG1/HA_VAG-EU-Data-Act`) → `KM_SENSOR` + - den Tankfüllstand-Sensor (dieselbe Integration) → `TANK_SENSOR` + - den WLAN-Verbindungssensor des iPhones → `WLAN_SENSOR` — dafür vorher + in der iOS-Companion-App unter **Einstellungen → Companion App → + Sensoren** den Sensor für das verbundene WLAN aktivieren, falls er dort + noch grau ist + +**Optional fürs Frontend** (nur für die Übersicht/Zustand-Seite, ohne sie +zeigt die Oberfläche „unbekannt" statt eines Werts — kein Absturz): + +3. Ebenfalls in Entwicklerwerkzeuge → Zustände suchen: + - Reichweite → `RANGE_SENSOR` + - Türstatus (falls als ein Sammel-Sensor vorhanden) → `DOORS_SENSOR` + - Fensterstatus → `WINDOWS_SENSOR` + - Verriegelung (Domäne `lock.`) → `LOCK_ENTITY` + - Batteriespannung, falls überhaupt vorhanden → `BATTERY_VOLTAGE_SENSOR` + +4. Datei `pyscript/modules/einstellungen.py` öffnen (Samba: direkt im + Explorer, Studio Code Server: im Editor) und alle gefundenen Werte + eintragen. Was ihr nicht findet, einfach auf dem Platzhalter stehen + lassen — siehe Kommentar in der Datei dazu. + +## Schritt 5 — Long-Lived Access Token erzeugen + +Wird für das Kilometerstand-Screening gebraucht (§7.2) — pyscript hat keinen +eingebauten Weg, auf die Recorder-Historie zuzugreifen, deshalb läuft das +über die normale Home-Assistant-Web-API. + +1. Unten links auf den eigenen Profil-Avatar klicken +2. Ganz nach unten scrollen zu **„Long-lived access tokens"** +3. **„Token erstellen"**, einen Namen vergeben (z. B. `audi_dashboard`) +4. Den angezeigten Token-Wert **sofort kopieren** — er wird danach nicht + noch einmal angezeigt +5. Eine neue Datei `audi_dashboard/ha_token.txt` anlegen (im selben Ordner, + in den `data/` kopiert wurde) und **nur den Token-Wert** hineinschreiben, + keine Anführungszeichen, keine zweite Zeile + + ⚠️ Diese Datei enthält ein Geheimnis. Nicht weitergeben, nicht in ein + Backup hochladen, das öffentlich einsehbar ist. + +## Schritt 6 — neu starten + +**Einstellungen → System → Neu starten.** + +## Schritt 7 — prüfen, ob alles geladen hat + +1. **Einstellungen → System → Protokolle**, nach „pyscript" oder + „audi_dashboard" filtern — es sollten keine roten Fehlermeldungen + auftauchen, insbesondere keine `ImportError` oder `ModuleNotFoundError` +2. **Entwicklerwerkzeuge → Zustände**, nach `pyscript.reifen` suchen — dort + sollten `pyscript.reifen_sommer_km`, `pyscript.reifen_winter_km` (Wert `0`, + solange noch kein Kilometerstand-Update seit der Installation einging) und + `pyscript.reifen_aktiver_satz` auftauchen +3. **Entwicklerwerkzeuge → Aktionen**, nach „audi_dashboard" suchen — die + Services `pyscript.audi_dashboard_screening_jetzt`, + `pyscript.audi_dashboard_reifen_wechseln`, + `pyscript.audi_dashboard_fahrt_manuell_anlegen`, + `pyscript.audi_dashboard_beleg_hochladen` und + `pyscript.audi_dashboard_tankvorgang_manuell` sollten dort erscheinen + +Wenn das alles stimmt, läuft das Backend. Die Fahrterkennung selbst lässt +sich am einfachsten testen, indem das WLAN am Handy kurz ausgeschaltet und +wieder eingeschaltet wird (Pausenregel greift) bzw. länger als die +eingestellte Pausenzeit ausgeschaltet bleibt (Fahrt wird angelegt) — danach +in `audi_dashboard/fahrten.jsonl` nachsehen, ob eine Zeile entstanden ist. + +## Schritt 8 — was jetzt noch fehlt, bevor es vollständig nutzbar ist + +- **Reifen-Kilometerstände:** laufen automatisch mit (`reifen.saetze. + sommer/winter.km`, siehe reifenzaehler.py) — beide starten bei der + Installation bei `0`. Hat einer der Sätze schon Laufleistung von vor der + Installation, den Wert einmalig direkt in `audi_dashboard/ + fahrzeugprofil.json` nachtragen +- **Kfz-Steuer-Fälligkeit:** `steuer.faellig` im selben Profil eintragen +- **shell_beleg_parser.py** liegt bereits unter `data/shell_beleg_parser.py` + und wird mit dem `data`-Ordner aus Schritt 2 automatisch nach + `audi_dashboard/shell_beleg_parser.py` mitkopiert. Er ruft `python3` als + eigenen Prozess auf (nicht die pyscript-Sandbox) und braucht dafür + einmalig `pypdf`: im **Terminal & SSH**-Add-on (oder per `docker exec`) + `pip install pypdf` ausführen. Ohne das schlägt jeder Beleg-Upload mit + `ModuleNotFoundError: No module named 'pypdf'` im Log fehl. +- **Tailscale „VPN On Demand"** in der Tailscale-App selbst einrichten + (Regel „Only On", gebunden an `Audi_MMI_2804_5GHz`) — unabhängig von + Home Assistant, kann jederzeit parallel erledigt werden + +## Schritt 9 — Frontend prüfen + +Der `panel_custom`-Eintrag aus Schritt 3 zeigt auf +`/config/www/audi-dashboard-panel.js`, die in Schritt 2 mitkopiert wurde. + +1. In der Sidebar sollte nach dem Neustart aus Schritt 6 ein neuer Eintrag + „Mein Audi" erscheinen (Auto-Symbol). Anklicken. +2. Die Übersicht sollte erscheinen: Fahrzeugbild (als Platzhalter, siehe + unten), Typenschild, Kilometerstand, Reichweite. Falls Kilometerstand + oder Reichweite „unbekannt"/leer bleiben, obwohl Schritt 7 erfolgreich + war: die optionalen Entity-IDs aus Schritt 4 nachtragen. +3. Falls die Seite leer bleibt oder gar nicht in der Sidebar erscheint: im + Browser die Entwicklerkonsole öffnen (F12) und nach Fehlern mit + „audi-dashboard" oder „pyscript" suchen — siehe Troubleshooting. + +**Was direkt sichtbar fehlt, ganz bewusst (siehe README):** +- **Bilder** — der Ordner `bilder/` mit den elf Fotos aus §7a ist nicht + Teil dieses Baustands. Die Bildflächen bleiben leer bzw. zeigen ein + gebrochenes Bild-Symbol, das Layout selbst springt nicht (die Maße sind + reserviert). Bilder können jederzeit einzeln unter `www/bilder/` nachgereicht + werden, feste Dateinamen siehe §7a im Lastenheft. +- **Karten** (Leaflet) laden weiterhin von einem CDN, genau wie im + Prototyp. Das Gerät braucht dafür zusätzlich zur Tailscale-Verbindung + normalen Internetzugang — sonst bleibt die Karte auf der Fahrt- und + Tankvorgang-Detailseite leer. +- **Statistik-Seite** zeigt weiterhin Beispielzahlen aus dem Prototyp, + keine echte Auswertung der eigenen Fahrten — eigenes Arbeitspaket. + +## Schritt 10 — künftige Updates einspielen, ohne die App neu zu bauen + +Ab hier ändert sich der Ablauf: kein Neustart mehr nötig, weder für +Backend- noch für Frontend-Änderungen. + +**Backend (pyscript):** pyscript überwacht `pyscript/` selbst auf +Änderungen und lädt geänderte Dateien automatisch neu — an der +Testinstanz gemessen: eine bearbeitete Datei war innerhalb von +Millisekunden aktiv, neue Services erschienen sofort in +Entwicklerwerkzeuge → Aktionen, ganz ohne Neustart. Es reicht, die neue +Datei per Samba/Studio-Code-Server/`scp` in `pyscript/` zu überschreiben. + +**Frontend:** hier gibt es keinen eingebauten Auto-Reload, dafür aber ein +echtes Cache-Problem — Home Assistant liefert Dateien aus `/local/` mit +`Cache-Control: max-age=2678400` aus, 31 Tage (an der Testinstanz +gemessen). Ohne Gegenmaßnahme würde eine Aktualisierung im Browser +tagelang nicht ankommen. Deshalb ist das Frontend zweigeteilt: + +- `audi-dashboard-panel.js` ist ein kleiner, stabil bleibender Lade-Stub, + auf den `panel_custom` in der `configuration.yaml` zeigt +- er lädt bei jedem Seitenaufruf zuerst `audi-dashboard-version.json` + ungecacht, hängt deren Versionsnummer als Parameter an und lädt darüber + erst den eigentlichen Code aus `audi-dashboard-app.js` nach — jede neue + Versionsnummer ist für den Browser eine neue URL und wird nie aus einem + alten Cache bedient + +**Am einfachsten mit dem beiliegenden Skript:** + +```powershell +.\update.ps1 -Ziel "\\\config" +``` + +Kopiert `pyscript/` und die Frontend-Dateien, schreibt +`audi-dashboard-version.json` automatisch mit einem neuen Zeitstempel — +**lässt `data/` unangetastet**, damit ein bereits laufendes Fahrzeugprofil +oder Fahrten-Archiv nicht überschrieben wird. Voraussetzung: der +Samba-Share aus Schritt 2 ist als Netzlaufwerk verbunden. + +Danach: pyscript-Änderungen sind sofort aktiv, für das Frontend reicht ein +ganz normales Neuladen der Seite (F5) — kein Hard-Refresh, kein +HA-Neustart. + +⚠️ Server-seitig geprüft: eine neue `audi-dashboard-version.json` und ein +geänderter `audi-dashboard-app.js`-Inhalt standen an der Testinstanz sofort +zur Verfügung (per `curl` nachgemessen). Ob ein echter Browser das beim +nächsten Öffnen tatsächlich nachlädt, ließ sich in der Sandbox-Testumgebung +nicht abschließend zeigen — deren eigene Netzwerkschicht verhielt sich +bereits beim WebSocket-Test nicht wie ein normaler Browser (siehe README). +Das Verfahren selbst (`cache:"no-store"` plus versionierter +Import-Parameter) ist eine Standardtechnik gegen genau dieses Problem, +keine Vermutung — der erste echte Test dafür ist trotzdem der erste echte +Update-Durchlauf bei euch. + +--- + +## Troubleshooting + +| Symptom | Wahrscheinliche Ursache | +|---|---| +| `ModuleNotFoundError: No module named 'einstellungen'` (oder `profil`, `fahrtabschluss_logik`) | Ordner falsch kopiert — `modules/`-Unterordner muss unter `pyscript/modules/` liegen, nicht direkt unter `pyscript/` | +| pyscript lädt gar nicht, keine Fehler, keine Services | `allow_all_imports: true` fehlt oder Einrückung in der `configuration.yaml` ist falsch (YAML ist einrückungsempfindlich) | +| Fahrt wird nie angelegt | `WLAN_SENSOR` in `einstellungen.py` zeigt auf die falsche Entity, oder `fahrzeug.wlan_name` im Profil passt nicht exakt zum Sensorwert (Groß-/Kleinschreibung zählt) | +| Screening findet nie einen Kilometerstand | `ha_token.txt` fehlt, ist leer, oder der Token wurde widerrufen — in den Protokollen nach `HTTPError` oder `401` suchen | +| Reifenzähler zeigt dauerhaft „unbekannt" | `KM_SENSOR` in `einstellungen.py` liefert keinen Wert, oder es kam seit der Installation noch keine Änderung des Kilometerstands an (reifenzaehler.py reagiert nur auf Sensor-Änderungen) | +| „Mein Audi" fehlt in der Sidebar | `panel_custom:`-Block fehlt oder ist falsch eingerückt in `configuration.yaml`; nach Änderungen daran hilft nur ein vollständiger Neustart, kein „YAML neu laden" | +| Sidebar-Eintrag da, Seite bleibt leer | Browser-Konsole (F12) prüfen: 404 bei `/local/audi-dashboard-panel.js` → Datei liegt nicht unter `/config/www/`; JS-Fehler beim Laden → Datei unvollständig kopiert, Dateigröße mit dem Original vergleichen | +| Übersicht erscheint, aber alle Werte „unbekannt" | Normal, solange `KM_SENSOR`/`TANK_SENSOR`/... in `einstellungen.py` noch Platzhalter sind (Schritt 4) — kein Frontend-Fehler | +| Karten bleiben leer auf Fahrt-/Tankdetailseite | Gerät hat keinen Internetzugang zusätzlich zu Tailscale — Leaflet lädt von einem CDN (siehe Schritt 9) | diff --git a/homeassistant/README.md b/homeassistant/README.md new file mode 100644 index 0000000..04c3a86 --- /dev/null +++ b/homeassistant/README.md @@ -0,0 +1,158 @@ +# Audi-Dashboard — Backend und Frontend, Stand nach dritter Bauphase + +Dieser Ordner enthält Backend **und** Frontend aus dem Lastenheft +(`bauauftrag.md`): das pyscript-Backend für Fahrterkennung, Fahrtabschluss, +Reifenzähler und Belegverarbeitung, plus der zum `panel_custom`-Custom- +Element umgebaute Prototyp. + +**Installation:** siehe [`INSTALL.md`](INSTALL.md) — Schritt für Schritt, +inklusive der einzigen Stelle, die inhaltlich angepasst werden muss +(`pyscript/modules/einstellungen.py`, Entity-IDs). + +## Getestet, nicht nur geprüft + +Beides lief in einer Wegwerf-Testinstanz (Home Assistant in Docker), nicht +nur gegen Dokumentation abgeglichen: + +- **Backend:** alle Module laden fehlerfrei, alle Trigger und Services + registrieren sich korrekt. Zwei echte Service-Aufrufe über die HA-API + haben tatsächlich Daten geschrieben — `audi_dashboard_reifen_wechseln` + hat `fahrzeugprofil.json` von „sommer" auf „winter" umgestellt, + `audi_dashboard_fahrt_manuell_anlegen` hat einen vollständigen Datensatz + an `fahrten.jsonl` angehängt. +- **Frontend:** das Custom Element wurde mit genau diesen echten + Backend-Daten gefüttert (per REST abgerufen) und hat daraus korrekt die + Übersicht und die Fahrtenliste gerendert — inklusive der zuvor + umgeschalteten Winterbereifung, sichtbar an der Fahrbahn-Szene, und der + manuell angelegten Testfahrt in der Fahrten-Ansicht. + +Dabei wurden drei echte pyscript-Eigenheiten gefunden und behoben, die in +keiner Dokumentation standen: + +1. `task.executor()` akzeptiert nur echte externe Python-Funktionen (z. B. + `io.open`), keine im pyscript-Ordner selbst definierten. +2. Variablen aus einem `with ... as f:`-Block waren danach außerhalb nicht + mehr auffindbar (`NameError`). +3. Das eingebaute `open()` existiert in pyscript nicht. + +**Eine Lücke bleibt:** die Testinstanz ließ keine WebSocket-Verbindung zu +(Einschränkung der Testumgebung, nicht des Codes). Der letzte Schritt — dass +`panel_custom` das Element beim normalen Draufklicken in der Sidebar selbst +erzeugt und mit `hass` versorgt — läuft über genau diese Verbindung und +ließ sich deshalb nicht End-to-End nachstellen. `panel_custom` ist aber +Home Assistants eigener, langjährig stabiler Mechanismus, nicht eigener +Code — das Risiko dort ist gering, verglichen mit dem, was schon getestet +werden konnte. + +## Persönliche Daten + +Alles Fahrzeug- und Personenspezifische (VIN, Kennzeichen, Versicherung, +Werkstattkontakt, Servicehistorie, Fahrten, Tankvorgänge) liegt ausschließlich +in `data/` und wird nie im App-Code (`pyscript/`, `www/`) fest hinterlegt — +`data/fahrzeugprofil.json`, `data/fahrten.jsonl`, `data/tankvorgaenge.jsonl` +sowie die echten Tankbeleg-PDFs unter `data/tests/belege/` sind deshalb per +`.gitignore` von der Versionierung ausgeschlossen. `data/fahrzeugprofil. +example.json` ist die getrackte Vorlage ohne echte Werte für eine +Neuinstallation (siehe INSTALL.md Schritt 2); die Kernangaben lassen sich +danach direkt in der App unter **Einstellungen → Fahrzeug einrichten** +eintragen, ohne die JSON-Datei von Hand zu bearbeiten. Das Backup +(**Einstellungen → Backup**) exportiert ausschließlich diese drei +Datendateien — nie App-Code oder -Konfiguration. + +## Danach noch offen + +- **Reifen-Kilometerstände** (`reifen.saetze.sommer/winter.km`) laufen seit + der Frontend-Anpassung automatisch mit: jeder gefahrene Kilometer zählt auf + den Satz, der laut `reifen.aktiv` gerade montiert ist (reifenzaehler.py, + reagiert auf `KM_SENSOR`). Beide starten bei `0` — Laufleistung von vor der + Installation muss einmalig direkt im Profil nachgetragen werden. +- **Kfz-Steuer-Fälligkeit:** `steuer.faellig` steht auf `null`, aus §13 noch + offen. +- **shell_beleg_parser.py** fehlte im Projektordner beim Bau und wurde + nachträglich neu geschrieben — liegt jetzt unter `data/shell_beleg_parser.py` + und landet mit dem `data`-Ordner automatisch unter + `/config/audi_dashboard/shell_beleg_parser.py` (siehe INSTALL.md Schritt 2 + und Schritt 8). Braucht dort einmalig `pip install pypdf` im System-`python3` + des Containers (eigener Prozess, nicht die pyscript-Sandbox). Geprüft gegen + zehn echte Mobile-Payment-Belege aus vier verschiedenen Stationen (Ingolstadt/ + Zrenner, Rain am Lech/Bauch, Ansbach/Sengül, Königsbronn/Mogler) — die + Erkennung ist bewusst nicht an Markenzeilen-Wortlaut ("SHELL STATION" vs. + "Shell-Station") oder Rabatt-Label ("V-Power Smart Deal" vs. + "ClubSmartRabatt") gebunden, siehe Regressionstest unter + `data/tests/test_shell_beleg_parser.py` (Aufruf: `python3 + data/tests/test_shell_beleg_parser.py`). Andere Zahlungswege als Mobile + Payment oder Belege mit mehreren Positionen (z. B. zusätzlicher + Autowäsche-Posten mit abweichendem MwSt.-Satz) sind nicht durch reale + Belege abgedeckt und müssten bei Bedarf nachgezogen werden. +- **Tailscale:** „VPN On Demand" mit Regel „Only On", gebunden an + `Audi_MMI_2804_5GHz`, muss in der Tailscale-App selbst eingerichtet werden + (§9, §10 Punkt 8) — das lässt sich nicht von hier aus mitinstallieren. +- **GPS-Fallback (§7.2) ist nicht umgesetzt.** Fahrten ohne passenden + Kilometerstand bleiben aktuell dauerhaft `offen`, statt auf die + GPS-Strecke auszuweichen. Erfordert eine `device_tracker`-Anbindung und + Adressauflösung. +- **Fahrterkennung übersteht keinen HA-Neustart mitten in einer Fahrt oder + Wartezeit** — der Zwischenstand lebt nur im Arbeitsspeicher von + `fahrterkennung.py`. +- **Bilder fehlen** (§7a) — Ordner `www/bilder/` ist noch leer. Layout + springt nicht (Maße sind reserviert), aber die Flächen bleiben leer. +- **Statistik-Seite** zeigt weiterhin Beispielzahlen aus dem Prototyp, keine + echte Auswertung der erfassten Fahrten. +- **Leaflet lädt per CDN**, wie im Prototyp — braucht zusätzlich zu + Tailscale echten Internetzugang auf dem Gerät. + +## Was schon funktioniert (getestet) + +- Fahrterkennung über WLAN mit Pausenregel (`task.unique` + `task.sleep`) +- Zweistufiger Fahrtabschluss per Recorder-Historie-Screening, inklusive + Verkettung direkt anschließender Fahrten +- Reifenzähler per direkter Subtraktion, kein `utility_meter` +- Belegverarbeitung inklusive 97-%-Volltankungsregel und Duplikatserkennung + über `receipt_key` +- Manuelle Fallback-Wege für Fahrten und Tankvorgänge ohne Beleg +- Frontend: Übersicht, Fahrtenliste, Navigation zwischen allen Ansichten, + Datenadapter Profil→Oberfläche, Schreibaktionen über `hass.callService` + +## Dateiübersicht + +``` +homeassistant/ +├── INSTALL.md Schritt-für-Schritt-Installation +├── README.md diese Datei +├── configuration_snippet.yaml Ergänzung zur configuration.yaml +├── data/ +│ ├── fahrzeugprofil.json Fahrzeugprofil (§6.1), mit echten Daten befüllt — gitignored +│ ├── fahrzeugprofil.example.json Vorlage ohne echte Daten, bleibt getrackt (siehe INSTALL.md Schritt 2) +│ ├── fahrten.jsonl Fahrten-Archiv (§6.1) — gitignored +│ └── tankvorgaenge.jsonl Tankvorgänge-Archiv (§6.1) — gitignored +├── pyscript/ +│ ├── modules/ +│ │ ├── einstellungen.py zentrale Entity-ID-Konfiguration (Schritt 4) +│ │ ├── profil.py Datenzugriff (§6) +│ │ ├── fahrtabschluss_logik.py Screening-Logik (§7.2) +│ │ └── frontend_veroeffentlichung.py Zustände fürs Frontend (§10 Punkt 7 Ersatz) +│ ├── fahrterkennung.py §7.1 +│ ├── fahrtabschluss.py §7.2 (Trigger-Registrierung) +│ ├── reifenzaehler.py §7.5 +│ ├── belegverarbeitung.py §7.4, §7.7 +│ └── frontend_api.py Lese-/Schreib-Anbindung fürs Frontend +├── www/ +│ ├── audi-dashboard-panel.js Lade-Stub (zeigt configuration.yaml hierher), ändert sich kaum +│ ├── audi-dashboard-app.js das eigentliche Custom Element, hier passiert die Arbeit +│ ├── audi-dashboard-version.json Cache-Buster, wird von update.ps1 automatisch neu geschrieben +│ ├── audi-dashboard.css aus dem Prototyp extrahiert, für Shadow DOM angepasst +│ └── bilder/ noch leer, siehe „Danach noch offen" +└── update.ps1 Updates einspielen ohne Neustart (Schritt 10 in INSTALL.md) +``` + +## Updates einspielen + +`pyscript/` lädt sich bei Änderungen selbst neu, ganz ohne HA-Neustart — an +der Testinstanz gemessen (Datei überschrieben, Service erschien innerhalb +von Millisekunden). Fürs Frontend gibt es dafür keinen eingebauten +Mechanismus, dazu kommt ein echtes Cache-Problem: `/local/` wird 31 Tage +lang gecacht (`Cache-Control: max-age=2678400`, ebenfalls gemessen). Gelöst +über einen kleinen Lade-Stub (`audi-dashboard-panel.js`), der bei jedem +Seitenaufruf ungecacht eine Versionsnummer nachlädt und darüber den +eigentlichen Code cache-sicher nachzieht — siehe Schritt 10 in +[`INSTALL.md`](INSTALL.md) und [`update.ps1`](update.ps1). diff --git a/homeassistant/configuration_snippet.yaml b/homeassistant/configuration_snippet.yaml new file mode 100644 index 0000000..4763226 --- /dev/null +++ b/homeassistant/configuration_snippet.yaml @@ -0,0 +1,38 @@ +# Ergänzung zur bestehenden configuration.yaml. +# Nicht die ganze Datei ersetzen - diese Zeilen unter den jeweils schon +# vorhandenen Top-Level-Schlüsseln einfügen (falls z. B. schon ein +# `pyscript:`-Block existiert, dort zusammenführen statt duplizieren). + +# pyscript-Backend: Fahrterkennung, Fahrtabschluss, Reifenzähler, +# Belegverarbeitung (siehe pyscript/-Ordner). allow_all_imports ist nötig, +# weil die Skripte os, subprocess, urllib.request, base64 und uuid nutzen - +# pyscript erlaubt standardmäßig gar keine Importe, auch keine aus der +# Standardbibliothek. +pyscript: + allow_all_imports: true + +# Frontend: bindet den umgebauten Prototyp als eigenen Sidebar-Eintrag ein +# (§10 Punkt 1). module_url zeigt auf /config/www/audi-dashboard-panel.js +# (lokal erreichbar unter /local/audi-dashboard-panel.js). Die Datei selbst +# entsteht erst in Phase 2 (Frontend-Umbau) - bis dahin ist dieser Eintrag +# noch nicht funktionsfähig. +# +# Das ?v=1 am Ende ist absichtlich: audi-dashboard-panel.js selbst wird von +# Home Assistant mit einer sehr langen Standard-Cache-Zeit ausgeliefert +# (31 Tage), weil sie als stabiler Lade-Stub gedacht ist, der praktisch nie +# wieder angefasst wird (siehe deren eigener Kopfkommentar). Ändert sie sich +# doch einmal (ist an der Testinstanz bereits vorgekommen), bleibt jeder +# Browser, der die Datei vorher schon geladen hatte, sonst für bis zu 31 +# Tage auf dem alten Stand hängen - unabhängig davon, wie oft die Datei auf +# dem Server ersetzt wird. Die Versionsnummer hier zu erhöhen ändert die +# angefragte URL und erzwingt damit einen echten Neuabruf bei jedem Browser. +# Das erfordert einen HA-Neustart (Änderungen an configuration.yaml), ist +# aber dafür zuverlässig - anders als bei audi-dashboard-app.js/.css, die +# über audi-dashboard-version.json ganz ohne Neustart aktualisiert werden. +panel_custom: + - name: audi-dashboard-panel + sidebar_title: Mein Audi + sidebar_icon: mdi:car-sports + module_url: /local/audi-dashboard-panel.js?v=1 + embed_iframe: false + trust_external_script: false diff --git a/homeassistant/data/fahrzeugprofil.example.json b/homeassistant/data/fahrzeugprofil.example.json new file mode 100644 index 0000000..feb1488 --- /dev/null +++ b/homeassistant/data/fahrzeugprofil.example.json @@ -0,0 +1,185 @@ +{ + "_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Vor der Installation nach fahrzeugprofil.json kopieren und die Platzhalter unten ersetzen (VIN, Kennzeichen, Erstzulassung, HU-Termin, WLAN-Name, Versicherung, Werkstatt, Servicebuch) - am einfachsten über den Button 'Einrichten' in den Einstellungen der App, sobald sie einmal läuft. Diese Datei selbst bleibt bewusst im Repo (siehe .gitignore), die echte fahrzeugprofil.json nicht.", + + "fahrzeug": { + "fin": "", + "kennzeichen": "", + "titel": "RS 4 Avant competition", + "zusatz": "Avant competition", + "untertitel": "quattro · 331 kW", + "details": "2.9 TFSI quattro competition · 331 kW · 450 PS", + "erstzulassung": "", + "hauptuntersuchung_faellig": "", + "wlan_name": "", + "tankvolumen_liter": 58, + "ausfuehrung": "competition" + }, + + "einstellungen": { + "nacht_von": "19:00", + "nacht_bis": "06:00", + "fahrten_pausenzeit_min": 15, + "uebersichtsbild": "Frontansicht", + "kraftstoffanbieter": "Shell", + "smartdeal": { "aktiv": false, "laeuft_ab": "" }, + "oelwechsel_intervall": { + "modus": "hersteller", + "km": 15000, + "monate": 12, + "hersteller_km": 30000, + "hersteller_monate": 24 + }, + "backup_intervall": "aus", + "letztes_backup": null + }, + + "versicherung": { + "gesellschaft": "", + "vertrag": "", + "nummer": "", + "service": "", + "notfall": "", + "beitrag": 0, + "steueranteil": 0, + "steuersatz": "19 %", + "gueltigAb": "", + "zahlweise": "jährlich", + "teile": [ + { "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" }, + { "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" }, + { "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null } + ], + "selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]], + "schutzbrief": { + "stand": "", + "gilt": "", + "versichert": "", + "ausloeser": [], + "entweder": [], + "gruppen": [], + "nicht": [], + "pflichten": [], + "rang": "" + }, + "leistungen": [] + }, + + "steuer": { + "betrag": 0, + "zeitraum": "jährlich", + "faellig": null, + "grundlage": "Hubraum und CO₂-Ausstoß", + "lastschrift": true, + "finanzamt": "" + }, + + "reifen": { + "aktiv": "sommer", + "anzugsmoment_nm": 120, + "wechsel": { "datum": "", "vereinbart": false }, + "referenz_odo_km": null, + "saetze": { + "sommer": { + "km": 0, + "bild": "/local/bilder/rad-sommer.webp", + "marke": "", + "modell": "", + "dot": "", + "mass": "275/30 R20 97Y XL", + "druck_vorne": "2,5", + "druck_hinten": "2,4", + "kommentar": "" + }, + "winter": { + "km": 0, + "bild": "/local/bilder/rad-winter.webp", + "marke": "", + "modell": "", + "dot": "", + "mass": "265/35 R19", + "druck_vorne": "2,6", + "druck_hinten": "2,5", + "kommentar": "" + } + } + }, + + "service": { + "vereinbart": {}, + "autohaus": { + "name": "", + "adresse": "", + "tel": "", + "mail": "", + "pos": null + }, + "buch": [] + }, + + "technik": [ + { "gruppe": "Motor", "posten": [ + ["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"], + ["Hubraum", "2 894 cm³"], + ["Leistung", "331 kW / 450 PS bei 5 700–6 700 min⁻¹"], + ["Maximales Drehmoment", "600 Nm bei 2 000–5 000 min⁻¹"], + ["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"], + ["Abgasnorm", "EU6 AP"], + ["Motormanagement", "vollelektronisch"] + ]}, + { "gruppe": "Kraftübertragung", "posten": [ + ["Getriebe", "8-Gang tiptronic"], + ["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"], + ["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"] + ]}, + { "gruppe": "Fahrleistungen", "posten": [ + ["0 auf 100 km/h", "3,9 s"], + ["Höchstgeschwindigkeit", "290 km/h"], + ["Effizienzklasse", "E"], + ["Fahrgeräusch", "70 dB(A)"] + ]}, + { "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [ + ["Fahrwerk", "RS-Sportfahrwerk"], + ["Vorderachse", "5-Lenker, Rohrstabilisator"], + ["Hinterachse", "5-Lenker, Rohrstabilisator"], + ["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"], + ["Wendekreis", "ca. 11,7 m"], + ["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"], + ["Vorn", "Faustsattel, Scheibe innenbelüftet"], + ["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"] + ]}, + { "gruppe": "Räder und Reifen", "posten": [ + ["Räder", "Leichtmetall 9J × 20, Audi Sport"], + ["Reifen", "275/30 R20 97Y XL"], + ["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"], + ["Radschrauben", "Standard"], + ["Anzugsmoment", "120 Nm"] + ]}, + { "gruppe": "Maße, Gewichte, Volumen", "posten": [ + ["Leergewicht", "1 820 kg"], + ["Zulässiges Gesamtgewicht", "2 350 kg"], + ["Dachlast / Stützlast", "90 / 80 kg"], + ["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"], + ["Anhängelast ungebremst", "750 kg"], + ["Gepäckraum", "495 / 1 495 l"], + ["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"] + ]}, + { "gruppe": "Bordnetz", "posten": [ + ["Batterie", "420 A, 75 Ah"], + ["Generator", "100–150 A"], + ["Kältemittel", "R1234yf"] + ]}, + { "gruppe": "Wartung", "posten": [ + ["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"], + ["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"] + ]} + ], + + "ausstattung": [ + { "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] }, + { "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] }, + { "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] }, + { "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] }, + { "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] }, + { "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] } + ] +} diff --git a/homeassistant/data/shell_beleg_parser.py b/homeassistant/data/shell_beleg_parser.py new file mode 100644 index 0000000..3d4b251 --- /dev/null +++ b/homeassistant/data/shell_beleg_parser.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py. + +Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in +pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken"). +Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren +tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine +angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier +Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3 +tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege +unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs. +"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung +("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die +Stations- und Rabatterkennung unten bewusst nicht an deren genauen +Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr., +Betragsmuster vor "EUR #A"). + +Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales +Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über +`python3 shell_beleg_parser.py ` (system-python3 im Container, nicht +die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py), +deshalb reicht `pip install pypdf` einmalig im selben Python, das +`python3` in configuration.yaml/Docker auch sonst benutzt. + +Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen: +Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout +bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg +(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht). + +Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix): + SHELL STATION + + + + Obj.-Nr.: Tel. ... + Beleg-Nr. + * EUR #A* + *Zp l EUR/l # * + [ - EUR #A -- optional, nur mit SmartDeal + EUR/Liter -- optional] + Gesamtbetrag EUR + Typ Netto Mwst Brutto + A:% + ... + Mobile Payment + -- Zahlungszeitstempel + ... + (Seite 2) POS:...-...-... TSE-Beleg-Nr: + Start/Ende: / ... +""" + +import datetime +import json +import re +import sys + +try: + import pypdf +except ImportError: + print( + "pypdf fehlt. Einmalig installieren mit: pip install pypdf " + "(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).", + file=sys.stderr, + ) + sys.exit(1) + + +def _text_aus_pdf(pdf_pfad): + leser = pypdf.PdfReader(pdf_pfad) + return "\n".join(seite.extract_text() or "" for seite in leser.pages) + + +def _de_zahl(text): + """"103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei + Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte + vor dem Umwandeln entfernen, nicht nur das Komma ersetzen.""" + if text is None: + return None + return float(text.strip().replace(".", "").replace(",", ".")) + + +def _iso(tag, monat, jahr, stunde, minute, sekunde="0"): + jahr = int(jahr) + if jahr < 100: + jahr += 2000 + return datetime.datetime( + jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde) + ).isoformat() + + +_ZAHL = r"[\d.,]+" + + +def _parsen(text): + def suchen(muster, *, pflicht=True, flags=re.MULTILINE): + treffer = re.search(muster, text, flags) + if treffer is None and pflicht: + raise ValueError(f"Muster nicht gefunden: {muster!r}") + return treffer + + # ---- Station --------------------------------------------------------- + # Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL + # STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen, + # offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben). + # Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die + # Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten + # Belegen gleich. + station = suchen( + r"(?P.+?)\s*\n\s*(?P.+?)\s*\n\s*(?P.+?)\s*\n\s*Obj\.-Nr\." + ) + station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1) + + # ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ---------- + kopf = suchen( + r"Beleg-Nr\.\s*(?P[\d/]+)\s+(?P\d{2})\.(?P\d{2})\.(?P\d{2})\s+(?P\d{2}):(?P\d{2})" + ) + receipt_no = kopf.group("receipt_no") + ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"), + kopf.group("stunde"), kopf.group("minute")) + + # ---- Kraftstoff-Position -------------------------------------------- + position = suchen( + r"\*(?P\d+)\s+(?P.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*" + ) + article_no = position.group("article_no") + product_name = position.group("produkt").strip() + + zapfsaeule = suchen( + r"\*Zp\s*\d+\s+(?P" + _ZAHL + r")\s*l\s+(?P" + _ZAHL + r")\s*EUR/l" + ) + liters = _de_zahl(zapfsaeule.group("liter")) + list_price_per_l = _de_zahl(zapfsaeule.group("preis")) + + # ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) -- + rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False) + discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None + rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False) + discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None + + # ---- Beträge ---------------------------------------------------------- + fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1)) + steuerzeile = suchen( + r"A:" + _ZAHL + r"%\s+(?P" + _ZAHL + r")\s+(?P" + _ZAHL + r")\s+(?P" + _ZAHL + r")" + ) + net_eur = _de_zahl(steuerzeile.group("netto")) + vat_eur = _de_zahl(steuerzeile.group("mwst")) + receipt_total_eur = _de_zahl(steuerzeile.group("brutto")) + + # ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes + # Zahlungsverfahren auf dem Beleg steht --------------------------------- + zahlung = suchen( + r"Mobile Payment\s*\n+\s*(?P\d{2})\.(?P\d{2})\.(?P\d{4})\s+(?P\d{2}):(?P\d{2}):(?P\d{2})", + pflicht=False, + ) + ts_payment = ( + _iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"), + zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde")) + if zahlung else None + ) + + # ---- TSE-Signatur (Seite 2) - optional ------------------------------- + tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False) + tse_beleg_nr = tse_nr.group(1) if tse_nr else None + tse_zeit = suchen( + r"Start/Ende:\s*(?P\d{2})\.(?P\d{2})\.(?P\d{4})\s+(?P\d{2}):(?P\d{2}):(?P\d{2})", + pflicht=False, + ) + ts_tse = ( + _iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"), + tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde")) + if tse_zeit else None + ) + + # receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3) + receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}" + + return { + "receipt_key": receipt_key, + "receipt_no": receipt_no, + "tse_beleg_nr": tse_beleg_nr, + "ts": ts, + "ts_payment": ts_payment, + "ts_tse": ts_tse, + "station_id": station_id, + "station_name": station.group("name").strip(), + "station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}", + "article_no": article_no, + "product_name": product_name, + "fuel_type": product_name, + "liters": liters, + "list_price_per_l": list_price_per_l, + "discount": discount, + "discount_per_l": discount_per_l, + "fuel_total_eur": fuel_total_eur, + "receipt_total_eur": receipt_total_eur, + "net_eur": net_eur, + "vat_eur": vat_eur, + } + + +def main(): + if len(sys.argv) != 2: + print("Aufruf: python3 shell_beleg_parser.py ", file=sys.stderr) + sys.exit(1) + pdf_pfad = sys.argv[1] + try: + text = _text_aus_pdf(pdf_pfad) + ergebnis = _parsen(text) + except Exception as exc: + print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr) + sys.exit(1) + print(json.dumps(ergebnis, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/homeassistant/data/tests/test_shell_beleg_parser.py b/homeassistant/data/tests/test_shell_beleg_parser.py new file mode 100644 index 0000000..7f3d0e7 --- /dev/null +++ b/homeassistant/data/tests/test_shell_beleg_parser.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Regressionstest für shell_beleg_parser.py gegen zehn echte Shell-eReceipts. + +Die PDFs in belege/ sind reale, vom Nutzer bereitgestellte Tankbelege aus +vier verschiedenen Stationen (Ingolstadt/Zrenner, Rain am Lech/Bauch, +Ansbach/Sengül, Königsbronn/Mogler) und decken damit die tatsächlich +beobachtete Formatvielfalt ab, nicht nur eine angenommene Struktur: + - Markenzeile: "SHELL STATION" / "Shell Station" / "Shell-Station" + - Rabattbezeichnung: "V-Power Smart Deal" / "ClubSmartRabatt" + - POS-Zeile: mit und ohne "68002-"-Präfix vor der Stations-ID + - unterschiedliche Fußzeilen-Länge/-Inhalte + +Aufruf: python3 data/tests/test_shell_beleg_parser.py +(braucht pypdf im selben Python wie shell_beleg_parser.py selbst) +""" + +import os +import sys +import unittest + +_HIER = os.path.dirname(os.path.abspath(__file__)) +_BELEGE = os.path.join(_HIER, "belege") +sys.path.insert(0, os.path.dirname(_HIER)) # .. /data, wo shell_beleg_parser.py liegt + +import shell_beleg_parser as p # noqa: E402 + + +def _parsen_datei(dateiname): + pfad = os.path.join(_BELEGE, dateiname) + return p._parsen(p._text_aus_pdf(pfad)) + + +class AlleBelegeParsenOhneFehler(unittest.TestCase): + """Jeder der zehn echten Belege muss ohne Exception durchlaufen und + strukturell plausible Werte liefern - das ist der eigentliche Zweck + dieser Suite: künftige Regex-Änderungen dürfen keinen der bekannten + realen Belegtypen mehr brechen.""" + + @classmethod + def setUpClass(cls): + cls.dateien = sorted(f for f in os.listdir(_BELEGE) if f.endswith(".pdf")) + cls.ergebnisse = {f: _parsen_datei(f) for f in cls.dateien} + + def test_zehn_belege_gefunden(self): + self.assertEqual(len(self.dateien), 10, self.dateien) + + def test_pflichtfelder_gesetzt(self): + pflichtfelder = [ + "receipt_key", "receipt_no", "ts", "station_id", "station_name", + "station_address", "article_no", "product_name", "fuel_type", + "liters", "list_price_per_l", "fuel_total_eur", + "receipt_total_eur", "net_eur", "vat_eur", + ] + for datei, d in self.ergebnisse.items(): + for feld in pflichtfelder: + with self.subTest(datei=datei, feld=feld): + self.assertIsNotNone(d[feld], f"{feld} fehlt in {datei}") + + def test_betraege_plausibel(self): + for datei, d in self.ergebnisse.items(): + with self.subTest(datei=datei): + self.assertGreater(d["liters"], 0) + self.assertGreater(d["list_price_per_l"], 0) + self.assertGreater(d["fuel_total_eur"], 0) + # Netto + MwSt muss dem Bruttobetrag entsprechen (Rundung: 1 Cent) + self.assertAlmostEqual(d["net_eur"] + d["vat_eur"], d["receipt_total_eur"], delta=0.01) + # Gesamtbetrag der Position == Bruttobetrag der Steuerzeile + self.assertAlmostEqual(d["fuel_total_eur"], d["receipt_total_eur"], delta=0.01) + if d["discount"] is not None: + self.assertGreater(d["discount"], 0) + # Rabatt darf den Bruttobetrag nicht übersteigen + self.assertLess(d["discount"], d["fuel_total_eur"]) + + def test_receipt_key_eindeutig(self): + schluessel = [d["receipt_key"] for d in self.ergebnisse.values()] + self.assertEqual(len(schluessel), len(set(schluessel)), schluessel) + + def test_bekannte_stationen(self): + # Vier verschiedene Betreiber/Städte in den zehn Belegen - stellt sicher, + # dass die Stationserkennung nicht zufällig nur für einen Betreiber passt. + namen = {d["station_name"] for d in self.ergebnisse.values()} + erwartet = { + "A. Zrenner GmbH", "Thomas Bauch GmbH", + "TC Sengül GmbH", "Hermann Mogler Mineralölg. GmbH", + } + self.assertEqual(namen, erwartet) + + def test_rabatt_label_unabhaengig(self): + # e-receipt7.pdf nutzt "ClubSmartRabatt" statt "V-Power Smart Deal" - + # die Rabatterkennung darf nicht am Label hängen, nur am Betragsmuster. + d = self.ergebnisse["e-receipt7.pdf"] + self.assertAlmostEqual(d["discount"], 6.95, delta=0.001) + self.assertAlmostEqual(d["discount_per_l"], 0.18, delta=0.001) + + def test_stichproben_werte(self): + # Punktuelle Kontrolle einzelner, von Hand aus dem Beleg abgelesener + # Werte - fängt stille Verschiebungen (z.B. falsche Regex-Gruppe) ab, + # die die reinen Plausibilitätschecks oben nicht erkennen würden. + stichproben = { + "e-receipt.pdf": dict(liters=42.15, fuel_total_eur=78.78, discount=11.80, station_id="0000007770"), + "e-receipt3.pdf": dict(liters=21.73, fuel_total_eur=44.53, discount=6.08, station_id="0000000456"), + "e-receipt5.pdf": dict(liters=47.76, fuel_total_eur=79.23, discount=12.90, station_id="0000002227"), + "e-receipt9.pdf": dict(liters=51.59, fuel_total_eur=85.59, discount=13.41, station_id="0000002103"), + } + for datei, erwartet in stichproben.items(): + d = self.ergebnisse[datei] + with self.subTest(datei=datei): + self.assertAlmostEqual(d["liters"], erwartet["liters"], delta=0.001) + self.assertAlmostEqual(d["fuel_total_eur"], erwartet["fuel_total_eur"], delta=0.001) + self.assertAlmostEqual(d["discount"], erwartet["discount"], delta=0.001) + self.assertEqual(d["station_id"], erwartet["station_id"]) + + +class BelegOhneRabatt(unittest.TestCase): + """SmartDeal/ClubSmart ist optional - nicht jeder Tankvorgang hat einen + Rabatt. Simuliert durch Entfernen der Rabattzeilen aus einem echten + Beleg, da keiner der zehn realen Belege rabattfrei war.""" + + def test_ohne_rabatt_bleibt_alles_andere_korrekt(self): + text = p._text_aus_pdf(os.path.join(_BELEGE, "e-receipt.pdf")) + ohne_rabatt = "\n".join( + z for z in text.split("\n") + if "Smart Deal" not in z and "EUR/Liter" not in z + ) + d = p._parsen(ohne_rabatt) + self.assertIsNone(d["discount"]) + self.assertIsNone(d["discount_per_l"]) + self.assertAlmostEqual(d["fuel_total_eur"], 78.78, delta=0.001) + self.assertAlmostEqual(d["liters"], 42.15, delta=0.001) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/homeassistant/pyscript/backup.py b/homeassistant/pyscript/backup.py new file mode 100644 index 0000000..54b658e --- /dev/null +++ b/homeassistant/pyscript/backup.py @@ -0,0 +1,92 @@ +"""Backup-Funktion für Fahrzeugprofil, Fahrten und Tankvorgänge. + +Manuell über "Backup jetzt erstellen" in den Einstellungen oder automatisch +je nach einstellungen.backup_intervall im Fahrzeugprofil (aus/täglich/ +wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner +täglicher Trigger um 04:00 genügt für alle drei Intervalle: wöchentlich läuft +nur montags, monatlich nur am Monatsersten, täglich jeden Tag - so reicht eine +einzige Trigger-Registrierung statt drei. + +Jedes Backup ist ein eigener Zeitstempel-Unterordner mit Kopien der drei +Bestände (siehe profil.py), keine ZIP-Datei - das würde den vorhandenen +task.executor(io.open, ...)-Weg (siehe dessen Kopfkommentar in profil.py) +unnötig verkomplizieren. shutil.copy() ist wie io.open eine echte externe +Funktion und läuft deshalb über denselben task.executor-Weg. + +"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein +über den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine +JSON-Datei, siehe audi-dashboard-app.js) - audi_dashboard_backup_wiederherstellen() +hier ist nur das Gegenstück zum Import, das die drei Bestände wieder +komplett überschreibt. +""" + +import datetime +import json +import os +import shutil + +import frontend_veroeffentlichung +import profil + +BACKUP_ORDNER = f"{profil.BASIS}/backups" +_DATEIEN = ["fahrzeugprofil.json", "fahrten.jsonl", "tankvorgaenge.jsonl"] + + +def _backup_jetzt(): + ziel = f"{BACKUP_ORDNER}/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}" + os.makedirs(ziel, exist_ok=True) + for name in _DATEIEN: + quelle = f"{profil.BASIS}/{name}" + if os.path.exists(quelle): + task.executor(shutil.copy, quelle, f"{ziel}/{name}") + return ziel + + +def _letztes_backup_vermerken(): + p = profil.profil_lesen() + p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat() + profil.profil_schreiben(p) + frontend_veroeffentlichung.profil_veroeffentlichen() + + +@service +def audi_dashboard_backup_jetzt(): + """Erstellt sofort ein Backup. Aufruf als pyscript.audi_dashboard_backup_jetzt.""" + ziel = _backup_jetzt() + _letztes_backup_vermerken() + log.info(f"audi_dashboard: Backup erstellt unter {ziel}") + + +@time_trigger("cron(0 4 * * *)") +def _automatisches_backup(): + p = profil.profil_lesen() + intervall = p.get("einstellungen", {}).get("backup_intervall", "aus") + heute = datetime.date.today() + faellig = ( + intervall == "taeglich" + or (intervall == "woechentlich" and heute.weekday() == 0) + or (intervall == "monatlich" and heute.day == 1) + ) + if not faellig: + return + ziel = _backup_jetzt() + _letztes_backup_vermerken() + log.info(f"audi_dashboard: automatisches Backup ({intervall}) erstellt unter {ziel}") + + +@service +def audi_dashboard_backup_wiederherstellen(profil_json=None, fahrten_json=None, tankvorgaenge_json=None): + """Stellt ein über "Backup exportieren" heruntergeladenes Backup wieder + her - überschreibt Profil, Fahrten und Tankvorgänge komplett. Aufruf als + pyscript.audi_dashboard_backup_wiederherstellen.""" + if profil_json: + p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json + profil.profil_schreiben(p) + if fahrten_json is not None: + fahrten = json.loads(fahrten_json) if isinstance(fahrten_json, str) else fahrten_json + profil.fahrten_schreiben(fahrten) + if tankvorgaenge_json is not None: + tankvorgaenge = json.loads(tankvorgaenge_json) if isinstance(tankvorgaenge_json, str) else tankvorgaenge_json + profil.tankvorgaenge_schreiben(tankvorgaenge) + frontend_veroeffentlichung.alles_veroeffentlichen() + log.info("audi_dashboard: Backup wiederhergestellt") diff --git a/homeassistant/pyscript/batterieverlauf.py b/homeassistant/pyscript/batterieverlauf.py new file mode 100644 index 0000000..24d2be1 --- /dev/null +++ b/homeassistant/pyscript/batterieverlauf.py @@ -0,0 +1,62 @@ +"""Langfristige Aufzeichnung der 12V-Batteriespannung (Mein Audi -> Zustand +-> Batteriespannung antippen). Eine über die Jahre absinkende Spannung +deutet auf eine alternde Batterie hin - dafür braucht es einen Verlauf, +nicht nur den aktuellen Wert. + +Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}) reicht für diesen +Zweck aus und hält batteriespannung.jsonl auch über viele Jahre klein - auch +wenn der Sensor selbst künftig mehrfach pro Stunde meldet (aktive Fahrt: +Motor an/aus, Ladezustand des Alternators wechselt laufend). +batterie_verlauf_pruefen() läuft deshalb häufig (alle 5 Minuten) und trägt +jede Messung in den Tageseintrag ein, statt wie zuvor nur einmal täglich +einen Einzelwert zu speichern - profil.batterieverlauf_tageswert_aktualisieren() +erweitert dabei nur min/max samt dem genauen Zeitstempel der jeweiligen +Extremmessung, robust gegen Neustarts und schwankende Erreichbarkeit. + +Der Tagesminimalwert ist die für die Entladung aussagekräftige Größe: er +fällt typischerweise auf den Moment kurz nach dem Start, bevor der +Alternator die Ladespannung hochfährt - also am nächsten an die "echte" +Ruhespannung heran, ohne 2+ Stunden Standzeit abwarten zu müssen. Deshalb +verwendet auch die Ladezustands-/Gesundheitsauswertung im Frontend +(audi-dashboard-app.js, AGM_RUHE_MAX_V) den Tagesminimalwert als +repräsentativen Wert des Tages, nicht den Maximalwert. + +Anders als in der ersten Fassung wird hier NICHT mehr gefiltert, welche +Messwerte gespeichert werden - min UND max jedes Tages werden roh +aufgezeichnet (der Maximalwert ist z. B. informativ beim Antippen eines +Punkts im Diagramm sichtbar). Die AGM-Ruhespannungs-Grenze wird stattdessen +erst beim Anzeigen angewendet (siehe AGM_RUHE_MAX_V im Frontend): Tage, an +denen selbst der Minimalwert über der Grenze liegt (Fahrzeug nie im +Ruhezustand beobachtet, z. B. bei einer sehr langen Fahrt), tauchen dort +nicht in der Ladezustands-Statistik auf. + +Ohne eingetragenen einstellungen.BATTERIE_SENSOR (aktuell der Fall - die +genutzte Integration liefert dafür keinen Sensor, siehe dortiger Kommentar) +passiert hier bewusst nichts, statt Platzhalter-Nullwerte aufzuzeichnen. +""" + +import datetime + +import einstellungen +import frontend_veroeffentlichung +import profil + + +@time_trigger("startup") +@time_trigger("period(now, 5 minutes)") +def batterie_verlauf_pruefen(): + if not einstellungen.BATTERIE_SENSOR: + return + wert = frontend_veroeffentlichung.zustand_oder_none(einstellungen.BATTERIE_SENSOR) + if wert is None: + return + try: + spannung = float(wert) + except (TypeError, ValueError): + return + + heute = datetime.date.today().isoformat() + ts = datetime.datetime.now(datetime.timezone.utc).isoformat() + profil.batterieverlauf_tageswert_aktualisieren(heute, ts, spannung) + frontend_veroeffentlichung.batterieverlauf_veroeffentlichen() + log.info(f"audi_dashboard: Batteriespannung erfasst ({heute}: {spannung} V)") diff --git a/homeassistant/pyscript/belegverarbeitung.py b/homeassistant/pyscript/belegverarbeitung.py new file mode 100644 index 0000000..81b32dc --- /dev/null +++ b/homeassistant/pyscript/belegverarbeitung.py @@ -0,0 +1,269 @@ +"""Belegverarbeitung für Shell-Tankbelege (§7.7, §10 Punkt 7). + +Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff. + +shell_beleg_parser.py, im Lastenheft (§2) als mitgeliefert genannt, lag +beim Bau nicht im Projektordner vor (siehe Ersteinschätzung) und wurde +nachträglich unter audi_dashboard/shell_beleg_parser.py gegen einen echten +Shell-eReceipt neu geschrieben (siehe dessen Kopfkommentar). Aufruf über +eine Kommandozeilen-Schnittstelle: `python3 shell_beleg_parser.py +`, Ausgabe als JSON auf stdout mit den Feldern aus §6.4 +(receipt_key, ts, station_name, liters, fuel_total_eur, discount, ...). +Muss unter /config/audi_dashboard/shell_beleg_parser.py liegen (siehe +INSTALL.md) - eigener Prozess mit dem System-python3 des Containers, nicht +die pyscript-Sandbox, deshalb dort einmalig `pip install pypdf` nötig. + +ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem +ursprünglichen Bau nachträglich verlangt): +- Volltankung (§7.4) ist entfernt - weder Berechnung noch Anzeige. Damit + entfallen auch level_before_pct/level_after_pct, die nur dafür gebraucht + wurden. +- fuel_total_eur ist immer der reale, bereits rabattierte Betrag. + Die SmartDeal-Ersparnis steht separat in discount. price_per_l wird + grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie aus einem vom + Beleg übernommenen Wert übernommen - siehe Kopfkommentar in + audi-dashboard-app.js (tankFelder()) für dieselbe Regel im Frontend. + +Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. +""" + +import base64 +import datetime +import io +import json +import os +import subprocess + +import einstellungen +import frontend_veroeffentlichung +import profil + +PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py" # TODO: Datei ablegen + + +# --------------------------------------------------------------- Hilfsfunktionen + +def _pdf_speichern(inhalt_bytes, dateiname): + # io.open über task.executor, kein with, kein bloßes open() - siehe + # Kopfkommentar in profil.py für alle drei Gründe. + os.makedirs(profil.BELEGE_ORDNER, exist_ok=True) + pfad = f"{profil.BELEGE_ORDNER}/{dateiname}" + f = task.executor(io.open, pfad, "wb") + f.write(inhalt_bytes) + f.close() + return pfad + + +def _parser_aufrufen(pdf_pfad): + ergebnis = subprocess.run( + ["python3", PARSER_SKRIPT, pdf_pfad], + capture_output=True, text=True, timeout=30, + ) + if ergebnis.returncode != 0: + raise RuntimeError(f"shell_beleg_parser.py fehlgeschlagen: {ergebnis.stderr}") + return json.loads(ergebnis.stdout) + + +def _preis_je_liter(fuel_total_eur, liters): + if not liters: + return None + return round(fuel_total_eur / liters, 3) + + +# -------------------------------------------------------------------- Service + +@service +def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None): + """Nimmt einen per Base64 codierten PDF-Beleg entgegen (Upload aus der + Oberfläche über hass.callService). + + Mit tank_id: trägt die Belegdaten direkt in einen bereits bestehenden + Tankvorgang nach - egal ob der ursprünglich manuell oder automatisch + angelegt wurde. Der Kilometerstand/die gefahrene Distanz dieses + Tankvorgangs bleiben dabei unangetastet (die kommen weiterhin vom + Fahrzeug, nie vom Beleg, §7.7 Regel 2), nur die Beleg-Felder (Liter, + Kosten, Ersparnis, Station, Zeitstempel, ...) werden ergänzt + beziehungsweise überschrieben. Passt das Belegdatum nicht zum bereits + erfassten Tankvorgang (unterschiedlicher Kalendertag), wird nichts + übernommen - stattdessen ein Fehler veröffentlicht, damit in der + Oberfläche nicht versehentlich ein falscher Beleg einem bestehenden + Eintrag zugeordnet wird. + + Ohne tank_id: legt KEINEN eigenständigen Tankvorgang mehr an (frühere + Abweichung vom Lastenheft, auf Wunsch entfernt), sondern parst den Beleg + nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte + in den gerade offenen Entwurf (neuer oder bearbeiteter Tankvorgang) und + speichert erst auf einen expliziten Klick auf "Speichern". + + Aufruf als pyscript.audi_dashboard_beleg_hochladen.""" + if not pdf_base64: + log.warning("audi_dashboard: kein pdf_base64 übergeben") + return + + # task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind + # in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur + # echte externe Python-Funktionen (siehe Kopfkommentar in profil.py). + inhalt_bytes = base64.b64decode(pdf_base64) + dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" + pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname) + + try: + geparst = _parser_aufrufen(pdf_pfad) + except Exception as exc: + log.error(f"audi_dashboard: Beleg konnte nicht geparst werden: {exc}") + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}) + return + + receipt_key = geparst.get("receipt_key") + if not receipt_key: + log.error("audi_dashboard: Parser lieferte keinen receipt_key") + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}) + return + + # §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt + # keinen zweiten Datensatz + vorhandener_beleg = profil.tankvorgang_nach_receipt_key(receipt_key) + if vorhandener_beleg is not None and vorhandener_beleg.get("tank_id") != tank_id: + log.info(f"audi_dashboard: Beleg {receipt_key} bereits vorhanden, übersprungen") + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg bereits erfasst", "tank_id": tank_id}) + return + + liters = geparst.get("liters") + fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert + + beleg_felder = { + "receipt_key": receipt_key, + "receipt_no": geparst.get("receipt_no"), + "tse_beleg_nr": geparst.get("tse_beleg_nr"), + "ts": geparst.get("ts"), + "ts_payment": geparst.get("ts_payment"), + "ts_tse": geparst.get("ts_tse"), + "station_id": geparst.get("station_id"), + "station_name": geparst.get("station_name"), + "station_address": geparst.get("station_address"), + "article_no": geparst.get("article_no"), + "product_name": geparst.get("product_name"), + "fuel_type": geparst.get("fuel_type"), + # Liter, Kosten und Ersparnis kommen vom Beleg (§7.7 Regel 2). + # price_per_l wird nie vom Beleg übernommen, sondern immer berechnet. + "liters": liters, + "fuel_total_eur": fuel_total_eur, + "price_per_l": _preis_je_liter(fuel_total_eur, liters), + "discount": geparst.get("discount"), # SmartDeal-Ersparnis + "discount_per_l": geparst.get("discount_per_l"), + "list_price_per_l": geparst.get("list_price_per_l"), + "receipt_total_eur": geparst.get("receipt_total_eur"), + "net_eur": geparst.get("net_eur"), + "vat_eur": geparst.get("vat_eur"), + "receipt_file": pdf_pfad, + } + + if tank_id: + vorhanden = profil.tankvorgang_nach_id(tank_id) + if not vorhanden: + log.warning(f"audi_dashboard: Tankvorgang {tank_id} für Beleg-Nachtrag nicht gefunden") + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}) + return + vorhandenes_datum = (vorhanden.get("ts") or "")[:10] + belegdatum = (geparst.get("ts") or "")[:10] + if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum: + log.warning(f"audi_dashboard: Belegdatum {belegdatum} passt nicht zu Tankvorgang {tank_id} (Datum {vorhandenes_datum})") + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Falsches Belegdatum", "tank_id": tank_id}) + return + aenderungen = dict(beleg_felder) + aenderungen["status"] = "vollständig" + if profil.tankvorgang_aktualisieren(tank_id, aenderungen): + frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id}) + log.info(f"audi_dashboard: Beleg {receipt_key} in Tankvorgang {tank_id} nachgetragen") + else: + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}) + return + + # Kein tank_id: nur parsen und dem gerade offenen Entwurf in der + # Oberfläche zum Ausfüllen bereitstellen - legt selbst KEINEN + # Tankvorgang an (siehe Kopfkommentar oben). + frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None}) + log.info(f"audi_dashboard: Beleg {receipt_key} geparst, wartet auf Speichern in der Oberfläche") + + +@service +def audi_dashboard_tankvorgang_manuell(ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None): + """Erfassung eines Tankvorgangs aus dem Formular "Neuer Tankvorgang" - + Fallback aus §7.7 / §10 Punkt 7, unter „Tanken" vorgesehen (§5.5). + Dieselben Felder wie beim Bearbeiten eines bestehenden Vorgangs (siehe + audi_dashboard_tankvorgang_aktualisieren). receipt_key/receipt_file sind + optional: kommen sie mit (weil zuvor ein Beleg über + audi_dashboard_beleg_hochladen ohne tank_id in den Entwurf übernommen + wurde), zählt der Tankvorgang als per Beleg erfasst, sonst als manuell. + Aufruf als pyscript.audi_dashboard_tankvorgang_manuell.""" + if liter is None or kosten is None: + log.warning("audi_dashboard: liter und kosten sind Pflichtfelder") + return + + odometer_km = km if km is not None else frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR) + tankvorgang = { + "tank_id": profil.neue_id("f"), + "receipt_key": receipt_key, + "ts": ts or datetime.datetime.now(datetime.timezone.utc).isoformat(), + "liters": liter, + "fuel_total_eur": kosten, + "price_per_l": _preis_je_liter(kosten, liter), + "discount": ersparnis, + "station_name": station, + "odometer_km": odometer_km, + # Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie + # odometer_km selbst frei überschreibbar - deshalb nimmt ein manuell + # übergebener Wert immer Vorrang vor der Berechnung. + "distance_km": distanz if distanz is not None else profil.distanz_seit_letzter_tankung(odometer_km), + "fuel_type": kraftstoff, + "source": "beleg" if receipt_key else "manual", + "status": "vollständig", + "receipt_file": receipt_file, + "edited_fields": [], + } + profil.tankvorgang_anhaengen(tankvorgang) + frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() + log.info(f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} angelegt") + + +@service +def audi_dashboard_tankvorgang_aktualisieren(tank_id=None, ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None): + """Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob + ursprünglich per Beleg oder manuell angelegt). receipt_key/receipt_file + sind optional: kommen sie mit (ein im Bearbeiten-Formular hochgeladener + Beleg wurde in den Entwurf übernommen), wird der Tankvorgang zusätzlich + als per Beleg erfasst markiert. Aufruf als + pyscript.audi_dashboard_tankvorgang_aktualisieren.""" + if not tank_id: + log.warning("audi_dashboard: tankvorgang_aktualisieren ohne tank_id aufgerufen") + return + aenderungen = { + "ts": ts, "liters": liter, "fuel_total_eur": kosten, + "price_per_l": _preis_je_liter(kosten, liter), + "odometer_km": km, "discount": ersparnis, "station_name": station, + "distance_km": distanz, "fuel_type": kraftstoff, + } + if receipt_key: + aenderungen["receipt_key"] = receipt_key + aenderungen["receipt_file"] = receipt_file + aenderungen["source"] = "beleg" + if profil.tankvorgang_aktualisieren(tank_id, aenderungen): + frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() + log.info(f"audi_dashboard: Tankvorgang {tank_id} aktualisiert") + else: + log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden") + + +@service +def audi_dashboard_tankvorgang_loeschen(tank_id=None): + """Löscht einen Tankvorgang unwiderruflich (Löschfunktion in der + Oberfläche). Aufruf als pyscript.audi_dashboard_tankvorgang_loeschen.""" + if not tank_id: + log.warning("audi_dashboard: tankvorgang_loeschen ohne tank_id aufgerufen") + return + if profil.tankvorgang_loeschen(tank_id): + frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() + log.info(f"audi_dashboard: Tankvorgang {tank_id} gelöscht") + else: + log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden") diff --git a/homeassistant/pyscript/bilderverwaltung.py b/homeassistant/pyscript/bilderverwaltung.py new file mode 100644 index 0000000..c3712a8 --- /dev/null +++ b/homeassistant/pyscript/bilderverwaltung.py @@ -0,0 +1,71 @@ +"""Fahrzeugbilder direkt aus der Oberfläche hochladen/löschen (Einstellungen +-> Fahrzeugbilder, siehe BILDER_UPLOAD_SLOTS in audi-dashboard-app.js). + +Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine +Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals +außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder +absolute Pfade). Die Bytes werden unverändert unter dem erwarteten Namen +abgelegt, unabhängig vom tatsächlichen Bildformat des Originalfotos (z. B. +JPEG von einem iPhone unter einem ".webp"-Namen) - Browser stellen nach +den tatsächlichen Bilddaten dar, nicht nach der Dateiendung, das funktioniert +also trotzdem. Eine Größenbeschränkung gibt es bewusst nicht (Vorgabe: jede +Fotogröße muss hochladbar sein). +""" + +import base64 +import io +import os + +BILDER_ORDNER = "/config/www/bilder" + +ERLAUBTE_DATEINAMEN = { + "seitenansicht.webp", + "seitenansicht-winter.webp", + "front-schraeg.webp", + "frontansicht.webp", + "heckansicht.webp", + "cockpit.webp", + "scheinwerfer.webp", + "sitze.webp", + "rad-sommer.webp", + "rad-winter.webp", +} + + +def _pfad(dateiname): + if dateiname not in ERLAUBTE_DATEINAMEN: + return None + return f"{BILDER_ORDNER}/{dateiname}" + + +@service +def audi_dashboard_bild_hochladen(dateiname=None, daten_base64=None): + """Speichert ein aus der Oberfläche hochgeladenes Fahrzeugfoto unter + einem der festen erwarteten Namen. Aufruf als + pyscript.audi_dashboard_bild_hochladen.""" + pfad = _pfad(dateiname) + if not pfad or not daten_base64: + log.warning(f"audi_dashboard: Bild-Upload abgelehnt (dateiname={dateiname})") + return + os.makedirs(BILDER_ORDNER, exist_ok=True) + rohdaten = base64.b64decode(daten_base64) + tmp = pfad + ".tmp" + f = task.executor(io.open, tmp, "wb") + f.write(rohdaten) + f.close() + os.replace(tmp, pfad) + log.info(f"audi_dashboard: Fahrzeugbild gespeichert ({dateiname}, {len(rohdaten)} Bytes)") + + +@service +def audi_dashboard_bild_loeschen(dateiname=None): + """Entfernt ein zuvor hochgeladenes Fahrzeugfoto wieder - die Ansicht + fällt danach auf den Platzhalter zurück. Aufruf als + pyscript.audi_dashboard_bild_loeschen.""" + pfad = _pfad(dateiname) + if not pfad: + log.warning(f"audi_dashboard: Bild-Löschen abgelehnt (dateiname={dateiname})") + return + if os.path.exists(pfad): + os.remove(pfad) + log.info(f"audi_dashboard: Fahrzeugbild gelöscht ({dateiname})") diff --git a/homeassistant/pyscript/fahrtabschluss.py b/homeassistant/pyscript/fahrtabschluss.py new file mode 100644 index 0000000..c215168 --- /dev/null +++ b/homeassistant/pyscript/fahrtabschluss.py @@ -0,0 +1,27 @@ +"""Trigger-Registrierung für den zweistufigen Fahrtabschluss (§7.2). + +Die eigentliche Logik liegt in pyscript/modules/fahrtabschluss_logik.py +(importierbares Modul, auch von fahrterkennung.py genutzt). Diese Datei +sorgt nur dafür, dass ein Screening auch dann läuft, wenn der Kilometerstand- +Sensor sich ändert - unabhängig vom Fahrtende-Ereignis selbst, weil der Wert +laut §10 Punkt 4 auch erst mit der nächsten Fahrt eintreffen kann. + +Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. +""" + +import einstellungen +import fahrtabschluss_logik + + +@state_trigger(f"{einstellungen.KM_SENSOR}") +def kilometerstand_geaendert(value=None, old_value=None): + if value is None or old_value is None: + return + fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) + + +@service +def audi_dashboard_screening_jetzt(): + """Manueller Anstoß des Screenings, z. B. aus der Oberfläche heraus + (pyscript.audi_dashboard_screening_jetzt).""" + fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) diff --git a/homeassistant/pyscript/fahrterkennung.py b/homeassistant/pyscript/fahrterkennung.py new file mode 100644 index 0000000..78f7928 --- /dev/null +++ b/homeassistant/pyscript/fahrterkennung.py @@ -0,0 +1,166 @@ +"""Fahrterkennung über die WLAN-Verbindung zum Fahrzeug (§7.1). + +Start: der WLAN-Sensor der Companion App meldet den in den Einstellungen +hinterlegten Fahrzeug-WLAN-Namen (Fahrzeugprofil, Feld fahrzeug.wlan_name). +Ende: die Verbindung bricht ab und kommt binnen der Pausenzeit nicht zurück. + +Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung des +WLAN-Sensors killt eine noch laufende, wartende Bestätigung des vorherigen +Ereignisses. Kommt die Verbindung während der Wartezeit zurück, wird dadurch +die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die Fahrt +beendet wird - das entspricht der Pausenregel aus §7.1. + +Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor +der Installation einmalig anpassen (siehe INSTALL.md). + +EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur +im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder +während der Wartezeit verliert diesen Zwischenstand - die Fahrt würde dann +nicht aufgezeichnet. Das ist ein bekannter Punkt für eine spätere Härtung +(z. B. Zwischenstand in einer input_datetime-Helper-Entität sichern), hier +bewusst nicht mitgebaut, um die Kernlogik nicht zu verkomplizieren, bevor sie +sich im echten Betrieb bewährt hat. +""" + +import datetime + +import einstellungen +import fahrtabschluss_logik +import frontend_veroeffentlichung +import profil + +_fahrt_start_ts = None + + +def _profil_wlan_name(): + p = profil.profil_lesen() + return p.get("fahrzeug", {}).get("wlan_name") + + +def _pausenzeit_sekunden(): + p = profil.profil_lesen() + return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60 + + +@time_trigger("startup") +def beim_start(): + profil.ordner_sicherstellen() + + +@state_trigger(f"{einstellungen.WLAN_SENSOR}") +def wlan_verbindung_geaendert(value=None, old_value=None): + # Killt eine noch laufende Ende-Bestätigung aus einer vorherigen + # Änderung - das ist der Mechanismus hinter der Pausenregel. + task.unique("audi_dashboard_fahrt_ende_pruefung") + + ziel = _profil_wlan_name() + if not ziel: + log.warning("audi_dashboard: kein fahrzeug.wlan_name im Fahrzeugprofil hinterlegt") + return + + global _fahrt_start_ts + verbunden_jetzt = (value == ziel) + verbunden_vorher = (old_value == ziel) + + if verbunden_jetzt and not verbunden_vorher: + if _fahrt_start_ts is None: + _fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc) + log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}") + return + + if verbunden_vorher and not verbunden_jetzt and _fahrt_start_ts is not None: + start_ts = _fahrt_start_ts + abbruch_ts = datetime.datetime.now(datetime.timezone.utc) + wartezeit_s = _pausenzeit_sekunden() + + # task.unique() oben markiert diese Ausführung als die aktuell + # gültige. Kommt die Verbindung vor Ablauf der Wartezeit zurück, + # ruft der nächste Trigger erneut task.unique() auf und killt genau + # diesen task.sleep() - der Code danach läuft dann nie. + task.sleep(wartezeit_s) + + _fahrt_beenden(start_ts, abbruch_ts) + + +def _fahrt_beenden(start_ts, ende_ts): + global _fahrt_start_ts + dauer_s = int((ende_ts - start_ts).total_seconds()) + + fahrt = { + "trip_id": profil.neue_id("t"), + "ts_start": start_ts.isoformat(), + "ts_end": ende_ts.isoformat(), + "duration_s": dauer_s, + "distance_km": None, + "km_quelle": None, + "odo_start": None, + "odo_end": None, + "avg_speed_kmh": None, + "start_lat": None, + "start_lon": None, + "end_lat": None, + "end_lon": None, + "start_address": None, + "end_address": None, + "art": "privat", + "route": None, + "pausen": [], + "source": "ha", + "status": "offen", + "edited_fields": [], + } + profil.fahrt_anhaengen(fahrt) + frontend_veroeffentlichung.fahrten_veroeffentlichen() + log.info(f"audi_dashboard: Fahrt {fahrt['trip_id']} beendet, Status 'offen', Screening angestoßen") + + _fahrt_start_ts = None + fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) + + +@service +def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat"): + """Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der + automatischen WLAN-Erkennung. Aufruf als pyscript.audi_dashboard_fahrt_manuell_anlegen.""" + start_ts = datetime.datetime.fromisoformat(ts_start) + ende_ts = datetime.datetime.fromisoformat(ts_end) + dauer_s = int((ende_ts - start_ts).total_seconds()) + fahrt = { + "trip_id": profil.neue_id("t"), + "ts_start": start_ts.isoformat(), + "ts_end": ende_ts.isoformat(), + "duration_s": dauer_s, + "distance_km": None, + "km_quelle": None, + "odo_start": None, + "odo_end": None, + "avg_speed_kmh": None, + "start_lat": None, + "start_lon": None, + "end_lat": None, + "end_lon": None, + "start_address": None, + "end_address": None, + "art": art, + "route": None, + "pausen": [], + "source": "manual", + "status": "offen", + "edited_fields": [], + } + profil.fahrt_anhaengen(fahrt) + frontend_veroeffentlichung.fahrten_veroeffentlichen() + fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) + + +@service +def audi_dashboard_fahrt_loeschen(trip_id=None): + """Löscht eine Fahrt unwiderruflich (Löschfunktion in der Oberfläche, + §Löschfunktion). Aufruf als pyscript.audi_dashboard_fahrt_loeschen.""" + if not trip_id: + log.warning("audi_dashboard: fahrt_loeschen ohne trip_id aufgerufen") + return + if profil.fahrt_loeschen(trip_id): + frontend_veroeffentlichung.fahrten_veroeffentlichen() + log.info(f"audi_dashboard: Fahrt {trip_id} gelöscht") + else: + log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden") diff --git a/homeassistant/pyscript/frontend_api.py b/homeassistant/pyscript/frontend_api.py new file mode 100644 index 0000000..f63a057 --- /dev/null +++ b/homeassistant/pyscript/frontend_api.py @@ -0,0 +1,75 @@ +"""Verbindung zwischen Frontend und Backend. + +Lesen: das Frontend liest hass.states["pyscript.audi_dashboard_profil"] usw. +direkt - kein Service-Aufruf mit Rückgabewert. Die Frontend-Dokumentation von +Home Assistant zu response-fähigen Services beschreibt das Verhalten aus +Sicht des Browsers nicht abschließend (Community-Diskussionen deuten auf +Einschränkungen hin); Zustände lesen ist dagegen ein seit Jahren stabiler, +einfacher Weg. Veröffentlicht wird periodisch UND sofort nach jeder +Schreibaktion (siehe frontend_veroeffentlichung.py, dort auch der Hinweis zur +Größengrenze von Zustands-Attributen). + +Schreiben: einzig audi_dashboard_profil_schreiben() als generischer Weg für +das gesamte Profil - das Frontend hält Profil, Fahrzeugdaten und +Einstellungen ohnehin als einen zusammenhängenden Stand im Speicher (wie +schon im Prototyp) und schickt ihn nach jeder Änderung komplett zurück, +statt für jedes einzelne Feld einen eigenen Service zu brauchen. +""" + +import json + +import einstellungen +import frontend_veroeffentlichung +import profil + + +@time_trigger("startup") +def beim_start(): + profil.ordner_sicherstellen() + frontend_veroeffentlichung.alles_veroeffentlichen() + + +@time_trigger("period(now, 20 seconds)") +def periodisch_aktualisieren(): + frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen() + + +@time_trigger("period(now, 60 seconds)") +def periodisch_daten_aktualisieren(): + # seltener als der Fahrzeugstatus - Profil/Fahrten/Tankvorgänge ändern + # sich normalerweise nur durch Aktionen, die ohnehin sofort selbst + # veröffentlichen (siehe unten); das hier ist nur das Sicherheitsnetz + # für Änderungen direkt im Dateisystem (§6.2, dritter Bearbeitungsweg). + frontend_veroeffentlichung.profil_veroeffentlichen() + frontend_veroeffentlichung.fahrten_veroeffentlichen() + frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() + + +@service +def audi_dashboard_jetzt_aktualisieren(): + """Gegenstück zum "Ziehen zum Aktualisieren" auf der Übersicht: stößt den + von der Integration bereitgestellten Refresh-Knopf an (siehe + einstellungen.REFRESH_BUTTON) statt auf den nächsten periodischen Takt zu + warten, und veröffentlicht den Status danach sofort neu. Ohne + eingetragenen Knopf (leerer Platzhalter) nur die direkte Neu- + veröffentlichung - besser als gar nichts, auch wenn dann kein echter neuer + Abruf beim Fahrzeug passiert. Aufruf als + pyscript.audi_dashboard_jetzt_aktualisieren.""" + if einstellungen.REFRESH_BUTTON: + button.press(entity_id=einstellungen.REFRESH_BUTTON) + task.sleep(3) + frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen() + log.info("audi_dashboard: manuelle Aktualisierung (Ziehen zum Aktualisieren) ausgelöst") + + +@service +def audi_dashboard_profil_schreiben(profil_json=None): + """Überschreibt das komplette Fahrzeugprofil. Aufruf als + pyscript.audi_dashboard_profil_schreiben.""" + if not profil_json: + log.warning("audi_dashboard: profil_schreiben ohne profil_json aufgerufen") + return + p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json + profil.profil_schreiben(p) + frontend_veroeffentlichung.profil_veroeffentlichen() + log.info("audi_dashboard: Profil aktualisiert") diff --git a/homeassistant/pyscript/modules/einstellungen.py b/homeassistant/pyscript/modules/einstellungen.py new file mode 100644 index 0000000..82cdda8 --- /dev/null +++ b/homeassistant/pyscript/modules/einstellungen.py @@ -0,0 +1,109 @@ +"""Technische Konfiguration — die einzige Stelle, die vor der Installation +angepasst werden muss (siehe INSTALL.md Schritt 4). + +Alles andere (WLAN-Name des Fahrzeugs, Pausenzeit, Reifendaten, ...) ist Teil +des Fahrzeugprofils (data/fahrzeugprofil.json) und über die Oberfläche +änderbar. Diese Entity-IDs sind es bewusst nicht: @state_trigger und die +Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein +Profil gelesen werden kann. + +In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen. + +Die drei ersten (WLAN, Kilometerstand, Tankfüllstand) werden vom Backend +zwingend gebraucht (§7.1, §7.2, §7.4, §7.5). Alles danach speist nur die +Übersicht im Frontend (§5.1, §5.2, §9) — bleibt ein Wert leer ("") oder passt +eine Entity-ID nicht zur tatsächlichen Integration, liefert +zustand_oder_none() in frontend_veroeffentlichung.py für das jeweilige Feld +None statt abzustürzen; die Oberfläche zeigt dann „unbekannt" statt eines +falschen Werts. + +Alle Entity-IDs unten sind an der echten Integration TommiG1/HA_VAG-EU-Data-Act +(Version 0.6.35, per HACS installiert) real geprüft, nicht nur vermutet: das +Gerät heißt dort "Audi RS 4 Avant" (mit Leerzeichen vor der "4") - deshalb +"rs_4" mit Unterstrich im Slug, nicht "rs4". Eine ältere Integrationsversion +hatte hier noch deutsche Feldnamen (z. B. "..._tur_vorne_links"); die aktuelle +Version benennt englisch (z. B. "..._front_left_door"). +""" + +# Companion-App-Sensor für das aktuell verbundene WLAN (iOS-App: +# Einstellungen -> Companion App -> Sensoren -> "Verbundenes WLAN") +WLAN_SENSOR = "sensor.iphone_wifi_connection" + +# Kilometerstand aus der HACS-Integration TommiG1/HA_VAG-EU-Data-Act +KM_SENSOR = "sensor.audi_rs_4_avant_mileage" + +# Tankfüllstand aus derselben Integration (Prozent) +TANK_SENSOR = "sensor.audi_rs_4_avant_fuel_level" + +# Reichweite (§5.1 Übersicht) +RANGE_SENSOR = "sensor.audi_rs_4_avant_range_primary" + +# 12V-Batteriespannung (Mein Audi -> Zustand). Die aktuell genutzte +# HACS-Integration (TommiG1/HA_VAG-EU-Data-Act 0.6.35) liefert dafür KEINEN +# Sensor (real geprüft: kein "battery"/"voltage"-Entity unter diesem Gerät) - +# deshalb hier bewusst leer gelassen. Liefert später eine andere Quelle (z. B. +# ein OBD-Adapter) eine Spannung, einfach die Entity-ID eintragen; bis dahin +# zeigt die Oberfläche "unbekannt" statt eines falschen Werts (siehe +# zustand_oder_none() in frontend_veroeffentlichung.py). +BATTERIE_SENSOR = "" + +# Der von der Integration selbst bereitgestellte Knopf für eine sofortige +# Neuabfrage beim Fahrzeug (Entity-Domäne "button", real geprüft) - genau der +# Weg, den "Ziehen zum Aktualisieren" im Frontend auslöst (siehe +# audi_dashboard_jetzt_aktualisieren() in frontend_api.py), statt eines +# generischen homeassistant.update_entity auf einzelne Sensoren. +REFRESH_BUTTON = "button.audi_rs_4_avant_refresh_now" + +# Türen (§4.1) - die Integration liefert vier Einzelsensoren statt eines +# Summensensors. binary_sensor, device_class "door": "off" = zu (real +# geprüft). +TUER_SENSOREN = [ + "binary_sensor.audi_rs_4_avant_front_left_door", + "binary_sensor.audi_rs_4_avant_front_right_door", + "binary_sensor.audi_rs_4_avant_rear_left_door", + "binary_sensor.audi_rs_4_avant_rear_right_door", +] + +# Fenster (§4.1) - device_class "window": "off" = zu (real geprüft) +FENSTER_SENSOREN = [ + "binary_sensor.audi_rs_4_avant_front_left_window", + "binary_sensor.audi_rs_4_avant_front_right_window", + "binary_sensor.audi_rs_4_avant_rear_left_window", + "binary_sensor.audi_rs_4_avant_rear_right_window", +] + +# Verriegelung - kein lock.*-Entity vorhanden, sondern vier +# Türschloss-Einzelsensoren. binary_sensor mit device_class "lock" (real +# geprüft): "on" = entriegelt, "off" = verriegelt (Home-Assistant-Konvention +# für diese device_class). +TUERSCHLOSS_SENSOREN = [ + "binary_sensor.audi_rs_4_avant_front_left_door_lock", + "binary_sensor.audi_rs_4_avant_front_right_door_lock", + "binary_sensor.audi_rs_4_avant_rear_left_door_lock", + "binary_sensor.audi_rs_4_avant_rear_right_door_lock", +] + +# Heckklappe und Motorhaube - von der Integration separat von den vier Türen +# geführt, für "Sicher abgestellt" (§9) aber ebenso relevant wie diese +HECKKLAPPE_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate" +HECKKLAPPENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_tailgate_lock" +HAUBE_SENSOR = "binary_sensor.audi_rs_4_avant_hood" +HAUBENSCHLOSS_SENSOR = "binary_sensor.audi_rs_4_avant_hood_lock" + +# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene, +# aus dem Servicebuch berechnete Prognose - siehe fahrzeugMeldung() im +# Frontend). Je ein Datum (ISO mit Zeitzone) und eine Streckenangabe in km; +# die Streckensensoren liefern negative Werte (Restkilometer als Countdown), +# im Frontend wird davon nur der Betrag angezeigt. +NAECHSTER_OELWECHSEL_SENSOR = "sensor.audi_rs_4_avant_oil_change_due" +OELWECHSEL_STRECKE_SENSOR = "sensor.audi_rs_4_avant_oil_change_distance" +NAECHSTE_INSPEKTION_SENSOR = "sensor.audi_rs_4_avant_inspection_due" +INSPEKTION_STRECKE_SENSOR = "sensor.audi_rs_4_avant_inspection_distance" + +# Update-Funktion (Einstellungen -> "Update suchen", siehe +# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird - +# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits +# verwendet. Leer lassen, solange es keins gibt - "Update suchen" meldet dann +# nur "keine Update-Quelle eingerichtet", ohne etwas zu tun. +UPDATE_REPO_URL = "" +UPDATE_BRANCH = "main" diff --git a/homeassistant/pyscript/modules/fahrtabschluss_logik.py b/homeassistant/pyscript/modules/fahrtabschluss_logik.py new file mode 100644 index 0000000..e3fcc04 --- /dev/null +++ b/homeassistant/pyscript/modules/fahrtabschluss_logik.py @@ -0,0 +1,187 @@ +"""Screening-Logik für den zweistufigen Fahrtabschluss (Bauauftrag §7.2). + +Importierbar aus anderen pyscript-Dateien mit `import fahrtabschluss_logik`. + +Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern +teils erst mit Beginn oder während der nächsten Fahrt (§10 Punkt 4). Statt auf +einen festen Zeitpunkt zu warten, wird deshalb die Recorder-Historie des +Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am +nächsten am Verbindungsabbruch liegt. + +Zugriff auf die Historie läuft über die HA-REST-API (/api/history/period), +nicht über einen internen pyscript-Mechanismus - dafür gibt es in der +pyscript-Referenz keinen dokumentierten Weg. Das erfordert ein Long-Lived +Access Token, siehe README. + +WICHTIGER VORBEHALT: Der GPS-Fallback aus §7.2 (km_quelle="gps") ist hier noch +NICHT umgesetzt, da die Erfassung von Start-/Endposition (device_tracker, +Adressauflösung) ein eigenes Arbeitspaket ist. Fahrten ohne passenden +Kilometerstand bleiben aktuell dauerhaft "offen" statt auf GPS-Distanz +auszuweichen. Siehe die Restarbeiten im Abschlussbericht. +""" + +import datetime +import io +import json +import urllib.parse +import urllib.request + +import frontend_veroeffentlichung +import profil + +BASIS = "/config/audi_dashboard" +TOKEN_PFAD = f"{BASIS}/ha_token.txt" +HA_BASIS_URL = "http://localhost:8123" + +# Wie weit vor/zurück in der Historie gesucht wird, ausgehend vom +# Verbindungsabbruch. 48h deckt auch länger stehende Fahrzeuge ab, ohne bei +# jedem Screening die komplette Historie zu laden. +SUCHFENSTER_STUNDEN = 48 + + +# ---------------------------------------------------------- HA-REST-Zugriff + +def _token_lesen(): + # io.open über task.executor statt bloßem open() - siehe Kopfkommentar + # in profil.py. + f = task.executor(io.open, TOKEN_PFAD, "r") + inhalt = f.read() + f.close() + return inhalt.strip() + + +def historie_abfragen(entity_id, start_iso, end_iso): + """urllib.request.urlopen läuft direkt, nicht über task.executor - anders + als ein bloßes open() ist das ein modulqualifizierter Aufruf einer echten + externen Funktion, der an der Testinstanz ohne Fehler durchlief. Blockiert + kurz, läuft nur beim Fahrtabschluss-Screening, nicht bei jedem Tick.""" + token = _token_lesen() + start_enc = urllib.parse.quote(start_iso) + end_enc = urllib.parse.quote(end_iso) + url = ( + f"{HA_BASIS_URL}/api/history/period/{start_enc}" + f"?filter_entity_id={entity_id}&end_time={end_enc}" + ) + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + resp = urllib.request.urlopen(req, timeout=10) + rohtext = resp.read().decode("utf-8") + resp.close() + daten = json.loads(rohtext) + return daten[0] if daten else [] + + +# ------------------------------------------------------------- Kernlogik + +def naechster_wert(zielzeit, punkte): + """Wert aus der Historie, dessen Zeitstempel am nächsten an zielzeit + liegt. Gibt (wert, zeitdifferenz_s) zurück, oder (None, None). Öffentlich, + weil auch belegverarbeitung.py sie für den Tankfüllstand nutzt.""" + beste_wert = None + beste_diff = None + for p in punkte: + try: + wert = float(p["state"]) + except (ValueError, TypeError, KeyError): + continue + try: + zeit = datetime.datetime.fromisoformat( + p["last_changed"].replace("Z", "+00:00") + ) + except (ValueError, KeyError): + continue + diff = abs((zeit - zielzeit).total_seconds()) + if beste_diff is None or diff < beste_diff: + beste_wert = wert + beste_diff = diff + return beste_wert, beste_diff + + +def _zeitpunkt_screenen(km_sensor, zielzeit): + start = (zielzeit - datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat() + ende = (zielzeit + datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat() + try: + punkte = historie_abfragen(km_sensor, start, ende) + except Exception as exc: + # Fehlt z. B. der Token (INSTALL.md Schritt 5) oder ist die API kurz + # nicht erreichbar, bleibt die Fahrt einfach offen statt den ganzen + # Screening-Lauf für alle anderen offenen Fahrten abzubrechen. + log.warning(f"audi_dashboard: Kilometerstand-Screening fehlgeschlagen: {exc}") + return None + wert, _ = naechster_wert(zielzeit, punkte) + return wert + + +def screening_durchfuehren(km_sensor): + """Versucht, offene Fahrten mit Start-/Endkilometer zu vervollständigen. + Wird sowohl nach Ablauf der Pausenzeit (§7.1) als auch bei jeder Änderung + des Kilometerstand-Sensors aufgerufen.""" + fahrten = profil.fahrten_lesen() + offene = [f for f in fahrten if f.get("status") == "offen"] + if not offene: + return + + offene.sort(key=lambda f: f.get("ts_start", "")) + + for fahrt in offene: + geaendert = False + + if fahrt.get("odo_end") is None: + ts_end = datetime.datetime.fromisoformat(fahrt["ts_end"]) + wert = _zeitpunkt_screenen(km_sensor, ts_end) + if wert is not None: + fahrt["odo_end"] = wert + geaendert = True + + if fahrt.get("odo_start") is None: + ts_start = datetime.datetime.fromisoformat(fahrt["ts_start"]) + wert = _zeitpunkt_screenen(km_sensor, ts_start) + if wert is not None: + fahrt["odo_start"] = wert + geaendert = True + + if geaendert: + aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")} + if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None: + aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1) + aenderungen["km_quelle"] = "odometer" + aenderungen["status"] = "vollständig" + profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) + + _lueckenlos_verketten() + frontend_veroeffentlichung.fahrten_veroeffentlichen() + + +def _lueckenlos_verketten(): + """Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren + wurde (§7.2). Zwischen zwei in der Liste direkt aufeinanderfolgenden + Fahrten steht das Fahrzeug nur - der Kilometerstand ändert sich dabei + nicht, unabhängig davon, wie lange die Standzeit dauert. Ein bestätigter + Wert wird deshalb an den direkten Nachbarn weitergereicht, auch wenn + dessen eigenes Screening noch keinen Treffer hatte. + + Annahme: die Fahrten-Liste enthält keine Lücken durch verpasste + Verbindungsereignisse (z. B. durch einen HA-Neustart mitten in einer + Fahrt). Das ist der Normalfall, aber keine Garantie - siehe + Abschlussbericht.""" + fahrten = sorted(profil.fahrten_lesen(), key=lambda f: f.get("ts_start", "")) + geaendert_ids = [] + + for i in range(len(fahrten) - 1): + aktuelle, naechste = fahrten[i], fahrten[i + 1] + + if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None: + naechste["odo_start"] = aktuelle["odo_end"] + geaendert_ids.append(naechste["trip_id"]) + elif naechste.get("odo_start") is not None and aktuelle.get("odo_end") is None: + aktuelle["odo_end"] = naechste["odo_start"] + geaendert_ids.append(aktuelle["trip_id"]) + + for fahrt in fahrten: + if fahrt["trip_id"] not in geaendert_ids: + continue + aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")} + if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None: + aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1) + aenderungen["km_quelle"] = "odometer" + aenderungen["status"] = "vollständig" + profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py new file mode 100644 index 0000000..521d9d3 --- /dev/null +++ b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py @@ -0,0 +1,152 @@ +"""Veröffentlicht Profil, Fahrten, Tankvorgänge und den Live-Fahrzeugstatus +als pyscript-Zustandsentitäten, damit das Frontend sie über das ganz normale +hass.states lesen kann - kein Service-Aufruf mit Rückgabewert nötig, dessen +genaues Verhalten in der Home-Assistant-Weboberfläche nicht durchgängig +dokumentiert ist (siehe Kopfkommentar in frontend_api.py). + +Importierbar mit `import frontend_veroeffentlichung`, aufgerufen sowohl +periodisch (frontend_api.py) als auch direkt nach Schreibaktionen aus +fahrterkennung.py, belegverarbeitung.py und reifenzaehler.py, damit sich die +Oberfläche unmittelbar aktualisiert statt bis zum nächsten Intervall zu +warten. + +BEKANNTE GRENZE: Zustands-Attribute in Home Assistant sind nicht für beliebig +große Datenmengen gedacht (~16 KB Richtwert für die Aufzeichnung in der +Recorder-Datenbank). Für das Profil und den aktuellen Stand ist das +unkritisch; die Fahrten- und Tankvorgänge-Archive könnten das nach einigen +Jahren überschreiten. Wird das relevant, müssten fahrten_veroeffentlichen()/ +tankvorgaenge_veroeffentlichen() auf einen Zeitraum eingeschränkt werden +(z. B. nur das laufende Jahr), statt hier vorschnell zu vereinfachen. +""" + +import einstellungen +import profil + + +def profil_veroeffentlichen(): + state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": profil.profil_lesen()}) + + +def fahrten_veroeffentlichen(): + state.set("pyscript.audi_dashboard_fahrten", "aktuell", new_attributes={"daten": profil.fahrten_lesen()}) + + +def tankvorgaenge_veroeffentlichen(): + state.set("pyscript.audi_dashboard_tankvorgaenge", "aktuell", new_attributes={"daten": profil.tankvorgaenge_lesen()}) + + +def batterieverlauf_veroeffentlichen(): + state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()}) + + +def beleg_ergebnis_veroeffentlichen(daten): + """Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines + Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach + demselben Zustands-statt-Rückgabewert-Muster wie die übrigen + veroeffentlichen()-Funktionen hier (siehe Kopfkommentar).""" + state.set("pyscript.audi_dashboard_beleg_ergebnis", "aktuell", new_attributes={"daten": daten}) + + +def zustand_oder_none(entity_id): + """Sicherer Zustandszugriff: pyscripts state.get() wirft NameError, wenn + die Entity (noch) nicht existiert - real an einer Testinstanz beobachtet, + nicht nur vermutet. Genau das ist hier der Normalfall, solange manche der + in einstellungen.py hinterlegten Entity-IDs noch Platzhalter sind, und + bleibt auch danach relevant: fällt eine Datenquelle aus, soll die + Oberfläche das zeigen (§9), nicht an einem Fehler hängen bleiben.""" + if not entity_id: + return None + try: + wert = state.get(entity_id) + except NameError: + return None + if wert in (None, "unknown", "unavailable"): + return None + return wert + + +def _zu_zahl(wert): + try: + return float(wert) + except (TypeError, ValueError): + return None + + +def _abs_zahl(wert): + zahl = _zu_zahl(wert) + return None if zahl is None else abs(zahl) + + +POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"] + + +def _sicherheitscheck(): + """Liste der einzeln geprüften Punkte hinter "Sicher abgestellt" (§9), + fürs Frontend (Klick auf den Status öffnet diese Liste mit einem grünen/ + roten/grauen Punkt je Zeile). "ok" ist None, wenn der Sensor fehlt oder + nicht verfügbar ist - genau daraus leitet sich auch die zusammengefasste + gesichert-Kennzahl unten ab, damit beide nie auseinanderlaufen können.""" + eintraege = [] + for pos, sensor in zip(POSITIONEN, einstellungen.TUER_SENSOREN): + w = zustand_oder_none(sensor) + eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"}) + for pos, sensor in zip(POSITIONEN, einstellungen.FENSTER_SENSOREN): + w = zustand_oder_none(sensor) + eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"}) + for pos, sensor in zip(POSITIONEN, einstellungen.TUERSCHLOSS_SENSOREN): + w = zustand_oder_none(sensor) + eintraege.append({"label": f"Türschloss {pos}", "ok": None if w is None else w == "off"}) + for label, sensor in [ + ("Heckklappe", einstellungen.HECKKLAPPE_SENSOR), + ("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR), + ("Motorhaube", einstellungen.HAUBE_SENSOR), + ("Haubenschloss", einstellungen.HAUBENSCHLOSS_SENSOR), + ]: + w = zustand_oder_none(sensor) + eintraege.append({"label": label, "ok": None if w is None else w == "off"}) + return eintraege + + +def fahrzeugstatus_veroeffentlichen(): + """Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte + Frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss. + """ + km = zustand_oder_none(einstellungen.KM_SENSOR) + tankprozent = zustand_oder_none(einstellungen.TANK_SENSOR) + reichweite = zustand_oder_none(einstellungen.RANGE_SENSOR) + batteriespannung = zustand_oder_none(einstellungen.BATTERIE_SENSOR) + + # "Sicher abgestellt" (§9) - erst wenn WIRKLICH jeder einzeln geprüfte + # Punkt zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch + # nur eine Meldung, ist der Status unbekannt statt geraten. + sicherheitscheck = _sicherheitscheck() + if any([e["ok"] is None for e in sicherheitscheck]): + gesichert = None + else: + gesichert = all([e["ok"] for e in sicherheitscheck]) + + daten = { + "km": _zu_zahl(km), + "tankprozent": _zu_zahl(tankprozent), + "reichweite_km": _zu_zahl(reichweite), + "batteriespannung": _zu_zahl(batteriespannung), + "gesichert": gesichert, + "sicherheitscheck": sicherheitscheck, + # Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die + # App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im + # Frontend) - die Streckensensoren liefern negative Restkilometer- + # Werte, hier deshalb der Betrag. + "oelwechsel_faellig_ts": zustand_oder_none(einstellungen.NAECHSTER_OELWECHSEL_SENSOR), + "oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.OELWECHSEL_STRECKE_SENSOR)), + "inspektion_faellig_ts": zustand_oder_none(einstellungen.NAECHSTE_INSPEKTION_SENSOR), + "inspektion_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.INSPEKTION_STRECKE_SENSOR)), + } + state.set("pyscript.audi_dashboard_fahrzeugstatus", "aktuell", new_attributes={"daten": daten}) + + +def alles_veroeffentlichen(): + profil_veroeffentlichen() + fahrten_veroeffentlichen() + tankvorgaenge_veroeffentlichen() + fahrzeugstatus_veroeffentlichen() + batterieverlauf_veroeffentlichen() diff --git a/homeassistant/pyscript/modules/profil.py b/homeassistant/pyscript/modules/profil.py new file mode 100644 index 0000000..62d7fe7 --- /dev/null +++ b/homeassistant/pyscript/modules/profil.py @@ -0,0 +1,261 @@ +"""Datenzugriff für Fahrzeugprofil, Fahrten und Tankvorgänge (Bauauftrag §6). + +Importierbar aus anderen pyscript-Dateien mit `import profil`. + +Drei getrennte Bestände, wie in §6.1 festgelegt: + - Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische + - Fahrten: JSON Lines, eine Zeile je Fahrt + - Tankvorgänge: JSON Lines + +Drei Eigenheiten von pyscript, alle an einer echten Testinstanz beobachtet, +nicht nur aus der Dokumentation übernommen: + +1. Das eingebaute `open()` existiert in pyscript nicht (NameError) - aus + Sicherheitsgründen bewusst nicht freigegeben. Der funktionierende Weg ist + `task.executor(io.open, pfad, modus)`: `io.open` ist eine echte externe + Funktion aus der Standardbibliothek, kein im pyscript-Ordner selbst + definierter Code. +2. task.executor() akzeptiert generell nur solche echten externen Funktionen + - eigene, im pyscript-Ordner definierte Funktionen weist es mit "pyscript + functions can't be called from task.executor" zurück. Deshalb hier nur + `io.open` selbst über den Executor, Lesen/Schreiben/Schließen auf dem + zurückgegebenen Datei-Objekt direkt (das ist kein bare-name-Aufruf mehr, + sondern ein Methodenaufruf auf einem bereits vorhandenen Objekt). +3. Variablen, die innerhalb eines `with ... as f:`-Blocks zugewiesen werden, + waren danach außerhalb nicht mehr auffindbar (NameError), deshalb kein + `with` - offen/lesen/schließen nacheinander. +""" + +import io +import json +import os +import uuid + +BASIS = "/config/audi_dashboard" +PROFIL_PFAD = f"{BASIS}/fahrzeugprofil.json" +FAHRTEN_PFAD = f"{BASIS}/fahrten.jsonl" +TANKVORGAENGE_PFAD = f"{BASIS}/tankvorgaenge.jsonl" +BATTERIEVERLAUF_PFAD = f"{BASIS}/batteriespannung.jsonl" +BELEGE_ORDNER = f"{BASIS}/belege" + + +# ---------------------------------------------------------------- Ordner --- + +def ordner_sicherstellen(): + os.makedirs(BASIS, exist_ok=True) + os.makedirs(BELEGE_ORDNER, exist_ok=True) + + +# ----------------------------------------------------------- Fahrzeugprofil + +def profil_lesen(): + f = task.executor(io.open, PROFIL_PFAD, "r") + inhalt = f.read() + f.close() + return json.loads(inhalt) + + +def profil_schreiben(profil): + tmp = PROFIL_PFAD + ".tmp" + text = json.dumps(profil, ensure_ascii=False, indent=2) + f = task.executor(io.open, tmp, "w") + f.write(text) + f.close() + os.replace(tmp, PROFIL_PFAD) + + +# --------------------------------------------------------- JSON-Lines-Basis + +def _zeilen_lesen(pfad): + if not os.path.exists(pfad): + return [] + f = task.executor(io.open, pfad, "r") + inhalt = f.read() + f.close() + datensaetze = [] + for zeile in inhalt.splitlines(): + zeile = zeile.strip() + if zeile: + datensaetze.append(json.loads(zeile)) + return datensaetze + + +def _zeilen_schreiben(pfad, datensaetze): + tmp = pfad + ".tmp" + zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze] + text = "\n".join(zeilen) + if zeilen: + text += "\n" + f = task.executor(io.open, tmp, "w") + f.write(text) + f.close() + os.replace(tmp, pfad) + + +def _zeile_anhaengen(pfad, datensatz): + f = task.executor(io.open, pfad, "a") + f.write(json.dumps(datensatz, ensure_ascii=False) + "\n") + f.close() + + +# ------------------------------------------------------------------ Fahrten + +def fahrten_lesen(): + return _zeilen_lesen(FAHRTEN_PFAD) + + +def fahrten_schreiben(fahrten): + _zeilen_schreiben(FAHRTEN_PFAD, fahrten) + + +def fahrt_anhaengen(fahrt): + _zeile_anhaengen(FAHRTEN_PFAD, fahrt) + + +def offene_fahrten(): + """Alle Fahrten mit status == 'offen', ältere zuerst.""" + fahrten = fahrten_lesen() + offen = [f for f in fahrten if f.get("status") == "offen"] + return sorted(offen, key=lambda f: f.get("ts_start", "")) + + +def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben): + """Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und + schreibt den gesamten Bestand neu. Manuell geänderte Felder + (edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben. + Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive + funktionieren nach demselben Muster.""" + geaendert = False + for d in zeilen: + if d.get(id_feld) == id_wert: + geschuetzt = set(d.get("edited_fields", [])) + for feld, wert in aenderungen.items(): + if feld not in geschuetzt: + d[feld] = wert + geaendert = True + break + if geaendert: + schreiben(zeilen) + return geaendert + + +def fahrt_aktualisieren(trip_id, aenderungen): + return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben) + + +def fahrt_loeschen(trip_id): + fahrten = fahrten_lesen() + uebrig = [f for f in fahrten if f.get("trip_id") != trip_id] + if len(uebrig) == len(fahrten): + return False + fahrten_schreiben(uebrig) + return True + + +# ------------------------------------------------------------ Tankvorgänge + +def tankvorgaenge_lesen(): + return _zeilen_lesen(TANKVORGAENGE_PFAD) + + +def tankvorgaenge_schreiben(tankvorgaenge): + _zeilen_schreiben(TANKVORGAENGE_PFAD, tankvorgaenge) + + +def tankvorgang_anhaengen(tankvorgang): + _zeile_anhaengen(TANKVORGAENGE_PFAD, tankvorgang) + + +def tankvorgang_aktualisieren(tank_id, aenderungen): + return _datensatz_aktualisieren(tankvorgaenge_lesen(), "tank_id", tank_id, aenderungen, tankvorgaenge_schreiben) + + +def tankvorgang_loeschen(tank_id): + tankvorgaenge = tankvorgaenge_lesen() + uebrig = [t for t in tankvorgaenge if t.get("tank_id") != tank_id] + if len(uebrig) == len(tankvorgaenge): + return False + tankvorgaenge_schreiben(uebrig) + return True + + +def letzter_tankvorgang(): + """Der zeitlich jüngste bereits erfasste Tankvorgang (nach ts), oder None, + falls noch keiner existiert. Grundlage für die Gefahrene-Distanz-Berechnung + beim Anlegen eines neuen Tankvorgangs (§5.5).""" + tankvorgaenge = tankvorgaenge_lesen() + if not tankvorgaenge: + return None + return max(tankvorgaenge, key=lambda t: t.get("ts") or "") + + +def distanz_seit_letzter_tankung(aktueller_km): + """Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag für + das gleichnamige Formularfeld (§5.5) - frei überschreibbar, genau wie + odometer_km selbst. None, wenn kein Kilometerstand oder kein vorheriger + Tankvorgang vorliegt (erster Eintrag überhaupt). Gemeinsame Grundlage für + Beleg-Erfassung (belegverarbeitung.py) und automatische Tankerkennung + (tankerkennung.py).""" + if aktueller_km is None: + return None + letzter = letzter_tankvorgang() + if not letzter or letzter.get("odometer_km") is None: + return None + return round(aktueller_km - letzter["odometer_km"], 1) + + +def tankvorgang_nach_id(tank_id): + for t in tankvorgaenge_lesen(): + if t.get("tank_id") == tank_id: + return t + return None + + +def tankvorgang_nach_receipt_key(receipt_key): + """Für §7.7 Regel: derselbe Beleg (receipt_key, minutengenau) erzeugt + keinen zweiten Datensatz.""" + for t in tankvorgaenge_lesen(): + if t.get("receipt_key") == receipt_key: + return t + return None + + +# ------------------------------------------------------- Batteriespannung + +def batterieverlauf_lesen(): + """Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste + zuerst - siehe batterieverlauf.py für die Aufzeichnungslogik. min_ts/ + max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen Einzelmessung, für + die Datum/Uhrzeit-Anzeige beim Antippen des Diagrammpunkts im Frontend - + der Punkt selbst zeigt nur den Minimalwert (siehe dortiger Kommentar, + warum der aussagekräftig für die Entladung ist).""" + return _zeilen_lesen(BATTERIEVERLAUF_PFAD) + + +def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung): + """Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt ihn + beim ersten Wert des Tages an, erweitert sonst nur min/max samt dem + Zeitstempel der jeweils neuen Extremmessung. Das Fahrzeug meldet die + Spannung künftig mehrfach pro Stunde (aktive Fahrt) statt nur einmal + täglich - der komplette Bestand wird deshalb bei jeder Messung neu + geschrieben (wie bei den übrigen JSON-Lines-Beständen hier), was bei + einem Eintrag pro Tag über Jahre hinweg unproblematisch bleibt.""" + verlauf = batterieverlauf_lesen() + for eintrag in verlauf: + if eintrag.get("datum") == datum: + if spannung < eintrag["min"]: + eintrag["min"] = spannung + eintrag["min_ts"] = ts + if spannung > eintrag["max"]: + eintrag["max"] = spannung + eintrag["max_ts"] = ts + break + else: + verlauf.append({"datum": datum, "min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}) + _zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf) + + +# --------------------------------------------------------------------- IDs + +def neue_id(praefix): + return f"{praefix}-{uuid.uuid4().hex[:12]}" diff --git a/homeassistant/pyscript/reifenzaehler.py b/homeassistant/pyscript/reifenzaehler.py new file mode 100644 index 0000000..95c62f6 --- /dev/null +++ b/homeassistant/pyscript/reifenzaehler.py @@ -0,0 +1,101 @@ +"""Reifenzähler ohne utility_meter (§7.5, §10 Punkt 6). + +ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem +ursprünglichen Bau nachträglich verlangt): früher war der Zähler = aktueller +Kilometerstand - fester Startwert je Satz (reifen.saetze.. +start_odo_km). Das war falsch, sobald einmal zwischen Sommer und Winter +umgeschaltet wurde: die Differenz zum Startwert zählt dann alle seither +gefahrenen Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt gilt +stattdessen: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum +Zeitpunkt der Fahrt aktiv war. Dafür führt reifen.saetze..km selbst den +fortlaufenden Gesamtstand (statt eines Startwerts), und reifen. +referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest, gegen den +die nächste Änderung als Delta gemessen wird. start_odo_km/§13 entfällt +damit vollständig. + +Ergebnis wird zusätzlich als eigene pyscript-Zustandsvariable bereitgestellt +(nur zur Kontrolle in Entwicklerwerkzeuge → Zustände, das Frontend liest die +Werte direkt aus dem veröffentlichten Profil): + pyscript.reifen_sommer_km, pyscript.reifen_winter_km, + pyscript.reifen_aktiver_satz + +Kein Abhängigkeit von total_increasing oder utility_meter-internem Zustand - +die Fortschreibung braucht nur den zuletzt bekannten Absolutwert des +Kilometerstand-Sensors. Ein zeitweise `unavailable` Quellsensor (§4.1) +verliert dadurch nichts: referenz_odo_km bleibt einfach stehen, bis wieder +ein gültiger Wert kommt, und das nächste Delta wird dann gegen diesen alten +Stand berechnet - auch km, die während eines HA-Neustarts/Ausfalls gefahren +wurden, werden so noch dem damals aktiven Satz zugerechnet. + +Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. +""" + +import einstellungen +import frontend_veroeffentlichung +import profil + + +def _aktueller_km(): + try: + return float(frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)) + except (TypeError, ValueError): + return None + + +def _zustaende_veroeffentlichen(p): + reifen = p.get("reifen", {}) + saetze = reifen.get("saetze", {}) + for satz_name in ("sommer", "winter"): + km = saetze.get(satz_name, {}).get("km") + if km is None: + state.set(f"pyscript.reifen_{satz_name}_km", "unbekannt") + else: + state.set(f"pyscript.reifen_{satz_name}_km", km, new_attributes={"unit_of_measurement": "km"}) + state.set("pyscript.reifen_aktiver_satz", reifen.get("aktiv", "unbekannt")) + + +def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None): + """Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit + referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser + Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz + zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden.""" + p = profil.profil_lesen() + reifen = p.setdefault("reifen", {}) + saetze = reifen.setdefault("saetze", {}) + aktiv = reifen.get("aktiv", "sommer") + referenz = reifen.get("referenz_odo_km") + + if referenz is not None and neuer_km is not None: + delta = round(neuer_km - referenz, 1) + if delta > 0: + satz = saetze.setdefault(aktiv, {}) + satz["km"] = round((satz.get("km") or 0) + delta, 1) + if neuer_km is not None: + reifen["referenz_odo_km"] = neuer_km + if neuer_aktiv is not None: + reifen["aktiv"] = neuer_aktiv + + profil.profil_schreiben(p) + frontend_veroeffentlichung.profil_veroeffentlichen() + _zustaende_veroeffentlichen(p) + + +@time_trigger("startup") +def beim_start(): + _fortschreiben_und_veroeffentlichen(_aktueller_km()) + + +@state_trigger(f"{einstellungen.KM_SENSOR}") +def kilometerstand_geaendert(value=None): + _fortschreiben_und_veroeffentlichen(_aktueller_km()) + + +@service +def audi_dashboard_reifen_wechseln(satz=None): + """Umschalten des aktiven Reifensatzes (§5.2). Aufruf als + pyscript.audi_dashboard_reifen_wechseln mit satz="sommer"|"winter".""" + if satz not in ("sommer", "winter"): + log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'") + return + _fortschreiben_und_veroeffentlichen(_aktueller_km(), neuer_aktiv=satz) + log.info(f"audi_dashboard: aktiver Reifensatz auf '{satz}' umgeschaltet") diff --git a/homeassistant/pyscript/tankerkennung.py b/homeassistant/pyscript/tankerkennung.py new file mode 100644 index 0000000..23c9889 --- /dev/null +++ b/homeassistant/pyscript/tankerkennung.py @@ -0,0 +1,116 @@ +"""Automatische Tankerkennung über den Füllstandssensor (Erweiterung zu §7.7/§5.5, +nach dem ursprünglichen Bau nachträglich verlangt). + +Beobachtung des Nutzers: der Füllstand (TANK_SENSOR, in Prozent) springt beim +Fahren nie nach oben, er sinkt nur - jeder Anstieg ist also ein Tankvorgang. +Schwelle nach Nutzervorgabe: ab +5 Liter ODER +9 Prozentpunkten Anstieg gilt +als nachgetankt. Die 5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus +dem Fahrzeugprofil in Prozentpunkte umgerechnet, damit beide Angaben auf +derselben Einheit (Prozent) verglichen werden können; es gilt jeweils die +empfindlichere (kleinere) der beiden Schwellen. + +Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die +Integration liefert einen Tankvorgang oft in mehreren kleinen Schritten +(z. B. 40% -> 45% -> 60%), von denen keiner allein die Schwelle +überschreiten muss. Deshalb wird der zuletzt bekannte Tiefststand +gespeichert und der Anstieg dagegen gemessen, nicht gegen den unmittelbar +vorherigen Wert. Nach Anlage eines Tankvorgangs wird der Tiefststand auf den +aktuellen Wert zurückgesetzt, damit derselbe Vorgang nicht mehrfach neue +Datensätze erzeugt, wenn der Sensor währenddessen weiter ansteigt. + +Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter +(status "unvollständig", ohne Liter/Kosten/Station) - siehe §10 Punkt 5 der +letzten Rückmeldung: die Erwartung ist ein automatisch erkannter Tankvorgang +mit Zeitstempel, Kilometerstand und gefahrener Distanz, den der Nutzer später +per Beleg-Nachtrag (audi_dashboard_beleg_hochladen mit tank_id, siehe +belegverarbeitung.py) vervollständigt. + +EINSCHRÄNKUNG: der Tiefststand (_tiefststand_pct) lebt nur im Arbeitsspeicher +dieses Skripts, analog zur bekannten Einschränkung in fahrterkennung.py +(_fahrt_start_ts). Ein HA-Neustart während oder kurz vor einem Tankvorgang +verliert den bisherigen Tiefststand - der nächste Anstieg wird dann gegen den +ersten nach dem Neustart gemeldeten Wert gemessen. Bewusst nicht gehärtet, +aus demselben Grund wie dort. + +Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. +""" + +import datetime + +import einstellungen +import frontend_veroeffentlichung +import profil + +LITER_SCHWELLE = 5 +PROZENT_SCHWELLE = 9 +STANDARD_TANKVOLUMEN_LITER = 58 # Fallback, falls im Profil nicht hinterlegt + +_tiefststand_pct = None + + +def _als_zahl(wert): + try: + return float(wert) + except (TypeError, ValueError): + return None + + +def _schwelle_prozent(): + p = profil.profil_lesen() + 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) + + +@time_trigger("startup") +def beim_start(): + profil.ordner_sicherstellen() + + +@state_trigger(f"{einstellungen.TANK_SENSOR}") +def tankfuellstand_geaendert(value=None, old_value=None): + global _tiefststand_pct + + aktuell = _als_zahl(value) + if aktuell is None: + return + + if _tiefststand_pct is None: + _tiefststand_pct = aktuell + return + + if aktuell <= _tiefststand_pct: + _tiefststand_pct = aktuell + return + + anstieg = aktuell - _tiefststand_pct + if anstieg >= _schwelle_prozent(): + _tankvorgang_automatisch_anlegen(anstieg) + _tiefststand_pct = aktuell + + +def _tankvorgang_automatisch_anlegen(anstieg_pct): + odometer_km = frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR) + tankvorgang = { + "tank_id": profil.neue_id("f"), + "receipt_key": None, + "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "liters": None, + "fuel_total_eur": None, + "price_per_l": None, + "discount": None, + "station_name": None, + "odometer_km": odometer_km, + "distance_km": profil.distanz_seit_letzter_tankung(odometer_km), + "fuel_type": None, + "source": "auto", + "status": "unvollständig", + "receipt_file": None, + "edited_fields": [], + } + profil.tankvorgang_anhaengen(tankvorgang) + frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() + log.info( + f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} automatisch erkannt " + f"(Füllstandsanstieg {round(anstieg_pct, 1)} Prozentpunkte)" + ) diff --git a/homeassistant/pyscript/updateverwaltung.py b/homeassistant/pyscript/updateverwaltung.py new file mode 100644 index 0000000..6e8ed31 --- /dev/null +++ b/homeassistant/pyscript/updateverwaltung.py @@ -0,0 +1,184 @@ +"""Update-Funktion für die Oberfläche ("Update suchen" in Einstellungen). + +Voraussetzung: einstellungen.UPDATE_REPO_URL zeigt auf ein Git-Repository, in +das dieses Projekt (pyscript/ + www/, wie mit update.ps1 auf die eigene +Testinstanz gespielt) gepflegt/gepusht wird - z. B. ein privates GitHub-Repo, +genau wie es für die HACS-Integration bereits genutzt wird. Ohne eingetragene +URL meldet "Update suchen" nur "keine Update-Quelle eingerichtet" (§9- +Fallback), es passiert nichts von selbst. + +Kopiert wie update.ps1 bewusst NUR Code: + - pyscript/ komplett (kein Nutzerdaten-Ordner dort) + - www/*.js, www/*.css, www/badges/* +Bewusst NICHT angefasst: www/bilder/ (vom Nutzer hochgeladene Fahrzeugfotos, +siehe bilderverwaltung.py) und alles unter audi_dashboard/ (Fahrzeugprofil, +Fahrten- und Tankvorgänge-Archive) - ein Update darf niemals echte +Fahrzeugdaten oder eigene Fotos überschreiben. + +Vor jeder Installation wird der bisherige Code-Stand unter +audi_dashboard/code_backups// gesichert (gleiches Muster wie +backup.py für die Nutzerdaten) - kein automatisches Rollback in der +Oberfläche, aber ein manueller Weg zurück, falls ein Update Probleme macht. + +git ist im offiziellen Home-Assistant-Container vorhanden (an der +Testinstanz geprüft) und subprocess ist über allow_all_imports bereits +freigegeben (siehe configuration_snippet.yaml), deshalb hier statt eines +selbstgebauten ZIP-Downloads einfach ein flacher Git-Checkout in einen +eigenen Zwischenordner, getrennt von /config/audi_dashboard (Nutzerdaten) +und /config/pyscript, /config/www (Live-Code). +""" + +import datetime +import io +import json +import os +import shutil +import subprocess + +import einstellungen +import profil + +QUELLE_ORDNER = "/config/.audi_dashboard_update_quelle" +BACKUP_ORDNER = f"{profil.BASIS}/code_backups" +LIVE_PYSCRIPT = "/config/pyscript" +LIVE_WWW = "/config/www" +CODE_DATEIEN_WWW = ["audi-dashboard-app.js", "audi-dashboard-panel.js", "audi-dashboard.css"] + + +def _status_veroeffentlichen(daten): + state.set("pyscript.audi_dashboard_update_status", "aktuell", new_attributes={"daten": daten}) + + +def _git(*args, cwd=None): + # subprocess.run() direkt, ohne task.executor - genau wie der bestehende + # PDF-Parser-Aufruf in belegverarbeitung.py (_parser_aufrufen), an der + # Testinstanz bereits bestätigt funktionsfähig. + return subprocess.run( + ["git", *args], + cwd=cwd, capture_output=True, text=True, timeout=60, + ) + + +def _lokale_version(): + pfad = f"{LIVE_WWW}/audi-dashboard-version.json" + if not os.path.exists(pfad): + return None + try: + f = task.executor(io.open, pfad, "r") + inhalt = f.read() + f.close() + return json.loads(inhalt).get("version") + except (ValueError, OSError): + return None + + +def _quelle_aktualisieren(): + """Klont bzw. holt den konfigurierten Branch flach in QUELLE_ORDNER. + Gibt (erfolgreich, fehlertext) zurück.""" + zweig = einstellungen.UPDATE_BRANCH or "main" + if not os.path.exists(f"{QUELLE_ORDNER}/.git"): + os.makedirs(os.path.dirname(QUELLE_ORDNER), exist_ok=True) + if os.path.exists(QUELLE_ORDNER): + task.executor(shutil.rmtree, QUELLE_ORDNER) + r = _git("clone", "--depth", "1", "--branch", zweig, einstellungen.UPDATE_REPO_URL, QUELLE_ORDNER) + if r.returncode != 0: + return False, r.stderr.strip() or "git clone fehlgeschlagen" + return True, None + r = _git("fetch", "--depth", "1", "origin", zweig, cwd=QUELLE_ORDNER) + if r.returncode != 0: + return False, r.stderr.strip() or "git fetch fehlgeschlagen" + r = _git("reset", "--hard", f"origin/{zweig}", cwd=QUELLE_ORDNER) + if r.returncode != 0: + return False, r.stderr.strip() or "git reset fehlgeschlagen" + return True, None + + +@service +def audi_dashboard_update_pruefen(): + """Prüft, ob unter einstellungen.UPDATE_REPO_URL eine neuere Version + vorliegt, ohne etwas zu installieren. Aufruf als + pyscript.audi_dashboard_update_pruefen.""" + jetzt = datetime.datetime.now().isoformat() + if not einstellungen.UPDATE_REPO_URL: + _status_veroeffentlichen({"stand": "keine_quelle", "geprueft_um": jetzt}) + return + ok, fehler = _quelle_aktualisieren() + if not ok: + _status_veroeffentlichen({ + "stand": "fehler", "fehler": fehler, "geprueft_um": jetzt, + "aktuelle_version": _lokale_version(), + }) + log.warning(f"audi_dashboard: Update-Prüfung fehlgeschlagen: {fehler}") + return + quell_version_pfad = f"{QUELLE_ORDNER}/www/audi-dashboard-version.json" + neue_version = None + if os.path.exists(quell_version_pfad): + f = task.executor(io.open, quell_version_pfad, "r") + inhalt = f.read() + f.close() + try: + neue_version = json.loads(inhalt).get("version") + except ValueError: + neue_version = None + aktuelle_version = _lokale_version() + verfuegbar = neue_version is not None and aktuelle_version is not None and neue_version > aktuelle_version + _status_veroeffentlichen({ + "stand": "geprueft", "verfuegbar": verfuegbar, + "aktuelle_version": aktuelle_version, "neue_version": neue_version, + "geprueft_um": jetzt, + }) + log.info(f"audi_dashboard: Update-Prüfung ok - aktuell={aktuelle_version}, verfügbar={neue_version}") + + +@service +def audi_dashboard_update_installieren(): + """Installiert den zuletzt per audi_dashboard_update_pruefen geholten + Stand aus QUELLE_ORDNER - sichert vorher den bisherigen Code. Aufruf als + pyscript.audi_dashboard_update_installieren.""" + if not os.path.exists(f"{QUELLE_ORDNER}/.git"): + log.warning("audi_dashboard: Update-Installation ohne vorherige Prüfung abgelehnt") + return + + zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + ziel_backup = f"{BACKUP_ORDNER}/{zeitstempel}" + os.makedirs(ziel_backup, exist_ok=True) + if os.path.exists(LIVE_PYSCRIPT): + task.executor(shutil.copytree, LIVE_PYSCRIPT, f"{ziel_backup}/pyscript") + for name in CODE_DATEIEN_WWW: + quelle = f"{LIVE_WWW}/{name}" + if os.path.exists(quelle): + task.executor(shutil.copy, quelle, f"{ziel_backup}/{name}") + + quelle_pyscript = f"{QUELLE_ORDNER}/pyscript" + if os.path.exists(quelle_pyscript): + if os.path.exists(LIVE_PYSCRIPT): + task.executor(shutil.rmtree, LIVE_PYSCRIPT) + task.executor(shutil.copytree, quelle_pyscript, LIVE_PYSCRIPT) + + for name in CODE_DATEIEN_WWW: + quelle = f"{QUELLE_ORDNER}/www/{name}" + if os.path.exists(quelle): + task.executor(shutil.copy, quelle, f"{LIVE_WWW}/{name}") + + quelle_badges = f"{QUELLE_ORDNER}/www/badges" + if os.path.exists(quelle_badges): + ziel_badges = f"{LIVE_WWW}/badges" + if os.path.exists(ziel_badges): + task.executor(shutil.rmtree, ziel_badges) + task.executor(shutil.copytree, quelle_badges, ziel_badges) + + # Version neu schreiben (aktuelle Uhrzeit als Cache-Buster, wie + # update.ps1) statt den im Quell-Repo eingecheckten Wert blind zu + # übernehmen - erzwingt bei jedem Browser einen echten Neuabruf von + # audi-dashboard-app.js/.css (siehe audi-dashboard-panel.js). + version = int(datetime.datetime.now().timestamp()) + f = task.executor(io.open, f"{LIVE_WWW}/audi-dashboard-version.json", "w") + f.write(json.dumps({"version": version})) + f.close() + + _status_veroeffentlichen({ + "stand": "installiert", "verfuegbar": False, + "aktuelle_version": version, "neue_version": None, + "geprueft_um": datetime.datetime.now().isoformat(), + }) + log.info(f"audi_dashboard: Update installiert (Backup unter {ziel_backup}), neue Version {version}") diff --git a/homeassistant/update.ps1 b/homeassistant/update.ps1 new file mode 100644 index 0000000..781c807 --- /dev/null +++ b/homeassistant/update.ps1 @@ -0,0 +1,67 @@ +<# +.SYNOPSIS + Spielt Code-Updates (pyscript + Frontend) in eine laufende Home-Assistant- + Installation ein, ohne Neustart und ohne Cache-Probleme im Browser. + +.BESCHREIBUNG + Kopiert nur Code, nie Daten: + - pyscript/* -> /pyscript/ (pyscript lädt das automatisch neu, + siehe Kopfkommentar dort - an + einer Testinstanz bestätigt: eine + geänderte Datei war innerhalb von + Millisekunden aktiv, ganz ohne + Neustart) + - www/*.js, www/*.css, www/bilder/* -> /www/ + - www/audi-dashboard-version.json wird IMMER neu geschrieben, mit der + aktuellen Uhrzeit als Versionsnummer - der Lade-Stub + (audi-dashboard-panel.js) liest diese Datei bei jedem Seitenaufruf + ohne Cache und lädt den eigentlichen Code darüber immer frisch nach + (siehe Kopfkommentar in www/audi-dashboard-panel.js). + + Absichtlich NICHT kopiert: data/ - das würde ein bereits laufendes + Fahrzeugprofil, die Fahrten- und Tankvorgänge-Archive überschreiben. Die + data/-Dateien sind nur die Erstbefüllung für die einmalige Installation + (siehe INSTALL.md Schritt 2). + +.PARAMETER Ziel + Pfad zum config-Verzeichnis von Home Assistant, z. B. ein per Samba + eingebundenes Laufwerk (\\\config) oder ein lokal gemounteter Pfad. + +.BEISPIEL + .\update.ps1 -Ziel "\\homeassistant.local\config" +#> +param( + [Parameter(Mandatory = $true)] + [string]$Ziel +) + +$ErrorActionPreference = "Stop" +$hier = $PSScriptRoot + +if (-not (Test-Path $Ziel)) { + throw "Ziel nicht erreichbar: $Ziel — Samba-Share verbunden? Pfad korrekt?" +} + +Write-Host "Kopiere pyscript/ ..." -ForegroundColor Cyan +robocopy "$hier\pyscript" "$Ziel\pyscript" /MIR /NFL /NDL /NJH /NJS | Out-Null + +Write-Host "Kopiere www/ (ohne bilder/, die separat behandelt werden) ..." -ForegroundColor Cyan +New-Item -ItemType Directory -Force -Path "$Ziel\www" | Out-Null +Copy-Item "$hier\www\audi-dashboard-app.js" "$Ziel\www\audi-dashboard-app.js" -Force +Copy-Item "$hier\www\audi-dashboard-panel.js" "$Ziel\www\audi-dashboard-panel.js" -Force +Copy-Item "$hier\www\audi-dashboard.css" "$Ziel\www\audi-dashboard.css" -Force + +if (Test-Path "$hier\www\bilder") { + Write-Host "Kopiere www/bilder/ ..." -ForegroundColor Cyan + New-Item -ItemType Directory -Force -Path "$Ziel\www\bilder" | Out-Null + Copy-Item "$hier\www\bilder\*" "$Ziel\www\bilder\" -Force -Recurse +} + +$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() +"{ ""version"": $version }" | Out-File -FilePath "$Ziel\www\audi-dashboard-version.json" -Encoding utf8 -NoNewline + +Write-Host "" +Write-Host "Fertig. Version $version." -ForegroundColor Green +Write-Host "pyscript-Änderungen sind bereits aktiv (Auto-Reload, kein Neustart nötig)." +Write-Host "Frontend-Änderungen: im Browser einmal ganz normal neu laden (F5) reicht -" +Write-Host "kein Hard-Refresh, kein HA-Neustart." diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js new file mode 100644 index 0000000..ec44b1e --- /dev/null +++ b/homeassistant/www/audi-dashboard-app.js @@ -0,0 +1,3130 @@ +/* ================================================================ + Audi-Dashboard — panel_custom Custom Element + ================================================================ + Umbau von dashboard-muster-ohne-bilder.html (der verbindliche Prototyp + aus dem Lastenheft) zu einem panel_custom-Element für Home Assistant. + + Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert + (dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde + nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den + pyscript-Zustandsentitäten geladen statt fest im Code zu stehen) und die + 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. + - Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur, + wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat. +*/ + +/* --------------------------------------------------------------------- + Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch + unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`) + weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel- + Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine + Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein + existierte. + --------------------------------------------------------------------- */ +let HASS = null; +let ROOT = null; // this.shadowRoot der Panel-Instanz +let HOST = null; // die Panel-Instanz selbst (für :host([data-theme])) + +let CONFIG = null; +let CAR = null; +let TRIPS = []; +let FILLS = []; +let BATTERIEVERLAUF = []; +let DATEN_GELADEN = false; +// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend - +// null, solange keiner bekannt ist (siehe standAlterText()). +let LETZTES_UPDATE_TS = null; + +/* Cache-Buster fuer audi-dashboard.css: diese Datei selbst wird von HA mit + derselben langen /local/-Cache-Zeit ausgeliefert wie audi-dashboard-panel.js + (siehe dessen Kopfkommentar) - ohne Versions-Query bleibt ein Browser, der + sie einmal geladen hat, auch nach einer CSS-Aenderung auf dem alten Stand + haengen, obwohl app.js selbst per audi-dashboard-version.json korrekt neu + geladen wird. Die eigene Script-URL (?v=N, von audi-dashboard-panel.js + gesetzt) liefert dieselbe Versionsnummer, ohne version.json ein zweites + Mal abfragen zu muessen. + */ +const CSS_VERSION = (() => { + try { + return new URL(import.meta.url).searchParams.get("v") || Date.now(); + } catch (e) { + return Date.now(); + } +})(); + +const $ = (s) => ROOT.querySelector(s); + +/* ---------------------------------------------------------------- Format */ +const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d }); +const eur = (n) => de(n, 2); +const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +const sum = (a, f) => a.reduce((s, x) => s + f(x), 0); +/* Durchschnittspreis IMMER mengengewichtet: Summe Kosten / Summe Liter */ +const schnittpreis = (list) => sum(list, (f) => f.fuel_total_eur ?? f.summe ?? 0) / sum(list, (f) => f.liters ?? f.liter ?? 0); + +/* -------------------------------------------------------- Adapter-Schicht + Übersetzt die Fahrzeugprofil-Struktur aus dem Backend (fahrzeug/ + einstellungen/versicherung/steuer/reifen/service/technik/ausstattung) in + die CONFIG/CAR-Form, mit der der Rest dieser Datei arbeitet - direkt aus + dem Prototyp übernommen, um dessen Render-Funktionen unverändert + weiterzuverwenden. */ +function profilZuConfig(p) { + return { + fahrzeugtitel: p.fahrzeug.titel, + fahrzeugzusatz: p.fahrzeug.zusatz, + fahrzeuguntertitel: p.fahrzeug.untertitel, + kennzeichen: p.fahrzeug.kennzeichen, + startbild: p.einstellungen.uebersichtsbild, + version: "1.0", + tankvolumen: p.fahrzeug.tankvolumen_liter, + nachtVon: p.einstellungen.nacht_von, + nachtBis: p.einstellungen.nacht_bis, + pauseMin: p.einstellungen.fahrten_pausenzeit_min, + markenname: p.einstellungen.kraftstoffanbieter, + wlanName: p.fahrzeug.wlan_name, + backupIntervall: p.einstellungen.backup_intervall || "aus", + letztesBackup: p.einstellungen.letztes_backup || null, + ausfuehrung: p.fahrzeug.ausfuehrung || "", + }; +} + +function profilZuCar(p, status) { + // km ist kein abgeleiteter Wert mehr, sondern wird vom Backend + // (reifenzaehler.py) direkt im Profil fortgeschrieben - jeder gefahrene + // Kilometer zählt dort auf den zum Zeitpunkt aktiven Satz. + const reifenSatz = (k) => p.reifen.saetze[k] || {}; + return { + fin: p.fahrzeug.fin, + details: p.fahrzeug.details, + erstzulassung: p.fahrzeug.erstzulassung, + hu: p.fahrzeug.hauptuntersuchung_faellig, + odo: status.km ?? 0, + odoBekannt: status.km != null, + tankPct: status.tankprozent ?? 0, + reichweite: status.reichweite_km, + batteriespannung: status.batteriespannung, + gesichert: status.gesichert, + sicherheitscheck: status.sicherheitscheck || [], + oelwechselFaelligTs: status.oelwechsel_faellig_ts, + oelwechselFaelligKm: status.oelwechsel_faellig_km, + inspektionFaelligTs: status.inspektion_faellig_ts, + inspektionFaelligKm: status.inspektion_faellig_km, + kmProTag: 18.3, // Fallback, nur solange kein Servicebuch-Eintrag existiert + // "Gültig bis" ist einschließlich - der Rabatt gilt noch am Stichtag + // selbst, erst am Folgetag schaltet der Schalter automatisch ab, ohne + // dass der Nutzer manuell eingreifen muss. + smartdeal: { aktiv: p.einstellungen.smartdeal.aktiv && (!p.einstellungen.smartdeal.laeuft_ab || + p.einstellungen.smartdeal.laeuft_ab >= new Date().toISOString().slice(0, 10)), laeuftAb: p.einstellungen.smartdeal.laeuft_ab }, + oel: { + modus: p.einstellungen.oelwechsel_intervall.modus, + km: p.einstellungen.oelwechsel_intervall.km, + monate: p.einstellungen.oelwechsel_intervall.monate, + herstellerKm: p.einstellungen.oelwechsel_intervall.hersteller_km, + herstellerMonate: p.einstellungen.oelwechsel_intervall.hersteller_monate, + }, + versicherung: p.versicherung, + steuer: { ...p.steuer, faellig: p.steuer.faellig || "" }, + reifen: { + aktiv: p.reifen.aktiv === "winter" ? "Winter" : "Sommer", + anzugsmoment: p.reifen.anzugsmoment_nm, + wechsel: p.reifen.wechsel, + sommer: reifenSatz("sommer"), + winter: reifenSatz("winter"), + }, + service: p.service, + technik: p.technik, + ausstattung: p.ausstattung, + }; +} + +/* Rückrichtung: CONFIG/CAR (im Speicher gehalten, vom Prototyp-Code direkt + mutiert) zurück in die Profil-Form für audi_dashboard_profil_schreiben. */ +function configCarZuProfil(vorherigesProfil) { + const p = JSON.parse(JSON.stringify(vorherigesProfil)); + p.fahrzeug.titel = CONFIG.fahrzeugtitel; + p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz; + p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel; + p.fahrzeug.kennzeichen = CONFIG.kennzeichen; + p.fahrzeug.wlan_name = CONFIG.wlanName; + p.fahrzeug.hauptuntersuchung_faellig = CAR.hu; + p.fahrzeug.fin = CAR.fin; + p.fahrzeug.erstzulassung = CAR.erstzulassung; + p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung; + p.einstellungen.uebersichtsbild = CONFIG.startbild; + p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin; + p.einstellungen.backup_intervall = CONFIG.backupIntervall; + p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb }; + p.einstellungen.oelwechsel_intervall = { + modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate, + hersteller_km: CAR.oel.herstellerKm, hersteller_monate: CAR.oel.herstellerMonate, + }; + p.versicherung = CAR.versicherung; + p.steuer = { ...CAR.steuer, faellig: CAR.steuer.faellig || null }; + p.reifen.aktiv = CAR.reifen.aktiv.toLowerCase(); + p.reifen.anzugsmoment_nm = CAR.reifen.anzugsmoment; + p.reifen.wechsel = CAR.reifen.wechsel; + for (const k of ["sommer", "winter"]) { + // km wird vom Backend fortgeschrieben (reifenzaehler.py, reagiert auf + // den Kilometerstand-Sensor) - hier nie mitschreiben, sonst würde ein im + // Browser gehaltener älterer Stand eine zwischenzeitliche Fortschreibung + // überschreiben. + const { km, ...rest } = CAR.reifen[k]; + p.reifen.saetze[k] = { ...p.reifen.saetze[k], ...rest }; + } + p.service = CAR.service; + return p; +} + +let PROFIL_ROH = null; // letzter vom Backend gelesener Rohstand, für configCarZuProfil() + +async function profilSpeichern() { + const neuesProfil = configCarZuProfil(PROFIL_ROH); + PROFIL_ROH = neuesProfil; + await HASS.callService("pyscript", "audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(neuesProfil) }); +} + +/* ------------------------------------------------------------- Bilder + Feste Dateinamen laut §7a, referenziert unter /local/ (aus www/ serviert). + Der Ordner bilder/ selbst ist noch nicht befüllt (siehe Kopfkommentar) - + die Flächen behalten dennoch ihre Maße, das Layout springt nicht. */ +const BILDER = [ + { name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" }, + { name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" }, + { name: "Frontansicht", label: "Auto Front", datei: "frontansicht.webp" }, + { name: "Heckansicht", label: "Auto Heck", datei: "heckansicht.webp" }, + { name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" }, + { name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" }, + { name: "Sitze", label: "Innenraum Sitze", datei: "sitze.webp" }, +]; +let bild = 0; +const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" }; +// Alle einzeln hochladbaren Bild-Plätze (§ Fahrzeugbilder-Upload in +// Einstellungen) - dieselben festen Dateinamen wie BILDER, plus die separat +// geführte Winter-Variante der Seitenansicht. +const BILDER_UPLOAD_SLOTS = [...BILDER, { name: "Seitenansicht (Winter)", label: SEITE_WINTER.label, datei: SEITE_WINTER.datei }]; +// Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als +// Query-Parameter an alle bilder/-URLs gehängt (siehe bildInfo() +// unten) - ohne das würde der Browser nach einem Upload weiter das alte Bild +// oder den zwischenzeitlich gecachten 404 unter derselben URL zeigen. +let bildVersion = Date.now(); + +/* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a + noch nicht vorhanden) wird die Bildfläche durch ein generisches Symbol + plus eindeutiger Beschriftung und dem erwarteten Dateinamen ersetzt - + damit klar ist, welche Datei unter welchem Namen nachgereicht werden muss, + ohne dass sich am Layout etwas verschiebt (Maße bleiben über die CSS- + Klassen erhalten, siehe carfix/carfix.klein/radbild in audi-dashboard.css). */ +/* A5: Der Platzhalter nennt das Motiv und die nächste Handlung - der + Dateiname ist Entwicklersprache und steht nur noch in den Einstellungen + (siehe BILDER_UPLOAD_SLOTS). Die Maße bleiben über die CSS-Klassen erhalten, + das Layout springt beim Nachliefern eines Fotos nicht. */ +function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) { + const idTeil = imgId ? ` id="${imgId}"` : ""; + return `
+ ${esc(label)} +
+ + ${esc(label)} + ${dateiZeigen ? esc(datei) : "Foto hinzufügen"} +
+
`; +} + +function startIndex() { + const i = BILDER.findIndex((b) => b.name === CONFIG.startbild); + return i < 0 ? 0 : i; +} +function bildInfo(i) { + const b = BILDER[i]; + if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") { + return { label: SEITE_WINTER.label, datei: SEITE_WINTER.datei, src: `/local/bilder/${SEITE_WINTER.datei}?v=${bildVersion}` }; + } + return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` }; +} +function bildSrc(i) { return bildInfo(i).src; } +function carImg(modus) { + if (modus === "fest") { + const info = bildInfo(startIndex()); + return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein"); + } + const info = bildInfo(bild); + return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", "carimg") + + `
${BILDER.map((_, i) => ``).join("")}
`; +} +/* Bildmarke des Kraftstoffanbieters (Shell). Anders als bei den Fahrzeug- + fotos ist hier kein generischer Platzhalter-Rahmen sinnvoll, weil das Logo + inline neben Text steht (.marke) - Fehlerzustand blendet stattdessen den + Markennamen als kleine Textmarke ein (.marke .ph in audi-dashboard.css), + exakt wie im Prototyp vorgesehen. Die Bilddatei selbst liefert der Nutzer + nach (offizielles Shell-Logo, hier nicht nachgezeichnet). */ +const MARKENLOGO_DATEI = "shell-logo.svg"; +function markenlogo() { + if (!CAR.smartdeal.aktiv) return ""; + const label = CONFIG.markenname || "Logo"; + return ``; +} +const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]; +function szeneKlasse(i) { + if (!bodenAn) return ""; + if (OHNE_SZENE.includes(BILDER[i].name)) return ""; + return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse"; +} +function bildWeiter() { + bild = (bild + 1) % BILDER.length; + const img = ROOT.getElementById("carimg"); + if (!img) return; + const info = bildInfo(bild); + img.src = info.src; + img.alt = info.label; + const box = img.closest("[data-bildbox]"); + if (box) { + box.classList.remove("bild-fehlt"); // neues Bild kann laden, auch wenn das vorherige fehlte + box.querySelector(".platzhalter-label").textContent = info.label; + box.querySelector(".platzhalter-datei").textContent = info.datei; + } + const sz = ROOT.getElementById("szene"); + if (sz) sz.className = "szene " + szeneKlasse(bild); + [...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild)); +} + +/* ---------------------------------------------------- Termine/Vorhersage + heute() statt einer eingefrorenen Konstante - der Prototyp nutzte ein + festes Demo-Datum (new Date(2026,7,3)), das für den echten Betrieb durch + das tatsächliche Tagesdatum ersetzt werden muss. */ +function heute() { return new Date(); } +const dTag = 86400000; +const mmjj = (d) => String(d.getMonth() + 1).padStart(2, "0") + "/" + String(d.getFullYear()).slice(2); +const dedat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); +function dat(s) { const [d, m, j] = s.split(".").map(Number); return new Date(j, m - 1, d); } + +const MISCHDAUER = 180; +function tagesleistung() { + // §9: fällt die Datenquelle aus, wird das sichtbar gemacht statt mit einem + // Platzhalterwert weiterzurechnen - ohne diese Sperre rechnete die + // Prognose mit CAR.odo=0 weiter und erzeugte Termine weit im Jahr -13000. + if (!CAR.odoBekannt) return { wert: CAR.kmProTag, quelle: "Kilometerstand unbekannt", tage: 0, km: 0 }; + const b = CAR.service.buch; + if (!b.length) return { wert: CAR.kmProTag, quelle: "Vorgabe", tage: 0, km: 0 }; + const alt = b[b.length - 1]; + const tageGesamt = Math.max(1, (heute() - dat(alt.datum)) / dTag); + const langfristig = (CAR.odo - alt.km) / tageGesamt; + const letzter = b[0]; + const tageSeit = (heute() - dat(letzter.datum)) / dTag; + const kmSeit = CAR.odo - letzter.km; + if (tageSeit < 14 || kmSeit <= 0) + return { wert: langfristig, quelle: "gesamter Verlauf", tage: Math.round(tageGesamt), km: CAR.odo - alt.km }; + const aktuell = kmSeit / tageSeit; + const g = Math.min(1, tageSeit / MISCHDAUER); + return { + wert: langfristig * (1 - g) + aktuell * g, + quelle: g >= 1 ? "seit dem letzten Service" : "gemischt, " + Math.round(g * 100) + " % aktueller Abschnitt", + tage: Math.round(tageSeit), km: kmSeit, + }; +} +function kmProTag() { return tagesleistung().wert; } +function prognose(zielKm) { + if (!CAR.odoBekannt) return { tage: null, datum: null }; + const rest = zielKm - CAR.odo; + if (rest <= 0) return { tage: 0, datum: heute() }; + const tage = Math.round(rest / kmProTag()); + return { tage, datum: new Date(heute().getTime() + tage * dTag) }; +} +function ics(titel, datum, ort, notiz) { + const tag = datum.replace(/-/g, ""); + const naechster = new Date(new Date(datum).getTime() + dTag).toISOString().slice(0, 10).replace(/-/g, ""); + const text = [ + "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Audi Dashboard//DE", "BEGIN:VEVENT", + "UID:" + Date.now() + "@audi-dashboard.local", + "DTSTAMP:" + new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z", + "DTSTART;VALUE=DATE:" + tag, "DTEND;VALUE=DATE:" + naechster, + "SUMMARY:" + titel, "LOCATION:" + (ort || ""), "DESCRIPTION:" + (notiz || ""), + "BEGIN:VALARM", "TRIGGER:-P2D", "ACTION:DISPLAY", "DESCRIPTION:" + titel, "END:VALARM", + "END:VEVENT", "END:VCALENDAR", + ].join("\r\n"); + const a = document.createElement("a"); + a.href = "data:text/calendar;charset=utf-8," + encodeURIComponent(text); + a.download = titel.replace(/[^\wäöüÄÖÜß ]/g, "").replace(/\s+/g, "-") + ".ics"; + document.body.appendChild(a); a.click(); a.remove(); +} +function ring(pct) { + const r = 64, c = 2 * Math.PI * r, on = (c * pct) / 100; + return `
+ + + + +
+
${pct}%
+ ${de(CONFIG.tankvolumen * pct / 100, 1)} Liter +
`; +} + +/* ------------------------------------------------------------------ Karte + Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */ +let MAP = null, TILE = null, LEAFLET_LADEND = null; +const TILES = { + nacht: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + tag: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", +}; +const ATTR = "© OpenStreetMap · CARTO"; + +function leafletLaden() { + if (window.L) return Promise.resolve(); + if (LEAFLET_LADEND) return LEAFLET_LADEND; + LEAFLET_LADEND = new Promise((resolve, reject) => { + const css1 = document.createElement("link"); + css1.rel = "stylesheet"; + css1.href = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"; + document.head.appendChild(css1); + const script = document.createElement("script"); + script.src = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + return LEAFLET_LADEND; +} + +function mapBox() { return `
`; } + +/* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte): + die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest + hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen + schon aus einer echten, dafür vorgesehenen Datenquelle. Ohne Werkstatt- + Koordinaten bleibt nur ein neutraler Mittelpunkt (geografische Mitte + Deutschlands), keine an dieses Fahrzeug gebundene Vermutung. */ +function kartenMittelpunkt() { + const pos = CAR.service.autohaus.pos; + return pos && pos.length === 2 ? pos : [51.1657, 10.4515]; +} + +function fakeTrack(a, b, n = 13) { + const pts = []; let seed = Math.round((a[0] + b[1]) * 1000); + const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; }; + for (let i = 0; i <= n; i++) { + const t = i / n, k = Math.sin(t * Math.PI); + pts.push([a[0] + (b[0] - a[0]) * t + rnd() * 0.05 * k, a[1] + (b[1] - a[1]) * t + rnd() * 0.05 * k]); + } + pts[0] = a; pts[n] = b; return pts; +} +function tiles() { + if (!MAP || !window.L) return; + const url = TILES[HOST.dataset.theme]; + if (TILE) MAP.removeLayer(TILE); + TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP); +} +async function initMap() { + await leafletLaden(); + const el = ROOT.getElementById("map"); + if (!el || typeof window.L === "undefined") return; + MAP = window.L.map(el, { zoomControl: true, attributionControl: true, scrollWheelZoom: false }); + TILE = null; tiles(); + const rot = getComputedStyle(HOST).getPropertyValue("--red").trim() || "#F50537"; + if (route.name === "trip") { + const t = TRIPS.find((x) => x.trip_id === route.id); + if (t && t.start_lat != null && t.end_lat != null) { + const track = fakeTrack([t.start_lat, t.start_lon], [t.end_lat, t.end_lon]); + const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9 }).addTo(MAP); + track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP)); + window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || "")); + window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || "")); + MAP.fitBounds(line.getBounds(), { padding: [26, 26] }); + } else { + MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke) + } + } else if (route.name === "fill") { + const f = FILLS.find((x) => x.tank_id === route.id); + if (f && f.station_lat != null) { + MAP.setView([f.station_lat, f.station_lon], 14); + window.L.circleMarker([f.station_lat, f.station_lon], { radius: 7, color: rot, weight: 2, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP).bindPopup((f.station_name || "") + "
" + (f.station_address || "")); + } else { + MAP.setView(kartenMittelpunkt(), 11); + } + } + setTimeout(() => MAP && MAP.invalidateSize(), 60); +} + +/* -------------------------------------------------------------- Ansichten + Ab hier praktisch unverändert aus dem Prototyp übernommen. */ +function badge() { + // Offizielle Audi-Markenassets aus www/badges/ (positive = für hellen, + // negative = für dunklen Hintergrund) - welche Variante sichtbar ist, + // entscheidet reines CSS über :host([data-theme=...]) (siehe .badge img in + // audi-dashboard.css), genau wie beim Farbwechsel des restigen Interfaces + // beim Tag/Nacht-Umschalten. Kein Re-Render beim Theme-Wechsel nötig. + // + // Der Zusatz neben dem Badge (z. B. "Avant competition") setzt sich aus + // dem Modellnamen selbst zusammen - alles nach dem erkannten Badge-Präfix + // ("Avant"/"Sportback"/...) - plus der separat gepflegten Ausführung, statt + // aus dem alten, nicht mehr an die Modell-Auswahl gekoppelten + // CONFIG.fahrzeugzusatz (siehe Kopfkommentar bei FAHRZEUG_MODELLE). + const praefix = badgePraefix(); + if (!praefix) return ""; + const datei = BADGE_DATEIEN[praefix]; + const modellZusatz = (CONFIG.fahrzeugtitel || "").slice(praefix.length).trim(); + const zusatz = [modellZusatz, CONFIG.ausfuehrung].filter(Boolean).join(" "); + const alt = esc(CONFIG.fahrzeugtitel) + " Badge"; + return `
+ ${alt} + ${alt} + ${zusatz ? `${esc(zusatz)}` : ""}
`; +} + +function teaser() { + if (!TRIPS.length && !FILLS.length) return ""; + const t = TRIPS[0], f = FILLS[0]; + return `
Zuletzt + ${t ? `` : ""} + ${f ? `` : ""}
`; +} + +function naechsterTermin() { return termine().slice().sort((a, b) => a.ziel - b.ziel)[0]; } + +function intervalle(t) { + const oel = CAR.oel, b = t.basis; + if (!b) return null; + const bauen = (km, monate) => ({ km: km ? b.km + km : null, datum: monatePlus(b.datum, monate) }); + if (t.art === "Hauptuntersuchung") { + const eig = bauen(null, 24); + return { hersteller: null, eigen: eig, prognose: eig.datum }; + } + const eKm = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm)) : 30000; + const eM = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)) : 24; + const eig = bauen(eKm, eM); + // "Herstellervorgabe" gilt bei Ölwechsel nur, wenn der Kunde in "Einrichten" + // tatsächlich die Herstellervorgabe gewählt hat - bei eigenem Intervall ist + // "eigen" maßgeblich, sonst würde anzeige = iv.hersteller || iv.eigen unten + // immer die Herstellerwerte zeigen und das Kundenintervall ignorieren. + const her = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? bauen(oel.herstellerKm, oel.herstellerMonate) : null) : bauen(30000, 24); + const perKm = prognose(eig.km).datum; + return { hersteller: her, eigen: eig, prognose: perKm && perKm < eig.datum ? perKm : eig.datum }; +} +/* Vom Fahrzeug selbst gemeldete Fälligkeit (VW Group EU Data Act, siehe + einstellungen.py) - ergänzt die App-eigene, aus dem Servicebuch berechnete + Prognose (intervalle()/terminText() oben), ersetzt sie aber bewusst nicht: + die Werkscomputer-Logik dahinter ist nicht bekannt und könnte von den + selbst eingestellten Intervallen abweichen. null, wenn das Fahrzeug dazu + nichts meldet (Sensor fehlt/nicht verfügbar). */ +function fahrzeugMeldungRoh(art) { + if (art === "Ölwechsel") return { ts: CAR.oelwechselFaelligTs, km: CAR.oelwechselFaelligKm }; + if (art === "Inspektion") return { ts: CAR.inspektionFaelligTs, km: CAR.inspektionFaelligKm }; + return { ts: null, km: null }; +} + +/* App-eigene Ölwechsel-Prognose, eigenständig statt über die allgemeine + tagesleistung()/prognose()-Mischrechnung (die für Ölwechsel zu träge ist, + weil sie den langfristigen Durchschnitt seit dem allerersten Servicebuch- + eintrag mit einfließen lässt): die Berechnung beginnt bewusst komplett neu + ab dem Datum des letzten tatsächlich durchgeführten Ölwechsels. + + Logik (wie vorgegeben): km und Tage seit dem letzten Ölwechsel ergeben die + aktuelle Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum + eingestellten Intervall (Kundeneigene Einstellung, siehe CAR.oel) ergibt + das ein Datum - gedeckelt auf spätestens das Intervall-Zeitlimit (z. B. 1 + Jahr) nach dem letzten Ölwechsel, falls seither wenig gefahren wurde. */ +function oelwechselPrognose() { + if (!CAR.odoBekannt) return null; + const letzter = letzterEintrag("öl"); + if (!letzter) return null; + const oel = CAR.oel; + const intervallKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm); + const intervallMonate = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate); + const zielKm = letzter.km + intervallKm; + const restKm = Math.max(0, zielKm - CAR.odo); + const kappDatum = monatePlus(letzter.datum, intervallMonate); + let datum; + if (restKm <= 0) { + datum = heute(); + } else { + const tageSeit = (heute() - dat(letzter.datum)) / dTag; + const kmSeit = CAR.odo - letzter.km; + if (tageSeit <= 0 || kmSeit <= 0) { + // Noch keine belastbare Fahrleistung seit dem Ölwechsel bekannt (z. B. + // direkt danach) - ohne Rate keine seriöse Km-Prognose, also das + // Zeitlimit als einzig belastbare Grenze zeigen. + datum = kappDatum; + } else { + const kmProTagSeitOel = kmSeit / tageSeit; + const tageBisZiel = restKm / kmProTagSeitOel; + const berechnet = new Date(heute().getTime() + tageBisZiel * dTag); + datum = berechnet < kappDatum ? berechnet : kappDatum; + } + } + return { km: zielKm, restKm, datum }; +} + +function terminText(t) { + const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null; + const restKmAbs = restKm !== null ? Math.abs(restKm) : null; + const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : dedat(t.datum); + const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum) + : (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Servicebuch fortgeschrieben")); + return { haupt, neben, restKm }; +} + +/* Zählt seit LETZTES_UPDATE_TS (echter Zeitpunkt des letzten Fahrzeugstatus- + Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet + dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24, + dann Tage. */ +function standAlterText() { + if (LETZTES_UPDATE_TS == null) return "unbekannt"; + const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_TS) / 1000)); + if (sekunden < 60) return `vor ${sekunden} Sek.`; + const minuten = Math.floor(sekunden / 60); + if (minuten < 60) return `vor ${minuten} Min.`; + const stunden = Math.floor(minuten / 60); + if (stunden < 24) return `vor ${stunden} Std.`; + return `vor ${Math.floor(stunden / 24)} Tage`; +} +function standAlterTicken() { + if (!ROOT || !CAR) return; + const sy = $("#sync span"); if (sy) sy.textContent = standAlterText(); +} + +function vHome() { + const g = CAR.gesichert; + const statusText = g === null ? "Status unbekannt" : g ? "Sicher abgestellt" : "Nicht sicher abgestellt"; + const statusDot = g === null ? "warn" : g ? "" : "bad"; + const pct = CAR.tankPct; + const balkenFarbe = pct <= 10 ? "var(--bad)" : pct <= 25 ? "var(--warn)" : "var(--ok)"; + const liter = CONFIG.tankvolumen ? Math.round((CONFIG.tankvolumen * pct) / 100) : null; + const t = naechsterTermin(); + /* C2/C5/D1: Reihenfolge der myAudi-App - randloses Fahrzeugbild, Name und + Status, große Reichweite mit farbigem Balken, dann Aktionskacheln. Die + Kacheln führen zu Ansichten statt zu Fernsteuerung, die das Panel bewusst + nicht kann. */ + return ` +
${carImg("fest")}
+
+ ${badge()} + ${esc(CONFIG.kennzeichen)} + +
+
+
+
${CAR.reichweite != null ? de(CAR.reichweite) : "–"}km
+ ${pct} % +
+
+ Reichweite${liter != null ? ` · noch etwa ${de(liter)} l im Tank` : ""} +
+
+ + + + +
+
+
Kilometerstand +
${CAR.odoBekannt ? de(CAR.odo) : "–"}km
+ ${(() => { if (!t) return `
Service
kein Eintrag
`; + const artLabel = t.art === "Hauptuntersuchung" ? "Haupt-
untersuchung" : esc(t.art); + if (t.art === "Ölwechsel") { + const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null; + const datum = t.kmDatum || t.datum; + const teile = []; + if (restKm !== null) teile.push(de(restKm / 1000, 1) + " tkm"); + if (datum) teile.push(mmjj(datum)); + return `
${artLabel} +
${teile.join("/ ")}
`; + } + const teileHU = []; + if (t.zuerst === "km") { + teileHU.push(de(t.km - CAR.odo) + " km"); + if (t.kmDatum) teileHU.push(mmjj(t.kmDatum)); + } else { + teileHU.push(mmjj(t.datum)); + if (t.km !== null && CAR.odoBekannt) teileHU.push(de(Math.abs(t.km - CAR.odo)) + " km"); + } + return `
${artLabel} +
${teileHU.join("/ ")}
`; })()} +
+ ${teaser()}`; +} + +function vSicherheit() { + const eintraege = CAR.sicherheitscheck; + const g = CAR.gesichert; + const offen = eintraege.filter((e) => e.ok === false).length; + const unbekannt = eintraege.filter((e) => e.ok === null).length; + const sammelText = g === null + ? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert` + : g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`; + /* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe + Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */ + return ` +
${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}
+
+ + ${esc(sammelText)} +
+
Geprüfte Punkte +
+ ${eintraege.length ? eintraege.map((e, i, arr) => `
+
${esc(e.label)}
+
+ + ${e.ok === null ? "unbekannt" : e.ok ? "in Ordnung" : "prüfen"} +
+
`).join("") : `
Keine Daten verfügbar
`} +
+
`; +} + +function vAudi() { + const r = CAR.reifen, a = r[r.aktiv.toLowerCase()]; + return ` +
${carImg("galerie")}
+
+ ${badge()} + ${esc(CONFIG.kennzeichen)} +
+ +
Zustand
+
Kilometerstand
${CAR.odoBekannt ? de(CAR.odo) + " km" : "unbekannt"}
+
Tankfüllung
${de(CONFIG.tankvolumen * CAR.tankPct / 100, 1)} l${CAR.tankPct} % von ${CONFIG.tankvolumen} l
+
Batteriespannung
+
${CAR.batteriespannung != null ? de(CAR.batteriespannung, 1) + " V" : "unbekannt"} +
+ + + `; +} + +/* ------------------------------------------------- Batteriespannungs-Verlauf + Handgebautes Diagramm ohne externe Bibliothek (kein Bundler im Projekt, + siehe INSTALL.md) - Zoom per Zwei-Finger-Pinch/Mausrad, Verschieben per + Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt. + bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit + Zoom/Pan flüssig bleiben. */ +let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null; +const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger) +let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste} +let bvTapKandidat = null, bvBewegtSeit = false; +const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22; +const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" }); + +/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei + aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle + AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V, + 50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch + entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - + deutlich über dem Ruhespannungs-Maximum. + + Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf, + ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive + Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet, + und zwar auf den Tagesminimalwert: der fällt typischerweise auf den + Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit + die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das + Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet), + tauchen entsprechend nicht in der Ladezustands-Statistik auf. */ +const AGM_RUHE_MAX_V = 13.2; +const BV_SOC_TABELLE = [ + [10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40], + [12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100], +]; +function bvSocProzent(v) { + const t = BV_SOC_TABELLE; + if (v <= t[0][0]) return 0; + if (v >= t[t.length - 1][0]) return 100; + for (let i = 0; i < t.length - 1; i++) { + const [v0, s0] = t[i], [v1, s1] = t[i + 1]; + if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0)); + } + return 100; +} +function bvSocLabel(soc) { + return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"; +} +// Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei +// Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) - +// dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok), +// damit die Skala zum Rest der App passt statt eigene Farben zu erfinden. +function bvHexZuRgb(hex) { + const h = hex.trim().replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} +function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); } +function bvFarbstufen() { + const stil = getComputedStyle(HOST); + return { + bad: bvHexZuRgb(stil.getPropertyValue("--bad")), + warn: bvHexZuRgb(stil.getPropertyValue("--warn")), + ok: bvHexZuRgb(stil.getPropertyValue("--ok")), + }; +} +function bvFarbe(v, stufen) { + let rgb; + if (v <= 11.6) rgb = stufen.bad; + else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6)); + else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2)); + else rgb = stufen.ok; + return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; +} + +/* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine + Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe + Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of- + health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt, + nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase + der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen + eingeordnet (übliche Lebensdauer 4-6 Jahre). */ +function bvSohBewertung() { + const TAG_MS = 86400000; + if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." }; + const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS; + if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." }; + const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS; + const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs); + const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs); + const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length; + const delta = neuD - altD; + const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad"; + const text = delta >= -0.05 + ? "Stabil - kein auffälliger Rückgang der Ruhespannung." + : delta >= -0.15 + ? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen." + : "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten."; + return { status, text, delta }; +} + +function vBatterieverlauf() { + // Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem + // Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der + // echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver + // Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. + bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V) + .map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum })) + .sort((a, b) => a.t - b.t); + bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null; + + const genug = bvPunkte.length >= 2; + bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null; + bvDomain = genug ? [...bvVollDomain] : null; + + const stufen = bvFarbstufen(); + const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null; + const soc = letzter ? bvSocProzent(letzter.v) : null; + const soh = bvSohBewertung(); + const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)"); + const leerText = BATTERIEVERLAUF.length === 0 + ? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist." + : "Noch zu wenige Messwerte für einen Verlauf."; + + return ` +
+ Verlauf + ${genug ? ` +
+ +
+
+ + +
+ + Ziehen zum Verschieben, mit zwei Fingern oder dem Mausrad + zoomen, auf einen Punkt tippen für Datum, Uhrzeit sowie Min-/Max-Spannung + ` : ` + ${leerText} + `} + Farbskala Ladezustand (AGM, Ruhespannung) +
+
+ 11,6 V · leer + 12,2 V · 50 % + 12,8 V · voll +
+
+
+ Bewertung +
+
Ladezustand (SOC)
+
+ ${letzter + ? `${de(letzter.v, 1)} V · ${soc} % · ${bvSocLabel(soc)}` + : `unbekannt`}
+
Gesundheitszustand (SOH-Trend)
+
+ ${soh.status + ? `${(soh.delta >= 0 ? "+" : "") + de(soh.delta, 2)} V` + : `-`}
+
+ ${soh.text} + Näherung auf Basis der täglich niedrigsten gemessenen Spannung (aussagekräftig für die + Entladung), keine Kapazitätsmessung - Tageswerte über ${de(AGM_RUHE_MAX_V, 1)} V (Ladevorgang während der Fahrt) fließen nicht ein. + Für eine verlässliche Aussage ist ein Lasttest an der Batterie nötig. +
`; +} + +function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); } +function bvSkalaY(v, yMin, yMax) { return BV_H - BV_MB - ((v - yMin) / (yMax - yMin)) * (BV_H - BV_MT - BV_MB); } +function bvDomainKlemmen(domain) { + let [a, b] = domain; + const volle = bvVollDomain[1] - bvVollDomain[0]; + let breite = Math.min(b - a, volle); + breite = Math.max(breite, volle * 0.02); + if (a < bvVollDomain[0]) { a = bvVollDomain[0]; b = a + breite; } + if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; } + return [a, b]; +} +function bvPlotRahmen(rect) { + return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) }; +} + +function bvZeichnen() { + const svg = $("#bvSvg"); + if (!svg || !bvDomain) return; + const sichtbar = bvPunkte.filter((p) => p.t >= bvDomain[0] && p.t <= bvDomain[1]); + const basis = sichtbar.length >= 2 ? sichtbar : bvPunkte; + let yMin = Math.min(...basis.map((p) => p.v)), yMax = Math.max(...basis.map((p) => p.v)); + if (yMax - yMin < 0.4) { const mitte = (yMax + yMin) / 2; yMin = mitte - 0.2; yMax = mitte + 0.2; } + const pad = (yMax - yMin) * 0.15; yMin -= pad; yMax += pad; + + // etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am + // Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt + // weiterläuft + const spanne = bvDomain[1] - bvDomain[0]; + const rahmen = bvPunkte.filter((p) => p.t >= bvDomain[0] - spanne * 0.05 && p.t <= bvDomain[1] + spanne * 0.05); + const linie = rahmen.map((p) => `${bvSkalaX(p.t).toFixed(1)},${bvSkalaY(p.v, yMin, yMax).toFixed(1)}`).join(" "); + + const gitter = [0, 1, 2, 3].map((i) => { + const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax); + return ` + ${de(v, 1)}`; + }).join(""); + const xLabels = [0, 0.5, 1].map((f) => { + const x = BV_ML + f * (BV_W - BV_ML - BV_MR); + const t = bvDomain[0] + f * spanne; + return `${kurzdat(new Date(t))}`; + }).join(""); + const stufen = bvFarbstufen(); + const kreise = rahmen.map((p) => { + const idx = bvPunkte.indexOf(p); + const gewaehlt = idx === bvAusgewaehlt; + return ``; + }).join(""); + + svg.innerHTML = `${gitter}${kreise}${xLabels}`; + + const rangeEl = $("#bvRange"); + if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`; + const tip = $("#bvTooltip"); + if (tip) { + if (bvAusgewaehlt != null) { + const p = bvPunkte[bvAusgewaehlt]; + const zeit = new Date(p.minTs); + tip.style.display = "block"; + tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`; + } else { + tip.style.display = "none"; + } + } +} + +function bvNaechsterPunkt(clientX) { + const svg = $("#bvSvg"); if (!svg) return null; + const rect = svg.getBoundingClientRect(); + const zeit = bvDomain[0] + ((clientX - bvPlotRahmen(rect).linksPx) / bvPlotRahmen(rect).breitePx) * (bvDomain[1] - bvDomain[0]); + let beste = null, besterAbstand = Infinity; + bvPunkte.forEach((p, i) => { const a = Math.abs(p.t - zeit); if (a < besterAbstand) { besterAbstand = a; beste = i; } }); + return beste; +} + +let svAuswahl = 0; +function vService() { + const sv = CAR.service; + return ` +
Anstehende Termine +
+ ${termine().map((t, i, arr) => { + const letzte = i === arr.length - 1 ? 'style="border-bottom:0"' : ""; + const iv = intervalle(t); + if (!iv) return `
+
${t.art}
kein Eintrag im Servicebuch
`; + const anzeige = iv.hersteller || iv.eigen; + const fm = fahrzeugMeldungRoh(t.art); + const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null; + // Forecast-Zeile nur bei Ölwechsel (Kundeneigenes Intervall aus + // "Einrichten", eigenständige Reset-nach-Service-Logik, siehe + // oelwechselPrognose()) - bei Inspektion und Hauptuntersuchung + // bewusst keine Forecast-Zeile mehr. + const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null; + return `
+
${t.art} + ${t.vereinbart ? `
Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""} +
+
+ ${iv.hersteller ? `Herstellervorgabe` : ""} + ${fm.km != null || fmDatum ? ` + ${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""} + ${eigenOel ? `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}` : ""} + ` : ` + ${eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)} + `} +
+
`; }).join("")} +
+
+
Termin vereinbaren + ${(() => { const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; + const vorschlag = CAR.service.vereinbart[t.art] || (t.ziel ? t.ziel.toISOString().slice(0, 10) : ""); + return ` +
+
+
+
+ + ${CAR.service.vereinbart[t.art] ? `` : ""}`; })()} +
+
+
Servicebuch + ${sv.buch.length ? sv.buch.map((e, i) => swipeZeile(`sbuch:${i}`, ``)).join("") : `
+ Noch keine Einträge +

Aus dem Servicebuch berechnet die App die Fälligkeit von Inspektion, Ölwechsel und Hauptuntersuchung. Der erste Eintrag ist die Grundlage für jede Prognose.

+
`} + +
`; +} + +function vIdent() { + return ` +
Fahrzeug +
+
Modell
${esc(CONFIG.fahrzeugtitel)}
+
Ausführung
${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}
+
Fahrgestellnummer
${esc(CAR.fin)}
+
Motor und Leistung
${esc(CAR.details)}
+
Erstzulassung
${esc(CAR.erstzulassung)}
+
Kennzeichen
${esc(CONFIG.kennzeichen)}
+
Zulassung
Deutschland
+
+ ${CAR.technik.map((g) => `
${esc(g.gruppe)} +
+ ${g.posten.map((pp, i) => `
+
${esc(pp[0])}
+
${pp[1] ? esc(pp[1]) : "noch offen"}
`).join("")} +
`).join("")} + ${CAR.ausstattung.map((g) => `
${esc(g.gruppe)} +
+ ${g.sonder.map((x, i) => `
+
${esc(x)}
`).join("")} +
`).join("")} + `; +} + +function monatePlus(datum, monate) { const d = dat(datum); d.setMonth(d.getMonth() + monate); return d; } +function letzterEintrag(stichwort) { return CAR.service.buch.find((e) => e.art.toLowerCase().includes(stichwort)) || null; } +function termine() { + const oel = CAR.oel; + const oelKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm); + const oelM = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate); + const bauen = (art, basis, kmIntervall, monate, ersatzDatum) => { + const kmZiel = basis && kmIntervall ? basis.km + kmIntervall : null; + const datZiel = basis ? monatePlus(basis.datum, monate) : (ersatzDatum ? monatePlus(ersatzDatum, monate) : null); + const kmDatum = kmZiel !== null ? prognose(kmZiel).datum : null; + const zuerst = (kmDatum && datZiel) ? (kmDatum < datZiel ? "km" : "zeit") : (kmDatum ? "km" : "zeit"); + return { art, km: kmZiel, datum: datZiel, kmDatum, zuerst, basis, + ziel: zuerst === "km" ? kmDatum : datZiel, vereinbart: CAR.service.vereinbart[art] || null }; + }; + const oelTermin = bauen("Ölwechsel", letzterEintrag("öl"), oelKm, oelM); + // Genauere, eigenständige Ölwechsel-Prognose (siehe oelwechselPrognose()) + // ersetzt hier den km-Anteil aus dem generischen bauen() oben, damit + // Übersicht, Service-Kachel und Anstehende Termine überall dieselbe Zahl + // für "wann ist der nächste Ölwechsel fällig" zeigen statt drei leicht + // unterschiedlicher Werte. + const oelProg = oelTermin.basis ? oelwechselPrognose() : null; + if (oelProg) { + oelTermin.km = oelProg.km; + oelTermin.kmDatum = oelProg.datum; + oelTermin.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km"; + oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum; + } + return [ + oelTermin, + bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24), + bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24, + CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null), + ]; +} + +function vWerkstatt() { + const a = CAR.service.autohaus; + return ` +
Angaben zum Autohaus +
+
+
+
+
+
`; +} + +function vSbuch() { + const i = parseInt(route.id, 10), e = CAR.service.buch[i]; + if (!e) return `
Eintrag nicht gefunden
`; + const istEntwurf = entwurfIndex === i; + const artBekannt = SERVICE_ARTEN.includes(e.art); + return ` +
${esc(e.art)} +
${de(e.km)}km
+ ${e.datum} · ${esc(e.werkstatt)} +
+
Eintrag +
+
+ km
+
+
+
+
+
+ Kosten fließen in die Jahresübersicht ein +
+
Durchgeführte Arbeiten + + Beim Verkauf ist diese Liste mehr wert als die Rechnung — + sie zeigt, dass gepflegt wurde +
+ ${istEntwurf ? `` + : ``}`; +} + +function vVers() { + const v = CAR.versicherung; + return ` +
Versicherung - Schaden Notruf 24/7 + + + Inland · ${esc(v.service)} + + Ausland · ${esc(v.notfall)} +
+
Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))} + +
${eur(v.beitrag)}
+ ${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} € Versicherungsteuer (${esc(v.steuersatz)}) +
+ ${v.teile.map((t, i) => `
+
${esc(t.bez)}${t.sf ? `
${esc(t.sf)} · Beitragssatz ${esc(t.satz)}
` : ""} +
${eur(t.betrag)} €${v.beitrag ? de(t.betrag / v.beitrag * 100, 0) : 0} %
`).join("")} +
+
+ ${v.teile.map((t, i) => ``).join("")} +
+
+
Vertrag +
+
Gesellschaft
${esc(v.gesellschaft)}
+
Umfang
${esc(v.vertrag)}
+
Vertragsnummer
${esc(v.nummer)}
+ ${v.selbst.map(([b, w]) => `
Selbstbeteiligung ${esc(b)}
${esc(w)}
`).join("")} +
Schadenfreiheitsklasse
+
${v.teile.map((t) => t.sf).filter(Boolean).join(" / ")}zuvor ${v.teile.map((t) => t.sfAlt).filter(Boolean).join(" / ")}
+
+ +
+ ${vSteuer()}`; +} + +function vNotrufBearbeiten() { + const v = CAR.versicherung; + return ` +
Rufnummern ändern +
+
+
+
+
`; +} + +function vBeitrag() { + const v = CAR.versicherung; + return ` +
Beitrag anpassen +
+
+
+
+
+
+
+
+
+
+
+
+ Der Jahresbeitrag oben ist die Summe dieser drei Posten und + rechnet sich beim Speichern automatisch neu — "Gültig ab" steuert den Titel der Jahresbeitrag-Kachel +
`; +} + +function vVertragsdetails() { + const v = CAR.versicherung; + return ` + + ${v.leistungen.map((g) => `
${esc(g.gruppe)} +
+ ${g.posten.map((x, i) => `
+
${esc(x)}
`).join("")} +
`).join("")}`; +} + +function vSchutz() { + const v = CAR.versicherung, sb = v.schutzbrief; + return ` +
Die Entscheidung am Schadenort +
+ Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf die + anderen beiden.
+
+ ${sb.entweder.map((x, i) => `
+
${esc(x.bez)}
+
${esc(x.grenze)}${x.zusatz ? `${esc(x.zusatz)}` : ""}
`).join("")} +
+ Mobilität wird binnen 60 Minuten organisiert +
+
Wann der Schutzbrief greift +
+ ${sb.ausloeser.map((a, i) => `
+
${esc(a)}
`).join("")} +
+ Gilt in ${esc(sb.gilt)} + Versichert: ${esc(sb.versichert)} +
+ ${sb.gruppen.map((g) => `
${esc(g.titel)} +
+ ${g.posten.map((pp, i) => `
+
${esc(pp[0])}${pp[2] ? `
${esc(pp[2])}` : ""}
+
${esc(pp[1])}
`).join("")} +
`).join("")} +
Nicht versichert +
+ ${sb.nicht.map((n, i) => `
+
${esc(n[0])}
+ ${esc(n[1])}
`).join("")} +
+
Deine Pflichten +
+ ${sb.pflichten.map((x, i) => `
+
${esc(x)}
`).join("")} +
+
${esc(sb.rang)}
+ ${esc(sb.stand)} +
`; +} + +function vSteuer() { + const t = CAR.steuer; + return ` +
Kfz-Steuer + +
+
Betrag
${eur(t.betrag)} €${esc(t.zeitraum)}
+
Nächste Fälligkeit
+
${t.faellig ? dedat(new Date(t.faellig)) : "unbekannt"}
+
+
`; +} + +function vSteuerBearbeiten() { + const t = CAR.steuer; + return ` +
Kfz-Steuer +
+
+
+
+
+
+
+
+
`; +} + +/* B5: Die Wahl gilt dauerhaft, nicht nur bis zum Reload - sie wird beim Aufbau + der Menüleiste wieder angewendet. */ +const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift"; +let beschriftung = (() => { + try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; } +})(); +let bodenAn = true; +function csvDatei(name, kopf, zeilen) { + const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n"); + const a = document.createElement("a"); + a.href = "data:text/csv;charset=utf-8," + encodeURIComponent("" + txt); + a.download = name; document.body.appendChild(a); a.click(); a.remove(); +} + +function vEinst() { + return ` +
Fahrzeug einrichten + + Fahrgestellnummer, Kennzeichen, Erstzulassung, Ausführung und Ölwechsel-Intervall — bei der + Ersteinrichtung oder wenn sich diese Angaben ändern. + ${einrichtenOffen ? ` +
+
+
+
+
+
+
+
+
+
+
+
+ Ölwechsel-Intervall +
+ + +
+ ${CAR.oel.modus === "hersteller" + ? `
+
Laufleistung
${de(CAR.oel.herstellerKm)} km
+
Zeit
${CAR.oel.herstellerMonate} Monate
+
+ Aus dem Fahrzeug gelesen` + : `
+
+
+
`} + ` : ""} + +
+
Darstellung +
+ + +
+
+ +
+ Aus zeigt nur die Symbole und gibt etwas Höhe frei +
+ +
+ Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten +
+
Bild der Übersicht +
+
+
${(() => { const info = bildInfo(startIndex()); return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix"); })()}
+
+
Fahrzeugbilder + + Auf ein Bild tippen, um ein eigenes Foto hochzuladen oder zu ersetzen. +
+ ${BILDER_UPLOAD_SLOTS.map((s) => ` +
+
+ ${bildMitPlatzhalter(`/local/bilder/${s.datei}?v=${bildVersion}`, s.datei, s.label, "carfix mini", null, true)} +
+ + ${esc(s.name)} + ${bildMenuOffen === s.datei ? ` +
+
+ + +
` : ""} +
`).join("")} +
+
+
Kraftstoff-Rabatt +
+
+ ${CAR.smartdeal.aktiv ? ` +
+ ${dedat(new Date(CAR.smartdeal.laeuftAb))}
` : + `Ersparnis und Bildmarke werden ausgeblendet`} + ${smartdealPopupOffen ? ` +
+
+
+
+
+ + +
+
` : ""} +
+
Fahrten zusammenfassen +
+ Minuten
+ Steigst du kürzer als diese Zeit aus — etwa zum Tanken —, + bleibt es eine zusammenhängende Fahrt statt zweier +
+
Fahrzeugprofil + + Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, + Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur + diese Datei getauscht — die App selbst bleibt unverändert. + + +
+
Backup + + Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" legt eine + Kopie auf dem Server ab, "Exportieren"/"Importieren" übertragen den aktuellen + Gesamtstand als einzelne Datei. + ${CONFIG.letztesBackup ? `Letztes Backup: + ${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : ""} + +
+
+ + +
+
Daten ausgeben + + Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in + Tabellenprogrammen aufgehen. + + + +
+
Version +
+
Dashboard
${esc(CONFIG.version)}
+
Fahrzeugdaten
EU Data ActAbruf alle 15 Minuten
+
Position
iPhoneCompanion App
+
+ ${(() => { + const s = UPDATE_STATUS; + if (!s || s.stand === "keine_quelle") return ` + ${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"} + `; + if (s.stand === "fehler") return ` + ${esc(s.fehler || "Update-Prüfung fehlgeschlagen")} + `; + if (s.stand === "geprueft" && s.verfuegbar) return ` + Update verfügbar — Version ${esc(s.neue_version)} + `; + return ` + Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""} + `; + })()} +
`; +} + +let nmOffen = false; +let einrichtenOffen = false; +let bildMenuOffen = null; +let smartdealPopupOffen = false; +function vReifen() { + const r = CAR.reifen; + const feld = (k, schluessel, bez, typ) => { + const t = r[k]; + return `
+
`; + }; + const satz = (k) => { + const t = r[k], aktiv = r.aktiv.toLowerCase() === k; + const radDatei = `rad-${k}.webp`; + const radLabel = k === "sommer" ? "Sommerrad" : "Winterrad"; + return `
+
+ ${bildMitPlatzhalter(`/local/bilder/${radDatei}?v=${bildVersion}`, radDatei, radLabel, "radbild")} +
+ + ${bildMenuOffen === radDatei ? ` +
+
+ + +
` : ""} +
+ ${k === "sommer" ? "Sommer" : "Winter"} +
${t.km != null ? de(t.km) : "–"}km
+
+
+ +
+
+ ${feld(k, "marke", "Hersteller")} + ${feld(k, "modell", "Modell")} + ${feld(k, "mass", "Größe")} + ${feld(k, "dot", "DOT")} +
+ + + / + + bar + +
+ ${feld(k, "kommentar", "Kommentar")} +
+
`; + }; + return ` +
Anzugsmoment der Radschrauben + +
${de(r.anzugsmoment)}Nm
+ ${nmOffen ? ` +
+
+ Nm
+ + +
` : ""} +
+
Wechseltermin +
+
+
+
+ +
+ ${satz("sommer")} + ${satz("winter")}`; +} + +function vTripsFormular() { + // eigene Funktion, damit sowohl der volle als auch der Leer-Zustand + // (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe + // Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf + // wechselte zu "Abbrechen", aber es erschienen keine Felder. + return `${fahrtOffen ? `
Neue Fahrt +
+
+
+
` : ""} + `; +} +function vTrips() { + const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a); + /* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn + das Telefon einmal im Fahrzeug-WLAN war. */ + if (!jahre.length) return `
+ Noch keine Fahrten +

Fahrten werden erkannt, sobald sich das Telefon mit dem WLAN des Fahrzeugs verbindet (${esc(CONFIG.wlanName || "noch nicht eingerichtet")}). Bis dahin lässt sich jede Fahrt von Hand erfassen.

+
+ ${vTripsFormular()}`; + let h = `
`; + jahre.forEach((j, ji) => { + const tj = TRIPS.filter((t) => new Date(t.ts_start).getFullYear() === j); + h += ` +
`; + const monate = [...new Set(tj.map((t) => new Date(t.ts_start).toLocaleDateString("de-DE", { month: "long" })))]; + monate.forEach((m, mi) => { + const tm = tj.filter((t) => new Date(t.ts_start).toLocaleDateString("de-DE", { month: "long" }) === m); + h += `
+
`; + tm.forEach((t) => { h += swipeZeile(`trip:${t.trip_id}`, ``); }); + h += `
`; + }); + h += `
`; + }); + return h + `
+ ${vTripsFormular()}`; +} + +function vTrip(id) { + const t = TRIPS.find((x) => x.trip_id === id); + if (!t) return `
Fahrt nicht gefunden
`; + return ` + ${mapBox()} +
+ Distanz +
${t.distance_km != null ? de(t.distance_km) : "–"}km
+
+
+ tippen zum Umschalten +
+
+
Startort
${esc(t.start_address || "unbekannt")}${dedat(new Date(t.ts_start))}
+
Zielort
${esc(t.end_address || "unbekannt")}
+
Startkilometer
${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}
+
Endkilometer
${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}
+
Dauer
${Math.round(t.duration_s / 60)} min
+
Verbrauch
${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}vom Fahrzeug gemeldet
+
Status
${t.status}Quelle: ${t.km_quelle || "–"}
+
+ `; +} + +function quad(t, v) { return `${t} +
${v.map((x) => `
${x[0]}
${x[1]}
`).join("")}
`; } +function statTile(id, label, fig, unit, extra, body) { + return `
${body}
`; +} +/* Echte Auswertung aus TRIPS/FILLS statt Beispielwerten. Nur abgeschlossene + Fahrten/Tankvorgänge zählen (distance_km bzw. liters gesetzt) - offene/ + unvollständige Einträge haben noch keinen belastbaren Wert. + Der Verbrauch ist eine Näherung (getankte Menge im Zeitraum ÷ gefahrene + km im selben Zeitraum), da einzelne Fahrten keinen eigenen Verbrauchswert + tragen - erst über einen Zeitraum gemittelt ergibt das einen sinnvollen + l/100km-Wert. */ +function periodStart(art) { + const jetzt = new Date(); + if (art === "jahr") return new Date(jetzt.getFullYear(), 0, 1); + if (art === "monat") return new Date(jetzt.getFullYear(), jetzt.getMonth(), 1); + if (art === "woche") { + const d = new Date(jetzt); d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); + return d; + } + const d = new Date(jetzt); d.setHours(0, 0, 0, 0); return d; +} +const fahrtenSeit = (art) => TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) >= periodStart(art)); +const tankSeit = (art) => FILLS.filter((f) => f.liters != null && new Date(f.ts) >= periodStart(art)); +function istNachtZeit(ts) { + const z = new Date(ts).toTimeString().slice(0, 5), von = CONFIG.nachtVon, bis = CONFIG.nachtBis; + return von > bis ? (z >= von || z < bis) : (z >= von && z < bis); +} +/* D3: Die Strecke führt die Zeile, das Datum steht darunter. Umbruch nur + zwischen den Orten, nie am Pfeil. */ +function strecke(t) { + const a = t.start_address, b = t.end_address; + if (!a && !b) return "Fahrt ohne Ortsangabe"; + if (!b) return esc(a); + if (!a) return esc(b); + return `${esc(a)} → ${esc(b)}`; +} + +/* A1.1: Der Verbrauch einer einzelnen Fahrt kommt vom Fahrzeug (OBD) und wird + vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */ +const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null); +/* Mittelwert der gemeldeten Fahrtverbräuche, nach Distanz gewichtet. */ +function schnittFahrtVerbrauch(list) { + const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0); + const km = sum(mit, (t) => t.distance_km); + return km > 0 ? de(sum(mit, (t) => fahrtVerbrauch(t) * t.distance_km) / km, 1) : "–"; +} +/* A1.2: Langzeitverbrauch = alle je getankten Liter geteilt durch alle bis zum + letzten Tankstop gefahrenen Kilometer. Die frühere Rechnung (Liter der + Periode ÷ km der Periode) ergab unmögliche Werte, sobald eine Fahrt fehlte + oder ein Tankvorgang noch unvollständig war. */ +function langzeitverbrauch() { + const tanks = FILLS.filter((f) => f.liters != null).sort((a, b) => new Date(a.ts) - new Date(b.ts)); + if (!tanks.length) return { wert: "–", km: 0, liter: 0, bis: null, basis: "–" }; + const letzter = tanks[tanks.length - 1]; + const bis = new Date(letzter.ts); + const liter = sum(tanks, (f) => f.liters); + /* Kilometerbasis: bevorzugt der Kilometerstand an den Tankvorgängen selbst - + er zählt auch Strecke mit, für die keine Fahrt aufgezeichnet wurde. Fehlt + er, bleibt die Summe der erfassten Fahrten bis zum letzten Tankstop. */ + const mitOdo = tanks.filter((f) => f.odometer_km != null); + let km = 0, basis = "erfasste Fahrten"; + if (mitOdo.length >= 2) { + km = mitOdo[mitOdo.length - 1].odometer_km - mitOdo[0].odometer_km; + basis = "Kilometerstand an den Tankvorgängen"; + } else { + km = sum(TRIPS.filter((t) => t.distance_km != null && new Date(t.ts_start) <= bis), (t) => t.distance_km); + } + const roh = km > 0 ? (liter / km) * 100 : null; + /* Plausibilitätsfenster: alles außerhalb gilt als unbekannt, statt einen + physikalisch unmöglichen Wert als Tatsache anzuzeigen. */ + const gueltig = roh != null && roh >= 3 && roh <= 30; + return { wert: gueltig ? de(roh, 1) : "–", km, liter, bis, basis, unplausibel: roh != null && !gueltig }; +} +function vStat() { + const jahr = new Date().getFullYear(); + const fJahr = fahrtenSeit("jahr"), fMonat = fahrtenSeit("monat"), fWoche = fahrtenSeit("woche"), fTag = fahrtenSeit("tag"); + const kmJahr = sum(fJahr, (t) => t.distance_km), hJahr = sum(fJahr, (t) => t.duration_s) / 3600; + const tJahr = tankSeit("jahr"); + const lJahr = sum(tJahr, (f) => f.liters), eJahr = sum(tJahr, (f) => f.fuel_total_eur || 0); + const tagKm = sum(fJahr.filter((t) => !istNachtZeit(t.ts_start)), (t) => t.distance_km); + const nachtKm = kmJahr - tagKm; + const arbeitFahrten = fJahr.filter((t) => t.art === "arbeitsweg"), privatFahrten = fJahr.filter((t) => t.art !== "arbeitsweg"); + const arbeitKm = sum(arbeitFahrten, (t) => t.distance_km), privatKm = kmJahr - arbeitKm; + const lv = langzeitverbrauch(); + return ` + ${statTile("s1", `Distanz und Fahrzeit ${jahr}`, de(kmJahr), "km", de(hJahr) + " h", + quad("Gefahrene Distanz", [[de(kmJahr), "Jahr"], [de(sum(fMonat, (t) => t.distance_km)), "Monat"], [de(sum(fWoche, (t) => t.distance_km)), "Woche"], [de(sum(fTag, (t) => t.distance_km)), "Tag"]]) + + quad("Fahrzeit", [[de(hJahr), "Jahr h"], [de(sum(fMonat, (t) => t.duration_s) / 3600), "Monat h"], [de(sum(fWoche, (t) => t.duration_s) / 3600, 1), "Woche h"], [de(sum(fTag, (t) => t.duration_s) / 3600, 1), "Tag h"]]))} + ${statTile("s2", `Getankte Menge und Kosten ${jahr}`, de(lJahr), "l", de(eJahr) + " €", + quad("Getankte Liter", [[de(lJahr), "Jahr"], [de(sum(tankSeit("monat"), (f) => f.liters)), "Monat"], [de(sum(tankSeit("woche"), (f) => f.liters)), "Woche"], [de(sum(tankSeit("tag"), (f) => f.liters)), "Tag"]]) + + quad("Kraftstoffkosten", [[de(eJahr), "Jahr €"], [de(sum(tankSeit("monat"), (f) => f.fuel_total_eur || 0)), "Monat €"], [de(sum(tankSeit("woche"), (f) => f.fuel_total_eur || 0)), "Woche €"], [de(sum(tankSeit("tag"), (f) => f.fuel_total_eur || 0)), "Tag €"]]))} + ${statTile("s3", "Langzeitverbrauch", lv.wert, "l/100 km", lv.bis ? "bis " + dedat(lv.bis) : "–", + `
+
Getankt insgesamt
${de(lv.liter, 1)} l
+
Strecke bis zum letzten Tankstop
${de(lv.km)} km${lv.basis}
+
Letzter Tankstop
${lv.bis ? dedat(lv.bis) : "–"}
+
+ ${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""} + ${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])} + Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.`)} + ${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km", + `
+
Tag · ${de(tagKm)} km + Nacht · ${de(nachtKm)} km
+ Nacht = ${CONFIG.nachtVon} bis ${CONFIG.nachtBis} Uhr`)} + ${statTile("s5", "Art der Fahrten", kmJahr > 0 ? de(Math.round((arbeitKm / kmJahr) * 100)) : "–", "% Arbeitsweg", de(arbeitKm) + " km", + `
+
Arbeitsweg · ${de(arbeitKm)} km + Privat · ${de(privatKm)} km
+
+
Fahrten Arbeitsweg
${de(arbeitFahrten.length)}
+
Fahrten privat
${de(privatFahrten.length)}
`)}`; +} + +let neuOffen = false; +let fahrtOffen = false; +let tankBearbeitenOffen = false; +// Ergebnis eines geparsten, noch nicht gespeicherten Belegs: { praefix, daten } +// - überlagert in tankFelder() die Beleg-Felder des jeweils offenen Formulars. +let belegEntwurf = null; +// Fehlermeldung aus einem Beleg-Upload (z. B. "Falsches Belegdatum"): +// { context, meldung } - context ist "neuTank"/"bearbTank" (Entwurf) oder +// eine tank_id (Einzelbeleg-Ansicht), je nachdem welcher Beleg-Button es war. +let belegFehler = null; +// Merkt sich beim Auslösen eines nur-parsenden Uploads (neu/bearb), welcher +// Entwurf auf das Ergebnis wartet - die Antwort selbst trägt dafür keine +// praefix-Information, nur tank_id (bei einem Parse-Ergebnis immer null). +let belegZielPraefix = null; + +/* Wischen nach links zum Löschen (Fahrten/Tankvorgänge/Servicebuch), analog + zu iOS-Listen. swipeZeile() umschließt eine bestehende Listenzeile mit + einem roten "Löschen"-Hintergrund; die eigentliche Wischgeste wird über + Pointer Events in ereignisseVerdrahten() ausgewertet (nicht Touch Events), + damit dieselbe Logik auch mit der Maus funktioniert. id folgt demselben + "art:id"-Schema wie data-go, siehe eintragLoeschen(). */ +let swipeAktivWrap = null, swipeStartX = 0, swipeStartY = 0, swipeGeste = null, swipeDX = 0; +let swipeWarGezogen = false; +const SWIPE_BREITE = 84; + +/* Ziehen zum Aktualisieren (nur Übersicht) - ptrY0 ist die Y-Position des + Fingers/der Maus beim Start, null wenn gerade keine Geste läuft. Eigene + Variablen statt der swipe*-Variablen oben, da beide Gesten unabhängig + voneinander gleichzeitig "angefasst" werden können (Pointerdown auf einer + Listenzeile UND ganz oben gescrollt). */ +let ptrY0 = null, ptrDrag = 0, ptrLaedt = false; +async function datenAktualisieren() { + try { + await HASS.callService("pyscript", "audi_dashboard_jetzt_aktualisieren", {}); + const states = await HASS.callWS({ type: "get_states" }); + const frisch = states.find((s) => s.entity_id === "pyscript.audi_dashboard_fahrzeugstatus"); + if (frisch) NACHGELADEN.status = frisch; + } catch (e) { + // bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt + // holt den Stand ohnehin nach + } + datenLaden(true); +} +function schliesseAlleSwipes() { + ROOT.querySelectorAll(".swipe-wrap.swiped").forEach((w) => w.classList.remove("swiped")); +} +function swipeZeile(id, inhaltHtml) { + return `
+
+
${inhaltHtml}
+
`; +} +const LOESCH_TEXT = { + trip: "Fahrt löschen?", + fill: "Tankvorgang löschen?", + sbuch: "Servicebuch-Eintrag löschen?", + bild: "Foto löschen?", +}; +const LOESCH_HINWEIS = { + trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.", + fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.", + sbuch: "Der Eintrag wird aus dem Servicebuch entfernt. Das lässt sich nicht widerrufen.", + bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.", +}; +function eintragLoeschen(art, id) { + if (art === "trip") { + serviceRufen("audi_dashboard_fahrt_loeschen", { trip_id: id }); + TRIPS = TRIPS.filter((t) => t.trip_id !== id); + } else if (art === "fill") { + serviceRufen("audi_dashboard_tankvorgang_loeschen", { tank_id: id }); + FILLS = FILLS.filter((f) => f.tank_id !== id); + } else if (art === "sbuch") { + CAR.service.buch.splice(parseInt(id, 10), 1); + profilSpeichern(); + } +} + +/* Index eines gerade per "Eintrag hinzufügen" angelegten, aber noch nicht + bestätigten Servicebuch-Eintrags. Nur solange gesetzt, wie die Seite + offen ist und nicht gespeichert wurde - siehe data-sbneu/data-sbspeichern + und den Verwerfen-Schritt in go(). */ +let entwurfIndex = null; +const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"]; + +/* Update-Funktion (Einstellungen -> "Update suchen", siehe + updateverwaltung.py). HASS.states wird nach einem Service-Aufruf nicht + zuverlässig sofort nachgeführt (derselbe Effekt wie beim Nachlade- + Fallback oben) - deshalb der frische Stand aktiv per WebSocket geholt statt + passiv auf den nächsten hass-Push zu warten. */ +let UPDATE_STATUS = null; +let UPDATE_STATUS_GEHOLT = false; +let updatePruefend = false; +let updateInstallierend = false; +async function updateStatusAbrufen() { + try { + const states = await HASS.callWS({ type: "get_states" }); + const s = states.find((x) => x.entity_id === "pyscript.audi_dashboard_update_status"); + UPDATE_STATUS = (s && s.attributes && s.attributes.daten) || null; + } catch (e) { + // zeigt dann weiterhin den zuletzt bekannten Stand + } +} + +/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel + (Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den + Schriftzug unter dem Bild auf der Übersicht). Zusatz, Untertitel, Details + und die technischen Daten hängen bewusst nicht daran - die bleiben, auch + bei einem anderen Modellnamen, wie zuletzt im Profil eingetragen. + Beschränkt auf die Modelle, zu denen ein echtes Badge vorliegt (siehe + BADGE_DATEIEN/www/badges/) - für alles andere gäbe es sonst ein falsches + oder gar kein Badge auf der Übersicht. */ +const FAHRZEUG_MODELLE = [ + "RS 3 Sportback", "RS 3 Limousine", "RS 4 Avant", + "RS 5 Coupé", "RS 5 Sportback", "RS 6 Avant", "RS 6 Avant performance", + "RS 7 Sportback", "S3 Sportback", "S3 Limousine", "SQ7", +]; + +/* Badge-Zuordnung (www/badges/-positive.svg / -negative.svg, jeweils + das offizielle Audi-Markenasset). Schlüssel sind eindeutige Präfixe der + FAHRZEUG_MODELLE-Einträge - Reihenfolge ist hier egal, da sich keiner der + Präfixe mit einem anderen überschneidet. */ +const BADGE_DATEIEN = { + "RS 3": "rs3", "RS 4": "rs4", "RS 5": "rs5", "RS 6": "rs6", "RS 7": "rs7", + "S3": "s3", "SQ7": "sq7", +}; +function badgePraefix() { + const titel = CONFIG.fahrzeugtitel || ""; + return Object.keys(BADGE_DATEIEN).find((p) => titel.startsWith(p)) || null; +} + +/* Gemeinsames Formular für "Tankvorgang erfassen" und die Bearbeitung eines + bestehenden Belegs (Einzelseite) - dieselben Felder an beiden Stellen, + damit sie nicht auseinanderlaufen können. Preis je Liter ist bewusst kein + Eingabefeld, sondern wird aus Kosten/Liter berechnet (live über den + input-Handler in ereignisseVerdrahten, endgültig beim Speichern). */ +const KRAFTSTOFFSORTEN = ["V-Power Racing", "Super Plus", "Super"]; + +/* Vorschlag für "Gefahrene Distanz" bei einer NEUEN Erfassung: Differenz zum + zeitlich letzten bereits erfassten Tankvorgang in FILLS - dieselbe Regel + wie backendseitig in _distanz_seit_letzter_tankung() (belegverarbeitung.py), + hier nur zur Vorbefüllung, da der neue Datensatz noch nicht existiert. Beim + Bearbeiten eines bestehenden Vorgangs kommt der Wert dagegen direkt aus + f.distance_km (siehe Aufrufer). */ +function distanzVorschlag(aktuellerKm) { + if (aktuellerKm === "" || aktuellerKm == null || !FILLS.length) return ""; + const letzte = FILLS.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a)); + if (letzte.odometer_km == null) return ""; + return Math.round((aktuellerKm - letzte.odometer_km) * 10) / 10; +} + +function tankFelder(praefix, f) { + // Ein zuvor hochgeladener, noch nicht gespeicherter Beleg (belegEntwurf) + // überlagert hier nur die Beleg-Felder - Kilometerstand/Distanz kommen + // immer vom Fahrzeug bzw. von f, nie vom Beleg (§7.7 Regel 2). + const b = belegEntwurf && belegEntwurf.praefix === praefix ? belegEntwurf.daten : null; + const q = b ? { ...(f || {}), ts: b.ts || (f && f.ts), liters: b.liters, fuel_total_eur: b.fuel_total_eur, discount: b.discount, station_name: b.station_name, fuel_type: b.fuel_type } : f; + const ts = q && q.ts ? new Date(q.ts) : new Date(); + const datum = ts.toISOString().slice(0, 10); + const zeit = String(ts.getHours()).padStart(2, "0") + ":" + String(ts.getMinutes()).padStart(2, "0"); + const km = f && f.odometer_km != null ? f.odometer_km : (CAR.odoBekannt ? CAR.odo : ""); + const distanz = f ? (f.distance_km != null ? f.distance_km : "") : distanzVorschlag(km); + const liter = q && q.liters != null ? q.liters : ""; + const kosten = q && q.fuel_total_eur != null ? q.fuel_total_eur : ""; + const ersparnis = q && q.discount != null ? q.discount : ""; + const kraftstoff = q && q.fuel_type ? q.fuel_type : KRAFTSTOFFSORTEN[0]; + const station = q && q.station_name ? q.station_name : ""; + const preisText = q && q.liters && q.fuel_total_eur != null && q.liters > 0 + ? `Preis je Liter: ${de(q.fuel_total_eur / q.liters, 3)} €` : "Preis je Liter: wird berechnet"; + return ` +
+
+
+
+ km
+
+
+
+
+
+ ${CAR.smartdeal.aktiv ? `
+
` : ""} +
+ ${preisText}`; +} + +function tankFormularWerte(praefix) { + const liter = parseFloat(($(`#${praefix}Liter`).value || "0").replace(",", ".")); + const kosten = parseFloat(($(`#${praefix}Kosten`).value || "0").replace(",", ".")); + const kmFeld = $(`#${praefix}Km`).value; + const distanzFeld = $(`#${praefix}Distanz`).value; + const ersparnisFeld = CAR.smartdeal.aktiv ? $(`#${praefix}Ersparnis`) : null; + const datum = $(`#${praefix}Datum`).value, zeit = $(`#${praefix}Zeit`).value; + return { + ts: new Date(`${datum}T${zeit}:00`).toISOString(), + liter, kosten, + km: kmFeld === "" ? null : parseFloat(kmFeld), + distanz: distanzFeld === "" ? null : parseFloat(distanzFeld), + ersparnis: ersparnisFeld && ersparnisFeld.value !== "" ? parseFloat(ersparnisFeld.value.replace(",", ".")) : null, + kraftstoff: $(`#${praefix}Kraftstoff`).value, + station: $(`#${praefix}Station`).value, + }; +} + +function vFuelFormular() { + // Gleicher Grund wie bei vTripsFormular(): das Formular muss auch im + // Leer-Zustand erscheinen, nicht nur wenn schon Tankvorgänge vorhanden sind. + return `${neuOffen ? `
Neuer Tankvorgang + ${tankFelder("neuTank", null)} + ${belegFehler && belegFehler.context === "neuTank" ? `${esc(belegFehler.meldung)}` : ""} + + +
` : ""} + `; +} +function vFuel() { + const jahre = [...new Set(FILLS.map((f) => new Date(f.ts).getFullYear()))].sort((a, b) => b - a); + /* D5 */ + if (!jahre.length) return `
+ Noch keine Tankvorgänge +

Ein Tankvorgang wird automatisch angelegt, sobald der Tankstand des Fahrzeugs steigt. Den Beleg kann man danach hochladen – oder den Vorgang gleich hier von Hand erfassen.

+
+ ${vFuelFormular()}`; + let h = `
`; + jahre.forEach((j, ji) => { + const fj = FILLS.filter((f) => new Date(f.ts).getFullYear() === j); + const ersparnisJahr = sum(fj, (f) => f.discount || 0); + /* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge + folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher + konkurrierten drei gleich große rechtsbündige Werte miteinander. */ + h += ` +
`; + const monate = [...new Set(fj.map((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" })))]; + monate.forEach((m, mi) => { + const fm = fj.filter((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" }) === m); + h += `
+
`; + fm.forEach((f) => { const preis = f.liters ? f.fuel_total_eur / f.liters : null; h += swipeZeile(`fill:${f.tank_id}`, ``); }); + h += `
`; + }); + h += `
`; + }); + return h + `
+ ${vFuelFormular()}`; +} + +function vFill(id) { + const f = FILLS.find((x) => x.tank_id === id); + if (!f) return `
Tankvorgang nicht gefunden
`; + const berechneterPreis = f.liters ? f.fuel_total_eur / f.liters : null; + + if (tankBearbeitenOffen) { + return ` +
Tankvorgang bearbeiten + ${tankFelder("bearbTank", f)} + ${belegFehler && belegFehler.context === "bearbTank" ? `${esc(belegFehler.meldung)}` : ""} + + + +
`; + } + + return ` + ${mapBox()} +
+ Getankt +
${f.liters != null ? de(f.liters, 2) : "–"}l
+
+
+
Zeitpunkt
${dedat(new Date(f.ts))}${new Date(f.ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr
+
Kilometerstand
${f.odometer_km != null ? de(f.odometer_km) + " km" : "unbekannt"}
+
Gefahrene Distanz
${f.distance_km != null ? de(f.distance_km) + " km" : "unbekannt"}seit der letzten Tankung
+
Kraftstoff
${esc(f.fuel_type || KRAFTSTOFFSORTEN[0])}
+
Getankte Liter
${f.liters != null ? de(f.liters, 2) + " l" : "–"}
+
Kosten
${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : "–"}real, nach Rabatt
+
Preis je Liter
${berechneterPreis != null ? de(berechneterPreis, 3) + " €" : "–"}Kosten ÷ Liter
+ ${CAR.smartdeal.aktiv && f.discount != null ? `
${markenlogo()} SmartDeal-Ersparnis
${eur(f.discount)} €
` : ""} +
Tankstelle
${esc(f.station_name || "unbekannt")}${esc(f.station_address || "")}
+
+ ${belegFehler && belegFehler.context === f.tank_id ? `${esc(belegFehler.meldung)}` : ""} + + + +
`; +} + +/* ---------------------------------------------------------- Steuerung */ +const ICONS = { + home: '', + audi: '', + trips: '', + stat: '', + fuel: '', +}; +const TABS = [ + { id: "home", label: "Übersicht", eyebrow: "", title: "Übersicht" }, + { id: "audi", label: "Mein Audi", eyebrow: "", title: "" }, + { id: "trips", label: "Fahrten", eyebrow: "", title: "Fahrten" }, + { id: "stat", label: "Statistik", eyebrow: "", title: "Statistik" }, + { id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" }, +]; +let route = { name: "home" }; +const ZURUECK = { + trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi", + vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails", + steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home", + battverlauf: "audi", + /* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren + Zurück-Pfeil. */ + sicherheit: "home", +}; +const ICON = { + sonne: '', + mond: '', +}; +function themeIcon() { const el = $("#themeicon"); if (el) el.innerHTML = HOST.dataset.theme === "nacht" ? ICON.sonne : ICON.mond; } + +/* A6: gespeicherte Wahl, sonst Systemeinstellung. */ +const THEME_KEY = "audi-dashboard-theme"; +function themeGewaehlt() { + let gespeichert = null; + try { gespeichert = localStorage.getItem(THEME_KEY); } catch (e) { /* Privatmodus */ } + if (gespeichert === "nacht" || gespeichert === "tag") return gespeichert; + return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "tag" : "nacht"; +} +function themeSetzen(modus) { + HOST.dataset.theme = modus; + try { localStorage.setItem(THEME_KEY, modus); } catch (e) { /* Privatmodus */ } + tiles(); +} + +/* ------------------------------------------------------------ Action Sheet + B4: ersetzt confirm()/alert(). Ein offenes Sheet ist Zustand wie jeder + andere - render() zeichnet es mit, damit es Ansichtswechsel überlebt. */ +let sheet = null; +const sheetSchliessen = () => { sheet = null; render(); }; +function bestaetigen(titel, text, knopf, aktion, destruktiv = true) { + sheet = { titel, text, knopf, aktion, destruktiv }; + render(); +} +function hinweis(titel, text) { + sheet = { titel, text, knopf: null, aktion: null }; + render(); +} +function sheetMarkup() { + if (!sheet) return ""; + const kopf = `
${esc(sheet.titel)}${sheet.text ? `${esc(sheet.text)}` : ""}
`; + const aktion = sheet.knopf + ? `` + : ``; + return `
+ `; +} + +let letzteAnsicht = null; +let routeStack = []; +let navRichtung = null; +const istTab = (name) => TABS.some((t) => t.id === name); +function render() { + if (!DATEN_GELADEN) return; + // Eine laufende Wischgeste bezieht sich auf Knoten, die render() gleich + // durch neue ersetzt - Referenz kappen, bevor sie ins Leere zeigt. + swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; + const v = $("#view"), b = $("#back"); + const merkeScroll = v.scrollTop; + const gleicheAnsicht = letzteAnsicht === route.name + "|" + (route.id || ""); + let head; + if (route.name === "home") { head = ["", "Übersicht"]; v.innerHTML = vHome(); } + else if (route.name === "trip") { head = ["Fahrt", "Einzelfahrt"]; v.innerHTML = vTrip(route.id); } + else if (route.name === "fill") { head = ["Tankvorgang", "Einzelbeleg"]; v.innerHTML = vFill(route.id); } + else if (route.name === "service") { head = ["Wartung", "Service"]; v.innerHTML = vService(); } + else if (route.name === "reifen") { head = ["Fahrzeug", "Reifen"]; v.innerHTML = vReifen(); } + else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); } + else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); } + else if (route.name === "sicherheit") { head = ["Fahrzeug", "Sicherheit"]; v.innerHTML = vSicherheit(); } + else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); } + else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); } + else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); } + else if (route.name === "steuer") { head = ["Versicherung", "Kfz-Steuer"]; v.innerHTML = vSteuerBearbeiten(); } + else if (route.name === "notruf") { head = ["Versicherung", "Rufnummern"]; v.innerHTML = vNotrufBearbeiten(); } + else if (route.name === "schutz") { head = ["Im Ernstfall", "Schutzbrief"]; v.innerHTML = vSchutz(); } + else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); } + else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); } + else if (route.name === "sbuch") { head = ["Servicebuch", "Eintrag"]; v.innerHTML = vSbuch(); } + else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title]; + v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); } + $("#eyebrow").textContent = head[0]; $("#title").textContent = head[1]; + /* B3: Push nach rechts hinein, Pop nach links heraus. */ + v.classList.remove("push-rein", "push-raus"); + if (navRichtung && !gleicheAnsicht) { + void v.offsetWidth; + v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein"); + } + navRichtung = null; + ROOT.getElementById("overlay").innerHTML = sheetMarkup(); + /* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht + dort der Zurück-Pfeil. */ + ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none"; + const sy = $("#sync"); + sy.style.display = route.name === "home" ? "flex" : "none"; + sy.querySelector("span").textContent = standAlterText(); + b.classList.toggle("on", !istTab(route.name)); + v.scrollTop = gleicheAnsicht ? merkeScroll : 0; + letzteAnsicht = route.name + "|" + (route.id || ""); + if (MAP) { MAP.remove(); MAP = null; TILE = null; } + if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer + ROOT.querySelectorAll(".tab").forEach((el) => el.classList.toggle("on", el.dataset.tab === route.name)); +} +const go = (name, id) => { + // Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die + // Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf + // "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen + // und beim nächsten unabhängigen Speichern versehentlich mit zu persistieren. + if (entwurfIndex !== null && !(name === "sbuch" && id === String(entwurfIndex))) { + CAR.service.buch.splice(entwurfIndex, 1); + entwurfIndex = null; + } + sheet = null; + /* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt + den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */ + if (istTab(name)) routeStack = []; + else if (route.name !== name || route.id !== id) routeStack.push(route); + navRichtung = istTab(name) ? null : "rein"; + route = { name, id }; render(); +}; +function zurueck() { + sheet = null; + const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" }; + navRichtung = "raus"; + route = ziel; render(); +} + +/* B3: Randwischen zurück - dieselbe Bewegung wie ein Tipp auf den Pfeil. */ +let randX0 = null, randY0 = null; +function randwischenVerdrahten() { + const phone = ROOT.querySelector(".phone"); + phone.addEventListener("pointerdown", (e) => { + randX0 = null; + if (!e.isPrimary || sheet) return; + const links = phone.getBoundingClientRect().left; + if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; } + }); + phone.addEventListener("pointerup", (e) => { + if (randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60) zurueck(); + randX0 = null; + }); + phone.addEventListener("pointercancel", () => { randX0 = null; }); +} + +/* -------------------------------------------------------------- Aktionen + Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()), + Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge + wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */ +function serviceRufen(dienst, daten) { + HASS.callService("pyscript", dienst, daten).catch((err) => console.error("audi_dashboard:", dienst, err)); +} + +function ereignisseVerdrahten() { + ROOT.getElementById("tabbar").innerHTML = TABS.map((t) => + ``).join(""); + ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); + ROOT.getElementById("tabbar").addEventListener("click", (e) => { const b = e.target.closest(".tab"); if (b) go(b.dataset.tab); }); + ROOT.getElementById("back").addEventListener("click", () => zurueck()); + ROOT.getElementById("ringsbtn").addEventListener("click", () => go("einst")); + ROOT.getElementById("theme").addEventListener("click", () => { + themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht"); + themeIcon(); + }); + // Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe + // Logik auch mit der Maus funktioniert (z. B. beim Testen im Browser). + // touch-action:pan-y auf .swipe-content lässt vertikales Scrollen nativ + // zu; erst wenn die Geste als horizontal erkannt ist, übernimmt hier + // preventDefault() die Kontrolle. + ROOT.addEventListener("pointerdown", (e) => { + // Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) - + // unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist. + if (e.isPrimary && route.name === "home" && !ptrLaedt) { + const ansicht = $("#view"); + if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = e.clientY; ptrDrag = 0; } + } + const wrap = e.target.closest(".swipe-wrap"); + if (!wrap || !e.isPrimary) return; + swipeAktivWrap = wrap; swipeStartX = e.clientX; swipeStartY = e.clientY; swipeGeste = null; swipeDX = 0; + }); + ROOT.addEventListener("pointermove", (e) => { + if (ptrY0 !== null && e.isPrimary) { + const ansicht = $("#view"), dy = e.clientY - ptrY0; + if (dy > 0 && ansicht && ansicht.scrollTop <= 0) { + e.preventDefault(); + ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS + const ptr = $("#ptr"); + if (ptr) { + ptr.style.transition = "none"; + ptr.style.height = ptrDrag + "px"; + ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren"; + } + } else { + ptrY0 = null; ptrDrag = 0; + const ptr = $("#ptr"); + if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; } + } + } + if (!swipeAktivWrap || !e.isPrimary) return; + const dx = e.clientX - swipeStartX, dy = e.clientY - swipeStartY; + if (swipeGeste === null) { + if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; + swipeGeste = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + if (swipeGeste === "y") { swipeAktivWrap = null; return; } + } + e.preventDefault(); + swipeWarGezogen = true; + const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0; + swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx)); + const inhalt = swipeAktivWrap.querySelector(".swipe-content"); + inhalt.style.transition = "none"; + inhalt.style.transform = `translateX(${swipeDX}px)`; + }, { passive: false }); + function swipeBeenden() { + if (!swipeAktivWrap) return; + const inhalt = swipeAktivWrap.querySelector(".swipe-content"); + inhalt.style.transition = ""; inhalt.style.transform = ""; + if (swipeGeste === "x") { + if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); } + else { swipeAktivWrap.classList.remove("swiped"); } + } + swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; + // Der nachlaufende Klick derselben Geste feuert synchron noch in diesem + // Tick (Maus: immer: Touch: falls vom Browser nicht ohnehin unterdrückt) - + // erst danach zurücksetzen, sonst würde ein echter, späterer Tipp (z. B. + // auf den gerade erst aufgedeckten Löschen-Knopf) fälschlich verschluckt. + if (swipeWarGezogen) setTimeout(() => { swipeWarGezogen = false; }, 0); + } + function ptrBeenden() { + if (ptrY0 === null) return; + ptrY0 = null; + const ptr = $("#ptr"); if (!ptr) { ptrDrag = 0; return; } + ptr.style.transition = "height .2s"; + if (ptrDrag >= 56) { + ptrLaedt = true; + ptr.style.height = "56px"; + ptr.classList.add("spin"); + ptr.querySelector("span").textContent = "Aktualisieren …"; + datenAktualisieren().finally(() => { + ptrLaedt = false; + const ptr2 = $("#ptr"); + if (ptr2) { ptr2.style.transition = "height .2s"; ptr2.style.height = "0"; ptr2.classList.remove("spin"); } + }); + } else { + ptr.style.height = "0"; + } + ptrDrag = 0; + } + ROOT.addEventListener("pointerup", (e) => { swipeBeenden(); ptrBeenden(); }); + ROOT.addEventListener("pointercancel", (e) => { swipeBeenden(); ptrBeenden(); }); + + // Batteriespannungs-Verlauf: Pinch-Zoom (2 Finger) / Ziehen zum Verschieben + // (1 Finger) / Mausrad-Zoom, per pointerId in bvZeiger nachverfolgt statt + // über die swipe*-Variablen oben, die für horizontales Wischen in Listen + // gedacht sind und mit einer zweiten aktiven Berührung nichts anfangen + // können. + ROOT.addEventListener("pointerdown", (e) => { + if (!e.target.closest("#bvChart") || !bvDomain) return; + bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (bvZeiger.size === 1) { + bvTapKandidat = { x: e.clientX, y: e.clientY }; + bvBewegtSeit = false; + bvGestStart = { typ: "pan", x: e.clientX, domain: [...bvDomain] }; + } else if (bvZeiger.size === 2) { + bvTapKandidat = null; + const [p1, p2] = [...bvZeiger.values()]; + const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); + const mitteX = (p1.x + p2.x) / 2; + const ankerFrac = Math.max(0, Math.min(1, (mitteX - rahmen.linksPx) / rahmen.breitePx)); + bvGestStart = { + typ: "pinch", dist: Math.hypot(p1.x - p2.x, p1.y - p2.y), domain: [...bvDomain], + ankerFrac, ankerZeit: bvDomain[0] + ankerFrac * (bvDomain[1] - bvDomain[0]), + }; + } + }); + ROOT.addEventListener("pointermove", (e) => { + if (!bvZeiger.has(e.pointerId) || !bvGestStart) return; + bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); + e.preventDefault(); + if (bvGestStart.typ === "pan" && bvZeiger.size === 1) { + const dxPx = e.clientX - bvGestStart.x; + if (Math.abs(dxPx) > 4) bvBewegtSeit = true; + const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(); + const breite = bvGestStart.domain[1] - bvGestStart.domain[0]; + const dt = -(dxPx / bvPlotRahmen(rect).breitePx) * breite; + bvDomain = bvDomainKlemmen([bvGestStart.domain[0] + dt, bvGestStart.domain[1] + dt]); + bvZeichnen(); + } else if (bvGestStart.typ === "pinch" && bvZeiger.size === 2) { + const [p1, p2] = [...bvZeiger.values()]; + const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y); + const faktor = bvGestStart.dist / Math.max(dist, 1); + const breite = (bvGestStart.domain[1] - bvGestStart.domain[0]) * faktor; + const start = bvGestStart.ankerZeit - bvGestStart.ankerFrac * breite; + bvDomain = bvDomainKlemmen([start, start + breite]); + bvZeichnen(); + } + }, { passive: false }); + function bvBeenden(e) { + if (!bvZeiger.has(e.pointerId)) return; + bvZeiger.delete(e.pointerId); + if (bvZeiger.size === 0) { + if (bvGestStart && bvGestStart.typ === "pan" && !bvBewegtSeit && bvTapKandidat) { + bvAusgewaehlt = bvNaechsterPunkt(bvTapKandidat.x); + bvZeichnen(); + } + bvGestStart = null; bvTapKandidat = null; + } else if (bvZeiger.size === 1) { + // von Pinch (2 Finger) auf Pan (1 Finger verbleibend) wechseln, statt + // die Geste einfach zu beenden - fühlt sich beim Loslassen eines + // Fingers sonst wie ein Ruckler an + const [rest] = [...bvZeiger.entries()]; + bvGestStart = { typ: "pan", x: rest[1].x, domain: [...bvDomain] }; + bvBewegtSeit = true; + } + } + ROOT.addEventListener("pointerup", bvBeenden); + ROOT.addEventListener("pointercancel", bvBeenden); + ROOT.addEventListener("wheel", (e) => { + if (!e.target.closest("#bvChart") || !bvDomain) return; + e.preventDefault(); + const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); + const frac = Math.max(0, Math.min(1, (e.clientX - rahmen.linksPx) / rahmen.breitePx)); + const ankerZeit = bvDomain[0] + frac * (bvDomain[1] - bvDomain[0]); + const faktor = e.deltaY > 0 ? 1.2 : 1 / 1.2; + const breite = (bvDomain[1] - bvDomain[0]) * faktor; + const start = ankerZeit - frac * breite; + bvDomain = bvDomainKlemmen([start, start + breite]); + bvZeichnen(); + }, { passive: false }); + + ROOT.addEventListener("click", (e) => { + // B4: Das Action Sheet liegt über allem und fängt daher zuerst ab. + if (e.target.closest("[data-sheet-abbrechen]")) { sheetSchliessen(); return; } + if (e.target.closest("[data-sheet-ok]")) { + const aktion = sheet && sheet.aktion; + sheet = null; render(); + if (aktion) aktion(); + return; + } + if (sheet) return; + const bvReset = e.target.closest("[data-bvreset]"); + if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; } + // Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung + // weiterhin) - nicht als Navigation/Aktion werten. + if (swipeWarGezogen) { swipeWarGezogen = false; return; } + const loeschBtn = e.target.closest("[data-swipe-loeschen]"); + const offenerWrap = ROOT.querySelector(".swipe-wrap.swiped"); + if (offenerWrap && !loeschBtn) { + // Erster Tipp bei offener Wischfläche schließt sie nur - wie bei iOS, + // egal ob innerhalb der Zeile (statt zu navigieren) oder außerhalb. + schliesseAlleSwipes(); + if (offenerWrap.contains(e.target)) return; + } + if (loeschBtn) { + const [art, id] = loeschBtn.dataset.swipeLoeschen.split(":"); + bestaetigen(LOESCH_TEXT[art], LOESCH_HINWEIS[art], "Löschen", () => { eintragLoeschen(art, id); schliesseAlleSwipes(); }); + return; + } + const tripDel = e.target.closest("[data-tripdel]"); + if (tripDel) { + const id = tripDel.dataset.tripdel; + bestaetigen(LOESCH_TEXT.trip, LOESCH_HINWEIS.trip, "Löschen", () => { eintragLoeschen("trip", id); go("trips"); }); + return; + } + const fillDel = e.target.closest("[data-filldel]"); + if (fillDel) { + const id = fillDel.dataset.filldel; + bestaetigen(LOESCH_TEXT.fill, LOESCH_HINWEIS.fill, "Löschen", () => { eintragLoeschen("fill", id); go("fuel"); }); + return; + } + const sbDel = e.target.closest("[data-sbdel]"); + if (sbDel) { + const id = sbDel.dataset.sbdel; + bestaetigen(LOESCH_TEXT.sbuch, LOESCH_HINWEIS.sbuch, "Löschen", () => { eintragLoeschen("sbuch", id); go("service"); }); + return; + } + const bildDel = e.target.closest("[data-bildloeschen]"); + if (bildDel) { + const dateiname = bildDel.dataset.bildloeschen; + bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => { + (async () => { + try { await HASS.callService("pyscript", "audi_dashboard_bild_loeschen", { dateiname }); } + catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; } + bildMenuOffen = null; bildVersion = Date.now(); render(); + })(); + }); + return; + } + // Fahrzeugbild (Radbild wie Fahrzeugbilder-Slots): vorhandenes Foto -> Popup + // mit Ersetzen/Löschen, fehlendes Foto (Platzhalter, bild-fehlt) -> Dateiauswahl + // direkt öffnen. + const bildKlick = e.target.closest("[data-bildklick]"); + if (bildKlick) { + const dateiname = bildKlick.dataset.bildklick; + const box = bildKlick.querySelector(".bildbox"); + const container = bildKlick.closest(".tile, .bildslot"); + if (box && box.classList.contains("bild-fehlt")) { + const input = container.querySelector(`[data-bildupload="${dateiname}"]`); + if (input) input.click(); + } else { + bildMenuOffen = dateiname; render(); + } + return; + } + if (e.target.closest("[data-bildmenu-schliessen]")) { bildMenuOffen = null; render(); return; } + const bildErsetzen = e.target.closest("[data-bildersetzen]"); + if (bildErsetzen) { + const container = bildErsetzen.closest(".tile, .bildslot"); + const input = container.querySelector(`[data-bildupload="${bildErsetzen.dataset.bildersetzen}"]`); + if (input) input.click(); + return; + } + if (e.target.closest("[data-sdabbrechen]")) { smartdealPopupOffen = false; render(); return; } + if (e.target.closest("[data-sdbestaetigen]")) { + const datum = $("#sdPopupBis").value; + if (!datum) return; + CAR.smartdeal.aktiv = true; CAR.smartdeal.laeuftAb = datum; + smartdealPopupOffen = false; profilSpeichern(); render(); + return; + } + if (e.target.id === "carimg") { bildWeiter(); return; } + if (e.target.closest("[data-sbneu]")) { + CAR.service.buch.unshift({ datum: dedat(heute()), km: CAR.odoBekannt ? CAR.odo : 0, art: SERVICE_ARTEN[0], werkstatt: CAR.service.autohaus.name, kosten: null, arbeiten: "" }); + entwurfIndex = 0; go("sbuch", "0"); return; + } + if (e.target.closest("[data-sbspeichern]")) { + entwurfIndex = null; profilSpeichern(); go("service"); return; + } + if (e.target.closest("[data-nmauf]")) { nmOffen = !nmOffen; render(); return; } + if (e.target.closest("[data-einrichten-auf]")) { einrichtenOffen = !einrichtenOffen; render(); return; } + if (e.target.closest("[data-nmspeichern]")) { + const wert = parseInt($("#nm").value, 10); + if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); } + nmOffen = false; render(); return; + } + if (e.target.closest("[data-neu]")) { + neuOffen = !neuOffen; + if (belegEntwurf && belegEntwurf.praefix === "neuTank") belegEntwurf = null; + if (belegFehler && belegFehler.context === "neuTank") belegFehler = null; + render(); return; + } + if (e.target.closest("[data-fahrt]")) { fahrtOffen = !fahrtOffen; render(); return; } + if (e.target.closest("[data-fahrtspeichern]")) { + const datum = $("#neuFahrtDatum").value, start = $("#neuFahrtStart").value, dauer = parseInt($("#neuFahrtDauer").value, 10) || 0; + const tsStart = new Date(`${datum}T${start}:00`); + const tsEnd = new Date(tsStart.getTime() + dauer * 60000); + serviceRufen("audi_dashboard_fahrt_manuell_anlegen", { ts_start: tsStart.toISOString(), ts_end: tsEnd.toISOString(), art: "privat" }); + fahrtOffen = false; render(); return; + } + if (e.target.closest("[data-tankspeichern]")) { + const w = tankFormularWerte("neuTank"); + if (w.liter > 0 && w.kosten > 0) { + serviceRufen("audi_dashboard_tankvorgang_manuell", { + ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, + distanz: w.distanz, kraftstoff: w.kraftstoff, + }); + } + neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; + } + if (e.target.closest("[data-tankspeichernbeleg]")) { + const w = tankFormularWerte("neuTank"); + const beleg = belegEntwurf && belegEntwurf.praefix === "neuTank" ? belegEntwurf.daten : null; + if (w.liter > 0 && w.kosten > 0) { + serviceRufen("audi_dashboard_tankvorgang_manuell", { + ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, + distanz: w.distanz, kraftstoff: w.kraftstoff, + receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, + }); + } + neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; + } + if (e.target.closest("[data-tankbearbeiten]")) { + tankBearbeitenOffen = !tankBearbeitenOffen; + if (belegEntwurf && belegEntwurf.praefix === "bearbTank") belegEntwurf = null; + if (belegFehler && belegFehler.context === "bearbTank") belegFehler = null; + render(); return; + } + const tankAkt = e.target.closest("[data-tankaktualisieren]"); + if (tankAkt) { + const w = tankFormularWerte("bearbTank"); + const beleg = belegEntwurf && belegEntwurf.praefix === "bearbTank" ? belegEntwurf.daten : null; + serviceRufen("audi_dashboard_tankvorgang_aktualisieren", { + tank_id: tankAkt.dataset.tankaktualisieren, ts: w.ts, liter: w.liter, kosten: w.kosten, + km: w.km, ersparnis: w.ersparnis, station: w.station, + distanz: w.distanz, kraftstoff: w.kraftstoff, + receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, + }); + tankBearbeitenOffen = false; belegEntwurf = null; belegFehler = null; render(); return; + } + const belegBtn = e.target.closest("[data-beleg]"); + if (belegBtn) { + // "neu"/"bearb": nur parsen und den offenen Entwurf ausfüllen (siehe + // belegEntwurf). Eine tank_id dagegen (Button in der Einzelbeleg- + // Ansicht) trägt den Beleg direkt in den bereits gespeicherten + // Tankvorgang nach - siehe Kopfkommentar in audi_dashboard_beleg_hochladen. + const ziel = belegBtn.dataset.beleg; + const nurParsen = ziel === "neu" || ziel === "bearb"; + const praefix = ziel === "neu" ? "neuTank" : ziel === "bearb" ? "bearbTank" : null; + const tankId = nurParsen ? undefined : ziel; + const kontext = nurParsen ? praefix : ziel; + if (belegFehler && belegFehler.context === kontext) belegFehler = null; + belegZielPraefix = praefix; + const eingabe = document.createElement("input"); + eingabe.type = "file"; eingabe.accept = "application/pdf"; + eingabe.onchange = () => { + const datei = eingabe.files[0]; if (!datei) return; + const leser = new FileReader(); + leser.onload = () => { + const base64 = leser.result.split(",")[1]; + serviceRufen("audi_dashboard_beleg_hochladen", { pdf_base64: base64, dateiname: datei.name, tank_id: tankId }); + }; + leser.readAsDataURL(datei); + }; + eingabe.click(); + return; + } + const del = e.target.closest("[data-loeschen]"); + if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; } + const oel = e.target.closest("[data-oel]"); + if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; } + const mode = e.target.closest("[data-mode]"); + if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; } + const pr = e.target.closest("[data-profil]"); + if (pr && pr.dataset.profil === "export") { + const a = document.createElement("a"); + a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2)); + a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove(); + return; + } + const bk = e.target.closest("[data-backup]"); + if (bk && bk.dataset.backup === "jetzt") { serviceRufen("audi_dashboard_backup_jetzt", {}); return; } + if (bk && bk.dataset.backup === "export") { + const paket = { erstellt: new Date().toISOString(), profil: PROFIL_ROH, fahrten: TRIPS, tankvorgaenge: FILLS }; + const a = document.createElement("a"); + a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(paket, null, 2)); + a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove(); + return; + } + if (e.target.closest("[data-update-pruefen]")) { + if (!updatePruefend) { + updatePruefend = true; render(); + (async () => { + try { await HASS.callService("pyscript", "audi_dashboard_update_pruefen", {}); } + catch (err) { hinweis("Update-Prüfung fehlgeschlagen", err.message); } + await updateStatusAbrufen(); + updatePruefend = false; render(); + })(); + } + return; + } + if (e.target.closest("[data-update-installieren]")) { + if (!updateInstallierend) bestaetigen( + "Update installieren?", + "Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben unangetastet. Die Seite lädt sich danach neu.", + "Installieren", + () => { updateInstallierend = true; render(); + (async () => { + try { + await HASS.callService("pyscript", "audi_dashboard_update_installieren", {}); + await updateStatusAbrufen(); + location.reload(); + return; + } catch (err) { hinweis("Update-Installation fehlgeschlagen", err.message); } + await updateStatusAbrufen(); + updateInstallierend = false; render(); + })(); + }, false); + return; + } + const cs = e.target.closest("[data-csv]"); + if (cs) { + const w = cs.dataset.csv; + if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"], + TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status])); + else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"], + FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 3) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""])); + else csvDatei("servicebuch.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"], + CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""])); + return; + } + const satz = e.target.closest("[data-satz]"); + if (satz) { serviceRufen("audi_dashboard_reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } + const k = e.target.closest("[data-ics]"); + if (k) { + if (k.dataset.ics === "service") { + const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; + const datum = $("#svDat").value; + CAR.service.vereinbart[t.art] = datum; + profilSpeichern(); + ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, datum, CAR.service.autohaus.name, + t.km !== null ? `Fällig bei ${de(t.km)} km · ${CAR.service.autohaus.adresse}` : CAR.service.autohaus.adresse); + render(); + } else { + const datum = $("#rfDat").value; + CAR.reifen.wechsel.datum = datum; + CAR.reifen.wechsel.vereinbart = $("#rfOk").checked; + profilSpeichern(); + const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer"; + ics(`${CONFIG.fahrzeugtitel}: Reifenwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`); + } + return; + } + const a = e.target.closest("[data-acc]"); + if (a) { const b = ROOT.getElementById(a.dataset.acc); a.setAttribute("aria-expanded", b.classList.toggle("on")); return; } + const art = e.target.closest("[data-art]"); + if (art) { const tr = TRIPS.find((x) => x.trip_id === art.dataset.art); tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg"; render(); return; } + const g = e.target.closest("[data-go]"); + if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); } + }); + + ROOT.addEventListener("input", (e) => { + const praefix = e.target.dataset.tankpreisfeld; + if (!praefix) return; + const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", ".")); + const kosten = parseFloat((ROOT.getElementById(`${praefix}Kosten`).value || "0").replace(",", ".")); + const anzeige = ROOT.getElementById(`${praefix}PreisAnzeige`); + if (anzeige) anzeige.textContent = liter > 0 ? `Preis je Liter: ${de(kosten / liter, 3)} €` : "Preis je Liter: wird berechnet"; + }); + + ROOT.addEventListener("change", (e) => { + if (e.target.dataset.profil === "import") { + const datei = e.target.files[0]; if (!datei) return; + const leser = new FileReader(); + leser.onload = () => { + try { + const p = JSON.parse(leser.result); + PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); + serviceRufen("audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(p) }); + bild = 0; render(); + } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } + }; + leser.readAsText(datei); + return; + } + if (e.target.dataset.backup === "import") { + const datei = e.target.files[0]; if (!datei) return; + const leser = new FileReader(); + leser.onload = () => { + try { + const paket = JSON.parse(leser.result); + if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt)"); + PROFIL_ROH = paket.profil; + CONFIG = profilZuConfig(paket.profil); + CAR = profilZuCar(paket.profil, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); + TRIPS = paket.fahrten || []; + FILLS = paket.tankvorgaenge || []; + serviceRufen("audi_dashboard_backup_wiederherstellen", { + profil_json: JSON.stringify(paket.profil), + fahrten_json: JSON.stringify(TRIPS), + tankvorgaenge_json: JSON.stringify(FILLS), + }); + bild = 0; render(); + } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } + }; + leser.readAsText(datei); + return; + } + if (e.target.dataset.bildupload !== undefined) { + const datei = e.target.files[0]; if (!datei) return; + const dateiname = e.target.dataset.bildupload; + const leser = new FileReader(); + leser.onload = async () => { + const daten_base64 = String(leser.result).split(",")[1] || ""; + try { + await HASS.callService("pyscript", "audi_dashboard_bild_hochladen", { dateiname, daten_base64 }); + } catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); } + bildMenuOffen = null; bildVersion = Date.now(); render(); + }; + leser.readAsDataURL(datei); + e.target.value = ""; + return; + } + if (e.target.dataset.backupintervall !== undefined) { + CONFIG.backupIntervall = e.target.value; profilSpeichern(); return; + } + const ein = e.target.dataset.einrichten; + if (ein) { + if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value; + else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value; + else if (ein === "wlan") CONFIG.wlanName = e.target.value; + else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value; + else CAR[ein] = e.target.value; + profilSpeichern(); return; + } + const vb = e.target.dataset.vereinbart; + if (vb) { CAR.service.vereinbart[vb] = e.target.value; profilSpeichern(); render(); return; } + const ah = e.target.dataset.ah; + if (ah) { + CAR.service.autohaus[ah] = e.target.value; + profilSpeichern(); render(); return; + } + const sb = e.target.dataset.sb; + if (sb) { + const [i, feld] = sb.split("|"); + const eintrag = CAR.service.buch[+i]; + eintrag[feld] = feld === "km" ? (parseInt(e.target.value, 10) || 0) : feld === "kosten" ? (e.target.value === "" ? null : parseFloat(e.target.value)) : e.target.value; + // Ein per "Eintrag hinzufügen" angelegter, noch nicht bestätigter Entwurf + // wird erst beim Klick auf "Speichern" (data-sbspeichern) persistiert - + // sonst würde schon das testweise Ausfüllen einzelner Felder auto-speichern. + if (entwurfIndex !== +i) profilSpeichern(); + render(); return; + } + const rf = e.target.dataset.rf; + if (rf) { + const [k, schluessel] = rf.split("|"); + const wert = e.target.value; + CAR.reifen[k][schluessel] = wert; + profilSpeichern(); render(); return; + } + if (e.target.dataset.schrift !== undefined) { + beschriftung = e.target.checked; + try { localStorage.setItem(SCHRIFT_KEY, beschriftung ? "an" : "aus"); } catch (err) { /* Privatmodus */ } + ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); + return; + } + if (e.target.dataset.steuer !== undefined) { CAR.steuer.betrag = parseFloat(e.target.value) || 0; profilSpeichern(); render(); return; } + if (e.target.dataset.steuerzeit !== undefined) { CAR.steuer.zeitraum = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.steuerfaellig !== undefined) { CAR.steuer.faellig = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.steuerlast !== undefined) { CAR.steuer.lastschrift = e.target.checked; profilSpeichern(); render(); return; } + const vteil = e.target.dataset.vteil; + if (vteil) { + const [i, feld] = vteil.split("|"); + CAR.versicherung.teile[+i][feld] = feld === "betrag" ? (parseFloat(e.target.value) || 0) : e.target.value; + CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0); + profilSpeichern(); render(); return; + } + if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; } + const vnotruf = e.target.dataset.vnotruf; + if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.pause !== undefined) { CONFIG.pauseMin = parseInt(e.target.value, 10) || 0; profilSpeichern(); return; } + if (e.target.dataset.sd !== undefined) { + if (e.target.checked) { smartdealPopupOffen = true; render(); } + else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); } + return; + } + if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; } + if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; } + if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; } + if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; } + if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } + }); +} + +/* ------------------------------------------------------------ Datenladen */ +function zustandsDaten(entityId) { + const st = HASS && HASS.states[entityId]; + return st && st.attributes ? st.attributes.daten : undefined; +} + +let letzterProfilState = null, letzterFahrtenState = null, letzterTankState = null, letzterStatusState = null, letzterBelegState = null, letzterBattState = null; + +/* Ergebnis eines Beleg-Uploads verarbeiten (siehe belegEntwurf/belegFehler + weiter oben) - kommt über dieselbe Zustand-statt-Rückgabewert-Route wie + Profil/Fahrten/Tankvorgänge, siehe Kopfkommentar in + frontend_veroeffentlichung.py. */ +function belegErgebnisVerarbeiten(daten) { + if (!daten) return; + if (daten.fehler) { + const kontext = daten.tank_id || belegZielPraefix; + belegFehler = { context: kontext, meldung: daten.fehler }; + if (!daten.tank_id) belegEntwurf = null; + } else if (daten.tank_id) { + // Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der + // Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über + // tankvorgaenge_veroeffentlichen(), hier nur eine evtl. noch offene + // Fehleranzeige für diesen Tankvorgang löschen. + if (belegFehler && belegFehler.context === daten.tank_id) belegFehler = null; + } else if (belegZielPraefix) { + belegEntwurf = { praefix: belegZielPraefix, daten }; + belegFehler = null; + } + render(); +} + +/* Nachlade-Fallback: panel_custom schiebt einem bereits gemounteten Element + frische hass-Objekte nicht zuverlässig nach - an der Testinstanz auch + Minuten nach dem Start und nach SPA-interner Navigation weg und zurück + beobachtet, dass HASS auf dem allerersten, noch unvollständigen Stand + hängen blieb (der übliche Fall: das Backend hat unsere vier Zustände beim + allerersten hass-Push noch nicht veröffentlicht, z. B. kurz nach einem + HA-Neustart). Deshalb hier aktiv per WebSocket nachfragen, statt passiv + auf den nächsten hass-Push zu warten. Ergebnisse landen in einem eigenen + Zwischenspeicher statt in HASS.states selbst - das gehört Home Assistant, + nicht uns. + + Ein früherer Stand brach nach 15 Versuchen (rund 30s) endgültig ab - real + zu kurz beobachtet: startet der Container komplett neu (HA + HACS + die + VAG-Integration mit eigenen Netzwerkaufrufen), kann es deutlich länger + dauern, bis pyscript überhaupt geladen ist und sein @time_trigger("startup") + feuert. Sichtbares Symptom war genau das gemeldete Verhalten: die App + blieb dauerhaft auf "Lädt …" hängen (und die Tableiste zeigte dadurch auch + kein aktives Symbol, weil render() den harten Rücksprung bei + !DATEN_GELADEN nie verlässt) - erst ein Panelwechsel weg und zurück half, + weil das einen frischen connectedCallback()/datenLaden(false) auslöst, zu + dem Zeitpunkt oft schon nach Ablauf der wirklichen Backend-Startzeit. + Deshalb jetzt ohne festes Abbruchlimit: nach der anfänglichen schnellen + Serie (siehe NACHLADE_INTERVALL_MS) läuft es mit einem größeren, aber + endlosen Intervall weiter, bis Daten da sind oder das Element abgehängt + wird (disconnectedCallback stoppt hier nichts explizit, aber ein + getimeter Aufruf ohne HASS/callWS bricht oben ohnehin ab). */ +const NACHGELADEN = {}; +let nachladeTimer = null; +let nachladeVersuche = 0; +const NACHLADE_INTERVALL_MS = 2000; +const NACHLADE_LANGSAM_MS = 10000; +const NACHLADE_SCHNELLE_VERSUCHE = 15; + +function nachladeAnstossen() { + if (nachladeTimer || !HASS || !HASS.callWS) return; + nachladeVersuche++; + // Erster Versuch fast sofort statt erst nach vollem Intervall - der + // Wettlauf mit pyscripts beim_start() (siehe Kopfkommentar) ist meist + // schon nach wenigen hundert Millisekunden entschieden, nicht erst nach + // den vollen zwei Sekunden. Nach den ersten Versuchen (Backend vermutlich + // noch nicht bereit) auf ein ruhigeres Intervall wechseln, statt für immer + // im schnellen Takt weiterzufragen. + const verzoegerung = nachladeVersuche === 1 ? 200 + : nachladeVersuche <= NACHLADE_SCHNELLE_VERSUCHE ? NACHLADE_INTERVALL_MS + : NACHLADE_LANGSAM_MS; + nachladeTimer = setTimeout(async () => { + nachladeTimer = null; + try { + const states = await HASS.callWS({ type: "get_states" }); + const finden = (id) => states.find((s) => s.entity_id === id); + NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil; + NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten; + NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank; + NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status; + NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt; + } catch (e) { + // wird beim naechsten Versuch erneut probiert + } + datenLaden(true); + }, verzoegerung); +} + +async function datenLaden(erzwingen) { + if (!HASS) return; + const profilState = HASS.states["pyscript.audi_dashboard_profil"] || NACHGELADEN.profil; + const fahrtenState = HASS.states["pyscript.audi_dashboard_fahrten"] || NACHGELADEN.fahrten; + const tankState = HASS.states["pyscript.audi_dashboard_tankvorgaenge"] || NACHGELADEN.tank; + const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status; + const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt; + const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"]; + + if (belegState && belegState !== letzterBelegState) { + letzterBelegState = belegState; + belegErgebnisVerarbeiten(belegState.attributes && belegState.attributes.daten); + } + + const geaendert = erzwingen + || profilState !== letzterProfilState || fahrtenState !== letzterFahrtenState + || tankState !== letzterTankState || statusState !== letzterStatusState || battState !== letzterBattState; + if (geaendert) { + letzterProfilState = profilState; letzterFahrtenState = fahrtenState; + letzterTankState = tankState; letzterStatusState = statusState; letzterBattState = battState; + + const profil = profilState && profilState.attributes ? profilState.attributes.daten : null; + if (profil) { + const status = (statusState && statusState.attributes && statusState.attributes.daten) || {}; + LETZTES_UPDATE_TS = (status.km != null && statusState && statusState.last_updated) + ? new Date(statusState.last_updated).getTime() : null; + PROFIL_ROH = profil; + CONFIG = profilZuConfig(profil); + CAR = profilZuCar(profil, status); + TRIPS = (fahrtenState && fahrtenState.attributes && fahrtenState.attributes.daten) || []; + FILLS = (tankState && tankState.attributes && tankState.attributes.daten) || []; + BATTERIEVERLAUF = (battState && battState.attributes && battState.attributes.daten) || []; + DATEN_GELADEN = true; + nachladeVersuche = 0; + if (!UPDATE_STATUS_GEHOLT) { UPDATE_STATUS_GEHOLT = true; updateStatusAbrufen().then(render); } + render(); + return; + } + } + + if (!DATEN_GELADEN) nachladeAnstossen(); // Backend noch nicht bereit (erster Tick nach Neustart) +} + +/* -------------------------------------------------------------- Element */ +class AudiDashboardPanel extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + /* ha-panel-custom erzeugt das Element teils schon, bevor unser eigenes + Modul (dieses hier) mit customElements.define() fertig registriert + ist - beobachtet als Zusammenspiel mit dessen fehlendem Cache für + module_url-Panels (siehe Kopfkommentar in audi-dashboard-panel.js): + _createPanel() kann mehrfach anlaufen, und beim ersten, noch zu + frühen document.createElement(tag) ist "audi-dashboard-panel" für + den Browser noch ein unbekanntes Element. Die anschließende + Zuweisung el.hass = ... trifft dann noch keinen Setter auf dem + Prototyp und legt stattdessen eine gewöhnliche eigene Eigenschaft + auf der Instanz an. Sobald customElements.define() greift und der + Browser das Element "aufwertet" (führt genau diesen Konstruktor + nach), holt die Plattform das NICHT automatisch nach - das gilt nur + für über observedAttributes beobachtete HTML-Attribute, nicht für + beliebige JS-Eigenschaften. Ohne dieses Nachholen bleibt set hass() + dauerhaft unerreichbar: _aufbauen() lief zwar (über + connectedCallback(), einen echten Lifecycle-Hook), aber + datenLaden() nie - sichtbares Symptom war ein dauerhaft + hängendes "Lädt …" trotz vorhandener Backend-Daten. */ + for (const eigenschaft of ["hass", "narrow", "panel"]) { + if (Object.prototype.hasOwnProperty.call(this, eigenschaft)) { + const wert = this[eigenschaft]; + delete this[eigenschaft]; + this[eigenschaft] = wert; + } + } + } + + set hass(hass) { + const erster = !HASS; + HASS = hass; + if (erster) { + this._aufbauen(); + } + datenLaden(false); + } + get hass() { return HASS; } + + set narrow(v) { this._narrow = v; } + set panel(v) { this._panel = v; } + + connectedCallback() { + HOST = this; + if (!this.shadowRoot.querySelector(".phone")) this._aufbauen(); + // Absicherung fuer den Fall, dass panel_custom das Element neu verbindet + // (z. B. weg- und zurueckgeklickt), ohne set hass() dabei erneut + // auszuloesen - ohne das hier bliebe ein noch nicht geladener Stand + // haengen, bis irgendwann zufaellig ein globaler hass-Push durchkommt. + if (!DATEN_GELADEN) datenLaden(false); + } + + disconnectedCallback() { + if (MAP) { MAP.remove(); MAP = null; } + } + + _aufbauen() { + // Idempotenz-Sperre: sowohl connectedCallback() als auch set hass() + // können _aufbauen() auslösen, je nachdem in welcher Reihenfolge + // panel_custom das Element einfügt und mit hass versorgt. Ohne diese + // Sperre lief der Aufbau zweimal, verdrahtete jeden Knopf doppelt und + // ein Klick auf Tag/Nacht schaltete zweimal um - macht sich gegenseitig + // zunichte, sichtbar erst beim tatsächlichen Testen, nicht im Code. + if (this.shadowRoot.querySelector(".phone")) return; + ROOT = this.shadowRoot; + HOST = this; + /* A6: Standard folgt der Systemeinstellung, eine manuelle Wahl überlebt den + Reload. Vorher stand die App nach jedem Neuladen wieder auf Nacht. */ + HOST.dataset.theme = HOST.dataset.theme || themeGewaehlt(); + + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `/local/audi-dashboard.css?v=${CSS_VERSION}`; + ROOT.appendChild(link); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = ` +
+
+
+
Übersicht
+
+ + +
+
Ziehen zum Aktualisieren
+
Lädt …
+
+ +
`; + ROOT.appendChild(wrapper); + + ereignisseVerdrahten(); + randwischenVerdrahten(); + themeIcon(); + setInterval(standAlterTicken, 1000); + } +} + +if (!customElements.get("audi-dashboard-panel")) { + customElements.define("audi-dashboard-panel", AudiDashboardPanel); +} + diff --git a/homeassistant/www/audi-dashboard-panel.js b/homeassistant/www/audi-dashboard-panel.js new file mode 100644 index 0000000..5786594 --- /dev/null +++ b/homeassistant/www/audi-dashboard-panel.js @@ -0,0 +1,47 @@ +/* ================================================================ + Audi-Dashboard — Lade-Stub für panel_custom + ================================================================ + Diese Datei selbst ändert sich praktisch nie und bleibt deshalb auch mit + der langen Standard-Cache-Zeit von Home Assistant für /local/ (31 Tage, + Cache-Control: public, max-age=2678400 - an der Testinstanz nachgemessen, + nicht vermutet) unproblematisch: sie lädt bei jedem vollständigen + Seitenaufruf zuerst audi-dashboard-version.json mit cache:"no-store" - + das erzwingt eine echte Netzwerkanfrage, unabhängig vom HTTP-Cache -, und + reicht deren Versionsnummer als Query-Parameter an den eigentlichen + Code (audi-dashboard-app.js) weiter. Jede neue Versionsnummer ist aus + Sicht des Browsers eine neue URL und wird deshalb nie aus einem alten + Cache bedient. + + Update-Ablauf ohne Neustart und ohne Cache-Probleme: + 1. audi-dashboard-app.js und/oder audi-dashboard.css ersetzen + 2. die Zahl in audi-dashboard-version.json um 1 erhöhen + 3. im Browser einmal ganz normal neu laden (kein Hard-Refresh nötig) + Diese Datei hier (audi-dashboard-panel.js) bleibt dabei unangetastet - + configuration.yaml und damit ein HA-Neustart sind für Frontend-Updates + nicht mehr nötig, nur noch für den allerersten Einbau (§10 Punkt 1). + + Bekannte HA-Eigenheit (an der Testinstanz reproduziert, nicht vermutet): + ha-panel-custom cached geladene Skripte nur für den alten js_url-Typ, + nicht für module_url (siehe dessen eigener Quellcode - für "js" gibt es + ein url-indiziertes Cache-Objekt, für "module" fehlt das komplett). Bei + einem direkten/harten Seitenaufruf (nicht bei SPA-interner Navigation) + kann das dazu führen, dass dieses Skript zweimal als