diff --git a/AGENTS.md b/AGENTS.md index d061458..0fe65bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ build`, since companion-app needs a live backend connection the local dev server 2026-08-23: **retroactive data import** — see section F below for the full entry. In short: HA's recorder keeps sensor history only 10 days by default, which silently capped how far back anything could ever be reconstructed; `recorder_snippet.yaml` raises that to a year, and a new -`pyscript/historienimport.py` + "Daten importieren aus Home Assistant" button (both codebases) +`historienimport.py` + "Daten importieren aus Home Assistant" button (both codebases) rebuilds trips, refuels and battery history for any past window out of that recorder history. This file is the entry point for every new agent session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in @@ -188,7 +188,8 @@ assets. Never mix these the other way around. | Area | What | Status | |---|---|---| -| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** — to be replaced by the app | +| `custom_components/audi_dashboard/` | The HA integration: backend + panel, installable via HACS | ✅ **finished, in use** — replaced the pyscript backend on 2026-08-23 (section H) | +| `homeassistant/` | Install package, recorder snippet, guides — **no code any more** | ✅ | | `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible | | `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) | | `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data | @@ -198,17 +199,20 @@ Root files: `dashboard-muster*.html` = original static prototype (superseded, re `bauauftrag.md`/`.html` = original build brief (historical), `DESIGN_BRIEF_DATAMETRIC360.md` = the prompt for the Claude Design project. -**`homeassistant/` in one paragraph:** 10 pyscript scripts + 5 modules (`pyscript/modules/`); trip -detection via the FMM003 ignition sensor with pause tolerance (the iPhone WLAN sensor is gone — see -section B), two-stage trip completion via HA history screening (odometer often updates only on the -next trip), fill-up detection on fuel-level rise, Shell PDF parser (`data/shell_beleg_parser.py`, -subprocess, the only tested part of the repo), tire km counter, backup, self-update, image -management. Frontend: one file `www/audi-dashboard-app.js` -(~4,000 lines, custom element, no framework/bundler), 5 tabs + ~19 detail routes, cache-busting via -`audi-dashboard-version.json` + loader stub. Entity IDs are assigned **in the UI** (Settings → -Fahrzeug einrichten → Setup), stored as overrides in `data/entitaeten.json`; -`pyscript/modules/einstellungen.py` holds only the built-in defaults. Deploy: `update.ps1` -(robocopy to Samba share) or — still inactive — self-update from git. +**`custom_components/audi_dashboard/` in one paragraph:** a normal HA integration with a config +flow, one `sensor` platform and 18 services. `koordinator.py` owns the runtime state and the +wiring; the domain modules beside it carry the logic: trip detection via the FMM003 ignition +sensor with pause tolerance (`fahrterkennung.py`), two-stage trip completion via recorder history +(`screening.py` — the odometer often updates only on the next trip), fill-up detection on +fuel-level rise (`tankerkennung.py`), Shell PDF parser (`shell_beleg_parser.py`, in-process via +`pypdf`, the only tested part of the repo), tire km counter, backup, image management, retroactive +history import. `ablage.py` is the only place that touches files. Frontend: `frontend/audi-dashboard-app.js` +(~4,900 lines, custom element, no framework/bundler), 5 tabs + ~19 detail routes, served by the +integration itself under `/audi_dashboard_static/` with the manifest version as the cache breaker. +Entity IDs are assigned **in the UI** (Settings → Fahrzeug einrichten → Setup), stored as overrides +in `/config/audi_dashboard/entitaeten.json`; `einstellungen.py` holds only the built-in defaults +(all empty). Deploy: HACS, or `homeassistant/installationspaket/install.ps1` where HACS can't reach +the repo. **`companion-app/` — what exists:** the full app. Data layer (`src/api/`: REST, WebSocket with reconnect backoff, persistent offline write queue, credential storage), domain logic @@ -356,10 +360,13 @@ vendored MIT library served from Home Assistant itself, which satisfies the actu ## Pending: this branch has diverged from `main` (noted 2026-08-13) -`ha_install.md` (root) plans the move from pyscript to a **native HA integration** with a config -flow — UI-only setup, no YAML, and updates via a self-reporting `UpdateEntity` against the Gitea -repo (HACS is GitHub-only, so it is not an option). All APIs in it were verified against the -running 2026.8.1 instance. It also records which review findings that move eliminates by design. +`ha_install.md` (root) planned the move from pyscript to a **native HA integration** with a config +flow — UI-only setup, no YAML. **That move happened on 2026-08-23; see section H.** The plan +document is now historical: it still argued for a self-reporting `UpdateEntity` against the Gitea +repo because HACS is GitHub-only, and that part was dropped — the built-in updater was removed +rather than reimplemented, and updates go through HACS (or the install script where HACS cannot +reach the repo). All APIs in it were verified against the running 2026.8.1 instance. It also +records which review findings that move eliminates by design. A full review of `main`'s 18 new commits is in `REVIEW_main_2026-08-13.md` — 15 findings, the three most serious in the new setup menu (saving with an unloaded catalogue wipes the whole @@ -1793,71 +1800,137 @@ especially for how long a full year takes (the UI waits up to two minutes before in the background). Address reverse-geocoding is not attempted for imported trips (`start_address`/ `end_address` stay null, same as live-detected ones before the user edits them). -### H) HACS: deferred by decision, and the deployed-parity problem it exposed (2026-08-23) +### H) HACS: the pyscript backend became a real integration (2026-08-23) -**Decision: no HACS conversion for now.** Owner asked whether the app could be installed via HACS. -Checked against the actually installed HACS 2.0.5 rather than from memory — it cannot, for two -independent reasons: +Owner asked for it directly ("build the hacs") after deferring it earlier the same day. The +deferral reasoning still held — converting first means debugging the rewrite and the real +environment at once — but the owner overrode it, so the conversion happened. + +**What was actually blocking HACS.** Two independent things, both checked against the installed +HACS 2.0.5 rather than from memory: -- **HACS is GitHub-only.** `github.com`/`api.github.com` are hardcoded throughout; no Gitea, GitLab - or self-hosted support. This repo lives on `gitea.nothaft.cloud`. - **No HACS category installs to `/config/pyscript/`.** The six categories resolve to `custom_components//`, `www/community//`, `python_scripts/`, `themes/`, `custom_templates/` and `appdaemon/apps//` (verified in `repositories/*.py` → `localpath`). - The app needs `/config/pyscript/` **and** `/config/audi_dashboard/` **and** `configuration.yaml` - entries — none of which HACS can do. + The app needed `/config/pyscript/` **and** `/config/audi_dashboard/` **and** `configuration.yaml` + entries. This is what the conversion solved. +- **HACS is GitHub-only.** `github.com`/`api.github.com` are hardcoded throughout; the repo lives + on `gitea.nothaft.cloud`. This is **not** solved and cannot be solved from this side — it needs + the repo mirrored to GitHub. Until then `homeassistant/installationspaket/install.ps1` installs + the identical folder without HACS, so the conversion is useful either way. -The only path is converting the pyscript backend into a real custom integration -(`custom_components/audi_dashboard/`), which would also kill `panel_custom:`, the `www/` copying, -the `data/`→`audi_dashboard/` rename, `allow_all_imports`/`hass_is_global` and the pyscript -dependency itself. Scope measured: 2.911 lines, 21 services, 15 state triggers, 10 time triggers, -39 `task.executor`, 12 `state.set`. Owner chose to defer it until after the app has actually run on -the real HA — sensible: converting first means debugging the rewrite and the real environment at the -same time. **Do not start this without the owner raising it again.** +**The shape.** `custom_components/audi_dashboard/` at repo root (HACS looks for +`custom_components/` there and nowhere else), `hacs.json` beside it. Config flow, one +`sensor` platform, 18 services, ~2,900 lines of pyscript ported to ~2,400 lines of integration. +`koordinator.py` holds the runtime state and does the wiring that `@service`/`@state_trigger`/ +`@time_trigger` used to do implicitly; the domain modules keep their old names and structure so the +diff stays readable. `modules/` was flattened (it only existed because pyscript treats that folder +specially). Three renames earned their keep: `profil.py` → `ablage.py` (it handles trips and +fill-ups too, the old name was actively wrong), `entitaeten.py` → `zuordnung.py` (avoids collision +with HA "entities"), `frontend_veroeffentlichung.py` → `veroeffentlichung.py`. -**The open question this surfaced — deployed parity of the iOS app.** The parity rule (binding, -above) guarantees *source* parity: both codebases change in the same session. It guarantees nothing -about what is *running*. Today those two are wired completely differently: +**Five install steps disappeared, and none of them by hand-waving:** -- the panel fetches `/local/audi-dashboard-version.json` with `cache: "no-store"` on every page load - and reloads its assets when the number changed — current within one page load, no user action +| Was | Now | +|---|---| +| `pyscript:` block with `allow_all_imports` + `hass_is_global` | gone — no YAML at all | +| `panel_custom:` block | the integration registers its own panel | +| copy the frontend to `/config/www/` | served from `frontend/` under `/audi_dashboard_static/` | +| long-lived token in `audi_dashboard/ha_token.txt` | recorder API read directly (`verlauf.py`) | +| `pip install pypdf` in the container | `requirements` in `manifest.json`, HA installs it | +| copy `fahrzeugprofil.example.json` by hand | seeded from `vorlage/` on first setup | + +Verified on a genuinely fresh throwaway container, not reasoned about: 12 entities, zero trips, +zero fill-ups, zero battery days, profile created from the template, and **pypdf 6.16.2 installed +by HA itself**. + +**The naming contract changed, and both frontends had to follow.** `pyscript.audi_dashboard_x` → +`sensor.audi_dashboard_x`, `pyscript.audi_dashboard_y` → `audi_dashboard.y`. The prefix only ever +existed because pyscript owned the domain. Both frontends now carry the names in one table each +(`const.py` ↔ `frontend/audi-dashboard-app.js` `E`/`DOMAIN` ↔ `companion-app/src/api/types.ts` +`ENTITAETEN`/`DIENST_DOMAIN`). **A companion app from before this change finds nothing after the +upgrade** — it must be rebuilt in the same step. The panel can't drift: it ships inside the +integration. + +**Three long-standing weaknesses fixed, because the conversion made them cheap:** + +1. **The payload no longer hits the recorder database.** `_unrecorded_attributes = {"daten"}` on the + entity. Only a real entity belonging to an integration can do this — pyscript's bare `state.set()` + can't, so every publish wrote the complete trip archive to the DB, every 60 seconds. Confirmed by + querying the history API: only `icon` and `friendly_name` come back. Note the mechanism moved: + exclusion now comes from `state_info["unrecorded_attributes"]` on the Entity, **not** from a + per-domain `recorder.py` platform hook (checked in `db_schema.py` of the installed 2026.8.0). +2. **A running trip survives a restart.** `fahrt_start_ts` and the tank low-water mark live in a + `Store` instead of only in memory. This was a documented data-loss bug. +3. **Trigger rebinding is live.** `@state_trigger` baked the entity ID in at module load, so + changing ZUENDUNG/KM/TANK_SENSOR needed an HA restart — the setup dialog had a warning and a + restart button for exactly that. `trigger_neu_binden()` replaces all three; the warning, the + button and the `neustart` service are gone. + +**Fixing (2) exposed a new failure mode, and it was found by testing rather than reasoning.** If the +ignition goes off *while HA is down*, nobody sees the transition — the resumed trip would stay open +forever and the next real trip would be appended to its start. `nach_neustart_fortsetzen()` closes +it at the last recorded ignition timestamp (not "now" — HA may have been down for days), and runs +via `async_at_started` so a not-yet-restored source can't be mistaken for "off". Verified live: an +orphaned trip was closed at 21:12:43, the last value the recorder held. + +**The self-updater is gone.** `updateverwaltung.py` cloned a git repo into a staging folder and +copied `pyscript/` and `www/` over the running code — necessary only as long as the app had no +install shape Home Assistant knows. HACS is that shape: it checks, downloads, installs, shows a +changelog and can roll back. A second, homemade update mechanism beside it is more attack surface +than value. The Settings tile now shows the installed version and points at HACS. + +**Versioning collapsed to one number.** `manifest.json` `version` is the single source: HA lists it, +HACS compares it, the integration publishes it as `sensor.audi_dashboard_app_version`, it hangs on +the panel URL as `?v=…`, and vite reads it for `__APP_VERSION__` (hard-failing if absent). The +`VERSION` file and the Unix-seconds cache-buster in `audi-dashboard-version.json` are both gone — +two sources for one fact would have drifted, and the comparison would have gone silently wrong. The +new cache breaker is strictly better: it changes only on real changes. `VERSIONIERUNG.md` rewritten. + +**Installer got smaller and much safer.** It copies one folder and touches nothing else. The +`configuration.yaml` manipulation — the one way an installer can stop HA from starting, and the +reason the old script needed timestamped backups, a conflict refusal and a read-back rollback — is +gone with the YAML it used to write. What remains: refuses to delete a folder whose `manifest.json` +doesn't claim `domain: audi_dashboard`, reads the manifest back after copying. Tested on a mock +config dir: dry run, first install, update run, and the refusal path. + +**Removed from the repo** (recoverable via `git log`): `homeassistant/pyscript/`, +`homeassistant/www/audi-dashboard-*` + `badges/`, `homeassistant/data/`, `homeassistant/install.sh`, +`homeassistant/update.ps1`, the `installationspaket/` copy of everything (the duplicate-and-sync +arrangement is gone — the installer reads the real folder), `configuration_snippet.yaml` (nothing +left to add), `ANLEITUNG.pdf` (stale and not regenerable here; the `.md` beside it is current). +`data/tests/` → `tests/belegparser/`, pointing at the integration's parser copy; 8/8 still pass. + +**Do not resurrect the pyscript tree.** If something is missing, port it — running both backends +writes two sets of trips into the same files. + +**Still open, unchanged by this:** the repo must reach GitHub before HACS can install it. Everything +else about the HACS path is built and tested. + +### The deployed-parity problem, and what was built for it (2026-08-23) + +The parity rule (binding, above) guarantees *source* parity: both codebases change in the same +session. It guarantees nothing about what is *running*. After the conversion the two sides are +wired differently in a new way: + +- the panel ships **inside** the integration — one HACS update moves backend and panel together, + and it cannot go stale at all - the companion app is a Capacitor shell with `webDir: "dist"` and **bundled** assets, sideloaded - through Xcode. `package.json` says `0.1.0` and **nothing in `src/` ever checks a version.** It - stays on whatever was bundled at signing time, silently, indefinitely + through Xcode. It stays on whatever was bundled at signing time, silently, indefinitely -So the iOS app can be weeks behind the panel with nothing anywhere making that visible. That is a -real gap, not a hypothetical one, and it gets worse once the app is genuinely in daily use. - -**Built 2026-08-23 — the version identity and the drift warning.** See `VERSIONIERUNG.md` for the -owner-facing version. - -The first finding was that the number everyone assumed was "the version" is not one: -`audi-dashboard-version.json`'s integer is a **cache-buster**, rewritten with `UtcNow` by -`install.ps1`/`update.ps1` on every deploy regardless of whether any code changed. Two builds of -identical source get different numbers; the same build deployed twice gets two more. It can never -answer "are these the same state?". So the two jobs were separated instead of conflated: - -- **`VERSION`** (new, repo root, e.g. `2026.08.23.1`) — the identity, bumped by hand on change -- the existing integer — unchanged, still only breaks the browser cache - -`VERSION` now flows to both sides: into `audi-dashboard-version.json` as a second field `app` -(carried over by all three deploy scripts — they previously overwrote the whole file and would have -silently destroyed it), and into the companion build via vite `define` → `__APP_VERSION__`. The -backend reads the file and publishes `pyscript.audi_dashboard_app_version`; the companion compares -that against its own compiled-in stamp and, on mismatch, says so in the existing `Hinweisleiste` — -whose stated principle is already "nie eine stille Veraltung", which is exactly this case with the -staleness moved from the data to the app itself. +The version comparison above is what makes that visible: the app compares its compiled-in stamp +against `sensor.audi_dashboard_app_version` and says so in the existing `Hinweisleiste`, whose +stated principle is already "nie eine stille Veraltung". Deliberate choices worth keeping: the comparison is **equality only**, never greater/less — the version is an identifier, not a number, and ordering it would be false precision that breaks silently on a format change. Either side missing yields `"unbekannt"` and **no** warning, so an -older backend or an offline start cannot produce a false alarm. And the vite build **fails hard** -if `VERSION` is absent rather than emitting an app that cannot detect its own staleness. The panel -needs none of this: it re-fetches on every page load and cannot go stale. +older backend or an offline start cannot produce a false alarm. And the vite build **fails hard** if +the manifest version is absent rather than emitting an app that cannot detect its own staleness. -**OTA delivery: verified viable, not yet built.** `@capgo/capacitor-updater` 8.51.14 checked -against the real package: MPL-2.0, peer `@capacitor/core: ^8.0.0` against our `^8.5.0`, and -self-hosting is first-class (`updateUrl`, or manual mode entirely). Manual mode is the good fit — +**OTA delivery: verified viable, not yet built.** `@capgo/capacitor-updater` 8.51.14 checked against +the real package: MPL-2.0, peer `@capacitor/core: ^8.0.0` against our `^8.5.0`, and self-hosting is +first-class (`updateUrl`, or manual mode entirely). Manual mode is the good fit — `download({version, url})` + `set()` against a plain zip under `/local/`, needing **no** custom endpoint at all, with automatic rollback to the last good bundle via `notifyAppReady()`. It pairs exactly with the version entity above: that entity is already the signal that a newer bundle exists. @@ -1869,11 +1942,14 @@ Capacitor's `server.url` pointed at HA was considered and **rejected**: it would current as the panel, but the shell then cannot boot without reaching HA, gutting the deliberately built offline queue (`api/warteschlange.ts`). -The whole mechanism survives the later integration conversion unchanged — that end state serves -panel assets and companion bundle from the same component, so one HACS update moves both. - ### G) Fresh-install audit + installer hardening (2026-08-23, before the first real deployment) +> **Historical from here on.** This describes the pyscript-era package, which section H replaced +> later the same day. The *findings* still matter — the blank-delivery rule and the safety +> properties carried over into the new installer — but the files it names (`pyscript/`, `www/`, +> `data/`, the `configuration.yaml` block) no longer exist. Kept as the record of what was wrong +> and why the current shape looks the way it does. + Owner is about to install on the real HA OS instance and asked for two things: that the one-click package produce a genuinely blank app, and that it carry no risk to the existing HA system. @@ -1974,6 +2050,13 @@ which the parity rule exempts as panel-only. - Decisions are logged, including rejected ones (see the Traccar section of the architecture doc); superseded sections stay in place marked "ÜBERHOLT" rather than being deleted. - Numbers in `de-DE` format; font weights 300/400 only, never ≥600 (only those cuts exist). -- Real vehicle/movement data stays local (`data/` contents and receipt PDFs are gitignored). +- Real vehicle/movement data stays local: it lives in Home Assistant under + `/config/audi_dashboard/`, never in this repo. Only the placeholder template + (`custom_components/audi_dashboard/vorlage/fahrzeugprofil.json`) is versioned; the real receipt + PDFs under `tests/belegparser/belege/` are gitignored. +- **There is no second copy of the code to keep in sync any more.** Until 2026-08-23, + `installationspaket/` duplicated `pyscript/`, `www/` and `data/`, and every change had to be + mirrored by hand — the older entries below still say so. The installer now reads + `custom_components/audi_dashboard/` directly. Do not reintroduce a copy. - Security principle: HA is never publicly exposed; only narrowly scoped surfaces (MQTT broker, proxy allowlist) may be exposed, each by explicit decision. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dadaa21 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Audi Dashboard — „Mein Audi" für Home Assistant + +Fahrtenbuch, Tankstatistik, Reifen- und Servicebuch für ein einzelnes +Fahrzeug — als eigener Sidebar-Eintrag in Home Assistant und als iOS-App, die +dieselben Daten zeigt. + +Die App wertet aus, was Home Assistant ohnehin über das Fahrzeug weiß +(Zündung, Kilometerstand, Tankfüllstand, Bordspannung, GPS — etwa über einen +Teltonika FMM003 oder eine EU-Data-Act-Integration des Herstellers), und macht +daraus einen dauerhaften Bestand: erkannte Fahrten, erkannte Tankvorgänge, +Belegdaten aus hochgeladenen Tank-PDFs, ein Spannungsverlauf über Jahre. + +> Privates Projekt für ein bestimmtes Fahrzeug. Es ist nicht als allgemein +> nutzbare Integration gedacht und steht in keinem Verhältnis zur AUDI AG. + +--- + +## Installation über HACS + +1. HACS → **Integrationen** → Menü oben rechts → **Benutzerdefinierte + Repositories** +2. Dieses Repository eintragen, Kategorie **Integration** +3. **Audi Dashboard** herunterladen +4. Home Assistant neu starten +5. **Einstellungen → Geräte & Dienste → Integration hinzufügen → Audi + Dashboard** + +Danach steht **Mein Audi** in der Seitenleiste. + +**Voraussetzung, die HACS selbst mitbringt:** HACS spricht ausschließlich mit +GitHub — `github.com` und `api.github.com` stecken fest im Code, Gitea, GitLab +und selbst gehostete Instanzen kennt es nicht. Liegt dieses Repository nicht +auf GitHub, findet HACS es auch als benutzerdefiniertes Repository nicht. Für +diesen Fall gibt es den Weg darunter, der denselben Ordner installiert. + +## Installation ohne HACS + +Von einem Windows-Rechner aus, der das `config`-Verzeichnis der HA-Instanz +erreicht (Samba-Add-on oder gemounteter Pfad): + +``` +homeassistant\installationspaket\Installieren.cmd +``` + +Das Skript kopiert `custom_components\audi_dashboard\` in die Instanz und +fasst sonst nichts an — keine `configuration.yaml`, keine Fahrzeugdaten. Mit +`-Pruefen` zeigt es vorher, was es tun würde. Details und die +Sicherheitszusagen stehen im Kopf von +[`install.ps1`](homeassistant/installationspaket/install.ps1). + +Danach ebenfalls: neu starten, Integration hinzufügen. + +## Nach der Installation + +**Sensoren zuordnen** — **Einstellungen → Fahrzeug einrichten → Setup** in der +App. Im Auslieferstand ist bewusst *kein* Sensor vorbelegt: welche Entity-IDs +richtig sind, hängt an der Instanz, und eine gesetzte, aber falsche ID ist +schlechter als eine leere. Zwingend ist allein der Zündungs-/ACC-Sensor — er +trägt die Fahrterkennung. Änderungen wirken sofort, ohne Neustart. + +**Datenaufbewahrung verlängern** — Home Assistant löscht Sensor-Verläufe nach +10 Tagen. Das begrenzt, wie weit „Daten importieren aus Home Assistant" +zurückreichen kann, und was einmal gelöscht ist, kommt nicht wieder. Der Block +aus [`recorder_snippet.yaml`](homeassistant/recorder_snippet.yaml) hebt das +auf ein Jahr an; dort steht auch der gemessene Platzbedarf. Bewusst nicht +automatisch eingefügt: das ist eine Entscheidung über den Plattenplatz der +Instanz. + +**Vergangenes nachholen** — **Einstellungen → Einrichten → Daten importieren +aus Home Assistant** leitet Fahrten, Tankvorgänge und Spannungswerte +rückwirkend aus dem bereits aufgezeichneten Verlauf ab. + +## Was die Integration mitbringt + +| | | +|---|---| +| Panel | Sidebar-Eintrag „Mein Audi", von der Integration selbst angemeldet | +| Entitäten | `sensor.audi_dashboard_*` — Profil, Fahrten, Tankvorgänge, Fahrzeugstatus, Batterieverlauf, Reifenzähler | +| Dienste | `audi_dashboard.*` — 18 Stück, in Entwicklerwerkzeuge → Aktionen dokumentiert | +| Daten | `/config/audi_dashboard/` — JSON und JSON Lines, dauerhaft, nie von einem Update angefasst | +| Abhängigkeit | `pypdf` (für Tankbelege), von Home Assistant automatisch nachinstalliert | + +Die Nutzlast der Entitäten steckt im Attribut `daten` und ist ausdrücklich von +der Aufzeichnung ausgenommen — das Fahrtenarchiv wächst über Jahre auf +Hunderte Kilobyte und hat in der Recorder-Datenbank nichts verloren. + +## Companion-App (iOS) + +`companion-app/` ist dieselbe Oberfläche als eigenständige App (React + +Capacitor), die über die HA-API auf dieselben Entitäten und Dienste zugreift. +Sie trägt ihre Version fest einkompiliert und meldet selbst, wenn sie älter +ist als die installierte Integration — siehe +[`VERSIONIERUNG.md`](VERSIONIERUNG.md). + +## Ordner in diesem Repository + +| Ordner | Inhalt | +|---|---| +| `custom_components/audi_dashboard/` | die Integration — Backend und Panel-Dateien | +| `companion-app/` | die iOS-App | +| `design-system/` | gemeinsame UI-Bausteine (`@audi-dash/ui`) | +| `homeassistant/` | Installationspaket, Konfigurationsschnipsel, Anleitungen | +| `testumgebung/` | Wegwerf-Home-Assistant in Docker für Entwicklung und Tests | +| `tests/belegparser/` | Regressionstest des Tankbeleg-Lesers | diff --git a/VERSION b/VERSION deleted file mode 100644 index 69e24fd..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -2026.08.23.1 diff --git a/VERSIONIERUNG.md b/VERSIONIERUNG.md index 2dd5d6a..5871909 100644 --- a/VERSIONIERUNG.md +++ b/VERSIONIERUNG.md @@ -1,80 +1,96 @@ -# Versionierung — warum es zwei Zahlen gibt +# Versionierung — eine Zahl, drei Verbraucher -Kurzfassung für den Alltag: **Wenn du etwas an der Oberfläche änderst, erhöhe -`VERSION`.** Alles Weitere erledigen die Bau- und Installationsskripte. +Kurzfassung für den Alltag: **Wenn du etwas änderst, erhöhe `version` in +`custom_components/audi_dashboard/manifest.json`.** Alles Weitere ergibt sich +daraus von selbst. --- -## Die zwei Zahlen tun verschiedene Dinge - -Sie sahen sich lange zum Verwechseln ähnlich, deshalb hier ausdrücklich -getrennt: - -| | `VERSION` (Repo-Wurzel) | `version` in `audi-dashboard-version.json` | -|---|---|---| -| Zweck | **Identität**: welcher Stand ist das? | **Cache-Bruch**: hol die Dateien neu | -| Format | `2026.08.23.1` (Datum + laufende Nummer) | Unix-Sekunden, z. B. `1787016000` | -| Wer setzt sie | **du, von Hand**, wenn sich etwas ändert | `install.ps1` / `update.ps1` bei jedem Deploy | -| Vergleichbar zwischen Panel und iOS-App | **ja, darum geht es** | nein, jede Installation hat eine andere | - -Die Unix-Zahl taugt bewusst **nicht** als Versionsvergleich: sie wird bei jeder -Installation neu gesetzt, ohne dass sich am Code etwas geändert hätte. Sie sagt -dem Browser nur „lade neu", nicht „das ist Stand X". - -## Warum das überhaupt nötig wurde +## Warum es diese Zahl braucht Das Panel und die iOS-App werden völlig unterschiedlich ausgeliefert: -- **Panel:** holt bei jedem Seitenaufruf `audi-dashboard-version.json` mit - `cache: "no-store"` und lädt seine Dateien neu, sobald die Zahl sich geändert - hat. Es ist damit nach einem Deploy sofort aktuell, ohne Zutun. -- **iOS-App:** eine Capacitor-Hülle mit **fest gebündelten** Dateien. Sie bleibt - auf dem Stand, der beim Signieren in Xcode eingebaut wurde — unbegrenzt. +- **Panel:** gehört zur Integration und wird mit ihr zusammen installiert. Es + kann gar nicht hinterherhinken — ein Update der Integration ist automatisch + ein Update des Panels. +- **iOS-App:** eine Capacitor-Hülle mit **fest gebündelten** Dateien. Sie + bleibt auf dem Stand, der beim Signieren in Xcode eingebaut wurde — + unbegrenzt. Die Paritätsregel in `AGENTS.md` sichert, dass beide Codebasen in derselben -Sitzung geändert werden. Über das, was *läuft*, sagte bisher nichts etwas: die -iOS-App konnte wochenlang hinterherhinken, ohne dass es irgendwo sichtbar wurde. +Sitzung geändert werden. Über das, was *läuft*, sagte lange nichts etwas: die +iOS-App konnte wochenlang zurückliegen, ohne dass es irgendwo sichtbar wurde. -`VERSION` schließt genau diese Lücke — nicht, indem es die Abweichung -verhindert (das kann keine Zahl), sondern indem es sie **sichtbar** macht. +Die Versionszahl schließt genau diese Lücke — nicht, indem sie die Abweichung +verhindert (das kann keine Zahl), sondern indem sie sie **sichtbar** macht. -## Wie die Zahl durchs System läuft +## Wo sie steht + +In `custom_components/audi_dashboard/manifest.json`, Feld `version`. Format +`2026.8.23.2` (Datum + laufende Nummer). + +Genau dort und nirgends sonst. Home Assistant verlangt das Feld für jede +benutzerdefinierte Integration, HACS zeigt es an und entscheidet danach, ob ein +Update bereitliegt — eine zweite Quelle für dieselbe Angabe wäre eine zu viel. +Sie hätten irgendwann auseinandergelegen, und dann wäre der Vergleich still +falsch geworden statt laut. + +> Bis 2026-08-23 gab es dafür eine eigene Datei `VERSION` in der Repo-Wurzel, +> daneben eine Unix-Sekundenzahl in `www/audi-dashboard-version.json` als +> Cache-Brecher. Beide sind mit dem Umbau zur Integration entfallen: die +> Version steht jetzt im Manifest, und die Oberflächen-Dateien tragen sie als +> `?v=…` in ihrer URL — die Integration hängt sie beim Anmelden des Panels an. +> Ein Cache-Brecher, der sich nur bei echten Änderungen ändert, ist der +> bessere: er lädt nichts unnötig neu. + +## Wie sie durchs System läuft ``` -VERSION (2026.08.23.1) +manifest.json { "version": "2026.8.23.2" } │ - ├─► homeassistant/www/audi-dashboard-version.json { "app": "2026.08.23.1" } - │ │ - │ └─► Backend liest die Datei und veröffentlicht sie als - │ pyscript.audi_dashboard_app_version - │ │ - │ └─► die iOS-App vergleicht sie mit ihrer eigenen, - │ einkompilierten Zahl und zeigt bei Abweichung - │ einen Hinweis in der Hinweisleiste + ├─► Home Assistant führt sie als Version der Integration + │ └─► HACS vergleicht sie gegen das Repository und meldet Updates │ - └─► companion-app: von vite beim Bauen als __APP_VERSION__ eingesetzt + ├─► die Integration veröffentlicht sie als + │ sensor.audi_dashboard_app_version + │ └─► die iOS-App vergleicht sie mit ihrer eigenen, einkompilierten + │ Zahl und zeigt bei Abweichung einen Hinweis in der + │ Hinweisleiste + │ + ├─► sie hängt als ?v=… an der Panel-URL + │ └─► Browser laden Panel, CSS und Badges nur dann neu, wenn sich + │ wirklich etwas geändert hat + │ + └─► companion-app: vite liest das Manifest beim Bauen und setzt sie als + __APP_VERSION__ ein (bricht ab, wenn das Feld fehlt) ``` -Der Vergleich läuft also immer in eine Richtung: **das Backend sagt, welcher -Stand ausgeliefert wurde; die App sagt, welchen sie hat.** Stimmen sie nicht -überein, ist die App zu alt (oder, seltener, das Backend). +Der Vergleich läuft in eine Richtung: **die Integration sagt, welcher Stand +installiert ist; die App sagt, welchen sie hat.** Stimmen sie nicht überein, +ist die App zu alt (oder, seltener, die Integration). + +Bewusst **nur auf Gleichheit**, nie größer/kleiner: die Version ist eine +Kennung, keine Zahl. Ein Sortierversuch wäre scheingenau und würde bei einem +Formatwechsel still falsche Antworten geben. Fehlt eine der beiden Seiten, wird +gar nicht verglichen und nichts gemeldet — ein älteres Backend oder ein Start +ohne Netz darf keinen Fehlalarm auslösen. ## Was du tun musst **Bei einer Änderung an der Oberfläche oder am Backend:** -1. `VERSION` erhöhen — bei mehreren Änderungen am selben Tag die laufende - Nummer: `2026.08.23.1` → `2026.08.23.2`, am nächsten Tag `2026.08.24.1`. -2. Panel deployen wie bisher (`update.ps1`). Das Skript trägt die neue `VERSION` - in `audi-dashboard-version.json` ein und setzt die Cache-Zahl frisch. +1. `version` in `manifest.json` erhöhen — bei mehreren Änderungen am selben Tag + die laufende Nummer: `2026.8.23.2` → `2026.8.23.3`, am nächsten Tag + `2026.8.24.1`. +2. Integration ausliefern: über HACS (wenn das Repository auf GitHub liegt) + oder mit `homeassistant\installationspaket\Installieren.cmd`. 3. iOS-App neu bauen (`npm run build`), damit sie dieselbe Zahl einkompiliert bekommt. Vergisst du Schritt 3, ist das kein stiller Fehler mehr: die App meldet selbst, dass sie älter ist als der Server. -**Bei einer reinen Neuinstallation** ohne Codeänderung: nichts tun. `VERSION` -bleibt, wie sie ist; nur die Cache-Zahl wird neu gesetzt. +**Bei einer reinen Neuinstallation** ohne Codeänderung: nichts tun. ## Ausblick: OTA-Updates diff --git a/companion-app/scripts/smoke-auth.ts b/companion-app/scripts/smoke-auth.ts index f8b11fc..64b09bd 100644 --- a/companion-app/scripts/smoke-auth.ts +++ b/companion-app/scripts/smoke-auth.ts @@ -14,7 +14,7 @@ import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import { dirname, resolve } from "node:path" -import { ENTITAETEN } from "../src/api/types.ts" +import { DIENST_DOMAIN, ENTITAETEN } from "../src/api/types.ts" import { HassRest } from "../src/api/rest.ts" import { Warteschlange } from "../src/api/warteschlange.ts" import { ablageSetzen, websocketUrl, type Ablage } from "../src/api/umgebung.ts" @@ -104,10 +104,19 @@ try { "tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status, Object.keys(status).slice(0, 6).join(", "), ) + /* Wie viele Punkte es sind, hängt an der Sensor-Zuordnung der Instanz: vier + Türen, vier Fenster, Heckklappe, Motorhaube — unbelegte Rollen liefern gar + keinen Eintrag. Eine feste Zahl zu erwarten hieße, die Zuordnung der + Testinstanz zu prüfen statt das Backend; genau daran scheiterte diese + Prüfung seit dem Entfernen der Türschloss-Erkennung (16 → 10), ohne dass + irgendetwas kaputt gewesen wäre. Geprüft wird deshalb die vereinbarte + Form: label ist Text, ok ist true/false/null (null = Sensor unbekannt). */ + const punkte = (status["sicherheitscheck"] ?? []) as { label?: unknown; ok?: unknown }[] pruefe( - "Sicherheitscheck liefert alle 16 Einzelprüfungen", - Array.isArray(status["sicherheitscheck"]) && (status["sicherheitscheck"] as unknown[]).length === 16, - `${(status["sicherheitscheck"] as unknown[] | undefined)?.length ?? 0} Punkte`, + "Sicherheitscheck liefert Punkte in der vereinbarten Form", + Array.isArray(punkte) && + punkte.every((p) => typeof p.label === "string" && (p.ok === null || typeof p.ok === "boolean")), + `${punkte.length} Punkte`, ) } catch (e) { pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e)) @@ -128,17 +137,17 @@ for (const [name, id] of [ /* -------------------------------------------------------- Dienstaufruf */ try { - await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren") - pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true) + await rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren") + pruefe("Dienstaufruf audi_dashboard.jetzt_aktualisieren", true) } catch (e) { - pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e)) + pruefe("Dienstaufruf audi_dashboard.jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e)) } /* ------------------------------------------------------ Warteschlange */ try { const warteschlange = new Warteschlange(rest) - await warteschlange.einreihen("pyscript", "audi_dashboard_jetzt_aktualisieren", {}, "Rauchtest") + await warteschlange.einreihen(DIENST_DOMAIN, "jetzt_aktualisieren", {}, "Rauchtest") /* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index 448519e..85a424b 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -13,7 +13,7 @@ export * from "./live.ts"; export * from "./warteschlange.ts"; export * from "./ablageNativ.ts"; -import { ENTITAETEN } from "./types.ts"; +import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts"; import type { Fahrt, Fahrzeugstatus, ImportErgebnis, Profil, Tankvorgang } from "./types.ts"; import { HassRest } from "./rest.ts"; import { HassLive } from "./live.ts"; @@ -34,7 +34,7 @@ export interface TankvorgangFelder { } /** Eingabefelder für Fahrten - Namen wie beim Backend (fahrterkennung.py: - audi_dashboard_fahrt_manuell_anlegen/audi_dashboard_fahrt_aktualisieren). + fahrt_manuell_anlegen/fahrt_aktualisieren). Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */ export interface FahrtFelder { @@ -92,8 +92,8 @@ export class DataMetricApi { profilSchreiben(profil: Profil): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_profil_schreiben", + DIENST_DOMAIN, + "profil_schreiben", { profil_json: JSON.stringify(profil) }, "Fahrzeugdaten speichern", ); @@ -101,8 +101,8 @@ export class DataMetricApi { belegHochladen(pdfBase64: string, dateiname: string, tankId?: string): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_beleg_hochladen", + DIENST_DOMAIN, + "beleg_hochladen", { pdf_base64: pdfBase64, dateiname, ...(tankId ? { tank_id: tankId } : {}) }, `Beleg „${dateiname}" hochladen`, ); @@ -111,8 +111,8 @@ export class DataMetricApi { /** Legt eine Fahrt von Hand an — für Fahrten ohne automatische Erkennung. */ fahrtAnlegen(felder: FahrtFelder): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_fahrt_manuell_anlegen", + DIENST_DOMAIN, + "fahrt_manuell_anlegen", { ...felder }, "Fahrt eintragen", ); @@ -122,8 +122,8 @@ export class DataMetricApi { Hand angelegt. */ fahrtAktualisieren(tripId: string, felder: FahrtFelder): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_fahrt_aktualisieren", + DIENST_DOMAIN, + "fahrt_aktualisieren", { trip_id: tripId, ...felder }, "Fahrt ändern", ); @@ -131,8 +131,8 @@ export class DataMetricApi { fahrtLoeschen(tripId: string): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_fahrt_loeschen", + DIENST_DOMAIN, + "fahrt_loeschen", { trip_id: tripId }, "Fahrt löschen", ); @@ -142,8 +142,8 @@ export class DataMetricApi { Parameternamen — belegverarbeitung.py). */ tankvorgangAnlegen(felder: TankvorgangFelder): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_tankvorgang_manuell", + DIENST_DOMAIN, + "tankvorgang_manuell", { ...felder }, "Tankvorgang eintragen", ); @@ -151,8 +151,8 @@ export class DataMetricApi { tankvorgangAktualisieren(tankId: string, felder: TankvorgangFelder): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_tankvorgang_aktualisieren", + DIENST_DOMAIN, + "tankvorgang_aktualisieren", { tank_id: tankId, ...felder }, "Tankvorgang ändern", ); @@ -160,8 +160,8 @@ export class DataMetricApi { tankvorgangLoeschen(tankId: string): Promise { return this.warteschlange.einreihen( - "pyscript", - "audi_dashboard_tankvorgang_loeschen", + DIENST_DOMAIN, + "tankvorgang_loeschen", { tank_id: tankId }, "Tankvorgang löschen", ); @@ -169,10 +169,10 @@ export class DataMetricApi { /** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */ jetztAktualisieren(): Promise { - return this.rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren"); + return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren"); } - /** Welchen Oberflächen-Stand das Backend ausliefert (Datei VERSION, siehe + /** Welchen Oberflächen-Stand das Backend ausliefert (Version der Integration, siehe VERSIONIERUNG.md). `null`, wenn die Entität fehlt — etwa weil das Backend älter ist als diese Funktion; dann wird nichts verglichen und nichts gemeldet, statt einen Fehlalarm auszulösen. */ @@ -198,7 +198,7 @@ export class DataMetricApi { /** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag angenommen hat — nicht, wenn er fertig ist; dafür importStatusLesen(). */ historieImportieren(start: string, ende: string): Promise { - return this.rest.dienstAufrufen("pyscript", "audi_dashboard_historie_importieren", { + return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", { start, ende, }); diff --git a/companion-app/src/api/rest.ts b/companion-app/src/api/rest.ts index d771468..42b0a25 100644 --- a/companion-app/src/api/rest.ts +++ b/companion-app/src/api/rest.ts @@ -5,8 +5,8 @@ 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. */ + Dieselben Entitäten, dieselben Dienste der Integration - 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"; @@ -124,8 +124,8 @@ export class HassRest { return (zustand as DatenEntity).attributes.daten; } - /** Ruft einen Dienst auf, z. B. dienstAufrufen("pyscript", - "audi_dashboard_profil_schreiben", { profil_json }). */ + /** Ruft einen Dienst auf, z. B. dienstAufrufen("audi_dashboard", + "profil_schreiben", { profil_json }). */ async dienstAufrufen( bereich: string, dienst: string, diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 85edb59..a39954d 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -19,7 +19,7 @@ export interface HassState> { context?: { id: string; parent_id: string | null; user_id: string | null }; } -/** Die pyscript.*-Entitäten transportieren ihre Nutzlast im Attribut "daten". */ +/** Die Entitäten der Integration transportieren ihre Nutzlast im Attribut "daten". */ export interface DatenAttribute { daten: T; [weitere: string]: unknown; @@ -95,7 +95,7 @@ export interface SicherheitsPunkt { } /** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation - abgeleitet: frontend_veroeffentlichung.py schreibt "tankprozent", + abgeleitet: veroeffentlichung.py schreibt "tankprozent", "gesichert" und "sicherheitscheck" — ein früherer Entwurf dieser Datei hatte "tank_prozent"/"sicher_abgestellt"/"sicherheit" angenommen, womit die Oberfläche still überall undefined gelesen hätte. */ @@ -138,7 +138,7 @@ export interface Profil { [weitere: string]: unknown; } -/** Ergebnis eines Historien-Imports (pyscript/historienimport.py). Die +/** Ergebnis eines Historien-Imports (custom_components/audi_dashboard/historienimport.py). Die Zählwerte fehlen, solange der Lauf noch nicht fertig ist; `meldung` steht nur im Fehlerfall. */ export interface ImportErgebnis { @@ -158,19 +158,30 @@ export interface ImportErgebnis { /* -------------------------------------------------- Entitäts-Verzeichnis */ -/** Alle vom Backend veröffentlichten pyscript-Entitäten an einer Stelle. +/** Alle vom Backend veröffentlichten Entitäten an einer Stelle. Kein Hardcoding über die App verstreut: wer eine Entität umbenennt, - ändert genau diese Tabelle. */ + ändert genau diese Tabelle. + + Die Namen sind ein Vertrag mit custom_components/audi_dashboard/const.py; + das HA-Panel führt dieselbe Tabelle in audi-dashboard-app.js. Bis + 2026-08-23 hießen sie pyscript.audi_dashboard_*, weil pyscript sie + bereitstellte und die Domain besaß. Seit dem Umbau zur eigenen Integration + sind es echte Entitäten dieser Integration. */ 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", - importStatus: "pyscript.audi_dashboard_import_status", - appVersion: "pyscript.audi_dashboard_app_version", + profil: "sensor.audi_dashboard_profil", + fahrten: "sensor.audi_dashboard_fahrten", + tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge", + fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus", + batterieverlauf: "sensor.audi_dashboard_batterieverlauf", + belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis", + importStatus: "sensor.audi_dashboard_import_status", + appVersion: "sensor.audi_dashboard_app_version", } as const; export type EntitaetsSchluessel = keyof typeof ENTITAETEN; + +/** Die Domain, unter der das Backend seine Dienste anbietet: + `audi_dashboard.`. Früher `pyscript.audi_dashboard_` - der + doppelte Präfix war nur nötig, weil sich alle pyscript-Dienste eine Domain + teilten. Siehe custom_components/audi_dashboard/dienste.py. */ +export const DIENST_DOMAIN = "audi_dashboard"; diff --git a/companion-app/src/daten/appVersion.ts b/companion-app/src/daten/appVersion.ts index 44528ff..8f05006 100644 --- a/companion-app/src/daten/appVersion.ts +++ b/companion-app/src/daten/appVersion.ts @@ -8,7 +8,7 @@ * unbemerkt. Diese Datei macht daraus ein sichtbares Signal. * * Bewusst kein „größer/kleiner"-Vergleich: die Version ist eine Kennung - * (`2026.08.23.1`), keine Zahl. Ein Sortierversuch wäre nur scheingenau und + * (`2026.8.23.2`), keine Zahl. Ein Sortierversuch wäre nur scheingenau und * würde bei einem Formatwechsel still falsche Antworten geben. Verglichen wird * auf Gleichheit — alles andere heißt „stimmt nicht überein", und was davon * älter ist, entscheidet der Mensch. diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index bd70f6f..c6566c0 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -8,7 +8,7 @@ import { useRef, useState } from "react" import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui" -import { zugangVerwerfen } from "../api" +import { DIENST_DOMAIN, zugangVerwerfen } from "../api" import { useDaten } from "../daten/DatenKontext" import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter" import { datumZeit, de, isoTag } from "../format" @@ -294,7 +294,7 @@ export function Einstellungen({
- void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt") + void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt") } > Jetzt sichern diff --git a/companion-app/src/screens/HistorienImport.tsx b/companion-app/src/screens/HistorienImport.tsx index 4fcdc35..79005f5 100644 --- a/companion-app/src/screens/HistorienImport.tsx +++ b/companion-app/src/screens/HistorienImport.tsx @@ -9,7 +9,7 @@ * abgeschlossenen Schritt — deckungsgleich mit dem Panel. * * Der eigentliche Lauf passiert im Backend. Es meldet seinen Stand über die - * Entität `pyscript.audi_dashboard_import_status`; hier wird sie im + * Entität `sensor.audi_dashboard_import_status`; hier wird sie im * Sekundentakt gelesen, bis sie „fertig" oder „fehler" sagt. Ein Poll statt * eines Abwartens des Dienstaufrufs, weil pyscript-Dienste sofort * zurückkehren, während die Arbeit im Hintergrund weiterläuft. diff --git a/companion-app/src/screens/Reifen.tsx b/companion-app/src/screens/Reifen.tsx index 2c3c248..795d3f1 100644 --- a/companion-app/src/screens/Reifen.tsx +++ b/companion-app/src/screens/Reifen.tsx @@ -11,6 +11,7 @@ import { useState } from "react" import { ActionButton, Feld, Seg, Tile } from "@audi-dash/ui" +import { DIENST_DOMAIN } from "../api" import { useDaten } from "../daten/DatenKontext" import type { Reifensatz } from "../daten/profilAdapter" import { datum, de, deOderStrich } from "../format" @@ -38,7 +39,7 @@ export function Reifen() { // Eigener Dienst: das Backend rechnet zuerst den alten Satz ab und // schaltet erst dann um, damit keine Kilometer auf den neuen Satz // rutschen, die noch auf dem alten gefahren wurden. - await api.rest.dienstAufrufen("pyscript", "audi_dashboard_reifen_wechseln", { satz: ziel }) + await api.rest.dienstAufrufen(DIENST_DOMAIN, "reifen_wechseln", { satz: ziel }) } finally { setzeLaeuft(false) } diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx index e795fca..f63ecd4 100644 --- a/companion-app/src/screens/TankDetail.tsx +++ b/companion-app/src/screens/TankDetail.tsx @@ -3,7 +3,7 @@ * * Der Beleg wird als PDF hochgeladen; das Ergebnis kommt **nicht** als * Rückgabewert des Dienstaufrufs zurück (pyscript liefert keine), sondern - * asynchron über die Entität `pyscript.audi_dashboard_beleg_ergebnis`. + * asynchron über die Entität `sensor.audi_dashboard_beleg_ergebnis`. * Genau dieses Muster hatte auch das alte Panel. */ diff --git a/companion-app/src/tests/beispieldaten.ts b/companion-app/src/tests/beispieldaten.ts index 19f80cc..6ca44c4 100644 --- a/companion-app/src/tests/beispieldaten.ts +++ b/companion-app/src/tests/beispieldaten.ts @@ -167,7 +167,7 @@ export function beispielApi(): DataMetricApi { return { rest: { zustandLesen: async () => ({ - entity_id: "pyscript.audi_dashboard_fahrzeugstatus", + entity_id: "sensor.audi_dashboard_fahrzeugstatus", state: "aktuell", attributes: { daten: beispielStatus }, last_changed: "2026-08-11T09:00:00+00:00", diff --git a/companion-app/vite.config.ts b/companion-app/vite.config.ts index 0240bbf..d09f043 100644 --- a/companion-app/vite.config.ts +++ b/companion-app/vite.config.ts @@ -4,16 +4,29 @@ import { fileURLToPath } from "node:url" import { defineConfig } from "vite" import react from "@vitejs/plugin-react" -// Die App-Version kommt aus der Datei VERSION im Projektstamm — derselben, aus -// der auch das Panel seine bekommt (siehe VERSIONIERUNG.md). Sie wird hier fest -// einkompiliert, damit die fertige App weiß, aus welchem Stand sie gebaut -// wurde, und das gegen den vom Backend gemeldeten Stand halten kann. +// Die App-Version kommt aus der manifest.json der Integration — derselben +// Zahl, die HACS anzeigt, die Home Assistant als Version der Integration führt +// und die das Backend über sensor.audi_dashboard_app_version meldet (siehe +// VERSIONIERUNG.md). Sie wird hier fest einkompiliert, damit die fertige App +// weiß, aus welchem Stand sie gebaut wurde, und das gegen den vom Backend +// gemeldeten Stand halten kann. // -// Absichtlich hart: fehlt die Datei, soll der Build abbrechen statt still eine -// App ohne Versionsangabe zu erzeugen — die könnte ihre eigene Veraltung nicht -// mehr erkennen, und genau das ist der Zweck der ganzen Mechanik. -const versionsDatei = fileURLToPath(new URL("../VERSION", import.meta.url)) -const appVersion = readFileSync(versionsDatei, "utf8").trim() +// Bis 2026-08-23 stand die Zahl in einer eigenen Datei VERSION im +// Projektstamm. Die ist mit dem Umbau zur HACS-Integration überflüssig +// geworden: die manifest.json MUSS eine Version tragen, HACS verlangt sie und +// vergleicht danach. Zwei Quellen für dieselbe Angabe wären eine Quelle zu +// viel — sie hätten irgendwann auseinandergelegen, und der Vergleich, den +// diese Zahl trägt, wäre still falsch geworden. +// +// Absichtlich hart: fehlt die Datei oder das Feld, soll der Build abbrechen +// statt still eine App ohne Versionsangabe zu erzeugen — die könnte ihre +// eigene Veraltung nicht mehr erkennen, und genau das ist der Zweck der +// ganzen Mechanik. +const manifestPfad = fileURLToPath( + new URL("../custom_components/audi_dashboard/manifest.json", import.meta.url), +) +const appVersion: string = JSON.parse(readFileSync(manifestPfad, "utf8")).version +if (!appVersion) throw new Error(`Kein "version"-Feld in ${manifestPfad}`) // Basispfad relativ: die App wird sowohl unter einer eigenen Domain als auch // aus einem Unterordner heraus ausgeliefert (Home Assistant: /local/dm360/), diff --git a/custom_components/audi_dashboard/__init__.py b/custom_components/audi_dashboard/__init__.py new file mode 100644 index 0000000..ea90a77 --- /dev/null +++ b/custom_components/audi_dashboard/__init__.py @@ -0,0 +1,136 @@ +"""Audi Dashboard - Fahrtenbuch, Tankstatistik und Fahrzeugzustand als eigene +Home-Assistant-Integration. + +Diese Datei ist der Lebenszyklus: einrichten, entladen, neu laden. Die Fach- +logik liegt in den Modulen daneben, der laufende Zustand im Koordinator. + +WAS DIE INTEGRATION MITBRINGT, WAS FRÜHER VON HAND EINGERICHTET WERDEN MUSSTE +----------------------------------------------------------------------------- +Die pyscript-Fassung brauchte fünf getrennte Handgriffe in der Installation: +einen `pyscript:`-Block mit allow_all_imports und hass_is_global, einen +`panel_custom:`-Block, das Kopieren der Oberflächen-Dateien nach /config/www/, +ein langlebiges Zugriffstoken in einer Textdatei und ein `pip install pypdf` +im Container. Jeder davon konnte vergessen werden, und mehrere scheiterten +still. + +Alle fünf sind hier weg: + - Das Panel meldet die Integration selbst an (kein YAML, kein Neustart). + - Die Oberflächen-Dateien liegen in frontend/ und werden von hier unter + einem eigenen URL-Pfad ausgeliefert (kein Kopieren nach www/). + - Den Verlauf liest die Integration direkt über die recorder-API (kein + Token, siehe verlauf.py). + - pypdf steht über manifest.json bereit (kein pip-Aufruf, siehe belege.py). + - Eingerichtet wird über die Oberfläche (config_flow), nicht über YAML. + +Was NICHT hierher gehört und deshalb weiterhin von Hand in die +configuration.yaml kommt: der `recorder:`-Block mit der verlängerten +Aufbewahrung (recorder_snippet.yaml). Der betrifft die ganze Instanz und ihre +Datenbankgröße - das ist eine Entscheidung des Betreibers, keine, die eine +einzelne Integration für ihn treffen darf. +""" + +from __future__ import annotations + +import logging +import os + +from homeassistant.components.http import StaticPathConfig +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.components import frontend, panel_custom +from homeassistant.loader import async_get_integration + +from . import dienste +from .const import ( + DOMAIN, + PANEL_ICON, + PANEL_KOMPONENTE, + PANEL_TITEL, + PANEL_URL, + STATIK_ORDNER, + STATIK_URL, +) +from .koordinator import Koordinator + +_LOGGER = logging.getLogger(__name__) + +PLATTFORMEN = [Platform.SENSOR] + +type AudiConfigEntry = ConfigEntry[Koordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: AudiConfigEntry) -> bool: + integration = await async_get_integration(hass, DOMAIN) + version = str(integration.version) + + koordinator = Koordinator(hass, entry, version) + entry.runtime_data = koordinator + + await _statik_anmelden(hass) + await koordinator.starten() + await hass.config_entries.async_forward_entry_setups(entry, PLATTFORMEN) + await _panel_anmelden(hass, version) + dienste.registrieren(hass, koordinator) + + # Ändert sich die Einrichtung, wird die Integration neu geladen statt + # halb angepasst - das ist die einzige Variante, bei der nichts von der + # alten Einrichtung übrig bleiben kann. + entry.async_on_unload(entry.add_update_listener(_neu_laden)) + + _LOGGER.info("Audi Dashboard %s eingerichtet", version) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AudiConfigEntry) -> bool: + geladen = await hass.config_entries.async_unload_platforms(entry, PLATTFORMEN) + if not geladen: + return False + dienste.abmelden(hass) + frontend.async_remove_panel(hass, PANEL_URL) + await entry.runtime_data.beenden() + return True + + +async def _neu_laden(hass: HomeAssistant, entry: AudiConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + +async def _statik_anmelden(hass: HomeAssistant) -> None: + """Liefert frontend/ unter STATIK_URL aus. + + Nur einmal je Home-Assistant-Lauf: aiohttp-Routen lassen sich nicht wieder + entfernen, ein zweiter Versuch nach einem Neu-Laden der Integration würde + scheitern. Das ist unkritisch - der Pfad zeigt auf einen Ordner, dessen + Inhalt sich beim Neu-Laden nicht ändert. + + cache_headers=False ist Absicht: die Dateien werden von HACS ersetzt, und + ein Browser, der eine alte Fassung 31 Tage lang aus dem Cache bedient + (die HA-Vorgabe für statische Pfade), wäre genau die stille Veraltung, die + das Versions-Verfahren verhindern soll.""" + if hass.data.get(DOMAIN, {}).get("statik"): + return + ordner = os.path.join(os.path.dirname(__file__), STATIK_ORDNER) + await hass.http.async_register_static_paths( + [StaticPathConfig(STATIK_URL, ordner, False)] + ) + hass.data.setdefault(DOMAIN, {})["statik"] = True + + +async def _panel_anmelden(hass: HomeAssistant, version: str) -> None: + """Meldet den Sidebar-Eintrag an. + + Die Version hängt als Query-Parameter an der Modul-URL: sie ändert sich + genau dann, wenn eine neue Fassung installiert wurde, und ist damit der + passende Cache-Brecher für den Lade-Stub.""" + await panel_custom.async_register_panel( + hass, + frontend_url_path=PANEL_URL, + webcomponent_name=PANEL_KOMPONENTE, + sidebar_title=PANEL_TITEL, + sidebar_icon=PANEL_ICON, + module_url=f"{STATIK_URL}/audi-dashboard-panel.js?v={version}", + embed_iframe=False, + require_admin=False, + config={"version": version, "statik": STATIK_URL}, + ) diff --git a/custom_components/audi_dashboard/ablage.py b/custom_components/audi_dashboard/ablage.py new file mode 100644 index 0000000..6ede3ab --- /dev/null +++ b/custom_components/audi_dashboard/ablage.py @@ -0,0 +1,414 @@ +"""Datenzugriff für Fahrzeugprofil, Fahrten, Tankvorgänge und Batterieverlauf. + +Drei getrennte Bestände, wie in SPECIFICATION.md §6.1 festgelegt: + - Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische + - Fahrten: JSON Lines, eine Zeile je Fahrt + - Tankvorgänge: JSON Lines + - Batterieverlauf: JSON Lines, ein Eintrag je Tag + +Dateiformat und Ordnername sind identisch mit der pyscript-Fassung +(/config/audi_dashboard/). Eine bestehende Installation läuft damit nach dem +Umstieg einfach weiter - es gibt keine Datenmigration, und deshalb auch +keinen Weg, bei der Migration etwas zu verlieren. + +ZWEI DINGE, DIE HIER ANDERS SIND ALS IN DER PYSCRIPT-FASSUNG +------------------------------------------------------------ +1. Ganz normales `open()`. Die dortige Verrenkung `task.executor(io.open, ...)` + war eine reine pyscript-Eigenheit (das eingebaute open() existiert dort + nicht). Hier läuft jede Dateioperation stattdessen über + `hass.async_add_executor_job` - ein Datei-Zugriff hat im Event-Loop nichts + verloren, und diese Klasse ist die einzige Stelle, die das kapselt: alle + öffentlichen Methoden sind `async` und schalten selbst in den Executor. + +2. Eine Sperre um die verändernden Zugriffe. Fast jede Änderung ist ein + Lesen-Ändern-Schreiben über den kompletten Bestand (eine Fahrt ändern + heißt: alle Fahrten lesen, eine anfassen, alle zurückschreiben). Liefen + zwei davon verschränkt, gewänne die zuletzt schreibende und die andere + Änderung wäre spurlos weg. In der pyscript-Fassung war das eine offene + Flanke; hier kostet die Absicherung eine `asyncio.Lock`. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import uuid +from collections.abc import Callable +from typing import Any + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class Ablage: + """Alle Dateizugriffe der App an einer Stelle.""" + + def __init__(self, hass: HomeAssistant, basis: str) -> None: + self._hass = hass + self.basis = basis + self.profil_pfad = os.path.join(basis, "fahrzeugprofil.json") + self.fahrten_pfad = os.path.join(basis, "fahrten.jsonl") + self.tankvorgaenge_pfad = os.path.join(basis, "tankvorgaenge.jsonl") + self.batterieverlauf_pfad = os.path.join(basis, "batteriespannung.jsonl") + self.zuordnung_pfad = os.path.join(basis, "entitaeten.json") + self.belege_ordner = os.path.join(basis, "belege") + self.backup_ordner = os.path.join(basis, "backups") + self._sperre = asyncio.Lock() + + async def _im_executor(self, funktion: Callable, *args: Any) -> Any: + return await self._hass.async_add_executor_job(funktion, *args) + + # ------------------------------------------------------------- Ordner + + def _ordner_anlegen(self) -> None: + os.makedirs(self.basis, exist_ok=True) + os.makedirs(self.belege_ordner, exist_ok=True) + + async def ordner_sicherstellen(self) -> None: + await self._im_executor(self._ordner_anlegen) + + def _vorlage_anlegen(self, vorlage_pfad: str) -> bool: + if os.path.exists(self.profil_pfad): + return False + with open(vorlage_pfad, encoding="utf-8") as datei: + inhalt = datei.read() + self._text_schreiben(self.profil_pfad, inhalt) + return True + + async def vorlage_anlegen(self, vorlage_pfad: str) -> bool: + """Legt bei der allerersten Einrichtung ein Fahrzeugprofil aus der + mitgelieferten Vorlage an. True, wenn dabei wirklich etwas entstanden + ist. + + Vorher war das ein eigener Installationsschritt ("die Datei aus data/ + nach /config/audi_dashboard/ kopieren"). Wurde er vergessen, startete + die App in einen Zustand, den man von einem Fehler nicht unterscheiden + konnte: das Panel blieb leer, im Protokoll stand nur eine Zeile über + eine fehlende Datei. + + Ein bereits vorhandenes Profil wird nie angefasst - deshalb die + Existenzprüfung im selben Executor-Aufruf wie das Schreiben, nicht + davor: sonst läge zwischen Prüfen und Schreiben ein Zeitfenster.""" + return await self._im_executor(self._vorlage_anlegen, vorlage_pfad) + + # ------------------------------------------------------ Rohe Dateiarbeit + + @staticmethod + def _text_lesen(pfad: str) -> str | None: + if not os.path.exists(pfad): + return None + with open(pfad, encoding="utf-8") as datei: + return datei.read() + + @staticmethod + def _text_schreiben(pfad: str, text: str) -> None: + """Atomar: erst in eine .tmp-Datei, dann umbenennen. + + os.replace ist auf allen unterstützten Systemen atomar. Damit kann ein + Absturz mitten im Schreiben keine halb geschriebene Datei hinterlassen + - der alte Stand bleibt vollständig, bis der neue vollständig da ist.""" + os.makedirs(os.path.dirname(pfad), exist_ok=True) + tmp = pfad + ".tmp" + with open(tmp, "w", encoding="utf-8") as datei: + datei.write(text) + os.replace(tmp, pfad) + + @staticmethod + def _zeilen_aus_text(text: str | None, pfad: str) -> list[dict]: + if not text: + return [] + datensaetze: list[dict] = [] + for nummer, zeile in enumerate(text.splitlines(), start=1): + zeile = zeile.strip() + if not zeile: + continue + try: + datensaetze.append(json.loads(zeile)) + except ValueError as fehler: + # Eine kaputte Zeile darf nicht den ganzen Bestand mitreißen: + # der Rest ist gültig und die App bleibt benutzbar. + _LOGGER.error( + "%s Zeile %s ist kein gültiges JSON (%s) - Zeile übersprungen", + pfad, nummer, fehler, + ) + return datensaetze + + @staticmethod + def _text_aus_zeilen(datensaetze: list[dict]) -> str: + zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze] + return "\n".join(zeilen) + ("\n" if zeilen else "") + + # ------------------------------------------------------- Fahrzeugprofil + + def _profil_lesen(self) -> dict | None: + text = self._text_lesen(self.profil_pfad) + if text is None: + _LOGGER.error( + "%s fehlt - bis dahin bleiben alle Funktionen aus, die das Profil brauchen", + self.profil_pfad, + ) + return None + try: + return json.loads(text) + except ValueError as fehler: + _LOGGER.error( + "%s ist kein gültiges JSON (%s). Letztes Backup aus %s zurückspielen.", + self.profil_pfad, fehler, self.backup_ordner, + ) + return None + + async def profil_lesen(self) -> dict | None: + """Das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist. + + Jeder Aufrufer muss den None-Fall abfangen: ohne diese Prüfung reißt + eine fehlende Datei jeden Zeittakt und jeden Dienst mit, der das + Profil braucht - bei laufenden Zeittriggern also im Minutentakt.""" + return await self._im_executor(self._profil_lesen) + + async def profil_schreiben(self, profil: dict) -> None: + async with self._sperre: + await self._im_executor( + self._text_schreiben, + self.profil_pfad, + json.dumps(profil, ensure_ascii=False, indent=2), + ) + + # -------------------------------------------------------------- Fahrten + + async def fahrten_lesen(self) -> list[dict]: + text = await self._im_executor(self._text_lesen, self.fahrten_pfad) + return self._zeilen_aus_text(text, self.fahrten_pfad) + + async def fahrten_schreiben(self, fahrten: list[dict]) -> None: + await self._im_executor( + self._text_schreiben, self.fahrten_pfad, self._text_aus_zeilen(fahrten) + ) + + async def fahrt_anhaengen(self, fahrt: dict) -> None: + async with self._sperre: + fahrten = await self.fahrten_lesen() + fahrten.append(fahrt) + await self.fahrten_schreiben(fahrten) + + async def fahrten_ergaenzen(self, neue: list[dict]) -> None: + """Mehrere Fahrten in einem Rutsch, nach Startzeit sortiert. + + Für den Historienimport: bei einem Jahr Verlauf wären das sonst + hunderte einzelne Schreibvorgänge.""" + if not neue: + return + async with self._sperre: + alle = await self.fahrten_lesen() + alle.extend(neue) + alle.sort(key=lambda f: f.get("ts_start") or "") + await self.fahrten_schreiben(alle) + + @staticmethod + def _datensatz_aendern( + zeilen: list[dict], id_feld: str, id_wert: str, aenderungen: dict, schutz: bool + ) -> bool: + """Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID. + + Manuell geänderte Felder (edited_fields) werden dabei nie + überschrieben. schutz=False hebt genau diese Sperre auf - nötig für + Eingaben aus der Oberfläche: edited_fields schützt gegen die + automatische Ergänzung, nicht gegen den Menschen, der das Feld gerade + selbst korrigiert.""" + for d in zeilen: + if d.get(id_feld) != id_wert: + continue + geschuetzt = set(d.get("edited_fields", [])) if schutz else set() + for feld, wert in aenderungen.items(): + if feld not in geschuetzt: + d[feld] = wert + return True + return False + + async def fahrt_aktualisieren(self, trip_id: str, aenderungen: dict) -> bool: + async with self._sperre: + fahrten = await self.fahrten_lesen() + if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, True): + return False + await self.fahrten_schreiben(fahrten) + return True + + async def fahrt_bearbeiten(self, trip_id: str, aenderungen: dict) -> bool: + """Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche: + eine von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld + schon einmal von Hand gesetzt wurde.""" + async with self._sperre: + fahrten = await self.fahrten_lesen() + if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, False): + return False + await self.fahrten_schreiben(fahrten) + return True + + async def fahrt_loeschen(self, trip_id: str) -> bool: + async with self._sperre: + fahrten = await self.fahrten_lesen() + uebrig = [f for f in fahrten if f.get("trip_id") != trip_id] + if len(uebrig) == len(fahrten): + return False + await self.fahrten_schreiben(uebrig) + return True + + # --------------------------------------------------------- Tankvorgänge + + async def tankvorgaenge_lesen(self) -> list[dict]: + text = await self._im_executor(self._text_lesen, self.tankvorgaenge_pfad) + return self._zeilen_aus_text(text, self.tankvorgaenge_pfad) + + async def tankvorgaenge_schreiben(self, tankvorgaenge: list[dict]) -> None: + await self._im_executor( + self._text_schreiben, + self.tankvorgaenge_pfad, + self._text_aus_zeilen(tankvorgaenge), + ) + + async def tankvorgang_anhaengen(self, tankvorgang: dict) -> None: + async with self._sperre: + alle = await self.tankvorgaenge_lesen() + alle.append(tankvorgang) + await self.tankvorgaenge_schreiben(alle) + + async def tankvorgaenge_ergaenzen(self, neue: list[dict]) -> None: + if not neue: + return + async with self._sperre: + alle = await self.tankvorgaenge_lesen() + alle.extend(neue) + alle.sort(key=lambda t: t.get("ts") or "") + await self.tankvorgaenge_schreiben(alle) + + async def tankvorgang_aktualisieren(self, tank_id: str, aenderungen: dict) -> bool: + async with self._sperre: + alle = await self.tankvorgaenge_lesen() + if not self._datensatz_aendern(alle, "tank_id", tank_id, aenderungen, True): + return False + await self.tankvorgaenge_schreiben(alle) + return True + + async def tankvorgang_loeschen(self, tank_id: str) -> bool: + async with self._sperre: + alle = await self.tankvorgaenge_lesen() + uebrig = [t for t in alle if t.get("tank_id") != tank_id] + if len(uebrig) == len(alle): + return False + await self.tankvorgaenge_schreiben(uebrig) + return True + + async def tankvorgang_nach_id(self, tank_id: str) -> dict | None: + for t in await self.tankvorgaenge_lesen(): + if t.get("tank_id") == tank_id: + return t + return None + + async def tankvorgang_nach_receipt_key(self, receipt_key: str) -> dict | None: + """Derselbe Beleg (receipt_key, minutengenau) darf keinen zweiten + Datensatz erzeugen (§7.7 Regel 3).""" + for t in await self.tankvorgaenge_lesen(): + if t.get("receipt_key") == receipt_key: + return t + return None + + async def distanz_seit_letzter_tankung(self, aktueller_km: float | None) -> float | None: + """Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag + für das gleichnamige Formularfeld - frei überschreibbar, genau wie + odometer_km selbst. None, wenn kein Kilometerstand oder kein + vorheriger Tankvorgang vorliegt (erster Eintrag überhaupt).""" + if aktueller_km is None: + return None + alle = await self.tankvorgaenge_lesen() + if not alle: + return None + letzter = max(alle, key=lambda t: t.get("ts") or "") + if letzter.get("odometer_km") is None: + return None + return round(aktueller_km - letzter["odometer_km"], 1) + + # ------------------------------------------------------ Batteriespannung + + async def batterieverlauf_lesen(self) -> list[dict]: + """Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste + zuerst. min_ts/max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen + Einzelmessung, für die Datum/Uhrzeit-Anzeige beim Antippen des + Diagrammpunkts - der Punkt selbst zeigt nur den Minimalwert.""" + text = await self._im_executor(self._text_lesen, self.batterieverlauf_pfad) + return self._zeilen_aus_text(text, self.batterieverlauf_pfad) + + async def batterieverlauf_tageswert_aktualisieren( + self, datum: str, ts: str, spannung: float + ) -> None: + """Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt + ihn beim ersten Wert des Tages an, erweitert sonst nur min/max samt + dem Zeitstempel der jeweils neuen Extremmessung.""" + async with self._sperre: + verlauf = await self.batterieverlauf_lesen() + for eintrag in verlauf: + if eintrag.get("datum") != datum: + continue + if spannung < eintrag["min"]: + eintrag["min"] = spannung + eintrag["min_ts"] = ts + if spannung > eintrag["max"]: + eintrag["max"] = spannung + eintrag["max_ts"] = ts + break + else: + verlauf.append( + {"datum": datum, "min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts} + ) + # Nach Datum sortiert schreiben, nicht in Einfügereihenfolge. + # Solange nur die Live-Aufzeichnung schrieb, war beides dasselbe + # (sie trägt immer den heutigen Tag ein). Der nachträgliche Import + # trägt dagegen vergangene Tage ein - ohne diese Zeile stünden sie + # hinter den neueren, und das Diagramm im Frontend, das die Datei + # in Dateireihenfolge zeichnet, liefe zeitlich rückwärts. + verlauf.sort(key=lambda e: e.get("datum") or "") + await self._im_executor( + self._text_schreiben, + self.batterieverlauf_pfad, + self._text_aus_zeilen(verlauf), + ) + + # ---------------------------------------------------- Sensor-Zuordnung + + async def zuordnung_lesen(self) -> dict: + """Die im Setup-Menü gespeicherten Zuordnungen, oder {} wenn die + Datei fehlt bzw. beschädigt ist. + + Der Fehlerfall ist nicht Vorsicht um ihrer selbst willen: das Anwenden + läuft beim Start VOR der ersten Veröffentlichung. Ohne die Absicherung + reißt eine einzige unlesbare Zeile den gesamten Startvorgang mit - das + Panel bliebe komplett leer, ohne dass irgendetwas auf die Ursache + hindeutet.""" + text = await self._im_executor(self._text_lesen, self.zuordnung_pfad) + if not text or not text.strip(): + return {} + try: + gelesen = json.loads(text) + except ValueError as fehler: + _LOGGER.error( + "%s ist kein gültiges JSON (%s). Die eingebauten Standardwerte gelten " + "weiter; die Zuordnung lässt sich im Setup-Menü neu speichern.", + self.zuordnung_pfad, fehler, + ) + return {} + if not isinstance(gelesen, dict): + _LOGGER.error("%s enthält kein Objekt - wird ignoriert.", self.zuordnung_pfad) + return {} + return gelesen + + async def zuordnung_schreiben(self, mapping: dict) -> None: + await self._im_executor( + self._text_schreiben, + self.zuordnung_pfad, + json.dumps(mapping, ensure_ascii=False, indent=2), + ) + + +def neue_id(praefix: str) -> str: + return f"{praefix}-{uuid.uuid4().hex[:12]}" diff --git a/custom_components/audi_dashboard/batterie.py b/custom_components/audi_dashboard/batterie.py new file mode 100644 index 0000000..8193fc1 --- /dev/null +++ b/custom_components/audi_dashboard/batterie.py @@ -0,0 +1,64 @@ +"""Langfristige Aufzeichnung der 12V-Batteriespannung (Mein Audi -> Zustand +-> Batteriespannung antippen). + +Eine über die Jahre absinkende Spannung deutet auf eine alternde Batterie hin +- dafür braucht es einen Verlauf, nicht nur den aktuellen Wert. + +Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}) reicht dafür und hält +die Datei auch über viele Jahre klein, selbst wenn der Sensor mehrfach pro +Stunde meldet (aktive Fahrt: Motor an/aus, Ladezustand des Alternators +wechselt laufend). Die Prüfung läuft deshalb alle 5 Minuten und trägt jede +Messung in den Tageseintrag ein; die Ablage erweitert dabei nur min/max samt +dem genauen Zeitstempel der jeweiligen Extremmessung - robust gegen Neustarts +und schwankende Erreichbarkeit. + +Der Tagesminimalwert ist die für die Entladung aussagekräftige Größe: er fällt +typischerweise auf den Moment kurz nach dem Start, bevor der Alternator die +Ladespannung hochfährt - also am nächsten an die "echte" Ruhespannung heran, +ohne 2+ Stunden Standzeit abwarten zu müssen. Deshalb verwendet auch die +Ladezustands-/Gesundheitsauswertung im Frontend (AGM_RUHE_MAX_V) den +Tagesminimalwert, nicht den Maximalwert. + +Es wird bewusst NICHT gefiltert, welche Messwerte gespeichert werden - min UND +max jedes Tages werden roh aufgezeichnet. Die AGM-Ruhespannungs-Grenze wird +erst beim Anzeigen angewendet: Tage, an denen selbst der Minimalwert über der +Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet, z. B. bei einer sehr +langen Fahrt), tauchen dort nicht in der Statistik auf. + +Ohne zugeordneten BATTERIE_SENSOR passiert hier bewusst nichts, statt +Platzhalter-Nullwerte aufzuzeichnen. +""" + +from __future__ import annotations + +import datetime +import logging +from typing import TYPE_CHECKING + +from .veroeffentlichung import zustand_oder_none + +if TYPE_CHECKING: + from .koordinator import Koordinator + +_LOGGER = logging.getLogger(__name__) + + +async def pruefen(k: Koordinator) -> None: + sensor = k.zuordnung.werte.BATTERIE_SENSOR + if not sensor: + return + wert = zustand_oder_none(k.hass, sensor) + if wert is None: + return + try: + spannung = float(wert) + except (TypeError, ValueError): + return + + jetzt = datetime.datetime.now(datetime.UTC) + heute = datetime.date.today().isoformat() + await k.ablage.batterieverlauf_tageswert_aktualisieren( + heute, jetzt.isoformat(), spannung + ) + await k.batterieverlauf_veroeffentlichen() + _LOGGER.debug("Batteriespannung erfasst (%s: %s V)", heute, spannung) diff --git a/custom_components/audi_dashboard/belege.py b/custom_components/audi_dashboard/belege.py new file mode 100644 index 0000000..7f76be3 --- /dev/null +++ b/custom_components/audi_dashboard/belege.py @@ -0,0 +1,275 @@ +"""Belegverarbeitung für Tankbelege (SPECIFICATION.md §7.7). + +Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff. +Das eigentliche Lesen der PDF macht shell_beleg_parser.py daneben; hier steht, +was mit dem Ergebnis passiert. + +ZWEI REGELN, DIE DEN AUFBAU ERKLÄREN +------------------------------------ +1. Liter, Kosten und Ersparnis kommen vom Beleg, Kilometerstand und gefahrene + Distanz kommen immer vom Fahrzeug (§7.7 Regel 2). Ein Beleg-Nachtrag rührt + odometer_km/distance_km deshalb nicht an. +2. price_per_l wird grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie + vom Beleg übernommen. fuel_total_eur ist dabei immer der reale, bereits + rabattierte Betrag; die SmartDeal-Ersparnis steht separat in discount. + +Mit tank_id trägt der Upload die Belegdaten in einen bestehenden Tankvorgang +nach - egal ob der ursprünglich manuell oder automatisch angelegt wurde. Passt +das Belegdatum nicht zum erfassten Tankvorgang (anderer Kalendertag), wird +nichts übernommen, sondern ein Fehler veröffentlicht: sonst landet in der +Oberfläche versehentlich ein falscher Beleg an einem bestehenden Eintrag. + +Ohne tank_id legt der Upload KEINEN eigenständigen Tankvorgang an, sondern +parst nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte +in den gerade offenen Entwurf und speichert erst auf einen expliziten Klick. +""" + +from __future__ import annotations + +import base64 +import datetime +import logging +import os +from typing import TYPE_CHECKING + +from . import shell_beleg_parser +from .tankerkennung import leerer_tankvorgang +from .veroeffentlichung import zustand_oder_none + +if TYPE_CHECKING: + from .koordinator import Koordinator + +_LOGGER = logging.getLogger(__name__) + + +def preis_je_liter(fuel_total_eur: float | None, liters: float | None) -> float | None: + if not liters or fuel_total_eur is None: + return None + return round(fuel_total_eur / liters, 3) + + +def _speichern_und_lesen(ordner: str, dateiname: str, rohdaten: bytes) -> tuple[str, dict]: + """Läuft komplett im Executor: Datei schreiben und PDF lesen sind beides + blockierende Arbeit.""" + os.makedirs(ordner, exist_ok=True) + pfad = os.path.join(ordner, dateiname) + with open(pfad, "wb") as datei: + datei.write(rohdaten) + return pfad, shell_beleg_parser.beleg_lesen(pfad) + + +async def hochladen(k: Koordinator, daten: dict) -> None: + pdf_base64 = daten.get("pdf_base64") + tank_id = daten.get("tank_id") + if not pdf_base64: + _LOGGER.warning("Beleg-Upload ohne pdf_base64 aufgerufen") + return + + dateiname = daten.get("dateiname") or ( + f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" + ) + # Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte + # Ausnahme hier (kaputtes Base64, volle Festplatte, unlesbares PDF) wäre + # für die Oberfläche unsichtbar - ein abgelehnter Dienstaufruf landet dort + # nur in der Browser-Konsole, angezeigt wird nichts. + try: + rohdaten = base64.b64decode(pdf_base64) + pdf_pfad, geparst = await k.hass.async_add_executor_job( + _speichern_und_lesen, k.ablage.belege_ordner, dateiname, rohdaten + ) + except Exception as fehler: # noqa: BLE001 - jede Ursache endet hier gleich + _LOGGER.error("Beleg konnte nicht verarbeitet werden: %s", fehler) + await k.beleg_ergebnis_veroeffentlichen( + {"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id} + ) + return + + receipt_key = geparst.get("receipt_key") + if not receipt_key: + _LOGGER.error("Parser lieferte keinen receipt_key") + await k.beleg_ergebnis_veroeffentlichen( + {"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id} + ) + return + + # §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt keinen + # zweiten Datensatz. + vorhandener = await k.ablage.tankvorgang_nach_receipt_key(receipt_key) + if vorhandener is not None and vorhandener.get("tank_id") != tank_id: + _LOGGER.info("Beleg %s bereits vorhanden, übersprungen", receipt_key) + await k.beleg_ergebnis_veroeffentlichen( + {"fehler": "Beleg bereits erfasst", "tank_id": tank_id} + ) + return + + beleg_felder = _beleg_felder(geparst, pdf_pfad) + + if not tank_id: + await k.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None}) + _LOGGER.info("Beleg %s geparst, wartet auf Speichern in der Oberfläche", receipt_key) + return + + await _nachtragen(k, tank_id, geparst, beleg_felder, receipt_key) + + +def _beleg_felder(geparst: dict, pdf_pfad: str) -> dict: + liters = geparst.get("liters") + fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert + return { + "receipt_key": geparst.get("receipt_key"), + "receipt_no": geparst.get("receipt_no"), + "tse_beleg_nr": geparst.get("tse_beleg_nr"), + "ts": geparst.get("ts"), + "ts_payment": geparst.get("ts_payment"), + "ts_tse": geparst.get("ts_tse"), + "station_id": geparst.get("station_id"), + "station_name": geparst.get("station_name"), + "station_address": geparst.get("station_address"), + "article_no": geparst.get("article_no"), + "product_name": geparst.get("product_name"), + "fuel_type": geparst.get("fuel_type"), + "liters": liters, + "fuel_total_eur": fuel_total_eur, + "price_per_l": preis_je_liter(fuel_total_eur, liters), + "discount": geparst.get("discount"), # SmartDeal-Ersparnis + "discount_per_l": geparst.get("discount_per_l"), + "list_price_per_l": geparst.get("list_price_per_l"), + "receipt_total_eur": geparst.get("receipt_total_eur"), + "net_eur": geparst.get("net_eur"), + "vat_eur": geparst.get("vat_eur"), + "receipt_file": pdf_pfad, + } + + +async def _nachtragen( + k: Koordinator, tank_id: str, geparst: dict, beleg_felder: dict, receipt_key: str +) -> None: + vorhanden = await k.ablage.tankvorgang_nach_id(tank_id) + if not vorhanden: + _LOGGER.warning("Tankvorgang %s für Beleg-Nachtrag nicht gefunden", tank_id) + await k.beleg_ergebnis_veroeffentlichen( + {"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id} + ) + return + + vorhandenes_datum = (vorhanden.get("ts") or "")[:10] + belegdatum = (geparst.get("ts") or "")[:10] + if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum: + _LOGGER.warning( + "Belegdatum %s passt nicht zu Tankvorgang %s (Datum %s)", + belegdatum, tank_id, vorhandenes_datum, + ) + await k.beleg_ergebnis_veroeffentlichen( + {"fehler": "Falsches Belegdatum", "tank_id": tank_id} + ) + return + + aenderungen = dict(beleg_felder) + aenderungen["status"] = "vollständig" + if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen): + await k.tankvorgaenge_veroeffentlichen() + await k.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id}) + _LOGGER.info("Beleg %s in Tankvorgang %s nachgetragen", receipt_key, tank_id) + else: + await k.beleg_ergebnis_veroeffentlichen( + {"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id} + ) + + +# ------------------------------------------------ Tankvorgänge aus dem Formular + +async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None: + """Erfassung aus dem Formular "Neuer Tankvorgang". + + receipt_key/receipt_file sind optional: kommen sie mit (weil zuvor ein + Beleg ohne tank_id in den Entwurf übernommen wurde), zählt der Tankvorgang + als per Beleg erfasst, sonst als manuell.""" + liter = daten.get("liter") + kosten = daten.get("kosten") + if liter is None or kosten is None: + _LOGGER.warning("liter und kosten sind Pflichtfelder") + return + + km = daten.get("km") + odometer_km = km if km is not None else _km_aus_sensor(k) + distanz = daten.get("distanz") + if distanz is None: + # Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie + # odometer_km selbst frei überschreibbar - ein übergebener Wert hat + # deshalb immer Vorrang vor der Berechnung. + distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km) + + receipt_key = daten.get("receipt_key") + tankvorgang = leerer_tankvorgang( + daten.get("ts") or datetime.datetime.now(datetime.UTC).isoformat(), + "beleg" if receipt_key else "manual", + ) + tankvorgang.update({ + "receipt_key": receipt_key, + "liters": liter, + "fuel_total_eur": kosten, + "price_per_l": preis_je_liter(kosten, liter), + "discount": daten.get("ersparnis"), + "station_name": daten.get("station"), + "odometer_km": odometer_km, + "distance_km": distanz, + "fuel_type": daten.get("kraftstoff"), + "status": "vollständig", + "receipt_file": daten.get("receipt_file"), + }) + + await k.ablage.tankvorgang_anhaengen(tankvorgang) + await k.tankvorgaenge_veroeffentlichen() + _LOGGER.info("Tankvorgang %s angelegt", tankvorgang["tank_id"]) + + +def _km_aus_sensor(k: Koordinator) -> float | None: + wert = zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR) + try: + return float(wert) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None: + """Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob + ursprünglich per Beleg oder manuell angelegt).""" + tank_id = daten.get("tank_id") + if not tank_id: + _LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen") + return + + liter = daten.get("liter") + kosten = daten.get("kosten") + aenderungen = { + "ts": daten.get("ts"), + "liters": liter, + "fuel_total_eur": kosten, + "price_per_l": preis_je_liter(kosten, liter), + "odometer_km": daten.get("km"), + "discount": daten.get("ersparnis"), + "station_name": daten.get("station"), + "distance_km": daten.get("distanz"), + "fuel_type": daten.get("kraftstoff"), + } + if daten.get("receipt_key"): + aenderungen["receipt_key"] = daten["receipt_key"] + aenderungen["receipt_file"] = daten.get("receipt_file") + aenderungen["source"] = "beleg" + + if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen): + await k.tankvorgaenge_veroeffentlichen() + _LOGGER.info("Tankvorgang %s aktualisiert", tank_id) + else: + _LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id) + + +async def tankvorgang_loeschen(k: Koordinator, tank_id: str | None) -> None: + if not tank_id: + _LOGGER.warning("tankvorgang_loeschen ohne tank_id aufgerufen") + return + if await k.ablage.tankvorgang_loeschen(tank_id): + await k.tankvorgaenge_veroeffentlichen() + _LOGGER.info("Tankvorgang %s gelöscht", tank_id) + else: + _LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id) diff --git a/custom_components/audi_dashboard/bilder.py b/custom_components/audi_dashboard/bilder.py new file mode 100644 index 0000000..9887da1 --- /dev/null +++ b/custom_components/audi_dashboard/bilder.py @@ -0,0 +1,91 @@ +"""Fahrzeugbilder direkt aus der Oberfläche hochladen und löschen +(Einstellungen -> Fahrzeugbilder). + +Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine +Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals +außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder +absolute Pfade). + +Die Bytes werden unverändert unter dem erwarteten Namen abgelegt, unabhängig +vom tatsächlichen Bildformat des Originalfotos (z. B. JPEG von einem iPhone +unter einem ".webp"-Namen) - Browser stellen nach den tatsächlichen +Bilddaten dar, nicht nach der Dateiendung. Eine Größenbeschränkung gibt es +bewusst nicht: jede Fotogröße muss hochladbar sein. + +WARUM DIESE DATEIEN NICHT IM INTEGRATIONSORDNER LIEGEN: sie sind Nutzerdaten, +kein Auslieferbestandteil. Lägen sie unter custom_components/audi_dashboard/, +würde das nächste HACS-Update sie mitlöschen. /config/www/bilder/ gehört +dagegen dem Nutzer und wird von keinem Update angefasst; ausgeliefert wird es +weiterhin unter /local/bilder/. +""" + +from __future__ import annotations + +import base64 +import binascii +import logging +import os + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +ERLAUBTE_DATEINAMEN = frozenset({ + "seitenansicht.webp", + "seitenansicht-winter.webp", + "front-schraeg.webp", + "frontansicht.webp", + "heckansicht.webp", + "cockpit.webp", + "scheinwerfer.webp", + "sitze.webp", + "rad-sommer.webp", + "rad-winter.webp", + "draufsicht.webp", +}) + + +def _pfad(ordner: str, dateiname: str | None) -> str | None: + if not dateiname or dateiname not in ERLAUBTE_DATEINAMEN: + return None + return os.path.join(ordner, dateiname) + + +def _schreiben(pfad: str, rohdaten: bytes) -> None: + os.makedirs(os.path.dirname(pfad), exist_ok=True) + tmp = pfad + ".tmp" + with open(tmp, "wb") as datei: + datei.write(rohdaten) + os.replace(tmp, pfad) + + +def _loeschen(pfad: str) -> bool: + if not os.path.exists(pfad): + return False + os.remove(pfad) + return True + + +async def hochladen( + hass: HomeAssistant, ordner: str, dateiname: str | None, daten_base64: str | None +) -> None: + pfad = _pfad(ordner, dateiname) + if not pfad or not daten_base64: + _LOGGER.warning("Bild-Upload abgelehnt (dateiname=%s)", dateiname) + return + try: + rohdaten = base64.b64decode(daten_base64) + except (binascii.Error, ValueError) as fehler: + _LOGGER.error("Bild-Upload: Daten nicht lesbar (%s)", fehler) + return + await hass.async_add_executor_job(_schreiben, pfad, rohdaten) + _LOGGER.info("Fahrzeugbild gespeichert (%s, %s Bytes)", dateiname, len(rohdaten)) + + +async def loeschen(hass: HomeAssistant, ordner: str, dateiname: str | None) -> None: + pfad = _pfad(ordner, dateiname) + if not pfad: + _LOGGER.warning("Bild-Löschen abgelehnt (dateiname=%s)", dateiname) + return + if await hass.async_add_executor_job(_loeschen, pfad): + _LOGGER.info("Fahrzeugbild gelöscht (%s)", dateiname) diff --git a/custom_components/audi_dashboard/config_flow.py b/custom_components/audi_dashboard/config_flow.py new file mode 100644 index 0000000..c17dea7 --- /dev/null +++ b/custom_components/audi_dashboard/config_flow.py @@ -0,0 +1,37 @@ +"""Einrichtung über die Oberfläche (Einstellungen -> Geräte & Dienste -> +Integration hinzufügen -> Audi Dashboard). + +Bewusst ohne Eingabefelder. Es gäbe genau eine Sache zu fragen - welche +Entität welche Rolle im Fahrzeug spielt -, und die gehört nicht hierher: die +Zuordnung wird im Setup-Menü der App selbst vorgenommen, wo neben jedem Feld +steht, wofür es gebraucht wird und was passiert, wenn es leer bleibt. Sie +hier abzufragen hieße, dieselbe Auswahl an zwei Stellen zu pflegen - und die +schlechtere von beiden zuerst zu zeigen. + +Eine einzige Instanz, erzwungen über single_config_entry in manifest.json: +die App gehört zu genau einem Fahrzeug, und alle Bestände liegen unter einem +festen Pfad. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DOMAIN + + +class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN): + """Ein Bestätigungsschritt, mehr braucht es nicht.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if user_input is None: + return self.async_show_form(step_id="user") + return self.async_create_entry(title="Audi Dashboard", data={}) diff --git a/custom_components/audi_dashboard/const.py b/custom_components/audi_dashboard/const.py new file mode 100644 index 0000000..61ae86c --- /dev/null +++ b/custom_components/audi_dashboard/const.py @@ -0,0 +1,83 @@ +"""Feste Namen und Pfade der Integration. + +Die Entity-IDs und Dienstnamen hier sind ein VERTRAG mit beiden Oberflächen +(homeassistant/www/audi-dashboard-app.js und companion-app/): dort stehen +dieselben Namen in je einer zentralen Tabelle. Wer hier umbenennt, muss beide +mitziehen - sonst liest die App ins Leere, ohne dass irgendetwas abstürzt. + +Warum die Namen sich gegenüber der pyscript-Fassung überhaupt geändert haben: +dort hießen sie `pyscript.audi_dashboard_*`, weil pyscript der Besitzer der +Domain war. Ohne pyscript wäre dieser Präfix eine Lüge - die Entitäten +gehören jetzt dieser Integration. Aus `pyscript.audi_dashboard_x` wird +deshalb `sensor.audi_dashboard_x`, aus dem Dienst `pyscript.audi_dashboard_y` +wird `audi_dashboard.y`. +""" + +DOMAIN = "audi_dashboard" + +# Unterordner im HA-Konfigurationsverzeichnis für alle Nutzerdaten +# (Fahrzeugprofil, Fahrten, Tankvorgänge, Belege, Sicherungen). Bewusst +# derselbe Name und dasselbe Dateiformat wie in der pyscript-Fassung: eine +# bestehende Installation wird dadurch einfach weiterbenutzt, es gibt keine +# Datenmigration und damit auch keinen Weg, dabei etwas zu verlieren. +BASIS_ORDNER = "audi_dashboard" + +# Panel: Sidebar-Eintrag und URL. Früher aus configuration.yaml +# (panel_custom:), jetzt von der Integration selbst registriert - deshalb +# entfällt der YAML-Block und mit ihm der Neustart beim ersten Einbau. +PANEL_URL = "audi-dashboard" +PANEL_TITEL = "Mein Audi" +PANEL_ICON = "mdi:car-sports" +PANEL_KOMPONENTE = "audi-dashboard-panel" + +# Statischer Pfad, unter dem die Oberflächen-Dateien aus frontend/ +# ausgeliefert werden. Ersetzt /local/ (also /config/www/): die Dateien +# gehören jetzt zum Installationspaket der Integration und werden von HACS +# mitgeliefert, statt separat nach www/ kopiert zu werden. +STATIK_URL = "/audi_dashboard_static" +STATIK_ORDNER = "frontend" + +# Vom Nutzer hochgeladene Fahrzeugfotos. Die bleiben in /config/www/bilder, +# also unter /local/bilder/: sie sind NUTZERDATEN, kein Auslieferbestandteil. +# Lägen sie im Integrationsordner, würde das nächste HACS-Update sie +# mitlöschen - genau das darf nicht passieren. +BILDER_ORDNER = "www/bilder" + +# --------------------------------------------------------------- Entitäten +# Schlüssel -> Objekt-ID. Die vollständige Entity-ID ist +# sensor.audi_dashboard_; sensor.py legt sie so an. +E_PROFIL = "profil" +E_FAHRTEN = "fahrten" +E_TANKVORGAENGE = "tankvorgaenge" +E_FAHRZEUGSTATUS = "fahrzeugstatus" +E_BATTERIEVERLAUF = "batterieverlauf" +E_ZUORDNUNG = "entitaeten" +E_BELEG_ERGEBNIS = "beleg_ergebnis" +E_IMPORT_STATUS = "import_status" +E_APP_VERSION = "app_version" +E_REIFEN_SOMMER = "reifen_sommer_km" +E_REIFEN_WINTER = "reifen_winter_km" +E_REIFEN_AKTIV = "reifen_aktiver_satz" + +# Signal für den Weg Koordinator -> Entität (async_dispatcher_send). +SIGNAL_AKTUALISIERT = f"{DOMAIN}_aktualisiert" + +# ----------------------------------------------------------------- Dienste +D_JETZT_AKTUALISIEREN = "jetzt_aktualisieren" +D_PROFIL_SCHREIBEN = "profil_schreiben" +D_ENTITAETEN_SCHREIBEN = "entitaeten_schreiben" +D_FAHRT_MANUELL_ANLEGEN = "fahrt_manuell_anlegen" +D_FAHRT_AKTUALISIEREN = "fahrt_aktualisieren" +D_FAHRT_LOESCHEN = "fahrt_loeschen" +D_SCREENING_JETZT = "screening_jetzt" +D_BELEG_HOCHLADEN = "beleg_hochladen" +D_TANKVORGANG_MANUELL = "tankvorgang_manuell" +D_TANKVORGANG_AKTUALISIEREN = "tankvorgang_aktualisieren" +D_TANKVORGANG_LOESCHEN = "tankvorgang_loeschen" +D_BILD_HOCHLADEN = "bild_hochladen" +D_BILD_LOESCHEN = "bild_loeschen" +D_REIFEN_WECHSELN = "reifen_wechseln" +D_REIFEN_KM_SETZEN = "reifen_km_setzen" +D_BACKUP_JETZT = "backup_jetzt" +D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen" +D_HISTORIE_IMPORTIEREN = "historie_importieren" diff --git a/custom_components/audi_dashboard/dienste.py b/custom_components/audi_dashboard/dienste.py new file mode 100644 index 0000000..9000872 --- /dev/null +++ b/custom_components/audi_dashboard/dienste.py @@ -0,0 +1,256 @@ +"""Die Dienste, die beide Oberflächen aufrufen. + +Aus `pyscript.audi_dashboard_` ist `audi_dashboard.` geworden - +der doppelte Präfix war nur nötig, weil alle pyscript-Dienste sich eine Domain +teilten. Die Namen selbst und die Feldnamen sind unverändert; beide +Oberflächen führen dieselbe Liste in je einer zentralen Tabelle. + +Die Schemata sind bewusst großzügig: die Oberfläche schickt Zahlenfelder je +nach Formularzustand mal als Zahl, mal gar nicht. Ein zu enges Schema würde +einen Dienstaufruf ablehnen, und ein abgelehnter Aufruf ist in der Oberfläche +unsichtbar - er landet nur in der Browser-Konsole. Was inhaltlich nicht passt, +weisen deshalb die Fachfunktionen mit einer verständlichen Protokollzeile ab, +nicht die Schema-Prüfung. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Coroutine +from typing import Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from . import belege, bilder, fahrterkennung, historienimport, reifen, screening, sicherung +from .const import ( + BILDER_ORDNER, + D_BACKUP_JETZT, + D_BACKUP_WIEDERHERSTELLEN, + D_BELEG_HOCHLADEN, + D_BILD_HOCHLADEN, + D_BILD_LOESCHEN, + D_ENTITAETEN_SCHREIBEN, + D_FAHRT_AKTUALISIEREN, + D_FAHRT_LOESCHEN, + D_FAHRT_MANUELL_ANLEGEN, + D_HISTORIE_IMPORTIEREN, + D_JETZT_AKTUALISIEREN, + D_PROFIL_SCHREIBEN, + D_REIFEN_KM_SETZEN, + D_REIFEN_WECHSELN, + D_SCREENING_JETZT, + D_TANKVORGANG_AKTUALISIEREN, + D_TANKVORGANG_LOESCHEN, + D_TANKVORGANG_MANUELL, + DOMAIN, +) +from .koordinator import Koordinator + +# Ein JSON-Feld darf als Zeichenkette oder schon ausgepackt ankommen: das +# Panel schickt JSON.stringify(...), ein Aufruf aus einer Automation heraus +# reicht dagegen naheliegenderweise die Struktur selbst durch. +_JSON = vol.Any(cv.string, dict, list) +_ZAHL = vol.Any(vol.Coerce(float), None) +_TEXT = vol.Any(cv.string, None) + +_FAHRT_FELDER = { + vol.Optional("art"): _TEXT, + vol.Optional("start_ort"): _TEXT, + vol.Optional("ziel_ort"): _TEXT, + vol.Optional("odo_start"): _ZAHL, + vol.Optional("odo_end"): _ZAHL, + vol.Optional("distanz"): _ZAHL, +} + +_TANK_FELDER = { + vol.Optional("ts"): _TEXT, + vol.Optional("liter"): _ZAHL, + vol.Optional("kosten"): _ZAHL, + vol.Optional("km"): _ZAHL, + vol.Optional("ersparnis"): _ZAHL, + vol.Optional("station"): _TEXT, + vol.Optional("distanz"): _ZAHL, + vol.Optional("kraftstoff"): _TEXT, + vol.Optional("receipt_key"): _TEXT, + vol.Optional("receipt_file"): _TEXT, +} + +SCHEMATA: dict[str, vol.Schema] = { + D_JETZT_AKTUALISIEREN: vol.Schema({}), + D_SCREENING_JETZT: vol.Schema({}), + D_BACKUP_JETZT: vol.Schema({}), + D_PROFIL_SCHREIBEN: vol.Schema({vol.Required("profil_json"): _JSON}), + D_ENTITAETEN_SCHREIBEN: vol.Schema({vol.Required("zuordnung_json"): _JSON}), + D_FAHRT_MANUELL_ANLEGEN: vol.Schema({ + vol.Required("ts_start"): cv.string, + vol.Required("ts_end"): cv.string, + **_FAHRT_FELDER, + }), + D_FAHRT_AKTUALISIEREN: vol.Schema({ + vol.Required("trip_id"): cv.string, + vol.Optional("ts_start"): _TEXT, + vol.Optional("ts_end"): _TEXT, + **_FAHRT_FELDER, + }), + D_FAHRT_LOESCHEN: vol.Schema({vol.Required("trip_id"): cv.string}), + D_BELEG_HOCHLADEN: vol.Schema({ + vol.Required("pdf_base64"): cv.string, + vol.Optional("dateiname"): _TEXT, + vol.Optional("tank_id"): _TEXT, + }), + D_TANKVORGANG_MANUELL: vol.Schema(dict(_TANK_FELDER)), + D_TANKVORGANG_AKTUALISIEREN: vol.Schema({ + vol.Required("tank_id"): cv.string, **_TANK_FELDER + }), + D_TANKVORGANG_LOESCHEN: vol.Schema({vol.Required("tank_id"): cv.string}), + D_BILD_HOCHLADEN: vol.Schema({ + vol.Required("dateiname"): cv.string, + vol.Required("daten_base64"): cv.string, + }), + D_BILD_LOESCHEN: vol.Schema({vol.Required("dateiname"): cv.string}), + D_REIFEN_WECHSELN: vol.Schema({vol.Required("satz"): vol.In(reifen.SAETZE)}), + D_REIFEN_KM_SETZEN: vol.Schema({ + vol.Required("satz"): vol.In(reifen.SAETZE), + vol.Required("km"): vol.Coerce(float), + }), + D_BACKUP_WIEDERHERSTELLEN: vol.Schema({ + vol.Optional("profil_json"): _JSON, + vol.Optional("fahrten_json"): _JSON, + vol.Optional("tankvorgaenge_json"): _JSON, + vol.Optional("entitaeten_json"): _JSON, + }), + D_HISTORIE_IMPORTIEREN: vol.Schema({ + vol.Required("start"): cv.string, + vol.Required("ende"): cv.string, + }), +} + +Fabrik = Callable[[ServiceCall], Coroutine[Any, Any, None]] + + +def _als_dienst(fabrik: Fabrik) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: + """Macht aus einer Funktion, die eine Koroutine zurückgibt, eine echte + Koroutinen-Funktion. + + Home Assistant prüft mit iscoroutinefunction(), ob es einen Dienst + erwarten muss. Ein Lambda, das eine Koroutine nur zurückgibt, besteht + diese Prüfung nicht - der Aufruf würde stillschweigend nichts tun.""" + + async def behandler(aufruf: ServiceCall) -> None: + await fabrik(aufruf) + + return behandler + + +def _objekt(wert: object) -> Any: + return json.loads(wert) if isinstance(wert, str) else wert + + +def registrieren(hass: HomeAssistant, k: Koordinator) -> None: + """Meldet alle Dienste an. Gegenstück: abmelden().""" + + async def jetzt_aktualisieren(_aufruf: ServiceCall) -> None: + """Gegenstück zum "Ziehen zum Aktualisieren": stößt den von der + Datenquelle bereitgestellten Refresh-Knopf an, statt auf den nächsten + Takt zu warten. Ohne eingetragenen Knopf nur die direkte + Neuveröffentlichung - besser als gar nichts, auch wenn dann kein + echter neuer Abruf beim Fahrzeug passiert.""" + knopf = k.zuordnung.werte.REFRESH_BUTTON + if knopf: + await hass.services.async_call( + "button", "press", {"entity_id": knopf}, blocking=True + ) + await k.fahrzeugstatus_veroeffentlichen() + + async def profil_schreiben(aufruf: ServiceCall) -> None: + """Überschreibt das komplette Fahrzeugprofil. + + Die Oberfläche hält Profil, Fahrzeugdaten und Einstellungen ohnehin + als einen zusammenhängenden Stand im Speicher und schickt ihn nach + jeder Änderung komplett zurück - deshalb ein einziger Dienst statt + eines eigenen je Feld.""" + await k.ablage.profil_schreiben(_objekt(aufruf.data["profil_json"])) + await k.profil_veroeffentlichen() + + async def entitaeten_schreiben(aufruf: ServiceCall) -> None: + """Speichert die im Setup-Menü gewählte Sensor-Zuordnung, wendet sie + sofort an, bindet die Zustandsbeobachter neu und veröffentlicht + Katalog und Status neu. + + Das Neubinden ist der Grund, warum der frühere Hinweis "wirkt erst + nach einem Neustart" und der Neustart-Knopf daneben entfallen konnten: + auch Zündung, Kilometerstand und Tankfüllstand wirken jetzt sofort.""" + await k.zuordnung.speichern(_objekt(aufruf.data["zuordnung_json"])) + await k.trigger_neu_binden() + await k.zuordnung_veroeffentlichen() + await k.fahrzeugstatus_veroeffentlichen() + + async def historie_importieren(aufruf: ServiceCall) -> None: + """Startet den Import und kehrt sofort zurück. + + Der Import kann bei einem Jahr Verlauf Minuten dauern. Die Oberfläche + wartet deshalb nicht auf den Dienstaufruf, sondern verfolgt den + Fortschritt über sensor.audi_dashboard_import_status - genau so, wie + sie es schon bei der pyscript-Fassung tat, deren Dienste ebenfalls + sofort zurückkehrten.""" + k.entry.async_create_background_task( + hass, + historienimport.importieren(k, aufruf.data["start"], aufruf.data["ende"]), + "audi_dashboard_historienimport", + ) + + bilder_ordner = hass.config.path(BILDER_ORDNER) + + behandler: dict[str, Callable[[ServiceCall], Coroutine[Any, Any, None]]] = { + D_JETZT_AKTUALISIEREN: jetzt_aktualisieren, + D_PROFIL_SCHREIBEN: profil_schreiben, + D_ENTITAETEN_SCHREIBEN: entitaeten_schreiben, + D_HISTORIE_IMPORTIEREN: historie_importieren, + D_SCREENING_JETZT: _als_dienst(lambda _a: screening.durchfuehren(k)), + D_FAHRT_MANUELL_ANLEGEN: _als_dienst( + lambda a: fahrterkennung.manuell_anlegen(k, dict(a.data)) + ), + D_FAHRT_AKTUALISIEREN: _als_dienst( + lambda a: fahrterkennung.aktualisieren(k, dict(a.data)) + ), + D_FAHRT_LOESCHEN: _als_dienst( + lambda a: fahrterkennung.loeschen(k, a.data["trip_id"]) + ), + D_BELEG_HOCHLADEN: _als_dienst(lambda a: belege.hochladen(k, dict(a.data))), + D_TANKVORGANG_MANUELL: _als_dienst( + lambda a: belege.tankvorgang_manuell(k, dict(a.data)) + ), + D_TANKVORGANG_AKTUALISIEREN: _als_dienst( + lambda a: belege.tankvorgang_aktualisieren(k, dict(a.data)) + ), + D_TANKVORGANG_LOESCHEN: _als_dienst( + lambda a: belege.tankvorgang_loeschen(k, a.data["tank_id"]) + ), + D_BILD_HOCHLADEN: _als_dienst( + lambda a: bilder.hochladen( + hass, bilder_ordner, a.data["dateiname"], a.data["daten_base64"] + ) + ), + D_BILD_LOESCHEN: _als_dienst( + lambda a: bilder.loeschen(hass, bilder_ordner, a.data["dateiname"]) + ), + D_REIFEN_WECHSELN: _als_dienst(lambda a: reifen.wechseln(k, a.data["satz"])), + D_REIFEN_KM_SETZEN: _als_dienst( + lambda a: reifen.km_setzen(k, a.data["satz"], a.data["km"]) + ), + D_BACKUP_JETZT: _als_dienst(lambda _a: sicherung.jetzt(k)), + D_BACKUP_WIEDERHERSTELLEN: _als_dienst( + lambda a: sicherung.wiederherstellen(k, dict(a.data)) + ), + } + + for name, funktion in behandler.items(): + hass.services.async_register(DOMAIN, name, funktion, schema=SCHEMATA[name]) + + +def abmelden(hass: HomeAssistant) -> None: + for name in SCHEMATA: + hass.services.async_remove(DOMAIN, name) diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py new file mode 100644 index 0000000..b7f3c72 --- /dev/null +++ b/custom_components/audi_dashboard/einstellungen.py @@ -0,0 +1,197 @@ +"""Welche Home-Assistant-Entität welche Rolle im Fahrzeug spielt. + +Das ist die einzige technische Konfiguration der App. Alles andere (Pausen- +zeit, Reifendaten, Versicherung, ...) gehört ins Fahrzeugprofil und ist über +die Oberfläche änderbar. + +**Im Auslieferstand ist hier alles leer.** Das ist Absicht, kein unfertiger +Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen Instanz und +ihren Integrationen. Zugeordnet wird nach der Installation im Setup-Menü der +App (Einstellungen -> Fahrzeug einrichten -> Setup), das die Zuordnung nach +audi_dashboard/entitaeten.json schreibt (siehe zuordnung.py). + +Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt +"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in +veroeffentlichung.py), und die trigger-gebundenen Felder registrieren gar +keinen Zustandsbeobachter, statt einen gegen eine nicht existierende Entität +zu registrieren. Eine gesetzte, aber falsche Entity-ID ist deshalb schlechter +als eine leere - genau das ist 2026-08-23 einmal passiert, als hier noch die +Entity-IDs einer längst abgeräumten Testinstanz standen. + +Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker +mit CAN-Anbindung, über flespi angebunden) und eine EU-Data-Act-Integration +des Herstellers. Welche davon welche Rolle bedient, entscheidet das +Setup-Menü - nicht diese Datei. + +UNTERSCHIED ZUR PYSCRIPT-FASSUNG: dort waren das Modul-Variablen, die +entitaeten.py zur Laufzeit per setattr() auf dem importierten Modul-Objekt +überschrieb. Das funktionierte, war aber ein globaler, veränderlicher Zustand +quer durch alle Dateien. Hier ist es ein ganz normales Objekt, das der +Koordinator hält und weiterreicht. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, fields + +# Vier gleich aufgebaute Positionen für Türen und Fenster. +POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"] + + +@dataclass +class Sensorzuordnung: + """Die wirksame Zuordnung. Feldnamen = Schlüssel in entitaeten.json.""" + + # Fahrterkennung: Start/Ende einer Fahrt über den Zündungs-/ACC-Status + # (on = Fahrt läuft). Dieselbe Entität speist auch die Anzeige + # "fährt/steht" - damit können Erfassung und Anzeige nicht auseinander- + # laufen. + ZUENDUNG_SENSOR: str = "" + + # Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und + # Ölwechsel-Prognose. + # + # Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand + # (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Strecken- + # rechnung statt auf dem Tacho und damit auf einer anderen Zählbasis als + # der echte Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle + # drei genannten Auswertungen mit einem inkonsistenten Basiswert. Der vom + # CAN gelesene Wert (*_total_vehicle_mileage_read_from_can) bzw. der + # Kilometerstand der EU-Data-Act-Integration ist der richtige. + KM_SENSOR: str = "" + + # Tankfüllstand in Prozent - für die automatische Tankerkennung. + TANK_SENSOR: str = "" + + # Reichweite (Übersicht). + RANGE_SENSOR: str = "" + + # 12V-Batteriespannung. Beim FMM003 ist das external_power_voltage - die + # vom Gerät gemessene Bordnetzspannung des Fahrzeugs -, NICHT + # battery_voltage (das ist die interne Pufferbatterie des Trackers selbst + # und hat mit der Fahrzeugbatterie nichts zu tun). + BATTERIE_SENSOR: str = "" + + # Knopf für eine sofortige Neuabfrage beim Fahrzeug. + REFRESH_BUTTON: str = "" + + # Türen/Fenster (je vier Positionen, siehe POSITIONEN). + TUER_SENSOREN: list[str] = field(default_factory=list) + FENSTER_SENSOREN: list[str] = field(default_factory=list) + HECKKLAPPE_SENSOR: str = "" + HAUBE_SENSOR: str = "" + + # Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die + # App-eigene, aus dem Servicebuch berechnete Prognose). + NAECHSTER_OELWECHSEL_SENSOR: str = "" + OELWECHSEL_STRECKE_SENSOR: str = "" + NAECHSTE_INSPEKTION_SENSOR: str = "" + INSPEKTION_STRECKE_SENSOR: str = "" + + # Live-GPS-Position (Übersicht -> Standort-Kachel). Breiten-/Längengrad + # als zwei eigene sensor-Entities: flespi liefert Koordinaten so, nicht + # als Attribute einer device_tracker-Entity. + STANDORT_LAT_SENSOR: str = "" + STANDORT_LON_SENSOR: str = "" + + +def _standardwerte() -> dict[str, object]: + """Die eingebauten Werte, einmal eingefroren. + + Gebraucht für "Zurücksetzen" im Setup-Menü: die Zuordnung wird bei jedem + Anwenden vollständig neu gesetzt, ein weggelassener Override fällt damit + auf genau diesen Wert zurück.""" + leer = Sensorzuordnung() + werte: dict[str, object] = {} + for f in fields(leer): + wert = getattr(leer, f.name) + werte[f.name] = list(wert) if isinstance(wert, list) else wert + return werte + + +STANDARDWERTE: dict[str, object] = _standardwerte() + +SCHLUESSEL: set[str] = set(STANDARDWERTE) + + +# Katalog aller Rollen fürs Setup-Menü im Frontend. Reihenfolge = +# Anzeige-Reihenfolge innerhalb jeder Gruppe. domains/device_classes/units +# sind Erwartungen fürs Frontend-seitige Filtern und Vorschlagen (Schalter +# "Nur passende Sensoren anzeigen") - eine fehlende oder leere Liste bedeutet +# "keine Einschränkung" bzw. "diese Rolle hat üblicherweise keine Einheit". +FELDER: list[dict] = [ + {"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung", + "hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende.", + "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True, + "stichworte": ["zündung", "ignition", "acc", "motor", "engine"]}, + {"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung", + "hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.", + "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]}, + {"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung", + "hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.", + "domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False, + "stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]}, + {"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht", + "hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".", + "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "stichworte": ["reichweite", "range"]}, + {"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht", + "hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".", + "domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False, + "stichworte": ["batterie", "battery", "spannung", "volt", "12v"]}, + {"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht", + "hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".", + "domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False, + "stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]}, + {"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.", + "domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN, + "pflicht": False, "stichworte": ["tür", "door"]}, + {"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = zu.", + "domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN, + "pflicht": False, "stichworte": ["fenster", "window"]}, + {"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = zu.", + "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]}, + {"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = zu.", + "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["haube", "hood", "bonnet", "motorhaube"]}, + {"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht", + "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.", + "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["ölwechsel", "oil", "service", "change"]}, + {"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht", + "hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", + "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "stichworte": ["ölwechsel", "oil", "distance", "strecke"]}, + {"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht", + "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.", + "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["inspektion", "inspection", "service"]}, + {"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht", + "hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", + "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, + "stichworte": ["inspektion", "inspection", "distance", "strecke"]}, + {"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort", + "hinweis": "Breitengrad als eigener Sensor (z. B. flespi).", + "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, + "stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]}, + {"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort", + "hinweis": "Längengrad als eigener Sensor (z. B. flespi).", + "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, + "stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]}, +] + +# In der pyscript-Fassung stand hier eine Liste TRIGGER_GEBUNDEN: die drei +# Felder ZUENDUNG/KM/TANK_SENSOR waren beim Modul-Laden einmalig in die +# @state_trigger-Registrierung eingebacken, eine Änderung wirkte deshalb erst +# nach einem HA-Neustart, und das Setup-Menü musste genau davor warnen. +# Diese Integration bindet ihre Zustandsbeobachter bei jeder Änderung neu +# (koordinator.trigger_neu_binden) - die Einschränkung, der Warnhinweis und +# der eigens dafür gebaute Neustart-Dienst sind damit ersatzlos weg. +for _feld in FELDER: + _feld["trigger_gebunden"] = False diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py new file mode 100644 index 0000000..7ab48ec --- /dev/null +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -0,0 +1,281 @@ +"""Fahrterkennung über den Zündungs-/ACC-Status (SPECIFICATION.md §7.1). + +Start: die Zündungs-Entität wechselt auf "on". Ende: sie wechselt auf "off" +und kommt binnen der Pausenzeit nicht zurück. + +Die Pausenregel läuft über eine abbrechbare Aufgabe: jede neue Änderung der +Zündung bricht eine noch wartende Ende-Bestätigung ab. Kommt die Zündung +während der Wartezeit zurück, wird die Fahrt dadurch nicht beendet - genau +die Pausenregel aus §7.1. (In der pyscript-Fassung leisteten task.unique() und +task.sleep() dasselbe.) + +WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG BESSER IST: dort lebte der Beginn +einer laufenden Fahrt nur im Arbeitsspeicher. Ein HA-Neustart mitten in einer +Fahrt oder während der Wartezeit verlor diesen Zwischenstand - die Fahrt wurde +dann gar nicht aufgezeichnet. Das war als bekannte Schwäche vermerkt und ist +hier behoben: der Zwischenstand liegt in einem Store (siehe koordinator.py), +überlebt einen Neustart und wird beim Hochfahren wieder aufgenommen. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +from typing import TYPE_CHECKING + +from . import screening +from .ablage import neue_id +from .veroeffentlichung import zustand_oder_none +from .verlauf import verlauf_lesen + +if TYPE_CHECKING: + from .koordinator import Koordinator + +_LOGGER = logging.getLogger(__name__) + +STANDARD_PAUSENZEIT_MIN = 15 + + +def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: str) -> dict: + """Das vollständige Fahrt-Schema mit allem, was noch nicht bekannt ist, + auf None. Bewusst alle Felder ausgeschrieben: die Oberfläche darf sich auf + ihre Existenz verlassen, und ein fehlendes Feld wäre schwerer zu finden + als ein leeres.""" + return { + "trip_id": neue_id("t"), + "ts_start": start_ts.isoformat(), + "ts_end": ende_ts.isoformat(), + "duration_s": int((ende_ts - start_ts).total_seconds()), + "distance_km": None, + "km_quelle": None, + "odo_start": None, + "odo_end": None, + "avg_speed_kmh": None, + "start_lat": None, + "start_lon": None, + "end_lat": None, + "end_lon": None, + "start_address": None, + "end_address": None, + "art": "privat", + "route": None, + "pausen": [], + "source": quelle, + "status": "offen", + "edited_fields": [], + } + + +async def pausenzeit_sekunden(k: Koordinator) -> int: + profil = await k.ablage.profil_lesen() + if profil is None: + return STANDARD_PAUSENZEIT_MIN * 60 + minuten = profil.get("einstellungen", {}).get( + "fahrten_pausenzeit_min", STANDARD_PAUSENZEIT_MIN + ) + return int(minuten) * 60 + + +async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None: + """Reagiert auf jede Zustandsänderung der Zündungs-Entität.""" + # Eine noch wartende Ende-Bestätigung aus einer vorherigen Änderung + # abbrechen - das ist der Mechanismus hinter der Pausenregel. + k.warte_ende_ab_abbrechen() + + an_jetzt = neu == "on" + an_vorher = alt == "on" + + if an_jetzt and not an_vorher: + if k.fahrt_start_ts is None: + await k.fahrt_start_setzen(datetime.datetime.now(datetime.UTC)) + _LOGGER.info("Fahrt gestartet um %s", k.fahrt_start_ts) + # Die Anzeige "fährt/steht" hängt an derselben Entität - sofort neu + # veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu warten. + await k.fahrzeugstatus_veroeffentlichen() + return + + if an_vorher and not an_jetzt and k.fahrt_start_ts is not None: + start_ts = k.fahrt_start_ts + abbruch_ts = datetime.datetime.now(datetime.UTC) + wartezeit = await pausenzeit_sekunden(k) + k.warte_ende_ab(_ende_bestaetigen(k, start_ts, abbruch_ts, wartezeit)) + + await k.fahrzeugstatus_veroeffentlichen() + + +async def _ende_bestaetigen( + k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime, wartezeit: int +) -> None: + """Wartet die Pausenzeit ab und beendet die Fahrt dann. + + Kommt die Zündung vorher zurück, bricht zuendung_geaendert() genau diese + Aufgabe ab und der Code hinter dem sleep läuft nie.""" + try: + await asyncio.sleep(wartezeit) + except asyncio.CancelledError: + _LOGGER.debug("Fahrtende verworfen - Zündung kam innerhalb der Pausenzeit zurück") + raise + await fahrt_beenden(k, start_ts, ende_ts) + + +async def nach_neustart_fortsetzen(k: Koordinator) -> None: + """Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging. + + Der Zwischenstand einer laufenden Fahrt überlebt jetzt einen Neustart + (Store im Koordinator). Damit entsteht aber ein neuer Fall, den es vorher + nicht gab: ging die Zündung aus, WÄHREND Home Assistant unten war, hat + niemand die Ende-Bestätigung gesehen - der Beobachter bekommt nach dem + Hochfahren keinen Wechsel mehr, weil der längst passiert ist. Ohne diese + Prüfung bliebe die Fahrt für immer offen, und die nächste echte Fahrt + würde an ihren Beginn angehängt. + + Läuft bewusst erst, wenn Home Assistant vollständig gestartet ist: mitten + im Einrichten hat die Datenquelle ihren Zustand oft noch nicht gemeldet, + und ein "noch nicht da" ließe sich nicht von "aus" unterscheiden.""" + if k.fahrt_start_ts is None: + return + + sensor = k.zuordnung.werte.ZUENDUNG_SENSOR + if zustand_oder_none(k.hass, sensor) == "on": + return # fährt noch - der Beobachter übernimmt wie sonst auch + + ende_ts = await _letztes_lebenszeichen(k, sensor, k.fahrt_start_ts) + _LOGGER.info( + "Fahrt seit %s wurde während eines Ausfalls beendet - wird jetzt mit " + "Ende %s abgeschlossen", k.fahrt_start_ts.isoformat(), ende_ts.isoformat(), + ) + await fahrt_beenden(k, k.fahrt_start_ts, ende_ts) + + +async def _letztes_lebenszeichen( + k: Koordinator, sensor: str, seit: datetime.datetime +) -> datetime.datetime: + """Der zuletzt aufgezeichnete Zeitpunkt der Zündungs-Entität seit `seit`. + + Das ist der ehrlichste verfügbare Endzeitpunkt: bis dahin wissen wir, dass + das Fahrzeug beobachtet wurde. "Jetzt" zu nehmen wäre falsch, wenn Home + Assistant tagelang unten war - daraus würde eine Fahrt über Tage. Gibt es + gar keine Aufzeichnung (Sensor nicht zugeordnet, recorder zu kurz), bleibt + nur "jetzt"; das Screening trägt die Kilometerstände ohnehin nach.""" + jetzt = datetime.datetime.now(datetime.UTC) + punkte = await verlauf_lesen(k.hass, sensor, seit, jetzt) + return punkte[-1][0] if punkte else jetzt + + +async def fahrt_beenden( + k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime +) -> None: + fahrt = leere_fahrt(start_ts, ende_ts, "ha") + await k.ablage.fahrt_anhaengen(fahrt) + await k.fahrt_start_setzen(None) + await k.fahrten_veroeffentlichen() + _LOGGER.info( + "Fahrt %s beendet, Status 'offen', Screening angestoßen", fahrt["trip_id"] + ) + await screening.durchfuehren(k) + + +# ------------------------------------------------------- Aus der Oberfläche + +HANDFELDER = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"} + + +def _handfelder( + start_ort: str | None, + ziel_ort: str | None, + odo_start: float | None, + odo_end: float | None, + distanz: float | None, +) -> dict: + """Die von Hand befüllbaren Felder auf ihre Datensatz-Namen abgebildet, + ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel zusätzlich + nach edited_fields - das schützt sie vor dem automatischen + Kilometerstand-Screening, das sonst später darüberschreiben würde.""" + werte = { + "start_address": start_ort or None, + "end_address": ziel_ort or None, + "odo_start": odo_start, + "odo_end": odo_end, + "distance_km": distanz, + } + felder = {k: v for k, v in werte.items() if v is not None} + # Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen - + # dieselbe Rechnung wie im Screening. + if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder: + felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1) + return felder + + +async def manuell_anlegen(k: Koordinator, daten: dict) -> None: + """Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der + automatischen Zündungs-Erkennung. Alles außer Start- und Endzeitpunkt ist + optional und bleibt leer, wenn es das Screening später selbst ergänzen + soll.""" + start_ts = datetime.datetime.fromisoformat(daten["ts_start"]) + ende_ts = datetime.datetime.fromisoformat(daten["ts_end"]) + hand = _handfelder( + daten.get("start_ort"), daten.get("ziel_ort"), + daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"), + ) + + fahrt = leere_fahrt(start_ts, ende_ts, "manual") + fahrt["art"] = daten.get("art") or "privat" + fahrt.update(hand) + fahrt["edited_fields"] = sorted(hand) + if fahrt["distance_km"] is not None: + fahrt["km_quelle"] = "manuell" + fahrt["status"] = "vollständig" + + await k.ablage.fahrt_anhaengen(fahrt) + await k.fahrten_veroeffentlichen() + await screening.durchfuehren(k) + _LOGGER.info("Fahrt %s von Hand angelegt", fahrt["trip_id"]) + + +async def aktualisieren(k: Koordinator, daten: dict) -> None: + """Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie + automatisch erkannt oder von Hand angelegt wurde.""" + trip_id = daten.get("trip_id") + if not trip_id: + _LOGGER.warning("fahrt_aktualisieren ohne trip_id aufgerufen") + return + + aenderungen = _handfelder( + daten.get("start_ort"), daten.get("ziel_ort"), + daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"), + ) + if daten.get("art"): + aenderungen["art"] = daten["art"] + if daten.get("ts_start") and daten.get("ts_end"): + start_ts = datetime.datetime.fromisoformat(daten["ts_start"]) + ende_ts = datetime.datetime.fromisoformat(daten["ts_end"]) + aenderungen["ts_start"] = start_ts.isoformat() + aenderungen["ts_end"] = ende_ts.isoformat() + aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds()) + if aenderungen.get("distance_km") is not None: + aenderungen["km_quelle"] = "manuell" + aenderungen["status"] = "vollständig" + + # Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit, + # damit ein späteres Screening sie nicht wieder überschreibt. + passend = [f for f in await k.ablage.fahrten_lesen() if f.get("trip_id") == trip_id] + bisher = set(passend[0].get("edited_fields", [])) if passend else set() + aenderungen["edited_fields"] = sorted(bisher | (HANDFELDER & set(aenderungen))) + + if await k.ablage.fahrt_bearbeiten(trip_id, aenderungen): + await k.fahrten_veroeffentlichen() + _LOGGER.info("Fahrt %s aktualisiert", trip_id) + else: + _LOGGER.warning("Fahrt %s nicht gefunden", trip_id) + + +async def loeschen(k: Koordinator, trip_id: str | None) -> None: + if not trip_id: + _LOGGER.warning("fahrt_loeschen ohne trip_id aufgerufen") + return + if await k.ablage.fahrt_loeschen(trip_id): + await k.fahrten_veroeffentlichen() + _LOGGER.info("Fahrt %s gelöscht", trip_id) + else: + _LOGGER.warning("Fahrt %s nicht gefunden", trip_id) diff --git a/homeassistant/installationspaket/www/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js similarity index 96% rename from homeassistant/installationspaket/www/audi-dashboard-app.js rename to custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 4f16168..7086940 100644 --- a/homeassistant/installationspaket/www/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -7,7 +7,7 @@ Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert (dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den - pyscript-Zustandsentitäten geladen statt fest im Code zu stehen) und die + Entitäten der Integration geladen statt fest im Code zu stehen) und die DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten). Bekannte Lücken in diesem Stand: @@ -35,21 +35,23 @@ let CAR = null; let TRIPS = []; let FILLS = []; let BATTERIEVERLAUF = []; -let ENTITAETEN = null; // {katalog, werte} aus pyscript.audi_dashboard_entitaeten (Setup-Menü) +let ENTITAETEN = null; // {katalog, werte} aus sensor.audi_dashboard_entitaeten (Setup-Menü) let DATEN_GELADEN = false; // Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend - // null, solange keiner bekannt ist (siehe standAlterText()). let LETZTES_UPDATE_TS = null; -/* Cache-Buster fuer audi-dashboard.css: diese Datei selbst wird von HA mit - derselben langen /local/-Cache-Zeit ausgeliefert wie audi-dashboard-panel.js - (siehe dessen Kopfkommentar) - ohne Versions-Query bleibt ein Browser, der - sie einmal geladen hat, auch nach einer CSS-Aenderung auf dem alten Stand - haengen, obwohl app.js selbst per audi-dashboard-version.json korrekt neu - geladen wird. Die eigene Script-URL (?v=N, von audi-dashboard-panel.js - gesetzt) liefert dieselbe Versionsnummer, ohne version.json ein zweites - Mal abfragen zu muessen. - */ +/* Cache-Brecher für audi-dashboard.css und audi-dashboard-ios.css: die + Stylesheets werden nicht als Modul geladen, sondern als in den + Shadow-Root gehängt - sie brauchen deshalb ihre eigene Versions-Query. + Ohne sie bliebe ein Browser, der sie einmal geladen hat, nach einer + CSS-Änderung auf dem alten Stand hängen, obwohl der Code selbst korrekt + neu geladen wird. + + Die Zahl steht schon in der eigenen Modul-URL (?v=…, von + audi-dashboard-panel.js durchgereicht, ursprünglich von der Integration + beim Anmelden des Panels gesetzt) - eine eigene Abfrage braucht es + nicht. */ const CSS_VERSION = (() => { try { return new URL(import.meta.url).searchParams.get("v") || Date.now(); @@ -58,6 +60,39 @@ const CSS_VERSION = (() => { } })(); +/* Basis-URL der ausgelieferten Dateien der Integration (CSS, Badges, + Markenlogo). Aus der eigenen Modul-URL abgeleitet statt fest verdrahtet: + damit stimmt sie auch dann, wenn sich der statische Pfad in const.py + einmal ändert, und es gibt keine zweite Stelle, die mitgepflegt werden + müsste. + + NICHT hierunter fallen die Fahrzeugfotos: die lädt der Nutzer selbst hoch + und sie liegen weiterhin unter /local/bilder/ (siehe bilder.py) - im + Integrationsordner würde sie das nächste Update mitlöschen. */ +const STATIK = new URL(".", import.meta.url).href.replace(/\/$/, ""); + +/* ------------------------------------------------- Namen des Backends + Die eine Stelle, an der die Entity-IDs und Dienstnamen stehen. Sie sind + ein Vertrag mit custom_components/audi_dashboard/const.py; die + Companion-App führt dieselbe Tabelle in src/api/types.ts. + + Bis 2026-08-23 hießen sie pyscript.audi_dashboard_* bzw. + pyscript.audi_dashboard_: der Präfix gehörte pyscript, das die + Entitäten damals bereitstellte. Seit dem Umbau zur eigenen Integration + gehören sie ihr auch dem Namen nach. */ +const DOMAIN = "audi_dashboard"; +const E = { + profil: "sensor.audi_dashboard_profil", + fahrten: "sensor.audi_dashboard_fahrten", + tankvorgaenge: "sensor.audi_dashboard_tankvorgaenge", + fahrzeugstatus: "sensor.audi_dashboard_fahrzeugstatus", + batterieverlauf: "sensor.audi_dashboard_batterieverlauf", + entitaeten: "sensor.audi_dashboard_entitaeten", + belegErgebnis: "sensor.audi_dashboard_beleg_ergebnis", + importStatus: "sensor.audi_dashboard_import_status", + appVersion: "sensor.audi_dashboard_app_version", +}; + const $ = (s) => ROOT.querySelector(s); /* ---------------------------------------------------------------- Format */ @@ -196,7 +231,7 @@ async function profilSpeichern() { // promise rejection ohne jede Rueckmeldung. Zentral hier gefangen, aus // demselben Grund wie serviceRufen() weiter unten. try { - await HASS.callService("pyscript", "audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(neuesProfil) }); + await HASS.callService(DOMAIN, "profil_schreiben", { profil_json: JSON.stringify(neuesProfil) }); } catch (err) { console.error("audi_dashboard: profilSpeichern", err); hinweis("Speichern fehlgeschlagen", err && err.message ? err.message : "Die Änderung konnte nicht gespeichert werden."); @@ -204,9 +239,13 @@ async function profilSpeichern() { } /* ------------------------------------------------------------- Bilder - Feste Dateinamen laut §7a, referenziert unter /local/ (aus www/ serviert). - Der Ordner bilder/ selbst ist noch nicht befüllt (siehe Kopfkommentar) - - die Flächen behalten dennoch ihre Maße, das Layout springt nicht. */ + Feste Dateinamen laut §7a. Diese Fotos lädt der Nutzer selbst hoch; sie + liegen deshalb unter /local/bilder/ (also in /config/www/bilder/) und + nicht bei den ausgelieferten Dateien der Integration - dort würde sie das + nächste Update mitlöschen (siehe bilder.py). + + Solange ein Foto fehlt, behalten die Flächen ihre Maße und zeigen einen + Platzhalter, das Layout springt beim Nachliefern also nicht. */ const BILDER = [ { name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" }, { name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" }, @@ -289,7 +328,7 @@ function markenlogo() { if (!CAR.smartdeal.aktiv) return ""; const label = CONFIG.markenname || "Logo"; return ``; @@ -1119,8 +1158,8 @@ function badge() { const zusatz = [modellZusatz, doppelt ? "" : ausfuehrung].filter(Boolean).join(" "); const alt = esc(CONFIG.fahrzeugtitel) + " Badge"; return `
- ${alt} - ${alt} + ${alt} + ${alt} ${zusatz ? `${esc(zusatz)}` : ""}
`; } @@ -2225,7 +2264,7 @@ function vEinst() {
Kraftstoff-Rabatt
+ Shell SmartDeal aktiv
${CAR.smartdeal.aktiv ? `
@@ -2288,24 +2327,15 @@ function vEinst() {
Version
Dashboard
${esc(CONFIG.version)}
+
Installiert
${esc(APP_VERSION || "unbekannt")}Home-Assistant-Integration
Fahrzeugdaten
FMM003Zündung · Batterie
Position
FMM003GPS-Tracker
- ${(() => { - const s = UPDATE_STATUS; - if (!s || s.stand === "keine_quelle") return ` - ${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"} - `; - if (s.stand === "fehler") return ` - ${esc(s.fehler || "Update-Prüfung fehlgeschlagen")} - `; - if (s.stand === "geprueft" && s.verfuegbar) return ` - Update verfügbar — Version ${esc(s.neue_version)} - `; - return ` - Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""} - `; - })()} + Updates laufen über HACS + + HACS meldet selbst, wenn eine neuere Fassung bereitliegt, und installiert sie. + Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben dabei unangetastet — + die liegen außerhalb des Integrationsordners.
`; } @@ -2323,7 +2353,7 @@ let kmOffen = null; an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */ let setupOffen = false; let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder -let setupZuordnungStart = null; // Schnappschuss von setupZuordnung beim Öffnen - für den Neustart-Hinweis (welche trigger-gebundenen Felder haben sich geändert) +let setupZuordnungStart = null; // Schnappschuss beim Öffnen - erlaubt "Abbrechen" ohne Nebeneffekt let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null let setupSuchtext = ""; @@ -2471,15 +2501,17 @@ function setupDuplikate() { return Object.keys(zaehler).filter((id) => zaehler[id] > 1); } -/* Labels der trigger-gebundenen Felder (ZUENDUNG/KM/TANK), deren Wert sich - seit dem Öffnen des Popups geändert hat - für den Neustart-Hinweis nach - dem Speichern. */ -function setupGeaenderteTriggerFelder() { - if (!setupZuordnungStart) return []; - return setupKatalog() - .filter((f) => f.trigger_gebunden && JSON.stringify(setupZuordnung[f.key]) !== JSON.stringify(setupZuordnungStart[f.key])) - .map((f) => f.label); -} +/* Hier stand bis 2026-08-23 setupGeaenderteTriggerFelder(): drei Felder + (Zündung, Kilometerstand, Tankfüllstand) waren in der pyscript-Fassung + beim Modul-Laden fest in die Trigger-Registrierung eingebacken und wirkten + erst nach einem Neustart von Home Assistant. Das Setup-Fenster musste + deshalb erkennen, ob eines davon geändert wurde, davor warnen und einen + Neustart-Knopf anbieten. + + Die Integration bindet ihre Zustandsbeobachter bei jedem Speichern neu + (trigger_neu_binden in koordinator.py). Die Warnung, der Knopf und der + Dienst dahinter sind damit ersatzlos entfallen - Speichern wirkt jetzt bei + allen Feldern sofort. */ function setupSpeichernAusfuehren() { // Zweiter Riegel gegen den Datenverlust-Pfad: selbst wenn das Fenster auf @@ -2492,26 +2524,11 @@ function setupSpeichernAusfuehren() { setupSpeichertLaeuft = true; render(); (async () => { try { - await HASS.callService("pyscript", "audi_dashboard_entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) }); + await HASS.callService(DOMAIN, "entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) }); } catch (err) { setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return; } - const geaendert = setupGeaenderteTriggerFelder(); setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render(); - if (geaendert.length) { - bestaetigen("Neustart nötig", `Wirkt für ${geaendert.join(", ")} erst nach einem Neustart von Home Assistant.`, "Jetzt neu starten", setupNeustartAusloesen, false); - } - })(); -} - -function setupNeustartAusloesen() { - (async () => { - try { - await HASS.callService("pyscript", "audi_dashboard_neustart", {}); - hinweis("Neustart ausgelöst", "Home Assistant startet neu - die App lädt sich danach automatisch neu."); - } catch (err) { - hinweis("Neustart fehlgeschlagen", err.message); - } })(); } @@ -2953,9 +2970,9 @@ const SWIPE_BREITE = 84; let ptrY0 = null, ptrDrag = 0, ptrLaedt = false; async function datenAktualisieren() { try { - await HASS.callService("pyscript", "audi_dashboard_jetzt_aktualisieren", {}); + await HASS.callService(DOMAIN, "jetzt_aktualisieren", {}); const states = await HASS.callWS({ type: "get_states" }); - const frisch = states.find((s) => s.entity_id === "pyscript.audi_dashboard_fahrzeugstatus"); + const frisch = states.find((s) => s.entity_id === E.fahrzeugstatus); if (frisch) NACHGELADEN.status = frisch; } catch (e) { // bleibt ohne sichtbaren Effekt - der naechste periodische 20s-Takt @@ -2986,10 +3003,10 @@ const LOESCH_HINWEIS = { }; function eintragLoeschen(art, id) { if (art === "trip") { - serviceRufen("audi_dashboard_fahrt_loeschen", { trip_id: id }); + serviceRufen("fahrt_loeschen", { trip_id: id }); TRIPS = TRIPS.filter((t) => t.trip_id !== id); } else if (art === "fill") { - serviceRufen("audi_dashboard_tankvorgang_loeschen", { tank_id: id }); + serviceRufen("tankvorgang_loeschen", { tank_id: id }); FILLS = FILLS.filter((f) => f.tank_id !== id); } else if (art === "sbuch") { CAR.service.buch.splice(parseInt(id, 10), 1); @@ -3004,24 +3021,19 @@ function eintragLoeschen(art, id) { let entwurfIndex = null; const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"]; -/* Update-Funktion (Einstellungen -> "Update suchen", siehe - updateverwaltung.py). HASS.states wird nach einem Service-Aufruf nicht - zuverlässig sofort nachgeführt (derselbe Effekt wie beim Nachlade- - Fallback oben) - deshalb der frische Stand aktiv per WebSocket geholt statt - passiv auf den nächsten hass-Push zu warten. */ -let UPDATE_STATUS = null; -let UPDATE_STATUS_GEHOLT = false; -let updatePruefend = false; -let updateInstallierend = false; -async function updateStatusAbrufen() { - try { - const states = await HASS.callWS({ type: "get_states" }); - const s = states.find((x) => x.entity_id === "pyscript.audi_dashboard_update_status"); - UPDATE_STATUS = (s && s.attributes && s.attributes.daten) || null; - } catch (e) { - // zeigt dann weiterhin den zuletzt bekannten Stand - } -} +/* Welche Fassung der Integration gerade installiert ist (Einstellungen -> + Version). Kommt aus sensor.audi_dashboard_app_version, also aus der + manifest.json der Integration - der Zahl, die auch HACS anzeigt. + + Hier stand bis 2026-08-23 eine eigene Update-Funktion: "Update suchen" + klonte ein Git-Repository in einen Zwischenordner und kopierte pyscript/ + und www/ von dort über den laufenden Code. Das war nötig, solange die App + keine Installationsform hatte, die Home Assistant selbst kennt. Als + HACS-Integration gibt es die: HACS prüft, lädt, installiert und kann + zurückrollen, mit Versionsanzeige und Änderungsprotokoll. Eine zweite, + selbstgebaute Update-Mechanik daneben wäre mehr Angriffsfläche als + Nutzen - der eigene Updater ist deshalb ersatzlos entfallen. */ +let APP_VERSION = null; /* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel (Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den @@ -3296,7 +3308,7 @@ function belegDateiVerarbeiten(datei, tankId, kontext) { const leser = new FileReader(); leser.onload = () => { const base64 = leser.result.split(",")[1]; - HASS.callService("pyscript", "audi_dashboard_beleg_hochladen", + HASS.callService(DOMAIN, "beleg_hochladen", { pdf_base64: base64, dateiname: datei.name || "beleg.pdf", tank_id: tankId } ).catch((err) => { console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err); @@ -3364,9 +3376,10 @@ function zwischenablageKnopfSinnvoll() { /* ------------------------------------------------- Historien-Import-Popup Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche - Import läuft im Backend (pyscript/historienimport.py) und meldet seinen - Stand über die Entität pyscript.audi_dashboard_import_status - dasselbe - Muster wie beim Update-Status (UPDATE_STATUS oben). */ + Import läuft im Backend (custom_components/audi_dashboard/historienimport.py) + und meldet seinen Stand über die Entität sensor.audi_dashboard_import_status: + der Dienstaufruf kehrt sofort zurück, weil ein Import über ein Jahr Verlauf + Minuten dauern kann. */ let importPopup = null; /* Vorbelegung: die letzten 30 Tage. Wert im Format, das ${esc(feld.label)} ${esc(feld.hinweis)} - ${feld.trigger_gebunden ? `Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.` : ""}
`; if (feld.liste) { return `
${kopf} @@ -3723,14 +3735,14 @@ function randwischenVerdrahten() { Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu schließen. Das Backend meldet seinen Stand über - pyscript.audi_dashboard_import_status; hier wird dieselbe Entität gepollt, + sensor.audi_dashboard_import_status; hier wird dieselbe Entität gepollt, bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des - callService-Versprechens, weil pyscript-Dienste sofort zurückkehren, wenn + callService-Versprechens, weil der Import-Dienst sofort zurückkehrt, wenn sie im Hintergrund weiterlaufen. */ async function importStatusLesen() { try { const states = await HASS.callWS({ type: "get_states" }); - return states.find((x) => x.entity_id === "pyscript.audi_dashboard_import_status") || null; + return states.find((x) => x.entity_id === E.importStatus) || null; } catch (e) { return null; } @@ -3753,7 +3765,7 @@ async function importAusloesen() { const vorherStand = vorher ? vorher.last_updated : null; try { - await HASS.callService("pyscript", "audi_dashboard_historie_importieren", { start: von, ende: bis }); + await HASS.callService(DOMAIN, "historie_importieren", { start: von, ende: bis }); } catch (err) { console.error("audi_dashboard: Import", err); if (importPopup) { @@ -3803,7 +3815,7 @@ function serviceRufen(dienst, daten) { // verloren gehen (derselbe Fehler, der für den Beleg-Upload und das Setup- // Speichern schon einmal einzeln gefixt wurde - hier zentral für alle // ~20 Aufrufstellen). - HASS.callService("pyscript", dienst, daten).catch((err) => { + HASS.callService(DOMAIN, dienst, daten).catch((err) => { console.error("audi_dashboard:", dienst, err); hinweis("Aktion fehlgeschlagen", err && err.message ? err.message : "Der Dienst konnte nicht ausgeführt werden."); }); @@ -4181,7 +4193,7 @@ function ereignisseVerdrahten() { const dateiname = bildDel.dataset.bildloeschen; bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => { (async () => { - try { await HASS.callService("pyscript", "audi_dashboard_bild_loeschen", { dateiname }); } + try { await HASS.callService(DOMAIN, "bild_loeschen", { dateiname }); } catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; } bildMenuOffen = null; bildVersion = Date.now(); render(); })(); @@ -4249,7 +4261,7 @@ function ereignisseVerdrahten() { // Zaehler selbst setzt und referenz_odo_km unangetastet laesst, damit // die naechste automatische Fortschreibung ab diesem neuen Stand // weiterzaehlt statt ihn zu ueberschreiben. - serviceRufen("audi_dashboard_reifen_km_setzen", { satz, km }); + serviceRufen("reifen_km_setzen", { satz, km }); } kmOffen = null; render(); return; } @@ -4261,7 +4273,7 @@ function ereignisseVerdrahten() { } if (e.target.closest("[data-fahrt]")) { fahrtOffen = !fahrtOffen; render(); return; } if (e.target.closest("[data-fahrtspeichern]")) { - serviceRufen("audi_dashboard_fahrt_manuell_anlegen", fahrtFormularWerte("neuFahrt")); + serviceRufen("fahrt_manuell_anlegen", fahrtFormularWerte("neuFahrt")); fahrtOffen = false; render(); return; } if (e.target.closest("[data-fahrtbearbeiten]")) { @@ -4269,14 +4281,14 @@ function ereignisseVerdrahten() { } const fahrtAkt = e.target.closest("[data-fahrtaktualisieren]"); if (fahrtAkt) { - serviceRufen("audi_dashboard_fahrt_aktualisieren", + serviceRufen("fahrt_aktualisieren", { trip_id: fahrtAkt.dataset.fahrtaktualisieren, ...fahrtFormularWerte("bearbFahrt") }); fahrtBearbeitenOffen = false; render(); return; } if (e.target.closest("[data-tankspeichern]")) { const w = tankFormularWerte("neuTank"); if (w.liter > 0 && w.kosten > 0) { - serviceRufen("audi_dashboard_tankvorgang_manuell", { + serviceRufen("tankvorgang_manuell", { ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, distanz: w.distanz, kraftstoff: w.kraftstoff, }); @@ -4287,7 +4299,7 @@ function ereignisseVerdrahten() { const w = tankFormularWerte("neuTank"); const beleg = belegEntwurf && belegEntwurf.praefix === "neuTank" ? belegEntwurf.daten : null; if (w.liter > 0 && w.kosten > 0) { - serviceRufen("audi_dashboard_tankvorgang_manuell", { + serviceRufen("tankvorgang_manuell", { ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, distanz: w.distanz, kraftstoff: w.kraftstoff, receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, @@ -4305,7 +4317,7 @@ function ereignisseVerdrahten() { if (tankAkt) { const w = tankFormularWerte("bearbTank"); const beleg = belegEntwurf && belegEntwurf.praefix === "bearbTank" ? belegEntwurf.daten : null; - serviceRufen("audi_dashboard_tankvorgang_aktualisieren", { + serviceRufen("tankvorgang_aktualisieren", { tank_id: tankAkt.dataset.tankaktualisieren, ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, distanz: w.distanz, kraftstoff: w.kraftstoff, @@ -4369,7 +4381,7 @@ function ereignisseVerdrahten() { return; } const bk = e.target.closest("[data-backup]"); - if (bk && bk.dataset.backup === "jetzt") { serviceRufen("audi_dashboard_backup_jetzt", {}); return; } + if (bk && bk.dataset.backup === "jetzt") { serviceRufen("backup_jetzt", {}); return; } if (bk && bk.dataset.backup === "export") { // Die Sensor-Zuordnung gehört mit ins Backup: sie liegt sonst nur in // entitaeten.json und wäre nach einer Wiederherstellung verloren. @@ -4380,37 +4392,6 @@ function ereignisseVerdrahten() { a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove(); return; } - if (e.target.closest("[data-update-pruefen]")) { - if (!updatePruefend) { - updatePruefend = true; render(); - (async () => { - try { await HASS.callService("pyscript", "audi_dashboard_update_pruefen", {}); } - catch (err) { hinweis("Update-Prüfung fehlgeschlagen", err.message); } - await updateStatusAbrufen(); - updatePruefend = false; render(); - })(); - } - return; - } - if (e.target.closest("[data-update-installieren]")) { - if (!updateInstallierend) bestaetigen( - "Update installieren?", - "Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben unangetastet. Die Seite lädt sich danach neu.", - "Installieren", - () => { updateInstallierend = true; render(); - (async () => { - try { - await HASS.callService("pyscript", "audi_dashboard_update_installieren", {}); - await updateStatusAbrufen(); - location.reload(); - return; - } catch (err) { hinweis("Update-Installation fehlgeschlagen", err.message); } - await updateStatusAbrufen(); - updateInstallierend = false; render(); - })(); - }, false); - return; - } const cs = e.target.closest("[data-csv]"); if (cs) { const w = cs.dataset.csv; @@ -4423,7 +4404,7 @@ function ereignisseVerdrahten() { return; } const satz = e.target.closest("[data-satz]"); - if (satz) { serviceRufen("audi_dashboard_reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } + if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } const k = e.target.closest("[data-ics]"); if (k) { if (k.dataset.ics === "service") { @@ -4452,7 +4433,7 @@ function ereignisseVerdrahten() { tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg"; // Bis 2026-08-17 blieb das Umschalten rein lokal und war nach dem // naechsten Backend-Push wieder weg - jetzt wandert es mit. - serviceRufen("audi_dashboard_fahrt_aktualisieren", { trip_id: tr.trip_id, art: tr.art }); + serviceRufen("fahrt_aktualisieren", { trip_id: tr.trip_id, art: tr.art }); render(); return; } // Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü- @@ -4511,7 +4492,7 @@ function ereignisseVerdrahten() { try { const p = JSON.parse(leser.result); PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); - serviceRufen("audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(p) }); + serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) }); bild = 0; render(); } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } }; @@ -4530,7 +4511,7 @@ function ereignisseVerdrahten() { CAR = profilZuCar(paket.profil, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); TRIPS = paket.fahrten || []; FILLS = paket.tankvorgaenge || []; - serviceRufen("audi_dashboard_backup_wiederherstellen", { + serviceRufen("backup_wiederherstellen", { profil_json: JSON.stringify(paket.profil), fahrten_json: JSON.stringify(TRIPS), tankvorgaenge_json: JSON.stringify(FILLS), @@ -4551,7 +4532,7 @@ function ereignisseVerdrahten() { leser.onload = async () => { const daten_base64 = String(leser.result).split(",")[1] || ""; try { - await HASS.callService("pyscript", "audi_dashboard_bild_hochladen", { dateiname, daten_base64 }); + await HASS.callService(DOMAIN, "bild_hochladen", { dateiname, daten_base64 }); } catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); } bildMenuOffen = null; bildVersion = Date.now(); render(); }; @@ -4688,7 +4669,7 @@ function belegErgebnisVerarbeiten(daten) { Ein früherer Stand brach nach 15 Versuchen (rund 30s) endgültig ab - real zu kurz beobachtet: startet der Container komplett neu (HA + HACS + die VAG-Integration mit eigenen Netzwerkaufrufen), kann es deutlich länger - dauern, bis pyscript überhaupt geladen ist und sein @time_trigger("startup") + dauern, bis die Integration überhaupt eingerichtet ist und ihre erste Veröffentlichung feuert. Sichtbares Symptom war genau das gemeldete Verhalten: die App blieb dauerhaft auf "Lädt …" hängen (und die Tableiste zeigte dadurch auch kein aktives Symbol, weil render() den harten Rücksprung bei @@ -4718,7 +4699,7 @@ function nachladeAnstossen() { if (nachladeTimer) return; nachladeVersuche++; // Erster Versuch fast sofort statt erst nach vollem Intervall - der - // Wettlauf mit pyscripts beim_start() (siehe Kopfkommentar) ist meist + // Wettlauf mit dem Start der Integration (siehe Kopfkommentar) ist meist // schon nach wenigen hundert Millisekunden entschieden, nicht erst nach // den vollen zwei Sekunden. Nach den ersten Versuchen (Backend vermutlich // noch nicht bereit) auf ein ruhigeres Intervall wechseln, statt für immer @@ -4737,14 +4718,14 @@ function nachladeAnstossen() { try { const states = await HASS.callWS({ type: "get_states" }); const finden = (id) => states.find((s) => s.entity_id === id); - NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil; - NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten; - NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank; - NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status; - NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt; + NACHGELADEN.profil = finden(E.profil) || NACHGELADEN.profil; + NACHGELADEN.fahrten = finden(E.fahrten) || NACHGELADEN.fahrten; + NACHGELADEN.tank = finden(E.tankvorgaenge) || NACHGELADEN.tank; + NACHGELADEN.status = finden(E.fahrzeugstatus) || NACHGELADEN.status; + NACHGELADEN.batt = finden(E.batterieverlauf) || NACHGELADEN.batt; // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer, // bis zufaellig ein Push kommt - siehe setupBereit(). - NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten; + NACHGELADEN.entitaeten = finden(E.entitaeten) || NACHGELADEN.entitaeten; } catch (e) { // wird beim naechsten Versuch erneut probiert } @@ -4762,16 +4743,18 @@ async function datenLaden(erzwingen) { if (!DATEN_GELADEN) nachladeAnstossen(); return; } - const profilState = HASS.states["pyscript.audi_dashboard_profil"] || NACHGELADEN.profil; - const fahrtenState = HASS.states["pyscript.audi_dashboard_fahrten"] || NACHGELADEN.fahrten; - const tankState = HASS.states["pyscript.audi_dashboard_tankvorgaenge"] || NACHGELADEN.tank; - const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status; - const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt; - const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"]; + const profilState = HASS.states[E.profil] || NACHGELADEN.profil; + const fahrtenState = HASS.states[E.fahrten] || NACHGELADEN.fahrten; + const tankState = HASS.states[E.tankvorgaenge] || NACHGELADEN.tank; + const statusState = HASS.states[E.fahrzeugstatus] || NACHGELADEN.status; + const battState = HASS.states[E.batterieverlauf] || NACHGELADEN.batt; + const belegState = HASS.states[E.belegErgebnis]; // Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/ // fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern, // nicht Teil des normalen Update-Rhythmus. - const entitaetenState = HASS.states["pyscript.audi_dashboard_entitaeten"] || NACHGELADEN.entitaeten; + const entitaetenState = HASS.states[E.entitaeten] || NACHGELADEN.entitaeten; + const versionState = HASS.states[E.appVersion]; + if (versionState && versionState.state) APP_VERSION = versionState.state; if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten; if (belegState && belegState !== letzterBelegState) { @@ -4799,7 +4782,7 @@ async function datenLaden(erzwingen) { BATTERIEVERLAUF = (battState && battState.attributes && battState.attributes.daten) || []; DATEN_GELADEN = true; nachladeVersuche = 0; - if (!UPDATE_STATUS_GEHOLT) { UPDATE_STATUS_GEHOLT = true; updateStatusAbrufen().then(render); } + render(); return; } @@ -4892,14 +4875,14 @@ class AudiDashboardPanel extends HTMLElement { const link = document.createElement("link"); link.rel = "stylesheet"; - link.href = `/local/audi-dashboard.css?v=${CSS_VERSION}`; + link.href = `${STATIK}/audi-dashboard.css?v=${CSS_VERSION}`; ROOT.appendChild(link); /* iOS-native Optik + responsive Grossbildschirm-Layout (@container ab 860px), rein additive Auflage aus dem Claude-Design-Entwurf 2026-08-11. */ const linkIos = document.createElement("link"); linkIos.rel = "stylesheet"; - linkIos.href = `/local/audi-dashboard-ios.css?v=${CSS_VERSION}`; + linkIos.href = `${STATIK}/audi-dashboard-ios.css?v=${CSS_VERSION}`; ROOT.appendChild(linkIos); const wrapper = document.createElement("div"); diff --git a/homeassistant/installationspaket/www/audi-dashboard-ios.css b/custom_components/audi_dashboard/frontend/audi-dashboard-ios.css similarity index 100% rename from homeassistant/installationspaket/www/audi-dashboard-ios.css rename to custom_components/audi_dashboard/frontend/audi-dashboard-ios.css diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-panel.js b/custom_components/audi_dashboard/frontend/audi-dashboard-panel.js new file mode 100644 index 0000000..99a8460 --- /dev/null +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-panel.js @@ -0,0 +1,37 @@ +/* ================================================================ + Audi-Dashboard — Lade-Stub für das Panel + ================================================================ + Home Assistant lädt diese Datei als Modul des Sidebar-Eintrags. Ihre + einzige Aufgabe ist, den eigentlichen Code nachzuladen. + + Der Cache-Brecher steht in der eigenen URL: die Integration hängt beim + Anmelden des Panels die installierte Version als ?v=… an (siehe + _panel_anmelden in __init__.py). Diese Datei reicht sie unverändert an + audi-dashboard-app.js weiter. Jede neue Version ist damit aus Sicht des + Browsers eine neue URL und wird nie aus einem alten Cache bedient - und + umgekehrt lädt eine unveränderte Installation nichts unnötig neu. + + Das ersetzt die frühere Lösung, bei der diese Datei erst + audi-dashboard-version.json mit cache:"no-store" holen musste, um an eine + Zahl zu kommen. Die brauchte es, weil /local/ von Home Assistant 31 Tage + lang gecacht wird und die Datei sonst nie wieder aktualisiert worden wäre. + Als Integration liefert die App ihre Dateien selbst aus - mit + cache_headers=False und mit der Version schon in der URL. Eine zusätzliche + Netzwerkanfrage bei jedem Seitenaufruf ist dafür nicht mehr nötig. + + Bekannte HA-Eigenheit (an der Testinstanz reproduziert, nicht vermutet): + ha-panel-custom cached geladene Skripte nur für den alten js_url-Typ, + nicht für module_url. Bei einem direkten/harten Seitenaufruf kann das + dazu führen, dass dieses Skript zweimal als - - - -
-

Gerät verbinden

-

- Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein. - Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet. -

- - - - - - - - - -
- -
- - - - diff --git a/homeassistant/installationspaket/www/dm360-qrcode-lib.js b/homeassistant/installationspaket/www/dm360-qrcode-lib.js deleted file mode 100644 index df13f82..0000000 --- a/homeassistant/installationspaket/www/dm360-qrcode-lib.js +++ /dev/null @@ -1,2297 +0,0 @@ -//--------------------------------------------------------------------- -// -// QR Code Generator for JavaScript -// -// Copyright (c) 2009 Kazuhiko Arase -// -// URL: http://www.d-project.com/ -// -// Licensed under the MIT license: -// http://www.opensource.org/licenses/mit-license.php -// -// The word 'QR Code' is registered trademark of -// DENSO WAVE INCORPORATED -// http://www.denso-wave.com/qrcode/faqpatent-e.html -// -//--------------------------------------------------------------------- - -var qrcode = function() { - - //--------------------------------------------------------------------- - // qrcode - //--------------------------------------------------------------------- - - /** - * qrcode - * @param typeNumber 1 to 40 - * @param errorCorrectionLevel 'L','M','Q','H' - */ - var qrcode = function(typeNumber, errorCorrectionLevel) { - - var PAD0 = 0xEC; - var PAD1 = 0x11; - - var _typeNumber = typeNumber; - var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; - var _modules = null; - var _moduleCount = 0; - var _dataCache = null; - var _dataList = []; - - var _this = {}; - - var makeImpl = function(test, maskPattern) { - - _moduleCount = _typeNumber * 4 + 17; - _modules = function(moduleCount) { - var modules = new Array(moduleCount); - for (var row = 0; row < moduleCount; row += 1) { - modules[row] = new Array(moduleCount); - for (var col = 0; col < moduleCount; col += 1) { - modules[row][col] = null; - } - } - return modules; - }(_moduleCount); - - setupPositionProbePattern(0, 0); - setupPositionProbePattern(_moduleCount - 7, 0); - setupPositionProbePattern(0, _moduleCount - 7); - setupPositionAdjustPattern(); - setupTimingPattern(); - setupTypeInfo(test, maskPattern); - - if (_typeNumber >= 7) { - setupTypeNumber(test); - } - - if (_dataCache == null) { - _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); - } - - mapData(_dataCache, maskPattern); - }; - - var setupPositionProbePattern = function(row, col) { - - for (var r = -1; r <= 7; r += 1) { - - if (row + r <= -1 || _moduleCount <= row + r) continue; - - for (var c = -1; c <= 7; c += 1) { - - if (col + c <= -1 || _moduleCount <= col + c) continue; - - if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) - || (0 <= c && c <= 6 && (r == 0 || r == 6) ) - || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { - _modules[row + r][col + c] = true; - } else { - _modules[row + r][col + c] = false; - } - } - } - }; - - var getBestMaskPattern = function() { - - var minLostPoint = 0; - var pattern = 0; - - for (var i = 0; i < 8; i += 1) { - - makeImpl(true, i); - - var lostPoint = QRUtil.getLostPoint(_this); - - if (i == 0 || minLostPoint > lostPoint) { - minLostPoint = lostPoint; - pattern = i; - } - } - - return pattern; - }; - - var setupTimingPattern = function() { - - for (var r = 8; r < _moduleCount - 8; r += 1) { - if (_modules[r][6] != null) { - continue; - } - _modules[r][6] = (r % 2 == 0); - } - - for (var c = 8; c < _moduleCount - 8; c += 1) { - if (_modules[6][c] != null) { - continue; - } - _modules[6][c] = (c % 2 == 0); - } - }; - - var setupPositionAdjustPattern = function() { - - var pos = QRUtil.getPatternPosition(_typeNumber); - - for (var i = 0; i < pos.length; i += 1) { - - for (var j = 0; j < pos.length; j += 1) { - - var row = pos[i]; - var col = pos[j]; - - if (_modules[row][col] != null) { - continue; - } - - for (var r = -2; r <= 2; r += 1) { - - for (var c = -2; c <= 2; c += 1) { - - if (r == -2 || r == 2 || c == -2 || c == 2 - || (r == 0 && c == 0) ) { - _modules[row + r][col + c] = true; - } else { - _modules[row + r][col + c] = false; - } - } - } - } - } - }; - - var setupTypeNumber = function(test) { - - var bits = QRUtil.getBCHTypeNumber(_typeNumber); - - for (var i = 0; i < 18; i += 1) { - var mod = (!test && ( (bits >> i) & 1) == 1); - _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; - } - - for (var i = 0; i < 18; i += 1) { - var mod = (!test && ( (bits >> i) & 1) == 1); - _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; - } - }; - - var setupTypeInfo = function(test, maskPattern) { - - var data = (_errorCorrectionLevel << 3) | maskPattern; - var bits = QRUtil.getBCHTypeInfo(data); - - // vertical - for (var i = 0; i < 15; i += 1) { - - var mod = (!test && ( (bits >> i) & 1) == 1); - - if (i < 6) { - _modules[i][8] = mod; - } else if (i < 8) { - _modules[i + 1][8] = mod; - } else { - _modules[_moduleCount - 15 + i][8] = mod; - } - } - - // horizontal - for (var i = 0; i < 15; i += 1) { - - var mod = (!test && ( (bits >> i) & 1) == 1); - - if (i < 8) { - _modules[8][_moduleCount - i - 1] = mod; - } else if (i < 9) { - _modules[8][15 - i - 1 + 1] = mod; - } else { - _modules[8][15 - i - 1] = mod; - } - } - - // fixed module - _modules[_moduleCount - 8][8] = (!test); - }; - - var mapData = function(data, maskPattern) { - - var inc = -1; - var row = _moduleCount - 1; - var bitIndex = 7; - var byteIndex = 0; - var maskFunc = QRUtil.getMaskFunction(maskPattern); - - for (var col = _moduleCount - 1; col > 0; col -= 2) { - - if (col == 6) col -= 1; - - while (true) { - - for (var c = 0; c < 2; c += 1) { - - if (_modules[row][col - c] == null) { - - var dark = false; - - if (byteIndex < data.length) { - dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); - } - - var mask = maskFunc(row, col - c); - - if (mask) { - dark = !dark; - } - - _modules[row][col - c] = dark; - bitIndex -= 1; - - if (bitIndex == -1) { - byteIndex += 1; - bitIndex = 7; - } - } - } - - row += inc; - - if (row < 0 || _moduleCount <= row) { - row -= inc; - inc = -inc; - break; - } - } - } - }; - - var createBytes = function(buffer, rsBlocks) { - - var offset = 0; - - var maxDcCount = 0; - var maxEcCount = 0; - - var dcdata = new Array(rsBlocks.length); - var ecdata = new Array(rsBlocks.length); - - for (var r = 0; r < rsBlocks.length; r += 1) { - - var dcCount = rsBlocks[r].dataCount; - var ecCount = rsBlocks[r].totalCount - dcCount; - - maxDcCount = Math.max(maxDcCount, dcCount); - maxEcCount = Math.max(maxEcCount, ecCount); - - dcdata[r] = new Array(dcCount); - - for (var i = 0; i < dcdata[r].length; i += 1) { - dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; - } - offset += dcCount; - - var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); - var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); - - var modPoly = rawPoly.mod(rsPoly); - ecdata[r] = new Array(rsPoly.getLength() - 1); - for (var i = 0; i < ecdata[r].length; i += 1) { - var modIndex = i + modPoly.getLength() - ecdata[r].length; - ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; - } - } - - var totalCodeCount = 0; - for (var i = 0; i < rsBlocks.length; i += 1) { - totalCodeCount += rsBlocks[i].totalCount; - } - - var data = new Array(totalCodeCount); - var index = 0; - - for (var i = 0; i < maxDcCount; i += 1) { - for (var r = 0; r < rsBlocks.length; r += 1) { - if (i < dcdata[r].length) { - data[index] = dcdata[r][i]; - index += 1; - } - } - } - - for (var i = 0; i < maxEcCount; i += 1) { - for (var r = 0; r < rsBlocks.length; r += 1) { - if (i < ecdata[r].length) { - data[index] = ecdata[r][i]; - index += 1; - } - } - } - - return data; - }; - - var createData = function(typeNumber, errorCorrectionLevel, dataList) { - - var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); - - var buffer = qrBitBuffer(); - - for (var i = 0; i < dataList.length; i += 1) { - var data = dataList[i]; - buffer.put(data.getMode(), 4); - buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); - data.write(buffer); - } - - // calc num max data. - var totalDataCount = 0; - for (var i = 0; i < rsBlocks.length; i += 1) { - totalDataCount += rsBlocks[i].dataCount; - } - - if (buffer.getLengthInBits() > totalDataCount * 8) { - throw 'code length overflow. (' - + buffer.getLengthInBits() - + '>' - + totalDataCount * 8 - + ')'; - } - - // end code - if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { - buffer.put(0, 4); - } - - // padding - while (buffer.getLengthInBits() % 8 != 0) { - buffer.putBit(false); - } - - // padding - while (true) { - - if (buffer.getLengthInBits() >= totalDataCount * 8) { - break; - } - buffer.put(PAD0, 8); - - if (buffer.getLengthInBits() >= totalDataCount * 8) { - break; - } - buffer.put(PAD1, 8); - } - - return createBytes(buffer, rsBlocks); - }; - - _this.addData = function(data, mode) { - - mode = mode || 'Byte'; - - var newData = null; - - switch(mode) { - case 'Numeric' : - newData = qrNumber(data); - break; - case 'Alphanumeric' : - newData = qrAlphaNum(data); - break; - case 'Byte' : - newData = qr8BitByte(data); - break; - case 'Kanji' : - newData = qrKanji(data); - break; - default : - throw 'mode:' + mode; - } - - _dataList.push(newData); - _dataCache = null; - }; - - _this.isDark = function(row, col) { - if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { - throw row + ',' + col; - } - return _modules[row][col]; - }; - - _this.getModuleCount = function() { - return _moduleCount; - }; - - _this.make = function() { - if (_typeNumber < 1) { - var typeNumber = 1; - - for (; typeNumber < 40; typeNumber++) { - var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); - var buffer = qrBitBuffer(); - - for (var i = 0; i < _dataList.length; i++) { - var data = _dataList[i]; - buffer.put(data.getMode(), 4); - buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); - data.write(buffer); - } - - var totalDataCount = 0; - for (var i = 0; i < rsBlocks.length; i++) { - totalDataCount += rsBlocks[i].dataCount; - } - - if (buffer.getLengthInBits() <= totalDataCount * 8) { - break; - } - } - - _typeNumber = typeNumber; - } - - makeImpl(false, getBestMaskPattern() ); - }; - - _this.createTableTag = function(cellSize, margin) { - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - var qrHtml = ''; - - qrHtml += ''; - qrHtml += ''; - - for (var r = 0; r < _this.getModuleCount(); r += 1) { - - qrHtml += ''; - - for (var c = 0; c < _this.getModuleCount(); c += 1) { - qrHtml += ''; - } - - qrHtml += ''; - qrHtml += '
'; - } - - qrHtml += '
'; - - return qrHtml; - }; - - _this.createSvgTag = function(cellSize, margin, alt, title) { - - var opts = {}; - if (typeof arguments[0] == 'object') { - // Called by options. - opts = arguments[0]; - // overwrite cellSize and margin. - cellSize = opts.cellSize; - margin = opts.margin; - alt = opts.alt; - title = opts.title; - } - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - // Compose alt property surrogate - alt = (typeof alt === 'string') ? {text: alt} : alt || {}; - alt.text = alt.text || null; - alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; - - // Compose title property surrogate - title = (typeof title === 'string') ? {text: title} : title || {}; - title.text = title.text || null; - title.id = (title.text) ? title.id || 'qrcode-title' : null; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var c, mc, r, mr, qrSvg='', rect; - - rect = 'l' + cellSize + ',0 0,' + cellSize + - ' -' + cellSize + ',0 0,-' + cellSize + 'z '; - - qrSvg += '' + - escapeXml(title.text) + '' : ''; - qrSvg += (alt.text) ? '' + - escapeXml(alt.text) + '' : ''; - qrSvg += ''; - qrSvg += ''; - qrSvg += ''; - - return qrSvg; - }; - - _this.createDataURL = function(cellSize, margin) { - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var min = margin; - var max = size - margin; - - return createDataURL(size, size, function(x, y) { - if (min <= x && x < max && min <= y && y < max) { - var c = Math.floor( (x - min) / cellSize); - var r = Math.floor( (y - min) / cellSize); - return _this.isDark(r, c)? 0 : 1; - } else { - return 1; - } - } ); - }; - - _this.createImgTag = function(cellSize, margin, alt) { - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - - var img = ''; - img += '': escaped += '>'; break; - case '&': escaped += '&'; break; - case '"': escaped += '"'; break; - default : escaped += c; break; - } - } - return escaped; - }; - - var _createHalfASCII = function(margin) { - var cellSize = 1; - margin = (typeof margin == 'undefined')? cellSize * 2 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var min = margin; - var max = size - margin; - - var y, x, r1, r2, p; - - var blocks = { - '██': '█', - '█ ': '▀', - ' █': '▄', - ' ': ' ' - }; - - var blocksLastLineNoMargin = { - '██': '▀', - '█ ': '▀', - ' █': ' ', - ' ': ' ' - }; - - var ascii = ''; - for (y = 0; y < size; y += 2) { - r1 = Math.floor((y - min) / cellSize); - r2 = Math.floor((y + 1 - min) / cellSize); - for (x = 0; x < size; x += 1) { - p = '█'; - - if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { - p = ' '; - } - - if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { - p += ' '; - } - else { - p += '█'; - } - - // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. - ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; - } - - ascii += '\n'; - } - - if (size % 2 && margin > 0) { - return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); - } - - return ascii.substring(0, ascii.length-1); - }; - - _this.createASCII = function(cellSize, margin) { - cellSize = cellSize || 1; - - if (cellSize < 2) { - return _createHalfASCII(margin); - } - - cellSize -= 1; - margin = (typeof margin == 'undefined')? cellSize * 2 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var min = margin; - var max = size - margin; - - var y, x, r, p; - - var white = Array(cellSize+1).join('██'); - var black = Array(cellSize+1).join(' '); - - var ascii = ''; - var line = ''; - for (y = 0; y < size; y += 1) { - r = Math.floor( (y - min) / cellSize); - line = ''; - for (x = 0; x < size; x += 1) { - p = 1; - - if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { - p = 0; - } - - // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. - line += p ? white : black; - } - - for (r = 0; r < cellSize; r += 1) { - ascii += line + '\n'; - } - } - - return ascii.substring(0, ascii.length-1); - }; - - _this.renderTo2dContext = function(context, cellSize) { - cellSize = cellSize || 2; - var length = _this.getModuleCount(); - for (var row = 0; row < length; row++) { - for (var col = 0; col < length; col++) { - context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; - context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); - } - } - } - - return _this; - }; - - //--------------------------------------------------------------------- - // qrcode.stringToBytes - //--------------------------------------------------------------------- - - qrcode.stringToBytesFuncs = { - 'default' : function(s) { - var bytes = []; - for (var i = 0; i < s.length; i += 1) { - var c = s.charCodeAt(i); - bytes.push(c & 0xff); - } - return bytes; - } - }; - - qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; - - //--------------------------------------------------------------------- - // qrcode.createStringToBytes - //--------------------------------------------------------------------- - - /** - * @param unicodeData base64 string of byte array. - * [16bit Unicode],[16bit Bytes], ... - * @param numChars - */ - qrcode.createStringToBytes = function(unicodeData, numChars) { - - // create conversion map. - - var unicodeMap = function() { - - var bin = base64DecodeInputStream(unicodeData); - var read = function() { - var b = bin.read(); - if (b == -1) throw 'eof'; - return b; - }; - - var count = 0; - var unicodeMap = {}; - while (true) { - var b0 = bin.read(); - if (b0 == -1) break; - var b1 = read(); - var b2 = read(); - var b3 = read(); - var k = String.fromCharCode( (b0 << 8) | b1); - var v = (b2 << 8) | b3; - unicodeMap[k] = v; - count += 1; - } - if (count != numChars) { - throw count + ' != ' + numChars; - } - - return unicodeMap; - }(); - - var unknownChar = '?'.charCodeAt(0); - - return function(s) { - var bytes = []; - for (var i = 0; i < s.length; i += 1) { - var c = s.charCodeAt(i); - if (c < 128) { - bytes.push(c); - } else { - var b = unicodeMap[s.charAt(i)]; - if (typeof b == 'number') { - if ( (b & 0xff) == b) { - // 1byte - bytes.push(b); - } else { - // 2bytes - bytes.push(b >>> 8); - bytes.push(b & 0xff); - } - } else { - bytes.push(unknownChar); - } - } - } - return bytes; - }; - }; - - //--------------------------------------------------------------------- - // QRMode - //--------------------------------------------------------------------- - - var QRMode = { - MODE_NUMBER : 1 << 0, - MODE_ALPHA_NUM : 1 << 1, - MODE_8BIT_BYTE : 1 << 2, - MODE_KANJI : 1 << 3 - }; - - //--------------------------------------------------------------------- - // QRErrorCorrectionLevel - //--------------------------------------------------------------------- - - var QRErrorCorrectionLevel = { - L : 1, - M : 0, - Q : 3, - H : 2 - }; - - //--------------------------------------------------------------------- - // QRMaskPattern - //--------------------------------------------------------------------- - - var QRMaskPattern = { - PATTERN000 : 0, - PATTERN001 : 1, - PATTERN010 : 2, - PATTERN011 : 3, - PATTERN100 : 4, - PATTERN101 : 5, - PATTERN110 : 6, - PATTERN111 : 7 - }; - - //--------------------------------------------------------------------- - // QRUtil - //--------------------------------------------------------------------- - - var QRUtil = function() { - - var PATTERN_POSITION_TABLE = [ - [], - [6, 18], - [6, 22], - [6, 26], - [6, 30], - [6, 34], - [6, 22, 38], - [6, 24, 42], - [6, 26, 46], - [6, 28, 50], - [6, 30, 54], - [6, 32, 58], - [6, 34, 62], - [6, 26, 46, 66], - [6, 26, 48, 70], - [6, 26, 50, 74], - [6, 30, 54, 78], - [6, 30, 56, 82], - [6, 30, 58, 86], - [6, 34, 62, 90], - [6, 28, 50, 72, 94], - [6, 26, 50, 74, 98], - [6, 30, 54, 78, 102], - [6, 28, 54, 80, 106], - [6, 32, 58, 84, 110], - [6, 30, 58, 86, 114], - [6, 34, 62, 90, 118], - [6, 26, 50, 74, 98, 122], - [6, 30, 54, 78, 102, 126], - [6, 26, 52, 78, 104, 130], - [6, 30, 56, 82, 108, 134], - [6, 34, 60, 86, 112, 138], - [6, 30, 58, 86, 114, 142], - [6, 34, 62, 90, 118, 146], - [6, 30, 54, 78, 102, 126, 150], - [6, 24, 50, 76, 102, 128, 154], - [6, 28, 54, 80, 106, 132, 158], - [6, 32, 58, 84, 110, 136, 162], - [6, 26, 54, 82, 110, 138, 166], - [6, 30, 58, 86, 114, 142, 170] - ]; - var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); - var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); - var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); - - var _this = {}; - - var getBCHDigit = function(data) { - var digit = 0; - while (data != 0) { - digit += 1; - data >>>= 1; - } - return digit; - }; - - _this.getBCHTypeInfo = function(data) { - var d = data << 10; - while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { - d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); - } - return ( (data << 10) | d) ^ G15_MASK; - }; - - _this.getBCHTypeNumber = function(data) { - var d = data << 12; - while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { - d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); - } - return (data << 12) | d; - }; - - _this.getPatternPosition = function(typeNumber) { - return PATTERN_POSITION_TABLE[typeNumber - 1]; - }; - - _this.getMaskFunction = function(maskPattern) { - - switch (maskPattern) { - - case QRMaskPattern.PATTERN000 : - return function(i, j) { return (i + j) % 2 == 0; }; - case QRMaskPattern.PATTERN001 : - return function(i, j) { return i % 2 == 0; }; - case QRMaskPattern.PATTERN010 : - return function(i, j) { return j % 3 == 0; }; - case QRMaskPattern.PATTERN011 : - return function(i, j) { return (i + j) % 3 == 0; }; - case QRMaskPattern.PATTERN100 : - return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; - case QRMaskPattern.PATTERN101 : - return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; - case QRMaskPattern.PATTERN110 : - return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; - case QRMaskPattern.PATTERN111 : - return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; - - default : - throw 'bad maskPattern:' + maskPattern; - } - }; - - _this.getErrorCorrectPolynomial = function(errorCorrectLength) { - var a = qrPolynomial([1], 0); - for (var i = 0; i < errorCorrectLength; i += 1) { - a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); - } - return a; - }; - - _this.getLengthInBits = function(mode, type) { - - if (1 <= type && type < 10) { - - // 1 - 9 - - switch(mode) { - case QRMode.MODE_NUMBER : return 10; - case QRMode.MODE_ALPHA_NUM : return 9; - case QRMode.MODE_8BIT_BYTE : return 8; - case QRMode.MODE_KANJI : return 8; - default : - throw 'mode:' + mode; - } - - } else if (type < 27) { - - // 10 - 26 - - switch(mode) { - case QRMode.MODE_NUMBER : return 12; - case QRMode.MODE_ALPHA_NUM : return 11; - case QRMode.MODE_8BIT_BYTE : return 16; - case QRMode.MODE_KANJI : return 10; - default : - throw 'mode:' + mode; - } - - } else if (type < 41) { - - // 27 - 40 - - switch(mode) { - case QRMode.MODE_NUMBER : return 14; - case QRMode.MODE_ALPHA_NUM : return 13; - case QRMode.MODE_8BIT_BYTE : return 16; - case QRMode.MODE_KANJI : return 12; - default : - throw 'mode:' + mode; - } - - } else { - throw 'type:' + type; - } - }; - - _this.getLostPoint = function(qrcode) { - - var moduleCount = qrcode.getModuleCount(); - - var lostPoint = 0; - - // LEVEL1 - - for (var row = 0; row < moduleCount; row += 1) { - for (var col = 0; col < moduleCount; col += 1) { - - var sameCount = 0; - var dark = qrcode.isDark(row, col); - - for (var r = -1; r <= 1; r += 1) { - - if (row + r < 0 || moduleCount <= row + r) { - continue; - } - - for (var c = -1; c <= 1; c += 1) { - - if (col + c < 0 || moduleCount <= col + c) { - continue; - } - - if (r == 0 && c == 0) { - continue; - } - - if (dark == qrcode.isDark(row + r, col + c) ) { - sameCount += 1; - } - } - } - - if (sameCount > 5) { - lostPoint += (3 + sameCount - 5); - } - } - }; - - // LEVEL2 - - for (var row = 0; row < moduleCount - 1; row += 1) { - for (var col = 0; col < moduleCount - 1; col += 1) { - var count = 0; - if (qrcode.isDark(row, col) ) count += 1; - if (qrcode.isDark(row + 1, col) ) count += 1; - if (qrcode.isDark(row, col + 1) ) count += 1; - if (qrcode.isDark(row + 1, col + 1) ) count += 1; - if (count == 0 || count == 4) { - lostPoint += 3; - } - } - } - - // LEVEL3 - - for (var row = 0; row < moduleCount; row += 1) { - for (var col = 0; col < moduleCount - 6; col += 1) { - if (qrcode.isDark(row, col) - && !qrcode.isDark(row, col + 1) - && qrcode.isDark(row, col + 2) - && qrcode.isDark(row, col + 3) - && qrcode.isDark(row, col + 4) - && !qrcode.isDark(row, col + 5) - && qrcode.isDark(row, col + 6) ) { - lostPoint += 40; - } - } - } - - for (var col = 0; col < moduleCount; col += 1) { - for (var row = 0; row < moduleCount - 6; row += 1) { - if (qrcode.isDark(row, col) - && !qrcode.isDark(row + 1, col) - && qrcode.isDark(row + 2, col) - && qrcode.isDark(row + 3, col) - && qrcode.isDark(row + 4, col) - && !qrcode.isDark(row + 5, col) - && qrcode.isDark(row + 6, col) ) { - lostPoint += 40; - } - } - } - - // LEVEL4 - - var darkCount = 0; - - for (var col = 0; col < moduleCount; col += 1) { - for (var row = 0; row < moduleCount; row += 1) { - if (qrcode.isDark(row, col) ) { - darkCount += 1; - } - } - } - - var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; - lostPoint += ratio * 10; - - return lostPoint; - }; - - return _this; - }(); - - //--------------------------------------------------------------------- - // QRMath - //--------------------------------------------------------------------- - - var QRMath = function() { - - var EXP_TABLE = new Array(256); - var LOG_TABLE = new Array(256); - - // initialize tables - for (var i = 0; i < 8; i += 1) { - EXP_TABLE[i] = 1 << i; - } - for (var i = 8; i < 256; i += 1) { - EXP_TABLE[i] = EXP_TABLE[i - 4] - ^ EXP_TABLE[i - 5] - ^ EXP_TABLE[i - 6] - ^ EXP_TABLE[i - 8]; - } - for (var i = 0; i < 255; i += 1) { - LOG_TABLE[EXP_TABLE[i] ] = i; - } - - var _this = {}; - - _this.glog = function(n) { - - if (n < 1) { - throw 'glog(' + n + ')'; - } - - return LOG_TABLE[n]; - }; - - _this.gexp = function(n) { - - while (n < 0) { - n += 255; - } - - while (n >= 256) { - n -= 255; - } - - return EXP_TABLE[n]; - }; - - return _this; - }(); - - //--------------------------------------------------------------------- - // qrPolynomial - //--------------------------------------------------------------------- - - function qrPolynomial(num, shift) { - - if (typeof num.length == 'undefined') { - throw num.length + '/' + shift; - } - - var _num = function() { - var offset = 0; - while (offset < num.length && num[offset] == 0) { - offset += 1; - } - var _num = new Array(num.length - offset + shift); - for (var i = 0; i < num.length - offset; i += 1) { - _num[i] = num[i + offset]; - } - return _num; - }(); - - var _this = {}; - - _this.getAt = function(index) { - return _num[index]; - }; - - _this.getLength = function() { - return _num.length; - }; - - _this.multiply = function(e) { - - var num = new Array(_this.getLength() + e.getLength() - 1); - - for (var i = 0; i < _this.getLength(); i += 1) { - for (var j = 0; j < e.getLength(); j += 1) { - num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); - } - } - - return qrPolynomial(num, 0); - }; - - _this.mod = function(e) { - - if (_this.getLength() - e.getLength() < 0) { - return _this; - } - - var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); - - var num = new Array(_this.getLength() ); - for (var i = 0; i < _this.getLength(); i += 1) { - num[i] = _this.getAt(i); - } - - for (var i = 0; i < e.getLength(); i += 1) { - num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); - } - - // recursive call - return qrPolynomial(num, 0).mod(e); - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // QRRSBlock - //--------------------------------------------------------------------- - - var QRRSBlock = function() { - - var RS_BLOCK_TABLE = [ - - // L - // M - // Q - // H - - // 1 - [1, 26, 19], - [1, 26, 16], - [1, 26, 13], - [1, 26, 9], - - // 2 - [1, 44, 34], - [1, 44, 28], - [1, 44, 22], - [1, 44, 16], - - // 3 - [1, 70, 55], - [1, 70, 44], - [2, 35, 17], - [2, 35, 13], - - // 4 - [1, 100, 80], - [2, 50, 32], - [2, 50, 24], - [4, 25, 9], - - // 5 - [1, 134, 108], - [2, 67, 43], - [2, 33, 15, 2, 34, 16], - [2, 33, 11, 2, 34, 12], - - // 6 - [2, 86, 68], - [4, 43, 27], - [4, 43, 19], - [4, 43, 15], - - // 7 - [2, 98, 78], - [4, 49, 31], - [2, 32, 14, 4, 33, 15], - [4, 39, 13, 1, 40, 14], - - // 8 - [2, 121, 97], - [2, 60, 38, 2, 61, 39], - [4, 40, 18, 2, 41, 19], - [4, 40, 14, 2, 41, 15], - - // 9 - [2, 146, 116], - [3, 58, 36, 2, 59, 37], - [4, 36, 16, 4, 37, 17], - [4, 36, 12, 4, 37, 13], - - // 10 - [2, 86, 68, 2, 87, 69], - [4, 69, 43, 1, 70, 44], - [6, 43, 19, 2, 44, 20], - [6, 43, 15, 2, 44, 16], - - // 11 - [4, 101, 81], - [1, 80, 50, 4, 81, 51], - [4, 50, 22, 4, 51, 23], - [3, 36, 12, 8, 37, 13], - - // 12 - [2, 116, 92, 2, 117, 93], - [6, 58, 36, 2, 59, 37], - [4, 46, 20, 6, 47, 21], - [7, 42, 14, 4, 43, 15], - - // 13 - [4, 133, 107], - [8, 59, 37, 1, 60, 38], - [8, 44, 20, 4, 45, 21], - [12, 33, 11, 4, 34, 12], - - // 14 - [3, 145, 115, 1, 146, 116], - [4, 64, 40, 5, 65, 41], - [11, 36, 16, 5, 37, 17], - [11, 36, 12, 5, 37, 13], - - // 15 - [5, 109, 87, 1, 110, 88], - [5, 65, 41, 5, 66, 42], - [5, 54, 24, 7, 55, 25], - [11, 36, 12, 7, 37, 13], - - // 16 - [5, 122, 98, 1, 123, 99], - [7, 73, 45, 3, 74, 46], - [15, 43, 19, 2, 44, 20], - [3, 45, 15, 13, 46, 16], - - // 17 - [1, 135, 107, 5, 136, 108], - [10, 74, 46, 1, 75, 47], - [1, 50, 22, 15, 51, 23], - [2, 42, 14, 17, 43, 15], - - // 18 - [5, 150, 120, 1, 151, 121], - [9, 69, 43, 4, 70, 44], - [17, 50, 22, 1, 51, 23], - [2, 42, 14, 19, 43, 15], - - // 19 - [3, 141, 113, 4, 142, 114], - [3, 70, 44, 11, 71, 45], - [17, 47, 21, 4, 48, 22], - [9, 39, 13, 16, 40, 14], - - // 20 - [3, 135, 107, 5, 136, 108], - [3, 67, 41, 13, 68, 42], - [15, 54, 24, 5, 55, 25], - [15, 43, 15, 10, 44, 16], - - // 21 - [4, 144, 116, 4, 145, 117], - [17, 68, 42], - [17, 50, 22, 6, 51, 23], - [19, 46, 16, 6, 47, 17], - - // 22 - [2, 139, 111, 7, 140, 112], - [17, 74, 46], - [7, 54, 24, 16, 55, 25], - [34, 37, 13], - - // 23 - [4, 151, 121, 5, 152, 122], - [4, 75, 47, 14, 76, 48], - [11, 54, 24, 14, 55, 25], - [16, 45, 15, 14, 46, 16], - - // 24 - [6, 147, 117, 4, 148, 118], - [6, 73, 45, 14, 74, 46], - [11, 54, 24, 16, 55, 25], - [30, 46, 16, 2, 47, 17], - - // 25 - [8, 132, 106, 4, 133, 107], - [8, 75, 47, 13, 76, 48], - [7, 54, 24, 22, 55, 25], - [22, 45, 15, 13, 46, 16], - - // 26 - [10, 142, 114, 2, 143, 115], - [19, 74, 46, 4, 75, 47], - [28, 50, 22, 6, 51, 23], - [33, 46, 16, 4, 47, 17], - - // 27 - [8, 152, 122, 4, 153, 123], - [22, 73, 45, 3, 74, 46], - [8, 53, 23, 26, 54, 24], - [12, 45, 15, 28, 46, 16], - - // 28 - [3, 147, 117, 10, 148, 118], - [3, 73, 45, 23, 74, 46], - [4, 54, 24, 31, 55, 25], - [11, 45, 15, 31, 46, 16], - - // 29 - [7, 146, 116, 7, 147, 117], - [21, 73, 45, 7, 74, 46], - [1, 53, 23, 37, 54, 24], - [19, 45, 15, 26, 46, 16], - - // 30 - [5, 145, 115, 10, 146, 116], - [19, 75, 47, 10, 76, 48], - [15, 54, 24, 25, 55, 25], - [23, 45, 15, 25, 46, 16], - - // 31 - [13, 145, 115, 3, 146, 116], - [2, 74, 46, 29, 75, 47], - [42, 54, 24, 1, 55, 25], - [23, 45, 15, 28, 46, 16], - - // 32 - [17, 145, 115], - [10, 74, 46, 23, 75, 47], - [10, 54, 24, 35, 55, 25], - [19, 45, 15, 35, 46, 16], - - // 33 - [17, 145, 115, 1, 146, 116], - [14, 74, 46, 21, 75, 47], - [29, 54, 24, 19, 55, 25], - [11, 45, 15, 46, 46, 16], - - // 34 - [13, 145, 115, 6, 146, 116], - [14, 74, 46, 23, 75, 47], - [44, 54, 24, 7, 55, 25], - [59, 46, 16, 1, 47, 17], - - // 35 - [12, 151, 121, 7, 152, 122], - [12, 75, 47, 26, 76, 48], - [39, 54, 24, 14, 55, 25], - [22, 45, 15, 41, 46, 16], - - // 36 - [6, 151, 121, 14, 152, 122], - [6, 75, 47, 34, 76, 48], - [46, 54, 24, 10, 55, 25], - [2, 45, 15, 64, 46, 16], - - // 37 - [17, 152, 122, 4, 153, 123], - [29, 74, 46, 14, 75, 47], - [49, 54, 24, 10, 55, 25], - [24, 45, 15, 46, 46, 16], - - // 38 - [4, 152, 122, 18, 153, 123], - [13, 74, 46, 32, 75, 47], - [48, 54, 24, 14, 55, 25], - [42, 45, 15, 32, 46, 16], - - // 39 - [20, 147, 117, 4, 148, 118], - [40, 75, 47, 7, 76, 48], - [43, 54, 24, 22, 55, 25], - [10, 45, 15, 67, 46, 16], - - // 40 - [19, 148, 118, 6, 149, 119], - [18, 75, 47, 31, 76, 48], - [34, 54, 24, 34, 55, 25], - [20, 45, 15, 61, 46, 16] - ]; - - var qrRSBlock = function(totalCount, dataCount) { - var _this = {}; - _this.totalCount = totalCount; - _this.dataCount = dataCount; - return _this; - }; - - var _this = {}; - - var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { - - switch(errorCorrectionLevel) { - case QRErrorCorrectionLevel.L : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; - case QRErrorCorrectionLevel.M : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; - case QRErrorCorrectionLevel.Q : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; - case QRErrorCorrectionLevel.H : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; - default : - return undefined; - } - }; - - _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { - - var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); - - if (typeof rsBlock == 'undefined') { - throw 'bad rs block @ typeNumber:' + typeNumber + - '/errorCorrectionLevel:' + errorCorrectionLevel; - } - - var length = rsBlock.length / 3; - - var list = []; - - for (var i = 0; i < length; i += 1) { - - var count = rsBlock[i * 3 + 0]; - var totalCount = rsBlock[i * 3 + 1]; - var dataCount = rsBlock[i * 3 + 2]; - - for (var j = 0; j < count; j += 1) { - list.push(qrRSBlock(totalCount, dataCount) ); - } - } - - return list; - }; - - return _this; - }(); - - //--------------------------------------------------------------------- - // qrBitBuffer - //--------------------------------------------------------------------- - - var qrBitBuffer = function() { - - var _buffer = []; - var _length = 0; - - var _this = {}; - - _this.getBuffer = function() { - return _buffer; - }; - - _this.getAt = function(index) { - var bufIndex = Math.floor(index / 8); - return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; - }; - - _this.put = function(num, length) { - for (var i = 0; i < length; i += 1) { - _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); - } - }; - - _this.getLengthInBits = function() { - return _length; - }; - - _this.putBit = function(bit) { - - var bufIndex = Math.floor(_length / 8); - if (_buffer.length <= bufIndex) { - _buffer.push(0); - } - - if (bit) { - _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); - } - - _length += 1; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qrNumber - //--------------------------------------------------------------------- - - var qrNumber = function(data) { - - var _mode = QRMode.MODE_NUMBER; - var _data = data; - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return _data.length; - }; - - _this.write = function(buffer) { - - var data = _data; - - var i = 0; - - while (i + 2 < data.length) { - buffer.put(strToNum(data.substring(i, i + 3) ), 10); - i += 3; - } - - if (i < data.length) { - if (data.length - i == 1) { - buffer.put(strToNum(data.substring(i, i + 1) ), 4); - } else if (data.length - i == 2) { - buffer.put(strToNum(data.substring(i, i + 2) ), 7); - } - } - }; - - var strToNum = function(s) { - var num = 0; - for (var i = 0; i < s.length; i += 1) { - num = num * 10 + chatToNum(s.charAt(i) ); - } - return num; - }; - - var chatToNum = function(c) { - if ('0' <= c && c <= '9') { - return c.charCodeAt(0) - '0'.charCodeAt(0); - } - throw 'illegal char :' + c; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qrAlphaNum - //--------------------------------------------------------------------- - - var qrAlphaNum = function(data) { - - var _mode = QRMode.MODE_ALPHA_NUM; - var _data = data; - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return _data.length; - }; - - _this.write = function(buffer) { - - var s = _data; - - var i = 0; - - while (i + 1 < s.length) { - buffer.put( - getCode(s.charAt(i) ) * 45 + - getCode(s.charAt(i + 1) ), 11); - i += 2; - } - - if (i < s.length) { - buffer.put(getCode(s.charAt(i) ), 6); - } - }; - - var getCode = function(c) { - - if ('0' <= c && c <= '9') { - return c.charCodeAt(0) - '0'.charCodeAt(0); - } else if ('A' <= c && c <= 'Z') { - return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; - } else { - switch (c) { - case ' ' : return 36; - case '$' : return 37; - case '%' : return 38; - case '*' : return 39; - case '+' : return 40; - case '-' : return 41; - case '.' : return 42; - case '/' : return 43; - case ':' : return 44; - default : - throw 'illegal char :' + c; - } - } - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qr8BitByte - //--------------------------------------------------------------------- - - var qr8BitByte = function(data) { - - var _mode = QRMode.MODE_8BIT_BYTE; - var _data = data; - var _bytes = qrcode.stringToBytes(data); - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return _bytes.length; - }; - - _this.write = function(buffer) { - for (var i = 0; i < _bytes.length; i += 1) { - buffer.put(_bytes[i], 8); - } - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qrKanji - //--------------------------------------------------------------------- - - var qrKanji = function(data) { - - var _mode = QRMode.MODE_KANJI; - var _data = data; - - var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; - if (!stringToBytes) { - throw 'sjis not supported.'; - } - !function(c, code) { - // self test for sjis support. - var test = stringToBytes(c); - if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { - throw 'sjis not supported.'; - } - }('\u53cb', 0x9746); - - var _bytes = stringToBytes(data); - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return ~~(_bytes.length / 2); - }; - - _this.write = function(buffer) { - - var data = _bytes; - - var i = 0; - - while (i + 1 < data.length) { - - var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); - - if (0x8140 <= c && c <= 0x9FFC) { - c -= 0x8140; - } else if (0xE040 <= c && c <= 0xEBBF) { - c -= 0xC140; - } else { - throw 'illegal char at ' + (i + 1) + '/' + c; - } - - c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); - - buffer.put(c, 13); - - i += 2; - } - - if (i < data.length) { - throw 'illegal char at ' + (i + 1); - } - }; - - return _this; - }; - - //===================================================================== - // GIF Support etc. - // - - //--------------------------------------------------------------------- - // byteArrayOutputStream - //--------------------------------------------------------------------- - - var byteArrayOutputStream = function() { - - var _bytes = []; - - var _this = {}; - - _this.writeByte = function(b) { - _bytes.push(b & 0xff); - }; - - _this.writeShort = function(i) { - _this.writeByte(i); - _this.writeByte(i >>> 8); - }; - - _this.writeBytes = function(b, off, len) { - off = off || 0; - len = len || b.length; - for (var i = 0; i < len; i += 1) { - _this.writeByte(b[i + off]); - } - }; - - _this.writeString = function(s) { - for (var i = 0; i < s.length; i += 1) { - _this.writeByte(s.charCodeAt(i) ); - } - }; - - _this.toByteArray = function() { - return _bytes; - }; - - _this.toString = function() { - var s = ''; - s += '['; - for (var i = 0; i < _bytes.length; i += 1) { - if (i > 0) { - s += ','; - } - s += _bytes[i]; - } - s += ']'; - return s; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // base64EncodeOutputStream - //--------------------------------------------------------------------- - - var base64EncodeOutputStream = function() { - - var _buffer = 0; - var _buflen = 0; - var _length = 0; - var _base64 = ''; - - var _this = {}; - - var writeEncoded = function(b) { - _base64 += String.fromCharCode(encode(b & 0x3f) ); - }; - - var encode = function(n) { - if (n < 0) { - // error. - } else if (n < 26) { - return 0x41 + n; - } else if (n < 52) { - return 0x61 + (n - 26); - } else if (n < 62) { - return 0x30 + (n - 52); - } else if (n == 62) { - return 0x2b; - } else if (n == 63) { - return 0x2f; - } - throw 'n:' + n; - }; - - _this.writeByte = function(n) { - - _buffer = (_buffer << 8) | (n & 0xff); - _buflen += 8; - _length += 1; - - while (_buflen >= 6) { - writeEncoded(_buffer >>> (_buflen - 6) ); - _buflen -= 6; - } - }; - - _this.flush = function() { - - if (_buflen > 0) { - writeEncoded(_buffer << (6 - _buflen) ); - _buffer = 0; - _buflen = 0; - } - - if (_length % 3 != 0) { - // padding - var padlen = 3 - _length % 3; - for (var i = 0; i < padlen; i += 1) { - _base64 += '='; - } - } - }; - - _this.toString = function() { - return _base64; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // base64DecodeInputStream - //--------------------------------------------------------------------- - - var base64DecodeInputStream = function(str) { - - var _str = str; - var _pos = 0; - var _buffer = 0; - var _buflen = 0; - - var _this = {}; - - _this.read = function() { - - while (_buflen < 8) { - - if (_pos >= _str.length) { - if (_buflen == 0) { - return -1; - } - throw 'unexpected end of file./' + _buflen; - } - - var c = _str.charAt(_pos); - _pos += 1; - - if (c == '=') { - _buflen = 0; - return -1; - } else if (c.match(/^\s$/) ) { - // ignore if whitespace. - continue; - } - - _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); - _buflen += 6; - } - - var n = (_buffer >>> (_buflen - 8) ) & 0xff; - _buflen -= 8; - return n; - }; - - var decode = function(c) { - if (0x41 <= c && c <= 0x5a) { - return c - 0x41; - } else if (0x61 <= c && c <= 0x7a) { - return c - 0x61 + 26; - } else if (0x30 <= c && c <= 0x39) { - return c - 0x30 + 52; - } else if (c == 0x2b) { - return 62; - } else if (c == 0x2f) { - return 63; - } else { - throw 'c:' + c; - } - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // gifImage (B/W) - //--------------------------------------------------------------------- - - var gifImage = function(width, height) { - - var _width = width; - var _height = height; - var _data = new Array(width * height); - - var _this = {}; - - _this.setPixel = function(x, y, pixel) { - _data[y * _width + x] = pixel; - }; - - _this.write = function(out) { - - //--------------------------------- - // GIF Signature - - out.writeString('GIF87a'); - - //--------------------------------- - // Screen Descriptor - - out.writeShort(_width); - out.writeShort(_height); - - out.writeByte(0x80); // 2bit - out.writeByte(0); - out.writeByte(0); - - //--------------------------------- - // Global Color Map - - // black - out.writeByte(0x00); - out.writeByte(0x00); - out.writeByte(0x00); - - // white - out.writeByte(0xff); - out.writeByte(0xff); - out.writeByte(0xff); - - //--------------------------------- - // Image Descriptor - - out.writeString(','); - out.writeShort(0); - out.writeShort(0); - out.writeShort(_width); - out.writeShort(_height); - out.writeByte(0); - - //--------------------------------- - // Local Color Map - - //--------------------------------- - // Raster Data - - var lzwMinCodeSize = 2; - var raster = getLZWRaster(lzwMinCodeSize); - - out.writeByte(lzwMinCodeSize); - - var offset = 0; - - while (raster.length - offset > 255) { - out.writeByte(255); - out.writeBytes(raster, offset, 255); - offset += 255; - } - - out.writeByte(raster.length - offset); - out.writeBytes(raster, offset, raster.length - offset); - out.writeByte(0x00); - - //--------------------------------- - // GIF Terminator - out.writeString(';'); - }; - - var bitOutputStream = function(out) { - - var _out = out; - var _bitLength = 0; - var _bitBuffer = 0; - - var _this = {}; - - _this.write = function(data, length) { - - if ( (data >>> length) != 0) { - throw 'length over'; - } - - while (_bitLength + length >= 8) { - _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); - length -= (8 - _bitLength); - data >>>= (8 - _bitLength); - _bitBuffer = 0; - _bitLength = 0; - } - - _bitBuffer = (data << _bitLength) | _bitBuffer; - _bitLength = _bitLength + length; - }; - - _this.flush = function() { - if (_bitLength > 0) { - _out.writeByte(_bitBuffer); - } - }; - - return _this; - }; - - var getLZWRaster = function(lzwMinCodeSize) { - - var clearCode = 1 << lzwMinCodeSize; - var endCode = (1 << lzwMinCodeSize) + 1; - var bitLength = lzwMinCodeSize + 1; - - // Setup LZWTable - var table = lzwTable(); - - for (var i = 0; i < clearCode; i += 1) { - table.add(String.fromCharCode(i) ); - } - table.add(String.fromCharCode(clearCode) ); - table.add(String.fromCharCode(endCode) ); - - var byteOut = byteArrayOutputStream(); - var bitOut = bitOutputStream(byteOut); - - // clear code - bitOut.write(clearCode, bitLength); - - var dataIndex = 0; - - var s = String.fromCharCode(_data[dataIndex]); - dataIndex += 1; - - while (dataIndex < _data.length) { - - var c = String.fromCharCode(_data[dataIndex]); - dataIndex += 1; - - if (table.contains(s + c) ) { - - s = s + c; - - } else { - - bitOut.write(table.indexOf(s), bitLength); - - if (table.size() < 0xfff) { - - if (table.size() == (1 << bitLength) ) { - bitLength += 1; - } - - table.add(s + c); - } - - s = c; - } - } - - bitOut.write(table.indexOf(s), bitLength); - - // end code - bitOut.write(endCode, bitLength); - - bitOut.flush(); - - return byteOut.toByteArray(); - }; - - var lzwTable = function() { - - var _map = {}; - var _size = 0; - - var _this = {}; - - _this.add = function(key) { - if (_this.contains(key) ) { - throw 'dup key:' + key; - } - _map[key] = _size; - _size += 1; - }; - - _this.size = function() { - return _size; - }; - - _this.indexOf = function(key) { - return _map[key]; - }; - - _this.contains = function(key) { - return typeof _map[key] != 'undefined'; - }; - - return _this; - }; - - return _this; - }; - - var createDataURL = function(width, height, getPixel) { - var gif = gifImage(width, height); - for (var y = 0; y < height; y += 1) { - for (var x = 0; x < width; x += 1) { - gif.setPixel(x, y, getPixel(x, y) ); - } - } - - var b = byteArrayOutputStream(); - gif.write(b); - - var base64 = base64EncodeOutputStream(); - var bytes = b.toByteArray(); - for (var i = 0; i < bytes.length; i += 1) { - base64.writeByte(bytes[i]); - } - base64.flush(); - - return 'data:image/gif;base64,' + base64; - }; - - //--------------------------------------------------------------------- - // returns qrcode function. - - return qrcode; -}(); - -// multibyte support -!function() { - - qrcode.stringToBytesFuncs['UTF-8'] = function(s) { - // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array - function toUTF8Array(str) { - var utf8 = []; - for (var i=0; i < str.length; i++) { - var charcode = str.charCodeAt(i); - if (charcode < 0x80) utf8.push(charcode); - else if (charcode < 0x800) { - utf8.push(0xc0 | (charcode >> 6), - 0x80 | (charcode & 0x3f)); - } - else if (charcode < 0xd800 || charcode >= 0xe000) { - utf8.push(0xe0 | (charcode >> 12), - 0x80 | ((charcode>>6) & 0x3f), - 0x80 | (charcode & 0x3f)); - } - // surrogate pair - else { - i++; - // UTF-16 encodes 0x10000-0x10FFFF by - // subtracting 0x10000 and splitting the - // 20 bits of 0x0-0xFFFFF into two halves - charcode = 0x10000 + (((charcode & 0x3ff)<<10) - | (str.charCodeAt(i) & 0x3ff)); - utf8.push(0xf0 | (charcode >>18), - 0x80 | ((charcode>>12) & 0x3f), - 0x80 | ((charcode>>6) & 0x3f), - 0x80 | (charcode & 0x3f)); - } - } - return utf8; - } - return toUTF8Array(s); - }; - -}(); - -(function (factory) { - if (typeof define === 'function' && define.amd) { - define([], factory); - } else if (typeof exports === 'object') { - module.exports = factory(); - } -}(function () { - return qrcode; -})); diff --git a/homeassistant/pyscript/backup.py b/homeassistant/pyscript/backup.py deleted file mode 100644 index afb421a..0000000 --- a/homeassistant/pyscript/backup.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Backup-Funktion für Fahrzeugprofil, Fahrten und Tankvorgänge. - -Manuell über "Backup jetzt erstellen" in den Einstellungen oder automatisch -je nach einstellungen.backup_intervall im Fahrzeugprofil (aus/täglich/ -wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner -täglicher Trigger um 04:00 genügt für alle drei Intervalle: wöchentlich läuft -nur montags, monatlich nur am Monatsersten, täglich jeden Tag - so reicht eine -einzige Trigger-Registrierung statt drei. - -Jedes Backup ist ein eigener Zeitstempel-Unterordner mit Kopien der drei -Bestände (siehe profil.py), keine ZIP-Datei - das würde den vorhandenen -task.executor(io.open, ...)-Weg (siehe dessen Kopfkommentar in profil.py) -unnötig verkomplizieren. shutil.copy() ist wie io.open eine echte externe -Funktion und läuft deshalb über denselben task.executor-Weg. - -"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein -über den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine -JSON-Datei, siehe audi-dashboard-app.js) - audi_dashboard_backup_wiederherstellen() -hier ist nur das Gegenstück zum Import, das die drei Bestände wieder -komplett überschreibt. -""" - -import datetime -import json -import os -import shutil - -import entitaeten -import frontend_veroeffentlichung -import profil - -BACKUP_ORDNER = f"{profil.BASIS}/backups" -# entitaeten.json gehört dazu: Ohne sie wäre nach einer Wiederherstellung die -# komplette Sensor-Zuordnung aus dem Setup-Menü verloren, und weil sie sonst -# nirgends abgelegt ist, gäbe es nichts zurückzuholen. -_DATEIEN = [ - "fahrzeugprofil.json", - "fahrten.jsonl", - "tankvorgaenge.jsonl", - "entitaeten.json", -] - - -def _backup_jetzt(): - ziel = f"{BACKUP_ORDNER}/{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}" - os.makedirs(ziel, exist_ok=True) - for name in _DATEIEN: - quelle = f"{profil.BASIS}/{name}" - if os.path.exists(quelle): - task.executor(shutil.copy, quelle, f"{ziel}/{name}") - return ziel - - -def _letztes_backup_vermerken(): - p = profil.profil_lesen() - if p is None: - return - p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat() - profil.profil_schreiben(p) - frontend_veroeffentlichung.profil_veroeffentlichen() - - -@service -def audi_dashboard_backup_jetzt(): - """Erstellt sofort ein Backup. Aufruf als pyscript.audi_dashboard_backup_jetzt.""" - ziel = _backup_jetzt() - _letztes_backup_vermerken() - log.info(f"audi_dashboard: Backup erstellt unter {ziel}") - - -@time_trigger("cron(0 4 * * *)") -def _automatisches_backup(): - p = profil.profil_lesen() - if p is None: - return - intervall = p.get("einstellungen", {}).get("backup_intervall", "aus") - heute = datetime.date.today() - faellig = ( - intervall == "taeglich" - or (intervall == "woechentlich" and heute.weekday() == 0) - or (intervall == "monatlich" and heute.day == 1) - ) - if not faellig: - return - ziel = _backup_jetzt() - _letztes_backup_vermerken() - log.info(f"audi_dashboard: automatisches Backup ({intervall}) erstellt unter {ziel}") - - -@service -def audi_dashboard_backup_wiederherstellen(profil_json=None, fahrten_json=None, tankvorgaenge_json=None, entitaeten_json=None): - """Stellt ein über "Backup exportieren" heruntergeladenes Backup wieder - her - überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung - komplett. Aufruf als pyscript.audi_dashboard_backup_wiederherstellen. - - entitaeten_json ist optional: ältere Sicherungen, die vor dem Setup-Menü - entstanden sind, enthalten den Abschnitt nicht - dann bleibt die aktuelle - Zuordnung unangetastet, statt sie zu leeren.""" - if profil_json: - p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json - profil.profil_schreiben(p) - if fahrten_json is not None: - fahrten = json.loads(fahrten_json) if isinstance(fahrten_json, str) else fahrten_json - profil.fahrten_schreiben(fahrten) - if tankvorgaenge_json is not None: - tankvorgaenge = json.loads(tankvorgaenge_json) if isinstance(tankvorgaenge_json, str) else tankvorgaenge_json - profil.tankvorgaenge_schreiben(tankvorgaenge) - if entitaeten_json: - zuordnung = json.loads(entitaeten_json) if isinstance(entitaeten_json, str) else entitaeten_json - entitaeten.overrides_schreiben(zuordnung) - entitaeten.overrides_anwenden() - frontend_veroeffentlichung.alles_veroeffentlichen() - log.info("audi_dashboard: Backup wiederhergestellt") diff --git a/homeassistant/pyscript/batterieverlauf.py b/homeassistant/pyscript/batterieverlauf.py deleted file mode 100644 index 24d2be1..0000000 --- a/homeassistant/pyscript/batterieverlauf.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Langfristige Aufzeichnung der 12V-Batteriespannung (Mein Audi -> Zustand --> Batteriespannung antippen). Eine über die Jahre absinkende Spannung -deutet auf eine alternde Batterie hin - dafür braucht es einen Verlauf, -nicht nur den aktuellen Wert. - -Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}) reicht für diesen -Zweck aus und hält batteriespannung.jsonl auch über viele Jahre klein - auch -wenn der Sensor selbst künftig mehrfach pro Stunde meldet (aktive Fahrt: -Motor an/aus, Ladezustand des Alternators wechselt laufend). -batterie_verlauf_pruefen() läuft deshalb häufig (alle 5 Minuten) und trägt -jede Messung in den Tageseintrag ein, statt wie zuvor nur einmal täglich -einen Einzelwert zu speichern - profil.batterieverlauf_tageswert_aktualisieren() -erweitert dabei nur min/max samt dem genauen Zeitstempel der jeweiligen -Extremmessung, robust gegen Neustarts und schwankende Erreichbarkeit. - -Der Tagesminimalwert ist die für die Entladung aussagekräftige Größe: er -fällt typischerweise auf den Moment kurz nach dem Start, bevor der -Alternator die Ladespannung hochfährt - also am nächsten an die "echte" -Ruhespannung heran, ohne 2+ Stunden Standzeit abwarten zu müssen. Deshalb -verwendet auch die Ladezustands-/Gesundheitsauswertung im Frontend -(audi-dashboard-app.js, AGM_RUHE_MAX_V) den Tagesminimalwert als -repräsentativen Wert des Tages, nicht den Maximalwert. - -Anders als in der ersten Fassung wird hier NICHT mehr gefiltert, welche -Messwerte gespeichert werden - min UND max jedes Tages werden roh -aufgezeichnet (der Maximalwert ist z. B. informativ beim Antippen eines -Punkts im Diagramm sichtbar). Die AGM-Ruhespannungs-Grenze wird stattdessen -erst beim Anzeigen angewendet (siehe AGM_RUHE_MAX_V im Frontend): Tage, an -denen selbst der Minimalwert über der Grenze liegt (Fahrzeug nie im -Ruhezustand beobachtet, z. B. bei einer sehr langen Fahrt), tauchen dort -nicht in der Ladezustands-Statistik auf. - -Ohne eingetragenen einstellungen.BATTERIE_SENSOR (aktuell der Fall - die -genutzte Integration liefert dafür keinen Sensor, siehe dortiger Kommentar) -passiert hier bewusst nichts, statt Platzhalter-Nullwerte aufzuzeichnen. -""" - -import datetime - -import einstellungen -import frontend_veroeffentlichung -import profil - - -@time_trigger("startup") -@time_trigger("period(now, 5 minutes)") -def batterie_verlauf_pruefen(): - if not einstellungen.BATTERIE_SENSOR: - return - wert = frontend_veroeffentlichung.zustand_oder_none(einstellungen.BATTERIE_SENSOR) - if wert is None: - return - try: - spannung = float(wert) - except (TypeError, ValueError): - return - - heute = datetime.date.today().isoformat() - ts = datetime.datetime.now(datetime.timezone.utc).isoformat() - profil.batterieverlauf_tageswert_aktualisieren(heute, ts, spannung) - frontend_veroeffentlichung.batterieverlauf_veroeffentlichen() - log.info(f"audi_dashboard: Batteriespannung erfasst ({heute}: {spannung} V)") diff --git a/homeassistant/pyscript/belegverarbeitung.py b/homeassistant/pyscript/belegverarbeitung.py deleted file mode 100644 index 1e3d92d..0000000 --- a/homeassistant/pyscript/belegverarbeitung.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Belegverarbeitung für Shell-Tankbelege (§7.7, §10 Punkt 7). - -Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff. - -shell_beleg_parser.py, im Lastenheft (§2) als mitgeliefert genannt, lag -beim Bau nicht im Projektordner vor (siehe Ersteinschätzung) und wurde -nachträglich unter audi_dashboard/shell_beleg_parser.py gegen einen echten -Shell-eReceipt neu geschrieben (siehe dessen Kopfkommentar). Aufruf über -eine Kommandozeilen-Schnittstelle: `python3 shell_beleg_parser.py -`, Ausgabe als JSON auf stdout mit den Feldern aus §6.4 -(receipt_key, ts, station_name, liters, fuel_total_eur, discount, ...). -Muss unter /config/audi_dashboard/shell_beleg_parser.py liegen (siehe -INSTALL.md) - eigener Prozess mit dem System-python3 des Containers, nicht -die pyscript-Sandbox, deshalb dort einmalig `pip install pypdf` nötig. - -ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem -ursprünglichen Bau nachträglich verlangt): -- Volltankung (§7.4) ist entfernt - weder Berechnung noch Anzeige. Damit - entfallen auch level_before_pct/level_after_pct, die nur dafür gebraucht - wurden. -- fuel_total_eur ist immer der reale, bereits rabattierte Betrag. - Die SmartDeal-Ersparnis steht separat in discount. price_per_l wird - grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie aus einem vom - Beleg übernommenen Wert übernommen - siehe Kopfkommentar in - audi-dashboard-app.js (tankFelder()) für dieselbe Regel im Frontend. - -Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. -""" - -import base64 -import datetime -import io -import json -import os -import subprocess - -import einstellungen -import frontend_veroeffentlichung -import profil - -PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py" - - -# --------------------------------------------------------------- Hilfsfunktionen - -def _pdf_speichern(inhalt_bytes, dateiname): - # io.open über task.executor, kein with, kein bloßes open() - siehe - # Kopfkommentar in profil.py für alle drei Gründe. - os.makedirs(profil.BELEGE_ORDNER, exist_ok=True) - pfad = f"{profil.BELEGE_ORDNER}/{dateiname}" - f = task.executor(io.open, pfad, "wb") - f.write(inhalt_bytes) - f.close() - return pfad - - -def _parser_aufrufen(pdf_pfad): - ergebnis = subprocess.run( - ["python3", PARSER_SKRIPT, pdf_pfad], - capture_output=True, text=True, timeout=30, - ) - if ergebnis.returncode != 0: - raise RuntimeError(f"shell_beleg_parser.py fehlgeschlagen: {ergebnis.stderr}") - return json.loads(ergebnis.stdout) - - -def _preis_je_liter(fuel_total_eur, liters): - if not liters: - return None - return round(fuel_total_eur / liters, 3) - - -# -------------------------------------------------------------------- Service - -@service -def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None): - """Nimmt einen per Base64 codierten PDF-Beleg entgegen (Upload aus der - Oberfläche über hass.callService). - - Mit tank_id: trägt die Belegdaten direkt in einen bereits bestehenden - Tankvorgang nach - egal ob der ursprünglich manuell oder automatisch - angelegt wurde. Der Kilometerstand/die gefahrene Distanz dieses - Tankvorgangs bleiben dabei unangetastet (die kommen weiterhin vom - Fahrzeug, nie vom Beleg, §7.7 Regel 2), nur die Beleg-Felder (Liter, - Kosten, Ersparnis, Station, Zeitstempel, ...) werden ergänzt - beziehungsweise überschrieben. Passt das Belegdatum nicht zum bereits - erfassten Tankvorgang (unterschiedlicher Kalendertag), wird nichts - übernommen - stattdessen ein Fehler veröffentlicht, damit in der - Oberfläche nicht versehentlich ein falscher Beleg einem bestehenden - Eintrag zugeordnet wird. - - Ohne tank_id: legt KEINEN eigenständigen Tankvorgang mehr an (frühere - Abweichung vom Lastenheft, auf Wunsch entfernt), sondern parst den Beleg - nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte - in den gerade offenen Entwurf (neuer oder bearbeiteter Tankvorgang) und - speichert erst auf einen expliziten Klick auf "Speichern". - - Aufruf als pyscript.audi_dashboard_beleg_hochladen.""" - if not pdf_base64: - log.warning("audi_dashboard: kein pdf_base64 übergeben") - return - - # task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind - # in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur - # echte externe Python-Funktionen (siehe Kopfkommentar in profil.py). - # - # Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte - # Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die - # Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen - # rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts - # an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf - # selbst so abgesichert; b64decode/_pdf_speichern fehlten. - try: - inhalt_bytes = base64.b64decode(pdf_base64) - dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" - pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname) - geparst = _parser_aufrufen(pdf_pfad) - except Exception as exc: - log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet werden: {exc}") - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}) - return - - receipt_key = geparst.get("receipt_key") - if not receipt_key: - log.error("audi_dashboard: Parser lieferte keinen receipt_key") - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}) - return - - # §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt - # keinen zweiten Datensatz - vorhandener_beleg = profil.tankvorgang_nach_receipt_key(receipt_key) - if vorhandener_beleg is not None and vorhandener_beleg.get("tank_id") != tank_id: - log.info(f"audi_dashboard: Beleg {receipt_key} bereits vorhanden, übersprungen") - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg bereits erfasst", "tank_id": tank_id}) - return - - liters = geparst.get("liters") - fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert - - beleg_felder = { - "receipt_key": receipt_key, - "receipt_no": geparst.get("receipt_no"), - "tse_beleg_nr": geparst.get("tse_beleg_nr"), - "ts": geparst.get("ts"), - "ts_payment": geparst.get("ts_payment"), - "ts_tse": geparst.get("ts_tse"), - "station_id": geparst.get("station_id"), - "station_name": geparst.get("station_name"), - "station_address": geparst.get("station_address"), - "article_no": geparst.get("article_no"), - "product_name": geparst.get("product_name"), - "fuel_type": geparst.get("fuel_type"), - # Liter, Kosten und Ersparnis kommen vom Beleg (§7.7 Regel 2). - # price_per_l wird nie vom Beleg übernommen, sondern immer berechnet. - "liters": liters, - "fuel_total_eur": fuel_total_eur, - "price_per_l": _preis_je_liter(fuel_total_eur, liters), - "discount": geparst.get("discount"), # SmartDeal-Ersparnis - "discount_per_l": geparst.get("discount_per_l"), - "list_price_per_l": geparst.get("list_price_per_l"), - "receipt_total_eur": geparst.get("receipt_total_eur"), - "net_eur": geparst.get("net_eur"), - "vat_eur": geparst.get("vat_eur"), - "receipt_file": pdf_pfad, - } - - if tank_id: - vorhanden = profil.tankvorgang_nach_id(tank_id) - if not vorhanden: - log.warning(f"audi_dashboard: Tankvorgang {tank_id} für Beleg-Nachtrag nicht gefunden") - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}) - return - vorhandenes_datum = (vorhanden.get("ts") or "")[:10] - belegdatum = (geparst.get("ts") or "")[:10] - if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum: - log.warning(f"audi_dashboard: Belegdatum {belegdatum} passt nicht zu Tankvorgang {tank_id} (Datum {vorhandenes_datum})") - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Falsches Belegdatum", "tank_id": tank_id}) - return - aenderungen = dict(beleg_felder) - aenderungen["status"] = "vollständig" - if profil.tankvorgang_aktualisieren(tank_id, aenderungen): - frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id}) - log.info(f"audi_dashboard: Beleg {receipt_key} in Tankvorgang {tank_id} nachgetragen") - else: - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}) - return - - # Kein tank_id: nur parsen und dem gerade offenen Entwurf in der - # Oberfläche zum Ausfüllen bereitstellen - legt selbst KEINEN - # Tankvorgang an (siehe Kopfkommentar oben). - frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None}) - log.info(f"audi_dashboard: Beleg {receipt_key} geparst, wartet auf Speichern in der Oberfläche") - - -@service -def audi_dashboard_tankvorgang_manuell(ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None): - """Erfassung eines Tankvorgangs aus dem Formular "Neuer Tankvorgang" - - Fallback aus §7.7 / §10 Punkt 7, unter „Tanken" vorgesehen (§5.5). - Dieselben Felder wie beim Bearbeiten eines bestehenden Vorgangs (siehe - audi_dashboard_tankvorgang_aktualisieren). receipt_key/receipt_file sind - optional: kommen sie mit (weil zuvor ein Beleg über - audi_dashboard_beleg_hochladen ohne tank_id in den Entwurf übernommen - wurde), zählt der Tankvorgang als per Beleg erfasst, sonst als manuell. - Aufruf als pyscript.audi_dashboard_tankvorgang_manuell.""" - if liter is None or kosten is None: - log.warning("audi_dashboard: liter und kosten sind Pflichtfelder") - return - - odometer_km = km if km is not None else frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR) - tankvorgang = { - "tank_id": profil.neue_id("f"), - "receipt_key": receipt_key, - "ts": ts or datetime.datetime.now(datetime.timezone.utc).isoformat(), - "liters": liter, - "fuel_total_eur": kosten, - "price_per_l": _preis_je_liter(kosten, liter), - "discount": ersparnis, - "station_name": station, - "odometer_km": odometer_km, - # Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie - # odometer_km selbst frei überschreibbar - deshalb nimmt ein manuell - # übergebener Wert immer Vorrang vor der Berechnung. - "distance_km": distanz if distanz is not None else profil.distanz_seit_letzter_tankung(odometer_km), - "fuel_type": kraftstoff, - "source": "beleg" if receipt_key else "manual", - "status": "vollständig", - "receipt_file": receipt_file, - "edited_fields": [], - } - profil.tankvorgang_anhaengen(tankvorgang) - frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() - log.info(f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} angelegt") - - -@service -def audi_dashboard_tankvorgang_aktualisieren(tank_id=None, ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None): - """Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob - ursprünglich per Beleg oder manuell angelegt). receipt_key/receipt_file - sind optional: kommen sie mit (ein im Bearbeiten-Formular hochgeladener - Beleg wurde in den Entwurf übernommen), wird der Tankvorgang zusätzlich - als per Beleg erfasst markiert. Aufruf als - pyscript.audi_dashboard_tankvorgang_aktualisieren.""" - if not tank_id: - log.warning("audi_dashboard: tankvorgang_aktualisieren ohne tank_id aufgerufen") - return - aenderungen = { - "ts": ts, "liters": liter, "fuel_total_eur": kosten, - "price_per_l": _preis_je_liter(kosten, liter), - "odometer_km": km, "discount": ersparnis, "station_name": station, - "distance_km": distanz, "fuel_type": kraftstoff, - } - if receipt_key: - aenderungen["receipt_key"] = receipt_key - aenderungen["receipt_file"] = receipt_file - aenderungen["source"] = "beleg" - if profil.tankvorgang_aktualisieren(tank_id, aenderungen): - frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() - log.info(f"audi_dashboard: Tankvorgang {tank_id} aktualisiert") - else: - log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden") - - -@service -def audi_dashboard_tankvorgang_loeschen(tank_id=None): - """Löscht einen Tankvorgang unwiderruflich (Löschfunktion in der - Oberfläche). Aufruf als pyscript.audi_dashboard_tankvorgang_loeschen.""" - if not tank_id: - log.warning("audi_dashboard: tankvorgang_loeschen ohne tank_id aufgerufen") - return - if profil.tankvorgang_loeschen(tank_id): - frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() - log.info(f"audi_dashboard: Tankvorgang {tank_id} gelöscht") - else: - log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden") diff --git a/homeassistant/pyscript/bilderverwaltung.py b/homeassistant/pyscript/bilderverwaltung.py deleted file mode 100644 index c3712a8..0000000 --- a/homeassistant/pyscript/bilderverwaltung.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Fahrzeugbilder direkt aus der Oberfläche hochladen/löschen (Einstellungen --> Fahrzeugbilder, siehe BILDER_UPLOAD_SLOTS in audi-dashboard-app.js). - -Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine -Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals -außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder -absolute Pfade). Die Bytes werden unverändert unter dem erwarteten Namen -abgelegt, unabhängig vom tatsächlichen Bildformat des Originalfotos (z. B. -JPEG von einem iPhone unter einem ".webp"-Namen) - Browser stellen nach -den tatsächlichen Bilddaten dar, nicht nach der Dateiendung, das funktioniert -also trotzdem. Eine Größenbeschränkung gibt es bewusst nicht (Vorgabe: jede -Fotogröße muss hochladbar sein). -""" - -import base64 -import io -import os - -BILDER_ORDNER = "/config/www/bilder" - -ERLAUBTE_DATEINAMEN = { - "seitenansicht.webp", - "seitenansicht-winter.webp", - "front-schraeg.webp", - "frontansicht.webp", - "heckansicht.webp", - "cockpit.webp", - "scheinwerfer.webp", - "sitze.webp", - "rad-sommer.webp", - "rad-winter.webp", -} - - -def _pfad(dateiname): - if dateiname not in ERLAUBTE_DATEINAMEN: - return None - return f"{BILDER_ORDNER}/{dateiname}" - - -@service -def audi_dashboard_bild_hochladen(dateiname=None, daten_base64=None): - """Speichert ein aus der Oberfläche hochgeladenes Fahrzeugfoto unter - einem der festen erwarteten Namen. Aufruf als - pyscript.audi_dashboard_bild_hochladen.""" - pfad = _pfad(dateiname) - if not pfad or not daten_base64: - log.warning(f"audi_dashboard: Bild-Upload abgelehnt (dateiname={dateiname})") - return - os.makedirs(BILDER_ORDNER, exist_ok=True) - rohdaten = base64.b64decode(daten_base64) - tmp = pfad + ".tmp" - f = task.executor(io.open, tmp, "wb") - f.write(rohdaten) - f.close() - os.replace(tmp, pfad) - log.info(f"audi_dashboard: Fahrzeugbild gespeichert ({dateiname}, {len(rohdaten)} Bytes)") - - -@service -def audi_dashboard_bild_loeschen(dateiname=None): - """Entfernt ein zuvor hochgeladenes Fahrzeugfoto wieder - die Ansicht - fällt danach auf den Platzhalter zurück. Aufruf als - pyscript.audi_dashboard_bild_loeschen.""" - pfad = _pfad(dateiname) - if not pfad: - log.warning(f"audi_dashboard: Bild-Löschen abgelehnt (dateiname={dateiname})") - return - if os.path.exists(pfad): - os.remove(pfad) - log.info(f"audi_dashboard: Fahrzeugbild gelöscht ({dateiname})") diff --git a/homeassistant/pyscript/fahrtabschluss.py b/homeassistant/pyscript/fahrtabschluss.py deleted file mode 100644 index 07eb09a..0000000 --- a/homeassistant/pyscript/fahrtabschluss.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Trigger-Registrierung für den zweistufigen Fahrtabschluss (§7.2). - -Die eigentliche Logik liegt in pyscript/modules/fahrtabschluss_logik.py -(importierbares Modul, auch von fahrterkennung.py genutzt). Diese Datei -sorgt nur dafür, dass ein Screening auch dann läuft, wenn der Kilometerstand- -Sensor sich ändert - unabhängig vom Fahrtende-Ereignis selbst, weil der Wert -laut §10 Punkt 4 auch erst mit der nächsten Fahrt eintreffen kann. - -Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. -""" - -import einstellungen -import fahrtabschluss_logik - - -# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren - -# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes -# Verhalten, siehe gleiches Muster in fahrterkennung.py. -if einstellungen.KM_SENSOR: - @state_trigger(f"{einstellungen.KM_SENSOR}") - def kilometerstand_geaendert(value=None, old_value=None): - if value is None or old_value is None: - return - fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) - - -@service -def audi_dashboard_screening_jetzt(): - """Manueller Anstoß des Screenings, z. B. aus der Oberfläche heraus - (pyscript.audi_dashboard_screening_jetzt).""" - fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) diff --git a/homeassistant/pyscript/fahrterkennung.py b/homeassistant/pyscript/fahrterkennung.py deleted file mode 100644 index 358cb45..0000000 --- a/homeassistant/pyscript/fahrterkennung.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Fahrterkennung über den Zündungs-/ACC-Status des FMM003 (§7.1). - -Start: die Zündungs-Entität (einstellungen.ZUENDUNG_SENSOR) wechselt auf -"on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht -zurück. - -2026-08-12: ersetzt die frühere WLAN-basierte Erkennung (Vergleich des vom -iPhone gemeldeten verbundenen WLANs gegen den im Fahrzeugprofil hinterlegten -Namen) - die Zündung ist ein direkteres, von jeder Companion-App-Eigenheit -unabhängiges Signal. - -Die Pausenregel wird über task.unique() umgesetzt: jede neue Änderung der -Zündungs-Entität killt eine noch laufende, wartende Bestätigung des -vorherigen Ereignisses. Kommt die Zündung während der Wartezeit zurück, wird -dadurch die wartende Ende-Bestätigung automatisch abgebrochen, ohne dass die -Fahrt beendet wird - das entspricht der Pausenregel aus §7.1. - -Die Entity-IDs stehen zentral in pyscript/modules/einstellungen.py - dort vor -der Installation einmalig anpassen (siehe INSTALL.md), oder über das -Setup-Menü in der Oberfläche zuordnen (siehe entitaeten.py; Änderungen dort -wirken für DIESEN Trigger allerdings erst nach einem Neustart, da -@state_trigger die Entity-ID einmalig beim Modul-Laden einliest). - -EINSCHRÄNKUNG: der Zustand einer laufenden Fahrt (_fahrt_start_ts) lebt nur -im Arbeitsspeicher dieses Skripts. Ein HA-Neustart mitten in einer Fahrt oder -während der Wartezeit verliert diesen Zwischenstand - die Fahrt würde dann -nicht aufgezeichnet. Das ist ein bekannter Punkt für eine spätere Härtung -(z. B. Zwischenstand in einer input_datetime-Helper-Entität sichern), hier -bewusst nicht mitgebaut, um die Kernlogik nicht zu verkomplizieren, bevor sie -sich im echten Betrieb bewährt hat. -""" - -import datetime - -import einstellungen -import fahrtabschluss_logik -import frontend_veroeffentlichung -import profil - -_fahrt_start_ts = None - - -def _pausenzeit_sekunden(): - p = profil.profil_lesen() - if p is None: - return 15 * 60 - return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60 - - -@time_trigger("startup") -def beim_start(): - profil.ordner_sicherstellen() - - -# Ohne konfigurierte Zündungs-Entität keinen Trigger registrieren - eine -# leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes Verhalten -# (siehe zustand_oder_none()-Kommentar in frontend_veroeffentlichung.py zur -# Grundhaltung "kein geratener Wert"), hier stattdessen ganz weggelassen. -if einstellungen.ZUENDUNG_SENSOR: - @state_trigger(f"{einstellungen.ZUENDUNG_SENSOR}") - def zuendung_geaendert(value=None, old_value=None): - # Killt eine noch laufende Ende-Bestätigung aus einer vorherigen - # Änderung - das ist der Mechanismus hinter der Pausenregel. - task.unique("audi_dashboard_fahrt_ende_pruefung") - - global _fahrt_start_ts - an_jetzt = (value == "on") - an_vorher = (old_value == "on") - - if an_jetzt and not an_vorher: - if _fahrt_start_ts is None: - _fahrt_start_ts = datetime.datetime.now(datetime.timezone.utc) - log.info(f"audi_dashboard: Fahrt gestartet um {_fahrt_start_ts.isoformat()}") - return - - if an_vorher and not an_jetzt and _fahrt_start_ts is not None: - start_ts = _fahrt_start_ts - abbruch_ts = datetime.datetime.now(datetime.timezone.utc) - wartezeit_s = _pausenzeit_sekunden() - - # task.unique() oben markiert diese Ausführung als die aktuell - # gültige. Kommt die Zündung vor Ablauf der Wartezeit zurück, - # ruft der nächste Trigger erneut task.unique() auf und killt - # genau diesen task.sleep() - der Code danach läuft dann nie. - task.sleep(wartezeit_s) - - _fahrt_beenden(start_ts, abbruch_ts) - - -def _fahrt_beenden(start_ts, ende_ts): - global _fahrt_start_ts - dauer_s = int((ende_ts - start_ts).total_seconds()) - - fahrt = { - "trip_id": profil.neue_id("t"), - "ts_start": start_ts.isoformat(), - "ts_end": ende_ts.isoformat(), - "duration_s": dauer_s, - "distance_km": None, - "km_quelle": None, - "odo_start": None, - "odo_end": None, - "avg_speed_kmh": None, - "start_lat": None, - "start_lon": None, - "end_lat": None, - "end_lon": None, - "start_address": None, - "end_address": None, - "art": "privat", - "route": None, - "pausen": [], - "source": "ha", - "status": "offen", - "edited_fields": [], - } - profil.fahrt_anhaengen(fahrt) - frontend_veroeffentlichung.fahrten_veroeffentlichen() - log.info(f"audi_dashboard: Fahrt {fahrt['trip_id']} beendet, Status 'offen', Screening angestoßen") - - _fahrt_start_ts = None - fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) - - -def _handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz): - """Die von Hand befüllbaren Felder einer Fahrt auf ihre Datensatz-Namen - abgebildet, ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel - zusätzlich nach edited_fields - das schützt sie vor dem automatischen - Kilometerstand-Screening (§7.2), das sonst später darüberschreiben würde.""" - werte = { - "start_address": start_ort or None, - "end_address": ziel_ort or None, - "odo_start": odo_start, - "odo_end": odo_end, - "distance_km": distanz, - } - return {k: v for k, v in werte.items() if v is not None} - - -def _strecke_ergaenzen(felder): - """Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen - - dieselbe Rechnung wie im Screening (fahrtabschluss_logik).""" - if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder: - felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1) - return felder - - -@service -def audi_dashboard_fahrt_manuell_anlegen(ts_start=None, ts_end=None, art="privat", - start_ort=None, ziel_ort=None, - odo_start=None, odo_end=None, distanz=None): - """Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der - automatischen Zündungs-Erkennung. Dieselben Felder wie beim Bearbeiten - einer bestehenden Fahrt (siehe audi_dashboard_fahrt_aktualisieren); alles - außer Start- und Endzeitpunkt ist optional und bleibt leer, wenn es das - Screening später selbst ergänzen soll. Aufruf als - pyscript.audi_dashboard_fahrt_manuell_anlegen.""" - start_ts = datetime.datetime.fromisoformat(ts_start) - ende_ts = datetime.datetime.fromisoformat(ts_end) - dauer_s = int((ende_ts - start_ts).total_seconds()) - hand = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz)) - fahrt = { - "trip_id": profil.neue_id("t"), - "ts_start": start_ts.isoformat(), - "ts_end": ende_ts.isoformat(), - "duration_s": dauer_s, - "distance_km": None, - "km_quelle": None, - "odo_start": None, - "odo_end": None, - "avg_speed_kmh": None, - "start_lat": None, - "start_lon": None, - "end_lat": None, - "end_lon": None, - "start_address": None, - "end_address": None, - "art": art, - "route": None, - "pausen": [], - "source": "manual", - "status": "offen", - "edited_fields": [], - } - fahrt.update(hand) - fahrt["edited_fields"] = sorted(hand) - if fahrt["distance_km"] is not None: - fahrt["km_quelle"] = "manuell" - fahrt["status"] = "vollständig" - profil.fahrt_anhaengen(fahrt) - frontend_veroeffentlichung.fahrten_veroeffentlichen() - fahrtabschluss_logik.screening_durchfuehren(einstellungen.KM_SENSOR) - - -@service -def audi_dashboard_fahrt_aktualisieren(trip_id=None, ts_start=None, ts_end=None, art=None, - start_ort=None, ziel_ort=None, - odo_start=None, odo_end=None, distanz=None): - """Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie - automatisch erkannt oder von Hand angelegt wurde. Aufruf als - pyscript.audi_dashboard_fahrt_aktualisieren.""" - if not trip_id: - log.warning("audi_dashboard: fahrt_aktualisieren ohne trip_id aufgerufen") - return - aenderungen = _strecke_ergaenzen(_handfelder(start_ort, ziel_ort, odo_start, odo_end, distanz)) - if art: - aenderungen["art"] = art - if ts_start and ts_end: - start_ts = datetime.datetime.fromisoformat(ts_start) - ende_ts = datetime.datetime.fromisoformat(ts_end) - aenderungen["ts_start"] = start_ts.isoformat() - aenderungen["ts_end"] = ende_ts.isoformat() - aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds()) - if aenderungen.get("distance_km") is not None: - aenderungen["km_quelle"] = "manuell" - aenderungen["status"] = "vollständig" - # Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit, - # damit ein späteres Screening sie nicht wieder überschreibt. - # Bewusst eine Listen-Abstraktion statt next(): pyscript kann - # keine Generator-Ausdrücke auswerten ("not implemented ast - # ast_generatorexp"), Listen-Abstraktionen dagegen schon. - passend = [f for f in profil.fahrten_lesen() if f.get("trip_id") == trip_id] - handfelder = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"} - bisher = set(passend[0].get("edited_fields", [])) if passend else set() - aenderungen["edited_fields"] = sorted(bisher | (handfelder & set(aenderungen))) - if profil.fahrt_bearbeiten(trip_id, aenderungen): - frontend_veroeffentlichung.fahrten_veroeffentlichen() - log.info(f"audi_dashboard: Fahrt {trip_id} aktualisiert") - else: - log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden") - - -@service -def audi_dashboard_fahrt_loeschen(trip_id=None): - """Löscht eine Fahrt unwiderruflich (Löschfunktion in der Oberfläche, - §Löschfunktion). Aufruf als pyscript.audi_dashboard_fahrt_loeschen.""" - if not trip_id: - log.warning("audi_dashboard: fahrt_loeschen ohne trip_id aufgerufen") - return - if profil.fahrt_loeschen(trip_id): - frontend_veroeffentlichung.fahrten_veroeffentlichen() - log.info(f"audi_dashboard: Fahrt {trip_id} gelöscht") - else: - log.warning(f"audi_dashboard: Fahrt {trip_id} nicht gefunden") diff --git a/homeassistant/pyscript/frontend_api.py b/homeassistant/pyscript/frontend_api.py deleted file mode 100644 index e3d6a99..0000000 --- a/homeassistant/pyscript/frontend_api.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Verbindung zwischen Frontend und Backend. - -Lesen: das Frontend liest hass.states["pyscript.audi_dashboard_profil"] usw. -direkt - kein Service-Aufruf mit Rückgabewert. Die Frontend-Dokumentation von -Home Assistant zu response-fähigen Services beschreibt das Verhalten aus -Sicht des Browsers nicht abschließend (Community-Diskussionen deuten auf -Einschränkungen hin); Zustände lesen ist dagegen ein seit Jahren stabiler, -einfacher Weg. Veröffentlicht wird periodisch UND sofort nach jeder -Schreibaktion (siehe frontend_veroeffentlichung.py, dort auch der Hinweis zur -Größengrenze von Zustands-Attributen). - -Schreiben: einzig audi_dashboard_profil_schreiben() als generischer Weg für -das gesamte Profil - das Frontend hält Profil, Fahrzeugdaten und -Einstellungen ohnehin als einen zusammenhängenden Stand im Speicher (wie -schon im Prototyp) und schickt ihn nach jeder Änderung komplett zurück, -statt für jedes einzelne Feld einen eigenen Service zu brauchen. -""" - -import json - -import einstellungen -import entitaeten -import frontend_veroeffentlichung -import profil - - -@time_trigger("startup") -def beim_start(): - profil.ordner_sicherstellen() - # Vor der ersten Veröffentlichung anwenden, damit sie die im Setup-Menü - # gespeicherten Zuordnungen (data/entitaeten.json) schon zeigt, siehe - # entitaeten.py-Kopfkommentar. - entitaeten.overrides_anwenden() - frontend_veroeffentlichung.alles_veroeffentlichen() - - -@time_trigger("period(now, 20 seconds)") -def periodisch_aktualisieren(): - frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen() - - -@time_trigger("period(now, 60 seconds)") -def periodisch_daten_aktualisieren(): - # seltener als der Fahrzeugstatus - Profil/Fahrten/Tankvorgänge ändern - # sich normalerweise nur durch Aktionen, die ohnehin sofort selbst - # veröffentlichen (siehe unten); das hier ist nur das Sicherheitsnetz - # für Änderungen direkt im Dateisystem (§6.2, dritter Bearbeitungsweg). - frontend_veroeffentlichung.profil_veroeffentlichen() - frontend_veroeffentlichung.fahrten_veroeffentlichen() - frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() - - -@service -def audi_dashboard_jetzt_aktualisieren(): - """Gegenstück zum "Ziehen zum Aktualisieren" auf der Übersicht: stößt den - von der Integration bereitgestellten Refresh-Knopf an (siehe - einstellungen.REFRESH_BUTTON) statt auf den nächsten periodischen Takt zu - warten, und veröffentlicht den Status danach sofort neu. Ohne - eingetragenen Knopf (leerer Platzhalter) nur die direkte Neu- - veröffentlichung - besser als gar nichts, auch wenn dann kein echter neuer - Abruf beim Fahrzeug passiert. Aufruf als - pyscript.audi_dashboard_jetzt_aktualisieren.""" - if einstellungen.REFRESH_BUTTON: - button.press(entity_id=einstellungen.REFRESH_BUTTON) - task.sleep(3) - frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen() - log.info("audi_dashboard: manuelle Aktualisierung (Ziehen zum Aktualisieren) ausgelöst") - - -@service -def audi_dashboard_profil_schreiben(profil_json=None): - """Überschreibt das komplette Fahrzeugprofil. Aufruf als - pyscript.audi_dashboard_profil_schreiben.""" - if not profil_json: - log.warning("audi_dashboard: profil_schreiben ohne profil_json aufgerufen") - return - p = json.loads(profil_json) if isinstance(profil_json, str) else profil_json - profil.profil_schreiben(p) - frontend_veroeffentlichung.profil_veroeffentlichen() - log.info("audi_dashboard: Profil aktualisiert") - - -@service -def audi_dashboard_entitaeten_schreiben(zuordnung_json=None): - """Speichert die im Setup-Menü (Einstellungen -> Fahrzeug einrichten -> - Setup) gewählte Sensor-zu-Entität-Zuordnung, wendet sie sofort an und - veröffentlicht Katalog+Status neu, damit Übersicht/Mein Audi ohne - Neustart die neuen Werte zeigen (Ausnahme: die drei trigger-gebundenen - Felder, siehe entitaeten.py). Aufruf als - pyscript.audi_dashboard_entitaeten_schreiben.""" - if not zuordnung_json: - log.warning("audi_dashboard: entitaeten_schreiben ohne zuordnung_json aufgerufen") - return - mapping = json.loads(zuordnung_json) if isinstance(zuordnung_json, str) else zuordnung_json - entitaeten.overrides_schreiben(mapping) - entitaeten.overrides_anwenden() - frontend_veroeffentlichung.entitaeten_veroeffentlichen() - frontend_veroeffentlichung.fahrzeugstatus_veroeffentlichen() - log.info("audi_dashboard: Entitäten-Zuordnung aktualisiert") - - -@service -def audi_dashboard_neustart(): - """Startet Home Assistant neu. Einziger Zweck: eine Änderung an einem der - drei trigger-gebundenen Setup-Felder (ZUENDUNG_SENSOR/KM_SENSOR/ - TANK_SENSOR, siehe entitaeten.py) wird erst nach einem Neustart wirksam, - weil @state_trigger die Entity-ID beim Modul-Laden einmalig einliest. - Wird vom Setup-Popup als expliziter, vom Nutzer bestätigter Knopf - angeboten - kein automatischer Selbst-Reload direkt nach dem Speichern. - Aufruf als pyscript.audi_dashboard_neustart.""" - log.info("audi_dashboard: Neustart über das Setup-Menü ausgelöst") - homeassistant.restart() diff --git a/homeassistant/pyscript/historienimport.py b/homeassistant/pyscript/historienimport.py deleted file mode 100644 index 8ec6881..0000000 --- a/homeassistant/pyscript/historienimport.py +++ /dev/null @@ -1,511 +0,0 @@ -"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf -(Einstellungen -> "Daten importieren aus Home Assistant"). - -WOZU ----- -Fahrterkennung (fahrterkennung.py), Tankerkennung (tankerkennung.py) und -Batterieverlauf (batterieverlauf.py) arbeiten alle nur ab dem Moment, in dem -sie laufen: sie hängen an @state_trigger bzw. an einem 5-Minuten-Takt. Alles, -was das Fahrzeug gemeldet hat, BEVOR die App lief (oder während Home -Assistant aus war, oder bevor ein Sensor überhaupt zugeordnet war), taucht in -den Beständen der App deshalb nie auf - obwohl der recorder von Home -Assistant es längst aufgezeichnet hat. - -Dieser Import schließt genau diese Lücke: er liest denselben Verlauf, den die -Live-Trigger sonst in Echtzeit sehen, und leitet daraus rückwirkend dieselben -Datensätze ab. - -GRENZE, DIE MAN KENNEN MUSS ---------------------------- -Weiter zurück als der recorder aufbewahrt, geht es nicht - was dort gelöscht -ist, ist endgültig weg. Home Assistant löscht standardmäßig nach 10 Tagen; -recorder_snippet.yaml hebt das auf ein Jahr an. Der Import meldet deshalb im -Ergebnis mit, ab wann im gewählten Zeitraum überhaupt Daten vorlagen -(`ab_wann_daten`), damit ein leeres Ergebnis nicht wie ein Fehler aussieht. - -WIE DER VERLAUF GELESEN WIRD ----------------------------- -Über Home Assistants eigene recorder-API -(`homeassistant.components.recorder.history.get_significant_states`), nicht -über direkten SQL-Zugriff auf home-assistant_v2.db. Der Umweg über die API -ist Absicht: das Datenbankschema des recorders ist HA-intern und ändert sich -zwischen Versionen, die Funktion dagegen ist die von HA selbst benutzte und -stabile Schnittstelle. - -Voraussetzung dafür ist `hass_is_global: true` im pyscript-Block der -configuration.yaml (siehe configuration_snippet.yaml) - ohne das existiert -der `hass`-Name hier nicht. Der Aufruf läuft über task.executor(), weil es -eine echte externe Funktion ist und ein Datenbankzugriff nichts im -Event-Loop verloren hat (dieselbe pyscript-Einschränkung wie bei io.open in -profil.py, siehe dortiger Kopfkommentar). - -`significant_changes_only=False` ist wichtig: bei numerischen Sensoren -(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige" -Änderungen und verschluckt genau die kleinen Schritte, aus denen sich -Fahrstrecke und Tankvorgänge zusammensetzen. - -DOPPELTE DATENSÄTZE -------------------- -Der Import ist absichtlich mehrfach ausführbar (überlappende Zeiträume, -zweiter Versuch nach einem Abbruch): jede erzeugte Fahrt wird gegen die -bereits vorhandenen geprüft und übersprungen, wenn sich ihr Zeitraum mit -einer bestehenden Fahrt überschneidet - egal ob die live erkannt, von Hand -angelegt oder aus einem früheren Import stammt. Tankvorgänge werden über ein -Zeitfenster (TANK_DUBLETTE_MIN) entdoppelt, Batteriewerte über den Tag als -Schlüssel (dort führt profil.batterieverlauf_tageswert_aktualisieren() min/ -max ohnehin zusammen, statt Zeilen zu vervielfachen). - -Erzeugte Datensätze tragen `source: "import"` - dieselbe Rolle wie "ha" -(live erkannt), "manual" (von Hand) und "auto" (Tankerkennung), damit später -nachvollziehbar bleibt, woher ein Eintrag stammt. -""" - -import datetime - -from homeassistant.components.recorder.history import get_significant_states - -import einstellungen -import frontend_veroeffentlichung -import profil - -# Ein Anstieg des Tankfüllstands gilt ab denselben Schwellen als Tankvorgang -# wie in der Live-Erkennung - bewusst dieselben Zahlen wie in -# tankerkennung.py, damit ein importierter Zeitraum dieselben Vorgänge -# erzeugt, die die Live-Erkennung erzeugt hätte. -LITER_SCHWELLE = 5 -PROZENT_SCHWELLE = 9 -STANDARD_TANKVOLUMEN_LITER = 58 - -# Zwei Tankvorgänge innerhalb dieser Spanne gelten als derselbe - schützt -# gegen Dubletten, wenn derselbe Zeitraum zweimal importiert wird oder sich -# Import und Live-Erkennung am Rand überschneiden. -TANK_DUBLETTE_MIN = 90 - -# Fahrten, die kürzer sind, sind Zündung-an-ohne-Fahrt (Radio, Tür öffnen mit -# Zündung, Diagnose) - die Live-Erkennung legt sie zwar an, im Rückblick -# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ. -MINDESTDAUER_S = 60 - - -def _status(zustand, daten=None): - """Fortschritt/Ergebnis für die Oberfläche. Gleiches Muster wie - _status_veroeffentlichen() in updateverwaltung.py.""" - state.set( - "pyscript.audi_dashboard_import_status", - zustand, - new_attributes={"daten": daten or {}}, - ) - - -def _als_zeit(wert): - """Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone. - Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt - im Formular schließlich Ortszeit, keine UTC.""" - if not wert: - return None - ts = datetime.datetime.fromisoformat(str(wert)) - if ts.tzinfo is None: - ts = ts.astimezone() - return ts.astimezone(datetime.timezone.utc) - - -def _zahl(wert): - try: - return float(wert) - except (TypeError, ValueError): - return None - - -def _verlauf(entity_id, start, ende): - """Zustandsverlauf einer Entität als Liste von (Zeitpunkt, Rohwert), - aufsteigend. Leere Liste, wenn die Entität nicht zugeordnet ist oder im - Zeitraum nichts vorliegt. - - Die Zustände "unknown"/"unavailable" werden verworfen: sie bedeuten - "keine Meldung", nicht "Wert 0" - würden sie durchgereicht, ergäbe ein - Ausfall der Integration eine Fahrt mit absurder Kilometerdifferenz.""" - if not entity_id: - return [] - roh = task.executor( - get_significant_states, hass, start, ende, [entity_id], None, True, False - ) - reihen = roh.get(entity_id) or [] - ergebnis = [] - for s in reihen: - if s.state in ("unknown", "unavailable", None, ""): - continue - ergebnis.append((s.last_updated, s.state)) - ergebnis.sort(key=lambda p: p[0]) - return ergebnis - - -def _wert_bei(verlauf, zeitpunkt): - """Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste - danach, sonst None. "Zuletzt davor" ist die richtige Wahl für einen - Zählerstand: der Kilometerstand bei Fahrtbeginn ist der, der zuletzt - gemeldet wurde, nicht der nächste (der schon Strecke enthält).""" - davor = None - for ts, wert in verlauf: - zahl = _zahl(wert) - if zahl is None: - continue - if ts <= zeitpunkt: - davor = zahl - else: - return davor if davor is not None else zahl - return davor - - -def _pausenzeit_sekunden(): - p = profil.profil_lesen() - if p is None: - return 15 * 60 - return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60 - - -def _fahrtfenster(zuendung_verlauf, pausenzeit_s): - """Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde. - - Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden: - erst jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte - Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau - das tut die Live-Erkennung über task.unique() + task.sleep(), nur eben - im Nachhinein und ohne Warten.""" - roh = [] - offen = None - for ts, wert in zuendung_verlauf: - an = str(wert).lower() in ("on", "true", "1") - if an and offen is None: - offen = ts - elif not an and offen is not None: - roh.append((offen, ts)) - offen = None - # Ein am Ende des Zeitraums noch offener Abschnitt wird verworfen: die - # Fahrt ist zu diesem Zeitpunkt noch nicht beendet, ihr Ende läge hinter - # dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine - # Fahrt mit erfundener Endzeit erzeugen. - - if not roh: - return [] - - verschmolzen = [roh[0]] - for start, ende in roh[1:]: - vorheriger_start, vorheriges_ende = verschmolzen[-1] - if (start - vorheriges_ende).total_seconds() < pausenzeit_s: - verschmolzen[-1] = (vorheriger_start, ende) - else: - verschmolzen.append((start, ende)) - return verschmolzen - - -def _ueberschneidet(start, ende, bestehende): - """True, wenn sich [start, ende] mit einer bereits erfassten Fahrt - überschneidet. Verhindert Dubletten beim wiederholten Import.""" - for f in bestehende: - try: - f_start = datetime.datetime.fromisoformat(f.get("ts_start")) - f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) - except (TypeError, ValueError): - continue - if f_start.tzinfo is None or f_ende.tzinfo is None: - continue - if start < f_ende and f_start < ende: - return True - return False - - -def _fahrten_importieren(start, ende, verlaeufe): - """Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/ - Zielkoordinaten aus den übrigen Verläufen ergänzt.""" - fenster = _fahrtfenster(verlaeufe["zuendung"], _pausenzeit_sekunden()) - if not fenster: - return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0} - - bestehende = profil.fahrten_lesen() - km_verlauf = verlaeufe["km"] - lat_verlauf = verlaeufe["lat"] - lon_verlauf = verlaeufe["lon"] - - angelegt = 0 - uebersprungen = 0 - zu_kurz = 0 - neue = [] - - for f_start, f_ende in fenster: - dauer_s = int((f_ende - f_start).total_seconds()) - if dauer_s < MINDESTDAUER_S: - zu_kurz += 1 - continue - if _ueberschneidet(f_start, f_ende, bestehende): - uebersprungen += 1 - continue - - odo_start = _wert_bei(km_verlauf, f_start) - odo_end = _wert_bei(km_verlauf, f_ende) - distanz = None - if odo_start is not None and odo_end is not None and odo_end >= odo_start: - distanz = round(odo_end - odo_start, 1) - - durchschnitt = None - if distanz is not None and dauer_s > 0: - durchschnitt = round(distanz / (dauer_s / 3600.0), 1) - - fahrt = { - "trip_id": profil.neue_id("t"), - "ts_start": f_start.isoformat(), - "ts_end": f_ende.isoformat(), - "duration_s": dauer_s, - "distance_km": distanz, - "km_quelle": "sensor" if distanz is not None else None, - "odo_start": odo_start, - "odo_end": odo_end, - "avg_speed_kmh": durchschnitt, - "start_lat": _wert_bei(lat_verlauf, f_start), - "start_lon": _wert_bei(lon_verlauf, f_start), - "end_lat": _wert_bei(lat_verlauf, f_ende), - "end_lon": _wert_bei(lon_verlauf, f_ende), - "start_address": None, - "end_address": None, - "art": "privat", - "route": None, - "pausen": [], - "source": "import", - "status": "vollständig" if distanz is not None else "offen", - "edited_fields": [], - } - neue.append(fahrt) - bestehende.append(fahrt) - angelegt += 1 - - # Alle neuen Fahrten in einem Rutsch anhängen statt je Fahrt einmal die - # Datei zu öffnen - bei einem Jahr Verlauf sind das sonst hunderte - # Einzelschreibvorgänge. - if neue: - alle = profil.fahrten_lesen() + neue - alle.sort(key=lambda f: f.get("ts_start") or "") - profil.fahrten_schreiben(alle) - - return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz} - - -def _schwelle_prozent(): - p = profil.profil_lesen() - if p is None: - tankvolumen = STANDARD_TANKVOLUMEN_LITER - else: - tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER - return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE) - - -def _tankvorgaenge_importieren(verlaeufe): - """Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik - wie tankerkennung.py (siehe dortiger Kopfkommentar): jeder Anstieg über - die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein Tankvorgang, - nicht jeder Anstieg gegen den unmittelbar vorherigen Wert.""" - verlauf = verlaeufe["tank"] - if not verlauf: - return {"angelegt": 0, "uebersprungen": 0} - - schwelle = _schwelle_prozent() - km_verlauf = verlaeufe["km"] - bestehende = profil.tankvorgaenge_lesen() - fenster = datetime.timedelta(minutes=TANK_DUBLETTE_MIN) - - bekannte_zeiten = [] - for t in bestehende: - try: - ts = datetime.datetime.fromisoformat(t.get("ts")) - except (TypeError, ValueError): - continue - if ts.tzinfo is not None: - bekannte_zeiten.append(ts) - - angelegt = 0 - uebersprungen = 0 - neue = [] - tiefststand = None - - for ts, wert in verlauf: - aktuell = _zahl(wert) - if aktuell is None: - continue - if tiefststand is None or aktuell <= tiefststand: - tiefststand = aktuell - continue - if aktuell - tiefststand < schwelle: - continue - - dublette = False - for bekannt in bekannte_zeiten: - if abs((bekannt - ts).total_seconds()) < fenster.total_seconds(): - dublette = True - break - if dublette: - uebersprungen += 1 - tiefststand = aktuell - continue - - odometer_km = _wert_bei(km_verlauf, ts) - # "Gefahren seit der letzten Tankung" heißt: seit der letzten Tankung - # VOR dieser hier - nicht seit der zeitlich jüngsten überhaupt. Beim - # Import eines vergangenen Zeitraums liegen im Bestand regelmäßig - # bereits neuere Tankvorgänge; die als Bezug zu nehmen ergäbe eine - # negative Strecke (und damit, nach der Prüfung unten, gar keine). - eigene_ts = ts.isoformat() - vorheriger = None - for t in neue + bestehende: - t_ts = t.get("ts") or "" - if t.get("odometer_km") is None or not t_ts or t_ts >= eigene_ts: - continue - if vorheriger is None or t_ts > vorheriger[0]: - vorheriger = (t_ts, t["odometer_km"]) - distanz = None - if odometer_km is not None and vorheriger is not None: - distanz = round(odometer_km - vorheriger[1], 1) - if distanz < 0: - distanz = None - - tankvorgang = { - "tank_id": profil.neue_id("f"), - "receipt_key": None, - "ts": ts.isoformat(), - "liters": None, - "fuel_total_eur": None, - "price_per_l": None, - "discount": None, - "station_name": None, - "odometer_km": odometer_km, - "distance_km": distanz, - "fuel_type": None, - "source": "import", - "status": "unvollständig", - "receipt_file": None, - "edited_fields": [], - } - neue.append(tankvorgang) - bekannte_zeiten.append(ts) - angelegt += 1 - tiefststand = aktuell - - if neue: - alle = profil.tankvorgaenge_lesen() + neue - alle.sort(key=lambda t: t.get("ts") or "") - profil.tankvorgaenge_schreiben(alle) - - return {"angelegt": angelegt, "uebersprungen": uebersprungen} - - -def _batterie_importieren(verlaeufe): - """Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums. - - profil.batterieverlauf_tageswert_aktualisieren() führt bestehende und - neue Werte pro Tag zusammen (min bleibt min, max bleibt max), deshalb - braucht es hier keine eigene Dubletten-Prüfung: ein zweiter Import - desselben Zeitraums verändert die Einträge nicht mehr.""" - verlauf = verlaeufe["batterie"] - if not verlauf: - return {"tage": 0} - - tage = {} - for ts, wert in verlauf: - spannung = _zahl(wert) - if spannung is None: - continue - tag = ts.date().isoformat() - eintrag = tage.get(tag) - if eintrag is None: - tage[tag] = {"min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts} - continue - if spannung < eintrag["min"]: - eintrag["min"] = spannung - eintrag["min_ts"] = ts - if spannung > eintrag["max"]: - eintrag["max"] = spannung - eintrag["max_ts"] = ts - - for tag in sorted(tage): - werte = tage[tag] - profil.batterieverlauf_tageswert_aktualisieren( - tag, werte["min_ts"].isoformat(), werte["min"] - ) - profil.batterieverlauf_tageswert_aktualisieren( - tag, werte["max_ts"].isoformat(), werte["max"] - ) - - return {"tage": len(tage)} - - -@service -def audi_dashboard_historie_importieren(start=None, ende=None): - """Liest den Home-Assistant-Verlauf im gewählten Zeitraum und leitet - daraus Fahrten, Tankvorgänge und Batteriewerte ab. - - start/ende sind ISO-Zeitstempel aus der Oberfläche (Ortszeit ohne - Zeitzone ist zulässig). Aufruf als - pyscript.audi_dashboard_historie_importieren.""" - profil.ordner_sicherstellen() - - try: - von = _als_zeit(start) - bis = _als_zeit(ende) - except ValueError as fehler: - log.error(f"audi_dashboard: Import mit unlesbarem Zeitraum aufgerufen ({fehler})") - _status("fehler", {"meldung": "Zeitraum nicht lesbar"}) - return - - if von is None or bis is None or von >= bis: - log.warning("audi_dashboard: Import ohne gültigen Zeitraum aufgerufen") - _status("fehler", {"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."}) - return - - _status("laeuft", {"von": von.isoformat(), "bis": bis.isoformat()}) - log.info(f"audi_dashboard: Import gestartet für {von.isoformat()} bis {bis.isoformat()}") - - try: - verlaeufe = { - "zuendung": _verlauf(einstellungen.ZUENDUNG_SENSOR, von, bis), - "km": _verlauf(einstellungen.KM_SENSOR, von, bis), - "tank": _verlauf(einstellungen.TANK_SENSOR, von, bis), - "batterie": _verlauf(einstellungen.BATTERIE_SENSOR, von, bis), - "lat": _verlauf(einstellungen.STANDORT_LAT_SENSOR, von, bis), - "lon": _verlauf(einstellungen.STANDORT_LON_SENSOR, von, bis), - } - except Exception as fehler: - log.error(f"audi_dashboard: Verlauf nicht lesbar ({type(fehler).__name__}: {fehler})") - _status("fehler", {"meldung": ( - "Der Verlauf konnte nicht gelesen werden. Steht hass_is_global: true " - "im pyscript-Block der configuration.yaml?" - )}) - return - - # Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas - # aufgezeichnet war - damit ein leeres Ergebnis erklärbar wird - # ("recorder reicht nur bis ...") statt wie ein Fehler auszusehen. - frueheste = None - for name in verlaeufe: - if verlaeufe[name]: - erster = verlaeufe[name][0][0] - if frueheste is None or erster < frueheste: - frueheste = erster - - fahrten = _fahrten_importieren(von, bis, verlaeufe) - tank = _tankvorgaenge_importieren(verlaeufe) - batterie = _batterie_importieren(verlaeufe) - - frontend_veroeffentlichung.fahrten_veroeffentlichen() - frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() - frontend_veroeffentlichung.batterieverlauf_veroeffentlichen() - - ergebnis = { - "von": von.isoformat(), - "bis": bis.isoformat(), - "ab_wann_daten": frueheste.isoformat() if frueheste else None, - "fahrten_angelegt": fahrten["angelegt"], - "fahrten_uebersprungen": fahrten["uebersprungen"], - "fahrten_zu_kurz": fahrten["zu_kurz"], - "tankvorgaenge_angelegt": tank["angelegt"], - "tankvorgaenge_uebersprungen": tank["uebersprungen"], - "batterie_tage": batterie["tage"], - } - _status("fertig", ergebnis) - log.info(f"audi_dashboard: Import abgeschlossen - {ergebnis}") diff --git a/homeassistant/pyscript/modules/einstellungen.py b/homeassistant/pyscript/modules/einstellungen.py deleted file mode 100644 index 04f08b1..0000000 --- a/homeassistant/pyscript/modules/einstellungen.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Technische Konfiguration — die einzige Stelle, die vor der Installation -angepasst werden muss (siehe INSTALL.md Schritt 4). - -Alles andere (WLAN-Name des Fahrzeugs, Pausenzeit, Reifendaten, ...) ist Teil -des Fahrzeugprofils (data/fahrzeugprofil.json) und über die Oberfläche -änderbar. Diese Entity-IDs sind es bewusst nicht: @state_trigger und die -Fahrzeugstatus-Abfrage brauchen sie als festen Wert, bevor überhaupt ein -Profil gelesen werden kann. - -In Home Assistant unter Entwicklerwerkzeuge -> Zustände nachschlagen. Werte -lassen sich außerdem über das Setup-Menü in der Oberfläche zuordnen (siehe -entitaeten.py) - Änderungen von dort werden zur Laufzeit auf diese Variablen -angewendet (überschreiben also die hier hinterlegten Standardwerte), ohne -diese Datei anzufassen. - -**Im Auslieferstand sind alle Felder hier leer.** Das ist Absicht, kein -unfertiger Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen -Instanz und ihren Integrationen. Die Zuordnung passiert nach der Installation -im Setup-Menü der App (Einstellungen -> Fahrzeug einrichten -> Setup), das -sie nach data/entitaeten.json schreibt; diese Datei bleibt dabei unangetastet. - -Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt -"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in -frontend_veroeffentlichung.py), und die trigger-gebundenen Felder -(ZUENDUNG_SENSOR, KM_SENSOR, TANK_SENSOR) registrieren gar keinen Trigger, -statt einen gegen eine nicht existierende Entität zu registrieren. Eine -gesetzte, aber falsche Entity-ID ist deshalb schlechter als eine leere. - -Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker -mit CAN-Anbindung, über flespi angebunden - Standort, Zündungsstatus, -Bordnetzspannung, CAN-Werte wie Kilometerstand und Tankfüllstand) und eine -EU-Data-Act-Integration des Herstellers (Kilometerstand, Tankfüllstand, -Türen/Fenster/Schlösser, Reifendrücke, Ölwechsel-/Inspektionstermine). Welche -davon welche Rolle bedient, entscheidet das Setup-Menü - nicht diese Datei. -""" - -# Fahrterkennung (§7.1): Start/Ende einer Fahrt wird über den Zündungs-/ACC- -# Status des FMM003 erkannt (on = Fahrt läuft), nicht mehr über die WLAN- -# Verbindung des iPhones zum Fahrzeug (siehe fahrterkennung.py). -# -# Leer im Auslieferstand, wie alle Felder hier. Bis 2026-08-23 stand hier die -# Entity-ID einer längst abgeräumten Testinstanz -# ("binary_sensor.testzone_fmm003_..."). Auf einer frischen Installation -# zeigte sie ins Leere - und richtete dabei mehr an, als nur nutzlos zu sein: -# fahrterkennung.py registriert seinen @state_trigger nur, WENN dieses Feld -# belegt ist ("if einstellungen.ZUENDUNG_SENSOR:", ausdrücklich als Schutz -# gegen eine leere Entity-ID gebaut). Ein gesetzter, aber nicht existierender -# Wert hebelt genau diesen Schutz aus. Dasselbe galt für BATTERIE_SENSOR -# weiter unten. Zuordnung gehört ins Setup-Menü, nicht in den Auslieferstand. -ZUENDUNG_SENSOR = "" - -# Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und -# Ölwechsel-Prognose. -# -# Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand -# (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Streckenrechnung -# statt auf dem Tacho und damit auf einer anderen Zählbasis als der echte -# Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle drei genannten -# Auswertungen mit einem inkonsistenten Basiswert. Der vom CAN gelesene Wert -# (*_total_vehicle_mileage_read_from_can) bzw. der Kilometerstand der -# EU-Data-Act-Integration ist der richtige. -KM_SENSOR = "" - -# Tankfüllstand (Prozent) - keine Quelle mehr vorhanden (der FMM003 ist kein -# Tankgeber). -TANK_SENSOR = "" - -# Reichweite (§5.1 Übersicht) - keine Quelle mehr vorhanden. -RANGE_SENSOR = "" - -# 12V-Batteriespannung (Mein Audi -> Zustand). Beim FMM003 ist das -# external_power_voltage - die vom Gerät gemessene Bordnetzspannung des -# Fahrzeugs -, NICHT battery_voltage (das ist die interne Pufferbatterie des -# Trackers selbst und hat mit der Fahrzeugbatterie nichts zu tun). -# Leer im Auslieferstand, siehe ZUENDUNG_SENSOR oben. -BATTERIE_SENSOR = "" - -# Knopf für eine sofortige Neuabfrage beim Fahrzeug - kam aus der -# TommiG1-Integration, keine Entsprechung beim FMM003 vorhanden. -REFRESH_BUTTON = "" - -# Türen (§4.1) - keine Quelle mehr vorhanden. -TUER_SENSOREN = [] - -# Fenster (§4.1) - keine Quelle mehr vorhanden. -FENSTER_SENSOREN = [] - -# Heckklappe und Motorhaube - keine Quelle mehr vorhanden. -HECKKLAPPE_SENSOR = "" -HAUBE_SENSOR = "" - -# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene, -# aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden. -NAECHSTER_OELWECHSEL_SENSOR = "" -OELWECHSEL_STRECKE_SENSOR = "" -NAECHSTE_INSPEKTION_SENSOR = "" -INSPEKTION_STRECKE_SENSOR = "" - -# Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Breiten-/ -# Längengrad als zwei eigene sensor-Entities (flespi liefert Koordinaten so, -# nicht als Attribute einer device_tracker-Entity - siehe _standort() in -# frontend_veroeffentlichung.py). -STANDORT_LAT_SENSOR = "" -STANDORT_LON_SENSOR = "" - -# Update-Funktion (Einstellungen -> "Update suchen", siehe -# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird - -# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits -# verwendet. Leer lassen, solange es keins gibt - "Update suchen" meldet dann -# nur "keine Update-Quelle eingerichtet", ohne etwas zu tun. -UPDATE_REPO_URL = "" -UPDATE_BRANCH = "main" diff --git a/homeassistant/pyscript/modules/entitaeten.py b/homeassistant/pyscript/modules/entitaeten.py deleted file mode 100644 index 23bc62d..0000000 --- a/homeassistant/pyscript/modules/entitaeten.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug -einrichten -> Setup). - -Ersetzt NICHT das manuelle Eintragen in einstellungen.py (dessen Kommentare -sind die eigentliche Dokumentation der Integration und bleiben unangetastet), -sondern legt eine zusätzliche, zur Laufzeit angewendete Override-Ebene -darüber: data/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü -geänderten Zuordnungen, keyed nach den einstellungen.py-Variablennamen. - -Warum das funktioniert, ohne jede Verbraucher-Datei anzufassen: Alle -bestehenden Leser (frontend_veroeffentlichung.py, fahrterkennung.py, ...) -machen `import einstellungen` und lesen dann `einstellungen.KM_SENSOR` - -ein lebendiger Attributzugriff auf das Modul-Objekt, kein `from einstellungen -import KM_SENSOR` (das würde den Wert beim Import einfrieren). overrides_ -anwenden() unten nutzt genau das: setattr(einstellungen, key, wert) ändert -das Attribut auf dem bereits importierten Modul-Objekt, jeder spätere -Zugriff `einstellungen.KM_SENSOR` liefert sofort den neuen Wert. - -Eine Ausnahme (siehe TRIGGER_GEBUNDEN unten): mehrere @state_trigger(f"...")- -Dekoratoren backen ZUENDUNG_SENSOR/KM_SENSOR/TANK_SENSOR einmalig beim -Modul-Laden in die Trigger-Registrierung ein. Eine Änderung dieser drei -Felder wirkt für die Fahrterkennung selbst erst nach einem Neustart von -Home Assistant (bzw. pyscript.reload) - das Setup-Menü weist deshalb bei -diesen drei Feldern ausdrücklich darauf hin, statt automatisch und riskant -einen Selbst-Reload aus einer laufenden pyscript-Funktion heraus zu -versuchen. -""" - -import io -import json -import os - -import einstellungen - -BASIS = "/config/audi_dashboard" -OVERRIDES_PFAD = f"{BASIS}/entitaeten.json" - -# Vier gleich aufgebaute Positionen, wie schon in frontend_veroeffentlichung.py -# (dort POSITIONEN genannt) - hier dupliziert statt importiert, damit -# entitaeten.py nicht von frontend_veroeffentlichung.py abhängt (wäre die -# falsche Richtung: die Veröffentlichung importiert diese Datei, nicht -# umgekehrt). -POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"] - -# Katalog aller von der App genutzten Entity-Rollen, fürs Setup-Menü im -# Frontend. Reihenfolge = Anzeige-Reihenfolge innerhalb jeder Gruppe. -# domains/device_classes/units sind Erwartungen fürs Frontend-seitige -# Filtern+Vorschlagen (Schalter "Nur passende Sensoren anzeigen") - eine -# fehlende oder leere Liste bedeutet "keine Einschränkung" bzw. "diese Rolle -# hat üblicherweise keine Einheit" (binary_sensor/button/device_tracker). -FELDER = [ - {"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung", - "hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende (ersetzt die frühere WLAN-Erkennung).", - "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True, - "stichworte": ["zündung", "ignition", "acc", "motor", "engine"]}, - {"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung", - "hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.", - "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, - "stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]}, - {"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung", - "hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.", - "domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False, - "stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]}, - {"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht", - "hinweis": "Für die Übersicht - bleibt leer, zeigt die Oberfläche \"unbekannt\".", - "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, - "stichworte": ["reichweite", "range"]}, - {"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht", - "hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".", - "domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False, - "stichworte": ["batterie", "battery", "spannung", "volt", "12v"]}, - {"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht", - "hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".", - "domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False, - "stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]}, - {"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit", - "hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.", - "domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN, - "pflicht": False, "stichworte": ["tür", "door"]}, - {"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit", - "hinweis": "\"aus\"/off = zu.", - "domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN, - "pflicht": False, "stichworte": ["fenster", "window"]}, - {"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit", - "hinweis": "\"aus\"/off = zu.", - "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, - "stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]}, - {"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit", - "hinweis": "\"aus\"/off = zu.", - "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, - "stichworte": ["haube", "hood", "bonnet", "motorhaube"]}, - {"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht", - "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.", - "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, - "stichworte": ["ölwechsel", "oil", "service", "change"]}, - {"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht", - "hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", - "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, - "stichworte": ["ölwechsel", "oil", "distance", "strecke"]}, - {"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht", - "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.", - "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, - "stichworte": ["inspektion", "inspection", "service"]}, - {"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht", - "hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.", - "domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False, - "stichworte": ["inspektion", "inspection", "distance", "strecke"]}, - {"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort", - "hinweis": "Breitengrad als eigener Sensor (z. B. flespi).", - "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, - "stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]}, - {"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort", - "hinweis": "Längengrad als eigener Sensor (z. B. flespi).", - "domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, - "stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]}, -] - -# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py, -# fahrtabschluss.py, tankerkennung.py, reifenzaehler.py) backen diese drei -# Felder beim Modul-Laden ein (siehe Kopfkommentar) - im Setup-Menü als -# "wirkt erst nach Neustart"-Hinweis markiert. -_TRIGGER_GEBUNDEN = {"ZUENDUNG_SENSOR", "KM_SENSOR", "TANK_SENSOR"} -for _feld in FELDER: - _feld["trigger_gebunden"] = _feld["key"] in _TRIGGER_GEBUNDEN - -_SCHLUESSEL = {feld["key"] for feld in FELDER} - -# Schnappschuss der eingebauten Standardwerte aus einstellungen.py, genommen -# BEVOR overrides_anwenden() jemals lief (Modul-Ebene, läuft beim `import -# entitaeten` in frontend_api.py - vor dem @time_trigger("startup"), der -# overrides_anwenden() erstmals aufruft). Notwendig fürs Setup-Menü -# "Zurücksetzen": setattr() in overrides_anwenden() überschreibt das Attribut -# auf dem laufenden einstellungen-Modul dauerhaft, ein einfaches Weglassen -# des Overrides beim nächsten Speichern stellt den eingebauten Wert NICHT -# wieder her (das Attribut bleibt ja gesetzt) - "Zurücksetzen" muss den -# Standardwert stattdessen aktiv zurückschreiben. -_STANDARDWERTE = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER} - - -# --------------------------------------------------------------- JSON-I/O --- -# Gleiches Muster wie profil.profil_lesen()/profil_schreiben(): io.open nur -# über task.executor (pyscript-Einschränkung, siehe Kopfkommentar dort), -# atomares Schreiben über eine .tmp-Datei + os.replace. - -def overrides_lesen(): - """Liest die gespeicherten Zuordnungen, oder {} wenn die Datei fehlt bzw. - beschädigt ist. - - Der try/except ist nicht Vorsicht um ihrer selbst willen: overrides_ - anwenden() läuft in beim_start() VOR alles_veroeffentlichen(). Ohne die - Absicherung reißt eine einzige unlesbare Zeile in dieser Datei den - gesamten Startvorgang mit - das Panel bliebe komplett leer, ohne dass - irgendetwas auf die Ursache hindeutet.""" - if not os.path.exists(OVERRIDES_PFAD): - return {} - f = task.executor(io.open, OVERRIDES_PFAD, "r") - inhalt = f.read() - f.close() - if not inhalt.strip(): - return {} - try: - gelesen = json.loads(inhalt) - except ValueError as fehler: - log.error( - f"audi_dashboard: {OVERRIDES_PFAD} ist kein gültiges JSON ({fehler}). " - "Die eingebauten Standardwerte gelten weiter; die Zuordnung lässt sich " - "im Setup-Menü neu speichern." - ) - return {} - if not isinstance(gelesen, dict): - log.error(f"audi_dashboard: {OVERRIDES_PFAD} enthält kein Objekt - wird ignoriert.") - return {} - return gelesen - - -def overrides_schreiben(mapping): - """Schreibt nur bekannte Schlüssel (siehe FELDER); unbekannte werden - stillschweigend verworfen, damit ein späterer Katalog-Umbau keine - Datenleichen in entitaeten.json hinterlässt.""" - bereinigt = {k: v for k, v in mapping.items() if k in _SCHLUESSEL} - os.makedirs(BASIS, exist_ok=True) - tmp = OVERRIDES_PFAD + ".tmp" - text = json.dumps(bereinigt, ensure_ascii=False, indent=2) - f = task.executor(io.open, tmp, "w") - f.write(text) - f.close() - os.replace(tmp, OVERRIDES_PFAD) - - -# ------------------------------------------------------- Anwenden/Lesen --- - -def _ist_leer(wert): - """Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge - enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...], - und das ist inhaltlich dasselbe wie gar keine Liste. - - Die Schleife steht hier bewusst statt eines any(...)-Generatorausdrucks: - pyscript kennt Generatorausdrücke nicht und wirft zur Laufzeit - "not implemented ast ast_generatorexp". Mengen-, Listen- und - Dict-Comprehensions funktionieren dagegen (siehe _SCHLUESSEL oben).""" - if wert in (None, ""): - return True - if isinstance(wert, list): - for eintrag in wert: - if eintrag: - return False - return True - return False - - -def overrides_anwenden(): - """Setzt für JEDES bekannte Feld den wirksamen Wert auf dem laufenden - einstellungen-Modul - den Override, wenn einer hinterlegt ist, sonst den - eingebauten Standardwert. - - Wichtig ist das "jedes": Eine frühere Fassung übersprang leere Werte und - setzte nur die belegten. Weil setattr() das Modul-Attribut aber dauerhaft - verändert, blieb ein einmal gesetzter Wert danach für immer stehen - - "Zurücksetzen" im Setup-Menü hatte bei allen Feldern mit leerem - Standardwert (15 von 17) schlicht keine Wirkung, und die Oberfläche zeigte - beim nächsten Zeichnen wieder den alten Wert, als sei das Speichern - fehlgeschlagen. Umgekehrt wurde eine Liste aus leeren Einträgen gesetzt - statt übersprungen, was den Sicherheitscheck mit zwölf "unbekannt"-Zeilen - füllte und "Sicher abgestellt" dauerhaft grau werden ließ. - - Jedes Feld bei jedem Durchlauf zu schreiben ist damit nicht nur korrekt, - sondern auch idempotent: der Zustand hängt allein an entitaeten.json, - nicht an der Reihenfolge vorheriger Aufrufe.""" - overrides = overrides_lesen() - for key in _SCHLUESSEL: - wert = overrides.get(key) - setattr(einstellungen, key, _STANDARDWERTE[key] if _ist_leer(wert) else wert) - - -def aktueller_stand(): - """Katalog + aktuell wirksamer Wert je Feld (aus dem einstellungen-Modul - selbst gelesen, nicht direkt aus der JSON-Datei) - zeigt damit sowohl - bereits angewendete Overrides als auch die eingebauten Standardwerte - einheitlich. Grundlage für pyscript.audi_dashboard_entitaeten.""" - werte = {feld["key"]: getattr(einstellungen, feld["key"], None) for feld in FELDER} - return {"katalog": FELDER, "werte": werte, "standardwerte": _STANDARDWERTE} diff --git a/homeassistant/pyscript/modules/fahrtabschluss_logik.py b/homeassistant/pyscript/modules/fahrtabschluss_logik.py deleted file mode 100644 index 14f0648..0000000 --- a/homeassistant/pyscript/modules/fahrtabschluss_logik.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Screening-Logik für den zweistufigen Fahrtabschluss (Bauauftrag §7.2). - -Importierbar aus anderen pyscript-Dateien mit `import fahrtabschluss_logik`. - -Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern -teils erst mit Beginn oder während der nächsten Fahrt (§10 Punkt 4). Statt auf -einen festen Zeitpunkt zu warten, wird deshalb die Recorder-Historie des -Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am -nächsten am Verbindungsabbruch liegt. - -Zugriff auf die Historie läuft über die HA-REST-API (/api/history/period), -nicht über einen internen pyscript-Mechanismus - dafür gibt es in der -pyscript-Referenz keinen dokumentierten Weg. Das erfordert ein Long-Lived -Access Token, siehe README. - -WICHTIGER VORBEHALT: Der GPS-Fallback aus §7.2 (km_quelle="gps") ist hier noch -NICHT umgesetzt, da die Erfassung von Start-/Endposition (device_tracker, -Adressauflösung) ein eigenes Arbeitspaket ist. Fahrten ohne passenden -Kilometerstand bleiben aktuell dauerhaft "offen" statt auf GPS-Distanz -auszuweichen. Siehe die Restarbeiten im Abschlussbericht. -""" - -import datetime -import io -import json -import urllib.parse -import urllib.request - -import frontend_veroeffentlichung -import profil - -BASIS = "/config/audi_dashboard" -TOKEN_PFAD = f"{BASIS}/ha_token.txt" -HA_BASIS_URL = "http://localhost:8123" - -# Wie weit vor/zurück in der Historie gesucht wird, ausgehend vom -# Verbindungsabbruch. 48h deckt auch länger stehende Fahrzeuge ab, ohne bei -# jedem Screening die komplette Historie zu laden. -SUCHFENSTER_STUNDEN = 48 - - -# ---------------------------------------------------------- HA-REST-Zugriff - -def _token_lesen(): - # io.open über task.executor statt bloßem open() - siehe Kopfkommentar - # in profil.py. - f = task.executor(io.open, TOKEN_PFAD, "r") - inhalt = f.read() - f.close() - return inhalt.strip() - - -def historie_abfragen(entity_id, start_iso, end_iso): - """Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab. - - Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand - rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant - anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und - abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to - putrequest ... inside the event loop"). Seitdem schlug jedes Screening - fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur - als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt. - - task.executor braucht echte externe Funktionen; urllib.request.urlopen und - die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei - io.open in profil.py).""" - token = _token_lesen() - start_enc = urllib.parse.quote(start_iso) - end_enc = urllib.parse.quote(end_iso) - url = ( - f"{HA_BASIS_URL}/api/history/period/{start_enc}" - f"?filter_entity_id={entity_id}&end_time={end_enc}" - ) - req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) - resp = task.executor(urllib.request.urlopen, req, timeout=10) - rohdaten = task.executor(resp.read) - resp.close() - daten = json.loads(rohdaten.decode("utf-8")) - return daten[0] if daten else [] - - -# ------------------------------------------------------------- Kernlogik - -def naechster_wert(zielzeit, punkte): - """Wert aus der Historie, dessen Zeitstempel am nächsten an zielzeit - liegt. Gibt (wert, zeitdifferenz_s) zurück, oder (None, None). Öffentlich, - weil auch belegverarbeitung.py sie für den Tankfüllstand nutzt.""" - beste_wert = None - beste_diff = None - for p in punkte: - try: - wert = float(p["state"]) - except (ValueError, TypeError, KeyError): - continue - try: - zeit = datetime.datetime.fromisoformat( - p["last_changed"].replace("Z", "+00:00") - ) - except (ValueError, KeyError): - continue - diff = abs((zeit - zielzeit).total_seconds()) - if beste_diff is None or diff < beste_diff: - beste_wert = wert - beste_diff = diff - return beste_wert, beste_diff - - -def _zeitpunkt_screenen(km_sensor, zielzeit): - start = (zielzeit - datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat() - ende = (zielzeit + datetime.timedelta(hours=SUCHFENSTER_STUNDEN)).isoformat() - try: - punkte = historie_abfragen(km_sensor, start, ende) - except Exception as exc: - # Fehlt z. B. der Token (INSTALL.md Schritt 5) oder ist die API kurz - # nicht erreichbar, bleibt die Fahrt einfach offen statt den ganzen - # Screening-Lauf für alle anderen offenen Fahrten abzubrechen. - log.warning(f"audi_dashboard: Kilometerstand-Screening fehlgeschlagen: {exc}") - return None - wert, _ = naechster_wert(zielzeit, punkte) - return wert - - -def screening_durchfuehren(km_sensor): - """Versucht, offene Fahrten mit Start-/Endkilometer zu vervollständigen. - Wird sowohl nach Ablauf der Pausenzeit (§7.1) als auch bei jeder Änderung - des Kilometerstand-Sensors aufgerufen.""" - fahrten = profil.fahrten_lesen() - offene = [f for f in fahrten if f.get("status") == "offen"] - if not offene: - return - - offene.sort(key=lambda f: f.get("ts_start", "")) - - for fahrt in offene: - geaendert = False - - if fahrt.get("odo_end") is None: - ts_end = datetime.datetime.fromisoformat(fahrt["ts_end"]) - wert = _zeitpunkt_screenen(km_sensor, ts_end) - if wert is not None: - fahrt["odo_end"] = wert - geaendert = True - - if fahrt.get("odo_start") is None: - ts_start = datetime.datetime.fromisoformat(fahrt["ts_start"]) - wert = _zeitpunkt_screenen(km_sensor, ts_start) - if wert is not None: - fahrt["odo_start"] = wert - geaendert = True - - if geaendert: - aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")} - if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None: - aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1) - aenderungen["km_quelle"] = "odometer" - aenderungen["status"] = "vollständig" - profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) - - _lueckenlos_verketten() - frontend_veroeffentlichung.fahrten_veroeffentlichen() - - -def _lueckenlos_verketten(): - """Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren - wurde (§7.2). Zwischen zwei in der Liste direkt aufeinanderfolgenden - Fahrten steht das Fahrzeug nur - der Kilometerstand ändert sich dabei - nicht, unabhängig davon, wie lange die Standzeit dauert. Ein bestätigter - Wert wird deshalb an den direkten Nachbarn weitergereicht, auch wenn - dessen eigenes Screening noch keinen Treffer hatte. - - Annahme: die Fahrten-Liste enthält keine Lücken durch verpasste - Verbindungsereignisse (z. B. durch einen HA-Neustart mitten in einer - Fahrt). Das ist der Normalfall, aber keine Garantie - siehe - Abschlussbericht.""" - fahrten = sorted(profil.fahrten_lesen(), key=lambda f: f.get("ts_start", "")) - geaendert_ids = [] - - for i in range(len(fahrten) - 1): - aktuelle, naechste = fahrten[i], fahrten[i + 1] - - if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None: - naechste["odo_start"] = aktuelle["odo_end"] - geaendert_ids.append(naechste["trip_id"]) - elif naechste.get("odo_start") is not None and aktuelle.get("odo_end") is None: - aktuelle["odo_end"] = naechste["odo_start"] - geaendert_ids.append(aktuelle["trip_id"]) - - for fahrt in fahrten: - if fahrt["trip_id"] not in geaendert_ids: - continue - aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")} - if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None: - aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1) - aenderungen["km_quelle"] = "odometer" - aenderungen["status"] = "vollständig" - profil.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) diff --git a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py b/homeassistant/pyscript/modules/frontend_veroeffentlichung.py deleted file mode 100644 index d5640cd..0000000 --- a/homeassistant/pyscript/modules/frontend_veroeffentlichung.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Veröffentlicht Profil, Fahrten, Tankvorgänge und den Live-Fahrzeugstatus -als pyscript-Zustandsentitäten, damit das Frontend sie über das ganz normale -hass.states lesen kann - kein Service-Aufruf mit Rückgabewert nötig, dessen -genaues Verhalten in der Home-Assistant-Weboberfläche nicht durchgängig -dokumentiert ist (siehe Kopfkommentar in frontend_api.py). - -Importierbar mit `import frontend_veroeffentlichung`, aufgerufen sowohl -periodisch (frontend_api.py) als auch direkt nach Schreibaktionen aus -fahrterkennung.py, belegverarbeitung.py und reifenzaehler.py, damit sich die -Oberfläche unmittelbar aktualisiert statt bis zum nächsten Intervall zu -warten. - -BEKANNTE GRENZE: Zustands-Attribute in Home Assistant sind nicht für beliebig -große Datenmengen gedacht (~16 KB Richtwert für die Aufzeichnung in der -Recorder-Datenbank). Für das Profil und den aktuellen Stand ist das -unkritisch; die Fahrten- und Tankvorgänge-Archive könnten das nach einigen -Jahren überschreiten. Wird das relevant, müssten fahrten_veroeffentlichen()/ -tankvorgaenge_veroeffentlichen() auf einen Zeitraum eingeschränkt werden -(z. B. nur das laufende Jahr), statt hier vorschnell zu vereinfachen. -""" - -import io -import json -import os - -import einstellungen -import entitaeten -import profil - - -def profil_veroeffentlichen(): - daten = profil.profil_lesen() - if daten is None: - return - state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten}) - - -def fahrten_veroeffentlichen(): - state.set("pyscript.audi_dashboard_fahrten", "aktuell", new_attributes={"daten": profil.fahrten_lesen()}) - - -def tankvorgaenge_veroeffentlichen(): - state.set("pyscript.audi_dashboard_tankvorgaenge", "aktuell", new_attributes={"daten": profil.tankvorgaenge_lesen()}) - - -def batterieverlauf_veroeffentlichen(): - state.set("pyscript.audi_dashboard_batterieverlauf", "aktuell", new_attributes={"daten": profil.batterieverlauf_lesen()}) - - -def entitaeten_veroeffentlichen(): - """Katalog + aktuell wirksame Zuordnung fürs Setup-Menü (Einstellungen -> - Fahrzeug einrichten -> Setup) - siehe entitaeten.py.""" - state.set("pyscript.audi_dashboard_entitaeten", "aktuell", new_attributes={"daten": entitaeten.aktueller_stand()}) - - -def beleg_ergebnis_veroeffentlichen(daten): - """Ergebnis eines Beleg-Uploads (geparste Werte fürs Ausfüllen eines - Entwurfs, oder ein Fehler wie 'Falsches Belegdatum') fürs Frontend, nach - demselben Zustands-statt-Rückgabewert-Muster wie die übrigen - veroeffentlichen()-Funktionen hier (siehe Kopfkommentar).""" - state.set("pyscript.audi_dashboard_beleg_ergebnis", "aktuell", new_attributes={"daten": daten}) - - -def zustand_oder_none(entity_id): - """Sicherer Zustandszugriff: pyscripts state.get() wirft NameError, wenn - die Entity (noch) nicht existiert - real an einer Testinstanz beobachtet, - nicht nur vermutet. Genau das ist hier der Normalfall, solange manche der - in einstellungen.py hinterlegten Entity-IDs noch Platzhalter sind, und - bleibt auch danach relevant: fällt eine Datenquelle aus, soll die - Oberfläche das zeigen (§9), nicht an einem Fehler hängen bleiben.""" - if not entity_id: - return None - try: - wert = state.get(entity_id) - except NameError: - return None - if wert in (None, "unknown", "unavailable"): - return None - return wert - - -def _zu_zahl(wert): - try: - return float(wert) - except (TypeError, ValueError): - return None - - -def _abs_zahl(wert): - zahl = _zu_zahl(wert) - return None if zahl is None else abs(zahl) - - -def _zu_bool(wert): - """binary_sensor-Zustand als echtes True/False, None bei fehlender Meldung. - - None ist hier ausdrücklich kein "nein": ohne zugeordneten Sensor weiß die - App schlicht nicht, ob gefahren wird - und muss das anzeigen dürfen (§9), - statt "steht" zu behaupten.""" - if wert is None: - return None - return str(wert).lower() in ("on", "true", "1", "open", "yes") - - -def _standort(): - """Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest - STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei eigene sensor-Entities für - Breiten-/Längengrad (flespi liefert Koordinaten so, nicht als Attribute - einer einzelnen device_tracker-Entity). Fehlt eine der beiden Entity-IDs - oder ist der Zustand (noch) nicht verfügbar, liefert diese Funktion - durchgehend None statt eines geratenen Werts - genau das - zustand_oder_none()-Muster von oben, hier als eigene Funktion, weil - zusätzlich zum Zustand noch der Aktualisierungszeitpunkt gebraucht wird: - state.get() liefert dafür ein StateVal (str-Unterklasse) mit - .last_updated als datetime-Attribut - kein eigenständiges - state.last_updated(), das gibt es in pyscript nicht.""" - leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None} - - lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR - if not lat_id or not lon_id: - return leer - try: - lat_wert = state.get(lat_id) - lon_wert = state.get(lon_id) - except NameError: - return leer - lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert) - if lat is None or lon is None: - return leer - zeit = None - try: - zeit = lat_wert.last_updated.isoformat() - except AttributeError: - pass - return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit} - - -POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"] - - -def _sicherheitscheck(): - """Liste der einzeln geprüften Punkte hinter "Sicher abgestellt" (§9), - fürs Frontend (Klick auf den Status öffnet diese Liste mit einem grünen/ - roten/grauen Punkt je Zeile). "ok" ist None, wenn der Sensor fehlt oder - nicht verfügbar ist - genau daraus leitet sich auch die zusammengefasste - gesichert-Kennzahl unten ab, damit beide nie auseinanderlaufen können.""" - eintraege = [] - for pos, sensor in zip(POSITIONEN, einstellungen.TUER_SENSOREN): - w = zustand_oder_none(sensor) - eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"}) - for pos, sensor in zip(POSITIONEN, einstellungen.FENSTER_SENSOREN): - w = zustand_oder_none(sensor) - eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"}) - for label, sensor in [ - ("Heckklappe", einstellungen.HECKKLAPPE_SENSOR), - ("Motorhaube", einstellungen.HAUBE_SENSOR), - ]: - w = zustand_oder_none(sensor) - eintraege.append({"label": label, "ok": None if w is None else w == "off"}) - return eintraege - - -def fahrzeugstatus_veroeffentlichen(): - """Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte - Frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss. - """ - km = zustand_oder_none(einstellungen.KM_SENSOR) - tankprozent = zustand_oder_none(einstellungen.TANK_SENSOR) - reichweite = zustand_oder_none(einstellungen.RANGE_SENSOR) - batteriespannung = zustand_oder_none(einstellungen.BATTERIE_SENSOR) - - # "Sicher abgestellt" (§9) - erst wenn WIRKLICH jeder einzeln geprüfte - # Punkt zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch - # nur eine Meldung, ist der Status unbekannt statt geraten. - sicherheitscheck = _sicherheitscheck() - if any([e["ok"] is None for e in sicherheitscheck]): - gesichert = None - else: - gesichert = all([e["ok"] for e in sicherheitscheck]) - - standort = _standort() - - daten = { - "km": _zu_zahl(km), - "tankprozent": _zu_zahl(tankprozent), - "reichweite_km": _zu_zahl(reichweite), - "batteriespannung": _zu_zahl(batteriespannung), - "gesichert": gesichert, - "sicherheitscheck": sicherheitscheck, - # Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die - # auch fahrterkennung.py als maßgebliches Signal für Fahrtbeginn/-ende - # nimmt - damit sagen Anzeige und Erfassung zwangsläufig dasselbe. - # - # Vorher wurde dieser Zustand im Frontend aus dem Status der Fahrten - # abgeleitet ("offen" = fährt). Das war falsch: "offen" heißt - # unvollständige Daten (Strecke fehlt noch, siehe - # fahrtabschluss_logik.py), nicht "unterwegs" - eine Fahrt ohne - # Kilometerstand blieb dauerhaft "offen" und das Fahrzeug damit - # dauerhaft "fahrend". None bedeutet: kein Zündungssensor zugeordnet, - # Zustand unbekannt. - "zuendung": _zu_bool(zustand_oder_none(einstellungen.ZUENDUNG_SENSOR)), - "standort_lat": standort["lat"], - "standort_lon": standort["lon"], - "standort_genauigkeit_m": standort["genauigkeit_m"], - "standort_zeit": standort["zeit"], - # Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die - # App-eigene Servicebuch-Berechnung, siehe fahrzeugMeldung() im - # Frontend) - die Streckensensoren liefern negative Restkilometer- - # Werte, hier deshalb der Betrag. - "oelwechsel_faellig_ts": zustand_oder_none(einstellungen.NAECHSTER_OELWECHSEL_SENSOR), - "oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.OELWECHSEL_STRECKE_SENSOR)), - "inspektion_faellig_ts": zustand_oder_none(einstellungen.NAECHSTE_INSPEKTION_SENSOR), - "inspektion_faellig_km": _abs_zahl(zustand_oder_none(einstellungen.INSPEKTION_STRECKE_SENSOR)), - } - state.set("pyscript.audi_dashboard_fahrzeugstatus", "aktuell", new_attributes={"daten": daten}) - - -APP_VERSION_PFAD = "/config/www/audi-dashboard-version.json" - - -def app_version_veroeffentlichen(): - """Welchen Oberflächen-Stand diese Installation ausliefert. - - Gedacht für die Companion-App: die trägt ihre Version fest einkompiliert - (vite setzt __APP_VERSION__ aus der Datei VERSION im Projektstamm) und - kann sie gegen diesen Wert halten. Weicht sie ab, ist die App älter als - der Server und sagt das selbst - siehe VERSIONIERUNG.md. - - Das Panel braucht den Vergleich nicht: es lädt seine Dateien bei jedem - Seitenaufruf frisch, sobald sich die Cache-Zahl in derselben Datei - geändert hat, und kann deshalb gar nicht veralten. - - Bewusst eine eigene Entität statt eines Felds im Fahrzeugstatus: das hier - ist eine Eigenschaft der Installation, keine des Fahrzeugs.""" - version = None - if os.path.exists(APP_VERSION_PFAD): - try: - f = task.executor(io.open, APP_VERSION_PFAD, "r") - inhalt = f.read() - f.close() - version = json.loads(inhalt).get("app") - except (ValueError, OSError) as fehler: - log.warning(f"audi_dashboard: App-Version nicht lesbar ({fehler})") - state.set( - "pyscript.audi_dashboard_app_version", - version or "unbekannt", - new_attributes={"daten": {"app": version}}, - ) - - -def alles_veroeffentlichen(): - app_version_veroeffentlichen() - profil_veroeffentlichen() - fahrten_veroeffentlichen() - tankvorgaenge_veroeffentlichen() - fahrzeugstatus_veroeffentlichen() - batterieverlauf_veroeffentlichen() - entitaeten_veroeffentlichen() diff --git a/homeassistant/pyscript/modules/profil.py b/homeassistant/pyscript/modules/profil.py deleted file mode 100644 index 9cc7c18..0000000 --- a/homeassistant/pyscript/modules/profil.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Datenzugriff für Fahrzeugprofil, Fahrten und Tankvorgänge (Bauauftrag §6). - -Importierbar aus anderen pyscript-Dateien mit `import profil`. - -Drei getrennte Bestände, wie in §6.1 festgelegt: - - Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische - - Fahrten: JSON Lines, eine Zeile je Fahrt - - Tankvorgänge: JSON Lines - -Drei Eigenheiten von pyscript, alle an einer echten Testinstanz beobachtet, -nicht nur aus der Dokumentation übernommen: - -1. Das eingebaute `open()` existiert in pyscript nicht (NameError) - aus - Sicherheitsgründen bewusst nicht freigegeben. Der funktionierende Weg ist - `task.executor(io.open, pfad, modus)`: `io.open` ist eine echte externe - Funktion aus der Standardbibliothek, kein im pyscript-Ordner selbst - definierter Code. -2. task.executor() akzeptiert generell nur solche echten externen Funktionen - - eigene, im pyscript-Ordner definierte Funktionen weist es mit "pyscript - functions can't be called from task.executor" zurück. Deshalb hier nur - `io.open` selbst über den Executor, Lesen/Schreiben/Schließen auf dem - zurückgegebenen Datei-Objekt direkt (das ist kein bare-name-Aufruf mehr, - sondern ein Methodenaufruf auf einem bereits vorhandenen Objekt). -3. Variablen, die innerhalb eines `with ... as f:`-Blocks zugewiesen werden, - waren danach außerhalb nicht mehr auffindbar (NameError), deshalb kein - `with` - offen/lesen/schließen nacheinander. -""" - -import io -import json -import os -import uuid - -BASIS = "/config/audi_dashboard" -PROFIL_PFAD = f"{BASIS}/fahrzeugprofil.json" -FAHRTEN_PFAD = f"{BASIS}/fahrten.jsonl" -TANKVORGAENGE_PFAD = f"{BASIS}/tankvorgaenge.jsonl" -BATTERIEVERLAUF_PFAD = f"{BASIS}/batteriespannung.jsonl" -BELEGE_ORDNER = f"{BASIS}/belege" - - -# ---------------------------------------------------------------- Ordner --- - -def ordner_sicherstellen(): - os.makedirs(BASIS, exist_ok=True) - os.makedirs(BELEGE_ORDNER, exist_ok=True) - - -# ----------------------------------------------------------- Fahrzeugprofil - -def profil_lesen(): - """Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist. - - Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig, - siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das - Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder - Aufrufer muss den None-Fall abfangen.""" - if not os.path.exists(PROFIL_PFAD): - log.error( - f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — " - "bis dahin bleiben alle Funktionen aus, die das Profil brauchen." - ) - return None - f = task.executor(io.open, PROFIL_PFAD, "r") - inhalt = f.read() - f.close() - try: - return json.loads(inhalt) - except ValueError as fehler: - log.error( - f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). " - "Letztes Backup aus audi_dashboard/backups/ zurückspielen." - ) - return None - - -def profil_schreiben(profil): - tmp = PROFIL_PFAD + ".tmp" - text = json.dumps(profil, ensure_ascii=False, indent=2) - f = task.executor(io.open, tmp, "w") - f.write(text) - f.close() - os.replace(tmp, PROFIL_PFAD) - - -# --------------------------------------------------------- JSON-Lines-Basis - -def _zeilen_lesen(pfad): - if not os.path.exists(pfad): - return [] - f = task.executor(io.open, pfad, "r") - inhalt = f.read() - f.close() - datensaetze = [] - for zeile in inhalt.splitlines(): - zeile = zeile.strip() - if zeile: - datensaetze.append(json.loads(zeile)) - return datensaetze - - -def _zeilen_schreiben(pfad, datensaetze): - tmp = pfad + ".tmp" - zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze] - text = "\n".join(zeilen) - if zeilen: - text += "\n" - f = task.executor(io.open, tmp, "w") - f.write(text) - f.close() - os.replace(tmp, pfad) - - -def _zeile_anhaengen(pfad, datensatz): - f = task.executor(io.open, pfad, "a") - f.write(json.dumps(datensatz, ensure_ascii=False) + "\n") - f.close() - - -# ------------------------------------------------------------------ Fahrten - -def fahrten_lesen(): - return _zeilen_lesen(FAHRTEN_PFAD) - - -def fahrten_schreiben(fahrten): - _zeilen_schreiben(FAHRTEN_PFAD, fahrten) - - -def fahrt_anhaengen(fahrt): - _zeile_anhaengen(FAHRTEN_PFAD, fahrt) - - -def offene_fahrten(): - """Alle Fahrten mit status == 'offen', ältere zuerst.""" - fahrten = fahrten_lesen() - offen = [f for f in fahrten if f.get("status") == "offen"] - return sorted(offen, key=lambda f: f.get("ts_start", "")) - - -def _datensatz_aktualisieren(zeilen, id_feld, id_wert, aenderungen, schreiben, schutz=True): - """Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID und - schreibt den gesamten Bestand neu. Manuell geänderte Felder - (edited_fields, analog zu §7.7 Regel 3) werden dabei nie überschrieben. - Gemeinsame Grundlage für Fahrten und Tankvorgänge - beide Archive - funktionieren nach demselben Muster. - - schutz=False hebt genau diese Sperre auf - nötig für Eingaben aus der - Oberfläche: edited_fields schützt gegen die automatische Ergänzung, nicht - gegen den Menschen, der das Feld gerade selbst korrigiert.""" - geaendert = False - for d in zeilen: - if d.get(id_feld) == id_wert: - geschuetzt = set(d.get("edited_fields", [])) if schutz else set() - for feld, wert in aenderungen.items(): - if feld not in geschuetzt: - d[feld] = wert - geaendert = True - break - if geaendert: - schreiben(zeilen) - return geaendert - - -def fahrt_aktualisieren(trip_id, aenderungen): - return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben) - - -def fahrt_bearbeiten(trip_id, aenderungen): - """Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche: eine - von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld schon einmal - von Hand gesetzt wurde (siehe schutz-Parameter oben).""" - return _datensatz_aktualisieren(fahrten_lesen(), "trip_id", trip_id, aenderungen, fahrten_schreiben, schutz=False) - - -def fahrt_loeschen(trip_id): - fahrten = fahrten_lesen() - uebrig = [f for f in fahrten if f.get("trip_id") != trip_id] - if len(uebrig) == len(fahrten): - return False - fahrten_schreiben(uebrig) - return True - - -# ------------------------------------------------------------ Tankvorgänge - -def tankvorgaenge_lesen(): - return _zeilen_lesen(TANKVORGAENGE_PFAD) - - -def tankvorgaenge_schreiben(tankvorgaenge): - _zeilen_schreiben(TANKVORGAENGE_PFAD, tankvorgaenge) - - -def tankvorgang_anhaengen(tankvorgang): - _zeile_anhaengen(TANKVORGAENGE_PFAD, tankvorgang) - - -def tankvorgang_aktualisieren(tank_id, aenderungen): - return _datensatz_aktualisieren(tankvorgaenge_lesen(), "tank_id", tank_id, aenderungen, tankvorgaenge_schreiben) - - -def tankvorgang_loeschen(tank_id): - tankvorgaenge = tankvorgaenge_lesen() - uebrig = [t for t in tankvorgaenge if t.get("tank_id") != tank_id] - if len(uebrig) == len(tankvorgaenge): - return False - tankvorgaenge_schreiben(uebrig) - return True - - -def letzter_tankvorgang(): - """Der zeitlich jüngste bereits erfasste Tankvorgang (nach ts), oder None, - falls noch keiner existiert. Grundlage für die Gefahrene-Distanz-Berechnung - beim Anlegen eines neuen Tankvorgangs (§5.5).""" - tankvorgaenge = tankvorgaenge_lesen() - if not tankvorgaenge: - return None - return max(tankvorgaenge, key=lambda t: t.get("ts") or "") - - -def distanz_seit_letzter_tankung(aktueller_km): - """Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag für - das gleichnamige Formularfeld (§5.5) - frei überschreibbar, genau wie - odometer_km selbst. None, wenn kein Kilometerstand oder kein vorheriger - Tankvorgang vorliegt (erster Eintrag überhaupt). Gemeinsame Grundlage für - Beleg-Erfassung (belegverarbeitung.py) und automatische Tankerkennung - (tankerkennung.py).""" - if aktueller_km is None: - return None - letzter = letzter_tankvorgang() - if not letzter or letzter.get("odometer_km") is None: - return None - return round(aktueller_km - letzter["odometer_km"], 1) - - -def tankvorgang_nach_id(tank_id): - for t in tankvorgaenge_lesen(): - if t.get("tank_id") == tank_id: - return t - return None - - -def tankvorgang_nach_receipt_key(receipt_key): - """Für §7.7 Regel: derselbe Beleg (receipt_key, minutengenau) erzeugt - keinen zweiten Datensatz.""" - for t in tankvorgaenge_lesen(): - if t.get("receipt_key") == receipt_key: - return t - return None - - -# ------------------------------------------------------- Batteriespannung - -def batterieverlauf_lesen(): - """Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste - zuerst - siehe batterieverlauf.py für die Aufzeichnungslogik. min_ts/ - max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen Einzelmessung, für - die Datum/Uhrzeit-Anzeige beim Antippen des Diagrammpunkts im Frontend - - der Punkt selbst zeigt nur den Minimalwert (siehe dortiger Kommentar, - warum der aussagekräftig für die Entladung ist).""" - return _zeilen_lesen(BATTERIEVERLAUF_PFAD) - - -def batterieverlauf_tageswert_aktualisieren(datum, ts, spannung): - """Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt ihn - beim ersten Wert des Tages an, erweitert sonst nur min/max samt dem - Zeitstempel der jeweils neuen Extremmessung. Das Fahrzeug meldet die - Spannung künftig mehrfach pro Stunde (aktive Fahrt) statt nur einmal - täglich - der komplette Bestand wird deshalb bei jeder Messung neu - geschrieben (wie bei den übrigen JSON-Lines-Beständen hier), was bei - einem Eintrag pro Tag über Jahre hinweg unproblematisch bleibt.""" - verlauf = batterieverlauf_lesen() - for eintrag in verlauf: - if eintrag.get("datum") == datum: - if spannung < eintrag["min"]: - eintrag["min"] = spannung - eintrag["min_ts"] = ts - if spannung > eintrag["max"]: - eintrag["max"] = spannung - eintrag["max_ts"] = ts - break - else: - verlauf.append({"datum": datum, "min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}) - # Nach Datum sortiert schreiben, nicht in Einfügereihenfolge. Solange nur - # die Live-Aufzeichnung schrieb, war beides dasselbe (sie trägt immer den - # heutigen Tag ein, also stets den jüngsten). Der nachträgliche Import - # (historienimport.py) trägt dagegen vergangene Tage ein - ohne diese - # Zeile stünden sie hinter den neueren, und das Diagramm im Frontend, das - # die Datei in Dateireihenfolge zeichnet, liefe zeitlich rückwärts. - verlauf.sort(key=lambda e: e.get("datum") or "") - _zeilen_schreiben(BATTERIEVERLAUF_PFAD, verlauf) - - -# --------------------------------------------------------------------- IDs - -def neue_id(praefix): - return f"{praefix}-{uuid.uuid4().hex[:12]}" diff --git a/homeassistant/pyscript/reifenzaehler.py b/homeassistant/pyscript/reifenzaehler.py deleted file mode 100644 index 3ca799a..0000000 --- a/homeassistant/pyscript/reifenzaehler.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Reifenzähler ohne utility_meter (§7.5, §10 Punkt 6). - -ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem -ursprünglichen Bau nachträglich verlangt): früher war der Zähler = aktueller -Kilometerstand - fester Startwert je Satz (reifen.saetze.. -start_odo_km). Das war falsch, sobald einmal zwischen Sommer und Winter -umgeschaltet wurde: die Differenz zum Startwert zählt dann alle seither -gefahrenen Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt gilt -stattdessen: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum -Zeitpunkt der Fahrt aktiv war. Dafür führt reifen.saetze..km selbst den -fortlaufenden Gesamtstand (statt eines Startwerts), und reifen. -referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest, gegen den -die nächste Änderung als Delta gemessen wird. start_odo_km/§13 entfällt -damit vollständig. - -Ergebnis wird zusätzlich als eigene pyscript-Zustandsvariable bereitgestellt -(nur zur Kontrolle in Entwicklerwerkzeuge → Zustände, das Frontend liest die -Werte direkt aus dem veröffentlichten Profil): - pyscript.reifen_sommer_km, pyscript.reifen_winter_km, - pyscript.reifen_aktiver_satz - -Kein Abhängigkeit von total_increasing oder utility_meter-internem Zustand - -die Fortschreibung braucht nur den zuletzt bekannten Absolutwert des -Kilometerstand-Sensors. Ein zeitweise `unavailable` Quellsensor (§4.1) -verliert dadurch nichts: referenz_odo_km bleibt einfach stehen, bis wieder -ein gültiger Wert kommt, und das nächste Delta wird dann gegen diesen alten -Stand berechnet - auch km, die während eines HA-Neustarts/Ausfalls gefahren -wurden, werden so noch dem damals aktiven Satz zugerechnet. - -Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. -""" - -import einstellungen -import frontend_veroeffentlichung -import profil - - -def _aktueller_km(): - try: - return float(frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR)) - except (TypeError, ValueError): - return None - - -def _zustaende_veroeffentlichen(p): - reifen = p.get("reifen", {}) - saetze = reifen.get("saetze", {}) - for satz_name in ("sommer", "winter"): - km = saetze.get(satz_name, {}).get("km") - if km is None: - state.set(f"pyscript.reifen_{satz_name}_km", "unbekannt") - else: - state.set(f"pyscript.reifen_{satz_name}_km", km, new_attributes={"unit_of_measurement": "km"}) - state.set("pyscript.reifen_aktiver_satz", reifen.get("aktiv", "unbekannt")) - - -def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None): - """Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit - referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser - Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz - zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden.""" - p = profil.profil_lesen() - if p is None: - return - reifen = p.setdefault("reifen", {}) - saetze = reifen.setdefault("saetze", {}) - aktiv = reifen.get("aktiv", "sommer") - referenz = reifen.get("referenz_odo_km") - - if referenz is not None and neuer_km is not None: - delta = round(neuer_km - referenz, 1) - if delta > 0: - satz = saetze.setdefault(aktiv, {}) - satz["km"] = round((satz.get("km") or 0) + delta, 1) - if neuer_km is not None: - reifen["referenz_odo_km"] = neuer_km - if neuer_aktiv is not None: - reifen["aktiv"] = neuer_aktiv - - profil.profil_schreiben(p) - frontend_veroeffentlichung.profil_veroeffentlichen() - _zustaende_veroeffentlichen(p) - - -@time_trigger("startup") -def beim_start(): - _fortschreiben_und_veroeffentlichen(_aktueller_km()) - - -# Ohne konfigurierten Kilometerstand-Sensor keinen Trigger registrieren - -# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes -# Verhalten, siehe gleiches Muster in fahrterkennung.py. -if einstellungen.KM_SENSOR: - @state_trigger(f"{einstellungen.KM_SENSOR}") - def kilometerstand_geaendert(value=None): - _fortschreiben_und_veroeffentlichen(_aktueller_km()) - - -@service -def audi_dashboard_reifen_wechseln(satz=None): - """Umschalten des aktiven Reifensatzes (§5.2). Aufruf als - pyscript.audi_dashboard_reifen_wechseln mit satz="sommer"|"winter".""" - if satz not in ("sommer", "winter"): - log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'") - return - _fortschreiben_und_veroeffentlichen(_aktueller_km(), neuer_aktiv=satz) - log.info(f"audi_dashboard: aktiver Reifensatz auf '{satz}' umgeschaltet") - - -@service -def audi_dashboard_reifen_km_setzen(satz=None, km=None): - """Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes - (Editier-Symbol neben der km-Anzeige in der App). Überschreibt nur den - gespeicherten Zähler selbst - referenz_odo_km (der zuletzt verarbeitete - Fahrzeug-Kilometerstand) bleibt unverändert, damit die nächste - Fortschreibung (kilometerstand_geaendert oben) weiterhin korrekt von hier - aus weiterzählt, statt auf den neuen Wert einen erneuten Delta seit dem - alten Stand aufzuschlagen. configCarZuProfil() im Frontend schreibt km nie - selbst mit (siehe Kommentar dort) - genau deshalb braucht es diesen - eigenen Service statt eines einfachen Profil-Überschreibens.""" - if satz not in ("sommer", "winter"): - log.warning(f"audi_dashboard: ungültiger Reifensatz '{satz}'") - return - try: - km = round(float(km), 1) - except (TypeError, ValueError): - log.warning(f"audi_dashboard: ungültiger km-Wert '{km}'") - return - if km < 0: - log.warning(f"audi_dashboard: negativer km-Wert '{km}' abgelehnt") - return - p = profil.profil_lesen() - if p is None: - return - reifen = p.setdefault("reifen", {}) - saetze = reifen.setdefault("saetze", {}) - saetze.setdefault(satz, {})["km"] = km - profil.profil_schreiben(p) - frontend_veroeffentlichung.profil_veroeffentlichen() - _zustaende_veroeffentlichen(p) - log.info(f"audi_dashboard: km-Stand '{satz}' manuell auf {km} gesetzt") diff --git a/homeassistant/pyscript/tankerkennung.py b/homeassistant/pyscript/tankerkennung.py deleted file mode 100644 index cbd212d..0000000 --- a/homeassistant/pyscript/tankerkennung.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Automatische Tankerkennung über den Füllstandssensor (Erweiterung zu §7.7/§5.5, -nach dem ursprünglichen Bau nachträglich verlangt). - -Beobachtung des Nutzers: der Füllstand (TANK_SENSOR, in Prozent) springt beim -Fahren nie nach oben, er sinkt nur - jeder Anstieg ist also ein Tankvorgang. -Schwelle nach Nutzervorgabe: ab +5 Liter ODER +9 Prozentpunkten Anstieg gilt -als nachgetankt. Die 5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus -dem Fahrzeugprofil in Prozentpunkte umgerechnet, damit beide Angaben auf -derselben Einheit (Prozent) verglichen werden können; es gilt jeweils die -empfindlichere (kleinere) der beiden Schwellen. - -Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die -Integration liefert einen Tankvorgang oft in mehreren kleinen Schritten -(z. B. 40% -> 45% -> 60%), von denen keiner allein die Schwelle -überschreiten muss. Deshalb wird der zuletzt bekannte Tiefststand -gespeichert und der Anstieg dagegen gemessen, nicht gegen den unmittelbar -vorherigen Wert. Nach Anlage eines Tankvorgangs wird der Tiefststand auf den -aktuellen Wert zurückgesetzt, damit derselbe Vorgang nicht mehrfach neue -Datensätze erzeugt, wenn der Sensor währenddessen weiter ansteigt. - -Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter -(status "unvollständig", ohne Liter/Kosten/Station) - siehe §10 Punkt 5 der -letzten Rückmeldung: die Erwartung ist ein automatisch erkannter Tankvorgang -mit Zeitstempel, Kilometerstand und gefahrener Distanz, den der Nutzer später -per Beleg-Nachtrag (audi_dashboard_beleg_hochladen mit tank_id, siehe -belegverarbeitung.py) vervollständigt. - -EINSCHRÄNKUNG: der Tiefststand (_tiefststand_pct) lebt nur im Arbeitsspeicher -dieses Skripts, analog zur bekannten Einschränkung in fahrterkennung.py -(_fahrt_start_ts). Ein HA-Neustart während oder kurz vor einem Tankvorgang -verliert den bisherigen Tiefststand - der nächste Anstieg wird dann gegen den -ersten nach dem Neustart gemeldeten Wert gemessen. Bewusst nicht gehärtet, -aus demselben Grund wie dort. - -Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. -""" - -import datetime - -import einstellungen -import frontend_veroeffentlichung -import profil - -LITER_SCHWELLE = 5 -PROZENT_SCHWELLE = 9 -STANDARD_TANKVOLUMEN_LITER = 58 # Fallback, falls im Profil nicht hinterlegt - -_tiefststand_pct = None - - -def _als_zahl(wert): - try: - return float(wert) - except (TypeError, ValueError): - return None - - -def _schwelle_prozent(): - p = profil.profil_lesen() - if p is None: - tankvolumen = STANDARD_TANKVOLUMEN_LITER - else: - tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER - liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100 - return min(liter_als_prozent, PROZENT_SCHWELLE) - - -@time_trigger("startup") -def beim_start(): - profil.ordner_sicherstellen() - - -# Ohne konfigurierten Tankfüllstand-Sensor keinen Trigger registrieren - -# eine leere Entity-ID als @state_trigger-Ausdruck ist ungetestetes -# Verhalten, siehe gleiches Muster in fahrterkennung.py. -if einstellungen.TANK_SENSOR: - @state_trigger(f"{einstellungen.TANK_SENSOR}") - def tankfuellstand_geaendert(value=None, old_value=None): - global _tiefststand_pct - - aktuell = _als_zahl(value) - if aktuell is None: - return - - if _tiefststand_pct is None: - _tiefststand_pct = aktuell - return - - if aktuell <= _tiefststand_pct: - _tiefststand_pct = aktuell - return - - anstieg = aktuell - _tiefststand_pct - if anstieg >= _schwelle_prozent(): - _tankvorgang_automatisch_anlegen(anstieg) - _tiefststand_pct = aktuell - - -def _tankvorgang_automatisch_anlegen(anstieg_pct): - odometer_km = frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR) - tankvorgang = { - "tank_id": profil.neue_id("f"), - "receipt_key": None, - "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "liters": None, - "fuel_total_eur": None, - "price_per_l": None, - "discount": None, - "station_name": None, - "odometer_km": odometer_km, - "distance_km": profil.distanz_seit_letzter_tankung(odometer_km), - "fuel_type": None, - "source": "auto", - "status": "unvollständig", - "receipt_file": None, - "edited_fields": [], - } - profil.tankvorgang_anhaengen(tankvorgang) - frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() - log.info( - f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} automatisch erkannt " - f"(Füllstandsanstieg {round(anstieg_pct, 1)} Prozentpunkte)" - ) diff --git a/homeassistant/pyscript/updateverwaltung.py b/homeassistant/pyscript/updateverwaltung.py deleted file mode 100644 index 6e8ed31..0000000 --- a/homeassistant/pyscript/updateverwaltung.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Update-Funktion für die Oberfläche ("Update suchen" in Einstellungen). - -Voraussetzung: einstellungen.UPDATE_REPO_URL zeigt auf ein Git-Repository, in -das dieses Projekt (pyscript/ + www/, wie mit update.ps1 auf die eigene -Testinstanz gespielt) gepflegt/gepusht wird - z. B. ein privates GitHub-Repo, -genau wie es für die HACS-Integration bereits genutzt wird. Ohne eingetragene -URL meldet "Update suchen" nur "keine Update-Quelle eingerichtet" (§9- -Fallback), es passiert nichts von selbst. - -Kopiert wie update.ps1 bewusst NUR Code: - - pyscript/ komplett (kein Nutzerdaten-Ordner dort) - - www/*.js, www/*.css, www/badges/* -Bewusst NICHT angefasst: www/bilder/ (vom Nutzer hochgeladene Fahrzeugfotos, -siehe bilderverwaltung.py) und alles unter audi_dashboard/ (Fahrzeugprofil, -Fahrten- und Tankvorgänge-Archive) - ein Update darf niemals echte -Fahrzeugdaten oder eigene Fotos überschreiben. - -Vor jeder Installation wird der bisherige Code-Stand unter -audi_dashboard/code_backups// gesichert (gleiches Muster wie -backup.py für die Nutzerdaten) - kein automatisches Rollback in der -Oberfläche, aber ein manueller Weg zurück, falls ein Update Probleme macht. - -git ist im offiziellen Home-Assistant-Container vorhanden (an der -Testinstanz geprüft) und subprocess ist über allow_all_imports bereits -freigegeben (siehe configuration_snippet.yaml), deshalb hier statt eines -selbstgebauten ZIP-Downloads einfach ein flacher Git-Checkout in einen -eigenen Zwischenordner, getrennt von /config/audi_dashboard (Nutzerdaten) -und /config/pyscript, /config/www (Live-Code). -""" - -import datetime -import io -import json -import os -import shutil -import subprocess - -import einstellungen -import profil - -QUELLE_ORDNER = "/config/.audi_dashboard_update_quelle" -BACKUP_ORDNER = f"{profil.BASIS}/code_backups" -LIVE_PYSCRIPT = "/config/pyscript" -LIVE_WWW = "/config/www" -CODE_DATEIEN_WWW = ["audi-dashboard-app.js", "audi-dashboard-panel.js", "audi-dashboard.css"] - - -def _status_veroeffentlichen(daten): - state.set("pyscript.audi_dashboard_update_status", "aktuell", new_attributes={"daten": daten}) - - -def _git(*args, cwd=None): - # subprocess.run() direkt, ohne task.executor - genau wie der bestehende - # PDF-Parser-Aufruf in belegverarbeitung.py (_parser_aufrufen), an der - # Testinstanz bereits bestätigt funktionsfähig. - return subprocess.run( - ["git", *args], - cwd=cwd, capture_output=True, text=True, timeout=60, - ) - - -def _lokale_version(): - pfad = f"{LIVE_WWW}/audi-dashboard-version.json" - if not os.path.exists(pfad): - return None - try: - f = task.executor(io.open, pfad, "r") - inhalt = f.read() - f.close() - return json.loads(inhalt).get("version") - except (ValueError, OSError): - return None - - -def _quelle_aktualisieren(): - """Klont bzw. holt den konfigurierten Branch flach in QUELLE_ORDNER. - Gibt (erfolgreich, fehlertext) zurück.""" - zweig = einstellungen.UPDATE_BRANCH or "main" - if not os.path.exists(f"{QUELLE_ORDNER}/.git"): - os.makedirs(os.path.dirname(QUELLE_ORDNER), exist_ok=True) - if os.path.exists(QUELLE_ORDNER): - task.executor(shutil.rmtree, QUELLE_ORDNER) - r = _git("clone", "--depth", "1", "--branch", zweig, einstellungen.UPDATE_REPO_URL, QUELLE_ORDNER) - if r.returncode != 0: - return False, r.stderr.strip() or "git clone fehlgeschlagen" - return True, None - r = _git("fetch", "--depth", "1", "origin", zweig, cwd=QUELLE_ORDNER) - if r.returncode != 0: - return False, r.stderr.strip() or "git fetch fehlgeschlagen" - r = _git("reset", "--hard", f"origin/{zweig}", cwd=QUELLE_ORDNER) - if r.returncode != 0: - return False, r.stderr.strip() or "git reset fehlgeschlagen" - return True, None - - -@service -def audi_dashboard_update_pruefen(): - """Prüft, ob unter einstellungen.UPDATE_REPO_URL eine neuere Version - vorliegt, ohne etwas zu installieren. Aufruf als - pyscript.audi_dashboard_update_pruefen.""" - jetzt = datetime.datetime.now().isoformat() - if not einstellungen.UPDATE_REPO_URL: - _status_veroeffentlichen({"stand": "keine_quelle", "geprueft_um": jetzt}) - return - ok, fehler = _quelle_aktualisieren() - if not ok: - _status_veroeffentlichen({ - "stand": "fehler", "fehler": fehler, "geprueft_um": jetzt, - "aktuelle_version": _lokale_version(), - }) - log.warning(f"audi_dashboard: Update-Prüfung fehlgeschlagen: {fehler}") - return - quell_version_pfad = f"{QUELLE_ORDNER}/www/audi-dashboard-version.json" - neue_version = None - if os.path.exists(quell_version_pfad): - f = task.executor(io.open, quell_version_pfad, "r") - inhalt = f.read() - f.close() - try: - neue_version = json.loads(inhalt).get("version") - except ValueError: - neue_version = None - aktuelle_version = _lokale_version() - verfuegbar = neue_version is not None and aktuelle_version is not None and neue_version > aktuelle_version - _status_veroeffentlichen({ - "stand": "geprueft", "verfuegbar": verfuegbar, - "aktuelle_version": aktuelle_version, "neue_version": neue_version, - "geprueft_um": jetzt, - }) - log.info(f"audi_dashboard: Update-Prüfung ok - aktuell={aktuelle_version}, verfügbar={neue_version}") - - -@service -def audi_dashboard_update_installieren(): - """Installiert den zuletzt per audi_dashboard_update_pruefen geholten - Stand aus QUELLE_ORDNER - sichert vorher den bisherigen Code. Aufruf als - pyscript.audi_dashboard_update_installieren.""" - if not os.path.exists(f"{QUELLE_ORDNER}/.git"): - log.warning("audi_dashboard: Update-Installation ohne vorherige Prüfung abgelehnt") - return - - zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - ziel_backup = f"{BACKUP_ORDNER}/{zeitstempel}" - os.makedirs(ziel_backup, exist_ok=True) - if os.path.exists(LIVE_PYSCRIPT): - task.executor(shutil.copytree, LIVE_PYSCRIPT, f"{ziel_backup}/pyscript") - for name in CODE_DATEIEN_WWW: - quelle = f"{LIVE_WWW}/{name}" - if os.path.exists(quelle): - task.executor(shutil.copy, quelle, f"{ziel_backup}/{name}") - - quelle_pyscript = f"{QUELLE_ORDNER}/pyscript" - if os.path.exists(quelle_pyscript): - if os.path.exists(LIVE_PYSCRIPT): - task.executor(shutil.rmtree, LIVE_PYSCRIPT) - task.executor(shutil.copytree, quelle_pyscript, LIVE_PYSCRIPT) - - for name in CODE_DATEIEN_WWW: - quelle = f"{QUELLE_ORDNER}/www/{name}" - if os.path.exists(quelle): - task.executor(shutil.copy, quelle, f"{LIVE_WWW}/{name}") - - quelle_badges = f"{QUELLE_ORDNER}/www/badges" - if os.path.exists(quelle_badges): - ziel_badges = f"{LIVE_WWW}/badges" - if os.path.exists(ziel_badges): - task.executor(shutil.rmtree, ziel_badges) - task.executor(shutil.copytree, quelle_badges, ziel_badges) - - # Version neu schreiben (aktuelle Uhrzeit als Cache-Buster, wie - # update.ps1) statt den im Quell-Repo eingecheckten Wert blind zu - # übernehmen - erzwingt bei jedem Browser einen echten Neuabruf von - # audi-dashboard-app.js/.css (siehe audi-dashboard-panel.js). - version = int(datetime.datetime.now().timestamp()) - f = task.executor(io.open, f"{LIVE_WWW}/audi-dashboard-version.json", "w") - f.write(json.dumps({"version": version})) - f.close() - - _status_veroeffentlichen({ - "stand": "installiert", "verfuegbar": False, - "aktuelle_version": version, "neue_version": None, - "geprueft_um": datetime.datetime.now().isoformat(), - }) - log.info(f"audi_dashboard: Update installiert (Backup unter {ziel_backup}), neue Version {version}") diff --git a/homeassistant/update.ps1 b/homeassistant/update.ps1 deleted file mode 100644 index 57ff36a..0000000 --- a/homeassistant/update.ps1 +++ /dev/null @@ -1,100 +0,0 @@ -<# -.SYNOPSIS - Spielt Code-Updates (pyscript + Frontend) in eine laufende Home-Assistant- - Installation ein, ohne Neustart und ohne Cache-Probleme im Browser. - -.BESCHREIBUNG - Kopiert nur Code, nie Daten: - - pyscript/* -> /pyscript/ (pyscript lädt das automatisch neu, - siehe Kopfkommentar dort - an - einer Testinstanz bestätigt: eine - geänderte Datei war innerhalb von - Millisekunden aktiv, ganz ohne - Neustart) - - www/*.js, www/*.css, www/badges/*, www/bilder/* -> /www/ - (audi-dashboard-ios.css nicht vergessen: die App hängt sich dieses - Overlay-Stylesheet selbst per JS in den Shadow-Root - ohne es fehlt - nur der optische Feinschliff, kein Fehler, aber ein veralteter Stand) - - www/audi-dashboard-version.json wird IMMER neu geschrieben, mit der - aktuellen Uhrzeit als Versionsnummer - der Lade-Stub - (audi-dashboard-panel.js) liest diese Datei bei jedem Seitenaufruf - ohne Cache und lädt den eigentlichen Code darüber immer frisch nach - (siehe Kopfkommentar in www/audi-dashboard-panel.js). - - Absichtlich NICHT kopiert: die Datenbestände in data/ - das würde ein - bereits laufendes Fahrzeugprofil, die Fahrten- und Tankvorgänge-Archive - sowie die Sensor-Zuordnung überschreiben. Diese Dateien sind nur die - Erstbefüllung für die einmalige Installation (siehe INSTALL.md Schritt 2). - - Eine Ausnahme davon ist shell_beleg_parser.py: die Datei liegt zwar in - data/, ist aber Code, kein Datenbestand - belegverarbeitung.py ruft sie - als Unterprozess aus /config/audi_dashboard/ auf. Ohne diese Ausnahme - käme jede Änderung am Belegleser (dem einzigen getesteten Teil des - Projekts) auf keiner Instanz an. - -.PARAMETER Ziel - Pfad zum config-Verzeichnis von Home Assistant, z. B. ein per Samba - eingebundenes Laufwerk (\\\config) oder ein lokal gemounteter Pfad. - -.BEISPIEL - .\update.ps1 -Ziel "\\homeassistant.local\config" -#> -param( - [Parameter(Mandatory = $true)] - [string]$Ziel -) - -$ErrorActionPreference = "Stop" -$hier = $PSScriptRoot - -if (-not (Test-Path $Ziel)) { - throw "Ziel nicht erreichbar: $Ziel — Samba-Share verbunden? Pfad korrekt?" -} - -Write-Host "Kopiere pyscript/ ..." -ForegroundColor Cyan -robocopy "$hier\pyscript" "$Ziel\pyscript" /MIR /NFL /NDL /NJH /NJS | Out-Null - -Write-Host "Kopiere www/ (ohne bilder/ und badges/, die separat behandelt werden) ..." -ForegroundColor Cyan -New-Item -ItemType Directory -Force -Path "$Ziel\www" | Out-Null -Copy-Item "$hier\www\audi-dashboard-app.js" "$Ziel\www\audi-dashboard-app.js" -Force -Copy-Item "$hier\www\audi-dashboard-panel.js" "$Ziel\www\audi-dashboard-panel.js" -Force -Copy-Item "$hier\www\audi-dashboard.css" "$Ziel\www\audi-dashboard.css" -Force -Copy-Item "$hier\www\audi-dashboard-ios.css" "$Ziel\www\audi-dashboard-ios.css" -Force - -# Code, der in data/ liegt (siehe Kopfkommentar) - der Ordner selbst wird -# bewusst nicht gespiegelt, diese eine Datei aber schon. -if (Test-Path "$Ziel\audi_dashboard") { - Copy-Item "$hier\data\shell_beleg_parser.py" "$Ziel\audi_dashboard\shell_beleg_parser.py" -Force - Write-Host " Belegleser aktualisiert" -} - -if (Test-Path "$hier\www\bilder") { - Write-Host "Kopiere www/bilder/ ..." -ForegroundColor Cyan - New-Item -ItemType Directory -Force -Path "$Ziel\www\bilder" | Out-Null - Copy-Item "$hier\www\bilder\*" "$Ziel\www\bilder\" -Force -Recurse -} - -if (Test-Path "$hier\www\badges") { - Write-Host "Kopiere www/badges/ ..." -ForegroundColor Cyan - New-Item -ItemType Directory -Force -Path "$Ziel\www\badges" | Out-Null - Copy-Item "$hier\www\badges\*" "$Ziel\www\badges\" -Force -Recurse -} - -# Zwei Zahlen mit verschiedenen Aufgaben, siehe VERSIONIERUNG.md: -# version = Unix-Sekunden, bricht den Browser-Cache, bei jedem Deploy neu -# app = Inhalt der Datei VERSION im Projektstamm, identifiziert die Fassung -# Die App-Version muss mitgeschrieben werden: an ihr erkennt die iOS-App, ob sie -# noch dem ausgelieferten Stand entspricht. Ginge sie hier verloren, wäre der -# Vergleich still abgeschaltet - genau die Veraltung, die er verhindern soll. -$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() -$appVersion = "" -if (Test-Path "$hier\..\VERSION") { $appVersion = (Get-Content "$hier\..\VERSION" -Raw).Trim() } -$inhaltVersion = if ($appVersion) { "{ ""version"": $version, ""app"": ""$appVersion"" }" } - else { "{ ""version"": $version }" } -$inhaltVersion | Out-File -FilePath "$Ziel\www\audi-dashboard-version.json" -Encoding utf8 -NoNewline - -Write-Host "" -Write-Host "Fertig. Version $version$(if ($appVersion) { " (App $appVersion)" })." -ForegroundColor Green -Write-Host "pyscript-Änderungen sind bereits aktiv (Auto-Reload, kein Neustart nötig)." -Write-Host "Frontend-Änderungen: im Browser einmal ganz normal neu laden (F5) reicht -" -Write-Host "kein Hard-Refresh, kein HA-Neustart." diff --git a/homeassistant/www/audi-dashboard-app.js b/homeassistant/www/audi-dashboard-app.js deleted file mode 100644 index 4f16168..0000000 --- a/homeassistant/www/audi-dashboard-app.js +++ /dev/null @@ -1,4933 +0,0 @@ -/* ================================================================ - Audi-Dashboard — panel_custom Custom Element - ================================================================ - Umbau von dashboard-muster-ohne-bilder.html (der verbindliche Prototyp - aus dem Lastenheft) zu einem panel_custom-Element für Home Assistant. - - Strategie: die Render-Logik des Prototyps bleibt praktisch unverändert - (dieselben Funktionen vHome(), vTrips(), render() usw.) - geändert wurde - nur die Datenschicht (CONFIG/CAR/TRIPS/FILLS werden jetzt von den - pyscript-Zustandsentitäten geladen statt fest im Code zu stehen) und die - DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten). - - Bekannte Lücken in diesem Stand: - - Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits - vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als - gebrochene Bilder, bis bilder/ tatsächlich befüllt ist. - - Leaflet lädt weiterhin per CDN (wie im Prototyp) - funktioniert nur, - wenn das Gerät zusätzlich zur Tailscale-Verbindung Internetzugang hat. -*/ - -/* --------------------------------------------------------------------- - Modul-weiter Zustand. Bewusst außerhalb der Klasse, damit die praktisch - unveränderte Render-Logik des Prototyps (freie Funktionen, kein `this`) - weiterhin funktioniert - siehe Kopfkommentar. Für eine Einzel-Panel- - Anwendung wie diese ist eine Instanz gleichbedeutend mit "die eine - Oberfläche", genau wie im ursprünglichen Prototyp auch nur ein - existierte. - --------------------------------------------------------------------- */ -let HASS = null; -let ROOT = null; // this.shadowRoot der Panel-Instanz -let HOST = null; // die Panel-Instanz selbst (für :host([data-theme])) - -let CONFIG = null; -let CAR = null; -let TRIPS = []; -let FILLS = []; -let BATTERIEVERLAUF = []; -let ENTITAETEN = null; // {katalog, werte} aus pyscript.audi_dashboard_entitaeten (Setup-Menü) -let DATEN_GELADEN = false; -// Zeitpunkt (ms) des letzten echten Fahrzeugstatus-Updates vom Backend - -// null, solange keiner bekannt ist (siehe standAlterText()). -let LETZTES_UPDATE_TS = null; - -/* Cache-Buster fuer audi-dashboard.css: diese Datei selbst wird von HA mit - derselben langen /local/-Cache-Zeit ausgeliefert wie audi-dashboard-panel.js - (siehe dessen Kopfkommentar) - ohne Versions-Query bleibt ein Browser, der - sie einmal geladen hat, auch nach einer CSS-Aenderung auf dem alten Stand - haengen, obwohl app.js selbst per audi-dashboard-version.json korrekt neu - geladen wird. Die eigene Script-URL (?v=N, von audi-dashboard-panel.js - gesetzt) liefert dieselbe Versionsnummer, ohne version.json ein zweites - Mal abfragen zu muessen. - */ -const CSS_VERSION = (() => { - try { - return new URL(import.meta.url).searchParams.get("v") || Date.now(); - } catch (e) { - return Date.now(); - } -})(); - -const $ = (s) => ROOT.querySelector(s); - -/* ---------------------------------------------------------------- Format */ -const de = (n, d = 0) => Number(n ?? 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d }); -const eur = (n) => de(n, 2); -const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); -const sum = (a, f) => a.reduce((s, x) => s + f(x), 0); -/* Durchschnittspreis IMMER mengengewichtet: Summe Kosten / Summe Liter */ -const schnittpreis = (list) => sum(list, (f) => f.fuel_total_eur ?? f.summe ?? 0) / sum(list, (f) => f.liters ?? f.liter ?? 0); - -/* -------------------------------------------------------- Adapter-Schicht - Übersetzt die Fahrzeugprofil-Struktur aus dem Backend (fahrzeug/ - einstellungen/versicherung/steuer/reifen/service/technik/ausstattung) in - die CONFIG/CAR-Form, mit der der Rest dieser Datei arbeitet - direkt aus - dem Prototyp übernommen, um dessen Render-Funktionen unverändert - weiterzuverwenden. */ -function profilZuConfig(p) { - return { - fahrzeugtitel: p.fahrzeug.titel, - fahrzeugzusatz: p.fahrzeug.zusatz, - fahrzeuguntertitel: p.fahrzeug.untertitel, - kennzeichen: p.fahrzeug.kennzeichen, - startbild: p.einstellungen.uebersichtsbild, - version: "1.0", - tankvolumen: p.fahrzeug.tankvolumen_liter, - nachtVon: p.einstellungen.nacht_von, - nachtBis: p.einstellungen.nacht_bis, - pauseMin: p.einstellungen.fahrten_pausenzeit_min, - markenname: p.einstellungen.kraftstoffanbieter, - backupIntervall: p.einstellungen.backup_intervall || "aus", - letztesBackup: p.einstellungen.letztes_backup || null, - ausfuehrung: p.fahrzeug.ausfuehrung || "", - }; -} - -function profilZuCar(p, status) { - // km ist kein abgeleiteter Wert mehr, sondern wird vom Backend - // (reifenzaehler.py) direkt im Profil fortgeschrieben - jeder gefahrene - // Kilometer zählt dort auf den zum Zeitpunkt aktiven Satz. - const reifenSatz = (k) => p.reifen.saetze[k] || {}; - return { - fin: p.fahrzeug.fin, - details: p.fahrzeug.details, - erstzulassung: p.fahrzeug.erstzulassung, - hu: p.fahrzeug.hauptuntersuchung_faellig, - odo: status.km ?? 0, - odoBekannt: status.km != null, - tankPct: status.tankprozent ?? 0, - tankBekannt: status.tankprozent != null, - reichweite: status.reichweite_km, - batteriespannung: status.batteriespannung, - gesichert: status.gesichert, - sicherheitscheck: status.sicherheitscheck || [], - zuendung: status.zuendung ?? null, - standortLat: status.standort_lat, - standortLon: status.standort_lon, - standortGenauigkeitM: status.standort_genauigkeit_m, - standortZeit: status.standort_zeit, - oelwechselFaelligTs: status.oelwechsel_faellig_ts, - oelwechselFaelligKm: status.oelwechsel_faellig_km, - inspektionFaelligTs: status.inspektion_faellig_ts, - inspektionFaelligKm: status.inspektion_faellig_km, - kmProTag: 18.3, // Fallback, nur solange kein Servicebuch-Eintrag existiert - // "Gültig bis" ist einschließlich - der Rabatt gilt noch am Stichtag - // selbst, erst am Folgetag schaltet der Schalter automatisch ab, ohne - // dass der Nutzer manuell eingreifen muss. - smartdeal: { aktiv: p.einstellungen.smartdeal.aktiv && (!p.einstellungen.smartdeal.laeuft_ab || - p.einstellungen.smartdeal.laeuft_ab >= new Date().toISOString().slice(0, 10)), laeuftAb: p.einstellungen.smartdeal.laeuft_ab }, - oel: { - modus: p.einstellungen.oelwechsel_intervall.modus, - km: p.einstellungen.oelwechsel_intervall.km, - monate: p.einstellungen.oelwechsel_intervall.monate, - herstellerKm: p.einstellungen.oelwechsel_intervall.hersteller_km, - herstellerMonate: p.einstellungen.oelwechsel_intervall.hersteller_monate, - }, - versicherung: p.versicherung, - steuer: { ...p.steuer, faellig: p.steuer.faellig || "" }, - reifen: { - aktiv: p.reifen.aktiv === "winter" ? "Winter" : "Sommer", - anzugsmoment: p.reifen.anzugsmoment_nm, - wechsel: p.reifen.wechsel, - sommer: reifenSatz("sommer"), - winter: reifenSatz("winter"), - }, - service: p.service, - technik: p.technik, - ausstattung: p.ausstattung, - }; -} - -/* Rückrichtung: CONFIG/CAR (im Speicher gehalten, vom Prototyp-Code direkt - mutiert) zurück in die Profil-Form für audi_dashboard_profil_schreiben. */ -function configCarZuProfil(vorherigesProfil) { - const p = JSON.parse(JSON.stringify(vorherigesProfil)); - p.fahrzeug.titel = CONFIG.fahrzeugtitel; - p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz; - p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel; - p.fahrzeug.kennzeichen = CONFIG.kennzeichen; - p.fahrzeug.hauptuntersuchung_faellig = CAR.hu; - p.fahrzeug.fin = CAR.fin; - p.fahrzeug.erstzulassung = CAR.erstzulassung; - p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung; - p.einstellungen.uebersichtsbild = CONFIG.startbild; - p.einstellungen.fahrten_pausenzeit_min = CONFIG.pauseMin; - p.einstellungen.backup_intervall = CONFIG.backupIntervall; - p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb }; - p.einstellungen.oelwechsel_intervall = { - modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate, - hersteller_km: CAR.oel.herstellerKm, hersteller_monate: CAR.oel.herstellerMonate, - }; - p.versicherung = CAR.versicherung; - p.steuer = { ...CAR.steuer, faellig: CAR.steuer.faellig || null }; - p.reifen.aktiv = CAR.reifen.aktiv.toLowerCase(); - p.reifen.anzugsmoment_nm = CAR.reifen.anzugsmoment; - p.reifen.wechsel = CAR.reifen.wechsel; - for (const k of ["sommer", "winter"]) { - // km wird vom Backend fortgeschrieben (reifenzaehler.py, reagiert auf - // den Kilometerstand-Sensor) - hier nie mitschreiben, sonst würde ein im - // Browser gehaltener älterer Stand eine zwischenzeitliche Fortschreibung - // überschreiben. - const { km, ...rest } = CAR.reifen[k]; - p.reifen.saetze[k] = { ...p.reifen.saetze[k], ...rest }; - } - p.service = CAR.service; - return p; -} - -let PROFIL_ROH = null; // letzter vom Backend gelesener Rohstand, für configCarZuProfil() - -async function profilSpeichern() { - const neuesProfil = configCarZuProfil(PROFIL_ROH); - PROFIL_ROH = neuesProfil; - // HIG "Feedback": profilSpeichern() ist der Speicherpfad fuer praktisch - // jedes editierbare Feld im Panel (~25 Aufrufstellen) und wurde bisher an - // keiner davon awaited oder abgefangen - ein Fehlschlag war eine unhandled - // promise rejection ohne jede Rueckmeldung. Zentral hier gefangen, aus - // demselben Grund wie serviceRufen() weiter unten. - try { - await HASS.callService("pyscript", "audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(neuesProfil) }); - } catch (err) { - console.error("audi_dashboard: profilSpeichern", err); - hinweis("Speichern fehlgeschlagen", err && err.message ? err.message : "Die Änderung konnte nicht gespeichert werden."); - } -} - -/* ------------------------------------------------------------- Bilder - Feste Dateinamen laut §7a, referenziert unter /local/ (aus www/ serviert). - Der Ordner bilder/ selbst ist noch nicht befüllt (siehe Kopfkommentar) - - die Flächen behalten dennoch ihre Maße, das Layout springt nicht. */ -const BILDER = [ - { name: "Seitenansicht", label: "Auto Seite", datei: "seitenansicht.webp" }, - { name: "Front schräg", label: "Auto Front schräg", datei: "front-schraeg.webp" }, - { name: "Frontansicht", label: "Auto Front", datei: "frontansicht.webp" }, - { name: "Heckansicht", label: "Auto Heck", datei: "heckansicht.webp" }, - { name: "Cockpit", label: "Innenraum Cockpit", datei: "cockpit.webp" }, - { name: "Scheinwerfer", label: "Scheinwerfer", datei: "scheinwerfer.webp" }, - { name: "Sitze", label: "Innenraum Sitze", datei: "sitze.webp" }, -]; -let bild = 0; -const SEITE_WINTER = { label: "Auto Seite mit Winterrädern", datei: "seitenansicht-winter.webp" }; -// Bei jedem Hochladen/Löschen eines Fahrzeugfotos hochgezählt und als -// Query-Parameter an alle bilder/-URLs gehängt (siehe bildInfo() -// unten) - ohne das würde der Browser nach einem Upload weiter das alte Bild -// oder den zwischenzeitlich gecachten 404 unter derselben URL zeigen. -let bildVersion = Date.now(); - -/* Platzhalter statt kaputtem Bild-Symbol: bei Ladefehler (Bilder aus §7a - noch nicht vorhanden) wird die Bildfläche durch ein generisches Symbol - plus eindeutiger Beschriftung und dem erwarteten Dateinamen ersetzt - - damit klar ist, welche Datei unter welchem Namen nachgereicht werden muss, - ohne dass sich am Layout etwas verschiebt (Maße bleiben über die CSS- - Klassen erhalten, siehe carfix/carfix.klein/radbild in audi-dashboard.css). */ -/* A5: Der Platzhalter nennt das Motiv und die nächste Handlung - der - Dateiname ist Entwicklersprache und steht nur noch in den Einstellungen - (Bild der Übersicht: Ansicht wählen, dann auf das Bild tippen). Die Maße - bleiben über die CSS-Klassen erhalten, das Layout springt beim Nachliefern - eines Fotos nicht. */ -function bildMitPlatzhalter(src, datei, label, klasse, imgId, dateiZeigen = false) { - const idTeil = imgId ? ` id="${imgId}"` : ""; - return `
- ${esc(label)} -
- - ${esc(label)} - ${dateiZeigen ? esc(datei) : "Foto hinzufügen"} -
-
`; -} - -function startIndex() { - const i = BILDER.findIndex((b) => b.name === CONFIG.startbild); - return i < 0 ? 0 : i; -} -function bildInfo(i) { - const b = BILDER[i]; - if (b.name === "Seitenansicht" && CAR.reifen.aktiv === "Winter") { - return { label: SEITE_WINTER.label, datei: SEITE_WINTER.datei, src: `/local/bilder/${SEITE_WINTER.datei}?v=${bildVersion}` }; - } - return { label: b.label, datei: b.datei, src: `/local/bilder/${b.datei}?v=${bildVersion}` }; -} -function bildSrc(i) { return bildInfo(i).src; } -function carImg(modus) { - if (modus === "fest") { - const info = bildInfo(startIndex()); - return bildMitPlatzhalter(info.src, info.datei, info.label, "carfix klein"); - } - const info = bildInfo(bild); - /* data-bildgalerie: ein Tipp irgendwo auf die Kachel (Foto oder Platzhalter) - schaltet zum naechsten Bild weiter - Hochladen/Ersetzen/Loeschen bleibt - bewusst allein den Einstellungen vorbehalten (Bild der Übersicht), - deshalb hier kein data-bildupload/data-bildklick, nur bildWeiter(). - Der Wrapper ist noetig, weil das selbst bei fehlendem Foto per - .bildbox.bild-fehlt img{display:none} unsichtbar und damit unklickbar - ist - der bisherige Handler (id="carimg") griff dann ins Leere. */ - return `
- ${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", "carimg")} -
-
${BILDER.map((_, i) => ``).join("")}
`; -} -/* Bildmarke des Kraftstoffanbieters (Shell). Anders als bei den Fahrzeug- - fotos ist hier kein generischer Platzhalter-Rahmen sinnvoll, weil das Logo - inline neben Text steht (.marke) - Fehlerzustand blendet stattdessen den - Markennamen als kleine Textmarke ein (.marke .ph in audi-dashboard.css), - exakt wie im Prototyp vorgesehen. Die Bilddatei selbst liefert der Nutzer - nach (offizielles Shell-Logo, hier nicht nachgezeichnet). */ -const MARKENLOGO_DATEI = "shell-logo.svg"; -function markenlogo() { - if (!CAR.smartdeal.aktiv) return ""; - const label = CONFIG.markenname || "Logo"; - return ``; -} -const OHNE_SZENE = ["Cockpit", "Sitze", "Scheinwerfer"]; -function szeneKlasse(i) { - if (!bodenAn) return ""; - if (OHNE_SZENE.includes(BILDER[i].name)) return ""; - return CAR.reifen.aktiv === "Winter" ? "winter" : "strasse"; -} -function bildWeiter() { - bild = (bild + 1) % BILDER.length; - const img = ROOT.getElementById("carimg"); - if (!img) return; - const info = bildInfo(bild); - img.src = info.src; - img.alt = info.label; - const box = img.closest("[data-bildbox]"); - if (box) { - box.classList.remove("bild-fehlt"); // neues Bild kann laden, auch wenn das vorherige fehlte - box.querySelector(".platzhalter-label").textContent = info.label; - } - const sz = ROOT.getElementById("szene"); - if (sz) sz.className = "szene " + szeneKlasse(bild); - [...ROOT.getElementById("dots").children].forEach((d, i) => d.classList.toggle("on", i === bild)); -} - -/* ---------------------------------------------------- Termine/Vorhersage - heute() statt einer eingefrorenen Konstante - der Prototyp nutzte ein - festes Demo-Datum (new Date(2026,7,3)), das für den echten Betrieb durch - das tatsächliche Tagesdatum ersetzt werden muss. */ -function heute() { return new Date(); } -const dTag = 86400000; -/* Ein unlesbares Datum ergibt einen Strich, keine Rechenreste. dat() erwartet - TT.MM.JJJJ; steht im Profil etwas anderes (es wird laut INSTALL.md von Hand - gepflegt, z. B. versehentlich im ISO-Format), lieferte dedat() sonst - "NaN.NaN.NaN" - ein Wert, der wie eine Angabe aussieht und keine ist. */ -const gueltigesDatum = (d) => d instanceof Date && !Number.isNaN(d.getTime()); -const dedat = (d) => (gueltigesDatum(d) - ? d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }) - : "–"); -function dat(s) { const [d, m, j] = s.split(".").map(Number); return new Date(j, m - 1, d); } - -const MISCHDAUER = 180; -function tagesleistung() { - // §9: fällt die Datenquelle aus, wird das sichtbar gemacht statt mit einem - // Platzhalterwert weiterzurechnen - ohne diese Sperre rechnete die - // Prognose mit CAR.odo=0 weiter und erzeugte Termine weit im Jahr -13000. - if (!CAR.odoBekannt) return { wert: CAR.kmProTag, quelle: "Kilometerstand unbekannt", tage: 0, km: 0 }; - const b = CAR.service.buch; - if (!b.length) return { wert: CAR.kmProTag, quelle: "Vorgabe", tage: 0, km: 0 }; - const alt = b[b.length - 1]; - const tageGesamt = Math.max(1, (heute() - dat(alt.datum)) / dTag); - const langfristig = (CAR.odo - alt.km) / tageGesamt; - const letzter = b[0]; - const tageSeit = (heute() - dat(letzter.datum)) / dTag; - const kmSeit = CAR.odo - letzter.km; - if (tageSeit < 14 || kmSeit <= 0) - return { wert: langfristig, quelle: "gesamter Verlauf", tage: Math.round(tageGesamt), km: CAR.odo - alt.km }; - const aktuell = kmSeit / tageSeit; - const g = Math.min(1, tageSeit / MISCHDAUER); - return { - wert: langfristig * (1 - g) + aktuell * g, - quelle: g >= 1 ? "seit dem letzten Service" : "gemischt, " + Math.round(g * 100) + " % aktueller Abschnitt", - tage: Math.round(tageSeit), km: kmSeit, - }; -} -function kmProTag() { return tagesleistung().wert; } -function prognose(zielKm) { - if (!CAR.odoBekannt) return { tage: null, datum: null }; - const rest = zielKm - CAR.odo; - if (rest <= 0) return { tage: 0, datum: heute() }; - const tage = Math.round(rest / kmProTag()); - return { tage, datum: new Date(heute().getTime() + tage * dTag) }; -} -function ics(titel, datum, ort, notiz) { - const tag = datum.replace(/-/g, ""); - const naechster = new Date(new Date(datum).getTime() + dTag).toISOString().slice(0, 10).replace(/-/g, ""); - const text = [ - "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Audi Dashboard//DE", "BEGIN:VEVENT", - "UID:" + Date.now() + "@audi-dashboard.local", - "DTSTAMP:" + new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z", - "DTSTART;VALUE=DATE:" + tag, "DTEND;VALUE=DATE:" + naechster, - "SUMMARY:" + titel, "LOCATION:" + (ort || ""), "DESCRIPTION:" + (notiz || ""), - "BEGIN:VALARM", "TRIGGER:-P2D", "ACTION:DISPLAY", "DESCRIPTION:" + titel, "END:VALARM", - "END:VEVENT", "END:VCALENDAR", - ].join("\r\n"); - const a = document.createElement("a"); - a.href = "data:text/calendar;charset=utf-8," + encodeURIComponent(text); - a.download = titel.replace(/[^\wäöüÄÖÜß ]/g, "").replace(/\s+/g, "-") + ".ics"; - document.body.appendChild(a); a.click(); a.remove(); -} -function ring(pct) { - const r = 64, c = 2 * Math.PI * r, on = (c * pct) / 100; - return `
- - - - -
-
${pct}%
- ${de(CONFIG.tankvolumen * pct / 100, 1)} Liter -
`; -} - -/* ------------------------------------------------------------------ Karte - Leaflet weiterhin per CDN geladen (siehe Kopfkommentar). */ -let MAP = null, TILE = null, LEAFLET_LADEND = null; -// Zielpunkt des Zentrieren-Knopfs im Einzelbeleg (siehe mapBoxMitZentrieren). -let BELEG_STATION_POS = null; -/* Bewusst themeunabhaengig hell: Kartendienste (Google Maps & Co.) zeigen die - Standard-Strassenkarte immer im hellen Stil, weil Strassen, Beschriftungen - und Wegfarben darauf lesbar bleiben. Die frueher bei Nacht eingeblendeten - dark_all-Kacheln liessen die Karte im Panel schwarz wirken. */ -const TILE_URL = "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"; -const ATTR = "© OpenStreetMap · CARTO"; -/* Satellitenansicht der Standort-Vollbildkarte (Layer-Knopf) - wie die - CARTO-Straßenkacheln oben ohne eigenen Schlüssel nutzbar. */ -const SAT_TILE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"; -const SAT_ATTR = "Tiles © Esri"; - -const LEAFLET_CSS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"; -const LEAFLET_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"; - -/* Das Leaflet-Stylesheet muss in den Shadow-Root, nicht in document.head: - Dokument-Styles greifen nicht in ein Shadow-DOM hinein. Lag es im Head, - fehlte drinnen u. a. .leaflet-tile{position:absolute} - die Kacheln flossen - dann als gewoehnliche Bilder untereinander (bei 8 Kacheln ueber 1000 px - hoch), von der Karte war nur ein schmaler Streifen zu sehen und der - Fahrzeugmarker landete weit unterhalb des sichtbaren Ausschnitts. Das JS - gehoert dagegen weiterhin in den Head, damit window.L global bleibt. */ -function leafletCssEinhaengen() { - if (!ROOT || ROOT.querySelector("link[data-leaflet-css]")) return Promise.resolve(); - return new Promise((fertig) => { - const css = document.createElement("link"); - css.rel = "stylesheet"; - css.href = LEAFLET_CSS_URL; - css.dataset.leafletCss = "1"; - /* Auch bei Ladefehler weitermachen statt haengen zu bleiben - dann sieht - die Karte zwar wieder falsch aus, aber die Ansicht baut sich auf. */ - css.onload = fertig; - css.onerror = fertig; - ROOT.appendChild(css); - }); -} - -function leafletLaden() { - /* Bewusst bei jedem Aufruf: haengt HA das Panel neu ein, entsteht ein neuer - Shadow-Root ohne das Stylesheet, waehrend window.L laengst gesetzt ist. */ - const css = leafletCssEinhaengen(); - if (window.L) return css; - if (!LEAFLET_LADEND) { - LEAFLET_LADEND = new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = LEAFLET_JS_URL; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); - }); - } - return Promise.all([css, LEAFLET_LADEND]); -} - -function mapBox() { return `
`; } - -/* Wie mapBox(), aber mit Zentrieren-Knopf - nur im Einzelbeleg. Der Knopf - holt die Tankstelle zurück in die Mitte, nachdem man auf der Karte - herumgezogen hat. Er sitzt im .mapbox (position:relative), damit er über - der Karte schwebt, und ist bis zum Eintreffen der Koordinaten deaktiviert - - die kommen bei Belegen ohne station_lat erst aus dem Nominatim-Abruf - (siehe adresseAufloesen). */ -function mapBoxMitZentrieren() { - return `
-
-
- -
-
`; -} - -/* Kartenmittelpunkt ohne eigene Positionsdaten (z. B. Fahrt ohne GPS-Punkte): - die Werkstatt-Koordinaten aus dem Profil statt einer im Code fest - hinterlegten Region - die liegen ohnehin meist in der Nähe und kommen - schon aus einer echten, dafür vorgesehenen Datenquelle. Ohne Werkstatt- - Koordinaten bleibt nur ein neutraler Mittelpunkt (geografische Mitte - Deutschlands), keine an dieses Fahrzeug gebundene Vermutung. */ -function kartenMittelpunkt() { - const pos = CAR.service.autohaus.pos; - return pos && pos.length === 2 ? pos : [51.1657, 10.4515]; -} - -function fakeTrack(a, b, n = 13) { - const pts = []; let seed = Math.round((a[0] + b[1]) * 1000); - const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; }; - for (let i = 0; i <= n; i++) { - const t = i / n, k = Math.sin(t * Math.PI); - pts.push([a[0] + (b[0] - a[0]) * t + rnd() * 0.05 * k, a[1] + (b[1] - a[1]) * t + rnd() * 0.05 * k]); - } - pts[0] = a; pts[n] = b; return pts; -} -function tiles() { - if (!MAP || !window.L) return; - const url = TILE_URL; - if (TILE) MAP.removeLayer(TILE); - TILE = window.L.tileLayer(url, { maxZoom: 19, attribution: ATTR }).addTo(MAP); -} -async function initMap() { - await leafletLaden(); - const el = ROOT.getElementById("map"); - if (!el || typeof window.L === "undefined") return; - MAP = window.L.map(el, { zoomControl: true, attributionControl: true, scrollWheelZoom: false }); - TILE = null; tiles(); - const rot = getComputedStyle(HOST).getPropertyValue("--red").trim() || "#F50537"; - if (route.name === "trip") { - const t = TRIPS.find((x) => x.trip_id === route.id); - if (t && t.start_lat != null && t.end_lat != null) { - const track = fakeTrack([t.start_lat, t.start_lon], [t.end_lat, t.end_lon]); - const line = window.L.polyline(track, { color: rot, weight: 2.5, opacity: 0.9 }).addTo(MAP); - track.slice(1, -1).forEach((pt) => window.L.circleMarker(pt, { radius: 2.5, color: rot, weight: 0, fillColor: rot, fillOpacity: 0.85 }).addTo(MAP)); - window.L.circleMarker([t.start_lat, t.start_lon], { radius: 6, color: "#fff", weight: 2, fillColor: "#000", fillOpacity: 1 }).addTo(MAP).bindPopup("Start · " + (t.start_address || "")); - window.L.circleMarker([t.end_lat, t.end_lon], { radius: 6, color: rot, weight: 2, fillColor: rot, fillOpacity: 1 }).addTo(MAP).bindPopup("Ziel · " + (t.end_address || "")); - MAP.fitBounds(line.getBounds(), { padding: [26, 26] }); - } else { - MAP.setView(kartenMittelpunkt(), 11); // kein Track ohne GPS-Daten (§7a-Backend-Lücke) - } - } else if (route.name === "fill") { - BELEG_STATION_POS = null; // gehört zum vorherigen Beleg, sonst zentriert der Knopf falsch - const f = FILLS.find((x) => x.tank_id === route.id); - if (f && f.station_lat != null) { - tankstelleAufKarteZeigen(MAP, { lat: f.station_lat, lon: f.station_lon }, f); - } else if (f && (f.station_address || f.station_name)) { - /* Ohne Koordinaten die Anschrift vom Beleg nachschlagen. Bis die Antwort - da ist, bleibt der neutrale Ausschnitt stehen - und die Karte könnte - inzwischen zu einer anderen Ansicht gehören, deshalb vor dem Zeichnen - prüfen, ob wir noch auf demselben Beleg sind. */ - MAP.setView(kartenMittelpunkt(), 11); - const karte = MAP, belegId = route.id; - adresseAufloesen(f.station_address || f.station_name).then((pos) => { - if (!pos || MAP !== karte || route.name !== "fill" || route.id !== belegId) return; - tankstelleAufKarteZeigen(karte, pos, f); - }); - } else { - MAP.setView(kartenMittelpunkt(), 11); - } - } - setTimeout(() => MAP && MAP.invalidateSize(), 60); -} - -/* ---------------------------------------------------------------- Standort - Live-Position des Fahrzeugs (Übersicht-Kachel + Vollbildansicht) und des - Endgeräts (Browser-Geolocation). Bewusst außerhalb des sonst - durchgängigen "ein render() zeichnet alles neu"-Musters: eine Leaflet- - Karte verliert bei jedem Neuaufbau ihres DOM-Containers Kartenausschnitt - und Zoomstufe (genau wie MAP oben schon), und das Fahrzeug-Menü soll sich - beim Öffnen/Schließen/Ziehen nicht wie der Rest der App anfühlen (kein - kompletter Neuaufbau bei jedem Fingertipp). initMap() oben handhabt genau - dasselbe Problem bereits für Fahrt-/Belegkarten - hier nur konsequent für - eine interaktive Karte mit eigenem Bedienelement weitergedacht: Standort- - und Nutzerdaten ändern CAR/USER_POS und lösen ein normales render() aus - (Kartenneuaufbau inklusive, wie bei MAP), aber Menü-Auf/Zu/Ziehen und die - Zentrieren-Knöpfe manipulieren SMAP und das Menü-DOM direkt. */ -let USER_POS = null; // {lat, lon, genauigkeit} oder null -let USER_POS_TS = 0; -let USER_POS_FEHLER = null; -let STANDORT_ADRESSE = null; // zuletzt aufgelöste Adresse (Cache über Koordinaten-Rundung) -let STANDORT_ADRESSE_KEY = null; -let STANDORTMENU_OFFEN = false; -let SMAP = null, SMAP_TILE = null, SMAP_STIL = null; // Vollbild-Karte (eigenständig von MAP/TMAP); SMAP_STIL: null/"strasse" oder "satellit" -let TMAP = null, TMAP_TILE = null; // Vorschau-Karte der Übersicht-Kachel -let FAHRZEUG_MARKER = null, USER_MARKER = null; -const STANDORT_PEEK_PX = 96; // sichtbarer Anteil im geschlossenen Zustand, muss zu --standort-peek in audi-dashboard.css passen - -function standortBekannt() { return CAR.standortLat != null && CAR.standortLon != null; } - -/* Kartenmarkierung: poi-car aus dem CI-Satz, in den beiden gelieferten - Groessenvarianten - poi-car-l (48er-Raster, mehr Detail) ab 34 px, darunter - poi-car-s (24er-Raster). Die Nadelspitze sitzt unten, deshalb ankert Leaflet - am unteren Rand (siehe iconAnchor an den Aufrufstellen: Spitze bei y=45.25 - im 48er- bzw. y=23.3 im 24er-viewBox). */ -function fahrzeugMarkerSVG(px = 34) { - const gross = px >= 34; - const box = gross ? 48 : 24; - const sil = gross ? CI.poiCarLSil : CI.poiCarSil; - const icon = gross ? CI.poiCarL : CI.poiCar; - return ``; -} - -/* Tankstellenposition im Einzelbeleg - dieselbe Nadelform wie beim Fahrzeug, - nur ohne Auto-Glyph (CI.poiL/poiS), damit beide Marker sofort auseinander- - zuhalten sind. Spitze unten wie bei poi-car, gleiche Ankerrechnung. */ -function tankstellenMarkerSVG(px = 32) { - const gross = px >= 34; - const box = gross ? 48 : 24; - const sil = gross ? CI.poiLSil : CI.poiSSil; - const icon = gross ? CI.poiL : CI.poiS; - return ``; -} - -/* Fahrzustand fürs Menü: kommt aus der Zündung (CAR.zuendung, vom Backend aus - demselben Sensor veröffentlicht, den auch fahrterkennung.py als maßgebliches - Signal nimmt). Ist kein Zündungssensor zugeordnet, bleibt der Zustand - ehrlich unbekannt, statt "steht" zu behaupten. - - Vorher stand hier `TRIPS[0].status === "offen"` - gleich zwei Fehler - übereinander, die sich gegenseitig verstärkt haben: - - 1. "offen" heißt NICHT "unterwegs", sondern "Daten noch unvollständig" - (die Strecke fehlt, siehe fahrtabschluss_logik.py). Eine beendete - Fahrt ohne Kilometerstand - etwa weil zu dem Zeitpunkt kein - KM_SENSOR zugeordnet war - bleibt für immer "offen". - 2. TRIPS[0] ist die ÄLTESTE Fahrt, nicht die neueste: - fahrten_veroeffentlichen() gibt profil.fahrten_lesen() unverändert in - Dateireihenfolge weiter, und die ist aufsteigend. - - Zusammen hieß das: die älteste jemals unvollständig gebliebene Fahrt hat - das Fahrzeug dauerhaft als "fahrend" angezeigt. An der Testinstanz war das - eine Fahrt, die seit 18 Tagen beendet war. - - parkplatz unterscheidet einen endgültigen Stillstand (laenger als die in - "Fahrten zusammenfassen" hinterlegte Pausenzeit) von einem blossen - Zwischenstopp. Das Feld ist vorbereitet, wird aber derzeit von keiner - Ansicht gelesen - eine "Letzter Parkplatz"-Anzeige gibt es noch nicht. */ -function letzteFahrt() { - let neueste = null; - for (const t of TRIPS) { - if (!t || !t.ts_end) continue; - if (!neueste || t.ts_end > neueste.ts_end) neueste = t; - } - return neueste; -} -function standortZustand() { - const t = letzteFahrt(); - const seitTs = t && t.ts_end ? new Date(t.ts_end).getTime() : null; - if (CAR.zuendung === true) return { faehrt: true, seitTs: null, parkplatz: false }; - const minuten = seitTs != null ? (Date.now() - seitTs) / 60000 : null; - return { - faehrt: false, - unbekannt: CAR.zuendung == null, - seitTs, - parkplatz: minuten != null && minuten >= (CONFIG.pauseMin ?? 15), - }; -} -function dauerSeitText(ts) { - if (ts == null) return "unbekannt"; - const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000)); - const tage = Math.floor(sekunden / 86400); - const stunden = Math.floor((sekunden % 86400) / 3600); - const minuten = Math.floor((sekunden % 3600) / 60); - if (tage > 0) return `${tage} Tg. ${stunden} Std. ${minuten} Min.`; - if (stunden > 0) return `${stunden} Std. ${minuten} Min.`; - return `${minuten} Min.`; -} - -/* Haversine-Näherung für die Distanzangabe oben links im Menü ("15 m"). */ -function distanzMeter(a, b) { - if (!a || !b) return null; - const R = 6371000, rad = Math.PI / 180; - const dLat = (b.lat - a.lat) * rad, dLon = (b.lon - a.lon) * rad; - const s = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); -} -function distanzText(m) { return m == null ? "" : m < 1000 ? Math.round(m) + " m" : de(m / 1000, 1) + " km"; } - -/* Zeile unter dem Fahrzeugnamen: Abstand Nutzer -> Fahrzeug, sonst der Grund, - warum er noch fehlt. */ -function standortAbstandText() { - if (!standortBekannt()) return ""; - if (USER_POS) return distanzText(distanzMeter(USER_POS, { lat: CAR.standortLat, lon: CAR.standortLon })) + " entfernt"; - return USER_POS_FEHLER || "Abstand wird ermittelt …"; -} -function standortErfassen() { - if (!navigator.geolocation) { USER_POS_FEHLER = "GPS offline"; standortAnsichtAktualisieren(); return; } - navigator.geolocation.getCurrentPosition( - (pos) => { - USER_POS = { lat: pos.coords.latitude, lon: pos.coords.longitude, genauigkeit: pos.coords.accuracy }; - USER_POS_TS = Date.now(); USER_POS_FEHLER = null; - standortAnsichtAktualisieren(); - }, - (err) => { - // Diese Zeile steht direkt unter dem Fahrzeugnamen, an der Stelle, wo - // sonst der Abstand zum Auto steht - sie handelt aber vom Standort - // DIESES Geräts, nicht vom Fahrzeug. "Standortzugriff verweigert" las - // sich dort so, als sei der Standort des Autos nicht abrufbar. - // - // Die verweigerte Freigabe bleibt trotzdem eigens benannt: sie ist der - // einzige Fall mit einer anderen Abhilfe (Freigabe erteilen statt auf - // GPS-Empfang warten), und "GPS offline" würde dort in die falsche - // Richtung schicken. - USER_POS_FEHLER = err.code === err.PERMISSION_DENIED ? "GPS-Freigabe fehlt" : "GPS offline"; - // Auch der Fehlschlag zaehlt als Versuch. Ohne diese Zeile greift die - // Drossel in standortErfassenFallsNoetig() nie, solange der Nutzer die - // Freigabe verweigert - und jedes Backend-Update loeste eine neue - // Hochgenauigkeits-Abfrage aus. - USER_POS_TS = Date.now(); - standortAnsichtAktualisieren(); - }, - { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } - ); -} -function standortErfassenFallsNoetig() { - // Auf USER_POS_TS statt auf USER_POS pruefen: sonst waere ein fehlgeschlagener - // Versuch kein Versuch, siehe Fehlerpfad oben. - if (USER_POS_TS && Date.now() - USER_POS_TS < 30000) return; - standortErfassen(); -} - -/* Reverse-Geocoding über die öffentliche Nominatim-API (OpenStreetMap) - - wie die Kartenkacheln oben (TILE_URL) ohne eigenen Schlüssel; bei diesem - Aufrufvolumen (ein Fahrzeug, nur bei geöffneter Standortansicht) im Rahmen - der Nominatim-Nutzungsbedingungen unproblematisch, genau wie das bereits - dokumentierte Leaflet-über-CDN-Vorgehen (siehe AGENTS.md). Cache-Schlüssel - auf 3 Nachkommastellen (~110 m) gerundet, damit ein erneutes Öffnen an - derselben Stelle keinen zweiten Abruf auslöst. */ -async function standortAdresseAufloesen(lat, lon) { - const key = lat.toFixed(3) + "," + lon.toFixed(3); - if (STANDORT_ADRESSE_KEY === key) return STANDORT_ADRESSE; - try { - const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "de" } }); - const daten = await res.json(); - const a = daten.address || {}; - const strasse = [a.road, a.house_number].filter(Boolean).join(" "); - STANDORT_ADRESSE = [strasse, [a.postcode, a.city || a.town || a.village].filter(Boolean).join(" ")].filter(Boolean).join(", ") || daten.display_name || ""; - } catch (e) { - // Den Schluessel bewusst NICHT setzen: sonst gilt ein einzelner - // fehlgeschlagener Abruf (Rate-Limit, kurzer Netzausfall) fuer diese Zelle - // die ganze Sitzung lang als "schon versucht", und es blieben dauerhaft nur - // die Koordinaten stehen. - STANDORT_ADRESSE = null; - return null; - } - STANDORT_ADRESSE_KEY = key; - return STANDORT_ADRESSE; -} -function standortAdresseLaden() { - if (!standortBekannt()) return; - standortAdresseAufloesen(CAR.standortLat, CAR.standortLon).then((adresse) => { - const el = $("#standortAdresse"); - if (el && adresse) el.textContent = adresse; - }); -} - -/* Gegenrichtung zu standortAdresseAufloesen: Adresse -> Koordinaten, für die - Tankstellennadel im Einzelbeleg. Nötig, weil der Beleg nur eine Anschrift - trägt und nichts im Backend station_lat/station_lon je befüllt - ohne diesen - Schritt bliebe die Karte dort dauerhaft ohne Nadel. - - Ergebnisse landen in localStorage statt nur im RAM: eine Tankstellenadresse - ändert ihre Lage nicht, und der Einzelbeleg wird oft genug erneut geöffnet, - dass ein Cache nur für die laufende Sitzung Nominatim unnötig oft anfassen - würde (deren Nutzungsbedingungen bitten ausdrücklich darum, Ergebnisse zu - cachen). Auch ein Fehlschlag wird vermerkt - als null -, damit eine Adresse, - die Nominatim nicht kennt, nicht bei jedem Öffnen erneut abgefragt wird. */ -const GEO_CACHE_KEY = "audi_dashboard_geocache"; -let GEO_CACHE = null; -function geoCacheLesen() { - if (GEO_CACHE) return GEO_CACHE; - try { GEO_CACHE = JSON.parse(localStorage.getItem(GEO_CACHE_KEY)) || {}; } - catch (e) { GEO_CACHE = {}; } - return GEO_CACHE; -} -function geoCacheSchreiben(schluessel, wert) { - const cache = geoCacheLesen(); - cache[schluessel] = wert; - try { localStorage.setItem(GEO_CACHE_KEY, JSON.stringify(cache)); } - catch (e) { /* voller/gesperrter Speicher: Cache bleibt dann RAM-only */ } -} -async function adresseAufloesen(adresse) { - const suche = (adresse || "").trim(); - if (!suche) return null; - const cache = geoCacheLesen(); - if (Object.prototype.hasOwnProperty.call(cache, suche)) return cache[suche]; - let treffer = null; - try { - const res = await fetch( - `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(suche)}`, - { headers: { "Accept-Language": "de" } } - ); - const daten = await res.json(); - if (Array.isArray(daten) && daten.length) { - treffer = { lat: parseFloat(daten[0].lat), lon: parseFloat(daten[0].lon) }; - } - } catch (e) { - // Netzfehler ist kein "Adresse gibt es nicht" - nichts merken, damit der - // nächste Aufruf es erneut versucht (vgl. standortAdresseAufloesen). - return null; - } - geoCacheSchreiben(suche, treffer); - return treffer; -} - -/* Setzt Nadel und Ausschnitt, sobald die Koordinaten da sind. Getrennt von - initMap(), weil der Abruf asynchron ist und die Karte bis dahin schon - steht. */ -function tankstelleAufKarteZeigen(karte, pos, f) { - if (!karte || !window.L || !pos) return; - karte.setView([pos.lat, pos.lon], 14); - const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${tankstellenMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] }); - window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte) - .bindPopup((f.station_name || "") + "
" + (f.station_address || "")); - // Position merken und den Knopf scharf schalten - er kann erst jetzt etwas - // Sinnvolles tun (bei Adressauflösung kommt das hier verzögert, siehe - // Aufrufstelle in initMap). - BELEG_STATION_POS = pos; - const knopf = ROOT && ROOT.getElementById("belegZentrieren"); - if (knopf) knopf.disabled = false; -} -function belegAufTankstelleZentrieren() { - if (!MAP || !BELEG_STATION_POS) return; - MAP.flyTo([BELEG_STATION_POS.lat, BELEG_STATION_POS.lon], Math.max(MAP.getZoom(), 14)); -} - -function standortKartenlink() { - return standortBekannt() ? `https://www.google.com/maps/dir/?api=1&destination=${CAR.standortLat},${CAR.standortLon}` : null; -} -async function standortTeilen() { - const url = standortKartenlink(); - if (!url) return; - const titel = `${CONFIG.fahrzeugtitel} · Standort`; - if (navigator.share) { - try { await navigator.share({ title: titel, url }); } catch (e) { /* vom Nutzer abgebrochen */ } - return; - } - try { await navigator.clipboard.writeText(url); hinweis("Link kopiert", "Der Standort-Link wurde in die Zwischenablage kopiert."); } - catch (e) { hinweis("Teilen nicht möglich", url); } -} - -/* Direkte DOM-Updates ohne render() (siehe Kopfkommentar) - hält SMAP und - ein offenes Menü am Leben, während neue Positionsdaten eintreffen. */ -function standortAnsichtAktualisieren() { - if (route.name !== "standort") return; - standortMarkerZeichnen(); - const dist = $("#standortDistanz"); - if (dist) dist.textContent = standortAbstandText(); -} - -function standortTiles() { - if (!SMAP || !window.L) return; - if (SMAP_TILE) SMAP.removeLayer(SMAP_TILE); - SMAP_TILE = SMAP_STIL === "satellit" - ? window.L.tileLayer(SAT_TILE_URL, { maxZoom: 19, attribution: SAT_ATTR }).addTo(SMAP) - : window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(SMAP); -} -function standortMarkerZeichnen() { - if (!SMAP || !window.L) return; - if (FAHRZEUG_MARKER) { SMAP.removeLayer(FAHRZEUG_MARKER); FAHRZEUG_MARKER = null; } - if (USER_MARKER) { SMAP.removeLayer(USER_MARKER); USER_MARKER = null; } - if (standortBekannt()) { - const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(38)}
`, iconSize: [38, 38], iconAnchor: [19, 36] }); - FAHRZEUG_MARKER = window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(SMAP); - FAHRZEUG_MARKER.on("click", () => standortMenuOeffnen()); - } - if (USER_POS) { - const icon = window.L.divIcon({ className: "user-pin-wrap", html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); - USER_MARKER = window.L.marker([USER_POS.lat, USER_POS.lon], { icon, zIndexOffset: -100 }).addTo(SMAP); - } -} -function standortAufFahrzeug() { if (SMAP && standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], Math.max(SMAP.getZoom(), 15)); } -function standortAufUser() { - if (!USER_POS) { standortErfassen(); return; } - if (SMAP) SMAP.flyTo([USER_POS.lat, USER_POS.lon], Math.max(SMAP.getZoom(), 15)); -} -function standortBeideZeigen() { - if (!SMAP) return; - if (standortBekannt() && USER_POS) SMAP.fitBounds([[CAR.standortLat, CAR.standortLon], [USER_POS.lat, USER_POS.lon]], { padding: [64, 64], maxZoom: 16 }); - else if (standortBekannt()) SMAP.flyTo([CAR.standortLat, CAR.standortLon], 15); - else if (USER_POS) SMAP.flyTo([USER_POS.lat, USER_POS.lon], 15); -} - -async function initStandortMap() { - STANDORTMENU_OFFEN = false; - await leafletLaden(); - const el = ROOT.getElementById("smap"); - if (!el || typeof window.L === "undefined") return; - SMAP = window.L.map(el, { zoomControl: false, attributionControl: false }); - SMAP_TILE = null; standortTiles(); - /* Erst Ansicht, dann Marker: Leaflet legt Marker-Symbole nur auf einer Karte - mit gesetztem Mittelpunkt an - vorher angehaengte Marker blieben unsichtbar. */ - if (standortBekannt()) SMAP.setView([CAR.standortLat, CAR.standortLon], 15); - else if (USER_POS) SMAP.setView([USER_POS.lat, USER_POS.lon], 15); - else SMAP.setView(kartenMittelpunkt(), 11); - standortMarkerZeichnen(); - standortBeideZeigen(); - standortAdresseLaden(); - standortErfassenFallsNoetig(); - setTimeout(() => SMAP && SMAP.invalidateSize(), 60); -} -async function initVorschauMap() { - await leafletLaden(); - const el = ROOT.getElementById("tmap"); - if (!el || typeof window.L === "undefined") return; - TMAP = window.L.map(el, { - zoomControl: false, attributionControl: false, dragging: false, - scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, - }); - TMAP_TILE = window.L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTR }).addTo(TMAP); - if (standortBekannt()) { - TMAP.setView([CAR.standortLat, CAR.standortLon], 14); - const icon = window.L.divIcon({ className: "fahrzeug-pin-wrap", html: `
${fahrzeugMarkerSVG(32)}
`, iconSize: [32, 32], iconAnchor: [15, 31] }); - window.L.marker([CAR.standortLat, CAR.standortLon], { icon }).addTo(TMAP); - } else { - TMAP.setView(kartenMittelpunkt(), 11); - } - standortErfassenFallsNoetig(); - setTimeout(() => TMAP && TMAP.invalidateSize(), 60); -} - -function standortMenuElement() { return ROOT.getElementById("standortmenu"); } -function standortMenuOeffnen() { const el = standortMenuElement(); if (el) el.classList.add("offen"); STANDORTMENU_OFFEN = true; } -function standortMenuSchliessen() { const el = standortMenuElement(); if (el) el.classList.remove("offen"); STANDORTMENU_OFFEN = false; } -function standortMenuUmschalten() { STANDORTMENU_OFFEN ? standortMenuSchliessen() : standortMenuOeffnen(); } - -/* Ziehen am Griff/Kopfbereich des Menüs - eigene Pointer-Events-Kette, - bewusst getrennt von der Wisch-zum-Löschen-Logik unten (andere Achse, - anderer Auslöser), manipuliert das Menü-DOM direkt (siehe Kopfkommentar - Standort-Abschnitt). */ -let smY0 = null, smOffenBeimStart = false; -/* Zugweg: alles ausser dem sichtbaren Anteil. Die Blatthoehe haengt am Inhalt, - deshalb wird sie beim Ziehen gemessen statt fest verdrahtet. */ -function standortMenuHub() { - const el = standortMenuElement(); - return el ? Math.max(0, el.offsetHeight - STANDORT_PEEK_PX) : 0; -} -function standortMenuVerdrahten() { - ROOT.addEventListener("pointerdown", (e) => { - if (route.name !== "standort" || !e.target.closest(".standortmenu-griffzone")) return; - smY0 = e.clientY; smOffenBeimStart = STANDORTMENU_OFFEN; - const el = standortMenuElement(); if (el) el.style.transition = "none"; - }); - ROOT.addEventListener("pointermove", (e) => { - if (smY0 === null) return; - e.preventDefault(); - const el = standortMenuElement(); if (!el) return; - const dy = e.clientY - smY0; - const hub = standortMenuHub(); - const basis = smOffenBeimStart ? 0 : hub; - const versatz = Math.max(0, Math.min(hub, basis + dy)); - el.style.transform = `translateY(${versatz}px)`; - }, { passive: false }); - ROOT.addEventListener("pointerup", (e) => { - if (smY0 === null) return; - const dy = e.clientY - smY0; - standortMenuGesteBeenden(); - if (Math.abs(dy) > 40) { dy < 0 ? standortMenuOeffnen() : standortMenuSchliessen(); } - else standortMenuUmschalten(); - }); - /* Ohne pointercancel bleibt smY0 gesetzt, sobald das System die Beruehrung - uebernimmt (iOS-Randgeste, Multitouch, eingehender Anruf). Der - pointermove-Handler oben ruft dann preventDefault() fuer jede weitere - Bewegung - Scrollen waere in der ganzen App blockiert, bis irgendwo ein - pointerup kommt, der dann ein dy aus einem veralteten Startpunkt - auswertet. Die drei anderen Zeigergesten der Datei machen das laengst - richtig. */ - ROOT.addEventListener("pointercancel", () => { - if (smY0 === null) return; - standortMenuGesteBeenden(); - }); -} - -/* Setzt den Gestenzustand zurueck und gibt das Blatt wieder an die - CSS-Uebergaenge ab - gemeinsam von pointerup und pointercancel genutzt. */ -function standortMenuGesteBeenden() { - const el = standortMenuElement(); - if (el) { el.style.transition = ""; el.style.transform = ""; } - smY0 = null; -} - -function standortKachel() { - return `
- -
`; -} - -/* Symbole aus dem Audi-CI-Satz (assets/icons/) - hier eingebettet, weil die - Panel-Datei ohne zusaetzliche Abrufe auskommen soll. */ -const CI = { - mapLayer: '', - carSearch: '', - gps: '', - poiCar: '', - /* poi-car-l - grosse Variante desselben CI-Symbols (48er-Raster), fuer die - Fahrzeugnadel auf der Vollbildkarte. Siehe fahrzeugMarkerSVG(). */ - /* poi-l/poi-s aus dem CI-Satz: neutrale Kartennadel mit Kreis, fuer die - Tankstellenposition im Einzelbeleg (poi-car bleibt dem Fahrzeug - vorbehalten, sonst waeren beide Nadeln auf einer Karte nicht zu - unterscheiden). Siehe tankstellenMarkerSVG(). */ - poiL: '', - poiS: '', - poiCarL: '', - /* Vollton-Silhouetten fuer die Kartennadeln: poi-car/poi sind reine Kontur- - formen (Ring aus zwei gegenlaeufigen Randlinien + duenne Detaillinien im - Inneren) - eine Pixelprobe ergab nur 13-40% gefuellte Flaeche und war auf - Kartenkacheln praktisch unsichtbar (Nutzer-Feedback "Pin ist transparent/ - durchsichtig"). Jede Silhouette ist exakt das erste Teilpfad-Segment des - jeweiligen CI-Pfads oben (die aeussere Ballonform, vor dem Punkt, der die - Ring-Innenlinie startet) - keine Neuzeichnung, nur der bereits im CI-Satz - enthaltene Umriss, mit fester Farbe statt currentColor gefuellt. Wird in - fahrzeugMarkerSVG()/tankstellenMarkerSVG() als unterste Ebene hinter dem - Original-Icon (dann weiss statt dunkel) gerendert. */ - poiCarLSil: '', - poiCarSil: '', - poiLSil: '', - poiSSil: '', - selectAll: '', - planRoute: '', - upload: '', - fuelRange: '', - parking: '', - /* battery-12v-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, - verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */ - battery12vL: '', - battery12vS: '', - /* edit-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert, - verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */ - editL: '', - editS: '', - /* oil-change-l/-s, inspection-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als - SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie - poi-l/poi-s. Die -s-Variante steht links neben der Kennzahl im - Nächster-Service-Block (vHome()) statt des vorherigen "bis zum/zur..." - Textes - die Serviceart wird jetzt ueber das Icon erkannt, nicht ueber - Worte. */ - oilChangeL: '', - oilChangeS: '', - inspectionL: '', - inspectionS: '', -}; -function ciSVG(pfad, px = 16) { - return ``; -} - -function vStandort() { - const zustand = standortZustand(), bekannt = standortBekannt(); - const distanz = standortAbstandText(); - const adresseKey = bekannt ? CAR.standortLat.toFixed(3) + "," + CAR.standortLon.toFixed(3) : null; - /* Solange die Adresse nicht aufgeloest ist, stehen die Koordinaten da - - kein Platzhaltertext, der haengen bleiben kann. */ - const koordinaten = bekannt ? CAR.standortLat.toFixed(4) + ", " + CAR.standortLon.toFixed(4) : ""; - const adresseText = !bekannt ? "Kein GPS-Signal vom Fahrzeug" - : STANDORT_ADRESSE_KEY === adresseKey ? (STANDORT_ADRESSE || koordinaten) : koordinaten; - const link = standortKartenlink(); - return ` -
`; -} - -/* -------------------------------------------------------------- Ansichten - Ab hier praktisch unverändert aus dem Prototyp übernommen. */ -function badge() { - // Offizielle Audi-Markenassets aus www/badges/ (positive = für hellen, - // negative = für dunklen Hintergrund) - welche Variante sichtbar ist, - // entscheidet reines CSS über :host([data-theme=...]) (siehe .badge img in - // audi-dashboard.css), genau wie beim Farbwechsel des restigen Interfaces - // beim Tag/Nacht-Umschalten. Kein Re-Render beim Theme-Wechsel nötig. - // - // Der Zusatz neben dem Badge (z. B. "Avant competition") setzt sich aus - // dem Modellnamen selbst zusammen - alles nach dem erkannten Badge-Präfix - // ("Avant"/"Sportback"/...) - plus der separat gepflegten Ausführung, statt - // aus dem alten, nicht mehr an die Modell-Auswahl gekoppelten - // CONFIG.fahrzeugzusatz (siehe Kopfkommentar bei FAHRZEUG_MODELLE). - const praefix = badgePraefix(); - if (!praefix) return ""; - const datei = BADGE_DATEIEN[praefix]; - const modellZusatz = (CONFIG.fahrzeugtitel || "").slice(praefix.length).trim(); - /* Die Ausfuehrung steht oft schon im Modellnamen ("RS 4 Avant competition" - plus Ausfuehrung "competition") - dann nicht ein zweites Mal anhaengen, - sonst steht dort "Avant competition competition". */ - const ausfuehrung = (CONFIG.ausfuehrung || "").trim(); - const doppelt = ausfuehrung && modellZusatz.toLowerCase().includes(ausfuehrung.toLowerCase()); - const zusatz = [modellZusatz, doppelt ? "" : ausfuehrung].filter(Boolean).join(" "); - const alt = esc(CONFIG.fahrzeugtitel) + " Badge"; - return `
- ${alt} - ${alt} - ${zusatz ? `${esc(zusatz)}` : ""}
`; -} - -function teaser() { - if (!TRIPS.length && !FILLS.length) return ""; - const t = TRIPS[0], f = FILLS[0]; - return `
Zuletzt - ${t ? `` : ""} - ${f ? `` : ""}
`; -} - -function naechsterTermin() { return termine().slice().sort((a, b) => a.ziel - b.ziel)[0]; } - -// CI-Icon je Serviceart statt eines Wort-Zusatzes ("bis zum Ölwechsel" o.ä.) - -// nur Ölwechsel/Inspektion haben eines, die Hauptuntersuchung nicht. Verwendet -// von vHome() (Nächster-Service-Block) und vService() (Anstehende Termine). -// Das Ölwechsel-Icon ist bei gleicher Boxgröße optisch kleiner als das -// Inspektion-Icon (füllt in seinem eigenen 24x24-Raster nur eine 24x10-Fläche -// statt fast der vollen Höhe) - deshalb eigene, größere Boxgröße statt einer -// gemeinsamen Konstante. -const ART_ICON = { "Ölwechsel": CI.oilChangeS, "Inspektion": CI.inspectionS }; -const ART_ICON_GROESSE = { "Ölwechsel": 26, "Inspektion": 20 }; - -function intervalle(t) { - const oel = CAR.oel, b = t.basis; - if (!b) return null; - const bauen = (km, monate) => ({ km: km ? b.km + km : null, datum: monatePlus(b.datum, monate) }); - if (t.art === "Hauptuntersuchung") { - const eig = bauen(null, 24); - return { hersteller: null, eigen: eig, prognose: eig.datum }; - } - const eKm = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm)) : 30000; - const eM = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)) : 24; - const eig = bauen(eKm, eM); - // "Herstellervorgabe" gilt bei Ölwechsel nur, wenn der Kunde in "Einrichten" - // tatsächlich die Herstellervorgabe gewählt hat - bei eigenem Intervall ist - // "eigen" maßgeblich, sonst würde anzeige = iv.hersteller || iv.eigen unten - // immer die Herstellerwerte zeigen und das Kundenintervall ignorieren. - const her = t.art === "Ölwechsel" ? (oel.modus === "hersteller" ? bauen(oel.herstellerKm, oel.herstellerMonate) : null) : bauen(30000, 24); - const perKm = prognose(eig.km).datum; - return { hersteller: her, eigen: eig, prognose: perKm && perKm < eig.datum ? perKm : eig.datum }; -} -/* Vom Fahrzeug selbst gemeldete Fälligkeit (VW Group EU Data Act, siehe - einstellungen.py) - ergänzt die App-eigene, aus dem Servicebuch berechnete - Prognose (intervalle()/terminText() oben), ersetzt sie aber bewusst nicht: - die Werkscomputer-Logik dahinter ist nicht bekannt und könnte von den - selbst eingestellten Intervallen abweichen. null, wenn das Fahrzeug dazu - nichts meldet (Sensor fehlt/nicht verfügbar). */ -function fahrzeugMeldungRoh(art) { - if (art === "Ölwechsel") return { ts: CAR.oelwechselFaelligTs, km: CAR.oelwechselFaelligKm }; - if (art === "Inspektion") return { ts: CAR.inspektionFaelligTs, km: CAR.inspektionFaelligKm }; - return { ts: null, km: null }; -} - -/* App-eigene Service-Prognose, eigenständig statt über die allgemeine - tagesleistung()/prognose()-Mischrechnung (die dafür zu träge ist, weil sie - den langfristigen Durchschnitt seit dem allerersten Servicebucheintrag mit - einfließen lässt): die Berechnung beginnt bewusst komplett neu ab dem Datum - der letzten tatsächlich durchgeführten Arbeit dieser Art. - - Logik (wie vorgegeben): km und Tage seit diesem Eintrag ergeben die aktuelle - Fahrleistung pro Tag; hochgerechnet auf die restlichen km bis zum - eingestellten Intervall ergibt das ein Datum - gedeckelt auf spätestens das - Intervall-Zeitlimit (z. B. 1 Jahr) nach dem letzten Eintrag, falls seither - wenig gefahren wurde. - - Gemeinsame Grundlage für Ölwechsel und Inspektion (siehe die beiden - Aufrufer darunter) - beide rechnen identisch, nur mit anderem Intervall. */ -function servicePrognose(stichwort, intervallKm, intervallMonate) { - if (!CAR.odoBekannt) return null; - const letzter = letzterEintrag(stichwort); - if (!letzter) return null; - const zielKm = letzter.km + intervallKm; - const restKm = Math.max(0, zielKm - CAR.odo); - const kappDatum = monatePlus(letzter.datum, intervallMonate); - let datum; - if (restKm <= 0) { - datum = heute(); - } else { - const tageSeit = (heute() - dat(letzter.datum)) / dTag; - const kmSeit = CAR.odo - letzter.km; - if (tageSeit <= 0 || kmSeit <= 0) { - // Noch keine belastbare Fahrleistung seit dem Eintrag bekannt (z. B. - // direkt danach) - ohne Rate keine seriöse Km-Prognose, also das - // Zeitlimit als einzig belastbare Grenze zeigen. - datum = kappDatum; - } else { - const kmProTagSeither = kmSeit / tageSeit; - const tageBisZiel = restKm / kmProTagSeither; - const berechnet = new Date(heute().getTime() + tageBisZiel * dTag); - datum = berechnet < kappDatum ? berechnet : kappDatum; - } - } - return { km: zielKm, restKm, datum }; -} - -function oelwechselPrognose() { - const oel = CAR.oel; - return servicePrognose("öl", - oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm), - oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate)); -} -/* Inspektion: dieselben 30.000 km / 24 Monate wie in intervalle()/termine(). - Angezeigt wird davon bewusst nur das Datum ("voraussichtlich am …") - die - Restkilometer stehen bei der Inspektion schon in der Zeile daneben. */ -function inspektionPrognose() { return servicePrognose("inspektion", 30000, 24); } - -/* Kleine graue Zweitzeile unter dem Termin-Titel, in Service-Kachel und - Service-Seite identisch aufgebaut. Leer, solange kein passender - Servicebucheintrag vorliegt. */ -function prognoseZeile(art) { - const p = art === "Ölwechsel" ? oelwechselPrognose() : art === "Inspektion" ? inspektionPrognose() : null; - if (!p) return ""; - const km = art === "Ölwechsel" ? de(p.restKm) + " km, " : ""; - return `
${km}voraussichtlich am ${dedat(p.datum)}`; -} - -function terminText(t) { - const restKm = t.km !== null && CAR.odoBekannt ? t.km - CAR.odo : null; - const restKmAbs = restKm !== null ? Math.abs(restKm) : null; - const haupt = t.zuerst === "km" && restKm !== null ? "in " + de(restKmAbs) + " km" : dedat(t.datum); - const neben = t.zuerst === "km" ? "voraussichtlich " + dedat(t.kmDatum) + " · " + dedat(t.datum) - : (t.km !== null ? "in " + de(restKmAbs) + " km" : (t.art === "Hauptuntersuchung" ? "" : "aus dem Servicebuch fortgeschrieben")); - return { haupt, neben, restKm }; -} - -/* Zählt seit LETZTES_UPDATE_TS (echter Zeitpunkt des letzten Fahrzeugstatus- - Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet - dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24, - dann Tage. */ -function standAlterText() { - if (LETZTES_UPDATE_TS == null) return "unbekannt"; - const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_TS) / 1000)); - if (sekunden < 60) return `vor ${sekunden} Sek.`; - const minuten = Math.floor(sekunden / 60); - if (minuten < 60) return `vor ${minuten} Min.`; - const stunden = Math.floor(minuten / 60); - if (stunden < 24) return `vor ${stunden} Std.`; - return `vor ${Math.floor(stunden / 24)} Tage`; -} -function standAlterTicken() { - if (!ROOT || !CAR) return; - const sy = $("#sync span"); if (sy) sy.textContent = standAlterText(); -} - -function vHome() { - const g = CAR.gesichert; - const statusText = g === null ? "Status unbekannt" : g ? "Sicher abgestellt" : "Nicht sicher abgestellt"; - const statusDot = g === null ? "warn" : g ? "" : "bad"; - const pct = CAR.tankPct; - const t = naechsterTermin(); - /* C2/C5/D1: Reihenfolge der myAudi-App - randloses Fahrzeugbild, Name und - Status, große Reichweite mit farbigem Balken, dann Aktionskacheln. Die - Kacheln führen zu Ansichten statt zu Fernsteuerung, die das Panel bewusst - nicht kann. */ - return ` -
${carImg("fest")}
-
- ${badge()} - ${esc(CONFIG.kennzeichen)} - -
-
-
-
${CAR.reichweite != null ? de(CAR.reichweite) : "–"}km
- ${CAR.tankBekannt ? pct + " %" : "–"} -
-
- Reichweite -
- ${(() => { - /* Kilometerstand und Service standen bis 2026-08-17 als zwei gleich große - Kacheln nebeneinander - gleiches Maß, gleiche Zahlengröße, gleiche Farbe, - obwohl nur eine davon ein Knopf war und die beiden Zahlen Grundver- - schiedenes meinen: der Kilometerstand ist ein gemessener Ist-Wert, der - sich kaum ändert, der Service-Wert ein Countdown, der täglich kleiner - wird. Zwei gleich große Zahlen nebeneinander behaupteten damit eine - Gleichrangigkeit, die inhaltlich nicht besteht. - - Jetzt: eine führende Kennzahl, darunter der Kilometerstand als ruhige - Wertezeile, getrennt durch eine Haarlinie - Hierarchie über Größe und - Farbe (Audi-CI) kombiniert mit Apple HIGs "differentiate controls from - content": der Chevron sitzt nur am Serviceblock, damit erkennbar bleibt, - welcher Teil auf die Service-Seite führt und welcher reine Anzeige ist. - Beides zusammen in einer normalen .tile mit Hintergrund - die Haarlinie - trennt dann innerhalb der Kachel, statt die Kachel zu ersetzen. */ - const kmZeile = `
- Kilometerstand -
${CAR.odoBekannt ? de(CAR.odo) : "–"}km
-
`; - if (!t) return `
- Nächster Service: -
kein Eintrag im Servicebuch
- ${kmZeile} -
`; - // naechsterTermin() waehlt bereits ueber alle drei Arten hinweg (Oelwechsel, - // Inspektion, Hauptuntersuchung) den zeitlich naechsten aus. - const restKm = t.km !== null && CAR.odoBekannt ? Math.abs(t.km - CAR.odo) : null; - const datum = t.kmDatum || t.datum; - const hauptwert = restKm !== null - ? `${de(restKm)}km` - : (datum ? dedat(datum) : "–"); - // Ohne Restkilometer steht das Datum selbst im Zahlenfeld - auf Nutzerwunsch - // jetzt in derselben Groesse wie die km-Zahl (vorher 30px, damit "15.04.2027" - // auf 375px nicht umbricht - am laufenden Panel geprueft, dass es bei 40px - // weiterhin einzeilig bleibt). - const zahlGroesse = 40; - // Icon statt "bis zum Ölwechsel"/"bis zur Inspektion" (nur fuer die beiden - // Arten, die eines haben). Die Hauptuntersuchung hat kein Restkilometer-Ziel - // und zeigt stattdessen den Art-Namen selbst unter dem Datum. - const artIcon = ART_ICON[t.art]; - const nebenzeile = restKm !== null && datum - ? `voraussichtlich am ${dedat(datum)}` - : t.art; - const figMarkup = artIcon - ? `
${ciSVG(artIcon, 31)}
${hauptwert}
` - : `
${hauptwert}
`; - return `
- - ${kmZeile} -
`; - })()} - ${standortKachel()} - ${teaser()}`; -} - -function vSicherheit() { - const eintraege = CAR.sicherheitscheck; - const g = CAR.gesichert; - const offen = eintraege.filter((e) => e.ok === false).length; - const unbekannt = eintraege.filter((e) => e.ok === null).length; - const sammelText = g === null - ? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert` - : g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`; - /* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe - Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */ - return ` -
${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}
-
- - ${esc(sammelText)} -
-
Geprüfte Punkte -
- ${eintraege.length ? eintraege.map((e, i, arr) => `
-
${esc(e.label)}
-
- - ${e.ok === null ? "unbekannt" : e.ok ? "in Ordnung" : "prüfen"} -
-
`).join("") : `
Keine Daten verfügbar
`} -
-
`; -} - -function vAudi() { - const r = CAR.reifen, a = r[r.aktiv.toLowerCase()]; - return ` -
${carImg("galerie")}
-
- ${badge()} - ${esc(CONFIG.kennzeichen)} -
- -
Zustand
-
Kilometerstand
${CAR.odoBekannt ? de(CAR.odo) + " km" : "unbekannt"}
-
Tankfüllung
${CAR.tankPct} % / ${de(CONFIG.tankvolumen * CAR.tankPct / 100, 1)} l
-
Batteriespannung
-
${CAR.batteriespannung != null ? de(CAR.batteriespannung, 1) + " V" : "unbekannt"} -
- - - `; -} - -/* ------------------------------------------------- Batteriespannungs-Verlauf - Handgebautes Diagramm ohne externe Bibliothek (kein Bundler im Projekt, - siehe INSTALL.md) - Zoom per Zwei-Finger-Pinch/Mausrad, Verschieben per - Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt. - bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit - Zoom/Pan flüssig bleiben. */ -let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null, bvYDomain = null; -const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger) -let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste} -let bvTapKandidat = null, bvBewegtSeit = false; -const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22; -const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" }); - -/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei - aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle - AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V, - 50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch - entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - - deutlich über dem Ruhespannungs-Maximum. - - Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf, - ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive - Fahrt). AGM_RUHE_MAX_V wird deshalb erst hier beim Anzeigen angewendet, - und zwar auf den Tagesminimalwert: der fällt typischerweise auf den - Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit - die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das - Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet), - tauchen entsprechend nicht in der Ladezustands-Statistik auf. Die Grenze - liegt bei 12,8 V (voller Ruhezustand laut obiger Recherche) statt z. B. - 13,2 V, weil alles darüber bereits vom Generator stammt, nicht von der - Batterie selbst - solche Werte würden den Ladezustand künstlich zu gut - aussehen lassen. */ -const AGM_RUHE_MAX_V = 12.8; -const BV_SOC_TABELLE = [ - [10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40], - [12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100], -]; -function bvSocProzent(v) { - const t = BV_SOC_TABELLE; - if (v <= t[0][0]) return 0; - if (v >= t[t.length - 1][0]) return 100; - for (let i = 0; i < t.length - 1; i++) { - const [v0, s0] = t[i], [v1, s1] = t[i + 1]; - if (v >= v0 && v <= v1) return Math.round(s0 + ((s1 - s0) * (v - v0)) / (v1 - v0)); - } - return 100; -} -function bvSocLabel(soc) { - return soc >= 90 ? "Sehr gut" : soc >= 70 ? "Gut" : soc >= 50 ? "Mittel" : soc >= 30 ? "Schwach" : "Kritisch"; -} -// Rot (leer/kritisch) -> Gelb (50 %) -> Grün (voll) entlang derselben drei -// Eckwerte wie die SOC-Tabelle oben (11,6 V/0 %, 12,2 V/50 %, 12,8 V/100 %) - -// dieselben Statusfarben wie beim Sicherheitscheck (--bad/--warn/--ok), -// damit die Skala zum Rest der App passt statt eigene Farben zu erfinden. -function bvHexZuRgb(hex) { - const h = hex.trim().replace("#", ""); - return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; -} -function bvMischen(a, b, t) { return a.map((x, i) => Math.round(x + (b[i] - x) * t)); } -function bvFarbstufen() { - const stil = getComputedStyle(HOST); - return { - bad: bvHexZuRgb(stil.getPropertyValue("--bad")), - warn: bvHexZuRgb(stil.getPropertyValue("--warn")), - ok: bvHexZuRgb(stil.getPropertyValue("--ok")), - }; -} -function bvFarbe(v, stufen) { - let rgb; - if (v <= 11.6) rgb = stufen.bad; - else if (v <= 12.2) rgb = bvMischen(stufen.bad, stufen.warn, (v - 11.6) / (12.2 - 11.6)); - else if (v <= 12.8) rgb = bvMischen(stufen.warn, stufen.ok, (v - 12.2) / (12.8 - 12.2)); - else rgb = stufen.ok; - return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; -} - -/* Gesundheitszustand (SOH) ist mit reiner Ruhespannungs-Beobachtung nur eine - Näherung, keine Kapazitätsmessung (ein Lasttest wäre dafür nötig - siehe - Recherche, u. a. battlebornbatteries.com/blogs/articles/battery-state-of- - health-soh) - deshalb hier bewusst als Trend über die Zeit ausgedrückt, - nicht als exakter Prozentwert: Durchschnitt der ersten vs. letzten Phase - der Aufzeichnung, in einem für alternde AGM-Batterien üblichen Rahmen - eingeordnet (übliche Lebensdauer 4-6 Jahre). */ -function bvSohBewertung() { - const TAG_MS = 86400000; - if (bvPunkte.length < 6) return { status: null, text: "Noch nicht genug Messwerte für eine Trendbewertung." }; - const spanneTage = (bvPunkte[bvPunkte.length - 1].t - bvPunkte[0].t) / TAG_MS; - if (spanneTage < 60) return { status: null, text: "Noch zu kurzer Zeitraum für eine Trendbewertung (mind. ~2 Monate nötig)." }; - const fensterMs = Math.min(90, spanneTage / 3) * TAG_MS; - const alt = bvPunkte.filter((p) => p.t <= bvPunkte[0].t + fensterMs); - const neu = bvPunkte.filter((p) => p.t >= bvPunkte[bvPunkte.length - 1].t - fensterMs); - const altD = sum(alt, (p) => p.v) / alt.length, neuD = sum(neu, (p) => p.v) / neu.length; - const delta = neuD - altD; - const status = delta >= -0.05 ? "ok" : delta >= -0.15 ? "warn" : "bad"; - const text = delta >= -0.05 - ? "Stabil - kein auffälliger Rückgang der Ruhespannung." - : delta >= -0.15 - ? "Leicht sinkend - im für alternde AGM-Batterien üblichen Rahmen." - : "Deutlicher Rückgang - kann auf eine alternde oder geschwächte Batterie hindeuten."; - return { status, text, delta }; -} - -function vBatterieverlauf() { - // Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem - // Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der - // echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver - // Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. - bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V) - .map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum })) - .sort((a, b) => a.t - b.t); - bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null; - - const genug = bvPunkte.length >= 2; - bvVollDomain = genug ? [bvPunkte[0].t, bvPunkte[bvPunkte.length - 1].t] : null; - bvDomain = genug ? [...bvVollDomain] : null; - // Y-Achse einmalig aus allen Punkten festlegen, nicht bei jedem Zoom/Pan - // neu aus den gerade sichtbaren - sonst "streckt" sich die Achse bei jeder - // Geste neu auf den jeweils sichtbaren Min/Max-Ausschnitt. - if (genug) { - let yMin = Math.min(...bvPunkte.map((p) => p.v)), yMax = Math.max(...bvPunkte.map((p) => p.v)); - if (yMax - yMin < 0.4) { const mitte = (yMax + yMin) / 2; yMin = mitte - 0.2; yMax = mitte + 0.2; } - const pad = (yMax - yMin) * 0.15; - bvYDomain = [yMin - pad, yMax + pad]; - } else { - bvYDomain = null; - } - - const stufen = bvFarbstufen(); - const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null; - const soc = letzter ? bvSocProzent(letzter.v) : null; - const soh = bvSohBewertung(); - const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)"); - const leerText = BATTERIEVERLAUF.length === 0 - ? "Noch keine Messwerte aufgezeichnet - ein Eintrag entsteht automatisch, sobald eine Spannungsquelle eingerichtet ist." - : "Noch zu wenige Messwerte für einen Verlauf."; - - return ` -
- Verlauf - ${genug ? ` -
- -
-
- - -
- - ` : ` - ${leerText} - `} -
- ${ciSVG(CI.battery12vS, 15)} - Ruhespannung -
-
-
- 11,6 V · leer - 12,2 V · 50 % - 12,8 V · voll -
-
-
- Bewertung -
-
Ladezustand (SOC)
-
- ${letzter - ? `${de(letzter.v, 1)} V · ${soc} % · ${bvSocLabel(soc)}` - : `unbekannt`}
-
Gesundheitszustand (SOH-Trend)
-
- ${soh.status - ? `${(soh.delta >= 0 ? "+" : "") + de(soh.delta, 2)} V` - : `-`}
-
- ${soh.text} -
`; -} - -function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); } -function bvSkalaY(v, yMin, yMax) { return BV_H - BV_MB - ((v - yMin) / (yMax - yMin)) * (BV_H - BV_MT - BV_MB); } -function bvDomainKlemmen(domain) { - let [a, b] = domain; - const volle = bvVollDomain[1] - bvVollDomain[0]; - let breite = Math.min(b - a, volle); - breite = Math.max(breite, volle * 0.02); - if (a < bvVollDomain[0]) { a = bvVollDomain[0]; b = a + breite; } - if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; } - return [a, b]; -} -function bvPlotRahmen(rect) { - return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) }; -} - -function bvZeichnen() { - const svg = $("#bvSvg"); - if (!svg || !bvDomain || !bvYDomain) return; - // Feste Y-Achse (siehe vBatterieverlauf) statt Neuberechnung aus dem - // gerade sichtbaren Zoom-Ausschnitt, damit die Achse beim Zoomen/Verschieben - // nicht mehr "streckt". - const [yMin, yMax] = bvYDomain; - - // etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am - // Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt - // weiterläuft - const spanne = bvDomain[1] - bvDomain[0]; - const rahmen = bvPunkte.filter((p) => p.t >= bvDomain[0] - spanne * 0.05 && p.t <= bvDomain[1] + spanne * 0.05); - const linie = rahmen.map((p) => `${bvSkalaX(p.t).toFixed(1)},${bvSkalaY(p.v, yMin, yMax).toFixed(1)}`).join(" "); - - const gitter = [0, 1, 2, 3].map((i) => { - const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax); - return ` - ${de(v, 1)}`; - }).join(""); - const xLabels = [0, 0.5, 1].map((f) => { - const x = BV_ML + f * (BV_W - BV_ML - BV_MR); - const t = bvDomain[0] + f * spanne; - return `${kurzdat(new Date(t))}`; - }).join(""); - const stufen = bvFarbstufen(); - const kreise = rahmen.map((p) => { - const idx = bvPunkte.indexOf(p); - const gewaehlt = idx === bvAusgewaehlt; - return ``; - }).join(""); - - svg.innerHTML = `${gitter}${kreise}${xLabels}`; - - const rangeEl = $("#bvRange"); - if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`; - const tip = $("#bvTooltip"); - if (tip) { - if (bvAusgewaehlt != null) { - const p = bvPunkte[bvAusgewaehlt]; - const zeit = new Date(p.minTs); - tip.style.display = "block"; - tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`; - } else { - tip.style.display = "none"; - } - } -} - -function bvNaechsterPunkt(clientX) { - const svg = $("#bvSvg"); if (!svg) return null; - const rect = svg.getBoundingClientRect(); - const zeit = bvDomain[0] + ((clientX - bvPlotRahmen(rect).linksPx) / bvPlotRahmen(rect).breitePx) * (bvDomain[1] - bvDomain[0]); - let beste = null, besterAbstand = Infinity; - bvPunkte.forEach((p, i) => { const a = Math.abs(p.t - zeit); if (a < besterAbstand) { besterAbstand = a; beste = i; } }); - return beste; -} - -let svAuswahl = 0; -function vService() { - const sv = CAR.service; - return ` -
Anstehende Termine - ${termine().map((t) => { - const artIcon = ART_ICON[t.art]; - const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : ""; - const kopf = `
${iconMarkup}${t.art}
`; - const iv = intervalle(t); - if (!iv) return `
-
${kopf}
-
kein Eintrag im Servicebuch
-
`; - const anzeige = iv.hersteller || iv.eigen; - const fm = fahrzeugMeldungRoh(t.art); - const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null; - // Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit - // eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim - // Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit - // dem Datum. Hauptuntersuchung bleibt bewusst ohne. - const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null; - const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null; - const hatMeldung = fm.km != null || fmDatum; - // Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem - // Herstellerintervall - die Restkilometerzahl daneben waere nur eine - // zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim - // eigenen (kuerzeren) Intervall ist sie eine echte Zusatzinformation. - const fussnote = hatMeldung && eigenOel - ? (CAR.oel.modus === "hersteller" - ? `voraussichtlich am ${dedat(eigenOel.datum)}` - : `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`) - : eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : ""; - const wert = hatMeldung - ? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}` - : (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum)); - // Eine Fahrzeugmeldung stammt immer aus dem werkseigenen Wartungsprogramm - // des Bordcomputers - das kennt das eigene, in "Einrichten" gewaehlte - // Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller - // als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback - // (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht. - const herstellervorgabe = hatMeldung || iv.hersteller; - return `
-
- ${kopf} - ${t.vereinbart ? `Werkstatt am ${dedat(new Date(t.vereinbart))}` : ""} -
- ${herstellervorgabe ? `
Herstellervorgabe
` : ""} -
${wert}
- ${fussnote ? `
${fussnote}
` : ""} -
`; }).join("")} -
-
Termin vereinbaren - ${(() => { const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; - const vorschlag = CAR.service.vereinbart[t.art] || (t.ziel ? t.ziel.toISOString().slice(0, 10) : ""); - return ` -
-
-
-
- - ${CAR.service.vereinbart[t.art] ? `` : ""}`; })()} -
-
- Autohaus - -
${esc(sv.autohaus.name)}
- ${esc(sv.autohaus.adresse)} - ${esc(sv.autohaus.tel)} - ${sv.autohaus.mail ? `E-Mail schreiben` : ""} - In Karten öffnen -
-
Servicebuch - ${sv.buch.length ? sv.buch.map((e, i) => swipeZeile(`sbuch:${i}`, ``)).join("") : `
- Noch keine Einträge -

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

-
`} - -
`; -} - -function vIdent() { - return ` -
Fahrzeug -
-
Modell
${esc(CONFIG.fahrzeugtitel)}
-
Ausführung
${CONFIG.ausfuehrung ? esc(CONFIG.ausfuehrung) : "noch offen"}
-
Fahrgestellnummer
${esc(CAR.fin)}
-
Motor und Leistung
${esc(CAR.details)}
-
Erstzulassung
${esc(CAR.erstzulassung)}
-
Kennzeichen
${esc(CONFIG.kennzeichen)}
-
Zulassung
Deutschland
-
- ${CAR.technik.map((g) => `
${esc(g.gruppe)} -
- ${g.posten.map((pp, i) => `
-
${esc(pp[0])}
-
${pp[1] ? esc(pp[1]) : "noch offen"}
`).join("")} -
`).join("")} - ${CAR.ausstattung.map((g) => `
${esc(g.gruppe)} -
- ${g.sonder.map((x, i) => `
-
${esc(x)}
`).join("")} -
`).join("")} - `; -} - -function monatePlus(datum, monate) { const d = dat(datum); d.setMonth(d.getMonth() + monate); return d; } -function letzterEintrag(stichwort) { return CAR.service.buch.find((e) => e.art.toLowerCase().includes(stichwort)) || null; } -function termine() { - const oel = CAR.oel; - const oelKm = oel.modus === "hersteller" ? oel.herstellerKm : Math.min(oel.km, oel.herstellerKm); - const oelM = oel.modus === "hersteller" ? oel.herstellerMonate : Math.min(oel.monate, oel.herstellerMonate); - const bauen = (art, basis, kmIntervall, monate, ersatzDatum) => { - const kmZiel = basis && kmIntervall ? basis.km + kmIntervall : null; - const datZiel = basis ? monatePlus(basis.datum, monate) : (ersatzDatum ? monatePlus(ersatzDatum, monate) : null); - const kmDatum = kmZiel !== null ? prognose(kmZiel).datum : null; - const zuerst = (kmDatum && datZiel) ? (kmDatum < datZiel ? "km" : "zeit") : (kmDatum ? "km" : "zeit"); - return { art, km: kmZiel, datum: datZiel, kmDatum, zuerst, basis, - ziel: zuerst === "km" ? kmDatum : datZiel, vereinbart: CAR.service.vereinbart[art] || null }; - }; - const oelTermin = bauen("Ölwechsel", letzterEintrag("öl"), oelKm, oelM); - // Genauere, eigenständige Ölwechsel-Prognose (siehe oelwechselPrognose()) - // ersetzt hier den km-Anteil aus dem generischen bauen() oben, damit - // Übersicht, Service-Kachel und Anstehende Termine überall dieselbe Zahl - // für "wann ist der nächste Ölwechsel fällig" zeigen statt drei leicht - // unterschiedlicher Werte. - const oelProg = oelTermin.basis ? oelwechselPrognose() : null; - if (oelProg) { - oelTermin.km = oelProg.km; - oelTermin.kmDatum = oelProg.datum; - oelTermin.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km"; - oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum; - } - return [ - oelTermin, - bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24), - bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24, - CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null), - ]; -} - -function vWerkstatt() { - const a = CAR.service.autohaus; - return ` -
Angaben zum Autohaus -
-
-
-
-
-
`; -} - -function vSbuch() { - const i = parseInt(route.id, 10), e = CAR.service.buch[i]; - if (!e) return `
Eintrag nicht gefunden
`; - const istEntwurf = entwurfIndex === i; - const artBekannt = SERVICE_ARTEN.includes(e.art); - return ` -
${esc(e.art)} -
${de(e.km)}km
- ${e.datum} · ${esc(e.werkstatt)} -
-
Eintrag -
-
- km
-
-
-
-
-
- Kosten fließen in die Jahresübersicht ein -
-
Durchgeführte Arbeiten - - Beim Verkauf ist diese Liste mehr wert als die Rechnung — - sie zeigt, dass gepflegt wurde -
- ${istEntwurf ? `` - : ``}`; -} - -function vVers() { - const v = CAR.versicherung; - return ` -
Versicherung - Schaden Notruf 24/7 - - - Inland · ${esc(v.service)} - - Ausland · ${esc(v.notfall)} -
-
Jahresbeitrag ab ${dedat(new Date(v.gueltigAb))} - -
${eur(v.beitrag)}
- ${esc(v.zahlweise)} · enthält ${eur(v.steueranteil)} € Versicherungsteuer (${esc(v.steuersatz)}) -
- ${v.teile.map((t, i) => `
-
${esc(t.bez)}${t.sf ? `
${esc(t.sf)} · Beitragssatz ${esc(t.satz)}
` : ""} -
${eur(t.betrag)} €${v.beitrag ? de(t.betrag / v.beitrag * 100, 0) : 0} %
`).join("")} -
-
- ${v.teile.map((t, i) => ``).join("")} -
-
-
Vertrag - -
-
Gesellschaft
${esc(v.gesellschaft)}
-
Umfang
${esc(v.vertrag)}
-
Vertragsnummer
${esc(v.nummer)}
- ${v.selbst.map(([b, w]) => `
Selbstbeteiligung ${esc(b)}
${esc(w)}
`).join("")} -
Schadenfreiheitsklasse
-
${v.teile.map((t) => t.sf).filter(Boolean).join(" / ")}zuvor ${v.teile.map((t) => t.sfAlt).filter(Boolean).join(" / ")}
-
- -
- ${vSteuer()}`; -} - -function vNotrufBearbeiten() { - const v = CAR.versicherung; - return ` -
Rufnummern ändern -
-
-
-
-
`; -} - -function vBeitrag() { - const v = CAR.versicherung; - return ` -
Beitrag anpassen -
-
-
-
-
-
-
-
-
-
-
-
- Der Jahresbeitrag oben ist die Summe dieser drei Posten und - rechnet sich beim Speichern automatisch neu — "Gültig ab" steuert den Titel der Jahresbeitrag-Kachel -
`; -} - -function vVertragsdetails() { - const v = CAR.versicherung; - return ` - - ${v.leistungen.map((g) => `
${esc(g.gruppe)} -
- ${g.posten.map((x, i) => `
-
${esc(x)}
`).join("")} -
`).join("")}`; -} - -function vVertragBearbeiten() { - const v = CAR.versicherung; - return ` -
Vertrag -
-
-
-
-
-
-
-
Selbstbeteiligung - ${v.selbst.map(([b, w], i) => ` -
- - - - - - -
`).join("")} - ${!v.selbst.length ? `Keine Selbstbeteiligung hinterlegt` : ""} - -
-
Schadenfreiheitsklasse · zuvor -
-
-
-
- Die aktuelle Schadenfreiheitsklasse änderst du unter - "Beitrag anpassen" — hier steht nur der vorherige Wert für den Vergleich. -
`; -} - -function vSchutz() { - const v = CAR.versicherung, sb = v.schutzbrief; - return ` -
Die Entscheidung am Schadenort -
- Diese drei Leistungen schließen einander aus. Wer eine nimmt, verliert den Anspruch auf die - anderen beiden.
-
- ${sb.entweder.map((x, i) => `
-
${esc(x.bez)}
-
${esc(x.grenze)}${x.zusatz ? `${esc(x.zusatz)}` : ""}
`).join("")} -
- Mobilität wird binnen 60 Minuten organisiert -
-
Wann der Schutzbrief greift -
- ${sb.ausloeser.map((a, i) => `
-
${esc(a)}
`).join("")} -
- Gilt in ${esc(sb.gilt)} - Versichert: ${esc(sb.versichert)} -
- ${sb.gruppen.map((g) => `
${esc(g.titel)} -
- ${g.posten.map((pp, i) => `
-
${esc(pp[0])}${pp[2] ? `
${esc(pp[2])}` : ""}
-
${esc(pp[1])}
`).join("")} -
`).join("")} -
Nicht versichert -
- ${sb.nicht.map((n, i) => `
-
${esc(n[0])}
- ${esc(n[1])}
`).join("")} -
-
Deine Pflichten -
- ${sb.pflichten.map((x, i) => `
-
${esc(x)}
`).join("")} -
-
${esc(sb.rang)}
- ${esc(sb.stand)} -
`; -} - -function vSteuer() { - const t = CAR.steuer; - return ` -
Kfz-Steuer - -
-
Betrag
${eur(t.betrag)} €${t.zeitraum === "jährlich" ? "im Jahr" : esc(t.zeitraum)}
-
Nächste Fälligkeit
-
${t.faellig ? dedat(new Date(t.faellig)) : "unbekannt"}
-
-
`; -} - -function vSteuerBearbeiten() { - const t = CAR.steuer; - return ` -
Kfz-Steuer -
-
-
-
-
-
-
-
-
`; -} - -/* B5: Die Wahl gilt dauerhaft, nicht nur bis zum Reload - sie wird beim Aufbau - der Menüleiste wieder angewendet. */ -const SCHRIFT_KEY = "audi-dashboard-tableiste-schrift"; -let beschriftung = (() => { - try { return localStorage.getItem(SCHRIFT_KEY) !== "aus"; } catch (e) { return true; } -})(); -let bodenAn = true; -function csvDatei(name, kopf, zeilen) { - const txt = [kopf.join(";")].concat(zeilen.map((r) => r.join(";"))).join("\r\n"); - const a = document.createElement("a"); - a.href = "data:text/csv;charset=utf-8," + encodeURIComponent("" + txt); - a.download = name; document.body.appendChild(a); a.click(); a.remove(); -} - -function vEinst() { - return ` -
Fahrzeug einrichten - ${einrichtenOffen ? ` -
-
-
-
-
-
-
-
-
-
- Ölwechsel-Intervall -
- - -
- ${CAR.oel.modus === "hersteller" - ? `
-
Laufleistung
${de(CAR.oel.herstellerKm)} km
-
Zeit
${CAR.oel.herstellerMonate} Monate
-
` - : `
-
-
-
`} - - - - Legt Fahrten, Tankvorgänge und Spannungswerte für einen vergangenen Zeitraum - nachträglich an — aus dem Verlauf, den Home Assistant zu den oben zugeordneten - Sensoren bereits aufgezeichnet hat. - ` : ""} - -
-
Darstellung -
- - -
-
- -
- Aus zeigt nur die Symbole und gibt etwas Höhe frei -
- -
- Angedeutete Fahrbahn mit Schatten, nur bei Außenansichten -
-
Bild der Übersicht -
-
- - Auf das Bild tippen, um für diese Ansicht ein eigenes Foto hochzuladen oder zu ersetzen. - ${(() => { - const info = bildInfo(startIndex()); - return `
-
- ${bildMitPlatzhalter(info.src, info.datei, info.label, "carfix", null, true)} -
- - ${bildMenuOffen === info.datei ? ` -
-
- - -
` : ""} -
`; - })()} -
-
Kraftstoff-Rabatt -
-
- ${CAR.smartdeal.aktiv ? ` -
- ${dedat(new Date(CAR.smartdeal.laeuftAb))}
` : - `Ersparnis und Bildmarke werden ausgeblendet`} - ${smartdealPopupOffen ? ` -
-
-
-
-
- - -
-
` : ""} -
-
Fahrten zusammenfassen -
- Minuten
- Steigst du kürzer als diese Zeit aus — etwa zum Tanken —, - bleibt es eine zusammenhängende Fahrt statt zweier -
-
Fahrzeugprofil - - Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten, - Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur - diese Datei getauscht — die App selbst bleibt unverändert. - - -
-
Backup - - Sichert Fahrzeugprofil, Fahrten und Tankvorgänge gemeinsam. "Jetzt erstellen" legt eine - Kopie auf dem Server ab, "Exportieren"/"Importieren" übertragen den aktuellen - Gesamtstand als einzelne Datei. - ${CONFIG.letztesBackup ? `Letztes Backup: - ${dedat(new Date(CONFIG.letztesBackup))} · ${new Date(CONFIG.letztesBackup).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : ""} - -
-
- - -
-
Daten ausgeben - - Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in - Tabellenprogrammen aufgehen. - - - -
-
Version -
-
Dashboard
${esc(CONFIG.version)}
-
Fahrzeugdaten
FMM003Zündung · Batterie
-
Position
FMM003GPS-Tracker
-
- ${(() => { - const s = UPDATE_STATUS; - if (!s || s.stand === "keine_quelle") return ` - ${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"} - `; - if (s.stand === "fehler") return ` - ${esc(s.fehler || "Update-Prüfung fehlgeschlagen")} - `; - if (s.stand === "geprueft" && s.verfuegbar) return ` - Update verfügbar — Version ${esc(s.neue_version)} - `; - return ` - Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""} - `; - })()} -
`; -} - -let nmOffen = false; -let einrichtenOffen = false; -let bildMenuOffen = null; -let smartdealPopupOffen = false; -// "sommer"|"winter", welcher Reifensatz gerade sein km-Korrektur-Feld offen -// hat (Editier-Symbol neben der km-Anzeige) - null wenn keins offen ist. -let kmOffen = null; - -/* --------------------------------------------------------- Setup-Popup - Sensor-zu-Entität-Zuordnung (Einstellungen -> Fahrzeug einrichten -> - Setup). setupZuordnung ist eine Arbeitskopie, die erst bei "Speichern" - an den Service geht - Abbrechen verwirft sie einfach ohne Nebeneffekt. */ -let setupOffen = false; -let setupZuordnung = null; // {KEY: "entity.id"} bzw. {KEY: [4 IDs]} für Listenfelder -let setupZuordnungStart = null; // Schnappschuss von setupZuordnung beim Öffnen - für den Neustart-Hinweis (welche trigger-gebundenen Felder haben sich geändert) -let setupNurPassend = true; // globaler Schalter: Kandidaten auf passende Domain/Einheit einschränken -let setupSucheOffen = null; // Feld-Id ("KEY" oder "KEY:idx") des offenen Dropdowns, sonst null -let setupSuchtext = ""; -let setupSpeichertLaeuft = false; -let ALLE_ENTITAETEN = []; - -function setupKatalog() { return (ENTITAETEN && ENTITAETEN.katalog) || []; } - -function alleEntitaeten() { - if (!HASS || !HASS.states) return []; - return Object.keys(HASS.states).map((id) => { - const attrs = (HASS.states[id] && HASS.states[id].attributes) || {}; - return { id, name: attrs.friendly_name || id, domain: id.split(".")[0], unit: attrs.unit_of_measurement || "", deviceClass: attrs.device_class || "" }; - }); -} - -function entitaetName(id) { - if (!id) return ""; - const treffer = ALLE_ENTITAETEN.find((e) => e.id === id); - return treffer ? treffer.name : id; -} - -/* Höher = besser passend: Stichwort- und Freitext-Treffer zählen am meisten, - Domain/Einheit/Device-Class sind Zusatzpunkte fürs Sortieren, wenn der - Schalter "Nur passende Sensoren" aus ist (dann filtern sie nicht hart, - siehe entitaetKandidaten()). */ -function entitaetScore(feld, e, suchtext) { - let score = 0; - const hay = (e.id + " " + e.name).toLowerCase(); - for (const wort of feld.stichworte || []) if (hay.includes(wort)) score += 5; - if (feld.domains && feld.domains.includes(e.domain)) score += 3; - if (feld.device_classes && feld.device_classes.length && feld.device_classes.includes(e.deviceClass)) score += 3; - if (feld.units && feld.units.length && feld.units.includes(e.unit)) score += 2; - if (suchtext && hay.includes(suchtext.trim().toLowerCase())) score += 10; - return score; -} - -function entitaetKandidaten(feldKey, suchtext, aktuellerWert) { - const feld = setupKatalog().find((f) => f.key === feldKey); - if (!feld) return []; - const text = (suchtext || "").trim().toLowerCase(); - const liste = ALLE_ENTITAETEN.filter((e) => { - if (e.id === aktuellerWert) return true; - if (setupNurPassend) { - if (feld.domains && feld.domains.length && !feld.domains.includes(e.domain)) return false; - if (feld.units && feld.units.length && !feld.units.includes(e.unit)) return false; - } - if (text && !(e.id.toLowerCase().includes(text) || e.name.toLowerCase().includes(text))) return false; - return true; - }); - liste.sort((a, b) => entitaetScore(feld, b, suchtext) - entitaetScore(feld, a, suchtext)); - return liste.slice(0, 60); -} - -/* Ab diesem Wert gilt ein Vorschlag als belastbar. entitaetScore() vergibt für - einen reinen Domain-Treffer bereits 3 Punkte - eine Schwelle von 3 wäre - deshalb immer erfüllt und hätte jedes leere Feld mit dem erstbesten Sensor - der passenden Domain belegt (Reihenfolge aus Object.keys(HASS.states), also - willkürlich). 5 verlangt mindestens einen Stichworttreffer. */ -const SETUP_VORSCHLAG_SCHWELLE = 5; - -/* Bester Vorschlag für ein Feld, der noch nicht anderweitig vergeben ist. - Das "noch nicht vergeben" ist der Punkt: ohne diese Prüfung bekamen alle - vier Positionen einer Liste (Türen, Fenster, Schlösser) denselben Sensor, - weil der Vorschlag nicht vom Index abhängt. Der Sicherheitscheck hätte - danach viermal dieselbe Tür geprüft und "Sicher abgestellt" gemeldet, - obwohl drei Türen nie geprüft wurden. */ -function setupVorschlag(feld, schonVergeben) { - for (const kandidat of entitaetKandidaten(feld.key, "", "")) { - if (schonVergeben.has(kandidat.id)) continue; - if (entitaetScore(feld, kandidat, "") < SETUP_VORSCHLAG_SCHWELLE) return ""; - return kandidat.id; - } - return ""; -} - -function setupOeffnen() { - if (!setupBereit()) return; - ALLE_ENTITAETEN = alleEntitaeten(); - setupNurPassend = true; - const werte = (ENTITAETEN && ENTITAETEN.werte) || {}; - const zuordnung = {}; - /* Bereits gesetzte und bereits vorgeschlagene IDs, damit kein Sensor - zweimal landet. Vorbelegte Werte kommen zuerst hinein. */ - const vergeben = new Set(); - for (const feld of setupKatalog()) { - const vorhandeneWerte = feld.liste ? (werte[feld.key] || []) : [werte[feld.key]]; - for (const id of vorhandeneWerte) if (id) vergeben.add(id); - } - for (const feld of setupKatalog()) { - if (feld.liste) { - zuordnung[feld.key] = feld.positionen.map((_, i) => { - const vorhanden = (werte[feld.key] || [])[i]; - if (vorhanden) return vorhanden; - const vorschlag = setupVorschlag(feld, vergeben); - if (vorschlag) vergeben.add(vorschlag); - return vorschlag; - }); - } else { - const vorhanden = werte[feld.key]; - if (vorhanden) { zuordnung[feld.key] = vorhanden; continue; } - const vorschlag = setupVorschlag(feld, vergeben); - if (vorschlag) vergeben.add(vorschlag); - zuordnung[feld.key] = vorschlag; - } - } - setupZuordnung = zuordnung; - setupZuordnungStart = JSON.parse(JSON.stringify(zuordnung)); - setupSucheOffen = null; setupSuchtext = ""; setupSpeichertLaeuft = false; - setupOffen = true; render(); -} - -/* Das Setup lässt sich erst öffnen, wenn der Katalog wirklich da ist. - Ohne diese Prüfung öffnete sich das Fenster leer (setupKatalog() liefert - dann []), und ein Klick auf "Speichern" schrieb {} - was entitaeten.json - vollständig ersetzt und damit ALLE Zuordnungen löscht. Weil die Werte im - laufenden Prozess erhalten bleiben, wäre das erst beim nächsten Neustart - aufgefallen. */ -function setupBereit() { - if (setupKatalog().length) return true; - hinweis("Setup noch nicht bereit", - "Die Liste der Sensor-Rollen wurde noch nicht vom Server geladen. Nach einem Neustart dauert das einen Moment - bitte gleich noch einmal versuchen."); - return false; -} -function setupSchliessen() { - setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; render(); -} - -/* Standardwert (aus einstellungen.py, vor jedem Override) für ein Feld bzw. - eine Listenposition - Grundlage für den Zurücksetzen-Button. */ -function setupStandardwert(key, idx) { - const standard = (ENTITAETEN && ENTITAETEN.standardwerte) || {}; - const wert = standard[key]; - if (idx == null) return wert || ""; - return (wert || [])[idx] || ""; -} - -/* Entity-IDs, die im Arbeitsstand mehr als einer Rolle zugeordnet sind. */ -function setupDuplikate() { - const zaehler = {}; - for (const feld of setupKatalog()) { - const werte = feld.liste ? (setupZuordnung[feld.key] || []) : [setupZuordnung[feld.key]]; - for (const id of werte) { if (id) zaehler[id] = (zaehler[id] || 0) + 1; } - } - return Object.keys(zaehler).filter((id) => zaehler[id] > 1); -} - -/* Labels der trigger-gebundenen Felder (ZUENDUNG/KM/TANK), deren Wert sich - seit dem Öffnen des Popups geändert hat - für den Neustart-Hinweis nach - dem Speichern. */ -function setupGeaenderteTriggerFelder() { - if (!setupZuordnungStart) return []; - return setupKatalog() - .filter((f) => f.trigger_gebunden && JSON.stringify(setupZuordnung[f.key]) !== JSON.stringify(setupZuordnungStart[f.key])) - .map((f) => f.label); -} - -function setupSpeichernAusfuehren() { - // Zweiter Riegel gegen den Datenverlust-Pfad: selbst wenn das Fenster auf - // irgendeinem Weg leer aufgeht, wird nichts geschrieben. overrides_schreiben() - // ersetzt die Datei vollstaendig - ein leeres Objekt loescht alles. - if (!setupZuordnung || !Object.keys(setupZuordnung).length) { - hinweis("Nichts zu speichern", "Es ist keine Zuordnung geladen. Bitte das Setup schliessen und neu oeffnen."); - return; - } - setupSpeichertLaeuft = true; render(); - (async () => { - try { - await HASS.callService("pyscript", "audi_dashboard_entitaeten_schreiben", { zuordnung_json: JSON.stringify(setupZuordnung) }); - } catch (err) { - setupSpeichertLaeuft = false; hinweis("Speichern fehlgeschlagen", err.message); return; - } - const geaendert = setupGeaenderteTriggerFelder(); - setupOffen = false; setupSucheOffen = null; setupZuordnung = null; setupZuordnungStart = null; setupSpeichertLaeuft = false; render(); - if (geaendert.length) { - bestaetigen("Neustart nötig", `Wirkt für ${geaendert.join(", ")} erst nach einem Neustart von Home Assistant.`, "Jetzt neu starten", setupNeustartAusloesen, false); - } - })(); -} - -function setupNeustartAusloesen() { - (async () => { - try { - await HASS.callService("pyscript", "audi_dashboard_neustart", {}); - hinweis("Neustart ausgelöst", "Home Assistant startet neu - die App lädt sich danach automatisch neu."); - } catch (err) { - hinweis("Neustart fehlgeschlagen", err.message); - } - })(); -} - -/* Warnhinweis, falls der aktuell gewählte Sensor keinen brauchbaren Wert - liefert - sichtbar, bevor gespeichert wird, statt es erst hinterher in - der App zu bemerken. */ -function entitaetStatusWarnung(wert) { - if (!wert) return ""; - const zustand = HASS.states[wert] ? HASS.states[wert].state : null; - if (zustand === null) return `
Entität nicht gefunden.
`; - if (zustand === "unavailable" || zustand === "unknown") return `
Aktuell „${esc(zustand)}" - kein Wert vom Sensor.
`; - return ""; -} -function vReifen() { - const r = CAR.reifen; - const feld = (k, schluessel, bez, typ) => { - const t = r[k]; - return `
-
`; - }; - const satz = (k) => { - const t = r[k], aktiv = r.aktiv.toLowerCase() === k; - const radDatei = `rad-${k}.webp`; - const radLabel = k === "sommer" ? "Sommerrad" : "Winterrad"; - const satzLabel = k === "sommer" ? "Sommerräder" : "Winterräder"; - return `
-
-
- ${satzLabel} -
- - ${t.km != null ? de(t.km) : "–"}km -
-
-
- ${bildMitPlatzhalter(`/local/bilder/${radDatei}?v=${bildVersion}`, radDatei, radLabel, "radbild")} -
-
- ${kmOffen === k ? ` -
-
- km
- - Korrigiert nur den bisher gefahrenen Stand - weitere Fahrten mit diesem - Satz zählen ab hier weiter dazu. - - -
` : ""} - - ${bildMenuOffen === radDatei ? ` -
-
- - -
` : ""} - -
- ${feld(k, "marke", "Hersteller")} - ${feld(k, "modell", "Modell")} - ${feld(k, "mass", "Größe")} - ${feld(k, "dot", "DOT")} -
- - - / - - bar - -
- ${feld(k, "kommentar", "Kommentar")} -
-
`; - }; - return ` -
Anzugsmoment der Radschrauben - -
${de(r.anzugsmoment)}Nm
- ${nmOffen ? ` -
-
- Nm
- - -
` : ""} -
-
Wechseltermin -
-
-
-
- -
- ${satz("sommer")} - ${satz("winter")}`; -} - -/* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden - Fahrt - dieselben Felder an beiden Stellen, damit sie nicht auseinander- - laufen können (gleiches Muster wie tankFelder()/tankFormularWerte()). Die - Felder decken genau das ab, was die Einzelfahrt-Seite anzeigt; Verbrauch, - Status und km-Quelle bleiben außen vor, die meldet das Fahrzeug bzw. leitet - das Backend ab. */ -/* Gespeichert bleibt der kleingeschriebene Schlüssel - er wird an mehreren - Stellen verglichen (Pille auf der Einzelfahrt, Statistik "Arbeitsweg", CSV) - und steht so auch in allen bereits erfassten Fahrten. Angezeigt wird er - groß, deshalb artText() statt eines geänderten Werts. */ -const FAHRT_ARTEN = ["privat", "arbeitsweg"]; -const artText = (a) => (a ? a.charAt(0).toUpperCase() + a.slice(1) : ""); - -/* "1 Std. 15 Min." statt "75 min" - bei mehrstündigen Fahrten liest sich die - reine Minutenzahl schlecht. */ -function dauerText(sekunden) { - if (sekunden == null || !isFinite(sekunden)) return "–"; - const min = Math.max(0, Math.round(sekunden / 60)); - const std = Math.floor(min / 60); - return std ? `${std} Std. ${min % 60} Min.` : `${min} Min.`; -} - -/* Start und Ankunft eines Formulars als echte Zeitpunkte. Liegt die Ankunft - vor der Abfahrt, wurde über Mitternacht gefahren - dann zählt der Folgetag. - null, solange Datum oder eine der beiden Uhrzeiten fehlt. */ -function fahrtZeitraum(praefix) { - const datum = $(`#${praefix}Datum`).value, ab = $(`#${praefix}Start`).value, an = $(`#${praefix}Ende`).value; - if (!datum || !ab || !an) return null; - const start = new Date(`${datum}T${ab}:00`); - let ende = new Date(`${datum}T${an}:00`); - if (isNaN(start) || isNaN(ende)) return null; - if (ende <= start) ende = new Date(ende.getTime() + dTag); - return { start, ende }; -} -function fahrtDauerText(praefix) { - const z = fahrtZeitraum(praefix); - return z ? dauerText((z.ende - z.start) / 1000) : "–"; -} - -/* Datum für ein in Ortszeit. Bewusst nicht über - toISOString(), das rechnet nach UTC um und liefert kurz nach Mitternacht - den Vortag. */ -function datumsfeld(d) { - const zwei = (n) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`; -} -function zeitfeld(d) { - const zwei = (n) => String(n).padStart(2, "0"); - return `${zwei(d.getHours())}:${zwei(d.getMinutes())}`; -} - -function fahrtFelder(praefix, t) { - const start = t && t.ts_start ? new Date(t.ts_start) : new Date(); - const ende = t && t.ts_end ? new Date(t.ts_end) : null; - const art = t && t.art ? t.art : FAHRT_ARTEN[0]; - const wert = (v) => (v == null ? "" : v); - const startzeit = t ? zeitfeld(start) : "08:00", ankunftzeit = ende ? zeitfeld(ende) : "08:30"; - // Die Dauer ist bewusst kein Eingabefeld: sie ergibt sich aus Start und - // Ankunft und würde sich sonst mit ihnen widersprechen können. Aus - // t.duration_s statt neu berechnet, damit eine über Mitternacht laufende - // bestehende Fahrt (Ankunft "kleiner" als Start) korrekt bleibt; bei einer - // neuen Fahrt (t === null) ergibt sich der Startwert aus denselben - // Vorgabezeiten wie die Felder selbst. Der data-fahrtzeitfeld-Handler - // rechnet bei jeder Änderung neu. - let dauerSekunden = t && t.duration_s != null ? t.duration_s : null; - if (dauerSekunden == null) { - const [sh, sm] = startzeit.split(":").map(Number), [eh, em] = ankunftzeit.split(":").map(Number); - dauerSekunden = ((eh * 60 + em) - (sh * 60 + sm) + (eh * 60 + em <= sh * 60 + sm ? 1440 : 0)) * 60; - } - const dauer = dauerText(dauerSekunden); - return ` -
-
-
-
${dauer}
-
-
-
-
-
-
-
- km
`; -} - -function fahrtFormularWerte(praefix) { - const zahl = (id) => { const v = $(`#${praefix}${id}`).value; return v === "" ? null : parseFloat(v.replace(",", ".")); }; - const text = (id) => $(`#${praefix}${id}`).value.trim() || null; - const z = fahrtZeitraum(praefix); - return { - ts_start: z ? z.start.toISOString() : null, - ts_end: z ? z.ende.toISOString() : null, - art: $(`#${praefix}Art`).value, - start_ort: text("Startort"), ziel_ort: text("Zielort"), - odo_start: zahl("OdoStart"), odo_end: zahl("OdoEnde"), distanz: zahl("Distanz"), - }; -} - -function vTripsFormular() { - // eigene Funktion, damit sowohl der volle als auch der Leer-Zustand - // (§11 Abnahmekriterium 1: eine Fahrt muss erfasst werden können) dasselbe - // Formular zeigen - vorher fehlte es im Leer-Zustand komplett, der Knopf - // wechselte zu "Abbrechen", aber es erschienen keine Felder. - return `${fahrtOffen ? `
Neue Fahrt - ${fahrtFelder("neuFahrt", null)} -
` : ""} - `; -} -function vTrips() { - const jahre = [...new Set(TRIPS.map((t) => new Date(t.ts_start).getFullYear()))].sort((a, b) => b - a); - /* D5: Leerzustand nennt die Ursache - die Fahrtenerkennung greift erst, wenn - die Zündungs-Entität (Setup -> Für die Fahrterkennung nötig) erkennt, - dass der Motor läuft. */ - if (!jahre.length) return `
- Noch keine Fahrten -

Fahrten werden erkannt, sobald der Motor gestartet wird. Bis dahin lässt sich jede Fahrt von Hand erfassen.

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

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

-
- ${vFuelFormular()}`; - let h = `
`; - jahre.forEach((j, ji) => { - const fj = FILLS.filter((f) => new Date(f.ts).getFullYear() === j); - const ersparnisJahr = sum(fj, (f) => f.discount || 0); - /* D2: Auf der Jahreszeile stehen nur die Kosten, groß. Preis und Menge - folgen als Zweitzeile, die Ersparnis als neutrale Pille - vorher - konkurrierten drei gleich große rechtsbündige Werte miteinander. */ - h += ` -
`; - const monate = [...new Set(fj.map((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" })))]; - monate.forEach((m, mi) => { - const fm = fj.filter((f) => new Date(f.ts).toLocaleDateString("de-DE", { month: "long" }) === m); - h += `
-
`; - fm.forEach((f) => { const preis = f.liters ? f.fuel_total_eur / f.liters : null; h += swipeZeile(`fill:${f.tank_id}`, ``); }); - h += `
`; - }); - h += `
`; - }); - return h + `
- ${vFuelFormular()}`; -} - -function vFill(id) { - const f = FILLS.find((x) => x.tank_id === id); - if (!f) return `
Tankvorgang nicht gefunden
`; - const berechneterPreis = f.liters ? f.fuel_total_eur / f.liters : null; - - if (tankBearbeitenOffen) { - return ` -
Tankvorgang bearbeiten - ${tankFelder("bearbTank", f)} - ${belegFehler && belegFehler.context === "bearbTank" ? `${esc(belegFehler.meldung)}` : ""} - - - -
`; - } - - return ` - ${mapBoxMitZentrieren()} -
- Getankt -
${f.liters != null ? de(f.liters, 2) : "–"}l
-
-
-
Zeitpunkt
${dedat(new Date(f.ts))}${new Date(f.ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr
-
Kilometerstand
${f.odometer_km != null ? de(f.odometer_km) + " km" : "unbekannt"}
-
Gefahrene Distanz
${f.distance_km != null ? de(f.distance_km) + " km" : "unbekannt"}seit der letzten Tankung
-
Kraftstoff
${esc(f.fuel_type || KRAFTSTOFFSORTEN[0])}
-
Getankte Liter
${f.liters != null ? de(f.liters, 2) + " l" : "–"}
-
Kosten
${f.fuel_total_eur != null ? eur(f.fuel_total_eur) + " €" : "–"}real, nach Rabatt
-
Preis je Liter
${berechneterPreis != null ? de(berechneterPreis, 2) + " €" : "–"}Kosten ÷ Liter
- ${CAR.smartdeal.aktiv && f.discount != null ? `
${markenlogo()} SmartDeal-Ersparnis
${eur(f.discount)} €
` : ""} -
Tankstelle
${esc(f.station_name || "unbekannt")}${esc(f.station_address || "")}
-
- ${belegFehler && belegFehler.context === f.tank_id ? `${esc(belegFehler.meldung)}` : ""} - - - -
`; -} - -/* ---------------------------------------------------------- Steuerung */ -const ICONS = { - home: '', - /* Wagen von vorn - dasselbe Symbol wie in der myAudi-App (Audi-Symbolsatz, - assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */ - audi: '', - trips: '', - stat: '', - fuel: '', -}; -const TABS = [ - { id: "home", label: "Übersicht", eyebrow: "", title: "Übersicht" }, - { id: "audi", label: "Mein Audi", eyebrow: "", title: "" }, - { id: "trips", label: "Fahrten", eyebrow: "", title: "Fahrten" }, - { id: "stat", label: "Statistik", eyebrow: "", title: "Statistik" }, - { id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" }, -]; -/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */ -const ICONS_VOLL = new Set(["audi"]); -let route = { name: "home" }; -const ZURUECK = { - trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi", - vers: "audi", beitrag: "vers", vertragsdetails: "vers", schutz: "vertragsdetails", - vertrag: "vers", - steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home", - battverlauf: "audi", - /* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren - Zurück-Pfeil. */ - sicherheit: "home", - standort: "home", -}; -const ICON = { - sonne: '', - mond: '', -}; -function themeIcon() { const el = $("#themeicon"); if (el) el.innerHTML = HOST.dataset.theme === "nacht" ? ICON.sonne : ICON.mond; } - -/* A6: gespeicherte Wahl, sonst Systemeinstellung. */ -const THEME_KEY = "audi-dashboard-theme"; -function themeGewaehlt() { - let gespeichert = null; - try { gespeichert = localStorage.getItem(THEME_KEY); } catch (e) { /* Privatmodus */ } - if (gespeichert === "nacht" || gespeichert === "tag") return gespeichert; - return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "tag" : "nacht"; -} -function themeSetzen(modus) { - HOST.dataset.theme = modus; - try { localStorage.setItem(THEME_KEY, modus); } catch (e) { /* Privatmodus */ } - tiles(); -} - -/* ------------------------------------------------------------ Action Sheet - B4: ersetzt confirm()/alert(). Ein offenes Sheet ist Zustand wie jeder - andere - render() zeichnet es mit, damit es Ansichtswechsel überlebt. */ -let sheet = null; -const sheetSchliessen = () => { sheet = null; render(); }; -function bestaetigen(titel, text, knopf, aktion, destruktiv = true) { - sheet = { titel, text, knopf, aktion, destruktiv }; - render(); -} -function hinweis(titel, text) { - sheet = { titel, text, knopf: null, aktion: null }; - render(); -} -/* ------------------------------------------------------- Beleg-Upload - Ein Weg für alle drei Quellen (Ziehen, Einfügen, Dateiauswahl). Kein - serviceRufen() hier: dessen catch loggt nur in die Konsole - siehe den - ausführlichen Kommentar an der früheren Aufrufstelle, der Grund gilt - unverändert. */ -function belegDateiVerarbeiten(datei, tankId, kontext) { - if (!datei) return; - if (!istPdf(datei)) { - belegFehler = { context: kontext, meldung: "Nur PDF-Belege werden unterstützt" }; - render(); return; - } - const leser = new FileReader(); - leser.onload = () => { - const base64 = leser.result.split(",")[1]; - HASS.callService("pyscript", "audi_dashboard_beleg_hochladen", - { pdf_base64: base64, dateiname: datei.name || "beleg.pdf", tank_id: tankId } - ).catch((err) => { - console.error("audi_dashboard: audi_dashboard_beleg_hochladen", err); - belegFehler = { context: kontext, meldung: "Beleg konnte nicht hochgeladen werden" }; - if (!tankId) belegEntwurf = null; - render(); - }); - }; - leser.readAsDataURL(datei); -} - -/* Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte - oder gezogene Datei kommt je nach Browser und Quelle mit leerem type an. */ -function istPdf(datei) { - if (!datei) return false; - return datei.type === "application/pdf" || /\.pdf$/i.test(datei.name || ""); -} - -/* Nimmt das erste PDF aus einer DataTransfer-/Clipboard-Dateiliste. */ -function belegAusDateiliste(dateien) { - return [...(dateien || [])].find(istPdf) || null; -} - -function belegPopupSchliessenUndLaden(datei) { - const ctx = belegPopup; - if (!ctx || !datei) return; - belegPopup = null; - belegDateiVerarbeiten(datei, ctx.tankId, ctx.kontext); - render(); -} - -/* Telefon-Weg: das aus der Mail kopierte PDF liegt in der Zwischenablage. - navigator.clipboard.read() ist dort der einzige Weg, ohne dass der Nutzer - in ein Eingabefeld tippen muss - iOS zeigt dafür seine eigene - Einfügen-Abfrage. Der Knopf erscheint deshalb nur auf Tippgeräten, siehe - zwischenablageKnopfSinnvoll(). */ -async function belegAusZwischenablage() { - const ctx = belegPopup; - if (!ctx) return; - if (!navigator.clipboard || !navigator.clipboard.read) { - belegPopup.fehler = "Dieser Browser gibt die Zwischenablage nicht heraus - bitte \"Datei auswählen\"."; - render(); return; - } - try { - for (const eintrag of await navigator.clipboard.read()) { - const typ = (eintrag.types || []).find((t) => t === "application/pdf"); - if (typ) { belegPopupSchliessenUndLaden(new File([await eintrag.getType(typ)], "beleg.pdf", { type: "application/pdf" })); return; } - } - belegPopup.fehler = "In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder \"Datei auswählen\"."; - } catch (err) { - belegPopup.fehler = "Kein Zugriff auf die Zwischenablage - bitte \"Datei auswählen\"."; - } - render(); -} - -/* Der Einfügen-Knopf wird nur auf Tippgeräten angeboten. Am Rechner scheitert - er zwangsläufig: clipboard.read() gibt aus Datenschutzgründen nur Text, - HTML und Bilder heraus, eine im Explorer kopierte Datei taucht dort nie - auf. Und das Tastenkürzel Strg+V hilft dort ebenfalls nicht - ohne - Eingabefeld im Fenster hat der Browser kein Ziel für die Einfüge-Geste - (vom Nutzer bestätigt). Am Rechner bleiben daher Ziehen und Dateiauswahl. */ -function zwischenablageKnopfSinnvoll() { - return window.matchMedia && window.matchMedia("(pointer: coarse)").matches; -} - -/* ------------------------------------------------- Historien-Import-Popup - Zeitraum wählen, importieren lassen, Ergebnis zeigen. Der eigentliche - Import läuft im Backend (pyscript/historienimport.py) und meldet seinen - Stand über die Entität pyscript.audi_dashboard_import_status - dasselbe - Muster wie beim Update-Status (UPDATE_STATUS oben). */ -let importPopup = null; - -/* Vorbelegung: die letzten 30 Tage. Wert im Format, das erwartet (Ortszeit, ohne Zeitzone, minutengenau) - - genau das, was der Dienst als Ortszeit interpretiert. */ -function importZeitraumVorgabe() { - const jetzt = new Date(); - const vor30 = new Date(jetzt.getTime() - 30 * 86400000); - const alsFeld = (d) => new Date(d.getTime() - d.getTimezoneOffset() * 60000) - .toISOString().slice(0, 16); - return { von: alsFeld(vor30), bis: alsFeld(jetzt) }; -} - -function importErgebnisText(d) { - const anzahl = (n, eins, viele) => `${de(n)} ${n === 1 ? eins : viele}`; - const teile = [anzahl(d.fahrten_angelegt || 0, "Fahrt", "Fahrten") + " angelegt"]; - if (d.fahrten_uebersprungen) teile.push(`${de(d.fahrten_uebersprungen)} bereits vorhanden`); - if (d.fahrten_zu_kurz) teile.push(anzahl(d.fahrten_zu_kurz, "Zündung zu kurz für eine Fahrt", "Zündungen zu kurz für eine Fahrt")); - if (d.tankvorgaenge_angelegt) teile.push(anzahl(d.tankvorgaenge_angelegt, "Tankvorgang", "Tankvorgänge")); - if (d.batterie_tage) teile.push(anzahl(d.batterie_tage, "Tag Batteriespannung", "Tage Batteriespannung")); - return teile.join(" · "); -} - -function vImportPopup() { - if (!importPopup) return ""; - const p = importPopup; - const fertig = p.ergebnis && !p.laeuft; - // Nach dem Lauf zeigt dasselbe Fenster das Ergebnis statt der Felder - der - // Nutzer soll sehen, was passiert ist, bevor er es wegklickt (HIG - // "Feedback"), statt das Fenster wortlos verschwinden zu lassen. - const koerper = fertig - ? `
- ${esc(importErgebnisText(p.ergebnis))}
- ${p.ergebnis.fahrten_angelegt === 0 && p.ergebnis.ab_wann_daten - ? ` - Aufgezeichnet ist in diesem Zeitraum ab ${dedat(new Date(p.ergebnis.ab_wann_daten))} · - ${new Date(p.ergebnis.ab_wann_daten).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr. - Weiter zurück bewahrt Home Assistant nichts mehr auf.` - : ""} - ${p.ergebnis.fahrten_angelegt === 0 && !p.ergebnis.ab_wann_daten - ? ` - Für diesen Zeitraum liegt in Home Assistant kein Verlauf mehr vor.` - : ""}` - : `
-
-
-
- ${p.fehler ? `${esc(p.fehler)}` : ""} - - Bereits erfasste Fahrten bleiben unangetastet — überschneidet sich ein - Zeitraum, wird er übersprungen statt doppelt angelegt.`; - - return `
- `; -} - -function vBelegPopup() { - if (!belegPopup) return ""; - // Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am - // Rechner Ziehen bzw. Dateiauswahl. Strg+V wird am Rechner bewusst NICHT - // mehr angeboten: ohne Eingabefeld im Fenster hat der Browser kein Ziel für - // die Einfüge-Geste, das Tastenkürzel läuft dort also ins Leere. - const telefon = zwischenablageKnopfSinnvoll(); - return `
- `; -} - -function sheetMarkup() { - if (!sheet) return ""; - const kopf = `
${esc(sheet.titel)}${sheet.text ? `${esc(sheet.text)}` : ""}
`; - const aktion = sheet.knopf - ? `` - : ``; - return `
- `; -} - -/* ------------------------------------------------------------ Setup-Popup */ -const LUPE_SVG = ``; - -function entitaetZeilenMarkup(feldId, key, suchtext, aktuellerWert) { - const kandidaten = entitaetKandidaten(key, suchtext, aktuellerWert); - if (!kandidaten.length) return `
Keine passende Entität gefunden
`; - return kandidaten.map((e) => { - const zustand = HASS.states[e.id] ? HASS.states[e.id].state : null; - const unbekannt = zustand === null || zustand === "unavailable" || zustand === "unknown"; - const wertAnzeige = unbekannt ? (zustand === null ? "nicht gefunden" : zustand) : `${zustand}${e.unit ? " " + e.unit : ""}`; - return ` - `; - }).join(""); -} - -const RESET_SVG = ``; - -function entitaetCombo(key, idx) { - const feldId = idx == null ? key : `${key}:${idx}`; - const wert = idx == null ? setupZuordnung[key] : (setupZuordnung[key] || [])[idx]; - const offen = setupSucheOffen === feldId; - const anzeige = offen ? setupSuchtext : entitaetName(wert); - const standard = setupStandardwert(key, idx); - const zeigeReset = wert !== standard; - return `
- ${LUPE_SVG} - - ${zeigeReset ? `` : ""} - ${offen ? `
${entitaetZeilenMarkup(feldId, key, setupSuchtext, wert)}
` : ""} -
`; -} - -function setupFeldzeile(feld) { - const kopf = `
- ${esc(feld.label)} - ${esc(feld.hinweis)} - ${feld.trigger_gebunden ? `Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.` : ""} -
`; - if (feld.liste) { - return `
${kopf} -
- ${feld.positionen.map((pos, i) => ` -
- ${esc(pos)} - ${entitaetCombo(feld.key, i)} - ${entitaetStatusWarnung((setupZuordnung[feld.key] || [])[i])} -
`).join("")} -
-
`; - } - return `
${kopf}${entitaetCombo(feld.key, null)}${entitaetStatusWarnung(setupZuordnung[feld.key])}
`; -} - -const SETUP_GRUPPEN = [ - ["fahrterkennung", "Für die Fahrterkennung nötig"], - ["uebersicht", "Übersicht & Status"], - ["sicherheit", "Sicherheit"], - ["standort", "Standort"], -]; - -function vSetupPopup() { - if (!setupOffen) return ""; - const katalog = setupKatalog(); - return `
- `; -} - -let letzteAnsicht = null; -let routeStack = []; -let navRichtung = null; -const istTab = (name) => TABS.some((t) => t.id === name); -/* Zu welchem Tab gehoert eine Unterseite? Erst der tatsaechlich gelaufene Weg - (routeStack), sonst die ZURUECK-Tabelle. */ -function tabVon(name) { - if (routeStack.length && istTab(routeStack[0].name)) return routeStack[0].name; - let n = name, i = 0; - while (n && !istTab(n) && i++ < 8) n = ZURUECK[n]; - return istTab(n) ? n : null; -} -function render() { - if (!DATEN_GELADEN) return; - // Eine laufende Wischgeste bezieht sich auf Knoten, die render() gleich - // durch neue ersetzt - Referenz kappen, bevor sie ins Leere zeigt. - swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; - const v = $("#view"), b = $("#back"); - const merkeScroll = v.scrollTop; - const gleicheAnsicht = letzteAnsicht === route.name + "|" + (route.id || ""); - let head; - if (route.name === "home") { head = ["", "Übersicht"]; v.innerHTML = vHome(); } - else if (route.name === "trip") { head = ["Fahrt", "Einzelfahrt"]; v.innerHTML = vTrip(route.id); } - else if (route.name === "fill") { head = ["Tankvorgang", "Einzelbeleg"]; v.innerHTML = vFill(route.id); } - else if (route.name === "service") { head = ["Wartung", "Service"]; v.innerHTML = vService(); } - else if (route.name === "reifen") { head = ["Fahrzeug", "Reifen"]; v.innerHTML = vReifen(); } - else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); } - else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); } - else if (route.name === "sicherheit") { head = ["Fahrzeug", "Sicherheit"]; v.innerHTML = vSicherheit(); } - else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); } - else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); } - else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); } - else if (route.name === "vertrag") { head = ["Versicherung", "Vertrag"]; v.innerHTML = vVertragBearbeiten(); } - else if (route.name === "steuer") { head = ["Versicherung", "Kfz-Steuer"]; v.innerHTML = vSteuerBearbeiten(); } - else if (route.name === "notruf") { head = ["Versicherung", "Rufnummern"]; v.innerHTML = vNotrufBearbeiten(); } - else if (route.name === "schutz") { head = ["Im Ernstfall", "Schutzbrief"]; v.innerHTML = vSchutz(); } - else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); } - else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); } - else if (route.name === "sbuch") { head = ["Servicebuch", "Eintrag"]; v.innerHTML = vSbuch(); } - else if (route.name === "standort") { head = ["Fahrzeug", "Standort"]; v.innerHTML = vStandort(); } - else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title]; - v.innerHTML = { audi: vAudi, trips: vTrips, stat: vStat, fuel: vFuel }[route.name](); } - $("#eyebrow").textContent = head[0]; $("#title").textContent = head[1]; - /* B3: Push nach rechts hinein, Pop nach links heraus. */ - v.classList.remove("push-rein", "push-raus"); - if (navRichtung && !gleicheAnsicht) { - void v.offsetWidth; - v.classList.add(navRichtung === "raus" ? "push-raus" : "push-rein"); - } - navRichtung = null; - // .setup-koerper ist ein eigener Scroll-Container innerhalb des Overlays - - // die innerHTML-Ersetzung gleich darunter erzeugt ihn neu (scrollTop - // faellt sonst bei jedem render(), z. B. beim Umschalten des Filters, - // auf 0 zurueck). - const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0; - ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup(); - if (setupOffen) { - const koerper = ROOT.querySelector(".setup-koerper"); - if (koerper) koerper.scrollTop = setupKoerperScroll || 0; - } - // Ein offenes Setup-Suchfeld verliert bei jedem render() sein DOM-Element - // (v.innerHTML-Ersatz) - hier neu fokussieren, Cursor ans Ende, sonst - // müsste man nach jedem Tippen erneut anklicken. - if (setupOffen && setupSucheOffen) { - const feld = ROOT.querySelector(`[data-setup-suche="${setupSucheOffen}"]`); - if (feld) { feld.focus(); const len = feld.value.length; feld.setSelectionRange(len, len); } - } - /* D4: die Ringe sind nur auf den Tab-Ebenen sichtbar - auf Unterseiten steht - dort der Zurück-Pfeil. */ - ROOT.getElementById("marke").style.display = istTab(route.name) ? "inline-flex" : "none"; - const sy = $("#sync"); - sy.style.display = route.name === "home" ? "flex" : "none"; - sy.querySelector("span").textContent = standAlterText(); - b.classList.toggle("on", !istTab(route.name)); - v.scrollTop = gleicheAnsicht ? merkeScroll : 0; - letzteAnsicht = route.name + "|" + (route.id || ""); - /* Die Karten haengen an DOM-Knoten, die render() ueber v.innerHTML gerade - ersetzt hat - eine ueberlebende Leaflet-Instanz zeigte danach auf ein - abgehaengtes Element und bliebe leer. Sie muessen deshalb bei jedem - Durchlauf neu aufgebaut werden; die Haeufigkeit begrenzt stattdessen die - Aenderungspruefung in datenLaden(). */ - if (MAP) { MAP.remove(); MAP = null; TILE = null; } - if (route.name === "trip" || route.name === "fill") initMap().catch(() => {}); // ohne Internet bleibt die Kartenflaeche leer - if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } - if (route.name === "standort") initStandortMap().catch(() => {}); - if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } - if (route.name === "home") initVorschauMap().catch(() => {}); - /* Menueleiste wie in der myAudi-App: genau ein Eintrag traegt die gefuellte - Pille, und zwar auch dann, wenn eine Unterseite offen ist - die gehoert - zum Tab, aus dem sie aufgerufen wurde (ZURUECK zeigt darauf). */ - const aktiverTab = istTab(route.name) ? route.name : tabVon(route.name); - ROOT.querySelectorAll(".tab").forEach((el) => { - const an = el.dataset.tab === aktiverTab; - el.classList.toggle("on", an); - el.setAttribute("aria-current", an ? "page" : "false"); - }); -} -const go = (name, id) => { - // Bekanntes Race (siehe Kopfkommentar bei nachladeAnstossen): auf einem - // langsam startenden Backend kann die App auf "Lädt …" hängen bleiben, - // obwohl die Daten inzwischen längst da wären - der bislang einzige - // beobachtete Ausweg war ein Tabwechsel, der zufällig einen frischen - // datenLaden()-Durchlauf auslöste. Hier deshalb bei jedem Tab-/Menüklick - // aktiv nachschauen, statt nur auf den nächsten hass-Push zu warten - - // no-op, falls die Daten längst da sind (datenLaden() rendert dann - // ohnehin nichts doppelt Sichtbares). - if (!DATEN_GELADEN) datenLaden(false); - // Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die - // Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf - // "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen - // und beim nächsten unabhängigen Speichern versehentlich mit zu persistieren. - if (entwurfIndex !== null && !(name === "sbuch" && id === String(entwurfIndex))) { - CAR.service.buch.splice(entwurfIndex, 1); - entwurfIndex = null; - } - sheet = null; - // Ein offenes Bearbeiten-Formular gehört zu genau der Fahrt, auf der es - // geöffnet wurde - sonst begrüßt die nächste Fahrt einen mit Eingabefeldern. - fahrtBearbeitenOffen = false; - /* B3: echter Verlauf statt statischer ZURUECK-Tabelle. Ein Tabwechsel setzt - den Stapel zurück, weil Tabs Wurzeln sind, keine Ebenen. */ - if (istTab(name)) routeStack = []; - else if (route.name !== name || route.id !== id) routeStack.push(route); - navRichtung = istTab(name) ? null : "rein"; - route = { name, id }; render(); -}; -function zurueck() { - sheet = null; - fahrtBearbeitenOffen = false; - const ziel = routeStack.pop() || { name: ZURUECK[route.name] || "home" }; - navRichtung = "raus"; - route = ziel; render(); -} - -/* B3: Randwischen zurück - dieselbe Bewegung wie ein Tipp auf den Pfeil. */ -let randX0 = null, randY0 = null; -function randwischenVerdrahten() { - const phone = ROOT.querySelector(".phone"); - phone.addEventListener("pointerdown", (e) => { - randX0 = null; - if (!e.isPrimary || sheet || setupOffen) return; - const links = phone.getBoundingClientRect().left; - if (e.clientX - links <= 26 && !istTab(route.name)) { randX0 = e.clientX; randY0 = e.clientY; } - }); - phone.addEventListener("pointerup", (e) => { - if (randX0 !== null && e.clientX - randX0 > 62 && Math.abs(e.clientY - randY0) < 60) zurueck(); - randX0 = null; - }); - phone.addEventListener("pointercancel", () => { randX0 = null; }); -} - -/* -------------------------------------------------------------- Aktionen - Schreibende Aktionen: lokale Anzeige sofort aktualisieren (render()), - Persistenz über hass.callService im Hintergrund - dieselbe Reihenfolge - wie im Prototyp, nur dass jetzt zusätzlich gespeichert wird. */ -/* Import anstoßen und auf sein Ergebnis warten. - - Bewusst NICHT über serviceRufen(): der Import ist die eine Aktion, bei der - das Fenster offen bleibt und das Ergebnis zeigt, statt optimistisch zu - schließen. Das Backend meldet seinen Stand über - pyscript.audi_dashboard_import_status; hier wird dieselbe Entität gepollt, - bis sie "fertig" oder "fehler" meldet. Ein Poll statt eines Abwartens des - callService-Versprechens, weil pyscript-Dienste sofort zurückkehren, wenn - sie im Hintergrund weiterlaufen. */ -async function importStatusLesen() { - try { - const states = await HASS.callWS({ type: "get_states" }); - return states.find((x) => x.entity_id === "pyscript.audi_dashboard_import_status") || null; - } catch (e) { - return null; - } -} - -async function importAusloesen() { - if (!importPopup || importPopup.laeuft) return; - const von = importPopup.von; - const bis = importPopup.bis; - if (!von || !bis) { importPopup.fehler = "Bitte Start und Ende wählen."; render(); return; } - if (von >= bis) { importPopup.fehler = "Das Ende muss nach dem Start liegen."; render(); return; } - - importPopup.laeuft = true; - importPopup.fehler = null; - render(); - - // Zeitstempel des letzten Laufs merken: nur ein danach geschriebener Stand - // ist die Antwort auf DIESEN Aufruf und nicht das Ergebnis von gestern. - const vorher = await importStatusLesen(); - const vorherStand = vorher ? vorher.last_updated : null; - - try { - await HASS.callService("pyscript", "audi_dashboard_historie_importieren", { start: von, ende: bis }); - } catch (err) { - console.error("audi_dashboard: Import", err); - if (importPopup) { - importPopup.laeuft = false; - importPopup.fehler = (err && err.message) || "Der Import konnte nicht gestartet werden."; - render(); - } - return; - } - - // Bis zu zwei Minuten - ein Jahr Verlauf über sechs Sensoren braucht seine - // Zeit, und ein zu früher Abbruch sähe wie ein Fehlschlag aus, obwohl der - // Import im Hintergrund sauber zu Ende läuft. - for (let versuch = 0; versuch < 120; versuch++) { - await new Promise((r) => setTimeout(r, 1000)); - if (!importPopup) return; - const s = await importStatusLesen(); - if (!s || s.last_updated === vorherStand) continue; - if (s.state === "fertig") { - importPopup.laeuft = false; - importPopup.ergebnis = (s.attributes && s.attributes.daten) || {}; - render(); - // Fahrten-/Tankvorgangslisten im Frontend nachziehen - das Backend hat - // sie bereits neu veröffentlicht, die App muss sie nur neu lesen. - datenAktualisieren(); - return; - } - if (s.state === "fehler") { - importPopup.laeuft = false; - importPopup.fehler = (s.attributes && s.attributes.daten && s.attributes.daten.meldung) - || "Der Import ist fehlgeschlagen."; - render(); - return; - } - } - if (importPopup) { - importPopup.laeuft = false; - importPopup.fehler = "Der Import dauert ungewöhnlich lange — er läuft im Hintergrund weiter."; - render(); - } -} - -function serviceRufen(dienst, daten) { - // HIG "Feedback": ein fehlgeschlagener Aufruf darf nicht nur in der Konsole - // landen - die aufrufende Stelle hat ihr Popup meist schon geschlossen und - // die UI optimistisch aktualisiert, sonst würde die Änderung lautlos - // verloren gehen (derselbe Fehler, der für den Beleg-Upload und das Setup- - // Speichern schon einmal einzeln gefixt wurde - hier zentral für alle - // ~20 Aufrufstellen). - HASS.callService("pyscript", dienst, daten).catch((err) => { - console.error("audi_dashboard:", dienst, err); - hinweis("Aktion fehlgeschlagen", err && err.message ? err.message : "Der Dienst konnte nicht ausgeführt werden."); - }); -} - -function ereignisseVerdrahten() { - /* Tastaturbedienung fuer die beiden Ueberlagerungen. Bisher gab es in der - ganzen Datei keinen keydown-Handler: Das Setup-Fenster deklariert - role="dialog" aria-modal="true", liess sich aber weder mit Escape - schliessen noch verhinderte es, dass der Tabulator in den verdeckten - Hintergrund wandert. Beides gehoert zum Vertrag dieser Rolle. - Der Handler haengt am Dokument, weil Fokus im Shadow DOM auch ausserhalb - von ROOT liegen kann. */ - /* Ziehen und Ablegen (Rechner) sowie Einfügen per Tastatur, solange das - Beleg-Fenster offen ist. Die Listener hängen am Dokument, weil ein Zug - über der Ablagefläche im Shadow DOM sonst schon am Rand verloren geht - - und weil ohne preventDefault auf dragover der Browser das PDF einfach - selbst öffnet, statt ein drop-Ereignis zu liefern. */ - document.addEventListener("dragover", (e) => { - if (!belegPopup) return; - e.preventDefault(); - const zone = ROOT.getElementById("belegDrop"); - if (zone) zone.classList.add("darueber"); - }); - document.addEventListener("dragleave", (e) => { - if (!belegPopup || e.relatedTarget) return; - const zone = ROOT.getElementById("belegDrop"); - if (zone) zone.classList.remove("darueber"); - }); - document.addEventListener("drop", (e) => { - if (!belegPopup) return; - e.preventDefault(); - const datei = belegAusDateiliste(e.dataTransfer && e.dataTransfer.files); - if (datei) { belegPopupSchliessenUndLaden(datei); return; } - belegPopup.fehler = "Das war kein PDF."; - render(); - }); - /* Zugabe, kein beworbener Weg: wo der Browser eine Einfüge-Geste ohne - Eingabefeld überhaupt zustellt, wird sie mitgenommen. Am Rechner tut - Chrome das nicht (deshalb steht Strg+V nirgends mehr als Hinweis), es - kostet aber nichts, das Ereignis trotzdem anzunehmen. */ - document.addEventListener("paste", (e) => { - if (!belegPopup) return; - const datei = belegAusDateiliste(e.clipboardData && e.clipboardData.files); - if (datei) { e.preventDefault(); belegPopupSchliessenUndLaden(datei); } - }); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - if (belegPopup) { belegPopup = null; render(); e.preventDefault(); return; } - // Während der Import läuft nicht wegklickbar - er läuft im Backend - // weiter, das Fenster ist die einzige Stelle, die sein Ergebnis zeigt. - if (importPopup) { if (!importPopup.laeuft) { importPopup = null; render(); } e.preventDefault(); return; } - if (sheet) { sheetSchliessen(); e.preventDefault(); return; } - if (setupSucheOffen) { setupSucheOffen = null; setupSuchtext = ""; render(); e.preventDefault(); return; } - if (setupOffen) { setupSchliessen(); e.preventDefault(); return; } - return; - } - if (e.key !== "Tab" || !setupOffen) return; - /* Fokusfalle: innerhalb des Fensters im Kreis wandern statt dahinter. */ - const fokussierbar = ROOT.querySelectorAll( - '.setup-popup button:not([disabled]), .setup-popup input:not([disabled]), .setup-popup [tabindex]:not([tabindex="-1"])'); - if (!fokussierbar.length) return; - const erstes = fokussierbar[0]; - const letztes = fokussierbar[fokussierbar.length - 1]; - const aktiv = ROOT.activeElement; - if (e.shiftKey && (aktiv === erstes || !aktiv)) { letztes.focus(); e.preventDefault(); } - else if (!e.shiftKey && aktiv === letztes) { erstes.focus(); e.preventDefault(); } - }); - ROOT.getElementById("tabbar").innerHTML = TABS.map((t) => - ``).join(""); - ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); - /* Auf breiten Bildschirmen steht die Menueleiste als Spalte links; die Marke - gehoert dann an deren Kopf statt in die Kopfzeile (Design-Audit). Die Ringe - werden aus der Kopfzeile geklont, die Ids im SVG umbenannt, damit der - clipPath eindeutig bleibt. Sichtbar nur ueber die Regel in - audi-dashboard-ios.css - in Telefonbreite bleibt der Klon verborgen. */ - const tabbar = ROOT.getElementById("tabbar"); - if (!tabbar.querySelector(".navmarke")) { - const marke = document.createElement("div"); - marke.className = "navmarke"; - marke.setAttribute("aria-hidden", "true"); - marke.innerHTML = ROOT.getElementById("marke").innerHTML - .replace(/audiRingsClip/g, "audiRingsClipNav"); - tabbar.prepend(marke); - } - ROOT.getElementById("tabbar").addEventListener("click", (e) => { const b = e.target.closest(".tab"); if (b) go(b.dataset.tab); }); - ROOT.getElementById("back").addEventListener("click", () => zurueck()); - ROOT.getElementById("ringsbtn").addEventListener("click", () => go("einst")); - /* In Telefonbreite sind die Ringe oben rechts der Zugang zu den - Einstellungen (das Zahnrad ist dort ausgeblendet). */ - ROOT.getElementById("marke").addEventListener("click", () => go("einst")); - ROOT.getElementById("theme").addEventListener("click", () => { - themeSetzen(HOST.dataset.theme === "nacht" ? "tag" : "nacht"); - themeIcon(); - /* Neu zeichnen, damit theme-abhaengige, in JS gesetzte Werte (z. B. die - aus --red gelesene Routenfarbe in initMap) mitziehen. Die Kacheln selbst - sind seit TILE_URL themeunabhaengig hell und brauchen das nicht mehr. */ - render(); - }); - // Wischgeste über Pointer Events (nicht Touch Events), damit dieselbe - // Logik auch mit der Maus funktioniert (z. B. beim Testen im Browser). - // touch-action:pan-y auf .swipe-content lässt vertikales Scrollen nativ - // zu; erst wenn die Geste als horizontal erkannt ist, übernimmt hier - // preventDefault() die Kontrolle. - ROOT.addEventListener("pointerdown", (e) => { - // Ziehen zum Aktualisieren (nur Übersicht, nur wenn ganz oben gescrollt) - - // unabhängig von der Wischgeste unten, da hier keine .swipe-wrap nötig ist. - if (e.isPrimary && route.name === "home" && !ptrLaedt) { - const ansicht = $("#view"); - if (ansicht && ansicht.scrollTop <= 0) { ptrY0 = e.clientY; ptrDrag = 0; } - } - const wrap = e.target.closest(".swipe-wrap"); - if (!wrap || !e.isPrimary) return; - swipeAktivWrap = wrap; swipeStartX = e.clientX; swipeStartY = e.clientY; swipeGeste = null; swipeDX = 0; - }); - ROOT.addEventListener("pointermove", (e) => { - if (ptrY0 !== null && e.isPrimary) { - const ansicht = $("#view"), dy = e.clientY - ptrY0; - if (dy > 0 && ansicht && ansicht.scrollTop <= 0) { - e.preventDefault(); - ptrDrag = Math.min(90, dy * 0.5); // Widerstand, wie bei iOS - const ptr = $("#ptr"); - if (ptr) { - ptr.style.transition = "none"; - ptr.style.height = ptrDrag + "px"; - ptr.querySelector("span").textContent = ptrDrag >= 56 ? "Loslassen zum Aktualisieren" : "Ziehen zum Aktualisieren"; - } - } else { - ptrY0 = null; ptrDrag = 0; - const ptr = $("#ptr"); - if (ptr) { ptr.style.transition = ""; ptr.style.height = "0"; } - } - } - if (!swipeAktivWrap || !e.isPrimary) return; - const dx = e.clientX - swipeStartX, dy = e.clientY - swipeStartY; - if (swipeGeste === null) { - if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; - swipeGeste = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; - if (swipeGeste === "y") { swipeAktivWrap = null; return; } - } - e.preventDefault(); - swipeWarGezogen = true; - // Loeschknopf erst waehrend der Geste einblenden (siehe .swipe-delete in - // audi-dashboard.css) - sonst blitzt er dauerhaft an den Zeilenraendern durch. - swipeAktivWrap.classList.add("wischt"); - const basis = swipeAktivWrap.classList.contains("swiped") ? -SWIPE_BREITE : 0; - swipeDX = Math.max(-SWIPE_BREITE - 20, Math.min(0, basis + dx)); - const inhalt = swipeAktivWrap.querySelector(".swipe-content"); - inhalt.style.transition = "none"; - inhalt.style.transform = `translateX(${swipeDX}px)`; - }, { passive: false }); - function swipeBeenden() { - if (!swipeAktivWrap) return; - const inhalt = swipeAktivWrap.querySelector(".swipe-content"); - inhalt.style.transition = ""; inhalt.style.transform = ""; - swipeAktivWrap.classList.remove("wischt"); - if (swipeGeste === "x") { - if (swipeDX <= -SWIPE_BREITE / 2) { schliesseAlleSwipes(); swipeAktivWrap.classList.add("swiped"); } - else { swipeAktivWrap.classList.remove("swiped"); } - } - swipeAktivWrap = null; swipeGeste = null; swipeDX = 0; - // Der nachlaufende Klick derselben Geste feuert synchron noch in diesem - // Tick (Maus: immer: Touch: falls vom Browser nicht ohnehin unterdrückt) - - // erst danach zurücksetzen, sonst würde ein echter, späterer Tipp (z. B. - // auf den gerade erst aufgedeckten Löschen-Knopf) fälschlich verschluckt. - if (swipeWarGezogen) setTimeout(() => { swipeWarGezogen = false; }, 0); - } - function ptrBeenden() { - if (ptrY0 === null) return; - ptrY0 = null; - const ptr = $("#ptr"); if (!ptr) { ptrDrag = 0; return; } - ptr.style.transition = "height .2s"; - if (ptrDrag >= 56) { - ptrLaedt = true; - ptr.style.height = "56px"; - ptr.classList.add("spin"); - ptr.querySelector("span").textContent = "Aktualisieren …"; - datenAktualisieren().finally(() => { - ptrLaedt = false; - const ptr2 = $("#ptr"); - if (ptr2) { ptr2.style.transition = "height .2s"; ptr2.style.height = "0"; ptr2.classList.remove("spin"); } - }); - } else { - ptr.style.height = "0"; - } - ptrDrag = 0; - } - ROOT.addEventListener("pointerup", (e) => { swipeBeenden(); ptrBeenden(); }); - ROOT.addEventListener("pointercancel", (e) => { swipeBeenden(); ptrBeenden(); }); - - // Batteriespannungs-Verlauf: Pinch-Zoom (2 Finger) / Ziehen zum Verschieben - // (1 Finger) / Mausrad-Zoom, per pointerId in bvZeiger nachverfolgt statt - // über die swipe*-Variablen oben, die für horizontales Wischen in Listen - // gedacht sind und mit einer zweiten aktiven Berührung nichts anfangen - // können. - ROOT.addEventListener("pointerdown", (e) => { - if (!e.target.closest("#bvChart") || !bvDomain) return; - bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (bvZeiger.size === 1) { - bvTapKandidat = { x: e.clientX, y: e.clientY }; - bvBewegtSeit = false; - bvGestStart = { typ: "pan", x: e.clientX, domain: [...bvDomain] }; - } else if (bvZeiger.size === 2) { - bvTapKandidat = null; - const [p1, p2] = [...bvZeiger.values()]; - const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); - const mitteX = (p1.x + p2.x) / 2; - const ankerFrac = Math.max(0, Math.min(1, (mitteX - rahmen.linksPx) / rahmen.breitePx)); - bvGestStart = { - typ: "pinch", dist: Math.hypot(p1.x - p2.x, p1.y - p2.y), domain: [...bvDomain], - ankerFrac, ankerZeit: bvDomain[0] + ankerFrac * (bvDomain[1] - bvDomain[0]), - }; - } - }); - ROOT.addEventListener("pointermove", (e) => { - if (!bvZeiger.has(e.pointerId) || !bvGestStart) return; - bvZeiger.set(e.pointerId, { x: e.clientX, y: e.clientY }); - e.preventDefault(); - if (bvGestStart.typ === "pan" && bvZeiger.size === 1) { - const dxPx = e.clientX - bvGestStart.x; - if (Math.abs(dxPx) > 4) bvBewegtSeit = true; - const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(); - const breite = bvGestStart.domain[1] - bvGestStart.domain[0]; - const dt = -(dxPx / bvPlotRahmen(rect).breitePx) * breite; - bvDomain = bvDomainKlemmen([bvGestStart.domain[0] + dt, bvGestStart.domain[1] + dt]); - bvZeichnen(); - } else if (bvGestStart.typ === "pinch" && bvZeiger.size === 2) { - const [p1, p2] = [...bvZeiger.values()]; - const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y); - const faktor = bvGestStart.dist / Math.max(dist, 1); - const breite = (bvGestStart.domain[1] - bvGestStart.domain[0]) * faktor; - const start = bvGestStart.ankerZeit - bvGestStart.ankerFrac * breite; - bvDomain = bvDomainKlemmen([start, start + breite]); - bvZeichnen(); - } - }, { passive: false }); - function bvBeenden(e) { - if (!bvZeiger.has(e.pointerId)) return; - bvZeiger.delete(e.pointerId); - if (bvZeiger.size === 0) { - if (bvGestStart && bvGestStart.typ === "pan" && !bvBewegtSeit && bvTapKandidat) { - bvAusgewaehlt = bvNaechsterPunkt(bvTapKandidat.x); - bvZeichnen(); - } - bvGestStart = null; bvTapKandidat = null; - } else if (bvZeiger.size === 1) { - // von Pinch (2 Finger) auf Pan (1 Finger verbleibend) wechseln, statt - // die Geste einfach zu beenden - fühlt sich beim Loslassen eines - // Fingers sonst wie ein Ruckler an - const [rest] = [...bvZeiger.entries()]; - bvGestStart = { typ: "pan", x: rest[1].x, domain: [...bvDomain] }; - bvBewegtSeit = true; - } - } - ROOT.addEventListener("pointerup", bvBeenden); - ROOT.addEventListener("pointercancel", bvBeenden); - ROOT.addEventListener("wheel", (e) => { - if (!e.target.closest("#bvChart") || !bvDomain) return; - e.preventDefault(); - const svg = $("#bvSvg"), rect = svg.getBoundingClientRect(), rahmen = bvPlotRahmen(rect); - const frac = Math.max(0, Math.min(1, (e.clientX - rahmen.linksPx) / rahmen.breitePx)); - const ankerZeit = bvDomain[0] + frac * (bvDomain[1] - bvDomain[0]); - const faktor = e.deltaY > 0 ? 1.2 : 1 / 1.2; - const breite = (bvDomain[1] - bvDomain[0]) * faktor; - const start = ankerZeit - frac * breite; - bvDomain = bvDomainKlemmen([start, start + breite]); - bvZeichnen(); - }, { passive: false }); - - ROOT.addEventListener("click", (e) => { - // B4: Das Action Sheet liegt über allem und fängt daher zuerst ab. - if (e.target.closest("[data-sheet-abbrechen]")) { sheetSchliessen(); return; } - if (e.target.closest("[data-sheet-ok]")) { - const aktion = sheet && sheet.aktion; - sheet = null; render(); - if (aktion) aktion(); - return; - } - if (sheet) return; - if (e.target.closest("[data-setup-oeffnen]")) { setupOeffnen(); return; } - if (setupOffen) { - if (e.target.closest("[data-setup-abbrechen]")) { setupSchliessen(); return; } - const wahl = e.target.closest("[data-setup-wahl]"); - if (wahl) { - const feldId = wahl.dataset.setupWahl, entityId = wahl.dataset.setupEntity; - const teile = feldId.split(":"); - if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = entityId; - else setupZuordnung[feldId] = entityId; - setupSucheOffen = null; setupSuchtext = ""; render(); - return; - } - const reset = e.target.closest("[data-setup-reset]"); - if (reset) { - const feldId = reset.dataset.setupReset; - const teile = feldId.split(":"); - if (teile.length === 2) setupZuordnung[teile[0]][Number(teile[1])] = setupStandardwert(teile[0], Number(teile[1])); - else setupZuordnung[feldId] = setupStandardwert(feldId, null); - render(); - return; - } - if (e.target.closest("[data-setup-speichern]")) { - if (setupSpeichertLaeuft) return; - const duplikate = setupDuplikate(); - if (duplikate.length) { - const namen = duplikate.map(entitaetName).join(", "); - bestaetigen("Doppelt zugeordnet", `${namen} ${duplikate.length === 1 ? "ist" : "sind"} mehr als einer Rolle zugeordnet. Trotzdem speichern?`, "Trotzdem speichern", setupSpeichernAusfuehren, false); - return; - } - setupSpeichernAusfuehren(); - return; - } - // Klick aufs (noch geschlossene) Suchfeld öffnet dessen Dropdown; ein - // Klick, während es schon offen ist (z. B. Cursor neu setzen), soll - // keinen render() auslösen - würde die laufende Eingabe unterbrechen. - const sucheInput = e.target.closest("[data-setup-suche]"); - if (sucheInput) { - const feldId = sucheInput.dataset.setupSuche; - if (setupSucheOffen !== feldId) { setupSucheOffen = feldId; setupSuchtext = ""; render(); } - return; - } - // Klick irgendwo sonst im Popup (aber nicht auf den Catcher, der schon - // oben behandelt wurde) schließt ein offenes Dropdown, ohne das ganze - // Popup zu verlassen. - /* Den Filterschalter VOR dem Schliessen der Auswahlliste behandeln. - Sonst ersetzt render() unten das Overlay-DOM, bevor das - change-Ereignis des Kontrollkaestchens ausgeliefert wird - der - Schalter sprang dann beim naechsten Zeichnen einfach zurueck. */ - if (e.target.closest("[data-setup-nurpassend]") || e.target.closest(".switch")) { - const kasten = ROOT.querySelector("[data-setup-nurpassend]"); - if (kasten) { setupNurPassend = !setupNurPassend; render(); return; } - } - if (setupSucheOffen && !e.target.closest(".entitaet-combo")) { setupSucheOffen = null; setupSuchtext = ""; render(); return; } - return; - } - const bvReset = e.target.closest("[data-bvreset]"); - if (bvReset) { bvDomain = [...bvVollDomain]; bvAusgewaehlt = null; bvZeichnen(); return; } - // Nachlaufender Klick derselben Geste (Maus feuert ihn trotz Bewegung - // weiterhin) - nicht als Navigation/Aktion werten. - if (swipeWarGezogen) { swipeWarGezogen = false; return; } - const loeschBtn = e.target.closest("[data-swipe-loeschen]"); - const offenerWrap = ROOT.querySelector(".swipe-wrap.swiped"); - if (offenerWrap && !loeschBtn) { - // Erster Tipp bei offener Wischfläche schließt sie nur - wie bei iOS, - // egal ob innerhalb der Zeile (statt zu navigieren) oder außerhalb. - schliesseAlleSwipes(); - if (offenerWrap.contains(e.target)) return; - } - if (loeschBtn) { - const [art, id] = loeschBtn.dataset.swipeLoeschen.split(":"); - bestaetigen(LOESCH_TEXT[art], LOESCH_HINWEIS[art], "Löschen", () => { eintragLoeschen(art, id); schliesseAlleSwipes(); }); - return; - } - const tripDel = e.target.closest("[data-tripdel]"); - if (tripDel) { - const id = tripDel.dataset.tripdel; - bestaetigen(LOESCH_TEXT.trip, LOESCH_HINWEIS.trip, "Löschen", () => { eintragLoeschen("trip", id); go("trips"); }); - return; - } - const fillDel = e.target.closest("[data-filldel]"); - if (fillDel) { - const id = fillDel.dataset.filldel; - bestaetigen(LOESCH_TEXT.fill, LOESCH_HINWEIS.fill, "Löschen", () => { eintragLoeschen("fill", id); go("fuel"); }); - return; - } - const sbDel = e.target.closest("[data-sbdel]"); - if (sbDel) { - const id = sbDel.dataset.sbdel; - bestaetigen(LOESCH_TEXT.sbuch, LOESCH_HINWEIS.sbuch, "Löschen", () => { eintragLoeschen("sbuch", id); go("service"); }); - return; - } - const bildDel = e.target.closest("[data-bildloeschen]"); - if (bildDel) { - const dateiname = bildDel.dataset.bildloeschen; - bestaetigen(LOESCH_TEXT.bild, LOESCH_HINWEIS.bild, "Foto löschen", () => { - (async () => { - try { await HASS.callService("pyscript", "audi_dashboard_bild_loeschen", { dateiname }); } - catch (err) { hinweis("Löschen fehlgeschlagen", err.message); return; } - bildMenuOffen = null; bildVersion = Date.now(); render(); - })(); - }); - return; - } - // Fahrzeugbild (Radbild wie Fahrzeugbilder-Slots): vorhandenes Foto -> Popup - // mit Ersetzen/Löschen, fehlendes Foto (Platzhalter, bild-fehlt) -> Dateiauswahl - // direkt öffnen. - const bildKlick = e.target.closest("[data-bildklick]"); - if (bildKlick) { - const dateiname = bildKlick.dataset.bildklick; - const box = bildKlick.querySelector(".bildbox"); - const container = bildKlick.closest(".tile"); - if (box && box.classList.contains("bild-fehlt")) { - const input = container.querySelector(`[data-bildupload="${dateiname}"]`); - if (input) input.click(); - } else { - bildMenuOffen = dateiname; render(); - } - return; - } - if (e.target.closest("[data-bildmenu-schliessen]")) { bildMenuOffen = null; render(); return; } - const bildErsetzen = e.target.closest("[data-bildersetzen]"); - if (bildErsetzen) { - const container = bildErsetzen.closest(".tile"); - const input = container.querySelector(`[data-bildupload="${bildErsetzen.dataset.bildersetzen}"]`); - if (input) input.click(); - return; - } - if (e.target.closest("[data-sdabbrechen]")) { smartdealPopupOffen = false; render(); return; } - if (e.target.closest("[data-sdbestaetigen]")) { - const datum = $("#sdPopupBis").value; - if (!datum) return; - CAR.smartdeal.aktiv = true; CAR.smartdeal.laeuftAb = datum; - smartdealPopupOffen = false; profilSpeichern(); render(); - return; - } - if (e.target.closest("[data-bildgalerie]")) { bildWeiter(); return; } - if (e.target.closest("[data-sbneu]")) { - CAR.service.buch.unshift({ datum: dedat(heute()), km: CAR.odoBekannt ? CAR.odo : 0, art: SERVICE_ARTEN[0], werkstatt: CAR.service.autohaus.name, kosten: null, arbeiten: "" }); - entwurfIndex = 0; go("sbuch", "0"); return; - } - if (e.target.closest("[data-sbspeichern]")) { - entwurfIndex = null; profilSpeichern(); go("service"); return; - } - if (e.target.closest("[data-nmauf]")) { nmOffen = !nmOffen; render(); return; } - if (e.target.closest("[data-einrichten-auf]")) { einrichtenOffen = !einrichtenOffen; render(); return; } - if (e.target.closest("[data-nmspeichern]")) { - const wert = parseInt($("#nm").value, 10); - if (!isNaN(wert) && wert > 0) { CAR.reifen.anzugsmoment = wert; profilSpeichern(); } - nmOffen = false; render(); return; - } - const kmauf = e.target.closest("[data-kmauf]"); - if (kmauf) { kmOffen = kmOffen === kmauf.dataset.kmauf ? null : kmauf.dataset.kmauf; render(); return; } - const kmspeichern = e.target.closest("[data-kmspeichern]"); - if (kmspeichern) { - const satz = kmspeichern.dataset.kmspeichern; - const wert = parseFloat($(`#km-${satz}`).value); - if (!isNaN(wert) && wert >= 0) { - const km = Math.round(wert * 10) / 10; - CAR.reifen[satz].km = km; - // km wird bewusst nicht ueber profilSpeichern() geschrieben (siehe - // Kommentar in configCarZuProfil) - eigener Service, der nur den - // Zaehler selbst setzt und referenz_odo_km unangetastet laesst, damit - // die naechste automatische Fortschreibung ab diesem neuen Stand - // weiterzaehlt statt ihn zu ueberschreiben. - serviceRufen("audi_dashboard_reifen_km_setzen", { satz, km }); - } - kmOffen = null; render(); return; - } - if (e.target.closest("[data-neu]")) { - neuOffen = !neuOffen; - if (belegEntwurf && belegEntwurf.praefix === "neuTank") belegEntwurf = null; - if (belegFehler && belegFehler.context === "neuTank") belegFehler = null; - render(); return; - } - if (e.target.closest("[data-fahrt]")) { fahrtOffen = !fahrtOffen; render(); return; } - if (e.target.closest("[data-fahrtspeichern]")) { - serviceRufen("audi_dashboard_fahrt_manuell_anlegen", fahrtFormularWerte("neuFahrt")); - fahrtOffen = false; render(); return; - } - if (e.target.closest("[data-fahrtbearbeiten]")) { - fahrtBearbeitenOffen = !fahrtBearbeitenOffen; render(); return; - } - const fahrtAkt = e.target.closest("[data-fahrtaktualisieren]"); - if (fahrtAkt) { - serviceRufen("audi_dashboard_fahrt_aktualisieren", - { trip_id: fahrtAkt.dataset.fahrtaktualisieren, ...fahrtFormularWerte("bearbFahrt") }); - fahrtBearbeitenOffen = false; render(); return; - } - if (e.target.closest("[data-tankspeichern]")) { - const w = tankFormularWerte("neuTank"); - if (w.liter > 0 && w.kosten > 0) { - serviceRufen("audi_dashboard_tankvorgang_manuell", { - ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, - distanz: w.distanz, kraftstoff: w.kraftstoff, - }); - } - neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; - } - if (e.target.closest("[data-tankspeichernbeleg]")) { - const w = tankFormularWerte("neuTank"); - const beleg = belegEntwurf && belegEntwurf.praefix === "neuTank" ? belegEntwurf.daten : null; - if (w.liter > 0 && w.kosten > 0) { - serviceRufen("audi_dashboard_tankvorgang_manuell", { - ts: w.ts, liter: w.liter, kosten: w.kosten, km: w.km, ersparnis: w.ersparnis, station: w.station, - distanz: w.distanz, kraftstoff: w.kraftstoff, - receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, - }); - } - neuOffen = false; belegEntwurf = null; belegFehler = null; render(); return; - } - if (e.target.closest("[data-tankbearbeiten]")) { - tankBearbeitenOffen = !tankBearbeitenOffen; - if (belegEntwurf && belegEntwurf.praefix === "bearbTank") belegEntwurf = null; - if (belegFehler && belegFehler.context === "bearbTank") belegFehler = null; - render(); return; - } - const tankAkt = e.target.closest("[data-tankaktualisieren]"); - if (tankAkt) { - const w = tankFormularWerte("bearbTank"); - const beleg = belegEntwurf && belegEntwurf.praefix === "bearbTank" ? belegEntwurf.daten : null; - serviceRufen("audi_dashboard_tankvorgang_aktualisieren", { - tank_id: tankAkt.dataset.tankaktualisieren, ts: w.ts, liter: w.liter, kosten: w.kosten, - km: w.km, ersparnis: w.ersparnis, station: w.station, - distanz: w.distanz, kraftstoff: w.kraftstoff, - receipt_key: beleg ? beleg.receipt_key : null, receipt_file: beleg ? beleg.receipt_file : null, - }); - tankBearbeitenOffen = false; belegEntwurf = null; belegFehler = null; render(); return; - } - const belegBtn = e.target.closest("[data-beleg]"); - if (belegBtn) { - // "neu"/"bearb": nur parsen und den offenen Entwurf ausfüllen (siehe - // belegEntwurf). Eine tank_id dagegen (Button in der Einzelbeleg- - // Ansicht) trägt den Beleg direkt in den bereits gespeicherten - // Tankvorgang nach - siehe Kopfkommentar in audi_dashboard_beleg_hochladen. - const ziel = belegBtn.dataset.beleg; - const nurParsen = ziel === "neu" || ziel === "bearb"; - const praefix = ziel === "neu" ? "neuTank" : ziel === "bearb" ? "bearbTank" : null; - const tankId = nurParsen ? undefined : ziel; - const kontext = nurParsen ? praefix : ziel; - if (belegFehler && belegFehler.context === kontext) belegFehler = null; - belegZielPraefix = praefix; - // Statt direkt die Dateiauswahl zu öffnen: Fenster mit Ablagefläche - // (Rechner) und Einfügen-Knopf (Telefon, PDF aus der Mail kopiert). - belegPopup = { tankId, kontext, fehler: null }; - render(); - return; - } - if (e.target.closest("[data-belegzu]")) { belegPopup = null; render(); return; } - if (e.target.closest("[data-belegeinfuegen]")) { belegAusZwischenablage(); return; } - if (e.target.closest("[data-belegdatei]")) { - const eingabe = document.createElement("input"); - eingabe.type = "file"; eingabe.accept = "application/pdf"; - eingabe.onchange = () => belegPopupSchliessenUndLaden(eingabe.files[0]); - eingabe.click(); - return; - } - if (e.target.closest("[data-import-oeffnen]")) { - const vorgabe = importZeitraumVorgabe(); - importPopup = { von: vorgabe.von, bis: vorgabe.bis, laeuft: false, ergebnis: null, fehler: null }; - render(); - return; - } - if (e.target.closest("[data-import-zu]")) { - if (importPopup && importPopup.laeuft) return; - importPopup = null; render(); return; - } - if (e.target.closest("[data-import-start]")) { importAusloesen(); return; } - const del = e.target.closest("[data-loeschen]"); - if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; } - const vseldel = e.target.closest("[data-vselbstdel]"); - if (vseldel) { CAR.versicherung.selbst.splice(+vseldel.dataset.vselbstdel, 1); profilSpeichern(); render(); return; } - if (e.target.closest("[data-vselbstadd]")) { CAR.versicherung.selbst.push(["", ""]); profilSpeichern(); render(); return; } - const oel = e.target.closest("[data-oel]"); - if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; } - const mode = e.target.closest("[data-mode]"); - if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; } - const pr = e.target.closest("[data-profil]"); - if (pr && pr.dataset.profil === "export") { - const a = document.createElement("a"); - a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2)); - a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove(); - return; - } - const bk = e.target.closest("[data-backup]"); - if (bk && bk.dataset.backup === "jetzt") { serviceRufen("audi_dashboard_backup_jetzt", {}); return; } - if (bk && bk.dataset.backup === "export") { - // Die Sensor-Zuordnung gehört mit ins Backup: sie liegt sonst nur in - // entitaeten.json und wäre nach einer Wiederherstellung verloren. - const paket = { erstellt: new Date().toISOString(), profil: PROFIL_ROH, fahrten: TRIPS, tankvorgaenge: FILLS, - entitaeten: (ENTITAETEN && ENTITAETEN.werte) || null }; - const a = document.createElement("a"); - a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(paket, null, 2)); - a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove(); - return; - } - if (e.target.closest("[data-update-pruefen]")) { - if (!updatePruefend) { - updatePruefend = true; render(); - (async () => { - try { await HASS.callService("pyscript", "audi_dashboard_update_pruefen", {}); } - catch (err) { hinweis("Update-Prüfung fehlgeschlagen", err.message); } - await updateStatusAbrufen(); - updatePruefend = false; render(); - })(); - } - return; - } - if (e.target.closest("[data-update-installieren]")) { - if (!updateInstallierend) bestaetigen( - "Update installieren?", - "Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben unangetastet. Die Seite lädt sich danach neu.", - "Installieren", - () => { updateInstallierend = true; render(); - (async () => { - try { - await HASS.callService("pyscript", "audi_dashboard_update_installieren", {}); - await updateStatusAbrufen(); - location.reload(); - return; - } catch (err) { hinweis("Update-Installation fehlgeschlagen", err.message); } - await updateStatusAbrufen(); - updateInstallierend = false; render(); - })(); - }, false); - return; - } - const cs = e.target.closest("[data-csv]"); - if (cs) { - const w = cs.dataset.csv; - if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"], - TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status])); - else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"], - FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""])); - else csvDatei("servicebuch.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"], - CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""])); - return; - } - const satz = e.target.closest("[data-satz]"); - if (satz) { serviceRufen("audi_dashboard_reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; } - const k = e.target.closest("[data-ics]"); - if (k) { - if (k.dataset.ics === "service") { - const ts = termine(), t = ts[Math.min(svAuswahl, ts.length - 1)]; - const datum = $("#svDat").value; - CAR.service.vereinbart[t.art] = datum; - profilSpeichern(); - ics(`${CONFIG.fahrzeugtitel}: ${t.art}`, datum, CAR.service.autohaus.name, - t.km !== null ? `Fällig bei ${de(t.km)} km · ${CAR.service.autohaus.adresse}` : CAR.service.autohaus.adresse); - render(); - } else { - const datum = $("#rfDat").value; - CAR.reifen.wechsel.datum = datum; - CAR.reifen.wechsel.vereinbart = $("#rfOk").checked; - profilSpeichern(); - const ziel = CAR.reifen.aktiv === "Sommer" ? "Winter" : "Sommer"; - ics(`${CONFIG.fahrzeugtitel}: Reifenwechsel auf ${ziel}`, datum, CAR.service.autohaus.name, `Umstellung des Zählers auf den ${ziel}satz nicht vergessen`); - } - return; - } - const a = e.target.closest("[data-acc]"); - if (a) { const b = ROOT.getElementById(a.dataset.acc); a.setAttribute("aria-expanded", b.classList.toggle("on")); return; } - const art = e.target.closest("[data-art]"); - if (art) { - const tr = TRIPS.find((x) => x.trip_id === art.dataset.art); - tr.art = tr.art === "arbeitsweg" ? "privat" : "arbeitsweg"; - // Bis 2026-08-17 blieb das Umschalten rein lokal und war nach dem - // naechsten Backend-Push wieder weg - jetzt wandert es mit. - serviceRufen("audi_dashboard_fahrt_aktualisieren", { trip_id: tr.trip_id, art: tr.art }); - render(); return; - } - // Standort (Vollbild) - Karten-/Menü-Bedienung manipuliert SMAP/das Menü- - // DOM direkt statt über render() (siehe Kopfkommentar Standort-Abschnitt). - if (e.target.closest("[data-standort-umschalten]")) { standortMenuUmschalten(); return; } - if (e.target.closest("[data-standort-layer]")) { SMAP_STIL = SMAP_STIL === "satellit" ? "strasse" : "satellit"; standortTiles(); return; } - if (e.target.closest("[data-standort-fahrzeug]")) { standortAufFahrzeug(); return; } - if (e.target.closest("[data-standort-user]")) { standortAufUser(); return; } - if (e.target.closest("[data-standort-beide]")) { standortBeideZeigen(); return; } - if (e.target.closest("[data-standort-schliessen]")) { standortMenuSchliessen(); return; } - if (e.target.closest("[data-standort-teilen]")) { standortTeilen(); return; } - // Einzelbeleg: zurück auf die Tankstelle (manipuliert MAP direkt, kein - // render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren). - if (e.target.closest("#belegZentrieren")) { belegAufTankstelleZentrieren(); return; } - const g = e.target.closest("[data-go]"); - if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); } - }); - - ROOT.addEventListener("input", (e) => { - const sucheFeld = e.target.dataset.setupSuche; - if (sucheFeld) { - // Nur die Kandidatenliste patchen, nicht render() - ein voller - // Re-Render würde bei jedem Tastendruck das Eingabefeld neu erzeugen - // und den Fokus verlieren (gleiches Problem/gleiche Lösung wie beim - // bestehenden data-tankpreisfeld-Handler direkt hier drunter). - setupSuchtext = e.target.value; - const teile = sucheFeld.split(":"); - const key = teile[0]; - const wert = teile.length === 2 ? (setupZuordnung[key] || [])[Number(teile[1])] : setupZuordnung[key]; - const liste = ROOT.querySelector(`[data-setup-liste="${sucheFeld}"]`); - if (liste) liste.innerHTML = entitaetZeilenMarkup(sucheFeld, key, setupSuchtext, wert); - return; - } - // Gleicher Grund wie beim tankpreisfeld-Handler darunter: nur die - // Anzeige patchen, kein render() - das würde das gerade bearbeitete Feld - // neu erzeugen und den Fokus verlieren. - const zeitfeldPraefix = e.target.dataset.fahrtzeitfeld; - if (zeitfeldPraefix) { - const anzeige = ROOT.getElementById(`${zeitfeldPraefix}DauerAnzeige`); - if (anzeige) anzeige.textContent = fahrtDauerText(zeitfeldPraefix); - return; - } - const praefix = e.target.dataset.tankpreisfeld; - if (!praefix) return; - const liter = parseFloat((ROOT.getElementById(`${praefix}Liter`).value || "0").replace(",", ".")); - const kosten = parseFloat((ROOT.getElementById(`${praefix}Kosten`).value || "0").replace(",", ".")); - const anzeige = ROOT.getElementById(`${praefix}PreisAnzeige`); - if (anzeige) anzeige.textContent = liter > 0 ? `Preis je Liter: ${de(kosten / liter, 2)} €` : "Preis je Liter: wird berechnet"; - }); - - ROOT.addEventListener("change", (e) => { - if (e.target.dataset.profil === "import") { - const datei = e.target.files[0]; if (!datei) return; - const leser = new FileReader(); - leser.onload = () => { - try { - const p = JSON.parse(leser.result); - PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); - serviceRufen("audi_dashboard_profil_schreiben", { profil_json: JSON.stringify(p) }); - bild = 0; render(); - } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } - }; - leser.readAsText(datei); - return; - } - if (e.target.dataset.backup === "import") { - const datei = e.target.files[0]; if (!datei) return; - const leser = new FileReader(); - leser.onload = () => { - try { - const paket = JSON.parse(leser.result); - if (!paket.profil) throw new Error("Keine gültige Backup-Datei (Profil fehlt)"); - PROFIL_ROH = paket.profil; - CONFIG = profilZuConfig(paket.profil); - CAR = profilZuCar(paket.profil, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert }); - TRIPS = paket.fahrten || []; - FILLS = paket.tankvorgaenge || []; - serviceRufen("audi_dashboard_backup_wiederherstellen", { - profil_json: JSON.stringify(paket.profil), - fahrten_json: JSON.stringify(TRIPS), - tankvorgaenge_json: JSON.stringify(FILLS), - // Fehlt der Abschnitt (Sicherung von vor dem Setup-Menü), bleibt die - // aktuelle Zuordnung stehen, statt geleert zu werden. - ...(paket.entitaeten ? { entitaeten_json: JSON.stringify(paket.entitaeten) } : {}), - }); - bild = 0; render(); - } catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); } - }; - leser.readAsText(datei); - return; - } - if (e.target.dataset.bildupload !== undefined) { - const datei = e.target.files[0]; if (!datei) return; - const dateiname = e.target.dataset.bildupload; - const leser = new FileReader(); - leser.onload = async () => { - const daten_base64 = String(leser.result).split(",")[1] || ""; - try { - await HASS.callService("pyscript", "audi_dashboard_bild_hochladen", { dateiname, daten_base64 }); - } catch (err) { hinweis("Hochladen fehlgeschlagen", err.message); } - bildMenuOffen = null; bildVersion = Date.now(); render(); - }; - leser.readAsDataURL(datei); - e.target.value = ""; - return; - } - if (e.target.dataset.backupintervall !== undefined) { - CONFIG.backupIntervall = e.target.value; profilSpeichern(); return; - } - const ein = e.target.dataset.einrichten; - if (ein) { - if (ein === "kennzeichen") CONFIG.kennzeichen = e.target.value; - else if (ein === "ausfuehrung") CONFIG.ausfuehrung = e.target.value; - else if (ein === "modell") CONFIG.fahrzeugtitel = e.target.value; - else CAR[ein] = e.target.value; - profilSpeichern(); return; - } - const vb = e.target.dataset.vereinbart; - if (vb) { CAR.service.vereinbart[vb] = e.target.value; profilSpeichern(); render(); return; } - const ah = e.target.dataset.ah; - if (ah) { - CAR.service.autohaus[ah] = e.target.value; - profilSpeichern(); render(); return; - } - const sb = e.target.dataset.sb; - if (sb) { - const [i, feld] = sb.split("|"); - const eintrag = CAR.service.buch[+i]; - eintrag[feld] = feld === "km" ? (parseInt(e.target.value, 10) || 0) : feld === "kosten" ? (e.target.value === "" ? null : parseFloat(e.target.value)) : e.target.value; - // Ein per "Eintrag hinzufügen" angelegter, noch nicht bestätigter Entwurf - // wird erst beim Klick auf "Speichern" (data-sbspeichern) persistiert - - // sonst würde schon das testweise Ausfüllen einzelner Felder auto-speichern. - if (entwurfIndex !== +i) profilSpeichern(); - render(); return; - } - const rf = e.target.dataset.rf; - if (rf) { - const [k, schluessel] = rf.split("|"); - const wert = e.target.value; - CAR.reifen[k][schluessel] = wert; - profilSpeichern(); render(); return; - } - if (e.target.dataset.schrift !== undefined) { - beschriftung = e.target.checked; - try { localStorage.setItem(SCHRIFT_KEY, beschriftung ? "an" : "aus"); } catch (err) { /* Privatmodus */ } - ROOT.getElementById("tabbar").classList.toggle("ohne", !beschriftung); - return; - } - if (e.target.dataset.steuer !== undefined) { CAR.steuer.betrag = parseFloat(e.target.value) || 0; profilSpeichern(); render(); return; } - if (e.target.dataset.steuerzeit !== undefined) { CAR.steuer.zeitraum = e.target.value; profilSpeichern(); render(); return; } - if (e.target.dataset.steuerfaellig !== undefined) { CAR.steuer.faellig = e.target.value; profilSpeichern(); render(); return; } - if (e.target.dataset.steuerlast !== undefined) { CAR.steuer.lastschrift = e.target.checked; profilSpeichern(); render(); return; } - const vteil = e.target.dataset.vteil; - if (vteil) { - const [i, feld] = vteil.split("|"); - CAR.versicherung.teile[+i][feld] = feld === "betrag" ? (parseFloat(e.target.value) || 0) : e.target.value; - CAR.versicherung.beitrag = sum(CAR.versicherung.teile, (t) => t.betrag || 0); - profilSpeichern(); render(); return; - } - // Kein render() bei den beiden Import-Feldern: das Popup wird bei jedem - // render() neu erzeugt, ein Neuzeichnen mitten in der Eingabe risse dem - // Nutzer den gerade offenen Datumswähler weg. - if (e.target.dataset.importVon !== undefined) { if (importPopup) importPopup.von = e.target.value; return; } - if (e.target.dataset.importBis !== undefined) { if (importPopup) importPopup.bis = e.target.value; return; } - if (e.target.dataset.vgueltigab !== undefined) { CAR.versicherung.gueltigAb = e.target.value; profilSpeichern(); render(); return; } - const vnotruf = e.target.dataset.vnotruf; - if (vnotruf) { CAR.versicherung[vnotruf] = e.target.value; profilSpeichern(); render(); return; } - const vvertrag = e.target.dataset.vvertrag; - if (vvertrag) { CAR.versicherung[vvertrag] = e.target.value; profilSpeichern(); render(); return; } - const vselbst = e.target.dataset.vselbst; - if (vselbst) { - const [i, feld] = vselbst.split("|"); - CAR.versicherung.selbst[+i][+feld] = e.target.value; - profilSpeichern(); render(); return; - } - if (e.target.dataset.pause !== undefined) { CONFIG.pauseMin = parseInt(e.target.value, 10) || 0; profilSpeichern(); return; } - if (e.target.dataset.sd !== undefined) { - if (e.target.checked) { smartdealPopupOffen = true; render(); } - else { CAR.smartdeal.aktiv = false; profilSpeichern(); render(); } - return; - } - if (e.target.dataset.oelkm !== undefined) { CAR.oel.km = parseInt(e.target.value, 10); profilSpeichern(); return; } - if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; } - if (e.target.dataset.boden !== undefined) { bodenAn = e.target.checked; render(); return; } - if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; } - if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; } - if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; } - }); -} - -/* ------------------------------------------------------------ Datenladen */ -function zustandsDaten(entityId) { - const st = HASS && HASS.states[entityId]; - return st && st.attributes ? st.attributes.daten : undefined; -} - -let letzterProfilState = null, letzterFahrtenState = null, letzterTankState = null, letzterStatusState = null, letzterBelegState = null, letzterBattState = null; - -/* Ergebnis eines Beleg-Uploads verarbeiten (siehe belegEntwurf/belegFehler - weiter oben) - kommt über dieselbe Zustand-statt-Rückgabewert-Route wie - Profil/Fahrten/Tankvorgänge, siehe Kopfkommentar in - frontend_veroeffentlichung.py. */ -function belegErgebnisVerarbeiten(daten) { - if (!daten) return; - if (daten.fehler) { - const kontext = daten.tank_id || belegZielPraefix; - belegFehler = { context: kontext, meldung: daten.fehler }; - if (!daten.tank_id) belegEntwurf = null; - } else if (daten.tank_id) { - // Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der - // Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über - // tankvorgaenge_veroeffentlichen(), hier nur eine evtl. noch offene - // Fehleranzeige für diesen Tankvorgang löschen. - if (belegFehler && belegFehler.context === daten.tank_id) belegFehler = null; - } else if (belegZielPraefix) { - belegEntwurf = { praefix: belegZielPraefix, daten }; - belegFehler = null; - } - render(); -} - -/* Nachlade-Fallback: panel_custom schiebt einem bereits gemounteten Element - frische hass-Objekte nicht zuverlässig nach - an der Testinstanz auch - Minuten nach dem Start und nach SPA-interner Navigation weg und zurück - beobachtet, dass HASS auf dem allerersten, noch unvollständigen Stand - hängen blieb (der übliche Fall: das Backend hat unsere vier Zustände beim - allerersten hass-Push noch nicht veröffentlicht, z. B. kurz nach einem - HA-Neustart). Deshalb hier aktiv per WebSocket nachfragen, statt passiv - auf den nächsten hass-Push zu warten. Ergebnisse landen in einem eigenen - Zwischenspeicher statt in HASS.states selbst - das gehört Home Assistant, - nicht uns. - - Ein früherer Stand brach nach 15 Versuchen (rund 30s) endgültig ab - real - zu kurz beobachtet: startet der Container komplett neu (HA + HACS + die - VAG-Integration mit eigenen Netzwerkaufrufen), kann es deutlich länger - dauern, bis pyscript überhaupt geladen ist und sein @time_trigger("startup") - feuert. Sichtbares Symptom war genau das gemeldete Verhalten: die App - blieb dauerhaft auf "Lädt …" hängen (und die Tableiste zeigte dadurch auch - kein aktives Symbol, weil render() den harten Rücksprung bei - !DATEN_GELADEN nie verlässt) - erst ein Panelwechsel weg und zurück half, - weil das einen frischen connectedCallback()/datenLaden(false) auslöst, zu - dem Zeitpunkt oft schon nach Ablauf der wirklichen Backend-Startzeit. - Deshalb jetzt ohne festes Abbruchlimit: nach der anfänglichen schnellen - Serie (siehe NACHLADE_INTERVALL_MS) läuft es mit einem größeren, aber - endlosen Intervall weiter, bis Daten da sind oder das Element abgehängt - wird (disconnectedCallback stoppt den Timer, connectedCallback stößt ihn - über datenLaden() wieder an). - - 2026-08-17: Der Takt darf an keiner Stelle abreißen. Vorher stieg sowohl - nachladeAnstossen() (bei fehlendem HASS/callWS) als auch datenLaden() (bei - fehlendem HASS) still aus, ohne eine nächste Runde zu planen - danach half - nur noch ein zufälliger hass-Push oder ein Klick ins Menü (go() ruft - datenLaden() mit auf). Genau das war das gemeldete "Lädt … bis man auf - einen Menüpunkt klickt". */ -const NACHGELADEN = {}; -let nachladeTimer = null; -let nachladeVersuche = 0; -const NACHLADE_INTERVALL_MS = 2000; -const NACHLADE_LANGSAM_MS = 10000; -const NACHLADE_SCHNELLE_VERSUCHE = 15; - -function nachladeAnstossen() { - if (nachladeTimer) return; - nachladeVersuche++; - // Erster Versuch fast sofort statt erst nach vollem Intervall - der - // Wettlauf mit pyscripts beim_start() (siehe Kopfkommentar) ist meist - // schon nach wenigen hundert Millisekunden entschieden, nicht erst nach - // den vollen zwei Sekunden. Nach den ersten Versuchen (Backend vermutlich - // noch nicht bereit) auf ein ruhigeres Intervall wechseln, statt für immer - // im schnellen Takt weiterzufragen. - const verzoegerung = nachladeVersuche === 1 ? 200 - : nachladeVersuche <= NACHLADE_SCHNELLE_VERSUCHE ? NACHLADE_INTERVALL_MS - : NACHLADE_LANGSAM_MS; - nachladeTimer = setTimeout(async () => { - nachladeTimer = null; - // HASS kann hier noch fehlen: panel_custom erzeugt das Element und setzt - // hass nicht immer in dieser Reihenfolge. Frueher brach die Schleife in so - // einem Fall oben ganz ab (Bedingung !HASS || !HASS.callWS) und wurde nie - // wieder angestossen - genau der dauerhaft haengende "Lädt …"-Bildschirm. - // Jetzt wird die Runde nur uebersprungen, der Takt laeuft weiter. - if (HASS && HASS.callWS) { - try { - const states = await HASS.callWS({ type: "get_states" }); - const finden = (id) => states.find((s) => s.entity_id === id); - NACHGELADEN.profil = finden("pyscript.audi_dashboard_profil") || NACHGELADEN.profil; - NACHGELADEN.fahrten = finden("pyscript.audi_dashboard_fahrten") || NACHGELADEN.fahrten; - NACHGELADEN.tank = finden("pyscript.audi_dashboard_tankvorgaenge") || NACHGELADEN.tank; - NACHGELADEN.status = finden("pyscript.audi_dashboard_fahrzeugstatus") || NACHGELADEN.status; - NACHGELADEN.batt = finden("pyscript.audi_dashboard_batterieverlauf") || NACHGELADEN.batt; - // Ohne diese Zeile bleibt der Setup-Katalog nach einem HA-Neustart leer, - // bis zufaellig ein Push kommt - siehe setupBereit(). - NACHGELADEN.entitaeten = finden("pyscript.audi_dashboard_entitaeten") || NACHGELADEN.entitaeten; - } catch (e) { - // wird beim naechsten Versuch erneut probiert - } - } - datenLaden(true); - // Rueckversicherung: datenLaden() stoesst die naechste Runde selbst an, - // aber nur, wenn es nicht vorher aussteigt. Der Takt darf an keiner - // Stelle abreissen, solange noch keine Daten da sind. - if (!DATEN_GELADEN) nachladeAnstossen(); - }, verzoegerung); -} - -async function datenLaden(erzwingen) { - if (!HASS) { - if (!DATEN_GELADEN) nachladeAnstossen(); - return; - } - const profilState = HASS.states["pyscript.audi_dashboard_profil"] || NACHGELADEN.profil; - const fahrtenState = HASS.states["pyscript.audi_dashboard_fahrten"] || NACHGELADEN.fahrten; - const tankState = HASS.states["pyscript.audi_dashboard_tankvorgaenge"] || NACHGELADEN.tank; - const statusState = HASS.states["pyscript.audi_dashboard_fahrzeugstatus"] || NACHGELADEN.status; - const battState = HASS.states["pyscript.audi_dashboard_batterieverlauf"] || NACHGELADEN.batt; - const belegState = HASS.states["pyscript.audi_dashboard_beleg_ergebnis"]; - // Unabhängig vom geaendert-Block unten gelesen (der trackt nur profil/ - // fahrten/tank/status/batt) - ändert sich erst nach einem Setup-Speichern, - // nicht Teil des normalen Update-Rhythmus. - const entitaetenState = HASS.states["pyscript.audi_dashboard_entitaeten"] || NACHGELADEN.entitaeten; - if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten; - - if (belegState && belegState !== letzterBelegState) { - letzterBelegState = belegState; - belegErgebnisVerarbeiten(belegState.attributes && belegState.attributes.daten); - } - - const geaendert = erzwingen - || profilState !== letzterProfilState || fahrtenState !== letzterFahrtenState - || tankState !== letzterTankState || statusState !== letzterStatusState || battState !== letzterBattState; - if (geaendert) { - letzterProfilState = profilState; letzterFahrtenState = fahrtenState; - letzterTankState = tankState; letzterStatusState = statusState; letzterBattState = battState; - - const profil = profilState && profilState.attributes ? profilState.attributes.daten : null; - if (profil) { - const status = (statusState && statusState.attributes && statusState.attributes.daten) || {}; - LETZTES_UPDATE_TS = (status.km != null && statusState && statusState.last_updated) - ? new Date(statusState.last_updated).getTime() : null; - PROFIL_ROH = profil; - CONFIG = profilZuConfig(profil); - CAR = profilZuCar(profil, status); - TRIPS = (fahrtenState && fahrtenState.attributes && fahrtenState.attributes.daten) || []; - FILLS = (tankState && tankState.attributes && tankState.attributes.daten) || []; - BATTERIEVERLAUF = (battState && battState.attributes && battState.attributes.daten) || []; - DATEN_GELADEN = true; - nachladeVersuche = 0; - if (!UPDATE_STATUS_GEHOLT) { UPDATE_STATUS_GEHOLT = true; updateStatusAbrufen().then(render); } - render(); - return; - } - } - - if (!DATEN_GELADEN) nachladeAnstossen(); // Backend noch nicht bereit (erster Tick nach Neustart) -} - -/* -------------------------------------------------------------- Element */ -class AudiDashboardPanel extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: "open" }); - - /* ha-panel-custom erzeugt das Element teils schon, bevor unser eigenes - Modul (dieses hier) mit customElements.define() fertig registriert - ist - beobachtet als Zusammenspiel mit dessen fehlendem Cache für - module_url-Panels (siehe Kopfkommentar in audi-dashboard-panel.js): - _createPanel() kann mehrfach anlaufen, und beim ersten, noch zu - frühen document.createElement(tag) ist "audi-dashboard-panel" für - den Browser noch ein unbekanntes Element. Die anschließende - Zuweisung el.hass = ... trifft dann noch keinen Setter auf dem - Prototyp und legt stattdessen eine gewöhnliche eigene Eigenschaft - auf der Instanz an. Sobald customElements.define() greift und der - Browser das Element "aufwertet" (führt genau diesen Konstruktor - nach), holt die Plattform das NICHT automatisch nach - das gilt nur - für über observedAttributes beobachtete HTML-Attribute, nicht für - beliebige JS-Eigenschaften. Ohne dieses Nachholen bleibt set hass() - dauerhaft unerreichbar: _aufbauen() lief zwar (über - connectedCallback(), einen echten Lifecycle-Hook), aber - datenLaden() nie - sichtbares Symptom war ein dauerhaft - hängendes "Lädt …" trotz vorhandener Backend-Daten. */ - for (const eigenschaft of ["hass", "narrow", "panel"]) { - if (Object.prototype.hasOwnProperty.call(this, eigenschaft)) { - const wert = this[eigenschaft]; - delete this[eigenschaft]; - this[eigenschaft] = wert; - } - } - } - - set hass(hass) { - const erster = !HASS; - HASS = hass; - if (erster) { - this._aufbauen(); - } - datenLaden(false); - } - get hass() { return HASS; } - - set narrow(v) { this._narrow = v; } - set panel(v) { this._panel = v; } - - connectedCallback() { - HOST = this; - if (!this.shadowRoot.querySelector(".phone")) this._aufbauen(); - // Absicherung fuer den Fall, dass panel_custom das Element neu verbindet - // (z. B. weg- und zurueckgeklickt), ohne set hass() dabei erneut - // auszuloesen - ohne das hier bliebe ein noch nicht geladener Stand - // haengen, bis irgendwann zufaellig ein globaler hass-Push durchkommt. - if (!DATEN_GELADEN) datenLaden(false); - } - - disconnectedCallback() { - // Nachlade-Takt anhalten, solange das Panel nicht im Dokument haengt - - // connectedCallback() stoesst ihn ueber datenLaden() wieder an. - if (nachladeTimer) { clearTimeout(nachladeTimer); nachladeTimer = null; } - // Alle drei Karten, nicht nur MAP: sonst bleibt beim Abhaengen des Panels - // (Standort- oder Uebersichtsseite offen) eine Leaflet-Instanz samt ihrem - // window-Resize-Horcher und laufenden Kachelabrufen am Leben. - if (MAP) { MAP.remove(); MAP = null; TILE = null; } - if (SMAP) { SMAP.remove(); SMAP = null; SMAP_TILE = null; FAHRZEUG_MARKER = null; USER_MARKER = null; } - if (TMAP) { TMAP.remove(); TMAP = null; TMAP_TILE = null; } - } - - _aufbauen() { - // Idempotenz-Sperre: sowohl connectedCallback() als auch set hass() - // können _aufbauen() auslösen, je nachdem in welcher Reihenfolge - // panel_custom das Element einfügt und mit hass versorgt. Ohne diese - // Sperre lief der Aufbau zweimal, verdrahtete jeden Knopf doppelt und - // ein Klick auf Tag/Nacht schaltete zweimal um - macht sich gegenseitig - // zunichte, sichtbar erst beim tatsächlichen Testen, nicht im Code. - if (this.shadowRoot.querySelector(".phone")) return; - ROOT = this.shadowRoot; - HOST = this; - /* A6: Standard folgt der Systemeinstellung, eine manuelle Wahl überlebt den - Reload. Vorher stand die App nach jedem Neuladen wieder auf Nacht. */ - HOST.dataset.theme = HOST.dataset.theme || themeGewaehlt(); - - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = `/local/audi-dashboard.css?v=${CSS_VERSION}`; - ROOT.appendChild(link); - - /* iOS-native Optik + responsive Grossbildschirm-Layout (@container ab - 860px), rein additive Auflage aus dem Claude-Design-Entwurf 2026-08-11. */ - const linkIos = document.createElement("link"); - linkIos.rel = "stylesheet"; - linkIos.href = `/local/audi-dashboard-ios.css?v=${CSS_VERSION}`; - ROOT.appendChild(linkIos); - - const wrapper = document.createElement("div"); - wrapper.innerHTML = ` -
-
- -
Übersicht
-
- - -
-
Ziehen zum Aktualisieren
-
Lädt …
-
- -
`; - ROOT.appendChild(wrapper); - - ereignisseVerdrahten(); - randwischenVerdrahten(); - standortMenuVerdrahten(); - themeIcon(); - setInterval(standAlterTicken, 1000); - } -} - -if (!customElements.get("audi-dashboard-panel")) { - customElements.define("audi-dashboard-panel", AudiDashboardPanel); -} - diff --git a/homeassistant/www/audi-dashboard-ios.css b/homeassistant/www/audi-dashboard-ios.css deleted file mode 100644 index a97c020..0000000 --- a/homeassistant/www/audi-dashboard-ios.css +++ /dev/null @@ -1,344 +0,0 @@ -/* ================================================================== - iOS-Auflage über audi-dashboard.css — Audi CI (Rot F50537, Audi Type, - dunkle Fläche) in der Formensprache einer modernen iOS-App: - Große Titel, gruppierte Listen mit 44-px-Zeilen, transluente Tab-Leiste, - iOS-Schalter und -Segmente, gefüllte Aktionsknöpfe. - Reine Auflage: audi-dashboard.css und die App-Logik bleiben unberührt. - ================================================================== */ - -:host{ - display:block;height:100%;container-type:inline-size; - font-family:"Audi Type",Helvetica,Arial,sans-serif; - --ios-tint:#F50537; - --ios-sep:rgba(255,255,255,.13); - --ios-grau:#8E8E93; - --ios-fill:rgba(120,120,128,.24); - --r-tile:16px; - /* deckende Kachelfarbe fuer Flaechen, die etwas verdecken muessen - (Wischzeile ueber dem Loeschen-Feld, Kartenfeld) */ - --tile-deckend:#171B21; -} -:host([data-theme="nacht"]){ - --canvas:#0C1014; - --tile:rgba(255,255,255,.055); - --tile-2:rgba(255,255,255,.10); - --line:rgba(255,255,255,.13); - --line-strong:rgba(255,255,255,.24); - --fg:#FFFFFF; --fg2:#BFC4CC; --fg3:#8E8E93; - --ok:#30D158; --warn:#FFD60A; --bad:#FF453A; - /* Ohne diese Angabe rendert der Browser native Steuerelemente (Dropdown- - Aufklapp-Liste bei -Felder. */ - color-scheme: dark; -} -:host([data-theme="tag"]){ - --canvas:#F2F2F7; - --tile:#FFFFFF; - --tile-2:#EFEFF4; - --line:rgba(60,60,67,.13); - --line-strong:rgba(60,60,67,.24); - --fg:#000000; --fg2:#3C3C43; --fg3:#8E8E93; - --ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30; - --ios-sep:rgba(60,60,67,.16); - --ios-fill:rgba(120,120,128,.16); - --tile-deckend:#FFFFFF; - color-scheme: light; -} - -body{padding:0;background:var(--canvas)} -html,body{height:100%} -/* Der von _aufbauen() eingefuegte Wrapper zwischen Host und .phone. Die - 412px-Deckelung aus audi-dashboard.css (fuer die alte Einzelgroesse - gedacht) wird hier unbedingt aufgehoben, nicht nur ab 860px: auf - schmalen Bildschirmen macht der Viewport selbst die Breite eng genug, - :host-Selektoren innerhalb @container werden von diesem Browser aber - offenbar verworfen - deshalb unbedingt statt bedingt. - Die Hoehe bleibt dagegen viewport-verankert (100dvh statt 100%): das ist - der Grund, warum audi-dashboard.css das ueberhaupt so baute - HA gibt der - panel_custom-Kette keine verlaessliche Hoehe weiter, height:100% liess den - Hauptinhalt hier auf 0px kollabieren (Tabbar direkt unter dem Header, - main#view unsichtbar). Erst ab 860px (siehe @container unten) darf die - 880px-Deckelung fallen, damit die App auf grossen Bildschirmen mehr als - eine schwebende Telefonkarte ist. */ -:host > div{height:min(880px, calc(100dvh - 24px));max-width:none} -.phone{width:100%;max-width:none;height:100%;border-radius:0; - font-family:"Audi Type",Helvetica,Arial,sans-serif; - -webkit-font-smoothing:antialiased;letter-spacing:0} - -/* ---------------- Navigationsleiste ---------------- */ -/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */ -/* Linker Innenabstand = --seitenrand von main#view (16px), damit der Titel - exakt auf der Kante der Kacheln darunter sitzt. */ -.topbar{align-items:center;gap:4px;padding:56px 20px 4px 16px} -.head{display:flex;flex-direction:column} -.eyebrow{display:none} -.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2} -.back{width:34px;height:34px;margin-left:-8px;justify-content:center} -/* Design-Audit 2026-08-13, auf allen Breiten nachgezogen 2026-08-17: .back ist - auch inaktiv (opacity:0) noch ein Flex-Element plus Gap - auf Wurzelseiten - ohne Zurueck-Pfeil schob das die Kopfzeile (.head/.title) sichtbar weiter - nach rechts, als main#view darunter beginnt. Das negative margin-right hebt - zusaetzlich den Gap auf, sonst blieben 4px Versatz. Auf Detailseiten - (.back.on) bleibt der Platz bewusst reserviert, das ist dort ein echter, - bedienbarer Knopf. */ -.back:not(.on){width:0;min-width:0;margin:0 -4px 0 0;overflow:hidden} -/* Rot bleibt Akzent/destruktiv, nicht Navigationsfarbe (dieselbe Regel wie - bei den Aktions-Buttons und dem aktiven Menü-Icon) - derselbe neutrale Ton - wie die Kopfzeile selbst. */ -.back svg{width:22px;height:22px;color:var(--fg)} -.sync{margin-top:3px;font-size:12.5px;color:var(--fg3)} -.sync svg{width:13px;height:13px} -.themebtn{width:36px;height:36px;border:0;background:var(--ios-fill);color:var(--fg2)} -.themebtn:hover{background:var(--tile-2);color:var(--fg)} -/* Telefonbreite: die Ringe stehen oben rechts und oeffnen die Einstellungen - (Stand nach dem Design-Audit). Das Zahnrad entfaellt dort, damit oben rechts - nur ein Element sitzt. */ -.rings{order:9;margin-left:8px;margin-right:-10px;cursor:pointer; - min-width:44px;min-height:44px;height:44px;justify-content:flex-end;padding:0 10px} -.rings svg{height:17px} -.profilbtn{display:none} -/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */ -.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px} -.phone:has(.back.on) .head{align-items:center} -.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center} -.phone:has(.back.on) .sync{margin-top:2px;font-size:11.5px} - -main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smooth} - -/* ---------------- Karten und Listen ---------------- */ -.tile{padding:16px 18px;margin-bottom:14px;border-radius:var(--r-tile)} -.tile.flat{padding:0} -.tilebtn:hover{background:var(--tile-2)} -/* top/margin-top NICHT von der Basis uebernehmen: alle .go-Kacheln - (Fahrzeug/Service/Versicherung/Reifen/Schutzbrief) sind mehrzeilige - Kacheln mit .label oben plus einer ganzen dl.rows-Liste darunter - eine - vertikale 50%-Zentrierung landet dann mitten in der Zeilenliste statt - neben dem Titel und ueberlappt umgebrochenen Zeilentext (Design-Audit - 2026-08-13). Bleibt bei top:var(--sp-5) aus der Basis, nur rechter - Abstand/Farbe werden fuer die iOS-Optik angepasst. */ -.tilebtn .go{right:18px;stroke:var(--ios-grau);stroke-width:1.6} -.label{font-size:13px;letter-spacing:.01em;text-transform:none;color:var(--fg3)} -.row{min-height:44px;padding:11px 0;border-bottom:1px solid var(--ios-sep)} -.row dt{font-size:16px;color:var(--fg)} -.row dd{font-size:16px;color:var(--fg2)} -.row dd small{font-size:12.5px;margin-top:2px} -.row-fussnote{font-size:12.5px} -.rows-fest dt{flex:0 0 150px} -.chev{stroke:var(--ios-grau);stroke-width:1.6} - -.acc-h{padding:13px 0;border-bottom:1px solid var(--ios-sep)} -.acc-h .k{font-size:16px} -.acc-h .v{font-size:12.5px} -.leaf{padding:12px 0 12px 40px;border-bottom:1px solid var(--ios-sep)} -.leaf .k{font-size:16px} -.leaf .v{font-size:15px} -.leaf .k small,.leaf .v small{font-size:12.5px} -.mark::before,.mark::after{background:var(--ios-grau)} - -/* ---------------- Bedienelemente ---------------- */ -.seg{gap:0;padding:2px;background:var(--ios-fill);border-radius:9px;margin-top:14px} -.seg button{border:0;border-radius:7px;padding:7px 8px;font-size:13px;font-weight:400; - letter-spacing:0;text-transform:none;color:var(--fg2)} -.seg button.on{background:var(--tile-2);color:var(--fg);box-shadow:0 1px 3px rgba(0,0,0,.25)} - -/* Aktive/Primär-Buttons sind grau, nicht rot (Nutzerwunsch 2026-08-16): Rot - bleibt nur noch für destruktive Aktionen (.loeschen). .aktion allein ist - die neutrale Sekundäraktion, .primaer die grau gefüllte Hauptaktion - - dieselbe Fläche wie die Grundauflage (var(--fg)/var(--canvas)). */ -.aktion{margin-top:18px;padding:15px;border:0;border-radius:14px; - background:var(--ios-fill);color:var(--fg);font-size:16px;font-weight:400; - letter-spacing:0;text-transform:none} -.aktion:hover{background:var(--tile-2)} -.aktion.primaer{background:var(--fg);color:var(--canvas)} -.aktion.primaer:hover{filter:brightness(1.08)} -.aktion.loeschen{background:var(--ios-fill);color:var(--bad);border:0} -.aktion.loeschen:hover{background:var(--bad);color:#fff;filter:none} - -.feld{min-height:44px;padding:9px 0;border-bottom:1px solid var(--ios-sep)} -.feld label{font-size:16px;color:var(--fg)} -.feld input,.feld select{background:transparent;border:0;border-radius:8px;font-size:16px; - padding:8px 4px;color:var(--fg2)} -/* Design-Audit 2026-08-13/16: die graue Flaeche ist das einheitliche Signal - "das hier kann ich aendern". Sie trugen bisher nur die Felder mit Einheit - (.mitEinheit input, z.B. "Pause bis [15] Minuten"); alles andere -