Initialer Import: HA-Panel, Design-System, Companion-App
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 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -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
|
||||
@@ -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 `<button>`/interactive elements) but has no `:focus`/`:focus-visible` style at all — a keyboard or switch-control user (Bluetooth keyboard + iPad/desktop browser via Tailscale, or iOS Full Keyboard Access) gets no visual indication of where focus is. This is invisible on a touch-only phone, which is presumably why it wasn't caught, but the app is also reachable from a browser tab (desktop, Tailscale).
|
||||
**Fix:** add a generic `:focus-visible` rule (e.g. `outline: 2px solid var(--red); outline-offset: 2px;`) on `button, .tile, .tab, [tabindex]` — keeps mouse/touch users unaffected (`:focus-visible` only fires for keyboard focus in modern browsers) while fixing keyboard access.
|
||||
|
||||
### 🟠 Swipe-to-delete has no non-gesture affordance
|
||||
**Where:** `audi-dashboard-app.js:1872` (`.swipe-delete`/`.swipe-loeschen`), used for trip and fill-up rows.
|
||||
**Issue:** the delete action only becomes reachable (as an actual, labeled `<button>Löschen</button>`) *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 `<div>`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 `<span>`. 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 `<div>`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.
|
||||
@@ -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/<entity_id>` per entity, or subscribe over HA's **WebSocket API** for
|
||||
push updates instead of polling.
|
||||
- **Writes:** `POST /api/services/<domain>/<service>` — 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.
|
||||
@@ -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.
|
||||
@@ -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 `<audi-dashboard-panel>`, registered via `customElements.define` (guarded against double-registration).
|
||||
- Loaded through a tiny stable loader stub, `audi-dashboard-panel.js`, referenced from `configuration.yaml`'s `panel_custom` block. The stub fetches `/local/audi-dashboard-version.json` (uncached), then dynamically `import()`s `/local/audi-dashboard-app.js?v=<version>` — this is the cache-busting mechanism that lets the app be updated by rewriting one small JSON file (see §5, `updateverwaltung.py`), without touching `configuration.yaml` or restarting HA. It works around a documented HA quirk where `ha-panel-custom` only cache-busts `js_url`-type panels, not `module_url`-type ones.
|
||||
- **No framework, no bundler.** All module state lives in free (non-class) variables by design — this file is a near-verbatim port of the original static-HTML prototype (`dashboard-muster.html`), and keeping the render functions (`vHome()`, `vTrips()`, `render()`, …) free of `this` let them be reused almost unchanged. For a single-panel app, one instance == "the one interface," same as the prototype's single `<body>`.
|
||||
- Renders into an **open shadow root**. `$(selector)` = `ROOT.querySelector(selector)`.
|
||||
- `hass` is injected automatically by HA's `panel_custom` integration. The constructor works around a real timing bug: `ha-panel-custom` can call `document.createElement(tag)` before `customElements.define()` has registered the class, so an early `.hass =` assignment lands as a plain instance property instead of hitting the `set hass()` accessor and is never "upgraded." The constructor re-triggers the setters by deleting and reassigning any such own-properties found at construction time.
|
||||
|
||||
### Data flow
|
||||
Backend pyscript publishes 7 `pyscript.*` state entities; the frontend reads `hass.states[id].attributes.daten`:
|
||||
|
||||
| Entity | Frontend variable |
|
||||
|---|---|
|
||||
| `pyscript.audi_dashboard_profil` | `PROFIL_ROH` → `CONFIG`/`CAR` (via `profilZuConfig()`/`profilZuCar()`) |
|
||||
| `pyscript.audi_dashboard_fahrten` | `TRIPS` |
|
||||
| `pyscript.audi_dashboard_tankvorgaenge` | `FILLS` |
|
||||
| `pyscript.audi_dashboard_fahrzeugstatus` | merged into `CAR`; its `last_updated` drives the staleness indicator |
|
||||
| `pyscript.audi_dashboard_batterieverlauf` | `BATTERIEVERLAUF` |
|
||||
| `pyscript.audi_dashboard_beleg_ergebnis` | routed through `belegErgebnisVerarbeiten()` (receipt-upload async result) |
|
||||
| `pyscript.audi_dashboard_update_status` | `UPDATE_STATUS` (fetched once via WebSocket, not passive push) |
|
||||
|
||||
Most settings mutate `CONFIG`/`CAR` in place in the browser, then call `profilSpeichern()` → `HASS.callService("pyscript", "audi_dashboard_profil_schreiben", {...})`, which round-trips the **entire profile** as one JSON blob. There is no field-level write service for profile data.
|
||||
|
||||
A retry-loading system (`nachladeAnstossen()`) handles the case where pyscript hasn't published its entities yet after an HA restart (panel_custom doesn't reliably re-push `hass` afterward): retries at 200ms, then every 2s for 15 attempts, then falls back to every 10s indefinitely — no hard timeout. This replaced an earlier 15-retry/~30s hard cutoff that was observed in production to be too short (task #55).
|
||||
|
||||
### Navigation
|
||||
- 5 bottom tabs (`TABS`): **Übersicht** (home) · **Mein Audi** (audi) · **Fahrten** (trips) · **Statistik** (stat) · **Tanken** (fuel).
|
||||
- **Einstellungen** (Settings) lives behind the Audi-rings icon top-right — not a tab.
|
||||
- Navigation state is a flat `route = {name, id}` object, no browser history stack. `go(name, id)` sets `route` and calls `render()`.
|
||||
- Back-button target per sub-page is a static lookup table, `ZURUECK` — includes 3-level nesting (`schutz` → `vertragsdetails` → `vers`).
|
||||
|
||||
### Views
|
||||
| Route | Function | Notes |
|
||||
|---|---|---|
|
||||
| `home` | `vHome()` | vehicle photo, badge, plate, "sicher abgestellt" status, range ring/bar, odometer + next-service tile pair, last-trip/last-fill teaser |
|
||||
| `sicherheit` | `vSicherheit()` | 12-point door/window/lock detail |
|
||||
| `audi` | `vAudi()` | "Mein Audi" hub — photo gallery, tiles linking to sub-pages |
|
||||
| `ident` | `vIdent()` | identity, technical specs, equipment list |
|
||||
| `battverlauf` | `vBatterieverlauf()` | hand-built SVG battery-voltage chart |
|
||||
| `service` | `vService()` | upcoming appointments, book-an-appointment form, workshop contact, service book list |
|
||||
| `werkstatt` | `vWerkstatt()` | workshop contact edit form |
|
||||
| `sbuch` | `vSbuch(i)` | single service-book entry (view/edit/new-draft) |
|
||||
| `vers` | `vVers()` | insurance/tax hub |
|
||||
| `beitrag` | `vBeitrag()` | edit insurance premium line items |
|
||||
| `vertragsdetails` | `vVertragsdetails()` | contract line items, links to Schutzbrief |
|
||||
| `schutz` | `vSchutz()` | mobility-protection (Schutzbrief) terms, read-only |
|
||||
| `notruf` | `vNotrufBearbeiten()` | edit emergency phone numbers |
|
||||
| `steuer` | `vSteuer()` / `vSteuerBearbeiten()` | vehicle tax summary + edit |
|
||||
| `reifen` | `vReifen()` | tire sets, wheel-bolt torque, swap toggle, swap-date → calendar |
|
||||
| `trips` | `vTrips()` / `vTripsFormular()` | trip list (year→month accordion) + manual-add form |
|
||||
| `trip` | `vTrip(id)` | single trip detail + map |
|
||||
| `stat` | `vStat()` | statistics (distance/time, fuel, consumption, day/night, trip type — real computed values, see §7) |
|
||||
| `fuel` | `vFuel()` | fill-up list (year→month, volume-weighted avg price) |
|
||||
| `fill` | `vFill(id)` / edit form | single fill-up detail, receipt upload/replace |
|
||||
| `einst` | `vEinst()` | Settings — vehicle setup, theme/labels/ground toggle, overview image, photo upload grid, SmartDeal, trip-merge gap, profile export/import, backup, CSV export, version + self-update |
|
||||
|
||||
### Interactive components
|
||||
- **Swipe-to-delete** — Pointer Events (not Touch Events, deliberately, so mouse works for desktop testing), 84px reveal width, opening one row closes any other (`schliesseAlleSwipes()`), native `confirm()` before delete, then a service call + optimistic local-array filter. Used for trip and fill-up rows; service-book rows use a plain delete button instead.
|
||||
- **Pull-to-refresh** — home route only, only from scroll-top, iOS-style rubber-band resistance (`min(90, dy*0.5)`), 56px trigger threshold.
|
||||
- **Popups** — only two: `.bildmenu` (photo replace/delete, used both in Settings' photo grid and on tire-set wheel photos) and `.sdpopup` (SmartDeal activation, asks for an expiry date). No native `<dialog>` anywhere; every other "form" is an inline collapsible section toggled by a boolean state var (`einrichtenOffen`, `nmOffen`, `fahrtOffen`, `tankBearbeitenOffen`, …).
|
||||
- **Destructive/error UX** — native `confirm()` gates every delete and the self-update install; native `alert()` surfaces import/upload/update failures; receipt-parsing errors render inline in red next to the relevant form.
|
||||
- **Battery-voltage chart** — hand-drawn SVG, no charting library (no bundler in the project). Pinch-zoom (2-finger)/mouse-wheel, 1-finger pan, tap selects nearest point for a date/time + min/max tooltip.
|
||||
- **Maps** — Leaflet, loaded from CDN at runtime (not bundled) — requires the client to have internet access in addition to the Tailscale tunnel. Tile source switches with the day/night theme.
|
||||
- **Receipt upload** — native file picker (PDF only) → base64 → `audi_dashboard_beleg_hochladen` service call; the parsed result comes back asynchronously via a dedicated hass entity rather than the service call's return value (a documented pattern, see `frontend_veroeffentlichung.py`), because pyscript service calls don't return values to the frontend.
|
||||
|
||||
### Theming
|
||||
`data-theme` attribute on the host element (not `:root`). Set from Settings' segmented control (the topbar sun/moon button was retired by Design-Audit finding A6 — it's still in the markup but `hidden`), which then re-runs `themeIcon()`/`tiles()` (map tile layer swap). **Persisted** via `localStorage` (`THEME_KEY`, `themeGewaehlt()`/`themeSetzen()`); with nothing stored yet it follows the OS setting via `prefers-color-scheme`, defaulting to "nacht". The tab-bar-label toggle is persisted the same way (`SCHRIFT_KEY`). All `localStorage` access is wrapped in try/catch for private-mode browsers.
|
||||
|
||||
### Icons
|
||||
All hand-authored inline SVG (no icon font, no external library). A `ICONS`/`ICON` object holds tab and sun/moon fragments; the settings-cog path is duplicated verbatim in two places (`vSteuer()`, `vReifen()`) rather than factored out.
|
||||
|
||||
---
|
||||
|
||||
## 4. Logic (business rules)
|
||||
|
||||
| Rule | Where | Trigger | Summary |
|
||||
|---|---|---|---|
|
||||
| **Trip detection** | `fahrterkennung.py` | `@state_trigger` on the Companion App WLAN sensor | Start = sensor value becomes the vehicle's SSID (from profile). End = sensor leaves that SSID **and doesn't return within the pause-tolerance window** (`fahrten_pausenzeit_min`, default 15 min, editable in Settings). Implemented via `task.unique()` cancelling a pending `task.sleep()` if the WLAN reconnects in time — the trip record is only written after the wait, never on the raw disconnect. **In-memory only** (`_fahrt_start_ts`) — an HA restart mid-trip or mid-pause-window silently loses the trip (documented, deferred hardening gap). |
|
||||
| **Two-stage trip completion** | `modules/fahrtabschluss_logik.py` | odometer sensor state-trigger + post-trip-end call | The odometer often doesn't update until the *next* trip. `screening_durchfuehren()` searches the HA REST history API (48h window, Bearer token from `audi_dashboard/ha_token.txt`) for the odometer reading closest in time to trip start/end; fills `odo_start`/`odo_end`, sets `status="vollständig"` once both are known. Adjacent trips propagate a known odometer value across a gap when the vehicle was simply parked (`_lueckenlos_verketten`). **GPS fallback is NOT implemented** despite being referenced in comments — see §7. |
|
||||
| **Distance** | derived, everywhere | — | `distance_km = odo_end − odo_start`. GPS (when/if present) supplies route/addresses only, never distance — so gaps in position tracking are harmless by design. |
|
||||
| **Tire-set km counter** | `reifenzaehler.py` | odometer state-trigger + startup | Rewritten from the original spec: each tire set's `km` is a running cumulative total; `referenz_odo_km` is the last-processed absolute odometer reading. Every odometer change adds the delta to the **currently active** set, then advances the reference. On swap, the old set is settled first, *then* `aktiv` flips — km driven on the old set can never leak onto the new one. Resilient to a temporarily-`unavailable` odometer sensor (reference simply doesn't move; next valid reading computes its delta against the old reference, so no km is lost or double-counted across an outage). |
|
||||
| **Automatic fill-up detection** | `tankerkennung.py` | fuel-level state-trigger | Fires when the level rises, since the last low-water-mark, by ≥ the *more sensitive* of "5 L converted to %" (using `tankvolumen_liter`, default 58L) or "9 percentage points." Tracks a low-water-mark (not simple prev/current diff) because the integration sometimes reports a fill-up as several small incremental steps. Creates a **placeholder** fill-up (`status="unvollständig"`, no liters/cost/station, `source="auto"`) — the user completes it later via receipt upload. Low-water-mark is in-memory only (same restart caveat as trip detection). |
|
||||
| **Shell receipt parsing** | `data/shell_beleg_parser.py` (standalone CLI, `pypdf`) | invoked via `subprocess.run` from `belegverarbeitung.py` | All matching is structural regex (`Obj.-Nr.`, `Beleg-Nr.`, pump-line patterns, `#A` article markers), not brand/label text, because brand and discount-label wording vary across receipt printings. `price_per_l` is deliberately never read from the PDF — always recomputed as `fuel_total_eur / liters`. Dedup key: `"{station_id}_{receipt_no}_{ts[:16]}"` (minute granularity). Regression-tested against 10 real receipts from 4 stations. |
|
||||
| **Receipt-to-fill-up matching** | `belegverarbeitung.py` | `audi_dashboard_beleg_hochladen` service | Same `receipt_key` already attached to a *different* `tank_id` → rejected as duplicate. Patching an existing fill-up requires the receipt's calendar day to match the record's — else rejected as "Falsches Belegdatum." Odometer/distance always come from the vehicle, never overwritten by a receipt. Uploading a receipt with no `tank_id` never creates a record — only feeds the frontend's currently-open draft. Any field listed in a record's `edited_fields` is protected from being overwritten by future automated updates. |
|
||||
| **Service/oil-change forecast** | split — see §7 | — | The vehicle's own reported due date/km (EU-Data-Act sensors) is republished as-is by the backend. The app's **own** Servicebuch-based forecast (`oelwechselPrognose()`, `intervalle()`, `termine()`) — which mixes the full historical average with a ~180-day-weighted recent rate, restarting from zero after each logged oil change, capped at the configured interval's time limit — is entirely **frontend** logic, not backend, despite the brief describing it as core backend logic. |
|
||||
| **SmartDeal (fuel discount) validity** | frontend, `profilZuCar()` | recomputed every render | `aktiv = einstellungen.smartdeal.aktiv && (no laeuft_ab OR laeuft_ab >= today)` — the expiry date is **inclusive** (discount still applies on the expiry date itself). No backend cron enforces this; it's a pure computed property re-evaluated on load/render. |
|
||||
| **Backup** | `backup.py` | daily `@time_trigger("cron(0 4 * * *)")` | Folder-copy (not zip) of the 3 data files into `audi_dashboard/backups/<timestamp>/`. Interval (`aus`/`taeglich`/`woechentlich`/`monatlich`) evaluated inline inside the single daily trigger — weekly fires only on Monday, monthly only on day 1. Restore is the counterpart to a **browser-side** export/import that bundles profile+trips+fill-ups client-side. |
|
||||
| **Self-update** | `updateverwaltung.py` | manual services only | Check: shallow git clone/fetch of `UPDATE_REPO_URL`, compares `www/audi-dashboard-version.json`'s numeric version, source vs. live. Install: backs up live `pyscript/` + the 3 frontend files to `audi_dashboard/code_backups/<timestamp>/`, then wholesale-replaces `pyscript/`, the 3 www files, and `www/badges/` from the clone. Writes a **fresh** version number (`int(now.timestamp())`, not the source repo's own value) to force every browser to cache-bust. Never touches `www/bilder/` (user photos) or `audi_dashboard/` (user data). No-op entirely if `UPDATE_REPO_URL` is empty. |
|
||||
| **Battery voltage tracking** | `batterieverlauf.py` + frontend `vBatterieverlauf()` | every 5 min + startup; no-op if `BATTERIE_SENSOR` unset (currently the case) | Backend records only raw daily min/max + timestamps, unfiltered. All interpretation is client-side: SOC % via a voltage→percent lookup table, SOH as a trend (early-window vs. late-window average delta, not a real capacity measurement), using the daily **minimum** as the resting-voltage proxy (occurs shortly after engine start, before the alternator raises voltage) — an `AGM_RUHE_MAX_V` (13.2V) threshold filters out readings taken mid-drive. |
|
||||
| **"Securely parked" status** | `frontend_veroeffentlichung.py::_sicherheitscheck` | published with every status update | 16 individual door/window/lock/tailgate/hood checks, each `{label, ok}` where `ok` is `None` if the underlying sensor is missing. Aggregate is `None` if *any* check is unknown, else `True` only if *all* are `True` — guarantees the summary dot and the detail list can never disagree, and never shows a false-green from stale/missing data. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend
|
||||
|
||||
### pyscript files and their `@service` definitions
|
||||
|
||||
| File | Services |
|
||||
|---|---|
|
||||
| `frontend_api.py` | `audi_dashboard_jetzt_aktualisieren()`, `audi_dashboard_profil_schreiben(profil_json)` |
|
||||
| `backup.py` | `audi_dashboard_backup_jetzt()`, `audi_dashboard_backup_wiederherstellen(profil_json, fahrten_json, tankvorgaenge_json)` |
|
||||
| `belegverarbeitung.py` | `audi_dashboard_beleg_hochladen(pdf_base64, dateiname, tank_id)`, `audi_dashboard_tankvorgang_manuell(...)`, `audi_dashboard_tankvorgang_aktualisieren(...)`, `audi_dashboard_tankvorgang_loeschen(tank_id)` |
|
||||
| `bilderverwaltung.py` | `audi_dashboard_bild_hochladen(dateiname, daten_base64)`, `audi_dashboard_bild_loeschen(dateiname)` — both gated by a fixed 10-name allow-list (`ERLAUBTE_DATEINAMEN`); no other filename is ever writable/deletable |
|
||||
| `fahrtabschluss.py` | `audi_dashboard_screening_jetzt()` (manual trigger for the odometer-history screening) |
|
||||
| `fahrterkennung.py` | `audi_dashboard_fahrt_manuell_anlegen(ts_start, ts_end, art)`, `audi_dashboard_fahrt_loeschen(trip_id)` |
|
||||
| `reifenzaehler.py` | `audi_dashboard_reifen_wechseln(satz)` (`"sommer"`/`"winter"` only) |
|
||||
| `updateverwaltung.py` | `audi_dashboard_update_pruefen()`, `audi_dashboard_update_installieren()` |
|
||||
|
||||
`batterieverlauf.py` and `tankerkennung.py` expose no services — trigger-only. `modules/profil.py`, `modules/fahrtabschluss_logik.py`, `modules/frontend_veroeffentlichung.py`, `modules/einstellungen.py` are plain importable helper modules with no decorators.
|
||||
|
||||
### Triggers
|
||||
`@time_trigger("startup")` in most files (ensures data folders exist / initial publish); `@time_trigger("period(now, 20 seconds)")` republishes live status; `@time_trigger("period(now, 60 seconds)")` republishes profile/trips/fills as a filesystem-edit safety net; `@time_trigger("cron(0 4 * * *)")` daily backup check; `@time_trigger("period(now, 5 minutes)")` battery check; `@state_trigger` on the WLAN sensor (trip detection), the odometer sensor (tire counter + completion screening), and the fuel-level sensor (fill detection). No `@event_trigger` usage anywhere.
|
||||
|
||||
### Data files
|
||||
|
||||
**`data/fahrzeugprofil.json`** (single JSON file, gitignored; `.example.json` is the tracked template) — top-level sections: `fahrzeug` (FIN, plate, titles, WLAN SSID, tank volume, Erstzulassung, HU-due), `einstellungen` (night/day hours, trip pause tolerance, overview-image choice, SmartDeal, oil-interval mode, backup interval, last-backup timestamp), `versicherung` (premium line items, contract, Schutzbrief terms, coverage groups), `steuer`, `reifen` (active set, torque spec, swap date, per-set cumulative km + `referenz_odo_km` + spec fields), `service` (appointments, workshop contact, service-book array), `technik` (static spec sheet), `ausstattung` (static equipment list).
|
||||
|
||||
**`data/fahrten.jsonl`** (JSON Lines, one trip per line — **currently empty in this checkout**, no trips recorded yet): `trip_id`, `ts_start`, `ts_end`, `duration_s`, `distance_km`, `km_quelle` (`"odometer"` — `"gps"` is defined but never produced), `odo_start`, `odo_end`, `avg_speed_kmh` (defined, never populated), `start_lat`/`start_lon`/`end_lat`/`end_lon` (defined, never populated), `start_address`/`end_address` (defined, never populated), `art` (`"privat"`/`"arbeitsweg"`), `route` (always `null`), `pausen` (always `[]`), `source` (`"ha"`/`"manual"`), `status` (`"offen"`/`"vollständig"`), `edited_fields`.
|
||||
|
||||
**`data/tankvorgaenge.jsonl`** (JSON Lines — **currently empty**): `tank_id`, `receipt_key`, `receipt_no`, `tse_beleg_nr`, `ts`, `ts_payment`, `ts_tse`, `station_id/name/address`, `article_no`, `product_name`, `fuel_type`, `liters`, `fuel_total_eur`, `price_per_l` (always computed, never read from receipt), `discount`, `discount_per_l`, `list_price_per_l`, `receipt_total_eur`, `net_eur`, `vat_eur`, `receipt_file`, `odometer_km`, `distance_km`, `source` (`"auto"`/`"manual"`/`"beleg"`), `status` (`"unvollständig"`/`"vollständig"`), `edited_fields`. Note: no `full_tank`/`level_before_pct`/`level_after_pct` fields — that concept was removed (see §7).
|
||||
|
||||
**`audi_dashboard/batteriespannung.jsonl`** — one line per calendar day: `{datum, min, min_ts, max, max_ts}`.
|
||||
|
||||
**Other runtime paths:** `audi_dashboard/ha_token.txt` (plaintext HA long-lived access token, used only for the history-screening REST calls), `audi_dashboard/backups/<ts>/`, `audi_dashboard/code_backups/<ts>/`, `audi_dashboard/belege/*.pdf`, `/config/.audi_dashboard_update_quelle` (scratch git clone for self-update, deliberately outside both `audi_dashboard/` and `pyscript/`).
|
||||
|
||||
### HA entities consumed
|
||||
All centralized in `pyscript/modules/einstellungen.py` (the one file requiring edits before install), matched to `TommiG1/HA_VAG-EU-Data-Act` v0.6.35 for this vehicle: `sensor.iphone_wifi_connection` (trip trigger), `sensor.audi_rs_4_avant_mileage`, `..._fuel_level`, `..._range_primary`, `button.audi_rs_4_avant_refresh_now`, 4× door/window/door-lock `binary_sensor`s each, tailgate/hood + their locks, `..._oil_change_due`/`_distance`, `..._inspection_due`/`_distance`. `BATTERIE_SENSOR` is currently unset (integration provides none for this vehicle). All reads go through `zustand_oder_none()`, which treats `"unknown"`/`"unavailable"`/a missing entity as `None` rather than guessing.
|
||||
|
||||
### Installation / registration
|
||||
`configuration_snippet.yaml` merges two blocks into `configuration.yaml`: `pyscript: allow_all_imports: true` (required — the scripts use `os`, `subprocess`, `urllib.request`, `base64`, `uuid`, none of which pyscript's sandbox allows by default) and a `panel_custom` entry pointing `module_url` at `/local/audi-dashboard-panel.js?v=1` (sidebar title "Mein Audi", icon `mdi:car-sports`). `INSTALL.md` walks a ~30–40 min manual setup: install pyscript via HACS, copy the 3 folders into `config/`, merge the YAML, edit `einstellungen.py` with real entity IDs, drop a long-lived access token into `ha_token.txt`, restart HA, verify via Developer Tools. pyscript hot-reloads on file change; the frontend's cache-busting is handled entirely by the version-stub pattern described above (§3).
|
||||
|
||||
---
|
||||
|
||||
## 6. Product surface not covered elsewhere
|
||||
|
||||
- **CSV export** (Settings → "Daten ausgeben"): trips, fill-ups, service book — semicolon-delimited, German number format, BOM-prefixed for Excel.
|
||||
- **iOS calendar export**: `.ics` file generation client-side (`ics()`) for service appointments and tire-swap dates, with a 2-day advance alarm.
|
||||
- **Vehicle photos**: fixed filenames under `www/bilder/` (`seitenansicht.webp`, `front-schraeg.webp`, `frontansicht.webp`, `heckansicht.webp`, `cockpit.webp`, `scheinwerfer.webp`, `sitze.webp`, `seitenansicht-winter.webp`, plus `rad-sommer.webp`/`rad-winter.webp`), uploadable/deletable per-slot from Settings and the tire page; missing files render a placeholder box with an icon, label, and the exact expected filename (never a broken-image icon), preserving layout dimensions. In this checkout only `shell-logo.svg` is present — all vehicle photo slots are still empty.
|
||||
- **Model badges**: `www/badges/{rs3,rs4,rs5,rs6,rs7,sq7}-{positive,negative}.svg`, theme-swapped via pure CSS (`:host([data-theme=...])`), model selection in Settings restricted to the 11 names that have a matching badge.
|
||||
|
||||
---
|
||||
|
||||
## 7. Known Gaps & Discrepancies (read before trusting `bauauftrag.md`)
|
||||
|
||||
These are places the shipped code and the original build brief disagree — load-bearing for anyone extending this app:
|
||||
|
||||
1. **GPS-distance fallback does not exist.** Bauauftrag §7.2/§7.3 describe falling back to GPS-derived distance when the odometer never resolves. The actual `fahrtabschluss_logik.py` has an explicit `WICHTIGER VORBEHALT` comment: trips that never get an odometer match stay `status="offen"` forever. `km_quelle="gps"` is a value the schema supports but no code path ever produces.
|
||||
2. **GPS fields are dead schema.** `start_lat/lon`, `end_lat/lon`, `start_address`, `end_address`, `avg_speed_kmh`, `route` all exist on the trip record but are never populated by any reviewed code — the iPhone-GPS integration described in bauauftrag §4.2 was never actually wired in beyond the WLAN trip-trigger.
|
||||
3. **Full-tank derivation was removed.** Bauauftrag §7.4/§6.4 describe a 97%-fill-level threshold deriving a `full_tank` flag and consumption. `belegverarbeitung.py`'s own header comment confirms this was explicitly removed per a later change request; the current `tankvorgaenge.jsonl` schema has no `full_tank`/`level_before_pct`/`level_after_pct` fields.
|
||||
4. **SmartDeal expiry is frontend-only.** No backend cron enforces `laeuft_ab`; it's recomputed as a property every render (`profilZuCar()`). Correct in practice (the UI is the only thing that reads it), but there's no server-side source of truth if something else were to consume the flag.
|
||||
5. **The app's own service/oil forecast lives in the frontend, not the backend.** Bauauftrag §7.6 frames service-date forecasting as core *backend* logic ("Kernlogik"). In the shipped code, the backend only republishes the vehicle's own reported due date/km verbatim; the actual Servicebuch-based forecast math (`oelwechselPrognose()`, `intervalle()`, `termine()`) is JS in `audi-dashboard-app.js`.
|
||||
6. **`INSTALL.md` names entity variables that don't match `einstellungen.py`.** The doc's step 4 references `DOORS_SENSOR`/`WINDOWS_SENSOR`/`LOCK_ENTITY`/`BATTERY_VOLTAGE_SENSOR`; the actual variables are `TUER_SENSOREN`/`FENSTER_SENSOREN`/`TUERSCHLOSS_SENSOREN` (each a 4-element list) and singular `BATTERIE_SENSOR`.
|
||||
7. **Two in-memory-only state machines lose data across an HA restart**, by explicit design-doc admission: trip start (`_fahrt_start_ts` in `fahrterkennung.py`) and fill-up low-water-mark (`_tiefststand_pct` in `tankerkennung.py`). Both are flagged in code comments as deferred hardening, not accidental bugs.
|
||||
8. **The bauauftrag §2/§8 spacing scale (`--sp-1`..`--sp-5`) and `--r-func: 8px` token don't exist in the shipped CSS.** Small-radius elements use hardcoded 6px/12px/14px literals instead — which is exactly what §8's own rule ("nie als Literalwerte in Komponenten") says not to do.
|
||||
9. **A stale header comment in `audi-dashboard-app.js`** still claims the Statistik page shows placeholder numbers with no real evaluation. This is now false — `vStat()` computes genuine period-based statistics from `TRIPS`/`FILLS` (`fahrtenSeit`, `tankSeit`, `verbrauch`, day/night split via `istNachtZeit`).
|
||||
10. **`fahrten.jsonl` and `tankvorgaenge.jsonl` are empty in this checkout** — no trips or fill-ups have been recorded through this install yet.
|
||||
11. **`BATTERIE_SENSOR` is currently unset** — the vehicle integration provides no 12V-voltage sensor at present, so all battery-history logic is a permanent no-op until (if ever) one becomes available.
|
||||
12. **Backlog, not yet built** (bauauftrag §15, noted 2026-08-09): coolant-level and oil-level measurement/history, mirroring the battery-voltage pattern, contingent on the vehicle integration exposing the right sensors.
|
||||
|
||||
---
|
||||
|
||||
## 8. File map
|
||||
|
||||
```
|
||||
Audi_app_TG/
|
||||
├── bauauftrag.md original build brief (German, historical — see §7)
|
||||
├── dashboard-muster.html the original static prototype (superseded, kept for reference)
|
||||
├── SPECIFICATION.md this document
|
||||
├── design-system/ UNRELATED sibling project — Claude Design component library, no brand assets
|
||||
└── homeassistant/
|
||||
├── INSTALL.md, README.md, configuration_snippet.yaml, update.ps1
|
||||
├── data/ → deployed to config/audi_dashboard/
|
||||
│ ├── fahrzeugprofil.json (+ .example.json)
|
||||
│ ├── fahrten.jsonl, tankvorgaenge.jsonl
|
||||
│ ├── shell_beleg_parser.py + tests/
|
||||
├── pyscript/ → deployed to config/pyscript/
|
||||
│ ├── frontend_api.py, backup.py, batterieverlauf.py, belegverarbeitung.py,
|
||||
│ │ bilderverwaltung.py, fahrtabschluss.py, fahrterkennung.py,
|
||||
│ │ reifenzaehler.py, tankerkennung.py, updateverwaltung.py
|
||||
│ └── modules/
|
||||
│ ├── einstellungen.py entity-ID config — the one file to edit pre-install
|
||||
│ ├── profil.py file I/O for profile/trips/fills/battery history
|
||||
│ ├── fahrtabschluss_logik.py odometer-history screening + HA REST client
|
||||
│ └── frontend_veroeffentlichung.py publishes the pyscript.* state entities
|
||||
└── www/ → deployed/merged to config/www/
|
||||
├── audi-dashboard-panel.js stable loader stub
|
||||
├── audi-dashboard-app.js the actual custom element
|
||||
├── audi-dashboard.css design system
|
||||
├── audi-dashboard-version.json cache-buster, rewritten by updateverwaltung.py
|
||||
├── badges/ 12 static model-badge SVGs
|
||||
└── bilder/ vehicle photo storage (mostly unpopulated in this checkout)
|
||||
```
|
||||
+649
@@ -0,0 +1,649 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bauauftrag Fahrzeug-Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0E0E0E;
|
||||
--panel: #161616;
|
||||
--line: #2A2A2A;
|
||||
--fg: #F2F2F2;
|
||||
--muted: #9A9A9A;
|
||||
--accent: #BB0A30;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
gap: 56px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 32px 120px;
|
||||
}
|
||||
nav {
|
||||
position: sticky;
|
||||
top: 40px;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: 18px;
|
||||
}
|
||||
nav a {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
letter-spacing: .01em;
|
||||
padding: 5px 0;
|
||||
border-left: 2px solid transparent;
|
||||
margin-left: -20px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
nav a:hover { color: var(--fg); border-left-color: var(--accent); }
|
||||
main { min-width: 0; }
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -.02em;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 28px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -.01em;
|
||||
margin: 64px 0 18px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 34px 0 12px; color: var(--fg); }
|
||||
h1 + p strong { color: var(--accent); }
|
||||
p { margin: 0 0 14px; }
|
||||
a { color: var(--fg); }
|
||||
strong { font-weight: 600; }
|
||||
hr { display: none; }
|
||||
ul, ol { padding-left: 22px; margin: 0 0 16px; }
|
||||
li { margin: 5px 0; }
|
||||
li.todo, li.done { list-style: none; position: relative; }
|
||||
li.todo::before, li.done::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -22px; top: 7px;
|
||||
width: 12px; height: 12px;
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
li.done::before { background: var(--accent); border-color: var(--accent); }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 18px 0 26px;
|
||||
font-size: 14.5px;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .07em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tr:hover td { background: var(--panel); }
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: .88em;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
pre {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 18px 20px;
|
||||
overflow-x: auto;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
pre code { background: none; border: 0; padding: 0; }
|
||||
blockquote {
|
||||
margin: 18px 0;
|
||||
padding: 2px 0 2px 20px;
|
||||
border-left: 2px solid var(--accent);
|
||||
color: var(--fg);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.wrap { grid-template-columns: 1fr; gap: 0; padding: 32px 20px 80px; }
|
||||
nav { position: static; max-height: none; border-left: 0; border-bottom: 1px solid var(--line);
|
||||
padding: 0 0 20px; margin-bottom: 28px; columns: 2; }
|
||||
nav a { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
nav a:hover { border-left: 0; }
|
||||
h1 { font-size: 27px; }
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; color: #111; }
|
||||
nav { display: none; }
|
||||
.wrap { display: block; max-width: none; padding: 0; }
|
||||
a { color: #111; }
|
||||
code, pre { background: #f4f4f4; border-color: #ddd; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<nav><a href="#1-der-auftrag-in-einem-satz">1. Der Auftrag in einem Satz</a>
|
||||
<a href="#2-was-mitgeliefert-wird">2. Was mitgeliefert wird</a>
|
||||
<a href="#3-systemumgebung">3. Systemumgebung</a>
|
||||
<a href="#4-datenquellen">4. Datenquellen</a>
|
||||
<a href="#5-aufbau-der-oberflache">5. Aufbau der Oberfläche</a>
|
||||
<a href="#6-datenhaltung">6. Datenhaltung</a>
|
||||
<a href="#7-kernlogik">7. Kernlogik</a>
|
||||
<a href="#7a-bilder">7a. Bilder</a>
|
||||
<a href="#8-gestaltung">8. Gestaltung</a>
|
||||
<a href="#9-nicht-funktionale-anforderungen">9. Nicht-funktionale Anforderungen</a>
|
||||
<a href="#10-vor-baubeginn-zu-klaren">10. Vor Baubeginn zu klären</a>
|
||||
<a href="#11-abnahmekriterien">11. Abnahmekriterien</a>
|
||||
<a href="#12-ausdrucklich-nicht-bestandteil">12. Ausdrücklich nicht Bestandteil</a>
|
||||
<a href="#13-was-noch-zu-liefern-ist">13. Was noch zu liefern ist</a>
|
||||
<a href="#14-zeitlicher-rahmen">14. Zeitlicher Rahmen</a></nav>
|
||||
<main><h1 id="bauauftrag-fahrzeug-dashboard-in-home-assistant">Bauauftrag: Fahrzeug-Dashboard in Home Assistant</h1>
|
||||
<p><strong>Stand 05.08.2026 · privates Projekt, keine Veröffentlichung</strong></p>
|
||||
<p><em>Ü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.</em></p>
|
||||
<hr />
|
||||
<h2 id="1-der-auftrag-in-einem-satz">1. Der Auftrag in einem Satz</h2>
|
||||
<p>Zu bauen ist ein Fahrzeug-Dashboard in Home Assistant, das die Ende 2026 abgeschaltete App
|
||||
<em>Audi connect plug & play</em> 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.</p>
|
||||
<hr />
|
||||
<h2 id="2-was-mitgeliefert-wird">2. Was mitgeliefert wird</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Inhalt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>dashboard-muster-ohne-bilder.html</code></td>
|
||||
<td>Dieselbe Anwendung mit referenzierten statt eingebetteten Bildern, 184 statt 770 KB — die Fassung, mit der gearbeitet wird</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bilder/</code></td>
|
||||
<td>Die aufbereiteten Bilddateien, elf Stück, zusammen 435 KB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dashboard-muster.html</code></td>
|
||||
<td><strong>Vollständig bedienbarer Prototyp.</strong> Eine einzelne HTML-Datei, alle Ansichten, alle Interaktionen, Beispieldaten. Das ist die verbindliche Vorlage für Aufbau, Verhalten und Gestaltung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>audi-connect-nach-homeassistant.md</code></td>
|
||||
<td>Projektbeschreibung mit allen Entscheidungen und ihren Begründungen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>shell_beleg_parser.py</code></td>
|
||||
<td>Getesteter Parser für Shell-Tankbelege, gegen zwei echte Belege geprüft</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Schriften, Ringe, Typenschild, Fahrzeugbilder</td>
|
||||
<td>im Prototyp eingebettet, separat verfügbar</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>Der Prototyp ist keine Skizze.</strong> 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.</p>
|
||||
<hr />
|
||||
<h2 id="3-systemumgebung">3. Systemumgebung</h2>
|
||||
<ul>
|
||||
<li><strong>Home Assistant</strong>, bestehende Installation</li>
|
||||
<li><strong>Fahrzeugdaten:</strong> HACS-Integration <code>TommiG1/HA_VAG-EU-Data-Act</code>, bereits eingerichtet und liefernd</li>
|
||||
<li><strong>Positionsdaten:</strong> Home Assistant Companion App auf einem iPhone</li>
|
||||
<li><strong>Zugriff von außen:</strong> Tailscale, kein Portforwarding, kein Funnel</li>
|
||||
<li><strong>Fahrzeug:</strong> Audi RS 4 Avant competition, FIN WUAZZZF48PA902804, Erstzulassung 08/2023</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="4-datenquellen">4. Datenquellen</h2>
|
||||
<h3 id="41-eu-data-act-portal-uber-die-hacs-integration">4.1 EU-Data-Act-Portal über die HACS-Integration</h3>
|
||||
<p>Liefert Kilometerstand, Tankfüllstand, Reichweite, Serviceintervalle, Türen- und Fensterstatus,
|
||||
Verriegelung. <strong>Kein GPS.</strong> Aktualisierung im 15-Minuten-Takt und nur, wenn das Fahrzeug in diesem
|
||||
Fenster Daten hochgeladen hat. Leere Zyklen sind normal.</p>
|
||||
<p><strong>Konsequenzen, die im Entwurf bereits berücksichtigt sind:</strong></p>
|
||||
<ul>
|
||||
<li>Keine Live-Telemetrie. Momentanverbrauch, Drehzahl, Geschwindigkeit gibt es nicht.</li>
|
||||
<li>Der Kilometerstand steht nach dem Abstellen erst verzögert bereit → zweistufiger Fahrtabschluss (§7.2).</li>
|
||||
<li>Der Sensor ist zeitweise <code>unavailable</code> → betrifft den Reifenzähler (§7.5).</li>
|
||||
</ul>
|
||||
<h3 id="42-iphone">4.2 iPhone</h3>
|
||||
<p>Positionen über die Companion App. Fahrtstart und Fahrtende über die WLAN-Verbindung zum Fahrzeug
|
||||
(Companion-App-Sensor für das verbundene Netz), <strong>nicht</strong> ü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 (<code>Audi_MMI_2804_5GHz</code>) ist
|
||||
fahrzeugspezifisch, liegt im Fahrzeugprofil (§6.1) und ist in den Einstellungen änderbar (§5.6).</p>
|
||||
<h3 id="43-shell-tankbelege">4.3 Shell-Tankbelege</h3>
|
||||
<p>PDF per Upload in der App (§5.5, §7.7) — kein Postfachzugriff. Parser liegt bei. Belegnummer ist
|
||||
<strong>nicht</strong> eindeutig; Schlüssel ist der minutengenaue Zeitstempel aus der Belegkopfzeile.</p>
|
||||
<h3 id="44-manuelle-eingaben">4.4 Manuelle Eingaben</h3>
|
||||
<p>Stammdaten, Versicherung, Steuer, Reifendaten, Servicebuch, Werkstatt, Fahrten und Tankvorgänge
|
||||
ohne Beleg.</p>
|
||||
<hr />
|
||||
<h2 id="5-aufbau-der-oberflache">5. Aufbau der Oberfläche</h2>
|
||||
<p>Fünf Menüpunkte in einer festen Leiste unten, Symbole mit Beschriftung:</p>
|
||||
<pre><code>Übersicht · Mein Audi · Fahrten · Statistik · Tanken
|
||||
</code></pre>
|
||||
<p>Einstellungen liegen hinter den Audi-Ringen oben rechts.</p>
|
||||
<h3 id="51-ubersicht">5.1 Übersicht</h3>
|
||||
<p>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.</p>
|
||||
<h3 id="52-mein-audi">5.2 Mein Audi</h3>
|
||||
<p>Bildergalerie zum Durchtippen, Typenschild, dann Kacheln mit Sprung auf eigene Seiten:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kachel</th>
|
||||
<th>Unterseite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Identität</td>
|
||||
<td>Fahrzeugdaten, technische Daten, Ausstattung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Zustand</td>
|
||||
<td>Kilometerstand, Tankfüllung, Batteriespannung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Service</td>
|
||||
<td>Termine, Servicebuch, Autohaus, Terminvereinbarung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kosten</td>
|
||||
<td>Jahreskosten, Aufteilung, je Kilometer</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Versicherung und Steuer</td>
|
||||
<td>Beitrag, Vertrag, Notruf, Schutzbrief, Kfz-Steuer</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reifen</td>
|
||||
<td>beide Sätze, Umschalter, Wechseltermin, Anzugsmoment</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="53-fahrten">5.3 Fahrten</h3>
|
||||
<p>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.</p>
|
||||
<h3 id="54-statistik">5.4 Statistik</h3>
|
||||
<p>Aufklappbare Kacheln: Distanz und Fahrzeit, getankte Menge und Kosten, Durchschnittsverbrauch,
|
||||
Tag/Nacht, Art der Fahrten. Aufschlüsselung je Jahr, Monat, Woche, Tag.</p>
|
||||
<h3 id="55-tanken">5.5 Tanken</h3>
|
||||
<p>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.</p>
|
||||
<h3 id="56-einstellungen">5.6 Einstellungen</h3>
|
||||
<p>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.</p>
|
||||
<hr />
|
||||
<h2 id="6-datenhaltung">6. Datenhaltung</h2>
|
||||
<h3 id="61-drei-getrennte-bestande">6.1 Drei getrennte Bestände</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bestand</th>
|
||||
<th>Form</th>
|
||||
<th>Begründung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Fahrzeugprofil</strong></td>
|
||||
<td>eine JSON-Datei</td>
|
||||
<td>alles Fahrzeugspezifische an einem Ort, für Portierung austauschbar</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Fahrten</strong></td>
|
||||
<td>JSON Lines, eine Zeile je Fahrt</td>
|
||||
<td>wächst laufend, überlebt Purge und Neuinstallation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Tankvorgänge</strong></td>
|
||||
<td>JSON Lines</td>
|
||||
<td>dito</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Servicebuch, Versicherung, Steuer, Reifen und Werkstatt gehören ins <strong>Profil</strong>, nicht in die
|
||||
Archive — sie beschreiben das Fahrzeug, nicht seine Bewegung. Der WLAN-Name des Fahrzeugs gehört
|
||||
aus demselben Grund ebenfalls ins Profil.</p>
|
||||
<h3 id="62-portierbarkeit-ist-anforderung-nicht-zugabe">6.2 Portierbarkeit ist Anforderung, nicht Zugabe</h3>
|
||||
<p>Die Anwendung muss ohne Codeänderung für ein anderes Fahrzeug nutzbar sein. Das heißt: <strong>keine
|
||||
fahrzeugbezogene Angabe außerhalb des Profils</strong>, auch keine Bezeichnungen in Texten. Bilder werden
|
||||
referenziert, nicht eingebettet.</p>
|
||||
<p>Drei Wege der Bearbeitung sind zu unterstützen: in der Oberfläche, über Sichern und Laden der
|
||||
Datei, und direkt im Dateisystem.</p>
|
||||
<h3 id="63-fahrten-schema">6.3 Fahrten-Schema</h3>
|
||||
<p><code>trip_id</code>, <code>ts_start</code>, <code>ts_end</code>, <code>duration_s</code>, <code>distance_km</code>, <code>km_quelle</code> (<code>odometer</code> | <code>gps</code>),
|
||||
<code>odo_start</code>, <code>odo_end</code>, <code>avg_speed_kmh</code>, <code>start_lat/lon</code>, <code>end_lat/lon</code>, <code>start_address</code>,
|
||||
<code>end_address</code>, <code>art</code> (<code>privat</code> | <code>arbeitsweg</code>), <code>route</code> (GeoJSON oder <code>null</code>), <code>pausen</code>,
|
||||
<code>source</code> (<code>ha</code> | <code>manual</code> | <code>audi_connect</code>), <code>status</code> (<code>offen</code> | <code>vollständig</code>).</p>
|
||||
<h3 id="64-tankvorgang-schema">6.4 Tankvorgang-Schema</h3>
|
||||
<p><code>tank_id</code>, <code>receipt_key</code> (Zeitstempel, minutengenau), <code>receipt_no</code>, <code>tse_beleg_nr</code>, <code>ts</code>,
|
||||
<code>ts_payment</code>, <code>ts_tse</code>, <code>station_id</code>, <code>station_name</code>, <code>station_address</code>, <code>article_no</code>,
|
||||
<code>product_name</code>, <code>fuel_type</code>, <code>liters</code>, <code>list_price_per_l</code>, <code>discount</code>, <code>discount_per_l</code>,
|
||||
<code>fuel_total_eur</code>, <code>receipt_total_eur</code>, <code>price_per_l</code>, <code>net_eur</code>, <code>vat_eur</code>, <code>odometer_km</code>,
|
||||
<code>level_before_pct</code>, <code>level_after_pct</code>, <code>full_tank</code>, <code>source</code>, <code>status</code>, <code>receipt_file</code>,
|
||||
<code>edited_fields</code>.</p>
|
||||
<hr />
|
||||
<h2 id="7-kernlogik">7. Kernlogik</h2>
|
||||
<p>Diese sieben Punkte sind der eigentliche Auftrag. Die Oberfläche ist im Prototyp gelöst; hier liegt
|
||||
die Arbeit.</p>
|
||||
<h3 id="71-fahrterkennung">7.1 Fahrterkennung</h3>
|
||||
<ul>
|
||||
<li><strong>Start:</strong> iPhone verbindet sich mit dem hinterlegten Fahrzeug-WLAN (Companion-App-Sensor,
|
||||
Name in den Einstellungen hinterlegt, §5.6)</li>
|
||||
<li><strong>Ende:</strong> WLAN-Verbindung bricht ab und kommt nicht zurück</li>
|
||||
<li><strong>Pausenregel:</strong> Kommt die Verbindung binnen <em>n</em> Minuten zurück (Vorgabe 15, einstellbar), gilt
|
||||
die Fahrt als fortgesetzt. Der Datensatz wird deshalb <strong>erst nach Ablauf der Wartezeit</strong>
|
||||
geschrieben, nicht beim Abriss. Dieselbe Wartezeit löst danach auch das Kilometerstand-Screening
|
||||
aus (§7.2).</li>
|
||||
</ul>
|
||||
<h3 id="72-zweistufiger-fahrtabschluss">7.2 Zweistufiger Fahrtabschluss</h3>
|
||||
<ol>
|
||||
<li>Bei Fahrtende (WLAN-Verbindung länger als die Pausenzeit weg, §7.1): Datensatz aus GPS und
|
||||
Uhrzeit, Status <code>offen</code></li>
|
||||
<li>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 <code>vollständig</code></li>
|
||||
<li>Wird keiner gefunden, bleibt der Status <code>offen</code>. 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</li>
|
||||
</ol>
|
||||
<p>Bleibt der Wert dauerhaft aus, greift ersatzweise die GPS-Strecke, kenntlich über <code>km_quelle</code>. 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.</p>
|
||||
<p>Technische Voraussetzung: Zugriff auf die Recorder-Historie (nicht nur den aktuellen Zustand) und
|
||||
eine ausreichende Aufbewahrungsdauer des Recorders.</p>
|
||||
<h3 id="73-strecke-aus-dem-kilometerstand">7.3 Strecke aus dem Kilometerstand</h3>
|
||||
<p><code>km = Odometer(nach) − Odometer(vor)</code>. Das GPS liefert Route und Adressen, <strong>nicht</strong> die Distanz.
|
||||
Dadurch ist eine lückenhafte Positionsaufzeichnung unschädlich.</p>
|
||||
<h3 id="74-volltankung-ableiten">7.4 Volltankung ableiten</h3>
|
||||
<p>Nicht abfragen. Füllstand nach dem Tanken ≥ 97 % → Volltankung. Bei Teilbetankung wird <strong>kein</strong>
|
||||
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.</p>
|
||||
<h3 id="75-reifenzahler">7.5 Reifenzähler</h3>
|
||||
<p>Kein <code>utility_meter</code> — 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 <code>total_increasing</code> und von
|
||||
<code>utility_meter</code>-internem Zustand, der bei zeitweise <code>unavailable</code> Quellsensoren (§4.1) Strecke
|
||||
verschlucken kann — die Subtraktion braucht nur den zuletzt bekannten Absolutwert, keinen
|
||||
fortgeschriebenen Zählerstand.</p>
|
||||
<h3 id="76-serviceprognose">7.6 Serviceprognose</h3>
|
||||
<p>Termine werden <strong>nicht gespeichert, sondern berechnet</strong>: letzter passender Servicebuch-Eintrag plus
|
||||
Intervall. Gespeichert wird nur der vereinbarte Werkstatttermin.</p>
|
||||
<p>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.</p>
|
||||
<p>Ein eingestelltes Intervall darf die Herstellervorgabe nicht überschreiten.</p>
|
||||
<h3 id="77-belegverarbeitung">7.7 Belegverarbeitung</h3>
|
||||
<p>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:</p>
|
||||
<ol>
|
||||
<li>Schlüssel ist der Zeitstempel aus der Kopfzeile, minutengenau</li>
|
||||
<li>Liter, Preis und Betrag kommen vom Beleg, der Kilometerstand vom Fahrzeug</li>
|
||||
<li>Manuell geänderte Felder werden nie von einem Import überschrieben</li>
|
||||
<li>Nicht zuordenbare Fahrzeugereignisse bleiben als <code>beleg_fehlt</code> stehen</li>
|
||||
<li>Der Gesamtbetrag kann Shop-Käufe enthalten — Kraftstoffanteil separat rechnen</li>
|
||||
<li>Ohne Beleg: manuelle Eingabe von Liter und Preis als Fallback, bereits als Erfassungsweg unter
|
||||
„Tanken" vorgesehen</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="7a-bilder">7a. Bilder</h2>
|
||||
<p>Bilder werden <strong>nicht</strong> eingebettet, sondern referenziert. Sie kommen am Ende dazu; die Anwendung
|
||||
muss ohne sie vollständig bedienbar sein.</p>
|
||||
<p><strong>Ordner <code>bilder/</code>, feste Dateinamen:</strong></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datei</th>
|
||||
<th>Verwendung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>seitenansicht.webp</code>, <code>front-schraeg.webp</code>, <code>frontansicht.webp</code>, <code>heckansicht.webp</code>, <code>scheinwerfer.webp</code>, <code>cockpit.webp</code>, <code>sitze.webp</code></td>
|
||||
<td>Galerie in „Mein Audi", eine davon als Übersichtsbild</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>seitenansicht-winter.webp</code></td>
|
||||
<td>Seitenansicht mit montierten Winterrädern</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>rad-sommer.webp</code>, <code>rad-winter.webp</code></td>
|
||||
<td>Radbilder in der Reifenansicht</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>fahrbahn-schnee.webp</code></td>
|
||||
<td>Untergrund bei montierten Winterrädern</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>Drei Bedingungen, damit das Nachreichen ohne Nacharbeit gelingt:</strong></p>
|
||||
<ol>
|
||||
<li><strong>Die Flächen behalten ihre Maße.</strong> 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.</li>
|
||||
<li><strong>Die Namen stehen vorher fest.</strong> Das Übersichtsbild wird über seinen Namen ausgewählt, nicht
|
||||
über eine Position. Ein späterer Namenswechsel bricht die Konfiguration.</li>
|
||||
<li><strong>Die Bildaufbereitung ist ein eigenes Arbeitspaket</strong>, 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.</li>
|
||||
</ol>
|
||||
<p>Der beiliegende Prototyp enthält alle Bilder bereits aufbereitet; sie können übernommen werden.</p>
|
||||
<h2 id="8-gestaltung">8. Gestaltung</h2>
|
||||
<p>Verbindlich ist der Prototyp. Die Regeln dahinter:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Fläche</td>
|
||||
<td><code>#161b23</code>, Kacheln <code>#1f2733</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Text</td>
|
||||
<td><code>#FFFFFF</code>, sekundär <code>#9aa1ad</code>, Beschriftungen <code>#657081</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Akzent</td>
|
||||
<td>Progressive Red <code>#F50537</code>, sparsam</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Signalfarben</td>
|
||||
<td>Grün <code>#15da15</code>, Gelb <code>#ffaa00</code>, Rot <code>#fd2c4e</code> (Nachtmodus)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Radien</td>
|
||||
<td>Kacheln 20 px, Schaltflächen vollrund — dazwischen nichts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Schrift</td>
|
||||
<td>Audi Type: Normal für die Oberfläche, Wide Light für Kennzahlen, Extended Italic für den Fahrzeugtitel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Zahlen</td>
|
||||
<td>durchgehend Tausenderpunkt, Tabellenziffern</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Tag- und Nachtmodus sind umschaltbar, alle Farben liegen als Rollen vor. Keine Schatten, keine
|
||||
Verläufe außer der Fahrbahn unter dem Fahrzeug.</p>
|
||||
<p><strong>Rechtlich:</strong> Audi Type, Ringe und Typenschild sind lizenz- beziehungsweise markenrechtlich
|
||||
geschützt und ausschließlich für diese private, nicht veröffentlichte Installation freigegeben.</p>
|
||||
<hr />
|
||||
<h2 id="9-nicht-funktionale-anforderungen">9. Nicht-funktionale Anforderungen</h2>
|
||||
<ul>
|
||||
<li><strong>Erreichbarkeit:</strong> ü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.</li>
|
||||
<li><strong>Ausfall der Datenquelle:</strong> Veraltete Werte müssen als solche erkennbar sein. Die Anzeige
|
||||
„Sicher abgestellt" hat drei Zustände: grün, rot und <strong>unbekannt</strong> mit Zeitstempel. Kein grünes
|
||||
Häkchen auf Basis alter Daten.</li>
|
||||
<li><strong>Sicherung:</strong> Profil und Archive müssen gesichert und wiederhergestellt werden können,
|
||||
automatisch und von Hand.</li>
|
||||
<li><strong>Ausgabe:</strong> Fahrten, Tankvorgänge und Servicebuch als CSV mit Semikolon und deutschem
|
||||
Zahlenformat.</li>
|
||||
<li><strong>Kalender:</strong> Service- und Reifenwechseltermine als Eintrag in den iOS-Kalender, mit Vorwarnung.</li>
|
||||
<li><strong>Karten:</strong> Leaflet mit dem tatsächlich aufgezeichneten Track, nicht mit einer berechneten Route.
|
||||
Kachelquelle wechselt mit dem Modus.</li>
|
||||
<li><strong>Notrufnummern</strong> müssen auch dann erreichbar sein, wenn die Anwendung nicht lädt.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="10-vor-baubeginn-zu-klaren">10. Vor Baubeginn zu klären</h2>
|
||||
<p>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.</p>
|
||||
<ol>
|
||||
<li><strong>Einbindung der Oberfläche.</strong> 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?<br>
|
||||
<strong>Entscheidung:</strong> <code>panel_custom</code> — 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 <code>hass</code>-Objekt injiziert (Live-Zustände per WebSocket,
|
||||
<code>callService()</code> für Aktionen); die bestehenden Render-Funktionen (<code>vHome()</code>, <code>vTrips()</code> usw.)
|
||||
bleiben erhalten, nur die Datenquelle wechselt von den Beispielarrays auf <code>hass.states</code>.</li>
|
||||
<li><strong>Zurückschreiben von Daten.</strong> Wie schreibt die Oberfläche Dateien im Home-Assistant-Dateisystem?
|
||||
Eigene Integration, AppDaemon, pyscript, Skript im Add-on? Diese Entscheidung bestimmt den
|
||||
gesamten Aufbau.<br>
|
||||
<strong>Entscheidung:</strong> <code>pyscript</code> für die gesamte Logik — Fahrterkennung, Fahrtabschluss,
|
||||
Belegverarbeitung, Dateizugriff. Läuft installationsunabhängig (Core, Container, OS,
|
||||
Supervised), im HA-Python-Kontext, mit <code>@service</code> für aufrufbare Aktionen und
|
||||
<code>@time_trigger</code>/<code>task.sleep</code> für die Wartezeit-Logik aus §7.1.</li>
|
||||
<li><strong>Bluetooth- und WLAN-Erkennung unter iOS.</strong> Meldet die Companion App die Verbindung zum
|
||||
Fahrzeug zuverlässig genug für die Fahrterkennung, oder braucht es Kurzbefehle? <strong>Das ist die
|
||||
riskanteste Annahme des Entwurfs</strong> — trägt sie nicht, muss die Fahrterkennung anders gelöst
|
||||
werden.<br>
|
||||
<strong>Entscheidung:</strong> 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).</li>
|
||||
<li><strong>Verzögerung des Kilometerstands.</strong> Wie lange dauert es nach dem Abstellen tatsächlich, bis der
|
||||
Wert nachkommt? Bestimmt die Wartezeit im zweistufigen Abschluss.<br>
|
||||
<strong>Entscheidung:</strong> 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).</li>
|
||||
<li><strong>Auflösung des Tankfüllstands.</strong> Prozentgenau oder gerastert? Bestimmt, ob die 97-Prozent-Regel
|
||||
für die Volltankung trägt.<br>
|
||||
<strong>Entscheidung:</strong> 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).</li>
|
||||
<li><strong>Meldet die Integration den Kilometerstand als <code>total_increasing</code>?</strong> Falls nicht, ist ein
|
||||
vorgeschalteter Template-Sensor nötig, sonst funktioniert der Reifenzähler nicht.<br>
|
||||
<strong>Entscheidung:</strong> Frage erübrigt sich — kein <code>utility_meter</code>, kein Template-Sensor. Der
|
||||
Reifenzähler rechnet direkt mit dem absoluten Kilometerstand gegen den im Profil hinterlegten
|
||||
Startwert je Satz (§7.5).</li>
|
||||
<li><strong>Abholung der Belege aus dem Postfach.</strong> Welcher Weg, und wo läuft der Parser?<br>
|
||||
<strong>Entscheidung:</strong> 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.</li>
|
||||
<li><strong>Verhalten des Tailscale-Tunnels beim Losfahren.</strong> Steht er rechtzeitig für den Fahrtstart?<br>
|
||||
<strong>Entscheidung:</strong> 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.</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="11-abnahmekriterien">11. Abnahmekriterien</h2>
|
||||
<ol>
|
||||
<li>Eine Fahrt wird ohne Zutun erfasst, mit korrekten Kilometern aus dem Fahrzeug, und erscheint
|
||||
vollständig in der Liste.</li>
|
||||
<li>Ein Tankstopp mitten in einer Fahrt erzeugt <strong>eine</strong> Fahrt, nicht zwei.</li>
|
||||
<li>Ein hochgeladener Shell-Beleg erzeugt einen vollständigen Tankvorgang. Derselbe Beleg ein
|
||||
zweites Mal hochgeladen erzeugt keinen zweiten.</li>
|
||||
<li>Eine manuelle Korrektur überlebt den nächsten Import.</li>
|
||||
<li>Ein Eintrag im Servicebuch verschiebt die zugehörige Fälligkeit.</li>
|
||||
<li>Das Umschalten der Reifen leitet die Kilometer auf den anderen Satz um und verliert bei einem
|
||||
Neustart nichts.</li>
|
||||
<li>Fällt die Datenquelle aus, zeigt die Oberfläche das an, statt alte Werte als aktuell auszugeben.</li>
|
||||
<li>Ein Fahrzeugprofil lässt sich ausgeben, in einem Texteditor ändern und wieder einlesen.</li>
|
||||
<li>Die Oberfläche ist über Tailscale erreichbar, ohne dass Home Assistant öffentlich steht.</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="12-ausdrucklich-nicht-bestandteil">12. Ausdrücklich nicht Bestandteil</h2>
|
||||
<ul>
|
||||
<li>Fernsteuerung des Fahrzeugs — der Data-Act-Zugang ist ausschließlich lesend</li>
|
||||
<li>Fehlerspeicher und Live-Telemetrie über OBD</li>
|
||||
<li>Fahrstil- oder Effizienzbewertung</li>
|
||||
<li>Höchstgeschwindigkeit je Fahrt</li>
|
||||
<li>Mehrbenutzerbetrieb</li>
|
||||
<li>Veröffentlichung, Weitergabe oder Verwendung der Marken- und Schriftlizenzen außerhalb dieser
|
||||
Installation</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2 id="13-was-noch-zu-liefern-ist">13. Was noch zu liefern ist</h2>
|
||||
<p>Vom Auftraggeber, vor oder während des Baus:</p>
|
||||
<ul>
|
||||
<li>Bilder der Winterräder in höherer Auflösung</li>
|
||||
<li>Fälligkeitsdatum der Kfz-Steuer</li>
|
||||
<li>Reifendaten je Satz: Hersteller, Modell, DOT, bisherige Laufleistung</li>
|
||||
</ul>
|
||||
<p>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
|
||||
(<code>Audi_MMI_2804_5GHz</code>) und in den Einstellungen hinterlegt (§5.6).</p>
|
||||
<hr />
|
||||
<h2 id="14-zeitlicher-rahmen">14. Zeitlicher Rahmen</h2>
|
||||
<p><strong>Eine Frist ist hart:</strong> 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.</p>
|
||||
<p>Der <strong>Import</strong> 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.</p>
|
||||
<p>Alles Übrige lässt sich auch nach der Abschaltung bauen.</p></main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+449
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
Generated
+47
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string>((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;
|
||||
@@ -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<Profil> {
|
||||
return this.rest.datenLesen<Profil>(ENTITAETEN.profil);
|
||||
}
|
||||
|
||||
fahrtenLesen(): Promise<Fahrt[]> {
|
||||
return this.rest.datenLesen<Fahrt[]>(ENTITAETEN.fahrten);
|
||||
}
|
||||
|
||||
tankvorgaengeLesen(): Promise<Tankvorgang[]> {
|
||||
return this.rest.datenLesen<Tankvorgang[]>(ENTITAETEN.tankvorgaenge);
|
||||
}
|
||||
|
||||
fahrzeugstatusLesen(): Promise<Fahrzeugstatus> {
|
||||
return this.rest.datenLesen<Fahrzeugstatus>(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<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_profil_schreiben",
|
||||
{ profil_json: JSON.stringify(profil) },
|
||||
"Fahrzeugdaten speichern",
|
||||
);
|
||||
}
|
||||
|
||||
belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_beleg_hochladen",
|
||||
{ pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) },
|
||||
`Beleg „${dateiname}" hochladen`,
|
||||
);
|
||||
}
|
||||
|
||||
fahrtLoeschen(tripId: string): Promise<unknown> {
|
||||
return this.warteschlange.einreihen(
|
||||
"pyscript",
|
||||
"audi_dashboard_fahrt_loeschen",
|
||||
{ trip_id: tripId },
|
||||
"Fahrt löschen",
|
||||
);
|
||||
}
|
||||
|
||||
tankvorgangLoeschen(tankId: string): Promise<unknown> {
|
||||
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<unknown> {
|
||||
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren");
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
#beendet = false;
|
||||
|
||||
#zustandsHorcher = new Set<ZustandsHorcher>();
|
||||
#verbindungsHorcher = new Set<VerbindungsHorcher>();
|
||||
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Zugang> {
|
||||
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<T>(pfad: string, init: RequestInit, opt: AnfrageOptionen = {}): Promise<T> {
|
||||
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<A extends Record<string, unknown>>(
|
||||
entityId: string,
|
||||
opt?: AnfrageOptionen,
|
||||
): Promise<HassState<A>> {
|
||||
return this.#anfrage<HassState<A>>(`/api/states/${encodeURIComponent(entityId)}`, { method: "GET" }, opt);
|
||||
}
|
||||
|
||||
/** Liest eine pyscript-Entität und packt gleich die Nutzlast aus. */
|
||||
async datenLesen<T>(entityId: string, opt?: AnfrageOptionen): Promise<T> {
|
||||
const zustand = await this.zustandLesen<{ daten: T }>(entityId, opt);
|
||||
return (zustand as DatenEntity<T>).attributes.daten;
|
||||
}
|
||||
|
||||
/** Ruft einen Dienst auf, z. B. dienstAufrufen("pyscript",
|
||||
"audi_dashboard_profil_schreiben", { profil_json }). */
|
||||
async dienstAufrufen(
|
||||
bereich: string,
|
||||
dienst: string,
|
||||
daten: Record<string, unknown> = {},
|
||||
opt?: AnfrageOptionen,
|
||||
): Promise<unknown> {
|
||||
return this.#anfrage<unknown>(
|
||||
`/api/services/${encodeURIComponent(bereich)}/${encodeURIComponent(dienst)}`,
|
||||
{ method: "POST", body: JSON.stringify(daten) },
|
||||
opt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<A = Record<string, unknown>> {
|
||||
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<T> {
|
||||
daten: T;
|
||||
[weitere: string]: unknown;
|
||||
}
|
||||
|
||||
export type DatenEntity<T> = HassState<DatenAttribute<T>>;
|
||||
|
||||
/* ----------------------------------------------------------- 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<string, unknown>;
|
||||
einstellungen?: Record<string, unknown>;
|
||||
versicherung?: Record<string, unknown>;
|
||||
steuer?: Record<string, unknown>;
|
||||
reifen?: Record<string, unknown>;
|
||||
service?: Record<string, unknown>;
|
||||
technik?: Record<string, unknown>;
|
||||
ausstattung?: Record<string, unknown>;
|
||||
[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;
|
||||
@@ -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<string | null>;
|
||||
schreiben(schluessel: string, wert: string): Promise<void>;
|
||||
loeschen(schluessel: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<string, string>();
|
||||
|
||||
#ls(): Storage | null {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async lesen(schluessel: string): Promise<string | null> {
|
||||
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<void> {
|
||||
this.#speicher.set(schluessel, wert);
|
||||
try {
|
||||
this.#ls()?.setItem(schluessel, wert);
|
||||
} catch {
|
||||
/* Nur im Arbeitsspeicher - besser als ein Absturz. */
|
||||
}
|
||||
}
|
||||
|
||||
async loeschen(schluessel: string): Promise<void> {
|
||||
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<Zugang | null> {
|
||||
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<void> {
|
||||
const a = ablage();
|
||||
await Promise.all([
|
||||
a.schreiben(SCHLUESSEL_BASIS, basisUrlNormalisieren(zugang.basisUrl)),
|
||||
a.schreiben(SCHLUESSEL_TOKEN, zugang.token),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function zugangVerwerfen(): Promise<void> {
|
||||
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`;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
/** 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<WarteschlangenHorcher>();
|
||||
|
||||
constructor(private readonly rest: HassRest) {}
|
||||
|
||||
async laden(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, unknown>,
|
||||
beschreibung: string,
|
||||
): Promise<WartenderAuftrag> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 `<div className="ads-root" data-theme="nacht">` (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.
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
## Usage conventions
|
||||
|
||||
- **Always wrap the tree that uses these components in `<div className="ads-root" data-theme="nacht">` (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.
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { Accordion, LeafRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Open() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 300 }}>
|
||||
<Accordion title="Reifen" summary="4,2 mm" defaultOpen>
|
||||
<LeafRow title="Vorne" value="4,2 mm" />
|
||||
<LeafRow title="Hinten" value="3,8 mm" />
|
||||
</Accordion>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Closed() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 300 }}>
|
||||
<Accordion title="Versicherung" summary="248 €/Jahr">
|
||||
<LeafRow title="Haftpflicht" value="180 €" />
|
||||
<LeafRow title="Teilkasko" value="68 €" />
|
||||
</Accordion>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function NestedLevel2() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 300 }}>
|
||||
<Accordion title="Service" summary="Nächster Termin" defaultOpen>
|
||||
<Accordion title="Inspektion" level={2} defaultOpen>
|
||||
<LeafRow title="Fällig bei" value="45.000 km" />
|
||||
</Accordion>
|
||||
<Accordion title="Ölwechsel" level={2}>
|
||||
<LeafRow title="Fällig bei" value="47.500 km" />
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { ActionButton } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<ActionButton>Speichern</ActionButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Destructive() {
|
||||
return (
|
||||
<Root>
|
||||
<ActionButton variant="destructive">Fahrt löschen</ActionButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<Root>
|
||||
<ActionButton disabled>Speichern</ActionButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Group() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<ActionButton>Übernehmen</ActionButton>
|
||||
<ActionButton variant="destructive">Verwerfen</ActionButton>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Feld, Switch } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadOnlyValue() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<Feld label="Kennzeichen" value="M-AB 1234" />
|
||||
<Feld label="Erstzulassung" value="03/2022" last />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithUnit() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<Feld label="Anzugsmoment" unit="Nm" last>
|
||||
<input type="number" defaultValue={120} style={{ width: 60, textAlign: "right" }} />
|
||||
</Feld>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithControl() {
|
||||
const [on, setOn] = React.useState(true);
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<Feld label="Benachrichtigungen" last>
|
||||
<Switch checked={on} onChange={setOn} aria-label="Benachrichtigungen" />
|
||||
</Feld>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { Fig } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={128} unit="km" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function LargeHero() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={64} unit="%" size={40} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Compact() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={7.2} unit="l/100km" size={17} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoUnit() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={2.4} size={28} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { IconButton } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GearIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M12 2.5v3M12 18.5v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2.5 12h3M18.5 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BackIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M15 5l-7 7 7 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloseIcon = (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
<IconButton aria-label="Zurück">{BackIcon}</IconButton>
|
||||
<IconButton aria-label="Einstellungen">{GearIcon}</IconButton>
|
||||
<IconButton aria-label="Schließen">{CloseIcon}</IconButton>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
<IconButton aria-label="Einstellungen" disabled>
|
||||
{GearIcon}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { ImagePlaceholder } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty() {
|
||||
return (
|
||||
<Root>
|
||||
<ImagePlaceholder alt="Fahrzeugfoto" label="Kein Foto" hint="foto.jpg" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Mini() {
|
||||
return (
|
||||
<Root>
|
||||
<ImagePlaceholder alt="Vorschau" label="Kein Foto" size="mini" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Round() {
|
||||
return (
|
||||
<Root>
|
||||
<ImagePlaceholder alt="Profilbild" label="Kein Bild" size="round" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { LeafRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<LeafRow title="Sommerreifen" subTitle="Vorne" value="4,2 mm" onClick={() => {}} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function List() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<LeafRow title="Sommerreifen" subTitle="Vorne" value="4,2 mm" onClick={() => {}} />
|
||||
<LeafRow title="Sommerreifen" subTitle="Hinten" value="3,8 mm" onClick={() => {}} />
|
||||
<LeafRow title="Winterreifen" subTitle="Eingelagert" value="6,1 mm" subValue="seit Mai" onClick={() => {}} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { Pill } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Pill>Elektro</Pill>
|
||||
<Pill>Automatik</Pill>
|
||||
<Pill>5 Sitze</Pill>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Work() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Pill variant="work">Neu</Pill>
|
||||
<Pill variant="work">-15%</Pill>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Mixed() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Pill>Diesel</Pill>
|
||||
<Pill variant="work">SmartDeal</Pill>
|
||||
<Pill>4x4</Pill>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="ads-root" data-theme="nacht" style={{ position: "relative", width: "100%", height, padding: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Menu() {
|
||||
return (
|
||||
<Root height={260}>
|
||||
<Popup open onClose={() => {}} variant="menu" anchor="center">
|
||||
<PopupMenuItem onClick={() => {}}>Foto ändern</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}}>Foto entfernen</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}} destructive>
|
||||
Fahrzeug löschen
|
||||
</PopupMenuItem>
|
||||
</Popup>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Form() {
|
||||
return (
|
||||
<Root height={280}>
|
||||
<Popup open onClose={() => {}} variant="form" anchor="center">
|
||||
<label className="ads-label">Gültig bis</label>
|
||||
<input type="date" defaultValue="2026-12-31" />
|
||||
</Popup>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import { PopupMenuItem } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div
|
||||
className="ads-popup ads-popup--menu"
|
||||
style={{ position: "static", transform: "none", display: "inline-flex", flexDirection: "column" }}
|
||||
>
|
||||
<PopupMenuItem onClick={() => {}}>Foto ändern</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}}>Details anzeigen</PopupMenuItem>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Destructive() {
|
||||
return (
|
||||
<Root>
|
||||
<div
|
||||
className="ads-popup ads-popup--menu"
|
||||
style={{ position: "static", transform: "none", display: "inline-flex", flexDirection: "column" }}
|
||||
>
|
||||
<PopupMenuItem onClick={() => {}}>Bearbeiten</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}} destructive>
|
||||
Löschen
|
||||
</PopupMenuItem>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { ProgressBar } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleFill() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<ProgressBar percent={68} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedWithLegend() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: 4, color: "var(--ok)", label: "Kraftstoff" },
|
||||
{ value: 2, color: "var(--warn)", label: "Verschleiß" },
|
||||
{ value: 1, color: "var(--bad)", label: "Wartung" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleSegmentNoLegend() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<ProgressBar segments={[{ value: 1, color: "var(--fg)" }]} showLegend={false} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { ProgressRing } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={64} unit="%" label="Ladestand" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomValue() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={82} value="312" unit="km" label="Reichweite" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Low() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={12} unit="%" label="Kraftstoff" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Small() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={45} unit="%" size={90} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { RowList } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<RowList
|
||||
items={[
|
||||
{ key: "km", label: "Kilometerstand", value: "42.318 km" },
|
||||
{ key: "verbrauch", label: "Ø Verbrauch", value: "6,8 l/100km" },
|
||||
{ key: "letzte", label: "Letzte Fahrt", value: "12 km", subValue: "vor 2 Stunden" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithSubValues() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<RowList
|
||||
items={[
|
||||
{ key: "vorne", label: "Solldruck vorne", value: "2,3 bar", subValue: "kalt gemessen" },
|
||||
{ key: "hinten", label: "Solldruck hinten", value: "2,1 bar", subValue: "kalt gemessen" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { Seg } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoOptions() {
|
||||
const [value, setValue] = React.useState<"nacht" | "tag">("nacht");
|
||||
return (
|
||||
<Root>
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "nacht", label: "Nacht" },
|
||||
{ value: "tag", label: "Tag" },
|
||||
]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreeOptions() {
|
||||
const [value, setValue] = React.useState("woche");
|
||||
return (
|
||||
<Root>
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "tag", label: "Tag" },
|
||||
{ value: "woche", label: "Woche" },
|
||||
{ value: "monat", label: "Monat" },
|
||||
]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { StatGrid } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FourColumns() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 320 }}>
|
||||
<StatGrid
|
||||
items={[
|
||||
{ value: 42318, caption: "Kilometer" },
|
||||
{ value: 6.8, unit: "l", caption: "Ø Verbrauch" },
|
||||
{ value: 12, caption: "Fahrten" },
|
||||
{ value: 312, unit: "km", caption: "Reichweite" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoColumns() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 200 }}>
|
||||
<StatGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{ value: 2.3, unit: "bar", caption: "Vorne" },
|
||||
{ value: 2.1, unit: "bar", caption: "Hinten" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { StatusRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllStatuses() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, width: 260 }}>
|
||||
<StatusRow status="ok" title="Reifendruck" subtitle="Alle vier in Ordnung" />
|
||||
<StatusRow status="warn" title="Ölstand" subtitle="Nächster Wechsel in 800 km" />
|
||||
<StatusRow status="bad" title="Batterie" subtitle="Spannung niedrig" />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Clickable() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<StatusRow status="warn" title="Service fällig" subtitle="Tippen für Details" onClick={() => {}} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { SwipeRow, LeafRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Closed() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<SwipeRow onDelete={() => {}} deleteLabel="Löschen">
|
||||
<LeafRow title="Fahrt nach Hause" subTitle="Heute, 08:12" value="12 km" />
|
||||
</SwipeRow>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwipedOpen() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<SwipeRow onDelete={() => {}} deleteLabel="Löschen" swiped>
|
||||
<LeafRow title="Fahrt zur Arbeit" subTitle="Gestern, 17:40" value="18 km" />
|
||||
</SwipeRow>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { Switch } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function On() {
|
||||
const [checked, setChecked] = React.useState(true);
|
||||
return (
|
||||
<Root>
|
||||
<Switch checked={checked} onChange={setChecked} aria-label="Benachrichtigungen" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Off() {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
return (
|
||||
<Root>
|
||||
<Switch checked={checked} onChange={setChecked} aria-label="Benachrichtigungen" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<Root>
|
||||
<Switch checked={true} onChange={() => {}} disabled aria-label="Benachrichtigungen" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { TabBar } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HomeIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M4 11l8-7 8 7v9a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1v-9Z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const ListIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
const CarIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M4 16v-3l2-5h12l2 5v3M4 16h16M4 16v2M20 16v2M7 19v-2M17 19v-2" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const GearIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M12 2.5v3M12 18.5v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2.5 12h3M18.5 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Root>
|
||||
<div style={{ width: 340 }}>
|
||||
<TabBar items={items} activeKey={active} onChange={setActive} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconOnly() {
|
||||
const [active, setActive] = React.useState("trips");
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 220 }}>
|
||||
<TabBar items={items} activeKey={active} onChange={setActive} iconOnly />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { Tile, Fig } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 240 }}>
|
||||
<Tile>
|
||||
<div className="ads-label">Reichweite</div>
|
||||
<Fig value={312} unit="km" size={28} />
|
||||
</Tile>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Flat() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 240 }}>
|
||||
<Tile variant="flat">Zusatzinformation ohne Rahmenschatten</Tile>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonWithChevron() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 240 }}>
|
||||
<Tile variant="button" chevron onClick={() => {}}>
|
||||
Alle Fahrten anzeigen
|
||||
</Tile>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.ds-sync/
|
||||
ds-bundle/
|
||||
.design-sync/.cache/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
@@ -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 (
|
||||
<div className="ads-root" data-theme="nacht">
|
||||
<Tile>
|
||||
<Feld label="Beispiel">
|
||||
<Switch checked={true} onChange={() => {}} />
|
||||
</Feld>
|
||||
</Tile>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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 `<Name>.tsx`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
Generated
+1632
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={classes.join(" ")}>
|
||||
<button type="button" className="ads-acc-h" onClick={toggle} aria-expanded={isOpen}>
|
||||
<span className="ads-acc-h__k">{title}</span>
|
||||
{summary !== undefined && <span className="ads-acc-h__v">{summary}</span>}
|
||||
<span className="ads-mark" aria-hidden />
|
||||
</button>
|
||||
<div className={isOpen ? "ads-acc-b ads-acc-b--on" : "ads-acc-b"}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Tag type={onClick ? "button" : undefined} className={classes.join(" ")} onClick={onClick}>
|
||||
<span className="ads-leaf__k">
|
||||
{title}
|
||||
{subTitle !== undefined && <small>{subTitle}</small>}
|
||||
</span>
|
||||
<span className="ads-leaf__v">
|
||||
{value}
|
||||
{subValue !== undefined && <small>{subValue}</small>}
|
||||
</span>
|
||||
<svg className="ads-chev" viewBox="0 0 6 10" aria-hidden>
|
||||
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Accordion } from "./Accordion";
|
||||
export type { AccordionProps } from "./Accordion";
|
||||
export { LeafRow } from "./LeafRow";
|
||||
export type { LeafRowProps } from "./LeafRow";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<button type={type} className={classes.join(" ")} onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ActionButton } from "./ActionButton";
|
||||
export type { ActionButtonProps } from "./ActionButton";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<span className="ads-mit-einheit">
|
||||
{children}
|
||||
<span>{unit}</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
<label>{label}</label>
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Feld } from "./Feld";
|
||||
export type { FeldProps } from "./Feld";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={classes.join(" ")} style={style}>
|
||||
{value}
|
||||
{unit !== undefined && <span className="ads-fig__unit">{unit}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Fig } from "./Fig";
|
||||
export type { FigProps } from "./Fig";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.join(" ")}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { IconButton } from "./IconButton";
|
||||
export type { IconButtonProps } from "./IconButton";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = (
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5Zm2 0v14h12V5H6Zm3 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm-1 8 3-3 2 2 3-4 3 5H8Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className={classes.join(" ")}>
|
||||
{!showPlaceholder && (
|
||||
<img src={src} alt={alt} onError={() => setFailed(true)} />
|
||||
)}
|
||||
{showPlaceholder && (
|
||||
<div className="ads-platzhalter">
|
||||
<span className="ads-platzhalter__icon">{icon ?? DEFAULT_ICON}</span>
|
||||
<span className="ads-platzhalter__label">{label}</span>
|
||||
{hint !== undefined && <span className="ads-platzhalter__hint">{hint}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ImagePlaceholder } from "./ImagePlaceholder";
|
||||
export type { ImagePlaceholderProps } from "./ImagePlaceholder";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <span className={classes.join(" ")}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Pill } from "./Pill";
|
||||
export type { PillProps } from "./Pill";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = (
|
||||
<>
|
||||
<div className="ads-popup-catcher" onClick={onClose} />
|
||||
<div className={classes.join(" ")} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={destructive ? "ads-popup-item ads-popup-item--destructive" : "ads-popup-item"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Popup, PopupMenuItem } from "./Popup";
|
||||
export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./Popup";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={classes.join(" ")}>
|
||||
<div className="ads-bar">
|
||||
{segments.map((seg, i) => (
|
||||
<i
|
||||
key={i}
|
||||
style={{ flex: seg.value / total, background: seg.color ?? "var(--fg)" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{legend && (
|
||||
<div className="ads-legend">
|
||||
{segments.map((seg, i) => (
|
||||
<span key={i}>
|
||||
<i style={{ background: seg.color ?? "var(--fg)" }} />
|
||||
{seg.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, percent ?? 0));
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
<div className="ads-balken">
|
||||
<i style={{ width: `${clamped}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export type { ProgressBarProps, ProgressBarSegment } from "./ProgressBar";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={classes.join(" ")} style={{ width: size, height: size }}>
|
||||
<svg viewBox="0 0 150 150" width={size} height={size}>
|
||||
<circle cx="75" cy="75" r={RADIUS} className="ads-ring__track" />
|
||||
<circle
|
||||
cx="75"
|
||||
cy="75"
|
||||
r={RADIUS}
|
||||
className="ads-ring__arc"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ads-ring__mid">
|
||||
<Fig value={value ?? Math.round(clamped)} unit={unit} />
|
||||
{label !== undefined && <span className="ads-label">{label}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProgressRing } from "./ProgressRing";
|
||||
export type { ProgressRingProps } from "./ProgressRing";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<dl className={classes.join(" ")}>
|
||||
{items.map((item) => (
|
||||
<div className="ads-row" key={item.key}>
|
||||
<dt>{item.label}</dt>
|
||||
<dd>
|
||||
{item.value}
|
||||
{item.subValue !== undefined && <small>{item.subValue}</small>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RowList } from "./RowList";
|
||||
export type { RowListProps, RowListItem } from "./RowList";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SegOption<T extends string = string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface SegProps<T extends string = string> {
|
||||
options: SegOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Seg<T extends string = string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SegProps<T>) {
|
||||
const classes = ["ads-seg"];
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={opt.value === value ? "ads-seg__on" : undefined}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Seg } from "./Seg";
|
||||
export type { SegProps, SegOption } from "./Seg";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={classes.join(" ")} style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i}>
|
||||
<Fig className="ads-quad__n" value={item.value} unit={item.unit} size={17} />
|
||||
<span className="ads-quad__l">{item.caption}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StatGrid } from "./StatGrid";
|
||||
export type { StatGridProps, StatGridItem } from "./StatGrid";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<Tag
|
||||
type={onClick ? "button" : undefined}
|
||||
className={classes.join(" ")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`ads-dot ads-dot--${status}`} aria-hidden />
|
||||
<span className="ads-status__text">
|
||||
<span className="ads-status__t">{title}</span>
|
||||
{subtitle !== undefined && <span className="ads-status__s">{subtitle}</span>}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StatusRow } from "./StatusRow";
|
||||
export type { StatusRowProps } from "./StatusRow";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<number | null>(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 (
|
||||
<div className={classes.join(" ")}>
|
||||
<button type="button" className="ads-swipe-delete" onClick={onDelete}>
|
||||
{deleteLabel}
|
||||
</button>
|
||||
<div
|
||||
className={isSwiped ? "ads-swipe-content ads-swiped" : "ads-swipe-content"}
|
||||
style={{ transform: isSwiped ? `translateX(${SWIPE_OPEN}px)` : undefined }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SwipeRow } from "./SwipeRow";
|
||||
export type { SwipeRowProps } from "./SwipeRow";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={classes.join(" ")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id={id}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
{...rest}
|
||||
/>
|
||||
<span className="ads-switch__track" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Switch } from "./Switch";
|
||||
export type { SwitchProps } from "./Switch";
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user