pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)

Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine
normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform
und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf
GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert
homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS.

Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der
panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige
Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und
"pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die
Integration beim ersten Start aus ihrer Vorlage an.

Drei alte Schwächen sind dabei mit erledigt:
- Die Nutzlast landet nicht mehr in der Recorder-Datenbank
  (_unrecorded_attributes - das kann nur eine echte Entität).
- Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher);
  fiel sie während eines Ausfalls ins Ende, schließt
  nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt.
- Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu
  gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg.

Namensvertrag geändert, beide Oberflächen mitgezogen:
pyscript.audi_dashboard_x -> sensor.audi_dashboard_x,
pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten
Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das
Panel liegt in der Integration und kann nicht driften.

Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home
Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json.

Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem
Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport,
echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit
automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112
Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 23:53:56 +02:00
parent 99cef7c393
commit d8b12da36d
136 changed files with 5289 additions and 17641 deletions
+154 -71
View File
@@ -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/<domain>/`, `www/community/<name>/`, `python_scripts/`, `themes/`,
`custom_templates/` and `appdaemon/apps/<name>/` (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/<first dir>` 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.
+104
View File
@@ -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 |
-1
View File
@@ -1 +0,0 @@
2026.08.23.1
+65 -49
View File
@@ -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
+17 -8
View File
@@ -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
+21 -21
View File
@@ -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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
return this.rest.dienstAufrufen("pyscript", "audi_dashboard_historie_importieren", {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
start,
ende,
});
+4 -4
View File
@@ -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<T>).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,
+25 -14
View File
@@ -19,7 +19,7 @@ export interface HassState<A = Record<string, unknown>> {
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<T> {
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.<name>`. Früher `pyscript.audi_dashboard_<name>` - 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";
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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({
<div className="dm-knopfreihe">
<ActionButton
onClick={() =>
void api.rest.dienstAufrufen("pyscript", "audi_dashboard_backup_jetzt")
void api.rest.dienstAufrufen(DIENST_DOMAIN, "backup_jetzt")
}
>
Jetzt sichern
@@ -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.
+2 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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",
+22 -9
View File
@@ -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/),
@@ -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},
)
+414
View File
@@ -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]}"
@@ -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)
+275
View File
@@ -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)
@@ -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 <img> 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)
@@ -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={})
+83
View File
@@ -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_<objekt-id>; 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"
+256
View File
@@ -0,0 +1,256 @@
"""Die Dienste, die beide Oberflächen aufrufen.
Aus `pyscript.audi_dashboard_<name>` ist `audi_dashboard.<name>` 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)
@@ -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
@@ -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)
@@ -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 <link> 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_<dienst>: 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 `<span class="marke-logo" data-markelogo title="${esc(label)} (${esc(MARKENLOGO_DATEI)})">
<img src="/local/bilder/${MARKENLOGO_DATEI}" alt="${esc(label)}" data-bilddatei="${esc(MARKENLOGO_DATEI)}"
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="${esc(label)}" data-bilddatei="${esc(MARKENLOGO_DATEI)}"
onerror="this.closest('[data-markelogo]').classList.add('logo-fehlt')">
<span class="ph">${esc(label)}</span>
</span>`;
@@ -1119,8 +1158,8 @@ function badge() {
const zusatz = [modellZusatz, doppelt ? "" : ausfuehrung].filter(Boolean).join(" ");
const alt = esc(CONFIG.fahrzeugtitel) + " Badge";
return `<div class="badge">
<img class="positive" src="/local/badges/${datei}-positive.svg" alt="${alt}">
<img class="negative" src="/local/badges/${datei}-negative.svg" alt="${alt}">
<img class="positive" src="${STATIK}/badges/${datei}-positive.svg" alt="${alt}">
<img class="negative" src="${STATIK}/badges/${datei}-negative.svg" alt="${alt}">
${zusatz ? `<span class="zusatz">${esc(zusatz)}</span>` : ""}</div>`;
}
@@ -2225,7 +2264,7 @@ function vEinst() {
</div>
<div class="tile"><span class="label">Kraftstoff-Rabatt</span>
<div class="feld"><label for="sdAktiv" style="display:flex;align-items:center;gap:6px">
<img src="/local/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:14px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<img src="${STATIK}/bilder/${MARKENLOGO_DATEI}" alt="Shell" style="height:14px;width:auto" onerror="this.style.display='none'"> SmartDeal aktiv</label>
<label class="switch"><input id="sdAktiv" type="checkbox" data-sd ${CAR.smartdeal.aktiv ? "checked" : ""}><span class="track"></span></label></div>
${CAR.smartdeal.aktiv ? `
<div class="feld" style="border-bottom:0"><label>Gültig bis</label>
@@ -2288,24 +2327,15 @@ function vEinst() {
<div class="tile"><span class="label">Version</span>
<dl class="rows">
<div class="row"><dt>Dashboard</dt><dd>${esc(CONFIG.version)}</dd></div>
<div class="row"><dt>Installiert</dt><dd>${esc(APP_VERSION || "unbekannt")}<small>Home-Assistant-Integration</small></dd></div>
<div class="row"><dt>Fahrzeugdaten</dt><dd>FMM003<small>Zündung · Batterie</small></dd></div>
<div class="row" style="border-bottom:0"><dt>Position</dt><dd>FMM003<small>GPS-Tracker</small></dd></div>
</dl>
${(() => {
const s = UPDATE_STATUS;
if (!s || s.stand === "keine_quelle") return `
<span class="label" style="margin-top:14px">${s ? "Keine Update-Quelle eingerichtet" : "Prüft, ob eine neuere Version verfügbar ist"}</span>
<button class="aktion" data-update-pruefen style="margin-top:12px">${updatePruefend ? "Prüfe …" : "Update suchen"}</button>`;
if (s.stand === "fehler") return `
<span class="label" style="margin-top:14px;color:var(--red)">${esc(s.fehler || "Update-Prüfung fehlgeschlagen")}</span>
<button class="aktion" data-update-pruefen style="margin-top:12px">${updatePruefend ? "Prüfe …" : "Erneut versuchen"}</button>`;
if (s.stand === "geprueft" && s.verfuegbar) return `
<span class="label" style="margin-top:14px">Update verfügbar Version ${esc(s.neue_version)}</span>
<button class="aktion" data-update-installieren style="margin-top:12px">${updateInstallierend ? "Installiere …" : "Update installieren"}</button>`;
return `
<span class="label" style="margin-top:14px">Aktuell${s.geprueft_um ? " — zuletzt geprüft " + dedat(new Date(s.geprueft_um)) : ""}</span>
<button class="aktion" data-update-pruefen style="margin-top:12px">${updatePruefend ? "Prüfe …" : "Erneut prüfen"}</button>`;
})()}
<span class="label" style="margin-top:14px">Updates laufen über HACS</span>
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
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.</span>
</div>`;
}
@@ -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 <input
@@ -3509,7 +3522,6 @@ function setupFeldzeile(feld) {
const kopf = `<div class="setup-feld-kopf">
<span class="setup-feld-label">${esc(feld.label)}</span>
<span class="setup-feld-hinweis">${esc(feld.hinweis)}</span>
${feld.trigger_gebunden ? `<span class="setup-feld-neustart">Wirkt nach dem Speichern erst nach einem Neustart von Home Assistant.</span>` : ""}
</div>`;
if (feld.liste) {
return `<div class="setup-feldzeile">${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");
@@ -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 <script type=module> eingefügt
wird, bevor das erste fertig geladen hat - beobachtet als zwei
Netzwerk-Requests und ein hängenbleibendes "Lädt …" ohne Fehler. Der
Cache unten macht das Starten robust dagegen: der zweite Aufruf wartet auf
das Ergebnis des ersten, statt selbst nochmal zu importieren. */
window.__audiDashboardStarten =
window.__audiDashboardStarten ||
(async () => {
const eigene = new URL(import.meta.url);
const version = eigene.searchParams.get("v") || Date.now();
await import(new URL(`audi-dashboard-app.js?v=${version}`, eigene).href);
})();
await window.__audiDashboardStarten;
@@ -1202,7 +1202,6 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.setup-feld-kopf { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
.setup-feld-label { font-size: 14.5px; color: var(--fg); }
.setup-feld-hinweis { font-size: 12.5px; color: var(--fg2); line-height: 1.4; }
.setup-feld-neustart { font-size: 12px; color: var(--warn); line-height: 1.4; }
.setup-unterfelder { display: flex; flex-direction: column; gap: 12px; }
.setup-unterfeld { display: flex; flex-direction: column; gap: 4px; }
.setup-unterfeld-label { font-size: 12px; color: var(--fg3); }

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,375 @@
"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf
(Einstellungen -> "Daten importieren aus Home Assistant").
WOZU
----
Fahrterkennung, Tankerkennung und Batterieverlauf arbeiten alle nur ab dem
Moment, in dem sie laufen. 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 es längst aufgezeichnet hat. Dieser Import schließt genau diese
Lücke: er liest denselben Verlauf, den die Live-Erkennung sonst in Echtzeit
sieht, 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.
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 die Ablage 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.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
from .tankerkennung import leerer_tankvorgang, schwelle_prozent
from .verlauf import Verlaufspunkt, verlauf_lesen, wert_bei, zahl
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
# 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 als_zeit(wert: object) -> datetime.datetime | None:
"""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.UTC)
def _fahrtfenster(
zuendung_verlauf: list[Verlaufspunkt], pausenzeit_s: int
) -> list[tuple[datetime.datetime, datetime.datetime]]:
"""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 die abbrechbare Wartezeit, nur eben im
Nachhinein und ohne Warten."""
roh: list[tuple[datetime.datetime, datetime.datetime]] = []
offen: datetime.datetime | None = 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: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
) -> bool:
"""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")) # type: ignore[arg-type]
f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) # type: ignore[arg-type]
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
async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
fenster = _fahrtfenster(verlaeufe["zuendung"], await pausenzeit_sekunden(k))
if not fenster:
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
bestehende = await k.ablage.fahrten_lesen()
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
angelegt = uebersprungen = zu_kurz = 0
neue: list[dict] = []
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 = leere_fahrt(f_start, f_ende, "import")
fahrt.update({
"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),
"status": "vollständig" if distanz is not None else "offen",
})
neue.append(fahrt)
bestehende.append(fahrt)
angelegt += 1
await k.ablage.fahrten_ergaenzen(neue)
return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz}
async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik wie
in der Live-Erkennung: 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(await k.ablage.profil_lesen())
km_verlauf = verlaeufe["km"]
bestehende = await k.ablage.tankvorgaenge_lesen()
fenster_s = TANK_DUBLETTE_MIN * 60
bekannte_zeiten: list[datetime.datetime] = []
for t in bestehende:
try:
ts = datetime.datetime.fromisoformat(t.get("ts")) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
if ts.tzinfo is not None:
bekannte_zeiten.append(ts)
angelegt = uebersprungen = 0
neue: list[dict] = []
tiefststand: float | None = 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
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
uebersprungen += 1
tiefststand = aktuell
continue
odometer_km = wert_bei(km_verlauf, ts)
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
tankvorgang["odometer_km"] = odometer_km
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
ts.isoformat(), odometer_km, neue + bestehende
)
neue.append(tankvorgang)
bekannte_zeiten.append(ts)
angelegt += 1
tiefststand = aktuell
await k.ablage.tankvorgaenge_ergaenzen(neue)
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
def _distanz_zum_vorherigen(
eigene_ts: str, odometer_km: float | None, kandidaten: list[dict]
) -> float | None:
""""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)."""
if odometer_km is None:
return None
vorheriger: tuple[str, float] | None = None
for t in kandidaten:
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"])
if vorheriger is None:
return None
distanz = round(odometer_km - vorheriger[1], 1)
return None if distanz < 0 else distanz
async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums.
Die Ablage 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: dict[str, dict] = {}
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]
await k.ablage.batterieverlauf_tageswert_aktualisieren(
tag, werte["min_ts"].isoformat(), werte["min"]
)
await k.ablage.batterieverlauf_tageswert_aktualisieren(
tag, werte["max_ts"].isoformat(), werte["max"]
)
return {"tage": len(tage)}
async def importieren(k: Koordinator, start: object, ende: object) -> None:
"""Liest den Verlauf im gewählten Zeitraum und leitet daraus Fahrten,
Tankvorgänge und Batteriewerte ab.
Der Fortschritt geht über die Entität sensor.audi_dashboard_import_status
an die Oberfläche - ein Dienstaufruf kehrt sofort zurück und kann kein
Ergebnis mitbringen."""
await k.ablage.ordner_sicherstellen()
try:
von = als_zeit(start)
bis = als_zeit(ende)
except ValueError as fehler:
_LOGGER.error("Import mit unlesbarem Zeitraum aufgerufen (%s)", fehler)
await k.import_status_veroeffentlichen("fehler", {"meldung": "Zeitraum nicht lesbar"})
return
if von is None or bis is None or von >= bis:
_LOGGER.warning("Import ohne gültigen Zeitraum aufgerufen")
await k.import_status_veroeffentlichen("fehler", {
"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."
})
return
await k.import_status_veroeffentlichen(
"laeuft", {"von": von.isoformat(), "bis": bis.isoformat()}
)
_LOGGER.info("Import gestartet für %s bis %s", von.isoformat(), bis.isoformat())
werte = k.zuordnung.werte
verlaeufe = {
"zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis),
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis),
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis),
"lon": await verlauf_lesen(k.hass, werte.STANDORT_LON_SENSOR, von, bis),
}
# 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 punkte in verlaeufe.values():
if punkte and (frueheste is None or punkte[0][0] < frueheste):
frueheste = punkte[0][0]
fahrten = await _fahrten_importieren(k, verlaeufe)
tank = await _tankvorgaenge_importieren(k, verlaeufe)
batterie = await _batterie_importieren(k, verlaeufe)
await k.fahrten_veroeffentlichen()
await k.tankvorgaenge_veroeffentlichen()
await k.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"],
}
await k.import_status_veroeffentlichen("fertig", ergebnis)
_LOGGER.info("Import abgeschlossen - %s", ergebnis)
@@ -0,0 +1,315 @@
"""Der Koordinator: hält den Zustand der App und verdrahtet alles miteinander.
Was hier zusammenkommt, lag in der pyscript-Fassung über die Dateien verteilt,
weil es dort keinen Ort dafür gab: die Dekoratoren @service, @state_trigger und
@time_trigger banden ihre Registrierung direkt an die Funktion daneben. Eine
Integration hat einen Lebenszyklus (Einrichten, Entladen, Neu-Laden), und
genau deshalb braucht es eine Stelle, die weiß, was sie angemeldet hat und es
wieder abmelden kann.
DREI DINGE, DIE DIESER AUFBAU MÖGLICH MACHT UND DIE ES VORHER NICHT GAB
-----------------------------------------------------------------------
1. Zustandsbeobachter lassen sich zur Laufzeit neu binden. Früher backte
@state_trigger die Entity-ID einmalig beim Modul-Laden ein - ein im
Setup-Menü geänderter Zündungs-, Kilometer- oder Tanksensor wirkte erst
nach einem HA-Neustart, und die Oberfläche musste einen eigenen
Neustart-Knopf dafür anbieten. trigger_neu_binden() ersetzt beides.
2. Der Zwischenstand einer laufenden Fahrt überlebt einen Neustart. Er liegt
in einem Store statt nur im Arbeitsspeicher; dasselbe gilt für den
Tank-Tiefststand. Beides war als bekannte Schwäche vermerkt.
3. Die Nutzlast landet nicht mehr in der Recorder-Datenbank (siehe sensor.py).
Der Weg von hier zur Oberfläche: `setzen()` legt Zustand und Nutzlast ab und
schickt ein Signal; die Entität in sensor.py hört darauf und schreibt sich
neu. Die Entitäten holen sich ihren Wert beim Anlegen selbst aus `werte` -
deshalb darf schon veröffentlicht werden, bevor die Plattform überhaupt
eingerichtet ist.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
import os
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_change,
async_track_time_interval,
)
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.storage import Store
from . import batterie, fahrterkennung, reifen, screening, sicherung, tankerkennung
from .ablage import Ablage
from .const import (
E_APP_VERSION,
E_BATTERIEVERLAUF,
E_BELEG_ERGEBNIS,
E_FAHRTEN,
E_FAHRZEUGSTATUS,
E_IMPORT_STATUS,
E_PROFIL,
E_REIFEN_AKTIV,
E_REIFEN_SOMMER,
E_REIFEN_WINTER,
E_TANKVORGAENGE,
E_ZUORDNUNG,
SIGNAL_AKTUALISIERT,
)
from .veroeffentlichung import fahrzeugstatus
from .zuordnung import Zuordnung
_LOGGER = logging.getLogger(__name__)
# Wie oft der Live-Fahrzeugstatus neu aus den Quellsensoren gelesen wird.
TAKT_STATUS = datetime.timedelta(seconds=20)
# Profil/Fahrten/Tankvorgänge ändern sich normalerweise nur durch Aktionen,
# die ohnehin sofort selbst veröffentlichen. Dieser Takt ist nur das
# Sicherheitsnetz für Änderungen direkt im Dateisystem.
TAKT_DATEN = datetime.timedelta(seconds=60)
TAKT_BATTERIE = datetime.timedelta(minutes=5)
STORE_VERSION = 1
class Koordinator:
"""Zustand, Zeittakte und Zustandsbeobachter der Integration."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, version: str) -> None:
self.hass = hass
self.entry = entry
self.version = version
self.ablage = Ablage(hass, hass.config.path("audi_dashboard"))
self.zuordnung = Zuordnung(self.ablage)
# Was die Entitäten anzeigen: Schlüssel -> (Zustand, Nutzlast).
self.werte: dict[str, tuple[str, dict | list | None]] = {}
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
self.fahrt_start_ts: datetime.datetime | None = None
self.tiefststand_pct: float | None = None
self._takte: list[CALLBACK_TYPE] = []
self._beobachter: list[CALLBACK_TYPE] = []
self._ende_aufgabe: asyncio.Task | None = None
# ------------------------------------------------------------ Lebenszyklus
async def starten(self) -> None:
await self.ablage.ordner_sicherstellen()
if await self.ablage.vorlage_anlegen(
os.path.join(os.path.dirname(__file__), "vorlage", "fahrzeugprofil.json")
):
_LOGGER.info(
"Erste Einrichtung: Fahrzeugprofil aus der Vorlage angelegt. "
"Die Fahrzeugdaten werden in der App unter Einstellungen -> "
"Fahrzeug einrichten eingetragen."
)
await self.zuordnung.anwenden()
await self._laufzeit_laden()
await self.alles_veroeffentlichen()
# Der Reifenzähler schreibt beim Start einmal fort: Kilometer, die
# während eines HA-Ausfalls gefahren wurden, gehören noch dem damals
# aktiven Satz (siehe reifen.py).
await reifen.fortschreiben(self, reifen.aktueller_km(self))
self._takte_anmelden()
await self.trigger_neu_binden()
# Erst wenn Home Assistant vollständig oben ist: bis dahin haben die
# Datenquellen ihre Zustände oft noch nicht gemeldet, und eine noch
# nicht gemeldete Zündung ließe sich nicht von "aus" unterscheiden
# (siehe fahrterkennung.nach_neustart_fortsetzen).
self._takte.append(
async_at_started(self.hass, self._nach_start)
)
async def _nach_start(self, _hass: HomeAssistant) -> None:
await fahrterkennung.nach_neustart_fortsetzen(self)
async def beenden(self) -> None:
for abmelden in (*self._takte, *self._beobachter):
abmelden()
self._takte.clear()
self._beobachter.clear()
self.warte_ende_ab_abbrechen()
# ------------------------------------------------------------- Zeittakte
def _takte_anmelden(self) -> None:
self._takte.append(
async_track_time_interval(self.hass, self._takt_status, TAKT_STATUS)
)
self._takte.append(
async_track_time_interval(self.hass, self._takt_daten, TAKT_DATEN)
)
self._takte.append(
async_track_time_interval(self.hass, self._takt_batterie, TAKT_BATTERIE)
)
# Ein einzelner täglicher Termin genügt für alle Sicherungs-Intervalle
# - welche heute fällig ist, entscheidet sicherung.automatisch().
self._takte.append(
async_track_time_change(self.hass, self._takt_sicherung, hour=4, minute=0, second=0)
)
async def _takt_status(self, _jetzt: datetime.datetime) -> None:
await self.fahrzeugstatus_veroeffentlichen()
async def _takt_daten(self, _jetzt: datetime.datetime) -> None:
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
async def _takt_batterie(self, _jetzt: datetime.datetime) -> None:
await batterie.pruefen(self)
async def _takt_sicherung(self, _jetzt: datetime.datetime) -> None:
await sicherung.automatisch(self)
# -------------------------------------------------- Zustandsbeobachter
async def trigger_neu_binden(self) -> None:
"""Meldet die Beobachter für Zündung, Kilometerstand und Tankfüllstand
neu an - nach jeder Änderung der Sensor-Zuordnung.
Ohne zugeordnete Entität wird bewusst gar kein Beobachter angemeldet,
statt einen gegen eine nicht existierende Entity-ID zu registrieren."""
for abmelden in self._beobachter:
abmelden()
self._beobachter.clear()
werte = self.zuordnung.werte
self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_geaendert)
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
def _beobachten(
self, entity_id: str, rueckruf: Callable[[Event[EventStateChangedData]], Coroutine[Any, Any, None]]
) -> None:
if not entity_id:
return
self._beobachter.append(
async_track_state_change_event(self.hass, [entity_id], rueckruf)
)
@staticmethod
def _zustaende(ereignis: Event[EventStateChangedData]) -> tuple[str | None, str | None]:
alt = ereignis.data["old_state"]
neu = ereignis.data["new_state"]
return (neu.state if neu else None, alt.state if alt else None)
async def _zuendung_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, alt = self._zustaende(ereignis)
await fahrterkennung.zuendung_geaendert(self, neu, alt)
async def _kilometerstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, alt = self._zustaende(ereignis)
if neu is None or alt is None:
return
await reifen.fortschreiben(self, reifen.aktueller_km(self))
await screening.durchfuehren(self)
async def _tankfuellstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, _alt = self._zustaende(ereignis)
await tankerkennung.fuellstand_geaendert(self, neu)
# ------------------------------------------------- Laufende Fahrt merken
async def _laufzeit_laden(self) -> None:
gespeichert = await self._store.async_load() or {}
roh = gespeichert.get("fahrt_start_ts")
if roh:
try:
self.fahrt_start_ts = datetime.datetime.fromisoformat(roh)
_LOGGER.info(
"Laufende Fahrt seit %s aus dem letzten Lauf übernommen", roh
)
except ValueError:
self.fahrt_start_ts = None
self.tiefststand_pct = gespeichert.get("tiefststand_pct")
async def _laufzeit_sichern(self) -> None:
await self._store.async_save({
"fahrt_start_ts": self.fahrt_start_ts.isoformat() if self.fahrt_start_ts else None,
"tiefststand_pct": self.tiefststand_pct,
})
async def fahrt_start_setzen(self, ts: datetime.datetime | None) -> None:
self.fahrt_start_ts = ts
await self._laufzeit_sichern()
async def tiefststand_setzen(self, wert: float | None) -> None:
self.tiefststand_pct = wert
await self._laufzeit_sichern()
def warte_ende_ab(self, koroutine: Coroutine[Any, Any, None]) -> None:
self.warte_ende_ab_abbrechen()
self._ende_aufgabe = self.entry.async_create_background_task(
self.hass, koroutine, "audi_dashboard_fahrtende"
)
def warte_ende_ab_abbrechen(self) -> None:
if self._ende_aufgabe is not None and not self._ende_aufgabe.done():
self._ende_aufgabe.cancel()
self._ende_aufgabe = None
# ---------------------------------------------------------- Veröffentlichen
def setzen(
self, schluessel: str, zustand: str | float | None, daten: dict | list | None = None
) -> None:
self.werte[schluessel] = (zustand, daten)
async_dispatcher_send(self.hass, f"{SIGNAL_AKTUALISIERT}_{schluessel}")
async def profil_veroeffentlichen(self, profil: dict | None = None) -> None:
if profil is None:
profil = await self.ablage.profil_lesen()
if profil is None:
return
self.setzen(E_PROFIL, "aktuell", profil)
# Die drei Reifen-Kontrollwerte hängen am selben Profil - zusammen
# veröffentlichen, damit sie nicht auseinanderlaufen können.
# Die beiden km-Stände bleiben Zahlen (die Entitäten tragen die
# Einheit km); None heißt "noch nie gezählt" und wird von Home
# Assistant als "unknown" angezeigt - richtiger als eine erfundene 0.
stand = reifen.zustaende(profil)
self.setzen(E_REIFEN_SOMMER, stand["sommer"])
self.setzen(E_REIFEN_WINTER, stand["winter"])
self.setzen(E_REIFEN_AKTIV, str(stand["aktiv"]))
async def fahrten_veroeffentlichen(self) -> None:
self.setzen(E_FAHRTEN, "aktuell", await self.ablage.fahrten_lesen())
async def tankvorgaenge_veroeffentlichen(self) -> None:
self.setzen(E_TANKVORGAENGE, "aktuell", await self.ablage.tankvorgaenge_lesen())
async def batterieverlauf_veroeffentlichen(self) -> None:
self.setzen(E_BATTERIEVERLAUF, "aktuell", await self.ablage.batterieverlauf_lesen())
async def zuordnung_veroeffentlichen(self) -> None:
self.setzen(E_ZUORDNUNG, "aktuell", self.zuordnung.aktueller_stand())
async def fahrzeugstatus_veroeffentlichen(self) -> None:
self.setzen(E_FAHRZEUGSTATUS, "aktuell", fahrzeugstatus(self.hass, self.zuordnung.werte))
async def beleg_ergebnis_veroeffentlichen(self, daten: dict) -> None:
self.setzen(E_BELEG_ERGEBNIS, "aktuell", daten)
async def import_status_veroeffentlichen(self, zustand: str, daten: dict) -> None:
self.setzen(E_IMPORT_STATUS, zustand, daten)
async def alles_veroeffentlichen(self) -> None:
self.setzen(E_APP_VERSION, self.version, {"app": self.version})
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
await self.fahrzeugstatus_veroeffentlichen()
await self.batterieverlauf_veroeffentlichen()
await self.zuordnung_veroeffentlichen()
@@ -0,0 +1,14 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.23.2",
"documentation": "https://github.com/paulnothaft/audi-app/blob/main/README.md",
"issue_tracker": "https://github.com/paulnothaft/audi-app/issues",
"codeowners": ["@paulnothaft"],
"config_flow": true,
"integration_type": "service",
"single_config_entry": true,
"iot_class": "local_polling",
"dependencies": ["http", "frontend", "panel_custom", "recorder"],
"requirements": ["pypdf>=4.0.0"]
}
+121
View File
@@ -0,0 +1,121 @@
"""Reifenzähler: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
Zeitpunkt der Fahrt aktiv war.
Früher war der Zähler = aktueller Kilometerstand minus fester Startwert je
Satz. 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 führt
reifen.saetze.<satz>.km selbst den fortlaufenden Gesamtstand, und
reifen.referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest,
gegen den die nächste Änderung als Delta gemessen wird.
Keine Abhängigkeit von total_increasing oder utility_meter: die Fortschreibung
braucht nur den zuletzt bekannten Absolutwert des Kilometerstand-Sensors. Ein
zeitweise "unavailable" Quellsensor 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 Kilometer, die
während eines HA-Ausfalls gefahren wurden, werden so noch dem damals aktiven
Satz zugerechnet.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .veroeffentlichung import zustand_oder_none
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
SAETZE = ("sommer", "winter")
def aktueller_km(k: Koordinator) -> float | None:
try:
return float(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
async def fortschreiben(k: Koordinator, neuer_km: float | None, neuer_aktiv: str | None = None) -> 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 Kilometer dem neuen Satz
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.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
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
async def wechseln(k: Koordinator, satz: str | None) -> None:
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
return
await fortschreiben(k, aktueller_km(k), neuer_aktiv=satz)
_LOGGER.info("Aktiver Reifensatz auf '%s' umgeschaltet", satz)
async def km_setzen(k: Koordinator, satz: str | None, km: object) -> None:
"""Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes.
Überschreibt nur den gespeicherten Zähler selbst - referenz_odo_km (der
zuletzt verarbeitete Fahrzeug-Kilometerstand) bleibt unverändert, damit
die nächste Fortschreibung weiterhin korrekt von hier aus weiterzählt,
statt auf den neuen Wert erneut ein Delta seit dem alten Stand
aufzuschlagen."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
return
try:
wert = round(float(km), 1) # type: ignore[arg-type]
except (TypeError, ValueError):
_LOGGER.warning("Ungültiger km-Wert '%s'", km)
return
if wert < 0:
_LOGGER.warning("Negativer km-Wert '%s' abgelehnt", wert)
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
reifen.setdefault("saetze", {}).setdefault(satz, {})["km"] = wert
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("km-Stand '%s' manuell auf %s gesetzt", satz, wert)
def zustaende(profil: dict | None) -> dict[str, object]:
"""Die drei Kontrollwerte für die eigenen Reifen-Entitäten.
Das Frontend liest die Zahlen direkt aus dem veröffentlichten Profil -
diese Entitäten sind nur zur Kontrolle in Entwicklerwerkzeuge -> Zustände
und für eigene Automationen des Nutzers da."""
reifen = (profil or {}).get("reifen", {})
saetze = reifen.get("saetze", {})
return {
"sommer": saetze.get("sommer", {}).get("km"),
"winter": saetze.get("winter", {}).get("km"),
"aktiv": reifen.get("aktiv") or "unbekannt",
}
@@ -0,0 +1,135 @@
"""Zweistufiger Fahrtabschluss: offene Fahrten nachträglich mit Start- und
Endkilometerstand vervollständigen (SPECIFICATION.md §7.2).
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
teils erst mit Beginn oder während der nächsten Fahrt. Statt auf einen festen
Zeitpunkt zu warten, wird deshalb der aufgezeichnete Verlauf des
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
nächsten am Fahrtbeginn bzw. -ende liegt.
Läuft nach jedem Fahrtende und zusätzlich bei jeder Änderung des
Kilometerstand-Sensors - unabhängig vom Fahrtende-Ereignis selbst, eben weil
der Wert auch erst mit der nächsten Fahrt eintreffen kann.
VORBEHALT: Der GPS-Fallback (km_quelle="gps") ist weiterhin nicht umgesetzt,
da die Adressauflösung ein eigenes Arbeitspaket ist. Fahrten ohne passenden
Kilometerstand bleiben "offen" statt auf GPS-Distanz auszuweichen.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .verlauf import naechster_wert, verlauf_lesen
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
# Wie weit vor/zurück gesucht wird, ausgehend von Fahrtbeginn bzw. -ende.
# 48h deckt auch länger stehende Fahrzeuge ab, ohne bei jedem Screening die
# komplette Historie zu laden.
SUCHFENSTER = datetime.timedelta(hours=48)
def _als_zeit(wert: str | None) -> datetime.datetime | None:
if not wert:
return None
try:
return datetime.datetime.fromisoformat(wert)
except ValueError:
return None
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch"."""
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"
return aenderungen
async def durchfuehren(k: Koordinator) -> None:
"""Versucht, alle offenen Fahrten zu vervollständigen."""
km_sensor = k.zuordnung.werte.KM_SENSOR
fahrten = await k.ablage.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", ""))
if km_sensor:
for fahrt in offene:
await _fahrt_screenen(k, km_sensor, fahrt)
await _lueckenlos_verketten(k)
await k.fahrten_veroeffentlichen()
async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
start = _als_zeit(fahrt.get("ts_start"))
ende = _als_zeit(fahrt.get("ts_end"))
if start is None or ende is None:
_LOGGER.warning(
"Fahrt %s hat keinen lesbaren Zeitraum - Screening übersprungen",
fahrt.get("trip_id"),
)
return
# Ein Abruf für beide Zeitpunkte statt zwei: die Fenster überlappen sich
# bei einer normalen Fahrt ohnehin fast vollständig.
punkte = await verlauf_lesen(k.hass, km_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
if not punkte:
return
geaendert = False
if fahrt.get("odo_end") is None:
wert = naechster_wert(ende, punkte)
if wert is not None:
fahrt["odo_end"] = wert
geaendert = True
if fahrt.get("odo_start") is None:
wert = naechster_wert(start, punkte)
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")}
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
async def _lueckenlos_verketten(k: Koordinator) -> None:
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
wurde.
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
Ereignisse. Das ist der Normalfall, aber keine Garantie."""
fahrten = sorted(await k.ablage.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
geaendert: set[str] = set()
for aktuelle, naechste in zip(fahrten, fahrten[1:]):
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
naechste["odo_start"] = aktuelle["odo_end"]
geaendert.add(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.add(aktuelle["trip_id"])
for fahrt in fahrten:
if fahrt["trip_id"] not in geaendert:
continue
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
+162
View File
@@ -0,0 +1,162 @@
"""Die Entitäten, über die beide Oberflächen die Daten der App lesen.
Das Frontend liest hass.states["sensor.audi_dashboard_profil"] usw. direkt und
holt sich die Nutzlast aus dem Attribut `daten` - kein Dienstaufruf mit
Rückgabewert, dessen Verhalten aus Sicht des Browsers nicht durchgängig
dokumentiert ist.
WARUM ECHTE ENTITÄTEN UND NICHT NUR EINTRÄGE IN DER ZUSTANDSMASCHINE
--------------------------------------------------------------------
Wegen einer einzigen Zeile: `_unrecorded_attributes`. Die Nutzlast dieser
Entitäten ist groß - das Fahrtenarchiv wächst über die Jahre auf Hunderte
Kilobyte. Home Assistant zeichnet Zustands-Attribute standardmäßig bei jeder
Änderung in der Recorder-Datenbank auf und warnt ab ~16 KB, dass genau das
Datenbankprobleme macht. In der pyscript-Fassung war das eine offene Flanke:
jede Veröffentlichung schrieb den kompletten Bestand erneut in die Datenbank,
alle 60 Sekunden.
Nur eine Entität, die zu einer Integration gehört, kann Attribute von der
Aufzeichnung ausnehmen. Ein roher Eintrag in der Zustandsmaschine (was
pyscripts state.set() macht) kann das nicht. Die Nutzlast geht damit
weiterhin an jede Oberfläche, landet aber nie in der Datenbank.
Die Zustände selbst sind bewusst schlicht ("aktuell"): der Inhalt steckt im
Attribut, der Zustand sagt nur, dass etwas da ist. Bei den drei
Reifen-Entitäten und der App-Version ist der Zustand dagegen der eigentliche
Wert - die sind zur Kontrolle in Entwicklerwerkzeuge -> Zustände und für
eigene Automationen des Nutzers gedacht.
"""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
E_APP_VERSION,
E_BATTERIEVERLAUF,
E_BELEG_ERGEBNIS,
E_FAHRTEN,
E_FAHRZEUGSTATUS,
E_IMPORT_STATUS,
E_PROFIL,
E_REIFEN_AKTIV,
E_REIFEN_SOMMER,
E_REIFEN_WINTER,
E_TANKVORGAENGE,
E_ZUORDNUNG,
SIGNAL_AKTUALISIERT,
)
from .koordinator import Koordinator
@dataclass(frozen=True)
class Beschreibung:
schluessel: str
name: str
icon: str
einheit: str | None = None
kategorie: EntityCategory | None = None
BESCHREIBUNGEN: tuple[Beschreibung, ...] = (
Beschreibung(E_PROFIL, "Audi Dashboard Profil", "mdi:car-info"),
Beschreibung(E_FAHRTEN, "Audi Dashboard Fahrten", "mdi:road-variant"),
Beschreibung(E_TANKVORGAENGE, "Audi Dashboard Tankvorgänge", "mdi:gas-station"),
Beschreibung(E_FAHRZEUGSTATUS, "Audi Dashboard Fahrzeugstatus", "mdi:car-connected"),
Beschreibung(E_BATTERIEVERLAUF, "Audi Dashboard Batterieverlauf", "mdi:car-battery"),
Beschreibung(E_REIFEN_SOMMER, "Audi Dashboard Reifen Sommer km", "mdi:tire", UnitOfLength.KILOMETERS),
Beschreibung(E_REIFEN_WINTER, "Audi Dashboard Reifen Winter km", "mdi:snowflake", UnitOfLength.KILOMETERS),
Beschreibung(E_REIFEN_AKTIV, "Audi Dashboard Reifen aktiver Satz", "mdi:tire"),
Beschreibung(E_ZUORDNUNG, "Audi Dashboard Entitäten", "mdi:link-variant", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_BELEG_ERGEBNIS, "Audi Dashboard Beleg Ergebnis", "mdi:receipt-text", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_IMPORT_STATUS, "Audi Dashboard Import Status", "mdi:database-import", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_APP_VERSION, "Audi Dashboard App Version", "mdi:tag-outline", None, EntityCategory.DIAGNOSTIC),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
koordinator: Koordinator = entry.runtime_data
async_add_entities(
AudiEntitaet(koordinator, beschreibung) for beschreibung in BESCHREIBUNGEN
)
class AudiEntitaet(SensorEntity):
"""Eine Entität je Datenbestand. Zustand kurz, Inhalt im Attribut `daten`."""
# Der eigentliche Grund für echte Entitäten - siehe Kopfkommentar.
_unrecorded_attributes = frozenset({"daten"})
# Bewusst KEIN has_entity_name: die Entity-IDs (sensor.audi_dashboard_*)
# sind ein Vertrag mit beiden Oberflächen (siehe const.py). Mit
# has_entity_name würde Home Assistant den Gerätenamen davorsetzen und die
# IDs hingen am - vom Nutzer änderbaren - Gerätenamen.
_attr_has_entity_name = False
_attr_should_poll = False
def __init__(self, koordinator: Koordinator, beschreibung: Beschreibung) -> None:
self._koordinator = koordinator
self._beschreibung = beschreibung
self._attr_name = beschreibung.name
self._attr_icon = beschreibung.icon
self._attr_native_unit_of_measurement = beschreibung.einheit
self._attr_entity_category = beschreibung.kategorie
self._attr_unique_id = f"{koordinator.entry.entry_id}_{beschreibung.schluessel}"
# Die gewünschte Objekt-ID ausdrücklich vorgeben, statt sie aus dem
# Namen ableiten zu lassen: aus dem Namen käme zwar dasselbe heraus,
# aber nur solange niemand den Namen anfasst.
self.internal_integration_suggested_object_id = (
f"audi_dashboard_{beschreibung.schluessel}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, koordinator.entry.entry_id)},
name="Audi Dashboard",
manufacturer="Audi Dashboard",
sw_version=koordinator.version,
entry_type=None,
)
async def async_added_to_hass(self) -> None:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_AKTUALISIERT}_{self._beschreibung.schluessel}",
self._neu_zeichnen,
)
)
@callback
def _neu_zeichnen(self) -> None:
self.async_write_ha_state()
def _wert(self) -> tuple[str, object]:
return self._koordinator.werte.get(self._beschreibung.schluessel, ("unbekannt", None))
@property
def available(self) -> bool:
"""Bis zur ersten Veröffentlichung gibt es nichts zu zeigen.
"unavailable" ist hier ehrlicher als ein Platzhalterwert: die
Oberfläche unterscheidet ausdrücklich zwischen "kein Wert" und "Wert
unbekannt" und darf sich darauf verlassen."""
return self._beschreibung.schluessel in self._koordinator.werte
@property
def native_value(self) -> str:
return self._wert()[0]
@property
def extra_state_attributes(self) -> dict[str, object]:
return {"daten": self._wert()[1]}
@@ -0,0 +1,257 @@
# Felder und Eingabehilfen für Entwicklerwerkzeuge -> Aktionen.
# Namen und Beschreibungen stehen in strings.json bzw. translations/, nicht
# hier - so steht jeder Text genau einmal.
#
# Beide Oberflächen rufen diese Dienste über die WebSocket- bzw. REST-API auf
# und brauchen diese Datei nicht. Sie ist dafür da, dass die Dienste in Home
# Assistant selbst bedienbar und nachvollziehbar sind - zum Nachschauen, zum
# Ausprobieren und für eigene Automationen des Nutzers.
jetzt_aktualisieren:
screening_jetzt:
backup_jetzt:
profil_schreiben:
fields:
profil_json:
required: true
selector:
object:
entitaeten_schreiben:
fields:
zuordnung_json:
required: true
selector:
object:
fahrt_manuell_anlegen:
fields:
ts_start:
required: true
example: "2026-08-23T07:30:00+02:00"
selector:
text:
ts_end:
required: true
example: "2026-08-23T08:05:00+02:00"
selector:
text:
art:
selector:
select:
options: ["privat", "arbeitsweg"]
start_ort:
selector:
text:
ziel_ort:
selector:
text:
odo_start:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
odo_end:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
fahrt_aktualisieren:
fields:
trip_id:
required: true
example: t-4f2a91c30b7e
selector:
text:
ts_start:
selector:
text:
ts_end:
selector:
text:
art:
selector:
select:
options: ["privat", "arbeitsweg"]
start_ort:
selector:
text:
ziel_ort:
selector:
text:
odo_start:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
odo_end:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
fahrt_loeschen:
fields:
trip_id:
required: true
selector:
text:
beleg_hochladen:
fields:
pdf_base64:
required: true
selector:
text:
multiline: true
dateiname:
selector:
text:
tank_id:
selector:
text:
tankvorgang_manuell:
fields:
ts:
selector:
text:
liter:
selector:
number: { min: 0, max: 200, step: 0.01, mode: box, unit_of_measurement: L }
kosten:
selector:
number: { min: 0, max: 2000, step: 0.01, mode: box, unit_of_measurement: EUR }
km:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
ersparnis:
selector:
number: { min: 0, max: 500, step: 0.01, mode: box, unit_of_measurement: EUR }
station:
selector:
text:
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
kraftstoff:
selector:
text:
receipt_key:
selector:
text:
receipt_file:
selector:
text:
tankvorgang_aktualisieren:
fields:
tank_id:
required: true
selector:
text:
ts:
selector:
text:
liter:
selector:
number: { min: 0, max: 200, step: 0.01, mode: box, unit_of_measurement: L }
kosten:
selector:
number: { min: 0, max: 2000, step: 0.01, mode: box, unit_of_measurement: EUR }
km:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
ersparnis:
selector:
number: { min: 0, max: 500, step: 0.01, mode: box, unit_of_measurement: EUR }
station:
selector:
text:
distanz:
selector:
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
kraftstoff:
selector:
text:
receipt_key:
selector:
text:
receipt_file:
selector:
text:
tankvorgang_loeschen:
fields:
tank_id:
required: true
selector:
text:
bild_hochladen:
fields:
dateiname:
required: true
example: seitenansicht.webp
selector:
text:
daten_base64:
required: true
selector:
text:
multiline: true
bild_loeschen:
fields:
dateiname:
required: true
selector:
text:
reifen_wechseln:
fields:
satz:
required: true
selector:
select:
options: ["sommer", "winter"]
reifen_km_setzen:
fields:
satz:
required: true
selector:
select:
options: ["sommer", "winter"]
km:
required: true
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
backup_wiederherstellen:
fields:
profil_json:
selector:
object:
fahrten_json:
selector:
object:
tankvorgaenge_json:
selector:
object:
entitaeten_json:
selector:
object:
historie_importieren:
fields:
start:
required: true
example: "2026-01-01T00:00:00"
selector:
text:
ende:
required: true
example: "2026-08-23T00:00:00"
selector:
text:
@@ -424,26 +424,40 @@ def _parsen_generisch(text):
}
def main():
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
sys.exit(1)
pdf_pfad = sys.argv[1]
try:
def beleg_lesen(pdf_pfad):
"""Liest einen Tankbeleg und gibt die Felder als dict zurück.
Der eine Einstiegspunkt für Aufrufer im selben Prozess. Die Integration
(belege.py) ruft genau diese Funktion im Executor auf - früher lief der
Parser als eigener Unterprozess über main() unten, weil pyscript keine
fremden Pakete laden konnte und pypdf deshalb von Hand ins Container-
Python installiert werden musste. Als echte Integration steht pypdf über
manifest.json bereit; der Unterprozess und der Handinstallations-Schritt
entfallen damit ersatzlos.
Wirft eine Exception, wenn weder das Shell-Format noch der
stationsunabhängige Rückfall greifen - der Aufrufer entscheidet, was er
dem Nutzer davon zeigt."""
text = _text_aus_pdf(pdf_pfad)
except Exception as exc:
print(f"PDF konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
try:
ergebnis = _parsen(text)
return _parsen(text)
except Exception:
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
# endgültig aufgegeben wird.
return _parsen_generisch(text)
def main():
"""Kommandozeilen-Aufruf, weiterhin nutzbar zum Nachschauen von Hand:
`python3 shell_beleg_parser.py <pfad-zur-pdf>`."""
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
sys.exit(1)
try:
ergebnis = _parsen_generisch(text)
ergebnis = beleg_lesen(sys.argv[1])
except Exception as exc:
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
print(f"Beleg konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(ergebnis, ensure_ascii=False))
@@ -0,0 +1,115 @@
"""Sicherung von Fahrzeugprofil, Fahrten, Tankvorgängen und Sensor-Zuordnung.
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 Termin genügt für alle drei Intervalle: wöchentlich läuft nur
montags, monatlich nur am Monatsersten, täglich jeden Tag.
Jede Sicherung ist ein eigener Zeitstempel-Unterordner mit Kopien der vier
Bestände, keine ZIP-Datei - eine Sicherung, in die man ohne Werkzeug
hineinschauen und aus der man eine einzelne Datei zurückkopieren kann, ist im
Ernstfall mehr wert als ein paar gesparte Kilobyte.
entitaeten.json gehört ausdrücklich 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.
"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein über
den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine
JSON-Datei); wiederherstellen() hier ist das Gegenstück zum Import, das die
Bestände wieder komplett überschreibt.
"""
from __future__ import annotations
import datetime
import json
import logging
import os
import shutil
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
DATEIEN = (
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
)
def _kopieren(basis: str, ziel: str) -> str:
os.makedirs(ziel, exist_ok=True)
for name in DATEIEN:
quelle = os.path.join(basis, name)
if os.path.exists(quelle):
shutil.copy(quelle, os.path.join(ziel, name))
return ziel
async def jetzt(k: Koordinator) -> str:
zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ziel = os.path.join(k.ablage.backup_ordner, zeitstempel)
await k.hass.async_add_executor_job(_kopieren, k.ablage.basis, ziel)
await _vermerken(k)
_LOGGER.info("Sicherung erstellt unter %s", ziel)
return ziel
async def _vermerken(k: Koordinator) -> None:
profil = await k.ablage.profil_lesen()
if profil is None:
return
profil.setdefault("einstellungen", {})["letztes_backup"] = (
datetime.datetime.now().isoformat()
)
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
async def automatisch(k: Koordinator) -> None:
"""Der tägliche 04:00-Termin - prüft selbst, ob heute überhaupt eine
Sicherung fällig ist."""
profil = await k.ablage.profil_lesen()
if profil is None:
return
intervall = profil.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 = await jetzt(k)
_LOGGER.info("Automatische Sicherung (%s) erstellt unter %s", intervall, ziel)
def _als_objekt(wert: object) -> object:
return json.loads(wert) if isinstance(wert, str) else wert
async def wiederherstellen(k: Koordinator, daten: dict) -> None:
"""Überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung
komplett.
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 daten.get("profil_json"):
await k.ablage.profil_schreiben(_als_objekt(daten["profil_json"])) # type: ignore[arg-type]
if daten.get("fahrten_json") is not None:
await k.ablage.fahrten_schreiben(_als_objekt(daten["fahrten_json"])) # type: ignore[arg-type]
if daten.get("tankvorgaenge_json") is not None:
await k.ablage.tankvorgaenge_schreiben(_als_objekt(daten["tankvorgaenge_json"])) # type: ignore[arg-type]
if daten.get("entitaeten_json"):
await k.zuordnung.speichern(_als_objekt(daten["entitaeten_json"])) # type: ignore[arg-type]
await k.trigger_neu_binden()
await k.alles_veroeffentlichen()
_LOGGER.info("Sicherung wiederhergestellt")
@@ -0,0 +1,174 @@
{
"config": {
"step": {
"user": {
"title": "Audi Dashboard",
"description": "Richtet das Panel \"Mein Audi\" ein. Welcher Sensor welche Rolle im Fahrzeug spielt, wird danach im Setup-Menü der App selbst zugeordnet (Einstellungen → Fahrzeug einrichten → Setup)."
}
},
"abort": {
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
"description": "Stößt eine sofortige Neuabfrage beim Fahrzeug an und veröffentlicht den Fahrzeugstatus neu."
},
"screening_jetzt": {
"name": "Screening jetzt",
"description": "Versucht sofort, offene Fahrten mit Start- und Endkilometerstand zu vervollständigen."
},
"backup_jetzt": {
"name": "Sicherung jetzt",
"description": "Legt sofort eine Sicherung von Profil, Fahrten, Tankvorgängen und Sensor-Zuordnung an."
},
"profil_schreiben": {
"name": "Profil schreiben",
"description": "Überschreibt das komplette Fahrzeugprofil.",
"fields": {
"profil_json": { "name": "Profil", "description": "Das vollständige Fahrzeugprofil als JSON." }
}
},
"entitaeten_schreiben": {
"name": "Sensor-Zuordnung schreiben",
"description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.",
"fields": {
"zuordnung_json": { "name": "Zuordnung", "description": "Feldname zu Entity-ID als JSON." }
}
},
"fahrt_manuell_anlegen": {
"name": "Fahrt anlegen",
"description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.",
"fields": {
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." }
}
},
"fahrt_aktualisieren": {
"name": "Fahrt bearbeiten",
"description": "Ändert eine bestehende Fahrt.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." },
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"distanz": { "name": "Strecke", "description": "Von Hand gesetzt." }
}
},
"fahrt_loeschen": {
"name": "Fahrt löschen",
"description": "Löscht eine Fahrt unwiderruflich.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }
}
},
"beleg_hochladen": {
"name": "Beleg hochladen",
"description": "Liest einen Tankbeleg (PDF) und trägt ihn in einen bestehenden Tankvorgang nach oder gibt die Werte an die Oberfläche zurück.",
"fields": {
"pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." },
"dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." },
"tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." }
}
},
"tankvorgang_manuell": {
"name": "Tankvorgang anlegen",
"description": "Erfasst einen Tankvorgang von Hand.",
"fields": {
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_aktualisieren": {
"name": "Tankvorgang bearbeiten",
"description": "Ändert einen bestehenden Tankvorgang.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." },
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_loeschen": {
"name": "Tankvorgang löschen",
"description": "Löscht einen Tankvorgang unwiderruflich.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }
}
},
"bild_hochladen": {
"name": "Fahrzeugbild hochladen",
"description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." },
"daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." }
}
},
"bild_loeschen": {
"name": "Fahrzeugbild löschen",
"description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }
}
},
"reifen_wechseln": {
"name": "Reifensatz wechseln",
"description": "Schaltet den aktiven Reifensatz um.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." }
}
},
"reifen_km_setzen": {
"name": "Reifen-Kilometerstand setzen",
"description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." },
"km": { "name": "Kilometer", "description": "Der neue Zählerstand." }
}
},
"backup_wiederherstellen": {
"name": "Sicherung wiederherstellen",
"description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.",
"fields": {
"profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." },
"fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." },
"tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." },
"entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." }
}
},
"historie_importieren": {
"name": "Daten aus dem Verlauf importieren",
"description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.",
"fields": {
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
}
}
}
@@ -0,0 +1,117 @@
"""Automatische Tankerkennung über den Füllstandssensor.
Beobachtung: der Füllstand (in Prozent) springt beim Fahren nie nach oben, er
sinkt nur - jeder Anstieg ist also ein Tankvorgang. Schwelle nach Vorgabe: 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
verglichen werden können; es gilt jeweils die empfindlichere (kleinere) der
beiden Schwellen.
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die Datenquelle
liefert einen Tankvorgang oft in mehreren kleinen Schritten (40% -> 45% ->
60%), von denen keiner allein die Schwelle überschreiten muss. Deshalb wird
der zuletzt bekannte Tiefststand gespeichert und der Anstieg dagegen gemessen.
Nach Anlage eines Tankvorgangs wird der Tiefststand auf den aktuellen Wert
zurückgesetzt, damit derselbe Vorgang nicht mehrfach Datensätze erzeugt.
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
(status "unvollständig", ohne Liter/Kosten/Station): erwartet wird ein
automatisch erkannter Tankvorgang mit Zeitstempel, Kilometerstand und
gefahrener Distanz, den der Nutzer später per Beleg-Nachtrag vervollständigt.
Auch hier gilt die Härtung aus fahrterkennung.py: der Tiefststand lebt nicht
mehr nur im Arbeitsspeicher, sondern im Store des Koordinators und überlebt
damit einen Neustart.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .ablage import neue_id
from .veroeffentlichung import zustand_oder_none
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
LITER_SCHWELLE = 5
PROZENT_SCHWELLE = 9
STANDARD_TANKVOLUMEN_LITER = 58 # Rückfall, falls im Profil nicht hinterlegt
def _als_zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def schwelle_prozent(profil: dict | None) -> float:
"""Die empfindlichere der beiden Schwellen, in Prozentpunkten."""
if profil is None:
tankvolumen = STANDARD_TANKVOLUMEN_LITER
else:
tankvolumen = (
profil.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
)
return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE)
def leerer_tankvorgang(ts: str, quelle: str) -> dict:
"""Das vollständige Tankvorgang-Schema, alles Unbekannte auf None."""
return {
"tank_id": neue_id("f"),
"receipt_key": None,
"ts": ts,
"liters": None,
"fuel_total_eur": None,
"price_per_l": None,
"discount": None,
"station_name": None,
"odometer_km": None,
"distance_km": None,
"fuel_type": None,
"source": quelle,
"status": "unvollständig",
"receipt_file": None,
"edited_fields": [],
}
async def fuellstand_geaendert(k: Koordinator, neu: str | None) -> None:
aktuell = _als_zahl(neu)
if aktuell is None:
return
tiefststand = k.tiefststand_pct
if tiefststand is None or aktuell <= tiefststand:
await k.tiefststand_setzen(aktuell)
return
anstieg = aktuell - tiefststand
if anstieg < schwelle_prozent(await k.ablage.profil_lesen()):
return
await _automatisch_anlegen(k, anstieg)
await k.tiefststand_setzen(aktuell)
async def _automatisch_anlegen(k: Koordinator, anstieg_pct: float) -> None:
odometer_km = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR))
tankvorgang = leerer_tankvorgang(
datetime.datetime.now(datetime.UTC).isoformat(), "auto"
)
tankvorgang["odometer_km"] = odometer_km
tankvorgang["distance_km"] = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
await k.ablage.tankvorgang_anhaengen(tankvorgang)
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info(
"Tankvorgang %s automatisch erkannt (Füllstandsanstieg %s Prozentpunkte)",
tankvorgang["tank_id"], round(anstieg_pct, 1),
)
@@ -0,0 +1,174 @@
{
"config": {
"step": {
"user": {
"title": "Audi Dashboard",
"description": "Richtet das Panel \"Mein Audi\" ein. Welcher Sensor welche Rolle im Fahrzeug spielt, wird danach im Setup-Menü der App selbst zugeordnet (Einstellungen → Fahrzeug einrichten → Setup)."
}
},
"abort": {
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
"description": "Stößt eine sofortige Neuabfrage beim Fahrzeug an und veröffentlicht den Fahrzeugstatus neu."
},
"screening_jetzt": {
"name": "Screening jetzt",
"description": "Versucht sofort, offene Fahrten mit Start- und Endkilometerstand zu vervollständigen."
},
"backup_jetzt": {
"name": "Sicherung jetzt",
"description": "Legt sofort eine Sicherung von Profil, Fahrten, Tankvorgängen und Sensor-Zuordnung an."
},
"profil_schreiben": {
"name": "Profil schreiben",
"description": "Überschreibt das komplette Fahrzeugprofil.",
"fields": {
"profil_json": { "name": "Profil", "description": "Das vollständige Fahrzeugprofil als JSON." }
}
},
"entitaeten_schreiben": {
"name": "Sensor-Zuordnung schreiben",
"description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.",
"fields": {
"zuordnung_json": { "name": "Zuordnung", "description": "Feldname zu Entity-ID als JSON." }
}
},
"fahrt_manuell_anlegen": {
"name": "Fahrt anlegen",
"description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.",
"fields": {
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
"distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." }
}
},
"fahrt_aktualisieren": {
"name": "Fahrt bearbeiten",
"description": "Ändert eine bestehende Fahrt.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." },
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
"start_ort": { "name": "Startort", "description": "Freitext." },
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
"distanz": { "name": "Strecke", "description": "Von Hand gesetzt." }
}
},
"fahrt_loeschen": {
"name": "Fahrt löschen",
"description": "Löscht eine Fahrt unwiderruflich.",
"fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }
}
},
"beleg_hochladen": {
"name": "Beleg hochladen",
"description": "Liest einen Tankbeleg (PDF) und trägt ihn in einen bestehenden Tankvorgang nach oder gibt die Werte an die Oberfläche zurück.",
"fields": {
"pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." },
"dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." },
"tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." }
}
},
"tankvorgang_manuell": {
"name": "Tankvorgang anlegen",
"description": "Erfasst einen Tankvorgang von Hand.",
"fields": {
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_aktualisieren": {
"name": "Tankvorgang bearbeiten",
"description": "Ändert einen bestehenden Tankvorgang.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." },
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." },
"liter": { "name": "Liter", "description": "Getankte Menge." },
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
"km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
"station": { "name": "Station", "description": "Name der Tankstelle." },
"distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
}
},
"tankvorgang_loeschen": {
"name": "Tankvorgang löschen",
"description": "Löscht einen Tankvorgang unwiderruflich.",
"fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }
}
},
"bild_hochladen": {
"name": "Fahrzeugbild hochladen",
"description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." },
"daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." }
}
},
"bild_loeschen": {
"name": "Fahrzeugbild löschen",
"description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.",
"fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }
}
},
"reifen_wechseln": {
"name": "Reifensatz wechseln",
"description": "Schaltet den aktiven Reifensatz um.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." }
}
},
"reifen_km_setzen": {
"name": "Reifen-Kilometerstand setzen",
"description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.",
"fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." },
"km": { "name": "Kilometer", "description": "Der neue Zählerstand." }
}
},
"backup_wiederherstellen": {
"name": "Sicherung wiederherstellen",
"description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.",
"fields": {
"profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." },
"fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." },
"tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." },
"entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." }
}
},
"historie_importieren": {
"name": "Daten aus dem Verlauf importieren",
"description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.",
"fields": {
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
"""Lesezugriff auf den aufgezeichneten Zustandsverlauf (recorder).
Über Home Assistants eigene recorder-API
(`homeassistant.components.recorder.history.get_significant_states`), nicht
über direkten SQL-Zugriff auf home-assistant_v2.db: das Datenbankschema des
recorders ist HA-intern und ändert sich zwischen Versionen, die Funktion
dagegen ist die von HA selbst benutzte und stabile Schnittstelle.
`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.
WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG WEGFÄLLT: das Fahrtabschluss-
Screening las den Verlauf früher über die HTTP-REST-API (/api/history/period)
und brauchte dafür ein langlebiges Zugriffstoken in
audi_dashboard/ha_token.txt - ein eigener Installationsschritt, der bei jeder
Neuinstallation vergessen werden konnte und dessen Fehlen sich nur als
Warnung im Protokoll zeigte (Fahrten blieben dann stumm ohne Strecke). Als
echte Integration liest die App den Verlauf direkt; Token und
Installationsschritt entfallen ersatzlos.
Die recorder-Abfrage läuft über den Executor: ein Datenbankzugriff hat im
Event-Loop nichts verloren.
"""
from __future__ import annotations
import datetime
import logging
from homeassistant.components.recorder import get_instance, history
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
Verlaufspunkt = tuple[datetime.datetime, str]
def _rohverlauf(
hass: HomeAssistant, entity_id: str, start: datetime.datetime, ende: datetime.datetime
) -> list[Verlaufspunkt]:
roh = history.get_significant_states(
hass, start, ende, [entity_id], None, True, False
)
ergebnis: list[Verlaufspunkt] = []
for zustand in roh.get(entity_id) or []:
# "unknown"/"unavailable" bedeuten "keine Meldung", nicht "Wert 0" -
# würden sie durchgereicht, ergäbe ein Ausfall der Datenquelle eine
# Fahrt mit absurder Kilometerdifferenz.
if zustand.state in ("unknown", "unavailable", None, ""):
continue
ergebnis.append((zustand.last_updated, zustand.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
async def verlauf_lesen(
hass: HomeAssistant,
entity_id: str | None,
start: datetime.datetime,
ende: datetime.datetime,
) -> list[Verlaufspunkt]:
"""Zustandsverlauf einer Entität als aufsteigende Liste von
(Zeitpunkt, Rohwert). Leere Liste, wenn die Entität nicht zugeordnet ist,
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist."""
if not entity_id:
return []
try:
return await get_instance(hass).async_add_executor_job(
_rohverlauf, hass, entity_id, start, ende
)
except Exception: # noqa: BLE001 - der Verlauf ist Beiwerk, nie der Kern
# Ohne Verlauf bleibt eine Fahrt ohne Strecke bzw. der Import leer -
# beides ist verkraftbar. Den aufrufenden Ablauf deswegen abzubrechen
# wäre es nicht.
_LOGGER.warning("Verlauf von %s nicht lesbar", entity_id, exc_info=True)
return []
def zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def wert_bei(
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
) -> float | None:
"""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:
gezahlt = zahl(wert)
if gezahlt is None:
continue
if ts <= zeitpunkt:
davor = gezahlt
else:
return davor if davor is not None else gezahlt
return davor
def naechster_wert(
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
) -> float | None:
"""Der Wert, dessen Zeitstempel am nächsten an `zielzeit` liegt.
Anders als wert_bei() ist hier bewusst egal, ob der Wert davor oder danach
liegt: der Kilometerstand kommt laut Datenquelle nicht sicher mit
Fahrtende, sondern teils erst mit Beginn der nächsten Fahrt."""
bester_wert = None
beste_diff = None
for ts, wert in verlauf:
gezahlt = zahl(wert)
if gezahlt is None:
continue
diff = abs((ts - zielzeit).total_seconds())
if beste_diff is None or diff < beste_diff:
bester_wert = gezahlt
beste_diff = diff
return bester_wert
@@ -0,0 +1,161 @@
"""Baut die Nutzlasten, die als Entitäts-Attribut `daten` an die Oberfläche
gehen.
Das Frontend liest die Werte über das ganz normale hass.states statt über
einen Dienst mit Rückgabewert: Zustände lesen ist ein seit Jahren stabiler,
einfacher Weg, während das Verhalten response-fähiger Dienste aus Sicht des
Browsers nicht durchgängig dokumentiert ist.
Hier stehen nur die reinen Rechenfunktionen - wer sie wann veröffentlicht,
entscheidet der Koordinator (koordinator.py).
GRÖSSENGRENZE, DIE JETZT KEINE MEHR IST: Zustands-Attribute sind in Home
Assistant nicht für beliebig große Datenmengen gedacht; ab ~16 KB warnt der
Recorder und schreibt sie trotzdem in die Datenbank. Die Fahrten- und
Tankvorgänge-Archive überschreiten das nach ein paar Jahren zwangsläufig. In
der pyscript-Fassung war das eine bekannte, offene Flanke. Diese Integration
markiert das Attribut `daten` als nicht aufzuzeichnen (siehe
_unrecorded_attributes in sensor.py) - die Nutzlast geht weiterhin an jede
Oberfläche, landet aber nie in der Datenbank.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .einstellungen import POSITIONEN, Sensorzuordnung
def zustand_oder_none(hass: HomeAssistant, entity_id: str | None) -> str | None:
"""Sicherer Zustandszugriff.
Eine nicht (mehr) existierende Entität ist hier der Normalfall, solange
Rollen im Setup-Menü unbelegt sind, und bleibt auch danach relevant: fällt
eine Datenquelle aus, soll die Oberfläche das zeigen, nicht an einem
Fehler hängen bleiben."""
if not entity_id:
return None
zustand = hass.states.get(entity_id)
if zustand is None or zustand.state in (None, "", "unknown", "unavailable"):
return None
return zustand.state
def _zu_zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _abs_zahl(wert: object) -> float | None:
zahl = _zu_zahl(wert)
return None if zahl is None else abs(zahl)
def _zu_bool(wert: object) -> bool | None:
"""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, statt
"steht" zu behaupten."""
if wert is None:
return None
return str(wert).lower() in ("on", "true", "1", "open", "yes")
def _standort(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
"""Live-GPS-Position - 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."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
lat_id, lon_id = werte.STANDORT_LAT_SENSOR, werte.STANDORT_LON_SENSOR
if not lat_id or not lon_id:
return leer
lat_zustand = hass.states.get(lat_id)
lon_zustand = hass.states.get(lon_id)
if lat_zustand is None or lon_zustand is None:
return leer
lat = _zu_zahl(zustand_oder_none(hass, lat_id))
lon = _zu_zahl(zustand_oder_none(hass, lon_id))
if lat is None or lon is None:
return leer
return {
"lat": lat,
"lon": lon,
"genauigkeit_m": None,
"zeit": lat_zustand.last_updated.isoformat(),
}
def _sicherheitscheck(hass: HomeAssistant, werte: Sensorzuordnung) -> list[dict]:
"""Die einzeln geprüften Punkte hinter "Sicher abgestellt", 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 Kennzahl unten ab, damit
beide nie auseinanderlaufen können."""
eintraege: list[dict] = []
for pos, sensor in zip(POSITIONEN, werte.TUER_SENSOREN):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"})
for pos, sensor in zip(POSITIONEN, werte.FENSTER_SENSOREN):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
for label, sensor in (
("Heckklappe", werte.HECKKLAPPE_SENSOR),
("Motorhaube", werte.HAUBE_SENSOR),
):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
return eintraege
def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss."""
sicherheitscheck = _sicherheitscheck(hass, werte)
# "Sicher abgestellt": 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.
if any(e["ok"] is None for e in sicherheitscheck):
gesichert = None
else:
gesichert = all(e["ok"] for e in sicherheitscheck)
standort = _standort(hass, werte)
return {
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
"batteriespannung": _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR)),
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
# auch die Fahrterkennung als maßgebliches Signal für Fahrtbeginn und
# -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, nicht "unterwegs" - eine Fahrt ohne
# Kilometerstand blieb dauerhaft "offen" und das Fahrzeug damit
# dauerhaft "fahrend". None bedeutet: kein Zündungssensor zugeordnet.
"zuendung": _zu_bool(zustand_oder_none(hass, werte.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) - die Streckensensoren liefern
# negative Restkilometer-Werte, hier deshalb der Betrag.
"oelwechsel_faellig_ts": zustand_oder_none(hass, werte.NAECHSTER_OELWECHSEL_SENSOR),
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.OELWECHSEL_STRECKE_SENSOR)),
"inspektion_faellig_ts": zustand_oder_none(hass, werte.NAECHSTE_INSPEKTION_SENSOR),
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.INSPEKTION_STRECKE_SENSOR)),
}
@@ -1,5 +1,5 @@
{
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Vor der Installation nach fahrzeugprofil.json kopieren und die Platzhalter unten ersetzen (VIN, Kennzeichen, Erstzulassung, HU-Termin, Versicherung, Werkstatt, Servicebuch) - am einfachsten über den Button 'Einrichten' in den Einstellungen der App, sobald sie einmal läuft. Diese Datei selbst bleibt bewusst im Repo (siehe .gitignore), die echte fahrzeugprofil.json nicht.",
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Die Integration legt daraus bei der ersten Einrichtung /config/audi_dashboard/fahrzeugprofil.json an und fasst sie danach nie wieder an. Die Platzhalter (VIN, Kennzeichen, Erstzulassung, HU-Termin, Versicherung, Werkstatt, Servicebuch) werden in der App unter Einstellungen -> Fahrzeug einrichten ersetzt, nicht hier.",
"fahrzeug": {
"fin": "",
@@ -0,0 +1,76 @@
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
einrichten -> Setup).
audi_dashboard/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
geänderten Zuordnungen, keyed nach den Feldnamen aus einstellungen.py.
anwenden() legt sie über die eingebauten Standardwerte.
Wichtig ist, dass JEDES bekannte Feld bei jedem Durchlauf gesetzt wird, nicht
nur die belegten. Eine frühere Fassung übersprang leere Werte - weil die
Zuordnung aber ein dauerhaftes Objekt ist, 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.
"""
from __future__ import annotations
from . import einstellungen
from .ablage import Ablage
from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung
def _ist_leer(wert: object) -> bool:
"""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."""
if wert in (None, ""):
return True
if isinstance(wert, list):
return not any(eintrag for eintrag in wert)
return False
class Zuordnung:
"""Hält die wirksame Sensorzuordnung und pflegt sie aus entitaeten.json."""
def __init__(self, ablage: Ablage) -> None:
self._ablage = ablage
self.werte = Sensorzuordnung()
async def anwenden(self) -> None:
"""Setzt für jedes bekannte Feld den wirksamen Wert - den Override,
wenn einer hinterlegt ist, sonst den eingebauten Standardwert."""
overrides = await self._ablage.zuordnung_lesen()
for key in SCHLUESSEL:
wert = overrides.get(key)
standard = STANDARDWERTE[key]
gesetzt = standard if _ist_leer(wert) else wert
setattr(self.werte, key, list(gesetzt) if isinstance(gesetzt, list) else gesetzt)
async def speichern(self, mapping: dict) -> None:
"""Schreibt nur bekannte Schlüssel; 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}
await self._ablage.zuordnung_schreiben(bereinigt)
await self.anwenden()
def aktueller_stand(self) -> dict:
"""Katalog + aktuell wirksamer Wert je Feld, Grundlage für die Entität
sensor.audi_dashboard_entitaeten. Die Werte kommen aus dem
Zuordnungsobjekt selbst, nicht direkt aus der JSON-Datei - damit zeigt
die Oberfläche bereits angewendete Overrides und eingebaute
Standardwerte einheitlich."""
werte = {feld["key"]: getattr(self.werte, feld["key"], None) for feld in FELDER}
return {"katalog": FELDER, "werte": werte, "standardwerte": STANDARDWERTE}
__all__ = ["Zuordnung", "einstellungen"]
+6 -5
View File
@@ -47,7 +47,7 @@ selbst wurde NICHT aus diesem Export übernommen**, um diese bereits fertigen Fu
verlieren.
**Aktivierung 2026-08-11:** `audi-dashboard-ios.css` ist jetzt in
`../homeassistant/www/audi-dashboard-app.js` (Stylesheet-Loader, ~Zeile 3098) fest verdrahtet - als
`../custom_components/audi_dashboard/frontend/audi-dashboard-app.js` (Stylesheet-Loader, ~Zeile 3098) fest verdrahtet - als
zweiter `<link>` neben `audi-dashboard.css`, rein additiv. Im Docker-Testcontainer bei 375px
(Handy: `.phone` bleibt `flex`, Tab-Leiste unten) und 1280px (Desktop: `.phone` wird `grid`,
264px-Seitennavigation, Inhalt bis 860px begrenzt) verifiziert.
@@ -59,10 +59,11 @@ Dabei zwei Fixes nötig, die für künftige Arbeit an dieser Datei wichtig sind:
Zentrierung aus `audi-dashboard.css` (`max-width:412px`, für die alte Einzelgröße gedacht) musste
deshalb **unbedingt** statt bedingt (nur ab 860px) die Deckelung genommen werden - auf schmalen
Bildschirmen macht der Viewport selbst die Breite eng genug, das ist folgenlos.
2. **`audi-dashboard-version.json` muss bei jeder CSS-/JS-Änderung im Testcontainer hochgezählt
werden**, sonst liefert der Browser die alte Datei aus dem HTTP-Cache aus, obwohl der Server
längst die neue hat (an `audi-dashboard-panel.js`'s eigenem Cache-Busting-Mechanismus erkennbar,
siehe dessen Kommentar). Zusätzlich cacht `window.__audiDashboardStarten` das Ladeergebnis pro
2. **Die Version in `manifest.json` muss bei jeder CSS-/JS-Änderung hochgezählt werden**, sonst
liefert der Browser die alte Datei aus dem HTTP-Cache aus, obwohl der Server längst die neue
hat. Die Integration hängt diese Zahl als `?v=…` an die Panel-URL; sie ist der Cache-Brecher
(siehe VERSIONIERUNG.md). *Bis 2026-08-23 stand sie in `audi-dashboard-version.json` - die
Datei gibt es nicht mehr.* Zusätzlich cacht `window.__audiDashboardStarten` das Ladeergebnis pro
Browser-Tab - eine SPA-interne Navigation zur selben Panel-URL lädt nicht neu; ein neuer Tab
(oder echter Hard-Reload) ist bei Verifikationen nötig.
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Audi Dashboard",
"homeassistant": "2025.1.0",
"render_readme": true
}
+9 -16
View File
@@ -1,16 +1,9 @@
# Echte Fahrzeug-/Personendaten - nie versionieren (VIN, Kennzeichen,
# Versicherung, Werkstattkontakt, Servicehistorie, Fahrten, Tankvorgänge,
# echte Tankbelege). Vorlage ohne echte Daten bleibt getrackt, siehe
# data/fahrzeugprofil.example.json und README.md.
data/fahrzeugprofil.json
data/fahrten.jsonl
data/tankvorgaenge.jsonl
data/entitaeten.json
data/tests/belege/*.pdf
# installationspaket/ wird seit 2026-08-16 mitversioniert (vorher ignoriert):
# es ist zwar nur eine Kopie der bereits getrackten pyscript/www/Vorlage-
# Dateien, soll aber direkt aus dem Repo auf eine neue HA-Instanz kopierbar
# sein, ohne es vorher von Hand zusammenzustellen. Es enthält bewusst keine
# echten Fahrzeugdaten - nur data/fahrzeugprofil.example.json (Platzhalter,
# siehe oben). Beim Ändern von pyscript/ oder www/ immer mitsynchronisieren.
# Die Fahrzeugdaten leben in Home Assistant selbst (/config/audi_dashboard/),
# nicht in diesem Repo - die Integration legt den Ordner beim ersten Start an
# und füllt ihn aus der Vorlage
# (../custom_components/audi_dashboard/vorlage/fahrzeugprofil.json).
#
# Bis 2026-08-23 lag hier ein data/-Ordner, aus dem die Erstbefüllung von Hand
# kopiert werden musste; die Regeln darunter hielten die echten Fahrzeug- und
# Personendaten aus dem Repo heraus. Beides ist mit dem Umbau zur Integration
# entfallen - es gibt hier nichts mehr zu ignorieren.
+171 -353
View File
@@ -1,404 +1,222 @@
# Installation — Schritt für Schritt
Betrifft den kompletten aktuellen Baustand: das pyscript-Backend
(Fahrterkennung, Fahrtabschluss, Reifenzähler, Belegverarbeitung) **und**
das Frontend (`panel_custom`, Sidebar-Eintrag „Mein Audi").
Zwei Wege führen zum selben Ergebnis: HACS lädt den Ordner
`custom_components/audi_dashboard/` aus dem Repository, das Skript kopiert
denselben Ordner von der Festplatte. Was danach passiert, ist identisch.
**Getestet, nicht nur geprüft.** Beides lief in einer Wegwerf-Testinstanz
(Home Assistant in Docker) wirklich: das Backend über echte Service-Aufrufe
(Reifen wechseln hat die Profildatei tatsächlich umgeschrieben, eine
manuell angelegte Fahrt wurde tatsächlich an `fahrten.jsonl` angehängt),
das Frontend, indem das Custom Element mit genau diesen echten Daten
gefüttert wurde und daraus korrekt die Übersicht und die Fahrtenliste
gerendert hat — inklusive der zuvor testweise umgeschalteten Winterbereifung,
sichtbar an der Fahrbahn-Szene. Wofür die Testinstanz **nicht** reichte: der
Weg über Home Assistants echte WebSocket-Verbindung, mit der der
`panel_custom`-Rahmen (`ha-panel-custom`) das Element normalerweise selbst
erzeugt und mit `hass` versorgt — diese Testumgebung ließ keine
WebSocket-Verbindung zu, unabhängig vom eigenen Code. Dieser letzte Schritt
— dass die Oberfläche beim ganz normalen Draufklicken in der Sidebar
erscheint — ist deshalb der einzige Teil, der sich erst bei euch wirklich
zeigt. `panel_custom` selbst ist Home Assistants eigener, seit Jahren
stabiler Mechanismus, nicht eigener Code, entsprechend gering ist das
Risiko dort.
**Voraussetzung:** HACS ist bereits installiert (wird für pyscript selbst
gebraucht, siehe Schritt 1). Für die Fahrterkennung und die Fahrzeugdaten
wird zusätzlich ein Teltonika FMM003 (bzw. dessen Integration in Home
Assistant, z. B. über flespi) vorausgesetzt — Details siehe
`COMPANION_APP_ARCHITECTURE.md` im Projektstamm. Ohne FMM003 läuft die App
trotzdem: alle davon abhängigen Werte zeigen einfach „unbekannt" statt
eines Werts.
Zeitaufwand: ca. 3040 Minuten, größtenteils Warten auf Neustarts.
Wer von der früheren pyscript-Fassung kommt: [Umstieg](#umstieg-von-der-pyscript-fassung)
weiter unten. Die Fahrzeugdaten bleiben dabei, wo sie sind.
---
## Voraussetzungen
## Der schnelle Weg: install.sh
- Home Assistant 2025.1 oder neuer
- Zugriff auf das `config`-Verzeichnis (Samba-Add-on, SSH oder ein
gemounteter Pfad) — nur für den Weg ohne HACS
- Mindestens eine Datenquelle, die den Zündungs-/ACC-Status des Fahrzeugs
als `binary_sensor` meldet. Ohne sie läuft die App, erkennt aber keine
Fahrten.
Für eine neue Instanz gibt es ein Skript, das die Schritte 1 bis 3 und 5
zusammen erledigt. Am einfachsten direkt auf der HA-Instanz im Add-on
**Terminal & SSH**:
Nicht mehr nötig, anders als bei der pyscript-Fassung: pyscript selbst, ein
langlebiges Zugriffstoken, ein `pip install` im Container und Einträge in der
`configuration.yaml`. Details dazu im Kopf von
[`__init__.py`](../custom_components/audi_dashboard/__init__.py).
```bash
bash <(curl -fsSL https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/homeassistant/install.sh)
---
## Weg A — über HACS
1. HACS → **Integrationen** → Menü oben rechts → **Benutzerdefinierte
Repositories**
2. Repository-URL eintragen, Kategorie **Integration**, hinzufügen
3. **Audi Dashboard** suchen und herunterladen
4. Home Assistant neu starten
**Hürde, die man kennen muss:** HACS spricht ausschließlich mit GitHub —
`github.com` und `api.github.com` stehen fest im Code, es gibt keinen
Schalter für Gitea, GitLab oder eine selbst gehostete Instanz. Liegt das
Repository woanders, findet HACS es auch als benutzerdefiniertes Repository
nicht, und es bleibt Weg B.
## Weg B — ohne HACS, per Skript
Vom Windows-Rechner aus, der das `config`-Verzeichnis erreicht:
```
homeassistant\installationspaket\Installieren.cmd
```
Liegt das Repository privat (Standard), braucht der Aufruf Zugangsdaten — mit
Token oder mit Nutzer und Passwort, beides funktioniert:
Das Skript sucht die HA-Instanz selbst (`\\homeassistant\config` und die
üblichen Varianten) und fragt nach, wenn es sie nicht findet. Es kopiert
`custom_components\audi_dashboard\` dorthin — und sonst nichts.
```bash
curl -fsSL -H "Authorization: token <TOKEN>" \
https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/homeassistant/install.sh -o install.sh
bash install.sh --repo https://<nutzer>:<token>@gitea.nothaft.cloud/paul/audi-app.git
Zum Vorabschauen, ohne dass etwas geschrieben wird:
```
powershell -ExecutionPolicy Bypass -File homeassistant\installationspaket\install.ps1 -Pruefen
```
**Eine Überlegung lohnt sich dabei:** Was in `--repo` steht, wird als Updatequelle
hinterlegt und landet im Klartext in `pyscript/modules/einstellungen.py`. Ein
Token ist dort besser aufgehoben als das Kontopasswort, weil er einzeln
widerrufbar und auf Lesezugriff beschränkbar ist. Wer gar nichts ablegen will,
hängt `--update-repo aus` an und aktualisiert weiter über dieses Skript.
Die Abwägung im Einzelnen steht in `ha_install.md`, Abschnitt 1b.
Was das Skript garantiert (und warum), steht in seinem eigenen Kopfkommentar.
Die kurze Fassung: es schreibt ausschließlich in seinen eigenen Ordner, fasst
`configuration.yaml` und `.storage\` nicht an, löscht nur den eigenen Ordner
und auch den nur, wenn dessen `manifest.json` ihn als solchen ausweist.
Alternativ von einem Rechner aus gegen ein eingebundenes config-Verzeichnis:
Danach Home Assistant neu starten.
```bash
./install.sh --ziel /Volumes/config --von ~/Development/audi-app
```
---
Das Skript installiert pyscript, spielt Backend und Oberfläche ein, legt das
Fahrzeugprofil aus der Vorlage an (**vorhandene Daten bleiben unangetastet**),
trägt den Abschnitt in die `configuration.yaml` ein — mit Sicherungskopie und
so, dass ein zweiter Lauf ihn ersetzt statt anhängt — und verdrahtet die
Selbstaktualisierung, damit „Update suchen" in der App funktioniert.
`--hilfe` zeigt alle Optionen.
## Nach dem Neustart
Danach bleiben nur noch drei Dinge, alle in der Weboberfläche: neu starten,
Sensoren zuordnen (Schritt 4 unten), Fahrzeugdaten eintragen.
### 1. Integration hinzufügen
Wer lieber jeden Schritt selbst nachvollzieht, folgt der ausführlichen
Anleitung ab hier.
**Einstellungen → Geräte & Dienste → Integration hinzufügen → Audi
Dashboard.** Es gibt nichts einzugeben — ein Bestätigungsschritt, mehr nicht.
Beim ersten Laden installiert Home Assistant die Abhängigkeit `pypdf` nach
(für die Tankbelege); das kann eine Minute dauern.
## Schritt 1 — pyscript installieren
Danach steht **Mein Audi** in der Seitenleiste, und es entstehen zwölf
Entitäten `sensor.audi_dashboard_*` sowie 18 Dienste `audi_dashboard.*`.
1. In Home Assistant: **HACS → Integrationen → Explore & Download Repositories**
2. Nach „pyscript" suchen, auswählen, **Download**
3. Home Assistant neu starten (**Einstellungen → System → Neu starten**)
### 2. Sensoren zuordnen
## Schritt 2 — Dateien auf den Home-Assistant-Rechner kopieren
In der App: **Einstellungen → Fahrzeug einrichten → Setup.**
Drei Ordner müssen in das `config`-Verzeichnis von Home Assistant kopiert
werden. Wählt den Weg, der zu eurer Installation passt:
Im Auslieferstand ist **kein** Sensor vorbelegt. Das ist Absicht: welche
Entity-IDs richtig sind, hängt an der Instanz und ihren Integrationen, und
eine gesetzte, aber falsche ID ist schlechter als eine leere — die App zeigt
dann „unbekannt" statt eines falschen Werts.
**Vorher:** `data/fahrzeugprofil.json` enthält bei einer laufenden Installation
echte Fahrzeug- und Personendaten (VIN, Kennzeichen, Versicherung, Werkstatt,
Servicehistorie) und ist deshalb nicht Teil dieses Repos (siehe `.gitignore`).
Für eine neue Installation zuerst `data/fahrzeugprofil.example.json` nach
`data/fahrzeugprofil.json` kopieren — die Platzhalter darin lassen sich nach
dem ersten Start bequem direkt in der App eintragen: **Einstellungen →
Fahrzeug einrichten** deckt FIN, Kennzeichen, Erstzulassung und Ausführung ab,
Versicherung/Werkstatt/Reifen haben eigene Bearbeiten-Ansichten.
| Rolle | Braucht es für |
|---|---|
| **Zündung/ACC** (Pflicht) | Fahrterkennung, Anzeige „fährt/steht" |
| Kilometerstand | Fahrtabschluss, Reifenzähler, Ölwechsel-Prognose |
| Tankfüllstand | automatische Tankerkennung |
| 12V-Spannung | Batterieverlauf |
| GPS Breiten-/Längengrad | Standort-Kachel |
| Türen, Fenster, Heckklappe, Haube | „Sicher abgestellt" |
| Reichweite, Service-Fälligkeiten | Anzeige in der Übersicht |
**Am einfachsten: Samba-Share-Add-on**
1. Falls noch nicht installiert: **Einstellungen → Add-ons → Add-on Store →
„Samba share"** installieren und starten
2. Am Windows-Rechner im Explorer verbinden: `\\<HA-IP-Adresse>\config`
3. Von diesem Projektordner aus kopieren:
- `pyscript\` (der ganze Ordner) → `\\<HA-IP>\config\pyscript\`
- `data\` (der ganze Ordner) → `\\<HA-IP>\config\audi_dashboard\`
(Ordner beim Kopieren von `data` in `audi_dashboard` umbenennen)
- `www\` (der ganze Ordner, Frontend) → `\\<HA-IP>\config\www\`
(Inhalt zusammenführen, falls dort schon eine `www`-Ablage existiert)
Beim FMM003 **nicht** den selbst berechneten Gesamtkilometerstand
(`*_total_calculated_mileage`) zuordnen: der beruht auf GPS-Streckenrechnung
statt auf dem Tacho und verfälscht damit alle drei Auswertungen, die daran
hängen. Der vom CAN gelesene Wert ist der richtige.
**Alternative: Studio Code Server Add-on**
1. **Einstellungen → Add-ons → Add-on Store → „Studio Code Server"**
installieren, starten, öffnen
2. Im Dateibaum links Ordner `pyscript`, `audi_dashboard` und `www` unter
`/config` anlegen, falls sie fehlen
3. Dateien einzeln per Drag & Drop aus dem Explorer in den Browser ziehen,
oder Rechtsklick → „Upload"
Änderungen wirken sofort. Der frühere Hinweis „wirkt erst nach einem
Neustart" bei Zündung, Kilometerstand und Tankfüllstand ist entfallen — die
Integration meldet ihre Beobachter bei jedem Speichern neu an.
**Alternative: SSH & Terminal Add-on**, falls bereits eingerichtet — dann
reicht `scp`/`rsync` vom gewohnten Terminal aus.
### 3. Datenaufbewahrung verlängern — zeitkritisch
## Schritt 3 — configuration.yaml ergänzen
Home Assistant löscht Sensor-Verläufe nach **10 Tagen**. Das ist die Grenze,
bis zu der „Daten importieren aus Home Assistant" zurückreichen kann; was
gelöscht ist, kommt nicht wieder. Je früher der Block aus
[`recorder_snippet.yaml`](recorder_snippet.yaml) in der `configuration.yaml`
steht, desto mehr Vergangenheit bleibt erhalten.
`configuration_snippet.yaml` aus diesem Ordner öffnen. Die zwei Blöcke
(`pyscript:` und `panel_custom:`) in die bestehende `configuration.yaml`
übernehmen — **nicht** die Datei komplett ersetzen. Falls dort schon ein
`pyscript:`-Block existiert, nur die beiden Zeilen `allow_all_imports: true`
und `hass_is_global: true` darin ergänzen statt einen zweiten Block anzulegen.
Vorher **Einstellungen → System → Speicher** prüfen: ein Jahr Verlauf braucht
grob 11,5 GB. Bei knappem Platz mit `purge_keep_days: 90` anfangen.
`hass_is_global: true` braucht nur der nachträgliche Datenimport
(Schritt 8) — ohne die Zeile läuft alles andere unverändert, der Import
meldet dann aber, dass er den Verlauf nicht lesen kann.
Bewusst nicht automatisch eingetragen: das ist eine Entscheidung über den
Plattenplatz der Instanz, und viele Instanzen haben bereits einen eigenen
`recorder:`-Block, den man zusammenführen muss statt zu überschreiben.
Der `panel_custom:`-Block kann schon jetzt mit rein; er wird erst mit dem
Frontend-Baustein wirksam und stört bis dahin nicht.
### 4. Fahrzeugdaten eintragen
### Datenaufbewahrung — je früher, desto mehr ist zu retten
**Einstellungen → Fahrzeug einrichten.** Das Profil ist bereits angelegt —
die Integration hat es beim ersten Start aus ihrer Vorlage erzeugt. Zu
ersetzen sind die Platzhalter: VIN, Kennzeichen, Erstzulassung, HU-Termin,
Versicherung, Werkstatt, Servicebuch.
Zusätzlich `recorder_snippet.yaml` aus demselben Ordner übernehmen. Home
Assistant löscht Sensor-Verläufe **standardmäßig nach 10 Tagen**; der Block
hebt das auf ein Jahr an.
### 5. Vergangenes nachholen (optional)
Das ist zeitkritisch, anders als der Rest dieser Anleitung: was der recorder
einmal gelöscht hat, ist endgültig weg — auch für den Import in Schritt 8.
Wer diesen Block erst in vier Wochen einbaut, kann die dazwischen liegenden
Fahrten nicht mehr nachtragen.
**Einstellungen → Einrichten → Daten importieren aus Home Assistant.** Der
Import liest denselben Verlauf, den die Live-Erkennung sonst in Echtzeit
sieht, und leitet daraus rückwirkend Fahrten, Tankvorgänge und
Spannungswerte ab. Mehrfach ausführbar — überschneidende Zeiträume erzeugen
keine Dubletten.
Nicht betroffen sind die Bestände der App selbst (`fahrten.jsonl`,
`tankvorgaenge.jsonl`, `batteriespannung.jsonl`, `fahrzeugprofil.json` unter
`/config/audi_dashboard/`): die werden nirgends automatisch gekürzt und
bleiben dauerhaft erhalten. Die 10-Tage-Grenze betrifft nur den **rohen**
Sensor-Verlauf, aus dem die App ihre Datensätze erst ableitet.
---
Zum Platzbedarf (an der Testinstanz gemessen: rund 5.300 Zustandsänderungen
pro Tag, hochgerechnet grob 11,5 GB für ein Jahr) steht alles im Kopf von
`recorder_snippet.yaml`, samt einer auskommentierten `exclude:`-Liste zum
Kürzen, falls die Datenbank zu groß wird.
## Prüfen, ob alles geladen hat
## Schritt 4 — die Sensoren zuordnen
**Entwicklerwerkzeuge → Zustände**, Filter `audi_dashboard`. Erwartet werden
zwölf Entitäten. Zwei sagen auf einen Blick, ob es läuft:
Anders als früher wird dafür **nicht** mehr `pyscript/modules/
einstellungen.py` von Hand bearbeitet — das übernimmt ein grafisches
Setup-Menü direkt in der App: **Mein Audi → Einstellungen Fahrzeug
einrichten → Einrichten → „Setup — Sensoren zuordnen"** (letzter Punkt,
erscheint erst nach Klick auf „Einrichten").
- `sensor.audi_dashboard_app_version` — steht auf der installierten Version
- `sensor.audi_dashboard_fahrzeugstatus` — Attribut `daten` enthält die live
gelesenen Fahrzeugwerte
> **Reihenfolge beachten:** Dieser Schritt braucht eine bereits laufende App.
> Arbeite deshalb erst die Schritte 5 bis 9 ab (Token, Neustart, Prüfung,
> Restarbeiten, Frontend) und komm dann hierher zurück. Schritt 7 verweist
> seinerseits auf die hier vorgenommene Zuordnung — das ist kein Widerspruch,
> sondern schlicht die Reihenfolge: erst starten, dann zuordnen, dann prüfen.
Im Protokoll (**Einstellungen → System → Protokolle**) steht beim Start eine
Zeile `Audi Dashboard <Version> eingerichtet`.
Das Setup-Menü listet jede Sensor-Rolle, die die App kennt (Zündung/
Fahrterkennung, Kilometerstand, Tankfüllstand, Standort, Batteriespannung,
Türen/Fenster/Schlösser, Ölwechsel/Inspektion, …), schlägt je Rolle
passende vorhandene HA-Entitäten vor (Schalter „Nur passende Sensoren
anzeigen" grenzt auf die erwartete Domäne/Einheit ein) und schreibt die
Auswahl direkt in eine Override-Datei — `einstellungen.py` selbst bleibt
unverändert.
---
Die Override-Datei ist `/config/audi_dashboard/entitaeten.json`. Sie wird von
der automatischen Sicherung und vom Backup-Export mit erfasst; ein
Wiederherstellen spielt die Zuordnung also mit zurück.
## Umstieg von der pyscript-Fassung
**Hinweis zu den Vorgabewerten:** Drei Rollen sind in `einstellungen.py` mit
den Test-Entitäten der Entwicklungsinstanz vorbelegt (Zündung, Batterie-
spannung, Standort — jeweils `*_testzone_fmm003`). Auf einer frischen
Installation zeigen sie ins Leere; das Setup-Menü meldet dann „Entität nicht
gefunden". Einfach die eigenen Entitäten zuordnen, damit ist es erledigt.
Die Daten wandern nicht: Ordnername (`/config/audi_dashboard/`) und
Dateiformate sind unverändert. Die Integration liest den bestehenden Bestand
einfach weiter — es gibt keine Migration und damit auch keinen Weg, dabei
etwas zu verlieren.
**Zwingend, damit die Fahrterkennung läuft:**
- **Zündung/ACC-Status** (`ZUENDUNG_SENSOR`) — ein `binary_sensor`, `on`
= Fahrt läuft. Kommt vom Teltonika FMM003 (z. B.
`binary_sensor.<gerätename>_engine_ignition_or_acc_status`).
1. Integration installieren (Weg A oder B oben)
2. Aus der `configuration.yaml` entfernen: den `pyscript:`-Block und den
`panel_custom:`-Eintrag `audi-dashboard-panel`. Bleiben sie stehen, gibt
es den Sidebar-Eintrag zweimal, und beide Backends schreiben in dieselben
Dateien.
3. Aus `/config/pyscript/` entfernen: `backup.py`, `batterieverlauf.py`,
`belegverarbeitung.py`, `bilderverwaltung.py`, `fahrtabschluss.py`,
`fahrterkennung.py`, `frontend_api.py`, `historienimport.py`,
`reifenzaehler.py`, `tankerkennung.py`, `updateverwaltung.py` und den
Ordner `modules/`. Andere pyscript-Skripte bleiben unberührt; wird
pyscript sonst nicht gebraucht, kann es über HACS ganz entfernt werden.
4. Aus `/config/www/` entfernen: `audi-dashboard-app.js`,
`audi-dashboard-panel.js`, `audi-dashboard.css`, `audi-dashboard-ios.css`,
`audi-dashboard-version.json` und den Ordner `badges/`. Die Integration
liefert diese Dateien selbst aus. **`www/bilder/` bleibt** — das sind die
eigenen Fahrzeugfotos.
5. `/config/audi_dashboard/ha_token.txt` kann weg: der Verlauf wird nicht mehr
über die REST-API gelesen.
6. Home Assistant neu starten, Integration hinzufügen.
**Alles Weitere ist optional** — ohne zugeordneten Sensor zeigt die
Oberfläche „unbekannt"/em-dash statt eines Werts, kein Absturz:
- Kilometerstand (`KM_SENSOR`), Tankfüllstand (`TANK_SENSOR`), Reichweite
(`RANGE_SENSOR`) — bis zu einer neuen Datenquelle unbelegt, siehe
`AGENTS.md` (die frühere `TommiG1/HA_VAG-EU-Data-Act`-Integration liefert
diese nicht mehr)
- Standort (`STANDORT_LAT_SENSOR`/`STANDORT_LON_SENSOR`, zwei eigene
`sensor`-Entities für Breiten-/Längengrad — flespi liefert Koordinaten so,
nicht als `latitude`/`longitude`-Attribute eines `device_tracker`) und
Batteriespannung (`BATTERIE_SENSOR`) — vom FMM003, z. B.
`sensor.<gerätename>_latitude_coordinate_value`/`..._longitude_coordinate_value`
bzw. `sensor.<gerätename>_external_power_voltage` (**nicht**
`..._battery_voltage` — das ist die interne Pufferzelle des Trackers,
nicht die Fahrzeugbatterie)
- Türen/Fenster/Schlösser, Ölwechsel/Inspektion — je nach Fahrzeug/
Integration vorhanden oder nicht
Die Sensor-Zuordnung aus `entitaeten.json` wird unverändert übernommen.
Wer lieber direkt in Entwicklerwerkzeuge → Zustände nach Entity-IDs sucht,
kann das weiterhin tun — die Suche im Setup-Menü filtert exakt auf
denselben Datenbestand (`HASS.states`), nur mit Vorschlägen und Filter.
**Einschränkung bei drei Feldern** (Zündung, Kilometerstand,
Tankfüllstand): sie sind intern fest mit einem Auslöser verdrahtet
(`@state_trigger`), der einmalig beim Laden des Backends gesetzt wird.
Eine Änderung im Setup-Menü wird gespeichert, wirkt für die Fahrterkennung
selbst aber erst nach einem Neustart von Home Assistant — das Setup-Menü
zeigt dafür einen Hinweis an.
## Schritt 5 — Long-Lived Access Token erzeugen
Wird für das Kilometerstand-Screening gebraucht (§7.2) — pyscript hat keinen
eingebauten Weg, auf die Recorder-Historie zuzugreifen, deshalb läuft das
über die normale Home-Assistant-Web-API.
1. Unten links auf den eigenen Profil-Avatar klicken
2. Ganz nach unten scrollen zu **„Long-lived access tokens"**
3. **„Token erstellen"**, einen Namen vergeben (z. B. `audi_dashboard`)
4. Den angezeigten Token-Wert **sofort kopieren** — er wird danach nicht
noch einmal angezeigt
5. Eine neue Datei `audi_dashboard/ha_token.txt` anlegen (im selben Ordner,
in den `data/` kopiert wurde) und **nur den Token-Wert** hineinschreiben,
keine Anführungszeichen, keine zweite Zeile
⚠️ Diese Datei enthält ein Geheimnis. Nicht weitergeben, nicht in ein
Backup hochladen, das öffentlich einsehbar ist.
## Schritt 6 — neu starten
**Einstellungen → System → Neu starten.**
## Schritt 7 — prüfen, ob alles geladen hat
1. **Einstellungen → System → Protokolle**, nach „pyscript" oder
„audi_dashboard" filtern — es sollten keine roten Fehlermeldungen
auftauchen, insbesondere keine `ImportError` oder `ModuleNotFoundError`
2. **Entwicklerwerkzeuge → Zustände**, nach `pyscript.reifen` suchen — dort
sollten `pyscript.reifen_sommer_km`, `pyscript.reifen_winter_km` (Wert `0`,
solange noch kein Kilometerstand-Update seit der Installation einging) und
`pyscript.reifen_aktiver_satz` auftauchen
3. **Entwicklerwerkzeuge → Aktionen**, nach „audi_dashboard" suchen — die
Services `pyscript.audi_dashboard_screening_jetzt`,
`pyscript.audi_dashboard_reifen_wechseln`,
`pyscript.audi_dashboard_fahrt_manuell_anlegen`,
`pyscript.audi_dashboard_beleg_hochladen` und
`pyscript.audi_dashboard_tankvorgang_manuell` sollten dort erscheinen
Wenn das alles stimmt, läuft das Backend. Die Fahrterkennung selbst lässt
sich am einfachsten testen, indem der Zündungs-Sensor (`ZUENDUNG_SENSOR`,
nach dem Zuordnen in Schritt 4) kurz auf `on` und wieder auf `off` gesetzt
wird (Pausenregel greift, kurzer Ausflug wird als eine Fahrt gewertet) bzw.
länger als die eingestellte Pausenzeit auf `off` bleibt (Fahrt wird
angelegt) — am Fahrzeug reicht dafür kurz die Zündung, ohne Fahrzeug
funktioniert es auch manuell über **Entwicklerwerkzeuge → Zustände** (den
Sensor suchen, Zustand testweise auf `on`/`off` setzen). Danach in
`audi_dashboard/fahrten.jsonl` nachsehen, ob eine Zeile entstanden ist.
## Schritt 8 — was jetzt noch fehlt, bevor es vollständig nutzbar ist
- **Reifen-Kilometerstände:** laufen automatisch mit (`reifen.saetze.
sommer/winter.km`, siehe reifenzaehler.py) — beide starten bei der
Installation bei `0`. Hat einer der Sätze schon Laufleistung von vor der
Installation, den Wert einmalig direkt in `audi_dashboard/
fahrzeugprofil.json` nachtragen
- **Kfz-Steuer-Fälligkeit:** `steuer.faellig` im selben Profil eintragen
- **shell_beleg_parser.py** liegt bereits unter `data/shell_beleg_parser.py`
und wird mit dem `data`-Ordner aus Schritt 2 automatisch nach
`audi_dashboard/shell_beleg_parser.py` mitkopiert. Er ruft `python3` als
eigenen Prozess auf (nicht die pyscript-Sandbox) und braucht dafür
einmalig `pypdf`: im **Terminal & SSH**-Add-on (oder per `docker exec`)
`pip install pypdf` ausführen. Ohne das schlägt jeder Beleg-Upload mit
`ModuleNotFoundError: No module named 'pypdf'` im Log fehl.
- **Tailscale „VPN On Demand"** in der Tailscale-App selbst einrichten
(Regel „Only On", gebunden an `Audi_MMI_2804_5GHz`) — unabhängig von
Home Assistant, kann jederzeit parallel erledigt werden
- **Vergangene Daten nachtragen:** Fahrterkennung, Tankerkennung und
Batterieverlauf laufen erst ab der Installation mit. Was Home Assistant
vorher schon aufgezeichnet hat, holt **Einstellungen → Einrichten → „Daten
importieren aus Home Assistant"** nach: Zeitraum wählen, „Importieren", und
es entstehen dieselben Fahrten, Tankvorgänge und Spannungswerte, die die
Live-Erkennung erzeugt hätte. Der Vorgang ist gefahrlos wiederholbar —
überschneidet sich ein Zeitraum mit bereits erfassten Fahrten, wird er
übersprungen statt doppelt angelegt.
Wie weit er zurückreicht, hängt allein an der Aufbewahrung aus Schritt 3
(Standard 10 Tage, mit `recorder_snippet.yaml` ein Jahr). Kommt „0 Fahrten
angelegt" zurück, nennt das Fenster den frühesten Zeitpunkt, zu dem
überhaupt noch etwas aufgezeichnet ist.
## Schritt 9 — Frontend prüfen
Der `panel_custom`-Eintrag aus Schritt 3 zeigt auf
`/config/www/audi-dashboard-panel.js`, die in Schritt 2 mitkopiert wurde.
1. In der Sidebar sollte nach dem Neustart aus Schritt 6 ein neuer Eintrag
„Mein Audi" erscheinen (Auto-Symbol). Anklicken.
2. Die Übersicht sollte erscheinen: Fahrzeugbild (als Platzhalter, siehe
unten), Typenschild, Kilometerstand, Reichweite. Falls Kilometerstand
oder Reichweite „unbekannt"/leer bleiben, obwohl Schritt 7 erfolgreich
war: die optionalen Sensoren aus Schritt 4 im Setup-Menü zuordnen.
3. Falls die Seite leer bleibt oder gar nicht in der Sidebar erscheint: im
Browser die Entwicklerkonsole öffnen (F12) und nach Fehlern mit
„audi-dashboard" oder „pyscript" suchen — siehe Troubleshooting.
**Was direkt sichtbar fehlt, ganz bewusst (siehe README):**
- **Bilder** — der Ordner `bilder/` mit den elf Fotos aus §7a ist nicht
Teil dieses Baustands. Die Bildflächen bleiben leer bzw. zeigen ein
gebrochenes Bild-Symbol, das Layout selbst springt nicht (die Maße sind
reserviert). Bilder können jederzeit einzeln unter `www/bilder/` nachgereicht
werden, feste Dateinamen siehe §7a im Lastenheft.
- **Karten** (Leaflet) laden weiterhin von einem CDN, genau wie im
Prototyp. Das Gerät braucht dafür zusätzlich zur Tailscale-Verbindung
normalen Internetzugang — sonst bleibt die Karte auf der Fahrt- und
Tankvorgang-Detailseite leer.
- **Statistik-Seite** wertet die eigenen Fahrten und Tankvorgänge echt aus
(Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg). Sie bleibt nur so
lange leer, wie noch keine Fahrten und Tankungen erfasst sind.
## Schritt 10 — künftige Updates einspielen, ohne die App neu zu bauen
Ab hier ändert sich der Ablauf: kein Neustart mehr nötig, weder für
Backend- noch für Frontend-Änderungen.
**Backend (pyscript):** pyscript überwacht `pyscript/` selbst auf
Änderungen und lädt geänderte Dateien automatisch neu — an der
Testinstanz gemessen: eine bearbeitete Datei war innerhalb von
Millisekunden aktiv, neue Services erschienen sofort in
Entwicklerwerkzeuge → Aktionen, ganz ohne Neustart. Es reicht, die neue
Datei per Samba/Studio-Code-Server/`scp` in `pyscript/` zu überschreiben.
**Frontend:** hier gibt es keinen eingebauten Auto-Reload, dafür aber ein
echtes Cache-Problem — Home Assistant liefert Dateien aus `/local/` mit
`Cache-Control: max-age=2678400` aus, 31 Tage (an der Testinstanz
gemessen). Ohne Gegenmaßnahme würde eine Aktualisierung im Browser
tagelang nicht ankommen. Deshalb ist das Frontend zweigeteilt:
- `audi-dashboard-panel.js` ist ein kleiner, stabil bleibender Lade-Stub,
auf den `panel_custom` in der `configuration.yaml` zeigt
- er lädt bei jedem Seitenaufruf zuerst `audi-dashboard-version.json`
ungecacht, hängt deren Versionsnummer als Parameter an und lädt darüber
erst den eigentlichen Code aus `audi-dashboard-app.js` nach — jede neue
Versionsnummer ist für den Browser eine neue URL und wird nie aus einem
alten Cache bedient
**Am einfachsten mit dem beiliegenden Skript:**
```powershell
.\update.ps1 -Ziel "\\<HA-IP-Adresse>\config"
```
Kopiert `pyscript/` und die Frontend-Dateien, schreibt
`audi-dashboard-version.json` automatisch mit einem neuen Zeitstempel —
**lässt `data/` unangetastet**, damit ein bereits laufendes Fahrzeugprofil
oder Fahrten-Archiv nicht überschrieben wird. Voraussetzung: der
Samba-Share aus Schritt 2 ist als Netzlaufwerk verbunden.
Danach: pyscript-Änderungen sind sofort aktiv, für das Frontend reicht ein
ganz normales Neuladen der Seite (F5) — kein Hard-Refresh, kein
HA-Neustart.
⚠️ Server-seitig geprüft: eine neue `audi-dashboard-version.json` und ein
geänderter `audi-dashboard-app.js`-Inhalt standen an der Testinstanz sofort
zur Verfügung (per `curl` nachgemessen). Ob ein echter Browser das beim
nächsten Öffnen tatsächlich nachlädt, ließ sich in der Sandbox-Testumgebung
nicht abschließend zeigen — deren eigene Netzwerkschicht verhielt sich
bereits beim WebSocket-Test nicht wie ein normaler Browser (siehe README).
Das Verfahren selbst (`cache:"no-store"` plus versionierter
Import-Parameter) ist eine Standardtechnik gegen genau dieses Problem,
keine Vermutung — der erste echte Test dafür ist trotzdem der erste echte
Update-Durchlauf bei euch.
**Die Companion-App muss neu gebaut und aufgespielt werden.** Sie spricht
Entitäten und Dienste unter neuen Namen an (`sensor.audi_dashboard_*` statt
`pyscript.audi_dashboard_*`, `audi_dashboard.<dienst>` statt
`pyscript.audi_dashboard_<dienst>`) — eine App vom alten Stand findet nach
dem Umstieg nichts mehr. Das Panel ist davon nicht betroffen: es wird von der
Integration mitgeliefert und ist damit automatisch auf demselben Stand.
---
## Troubleshooting
| Symptom | Wahrscheinliche Ursache |
|---|---|
| `ModuleNotFoundError: No module named 'einstellungen'` (oder `profil`, `fahrtabschluss_logik`) | Ordner falsch kopiert — `modules/`-Unterordner muss unter `pyscript/modules/` liegen, nicht direkt unter `pyscript/` |
| pyscript lädt gar nicht, keine Fehler, keine Services | `allow_all_imports: true` fehlt oder Einrückung in der `configuration.yaml` ist falsch (YAML ist einrückungsempfindlich) |
| Fahrt wird nie angelegt | `ZUENDUNG_SENSOR` ist im Setup-Menü nicht oder falsch zugeordnet, oder er ist nach einer Änderung im Setup-Menü noch nicht durch einen HA-Neustart aktiv geworden (siehe Schritt 4) |
| Screening findet nie einen Kilometerstand | `ha_token.txt` fehlt, ist leer, oder der Token wurde widerrufen — in den Protokollen nach `HTTPError` oder `401` suchen |
| Reifenzähler zeigt dauerhaft „unbekannt" | `KM_SENSOR` ist im Setup-Menü nicht zugeordnet, oder es kam seit der Installation noch keine Änderung des Kilometerstands an (reifenzaehler.py reagiert nur auf Sensor-Änderungen) |
| „Mein Audi" fehlt in der Sidebar | `panel_custom:`-Block fehlt oder ist falsch eingerückt in `configuration.yaml`; nach Änderungen daran hilft nur ein vollständiger Neustart, kein „YAML neu laden" |
| Sidebar-Eintrag da, Seite bleibt leer | Browser-Konsole (F12) prüfen: 404 bei `/local/audi-dashboard-panel.js` → Datei liegt nicht unter `/config/www/`; JS-Fehler beim Laden → Datei unvollständig kopiert, Dateigröße mit dem Original vergleichen |
| Übersicht erscheint, aber alle Werte „unbekannt" | Normal, solange `KM_SENSOR`/`TANK_SENSOR`/... im Setup-Menü noch nicht zugeordnet sind (Schritt 4) — kein Frontend-Fehler |
| Karten bleiben leer auf Fahrt-/Tankdetailseite | Gerät hat keinen Internetzugang zusätzlich zu Tailscale — Leaflet lädt von einem CDN (siehe Schritt 9) |
**„Mein Audi" fehlt in der Seitenleiste.** Ist die Integration unter
Einstellungen → Geräte & Dienste wirklich hinzugefügt? Das Panel entsteht
erst dabei, nicht schon beim Kopieren der Dateien.
**Das Panel bleibt auf „Lädt …".** Die App fragt so lange nach, bis das
Backend Daten liefert — ein Abbruch ist nicht vorgesehen. Bleibt es dauerhaft
stehen, im Protokoll nach `audi_dashboard` suchen.
**Alle Kacheln zeigen „unbekannt".** Kein Sensor zugeordnet (siehe Schritt 2)
oder die zugeordneten Entitäten existieren nicht mehr. Das Setup-Fenster
zeigt neben jedem Feld, ob der gewählte Sensor gerade einen brauchbaren Wert
liefert.
**Fahrten werden nicht erkannt.** Ohne Zündungs-/ACC-Sensor gibt es keine
Fahrterkennung — das ist das eine Pflichtfeld. Prüfen lässt sich das direkt:
den Zustand der zugeordneten Entität in Entwicklerwerkzeuge → Zustände
beobachten, während das Fahrzeug an- und ausgeht.
**Fahrten bleiben „offen".** „Offen" heißt: die Strecke fehlt noch, nicht
„unterwegs". Der Kilometerstand kommt laut Datenquelle teils erst mit der
nächsten Fahrt; das Screening trägt ihn dann nach. Bleibt es dauerhaft offen,
fehlt der Kilometerstand-Sensor oder der recorder reicht nicht weit genug
zurück.
**Belege lassen sich nicht lesen.** `pypdf` fehlt — normalerweise
installiert Home Assistant es beim ersten Laden der Integration selbst. Im
Protokoll nach `pypdf` suchen.
+28 -155
View File
@@ -1,168 +1,41 @@
# Audi-Dashboard — Backend und Frontend, Stand nach dritter Bauphase
# homeassistant/ — Installation und Konfiguration
Dieser Ordner enthält Backend **und** Frontend aus dem Lastenheft
(`bauauftrag.md`): das pyscript-Backend für Fahrterkennung, Fahrtabschluss,
Reifenzähler und Belegverarbeitung, plus der zum `panel_custom`-Custom-
Element umgebaute Prototyp.
Der Code liegt nicht mehr hier. Seit dem Umbau zur Home-Assistant-Integration
(2026-08-23) steht er unter [`../custom_components/audi_dashboard/`](../custom_components/audi_dashboard/):
Backend, Panel-Dateien, Vorlage und Belegleser in einem Ordner, der genau so
installiert wird, wie HACS ihn installieren würde.
**Installation:** siehe [`INSTALL.md`](INSTALL.md) — Schritt für Schritt,
inklusive der einzigen Stelle, die inhaltlich angepasst werden muss
(`pyscript/modules/einstellungen.py`, Entity-IDs).
Was hier bleibt, ist alles, was **um** die Installation herum gebraucht wird.
## Getestet, nicht nur geprüft
| Datei | Wofür |
|---|---|
| [`INSTALL.md`](INSTALL.md) | Installation Schritt für Schritt, inklusive Umstieg von der pyscript-Fassung |
| [`installationspaket/`](installationspaket/) | Ein-Klick-Installation ohne HACS (`Installieren.cmd`) samt Anleitung |
| [`recorder_snippet.yaml`](recorder_snippet.yaml) | Datenaufbewahrung von 10 Tagen auf ein Jahr anheben — der einzige YAML-Block, der noch von Hand eingetragen wird |
| [`REVERSE_PROXY.md`](REVERSE_PROXY.md) | Zugriff von außen |
| `www/dm360-qr.html` | QR-Code-Seite zum Einrichten der Companion-App |
Beides lief in einer Wegwerf-Testinstanz (Home Assistant in Docker), nicht
nur gegen Dokumentation abgeglichen:
## Wo die Fahrzeugdaten liegen
- **Backend:** alle Module laden fehlerfrei, alle Trigger und Services
registrieren sich korrekt. Zwei echte Service-Aufrufe über die HA-API
haben tatsächlich Daten geschrieben — `audi_dashboard_reifen_wechseln`
hat `fahrzeugprofil.json` von „sommer" auf „winter" umgestellt,
`audi_dashboard_fahrt_manuell_anlegen` hat einen vollständigen Datensatz
an `fahrten.jsonl` angehängt.
- **Frontend:** das Custom Element wurde mit genau diesen echten
Backend-Daten gefüttert (per REST abgerufen) und hat daraus korrekt die
Übersicht und die Fahrtenliste gerendert — inklusive der zuvor
umgeschalteten Winterbereifung, sichtbar an der Fahrbahn-Szene, und der
manuell angelegten Testfahrt in der Fahrten-Ansicht.
In Home Assistant selbst, unter `/config/audi_dashboard/`:
Fahrzeugprofil, Fahrten, Tankvorgänge, Batterieverlauf, Sensor-Zuordnung,
hochgeladene Belege und die automatischen Sicherungen.
Dabei wurden drei echte pyscript-Eigenheiten gefunden und behoben, die in
keiner Dokumentation standen:
Dieser Ordner gehört dem Nutzer, nicht der Installation: kein Update — weder
über HACS noch über das Installationsskript — fasst ihn an. Dasselbe gilt für
die selbst hochgeladenen Fahrzeugfotos unter `/config/www/bilder/`.
1. `task.executor()` akzeptiert nur echte externe Python-Funktionen (z. B.
`io.open`), keine im pyscript-Ordner selbst definierten.
2. Variablen aus einem `with ... as f:`-Block waren danach außerhalb nicht
mehr auffindbar (`NameError`).
3. Das eingebaute `open()` existiert in pyscript nicht.
**Eine Lücke bleibt:** die Testinstanz ließ keine WebSocket-Verbindung zu
(Einschränkung der Testumgebung, nicht des Codes). Der letzte Schritt — dass
`panel_custom` das Element beim normalen Draufklicken in der Sidebar selbst
erzeugt und mit `hass` versorgt — läuft über genau diese Verbindung und
ließ sich deshalb nicht End-to-End nachstellen. `panel_custom` ist aber
Home Assistants eigener, langjährig stabiler Mechanismus, nicht eigener
Code — das Risiko dort ist gering, verglichen mit dem, was schon getestet
werden konnte.
Bis 2026-08-23 lag hier ein `data/`-Ordner, aus dem die Erstbefüllung von Hand
kopiert werden musste. Den gibt es nicht mehr: die Integration legt das
Fahrzeugprofil beim ersten Start selbst aus ihrer Vorlage an
([`vorlage/fahrzeugprofil.json`](../custom_components/audi_dashboard/vorlage/fahrzeugprofil.json)).
## Persönliche Daten
Alles Fahrzeug- und Personenspezifische (VIN, Kennzeichen, Versicherung,
Werkstattkontakt, Servicehistorie, Fahrten, Tankvorgänge) liegt ausschließlich
in `data/` und wird nie im App-Code (`pyscript/`, `www/`) fest hinterlegt —
`data/fahrzeugprofil.json`, `data/fahrten.jsonl`, `data/tankvorgaenge.jsonl`
sowie die echten Tankbeleg-PDFs unter `data/tests/belege/` sind deshalb per
`.gitignore` von der Versionierung ausgeschlossen. `data/fahrzeugprofil.
example.json` ist die getrackte Vorlage ohne echte Werte für eine
Neuinstallation (siehe INSTALL.md Schritt 2); die Kernangaben lassen sich
danach direkt in der App unter **Einstellungen → Fahrzeug einrichten**
eintragen, ohne die JSON-Datei von Hand zu bearbeiten. Das Backup
(**Einstellungen → Backup**) exportiert ausschließlich diese drei
Datendateien — nie App-Code oder -Konfiguration.
in diesem Datenordner in Home Assistant und steht nirgends im App-Code. Im
Repository liegt nur die Vorlage mit Platzhaltern.
## Danach noch offen
- **Reifen-Kilometerstände** (`reifen.saetze.sommer/winter.km`) laufen seit
der Frontend-Anpassung automatisch mit: jeder gefahrene Kilometer zählt auf
den Satz, der laut `reifen.aktiv` gerade montiert ist (reifenzaehler.py,
reagiert auf `KM_SENSOR`). Beide starten bei `0` — Laufleistung von vor der
Installation muss einmalig direkt im Profil nachgetragen werden.
- **Kfz-Steuer-Fälligkeit:** `steuer.faellig` steht auf `null`, aus §13 noch
offen.
- **shell_beleg_parser.py** fehlte im Projektordner beim Bau und wurde
nachträglich neu geschrieben — liegt jetzt unter `data/shell_beleg_parser.py`
und landet mit dem `data`-Ordner automatisch unter
`/config/audi_dashboard/shell_beleg_parser.py` (siehe INSTALL.md Schritt 2
und Schritt 8). Braucht dort einmalig `pip install pypdf` im System-`python3`
des Containers (eigener Prozess, nicht die pyscript-Sandbox). Geprüft gegen
zehn echte Mobile-Payment-Belege aus vier verschiedenen Stationen (Ingolstadt/
Zrenner, Rain am Lech/Bauch, Ansbach/Sengül, Königsbronn/Mogler) — die
Erkennung ist bewusst nicht an Markenzeilen-Wortlaut ("SHELL STATION" vs.
"Shell-Station") oder Rabatt-Label ("V-Power Smart Deal" vs.
"ClubSmartRabatt") gebunden, siehe Regressionstest unter
`data/tests/test_shell_beleg_parser.py` (Aufruf: `python3
data/tests/test_shell_beleg_parser.py`). Andere Zahlungswege als Mobile
Payment oder Belege mit mehreren Positionen (z. B. zusätzlicher
Autowäsche-Posten mit abweichendem MwSt.-Satz) sind nicht durch reale
Belege abgedeckt und müssten bei Bedarf nachgezogen werden.
- **Tailscale:** „VPN On Demand" mit Regel „Only On", gebunden an
`Audi_MMI_2804_5GHz`, muss in der Tailscale-App selbst eingerichtet werden
(§9, §10 Punkt 8) — das lässt sich nicht von hier aus mitinstallieren.
- **GPS-Fallback (§7.2) ist nicht umgesetzt.** Fahrten ohne passenden
Kilometerstand bleiben aktuell dauerhaft `offen`, statt auf die
GPS-Strecke auszuweichen. Erfordert eine `device_tracker`-Anbindung und
Adressauflösung.
- **Fahrterkennung übersteht keinen HA-Neustart mitten in einer Fahrt oder
Wartezeit** — der Zwischenstand lebt nur im Arbeitsspeicher von
`fahrterkennung.py`.
- **Bilder fehlen** (§7a) — Ordner `www/bilder/` ist noch leer. Layout
springt nicht (Maße sind reserviert), aber die Flächen bleiben leer.
- **Leaflet lädt per CDN**, wie im Prototyp — braucht zusätzlich zu
Tailscale echten Internetzugang auf dem Gerät.
## Was schon funktioniert (getestet)
- Fahrterkennung über den Zündungssensor mit Pausenregel (`task.unique` + `task.sleep`);
bis 2026-08-12 lief sie über den WLAN-Sensor des iPhones
- Zweistufiger Fahrtabschluss per Recorder-Historie-Screening, inklusive
Verkettung direkt anschließender Fahrten
- Reifenzähler per direkter Subtraktion, kein `utility_meter`
- Belegverarbeitung mit Duplikatserkennung über `receipt_key` (die zunächst
geplante 97-%-Volltankungsregel wurde per Änderungswunsch entfernt, siehe
Kopfkommentar in `pyscript/belegverarbeitung.py`)
- Manuelle Fallback-Wege für Fahrten und Tankvorgänge ohne Beleg
- Frontend: Übersicht, Fahrtenliste, Navigation zwischen allen Ansichten,
Datenadapter Profil→Oberfläche, Schreibaktionen über `hass.callService`
- Statistik-Seite mit echter Auswertung aus Fahrten und Tankvorgängen
(Zeiträume, Verbrauch, Tag/Nacht, privat/Arbeitsweg)
- Setup-Menü für die Sensor-Zuordnung, inklusive Sicherung in
`data/entitaeten.json`
## Dateiübersicht
```
homeassistant/
├── INSTALL.md Schritt-für-Schritt-Installation
├── README.md diese Datei
├── configuration_snippet.yaml Ergänzung zur configuration.yaml
├── data/
│ ├── fahrzeugprofil.json Fahrzeugprofil (§6.1), mit echten Daten befüllt — gitignored
│ ├── fahrzeugprofil.example.json Vorlage ohne echte Daten, bleibt getrackt (siehe INSTALL.md Schritt 2)
│ ├── fahrten.jsonl Fahrten-Archiv (§6.1) — gitignored
│ └── tankvorgaenge.jsonl Tankvorgänge-Archiv (§6.1) — gitignored
├── pyscript/
│ ├── modules/
│ │ ├── einstellungen.py zentrale Entity-ID-Konfiguration (Schritt 4)
│ │ ├── profil.py Datenzugriff (§6)
│ │ ├── entitaeten.py Sensor-Zuordnung aus dem Setup-Menü
│ │ ├── fahrtabschluss_logik.py Screening-Logik (§7.2)
│ │ └── frontend_veroeffentlichung.py Zustände fürs Frontend (§10 Punkt 7 Ersatz)
│ ├── fahrterkennung.py §7.1 (Zündungssensor, früher WLAN)
│ ├── fahrtabschluss.py §7.2 (Trigger-Registrierung)
│ ├── tankerkennung.py §7.4 (automatische Erkennung am Füllstandsanstieg)
│ ├── reifenzaehler.py §7.5
│ ├── belegverarbeitung.py §7.4, §7.7
│ ├── batterieverlauf.py 12-V-Spannung, Tagesminimum/-maximum
│ ├── bilderverwaltung.py Upload/Löschen der Fahrzeugfotos
│ ├── backup.py tägliche Sicherung und Wiederherstellung
│ ├── updateverwaltung.py Update suchen und installieren
│ └── frontend_api.py Lese-/Schreib-Anbindung fürs Frontend
├── www/
│ ├── audi-dashboard-panel.js Lade-Stub (zeigt configuration.yaml hierher), ändert sich kaum
│ ├── audi-dashboard-app.js das eigentliche Custom Element, hier passiert die Arbeit
│ ├── audi-dashboard-version.json Cache-Buster, wird von update.ps1 automatisch neu geschrieben
│ ├── audi-dashboard.css aus dem Prototyp extrahiert, für Shadow DOM angepasst
│ └── bilder/ noch leer, siehe „Danach noch offen"
└── update.ps1 Updates einspielen ohne Neustart (Schritt 10 in INSTALL.md)
```
## Updates einspielen
`pyscript/` lädt sich bei Änderungen selbst neu, ganz ohne HA-Neustart — an
der Testinstanz gemessen (Datei überschrieben, Service erschien innerhalb
von Millisekunden). Fürs Frontend gibt es dafür keinen eingebauten
Mechanismus, dazu kommt ein echtes Cache-Problem: `/local/` wird 31 Tage
lang gecacht (`Cache-Control: max-age=2678400`, ebenfalls gemessen). Gelöst
über einen kleinen Lade-Stub (`audi-dashboard-panel.js`), der bei jedem
Seitenaufruf ungecacht eine Versionsnummer nachlädt und darüber den
eigentlichen Code cache-sicher nachzieht — siehe Schritt 10 in
[`INSTALL.md`](INSTALL.md) und [`update.ps1`](update.ps1).
Das Backup in der App (**Einstellungen → Backup**) exportiert ausschließlich
diese Datendateien — nie App-Code oder -Konfiguration.
-52
View File
@@ -1,52 +0,0 @@
# Ergänzung zur bestehenden configuration.yaml.
# Nicht die ganze Datei ersetzen - diese Zeilen unter den jeweils schon
# vorhandenen Top-Level-Schlüsseln einfügen (falls z. B. schon ein
# `pyscript:`-Block existiert, dort zusammenführen statt duplizieren).
# Datenaufbewahrung: Home Assistant löscht Sensor-Verläufe standardmäßig nach
# 10 Tagen - das begrenzt, wie weit "Daten aus Home Assistant importieren"
# zurückreichen kann. Der zugehörige `recorder:`-Block steht mit ausführlicher
# Begründung und gemessenem Platzbedarf in recorder_snippet.yaml daneben;
# von dort übernehmen.
# pyscript-Backend: Fahrterkennung, Fahrtabschluss, Reifenzähler,
# Belegverarbeitung (siehe pyscript/-Ordner). allow_all_imports ist nötig,
# weil die Skripte os, subprocess, urllib.request, base64 und uuid nutzen -
# pyscript erlaubt standardmäßig gar keine Importe, auch keine aus der
# Standardbibliothek.
#
# hass_is_global stellt den `hass`-Namen in pyscript bereit. Gebraucht wird er
# ausschließlich von historienimport.py ("Daten importieren aus Home
# Assistant"): der Verlauf wird über Home Assistants eigene recorder-API
# gelesen (get_significant_states), die das hass-Objekt als ersten Parameter
# erwartet. Ohne diese Zeile bleibt der Rest der App voll funktionsfähig, nur
# der Import meldet, dass er den Verlauf nicht lesen kann.
pyscript:
allow_all_imports: true
hass_is_global: true
# Frontend: bindet den umgebauten Prototyp als eigenen Sidebar-Eintrag ein
# (§10 Punkt 1). module_url zeigt auf /config/www/audi-dashboard-panel.js
# (lokal erreichbar unter /local/audi-dashboard-panel.js). Die Datei selbst
# entsteht erst in Phase 2 (Frontend-Umbau) - bis dahin ist dieser Eintrag
# noch nicht funktionsfähig.
#
# Das ?v=1 am Ende ist absichtlich: audi-dashboard-panel.js selbst wird von
# Home Assistant mit einer sehr langen Standard-Cache-Zeit ausgeliefert
# (31 Tage), weil sie als stabiler Lade-Stub gedacht ist, der praktisch nie
# wieder angefasst wird (siehe deren eigener Kopfkommentar). Ändert sie sich
# doch einmal (ist an der Testinstanz bereits vorgekommen), bleibt jeder
# Browser, der die Datei vorher schon geladen hatte, sonst für bis zu 31
# Tage auf dem alten Stand hängen - unabhängig davon, wie oft die Datei auf
# dem Server ersetzt wird. Die Versionsnummer hier zu erhöhen ändert die
# angefragte URL und erzwingt damit einen echten Neuabruf bei jedem Browser.
# Das erfordert einen HA-Neustart (Änderungen an configuration.yaml), ist
# aber dafür zuverlässig - anders als bei audi-dashboard-app.js/.css, die
# über audi-dashboard-version.json ganz ohne Neustart aktualisiert werden.
panel_custom:
- name: audi-dashboard-panel
sidebar_title: Mein Audi
sidebar_icon: mdi:car-sports
module_url: /local/audi-dashboard-panel.js?v=1
embed_iframe: false
trust_external_script: false
-452
View File
@@ -1,452 +0,0 @@
#!/usr/bin/env python3
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
Betragsmuster vor "EUR #A").
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
deshalb reicht `pip install pypdf` einmalig im selben Python, das
`python3` in configuration.yaml/Docker auch sonst benutzt.
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
SHELL STATION
<Betreibername, z.B. "A. Zrenner GmbH">
<Straße>
<PLZ Ort>
Obj.-Nr.: <Stations-ID> Tel. ...
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
*<article_no> <Produktname> <Listenbetrag> EUR #A*
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
<Rabatt/l> EUR/Liter -- optional]
Gesamtbetrag <Endbetrag> EUR
Typ Netto Mwst Brutto
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
...
Mobile Payment
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
...
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
"""
import datetime
import hashlib
import json
import re
import sys
try:
import pypdf
except ImportError:
print(
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
file=sys.stderr,
)
sys.exit(1)
def _text_aus_pdf(pdf_pfad):
leser = pypdf.PdfReader(pdf_pfad)
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
def _de_zahl(text):
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
if text is None:
return None
return float(text.strip().replace(".", "").replace(",", "."))
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
jahr = int(jahr)
if jahr < 100:
jahr += 2000
return datetime.datetime(
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
).isoformat()
_ZAHL = r"[\d.,]+"
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
# nennen aber nie den bezahlten Rechnungsbetrag.
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
_MARKEN = (
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
"bft", "BP", "Q1",
)
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
def _marke(zeilen):
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
kopf = "\n".join(zeilen[:8])
for marke in _MARKEN:
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
return marke
return None
def _ist_strasse(zeile):
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
if not zeile:
return False
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
def _tankstelle(marke, strasse, ort, ersatzname=None):
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
erkennbare Marke tritt der Betreibername an ihre Stelle."""
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
return ", ".join(teile) if teile else None
def _parsen(text):
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
treffer = re.search(muster, text, flags)
if treffer is None and pflicht:
raise ValueError(f"Muster nicht gefunden: {muster!r}")
return treffer
# ---- Station ---------------------------------------------------------
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
# Belegen gleich.
station = suchen(
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
)
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
kopf = suchen(
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
)
receipt_no = kopf.group("receipt_no")
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
kopf.group("stunde"), kopf.group("minute"))
# ---- Kraftstoff-Position --------------------------------------------
position = suchen(
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
)
article_no = position.group("article_no")
product_name = position.group("produkt").strip()
zapfsaeule = suchen(
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
)
liters = _de_zahl(zapfsaeule.group("liter"))
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
# ---- Beträge ----------------------------------------------------------
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
steuerzeile = suchen(
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
)
net_eur = _de_zahl(steuerzeile.group("netto"))
vat_eur = _de_zahl(steuerzeile.group("mwst"))
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
zahlung = suchen(
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_payment = (
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
if zahlung else None
)
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
tse_zeit = suchen(
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_tse = (
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
if tse_zeit else None
)
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": tse_beleg_nr,
"ts": ts,
"ts_payment": ts_payment,
"ts_tse": ts_tse,
"station_id": station_id,
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
# ausführliche station_address darunter, nicht in den Anzeigenamen.
"station_name": _tankstelle(
_marke(text.splitlines()),
station.group("strasse").strip(),
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
ersatzname=station.group("name").strip(),
),
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
"article_no": article_no,
"product_name": product_name,
"fuel_type": product_name,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": receipt_total_eur,
"net_eur": net_eur,
"vat_eur": vat_eur,
}
def _parsen_generisch(text):
"""Stationsunabhängiger Fallback für Belege, die keinem der oben fest an
Shells Beleg-Struktur verankerten Muster entsprechen (_parsen() wirft in
dem Fall ValueError - siehe main()). Arbeitet nach demselben Verfahren,
das an einem echten Beleg einer bislang unbekannten Tankstelle von Hand
nachvollzogen wurde: Adresse suchen, Gesamtbetrag suchen ("Gesamt.../
Absolut..."), Literangabe suchen ("Menge"/"Amount" oder eine an "l"/
"Liter" hängende Zahl), daraus den tatsächlich bezahlten Preis/Liter
errechnen (Gesamtbetrag / Liter). Steht daneben auch ein aufgedruckter
Preis/Liter auf dem Beleg und weicht er vom errechneten ab, wurde ein
Rabatt gewährt - auf dem Beleg meist als eigener Betrag mit einem Minus
davor erkennbar, danach wird zuerst gesucht, sonst aus der Preisdifferenz
hergeleitet. Ob ein so gefundener Rabatt als "SmartDeal" behandelt wird,
entscheidet einzig station_name (Shell-Logo/-Label im Frontend, siehe
frontend_veroeffentlichung.py) - kein Sonderfall hier nötig, die Frontend-
Anzeige hängt nur an einem gesetzten discount, nicht an einem eigenen
Kennzeichen.
Liefert deutlich weniger Felder als _parsen() (kein Bon-Kleingedrucktes
wie TSE-Nummer/Zahlungszeitstempel) - unproblematisch, denn
belegverarbeitung.py liest nur receipt_key als echtes Pflichtfeld,
alles andere per .get()."""
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
return re.search(muster, text, flags)
zeilen = [z.rstrip() for z in text.splitlines()]
# ---- Adresse -----------------------------------------------------------
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
# Name ist die erste Textzeile darüber.
plz_treffer = None
plz_zeile = None
for i, zeile in enumerate(zeilen[:12]):
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
if treffer:
plz_treffer, plz_zeile = treffer, i
break
ort = None
strasse = None
betreiber = None
station_address = None
if plz_treffer is not None:
ort = plz_treffer.group(1).strip(" ,.-")
station_address = zeilen[plz_zeile].strip()
# Die Straße steht entweder vor der PLZ auf derselben Zeile
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
if _ist_strasse(vor_plz):
strasse = vor_plz
for zeile in reversed(zeilen[:plz_zeile]):
if not zeile.strip():
continue
if strasse is None and _ist_strasse(zeile.strip()):
strasse = zeile.strip()
continue
betreiber = zeile.strip()
break
if betreiber is None:
for zeile in zeilen[:5]:
if zeile.strip():
betreiber = zeile.strip()
break
if station_address and strasse and strasse not in station_address:
station_address = f"{strasse}, {station_address}"
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
# ---- Gesamtbetrag ------------------------------------------------------
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
kandidaten = []
for zeile in zeilen:
kompakt = re.sub(r"\s+", "", zeile).upper()
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
continue
if any(wort in kompakt for wort in _STEUER_WOERTER):
continue
betraege = re.findall(_GELD, zeile)
if betraege:
kandidaten.append(_de_zahl(betraege[-1]))
if not kandidaten:
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
fuel_total_eur = max(kandidaten)
# ---- Menge in Litern ----------------------------------------------------
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
if not menge:
menge = suchen(r"(" + _ZAHL + r")\s*(?:Liter|Ltr\.?|l)\b")
if not menge:
raise ValueError("Keine Literangabe gefunden (weder 'Menge'/'Amount' noch 'X l')")
liters = _de_zahl(menge.group(1))
if not liters:
raise ValueError("Literangabe ist 0 oder unlesbar")
berechneter_preis_l = round(fuel_total_eur / liters, 3)
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
# deshalb ist alles nach dem L optional.
aufgedruckt = suchen(
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
)
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
discount = None
discount_per_l = None
if aufgedruckt and abs(list_price_per_l - berechneter_preis_l) > 0.005:
minus = suchen(r"-\s*(" + _ZAHL + r")\s*(?:€|EUR)")
if minus:
discount = _de_zahl(minus.group(1))
discount_per_l = round(discount / liters, 3)
else:
discount_per_l = round(list_price_per_l - berechneter_preis_l, 3)
discount = round(discount_per_l * liters, 2)
# ---- Zeitstempel: irgendein Datum+Uhrzeit auf dem Beleg ----------------
zeit = suchen(r"(\d{2})\.(\d{2})\.(\d{2,4})\D{0,10}?(\d{2}):(\d{2})")
ts = _iso(zeit.group(1), zeit.group(2), zeit.group(3), zeit.group(4), zeit.group(5)) if zeit else None
# ---- Belegnummer, sonst deterministischer Ersatzschlüssel --------------
# Wichtig: kein eingebautes hash() (pro Prozess zufällig gesalzen, siehe
# PYTHONHASHSEED) - der Parser läuft für jeden Beleg als eigener Prozess
# (Kopfkommentar oben), ein instabiler Schlüssel würde den §7.7-Regel-3-
# Dedup-Vergleich in belegverarbeitung.py unbrauchbar machen.
beleg_nr = suchen(
r"(?:Beleg-?Nr\.?|Bon-?Nr\.?|Rechnungs-?Nr\.?|Receipt\s*No\.?|Trans(?:aktions)?-?Nr\.?)\D{0,5}?([\dA-Za-z/\-]+)"
)
receipt_no = beleg_nr.group(1) if beleg_nr else None
schluessel_basis = "|".join(
str(teil) for teil in (receipt_no, ts, station_address, fuel_total_eur, liters) if teil
) or text[:80]
receipt_key = "generisch_" + hashlib.sha1(schluessel_basis.encode("utf-8")).hexdigest()[:16]
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": None,
"ts": ts,
"ts_payment": None,
"ts_tse": None,
"station_id": None,
"station_name": station_name,
"station_address": station_address,
"article_no": None,
"product_name": None,
"fuel_type": None,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": fuel_total_eur,
"net_eur": None,
"vat_eur": None,
}
def main():
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
sys.exit(1)
pdf_pfad = sys.argv[1]
try:
text = _text_aus_pdf(pdf_pfad)
except Exception as exc:
print(f"PDF konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
try:
ergebnis = _parsen(text)
except Exception:
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
# endgültig aufgegeben wird.
try:
ergebnis = _parsen_generisch(text)
except Exception as exc:
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(ergebnis, ensure_ascii=False))
if __name__ == "__main__":
main()
-318
View File
@@ -1,318 +0,0 @@
#!/usr/bin/env bash
#
# DataMetric360 — Installation in Home Assistant
#
# Richtet das Panel samt Backend auf einer Home-Assistant-Instanz ein und
# verdrahtet die Selbstaktualisierung, damit danach alles Weitere über die
# Weboberfläche läuft: Sensoren zuordnen, nach Updates suchen, einspielen.
#
# Wo ausführen:
# Am einfachsten direkt auf der HA-Instanz, im Add-on "Terminal & SSH":
# bash <(curl -fsSL <ROH-URL dieser Datei>)
# Alternativ von einem Rechner aus gegen ein eingebundenes config-Verzeichnis:
# ./install.sh --ziel /Volumes/config
#
# Mehrfach ausführbar: bereits vorhandene Fahrzeugdaten werden nie
# überschrieben, und der Abschnitt in der configuration.yaml wird beim
# erneuten Lauf ersetzt statt ein zweites Mal angehängt.
#
set -euo pipefail
# ------------------------------------------------------------ Voreinstellungen
REPO_STANDARD="https://gitea.nothaft.cloud/paul/audi-app.git"
BRANCH="main"
ZIEL="/config"
QUELLE=""
TOKEN=""
REPO="$REPO_STANDARD"
UPDATE_REPO="" # leer = wie --repo
PYSCRIPT_INSTALLIEREN="ja"
INTERAKTIV="ja"
MARKE_START="# >>> DataMetric360 (von install.sh verwaltet) >>>"
MARKE_ENDE="# <<< DataMetric360 <<<"
# ------------------------------------------------------------------- Ausgabe
rot() { printf '\033[31m%s\033[0m\n' "$*"; }
gruen(){ printf '\033[32m%s\033[0m\n' "$*"; }
grau() { printf '\033[90m%s\033[0m\n' "$*"; }
schritt(){ printf '\n\033[1m==> %s\033[0m\n' "$*"; }
fehler(){ rot "FEHLER: $*"; exit 1; }
hilfe() {
cat <<'ENDE'
DataMetric360 — Installation in Home Assistant
--ziel <pfad> config-Verzeichnis von Home Assistant (Standard: /config)
--von <pfad> lokale Arbeitskopie benutzen statt zu klonen
--repo <url> Repository zum Klonen (Standard: das Projekt-Repository)
--update-repo <url|aus>
Was als Updatequelle hinterlegt wird. Standard: wie --repo.
"aus" hinterlegt nichts - dann gibt es keine
Selbstaktualisierung, aber auch keine Zugangsdaten auf der
Platte. Siehe ha_install.md, Abschnitt 1b.
--branch <name> Branch (Standard: main)
--token <wert> langlebiges Zugriffstoken für die Kilometerstand-Historie
--ohne-pyscript pyscript nicht mitinstallieren (wenn schon vorhanden)
--still keine Rückfragen stellen (für unbeaufsichtigte Läufe)
--hilfe diese Übersicht
Beispiele
bash install.sh # auf der HA-Instanz, klont selbst
bash install.sh --von ~/audi-app # aus einer lokalen Arbeitskopie
bash install.sh --ziel /Volumes/config # gegen ein eingebundenes Laufwerk
bash install.sh --repo https://nutzer:token@gitea.example/paul/audi-app.git
bash install.sh --von . --update-repo aus # lokal einspielen, ohne Zugangsdaten abzulegen
ENDE
}
while [ $# -gt 0 ]; do
case "$1" in
--ziel) ZIEL="${2:-}"; shift 2 ;;
--von) QUELLE="${2:-}"; shift 2 ;;
--repo) REPO="${2:-}"; shift 2 ;;
--update-repo) UPDATE_REPO="${2:-}"; shift 2 ;;
--branch) BRANCH="${2:-}"; shift 2 ;;
--token) TOKEN="${2:-}"; shift 2 ;;
--ohne-pyscript) PYSCRIPT_INSTALLIEREN="nein"; shift ;;
--still) INTERAKTIV="nein"; shift ;;
--hilfe|-h) hilfe; exit 0 ;;
*) fehler "Unbekannte Option: $1 (--hilfe zeigt alle)" ;;
esac
done
# --------------------------------------------------------------- Vorprüfungen
schritt "Umgebung prüfen"
for werkzeug in python3 curl unzip; do
command -v "$werkzeug" >/dev/null 2>&1 || fehler "$werkzeug wird gebraucht, ist aber nicht vorhanden."
done
[ -d "$ZIEL" ] || fehler "Das Zielverzeichnis $ZIEL gibt es nicht. Mit --ziel den richtigen Pfad angeben."
[ -w "$ZIEL" ] || fehler "Keine Schreibrechte auf $ZIEL."
if [ ! -f "$ZIEL/configuration.yaml" ]; then
rot "In $ZIEL liegt keine configuration.yaml."
fehler "Das sieht nicht nach einem Home-Assistant-Verzeichnis aus. Pfad mit --ziel prüfen."
fi
grau " Ziel: $ZIEL"
# ------------------------------------------------------------ Quelle besorgen
AUFRAEUMEN=""
saeubern() { [ -n "$AUFRAEUMEN" ] && rm -rf "$AUFRAEUMEN" || true; }
trap saeubern EXIT
if [ -n "$QUELLE" ]; then
schritt "Lokale Arbeitskopie benutzen"
[ -d "$QUELLE/homeassistant" ] || fehler "In $QUELLE gibt es keinen Ordner homeassistant/."
HA_QUELLE="$QUELLE/homeassistant"
grau " $HA_QUELLE"
else
schritt "Projekt herunterladen"
command -v git >/dev/null 2>&1 || fehler "git wird zum Klonen gebraucht (oder --von <pfad> benutzen)."
AUFRAEUMEN="$(mktemp -d)"
if ! git clone --depth 1 --branch "$BRANCH" "$REPO" "$AUFRAEUMEN/quelle" >/dev/null 2>&1; then
rot "Klonen von $REPO fehlgeschlagen."
grau " Bei einem privaten Repository ein Zugangstoken in die URL aufnehmen, etwa:"
grau " --repo https://<nutzer>:<token>@gitea.example/paul/audi-app.git"
exit 1
fi
HA_QUELLE="$AUFRAEUMEN/quelle/homeassistant"
grau " $REPO ($BRANCH)"
fi
[ -d "$HA_QUELLE/pyscript" ] || fehler "Im Projekt fehlt homeassistant/pyscript/."
# ------------------------------------------------------------------- pyscript
if [ "$PYSCRIPT_INSTALLIEREN" = "ja" ]; then
if [ -d "$ZIEL/custom_components/pyscript" ]; then
schritt "pyscript ist bereits vorhanden"
grau " $ZIEL/custom_components/pyscript"
else
schritt "pyscript installieren"
VERSION="$(curl -fsSL https://api.github.com/repos/custom-components/pyscript/releases/latest \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])' 2>/dev/null || true)"
[ -n "$VERSION" ] || fehler "Konnte die neueste pyscript-Version nicht ermitteln (kein Internet?)."
TMPPY="$(mktemp -d)"
curl -fsSL -o "$TMPPY/pyscript.zip" \
"https://github.com/custom-components/pyscript/archive/refs/tags/${VERSION}.zip"
unzip -oq "$TMPPY/pyscript.zip" -d "$TMPPY"
mkdir -p "$ZIEL/custom_components"
cp -r "$TMPPY/pyscript-${VERSION}/custom_components/pyscript" "$ZIEL/custom_components/"
rm -rf "$TMPPY"
gruen " pyscript $VERSION installiert"
fi
fi
# --------------------------------------------------------- Dateien einspielen
schritt "Backend und Oberfläche einspielen"
mkdir -p "$ZIEL/pyscript" "$ZIEL/www" "$ZIEL/audi_dashboard"
# pyscript und www sind Code - vollständig ersetzen.
cp -r "$HA_QUELLE/pyscript/." "$ZIEL/pyscript/"
cp -r "$HA_QUELLE/www/." "$ZIEL/www/"
grau " pyscript/ und www/ aktualisiert"
# Der Belegleser liegt in data/, ist aber Code (belegverarbeitung.py ruft ihn
# als Unterprozess auf) - deshalb hier mit, anders als die Datenbestände.
cp "$HA_QUELLE/data/shell_beleg_parser.py" "$ZIEL/audi_dashboard/shell_beleg_parser.py"
# Datenbestände: nur anlegen, was fehlt. Vorhandenes nie überschreiben.
if [ ! -f "$ZIEL/audi_dashboard/fahrzeugprofil.json" ]; then
cp "$HA_QUELLE/data/fahrzeugprofil.example.json" "$ZIEL/audi_dashboard/fahrzeugprofil.json"
gruen " Fahrzeugprofil aus der Vorlage angelegt"
else
grau " Fahrzeugprofil vorhanden - unangetastet"
fi
for datei in fahrten.jsonl tankvorgaenge.jsonl; do
[ -f "$ZIEL/audi_dashboard/$datei" ] || : > "$ZIEL/audi_dashboard/$datei"
done
mkdir -p "$ZIEL/audi_dashboard/belege"
# --------------------------------------------------- Selbstaktualisierung
schritt "Selbstaktualisierung verdrahten"
# Was hinterlegt wird, ist bewusst von der Installationsquelle getrennt: die
# Selbstaktualisierung laeuft spaeter im Home-Assistant-Prozess und klont
# allein anhand dieser URL - Zugangsdaten muessen also darin stehen und landen
# damit im Klartext in einstellungen.py. Wer das nicht will, gibt
# "--update-repo aus" an und aktualisiert weiterhin ueber dieses Skript.
QUELLE_FUER_UPDATE="${UPDATE_REPO:-$REPO}"
if [ "$QUELLE_FUER_UPDATE" = "aus" ]; then
QUELLE_FUER_UPDATE=""
fi
python3 - "$ZIEL/pyscript/modules/einstellungen.py" "$QUELLE_FUER_UPDATE" "$BRANCH" <<'PYENDE'
import re, sys
pfad, repo, branch = sys.argv[1], sys.argv[2], sys.argv[3]
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
inhalt = re.sub(r'^UPDATE_REPO_URL\s*=.*$', f'UPDATE_REPO_URL = "{repo}"', inhalt, count=1, flags=re.M)
inhalt = re.sub(r'^UPDATE_BRANCH\s*=.*$', f'UPDATE_BRANCH = "{branch}"', inhalt, count=1, flags=re.M)
with open(pfad, "w", encoding="utf-8") as f:
f.write(inhalt)
PYENDE
if [ -n "$QUELLE_FUER_UPDATE" ]; then
grau " Updatequelle: $(printf '%s' "$QUELLE_FUER_UPDATE" | sed -E 's#://[^@/]+@#://***@#') ($BRANCH)"
grau " Danach in der App unter Einstellungen → Version → \"Update suchen\""
case "$QUELLE_FUER_UPDATE" in
*://*:*@*) rot " Hinweis: Die URL enthaelt Zugangsdaten. Sie stehen im Klartext in"
rot " pyscript/modules/einstellungen.py und damit auch in jeder Sicherung." ;;
esac
else
grau " Keine Updatequelle hinterlegt - Aktualisieren laeuft weiter ueber dieses Skript."
fi
# ---------------------------------------------------------- configuration.yaml
schritt "configuration.yaml ergänzen"
KONFIG="$ZIEL/configuration.yaml"
SICHERUNG="$KONFIG.vor-datametric360-$(date +%Y%m%d_%H%M%S)"
cp "$KONFIG" "$SICHERUNG"
grau " Sicherung: $(basename "$SICHERUNG")"
python3 - "$KONFIG" "$MARKE_START" "$MARKE_ENDE" <<'PYENDE'
import re, sys
pfad, start, ende = sys.argv[1], sys.argv[2], sys.argv[3]
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
block = f"""{start}
# Von install.sh eingefügt. Aenderungen innerhalb dieses Abschnitts werden bei
# der naechsten Installation ersetzt - Eigenes bitte ausserhalb notieren.
pyscript:
allow_all_imports: true
panel_custom:
- name: audi-dashboard-panel
sidebar_title: Mein Audi
sidebar_icon: mdi:car-sports
module_url: /local/audi-dashboard-panel.js?v=1
embed_iframe: false
trust_external_script: false
{ende}"""
muster = re.compile(re.escape(start) + r".*?" + re.escape(ende), re.S)
if muster.search(inhalt):
inhalt = muster.sub(block, inhalt)
print(" Abschnitt ersetzt")
else:
for schluessel in ("pyscript:", "panel_custom:"):
if re.search(rf"^{schluessel}", inhalt, re.M):
print(f" ACHTUNG: '{schluessel}' steht bereits ausserhalb des verwalteten Abschnitts.")
print(" Der Abschnitt wird trotzdem angehaengt - Home Assistant meldet dann")
print(" einen doppelten Schluessel. Bitte den alten Eintrag von Hand entfernen.")
if not inhalt.endswith("\n"):
inhalt += "\n"
inhalt += "\n" + block + "\n"
print(" Abschnitt angehaengt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(inhalt)
PYENDE
# ------------------------------------------------------------------- Token
schritt "Zugriffstoken für die Kilometerstand-Historie"
TOKENDATEI="$ZIEL/audi_dashboard/ha_token.txt"
if [ -n "$TOKEN" ]; then
printf '%s' "$TOKEN" > "$TOKENDATEI"
chmod 600 "$TOKENDATEI" 2>/dev/null || true
gruen " Token gespeichert"
elif [ -s "$TOKENDATEI" ]; then
grau " Vorhandenes Token bleibt unangetastet"
elif [ "$INTERAKTIV" = "ja" ] && [ -t 0 ]; then
echo " Der Fahrtabschluss liest den Kilometerstand aus dem Verlauf und braucht dafür"
echo " ein langlebiges Zugriffstoken (Profil → Sicherheit → Langlebige Zugriffstoken)."
echo " Leer lassen und später nachtragen ist möglich - Fahrten bleiben dann ohne Strecke."
printf " Token (Eingabe bleibt verborgen): "
read -rs EINGABE || EINGABE=""
echo
if [ -n "$EINGABE" ]; then
printf '%s' "$EINGABE" > "$TOKENDATEI"
chmod 600 "$TOKENDATEI" 2>/dev/null || true
gruen " Token gespeichert"
else
rot " Übersprungen - später in $TOKENDATEI ablegen."
fi
else
rot " Kein Token angegeben. Später in $TOKENDATEI ablegen,"
rot " sonst bleiben Fahrten ohne Streckenangabe."
fi
# ------------------------------------------------------------------ Abschluss
schritt "Fertig"
cat <<ENDE
Noch zu tun, alles in der Weboberfläche:
1. Home Assistant neu starten
Einstellungen → System → oben rechts → Neu starten
2. Sensoren zuordnen
Mein Audi → Einstellungen → Fahrzeug einrichten → Einrichten
→ "Setup — Sensoren zuordnen"
Pflicht ist nur die Zündung; alles Weitere ist optional.
3. Fahrzeugdaten eintragen
Im selben Menü unter "Einrichten": Kennzeichen, FIN, Versicherung, …
Ab dann läuft alles über die Oberfläche - auch Updates:
Mein Audi → Einstellungen → Version → "Update suchen"
Sicherung der configuration.yaml:
$SICHERUNG
ENDE
gruen "Installation abgeschlossen."
+119 -157
View File
@@ -1,189 +1,151 @@
# Kurzanleitung — Installation auf einer neuen Home-Assistant-Instanz
# Kurzanleitung — Installation auf einer Home-Assistant-Instanz
Alle Dateien, die dafür nötig sind, liegen schon in diesem Ordner. Ausführliche
Erklärungen, Troubleshooting und Hintergründe stehen in `../INSTALL.md` — hier
nur die Checkliste.
Ausführliche Erklärungen, Umstieg von der alten Fassung und Troubleshooting
stehen in [`../INSTALL.md`](../INSTALL.md) — hier nur die Checkliste.
## Voraussetzungen
- HACS installiert, darüber **pyscript** installiert (HACS → Integrationen →
„pyscript" suchen → Download → HA neu starten)
- Home Assistant 2025.1 oder neuer
- Das **Samba-Share-Add-on** in HA läuft, damit `\\<HA-IP>\config` vom PC aus
erreichbar ist
Das war's. Anders als früher braucht es weder HACS noch pyscript, kein
Zugriffstoken und keinen `pip install` auf der HA-Maschine.
---
## Der schnelle Weg: `Installieren.cmd`
**Doppelklick auf `Installieren.cmd`** — das erledigt die Schritte 13 unten von
selbst: es sucht die HA-Instanz im Netz (`\\homeassistant\config` und die
üblichen Alternativen, sonst fragt es nach dem Pfad), kopiert `pyscript\`,
`data\``audi_dashboard\` und `www\`, trägt die beiden Blöcke in die
`configuration.yaml` ein und listet am Ende auf, was noch von Hand zu tun ist
(Neustart, Token, Sensoren zuordnen).
**Doppelklick auf `Installieren.cmd`.** Das Skript sucht die HA-Instanz im
Netz (`\\homeassistant\config` und die üblichen Alternativen, sonst fragt es
nach dem Pfad) und kopiert einen einzigen Ordner:
Was es dabei **nicht** tut:
```
custom_components\audi_dashboard\ -> <config>\custom_components\
```
- Bestehende Daten überschreiben. `fahrzeugprofil.json`, `fahrten.jsonl`,
`tankvorgaenge.jsonl`, `entitaeten.json` und `ha_token.txt` bleiben
unangetastet — ein zweiter Lauf auf einer laufenden Installation ist deshalb
gefahrlos.
- Die `configuration.yaml` blind verändern. Sie wird vorher zeitgestempelt
gesichert (`configuration.yaml.<Datum-Uhrzeit>.bak`), und stehen dort schon
eigene `pyscript:`- oder `panel_custom:`-Einträge, fasst das Skript sie gar
nicht an und sagt stattdessen, was zu ergänzen ist (zwei gleiche
Top-Level-Schlüssel wären ungültiges YAML — HA würde nicht mehr starten).
Nach dem Schreiben liest es die Datei zurück und prüft sie; weicht auch nur
eine Kleinigkeit ab, rollt es selbsttätig auf die Sicherung zurück.
Am Ende listet es auf, was noch von Hand zu tun ist.
### Ist das für meine bestehende HA-Installation gefährlich?
Nein — und das lässt sich nachprüfen statt glauben:
- Es **löscht nie etwas**. Im ganzen Skript kommt kein `Remove-Item` vor, und
kopiert wird ohne `robocopy /MIR`; fremde Dateien in `pyscript\` und `www\`
bleiben liegen.
- Es fasst **`.storage\` nicht an** — keine Integrationen, Geräte, Entitäten,
Benutzer, Automatisierungen. `automations.yaml`, `scripts.yaml`, `scenes.yaml`
und `secrets.yaml` werden weder gelesen noch geschrieben.
- Es **startet HA nicht neu**. Bis zum manuellen Neustart ändert sich am
laufenden Betrieb nichts.
- Die einzige Datei außerhalb der eigenen Ordner, die es überhaupt anfasst, ist
`configuration.yaml` — mit den drei Netzen oben.
Der einzige Punkt, der einer sonst gesunden Installation gefährlich werden
kann, ist **nicht** der Installer, sondern die Datenaufbewahrung aus Schritt 3:
ein Jahr Fahrzeugverlauf braucht grob 11,5 GB. Auf HA OS mit SD-Karte oder
kleiner eMMC vorher unter **Einstellungen → System → Speicher** nachsehen; ist
der Datenträger voll, startet HA nicht mehr — unabhängig von dieser App.
Erst schauen, was passieren würde, ohne etwas zu schreiben:
Erst schauen, was passieren würde, ohne dass etwas geschrieben wird:
```powershell
.\install.ps1 -Pruefen
```
Mit festem Ziel und Token in einem Rutsch:
Mit festem Ziel:
```powershell
.\install.ps1 -Ziel "\\192.168.1.50\config" -Token "eyJhb..."
.\install.ps1 -Ziel "\\192.168.1.50\config"
```
Danach weiter bei **Schritt 3** (Datenaufbewahrung — macht der Installer
bewusst nicht selbst, siehe dort), **4** (Token, falls nicht übergeben),
**5** und **6**.
### Ist das für meine bestehende HA-Installation gefährlich?
Nein — und das lässt sich nachprüfen statt glauben.
Die riskanteste Stelle der früheren Fassung ist weg: sie musste Blöcke in die
`configuration.yaml` eintragen, und eine kaputte `configuration.yaml` ist der
eine Weg, auf dem ein Installer Home Assistant am Starten hindern kann.
**Diese Fassung fasst die Datei überhaupt nicht mehr an** — die Integration
meldet ihr Panel selbst an, dafür braucht es kein YAML.
Was bleibt:
- Geschrieben wird **ausschließlich** in
`<config>\custom_components\audi_dashboard`. Kein anderer Pfad — nicht
`.storage\`, nicht `configuration.yaml`, nicht `automations.yaml`, nicht
`www\`, nicht `audi_dashboard\` (die Fahrzeugdaten).
- Gelöscht wird nur der eigene Ordner, und auch der nur, wenn darin eine
`manifest.json` mit `"domain": "audi_dashboard"` liegt. Ein fremder Ordner
unter demselben Namen führt zum Abbruch, nicht zum Löschen.
- Nach dem Kopieren liest das Skript die `manifest.json` im Ziel zurück und
prüft die Version. Stimmt sie nicht, bricht es mit einer Meldung ab, statt
eine halbe Installation stehen zu lassen.
- Es **startet HA nicht neu**. Bis zum manuellen Neustart ändert sich am
laufenden Betrieb nichts.
Ein zweiter Lauf auf einer laufenden Installation ist damit gefahrlos — er ist
sogar der vorgesehene Weg für Updates.
Der einzige Punkt, der einer sonst gesunden Installation gefährlich werden
kann, ist **nicht** der Installer, sondern die Datenaufbewahrung aus Schritt 3
unten: ein Jahr Fahrzeugverlauf braucht grob 11,5 GB. Auf HA OS mit SD-Karte
oder kleiner eMMC vorher unter **Einstellungen → System → Speicher**
nachsehen; ist der Datenträger voll, startet HA nicht mehr — unabhängig von
dieser App.
---
## Der Weg von Hand
Falls `Installieren.cmd` nicht durchläuft oder lieber nachvollziehbar
Schritt für Schritt gearbeitet werden soll:
Falls `Installieren.cmd` nicht durchläuft oder lieber nachvollziehbar:
## 1. Dateien kopieren
Ziel ist das `config`-Verzeichnis der neuen HA-Instanz, z. B. über das
Samba-Share-Add-on (`\\<HA-IP>\config`):
| Aus diesem Ordner | Nach |
|---|---|
| `pyscript\` | `\\<HA-IP>\config\pyscript\` |
| `data\` | `\\<HA-IP>\config\audi_dashboard\` (**umbenennen** von `data` zu `audi_dashboard`) |
| `www\` | `\\<HA-IP>\config\www\` |
In `audi_dashboard\` danach `fahrzeugprofil.example.json` zu
`fahrzeugprofil.json` umbenennen — die Platzhalter darin lassen sich nach dem
ersten Start direkt in der App unter **Einstellungen → Fahrzeug einrichten**
eintragen.
## 2. configuration.yaml ergänzen
Die beiden Blöcke aus `configuration_snippet.yaml` (`pyscript:` und
`panel_custom:`) in die bestehende `configuration.yaml` übernehmen — nicht
ersetzen. Falls dort schon `pyscript:` existiert, nur die beiden Zeilen
`allow_all_imports: true` und `hass_is_global: true` ergänzen.
## 3. Datenaufbewahrung — je früher, desto besser
Auch `recorder_snippet.yaml` übernehmen. Home Assistant löscht Sensor-Verläufe
**standardmäßig nach 10 Tagen**; der Block hebt das auf ein Jahr an.
Das ist der einzige zeitkritische Schritt der ganzen Anleitung: was der
recorder einmal gelöscht hat, ist endgültig weg und lässt sich auch mit „Daten
importieren aus Home Assistant" (Schritt 7) nicht mehr nachtragen. Jeder Tag
ohne diesen Block ist ein Tag Vergangenheit, den es später nicht mehr gibt.
Vorher **Einstellungen → System → Speicher** ansehen: ein Jahr braucht grob
11,5 GB. Bei wenig Platz mit `purge_keep_days: 90` anfangen — Begründung,
gemessene Zahlen und eine `exclude:`-Liste zum Kürzen stehen im Kopf der Datei.
Nicht betroffen sind die Bestände der App selbst (`fahrten.jsonl`,
`tankvorgaenge.jsonl`, `batteriespannung.jsonl`, `fahrzeugprofil.json`) — die
werden nirgends automatisch gekürzt.
## 4. Long-Lived Access Token
Profil-Avatar (unten links) → „Long-lived access tokens" → Token erstellen →
Wert in eine neue Datei `audi_dashboard\ha_token.txt` einfügen (nur der Token,
keine Anführungszeichen). Wird fürs Kilometerstand-Screening gebraucht.
## 5. Neu starten
**Einstellungen → System → Neu starten.**
## 6. Sensoren zuordnen
Nach dem Neustart sollte „Mein Audi" in der Sidebar erscheinen. Dort:
**Einstellungen → Fahrzeug einrichten → Einrichten → „Setup — Sensoren
zuordnen"**. Im Auslieferstand ist **kein** Sensor vorbelegt.
Zwingend für die Fahrterkennung: der Zündungs-/ACC-Sensor (`binary_sensor`,
meist vom Teltonika FMM003). Alles Weitere ist optional — ohne zugeordneten
Sensor zeigt die App „unbekannt" statt eines Werts.
Danach **noch einmal neu starten**: die drei trigger-gebundenen Felder
(Zündung, Kilometerstand, Tankfüllstand) werden erst mit einem Neustart
wirksam. Das Setup-Menü weist bei diesen Feldern selbst darauf hin.
Beim Kilometerstand nicht den vom FMM003 selbst berechneten Wert
(`*_total_calculated_mileage`) nehmen — der beruht auf GPS-Streckenrechnung
statt auf dem Tacho und verfälscht Reifenzähler, Ölwechsel-Prognose und
Fahrtabschluss. Richtig ist der CAN-Wert
(`*_total_vehicle_mileage_read_from_can`) oder der Kilometerstand der
EU-Data-Act-Integration.
## 7. Optional: Vergangenheit nachtragen
Sobald die Sensoren zugeordnet sind, holt **Einstellungen → Einrichten →
„Daten importieren aus Home Assistant"** nach, was Home Assistant schon vor
der Installation aufgezeichnet hat: Zeitraum wählen, „Importieren", fertig.
Es entstehen dieselben Fahrten, Tankvorgänge und Spannungswerte, die die
Live-Erkennung erzeugt hätte.
Gefahrlos wiederholbar — überschneidet sich ein Zeitraum mit bereits erfassten
Fahrten, wird er übersprungen statt doppelt angelegt. Wie weit er zurückreicht,
hängt allein an Schritt 3.
## 8. Optional: Beleg-Parser
Nur nötig, wenn Shell-Tankbelege hochgeladen werden sollen: im Terminal &
SSH-Add-on (oder `docker exec`) `pip install pypdf` ausführen.
## Künftige Updates
Nicht diesen Ordner erneut kopieren — stattdessen `..\update.ps1` aus dem
Hauptprojekt verwenden:
```powershell
..\update.ps1 -Ziel "\\<HA-IP-Adresse>\config"
```
Kopiert `pyscript\` und alle `www\`-Dateien (inkl. `audi-dashboard-ios.css`
und `badges\`, die früher hier im Skript fehlten), lässt `data\`/
`audi_dashboard\` unangetastet. pyscript lädt automatisch neu, fürs Frontend
reicht ein normales Neuladen der Seite (F5).
Den Ordner `custom_components\audi_dashboard` aus dem Projekt nach
`<config>\custom_components\` kopieren. Ist dort schon eine ältere Fassung,
diese vorher komplett löschen — bleibt eine Datei liegen, die es im neuen
Stand nicht mehr gibt, lädt Home Assistant sie trotzdem mit.
---
Details, Screenshots-Ersatz-Erklärungen und die vollständige
Troubleshooting-Tabelle: siehe `../INSTALL.md`.
## Danach
### 1. Home Assistant neu starten
**Einstellungen → System → Neu starten.** Beim ersten Start lädt HA die
Abhängigkeit `pypdf` nach (für die Tankbelege) — das kann eine Minute dauern.
### 2. Integration hinzufügen
**Einstellungen → Geräte & Dienste → Integration hinzufügen → Audi
Dashboard.** Es gibt nichts einzugeben.
Danach steht **Mein Audi** in der Seitenleiste.
### 3. Datenaufbewahrung — zeitkritisch
Den Block aus [`recorder_snippet.yaml`](recorder_snippet.yaml) in die
`configuration.yaml` übernehmen. Home Assistant löscht Sensor-Verläufe sonst
nach 10 Tagen; was weg ist, lässt sich auch mit „Daten importieren aus Home
Assistant" nicht mehr nachtragen. Je früher der Block drin ist, desto mehr
Vergangenheit bleibt erhalten.
Vorher den Speicher prüfen (siehe oben). Bei knappem Platz mit
`purge_keep_days: 90` anfangen — Details im Kopf der Datei.
Bewusst nicht vom Installer erledigt: das ist eine Entscheidung über den
Plattenplatz der Instanz, und viele Instanzen haben schon einen eigenen
`recorder:`-Block, den man zusammenführen muss statt zu überschreiben.
### 4. Sensoren zuordnen
In der App: **Einstellungen → Fahrzeug einrichten → Setup.**
Im Auslieferstand ist **kein** Sensor vorbelegt — das ist Absicht, siehe
`../INSTALL.md`. Zwingend ist allein der Zündungs-/ACC-Sensor
(`binary_sensor`, meist vom Teltonika FMM003): er trägt die Fahrterkennung.
Änderungen wirken sofort, ein Neustart ist dafür nicht mehr nötig.
### 5. Fahrzeugdaten eintragen
**Einstellungen → Fahrzeug einrichten.** Das Profil ist bereits da — die
Integration hat es beim ersten Start aus ihrer Vorlage angelegt. Zu ersetzen
sind die Platzhalter: VIN, Kennzeichen, Erstzulassung, HU-Termin,
Versicherung, Werkstatt, Servicebuch.
### 6. Vergangenes nachholen (optional)
**Einstellungen → Einrichten → Daten importieren aus Home Assistant** holt
Fahrten, Tankvorgänge und Spannungswerte aus dem bereits aufgezeichneten
HA-Verlauf nach.
---
## Updates
Denselben Aufruf noch einmal — `Installieren.cmd` oder `install.ps1`. Der
Ordner wird ersetzt, die Fahrzeugdaten unter `audi_dashboard\` und die eigenen
Fotos unter `www\bilder\` bleiben unangetastet. Danach HA neu starten (oder
kürzer: **Einstellungen → Geräte & Dienste → Audi Dashboard → Neu laden**).
Liegt das Projekt auf GitHub, übernimmt HACS das: es meldet neue Fassungen
von selbst, installiert sie und kann zurückrollen.
Binary file not shown.
@@ -1,52 +0,0 @@
# Ergänzung zur bestehenden configuration.yaml.
# Nicht die ganze Datei ersetzen - diese Zeilen unter den jeweils schon
# vorhandenen Top-Level-Schlüsseln einfügen (falls z. B. schon ein
# `pyscript:`-Block existiert, dort zusammenführen statt duplizieren).
# Datenaufbewahrung: Home Assistant löscht Sensor-Verläufe standardmäßig nach
# 10 Tagen - das begrenzt, wie weit "Daten aus Home Assistant importieren"
# zurückreichen kann. Der zugehörige `recorder:`-Block steht mit ausführlicher
# Begründung und gemessenem Platzbedarf in recorder_snippet.yaml daneben;
# von dort übernehmen.
# pyscript-Backend: Fahrterkennung, Fahrtabschluss, Reifenzähler,
# Belegverarbeitung (siehe pyscript/-Ordner). allow_all_imports ist nötig,
# weil die Skripte os, subprocess, urllib.request, base64 und uuid nutzen -
# pyscript erlaubt standardmäßig gar keine Importe, auch keine aus der
# Standardbibliothek.
#
# hass_is_global stellt den `hass`-Namen in pyscript bereit. Gebraucht wird er
# ausschließlich von historienimport.py ("Daten importieren aus Home
# Assistant"): der Verlauf wird über Home Assistants eigene recorder-API
# gelesen (get_significant_states), die das hass-Objekt als ersten Parameter
# erwartet. Ohne diese Zeile bleibt der Rest der App voll funktionsfähig, nur
# der Import meldet, dass er den Verlauf nicht lesen kann.
pyscript:
allow_all_imports: true
hass_is_global: true
# Frontend: bindet den umgebauten Prototyp als eigenen Sidebar-Eintrag ein
# (§10 Punkt 1). module_url zeigt auf /config/www/audi-dashboard-panel.js
# (lokal erreichbar unter /local/audi-dashboard-panel.js). Die Datei selbst
# entsteht erst in Phase 2 (Frontend-Umbau) - bis dahin ist dieser Eintrag
# noch nicht funktionsfähig.
#
# Das ?v=1 am Ende ist absichtlich: audi-dashboard-panel.js selbst wird von
# Home Assistant mit einer sehr langen Standard-Cache-Zeit ausgeliefert
# (31 Tage), weil sie als stabiler Lade-Stub gedacht ist, der praktisch nie
# wieder angefasst wird (siehe deren eigener Kopfkommentar). Ändert sie sich
# doch einmal (ist an der Testinstanz bereits vorgekommen), bleibt jeder
# Browser, der die Datei vorher schon geladen hatte, sonst für bis zu 31
# Tage auf dem alten Stand hängen - unabhängig davon, wie oft die Datei auf
# dem Server ersetzt wird. Die Versionsnummer hier zu erhöhen ändert die
# angefragte URL und erzwingt damit einen echten Neuabruf bei jedem Browser.
# Das erfordert einen HA-Neustart (Änderungen an configuration.yaml), ist
# aber dafür zuverlässig - anders als bei audi-dashboard-app.js/.css, die
# über audi-dashboard-version.json ganz ohne Neustart aktualisiert werden.
panel_custom:
- name: audi-dashboard-panel
sidebar_title: Mein Audi
sidebar_icon: mdi:car-sports
module_url: /local/audi-dashboard-panel.js?v=1
embed_iframe: false
trust_external_script: false
@@ -1,184 +0,0 @@
{
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Vor der Installation nach fahrzeugprofil.json kopieren und die Platzhalter unten ersetzen (VIN, Kennzeichen, Erstzulassung, HU-Termin, WLAN-Name, Versicherung, Werkstatt, Servicebuch) - am einfachsten über den Button 'Einrichten' in den Einstellungen der App, sobald sie einmal läuft. Diese Datei selbst bleibt bewusst im Repo (siehe .gitignore), die echte fahrzeugprofil.json nicht.",
"fahrzeug": {
"fin": "",
"kennzeichen": "",
"titel": "RS 4 Avant competition",
"zusatz": "Avant competition",
"untertitel": "quattro · 331 kW",
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
"erstzulassung": "",
"hauptuntersuchung_faellig": "",
"tankvolumen_liter": 58,
"ausfuehrung": "competition"
},
"einstellungen": {
"nacht_von": "19:00",
"nacht_bis": "06:00",
"fahrten_pausenzeit_min": 15,
"uebersichtsbild": "Frontansicht",
"kraftstoffanbieter": "Shell",
"smartdeal": { "aktiv": false, "laeuft_ab": "" },
"oelwechsel_intervall": {
"modus": "hersteller",
"km": 15000,
"monate": 12,
"hersteller_km": 30000,
"hersteller_monate": 24
},
"backup_intervall": "aus",
"letztes_backup": null
},
"versicherung": {
"gesellschaft": "",
"vertrag": "",
"nummer": "",
"service": "",
"notfall": "",
"beitrag": 0,
"steueranteil": 0,
"steuersatz": "19 %",
"gueltigAb": "",
"zahlweise": "jährlich",
"teile": [
{ "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null }
],
"selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]],
"schutzbrief": {
"stand": "",
"gilt": "",
"versichert": "",
"ausloeser": [],
"entweder": [],
"gruppen": [],
"nicht": [],
"pflichten": [],
"rang": ""
},
"leistungen": []
},
"steuer": {
"betrag": 0,
"zeitraum": "jährlich",
"faellig": null,
"grundlage": "Hubraum und CO₂-Ausstoß",
"lastschrift": true,
"finanzamt": ""
},
"reifen": {
"aktiv": "sommer",
"anzugsmoment_nm": 120,
"wechsel": { "datum": "", "vereinbart": false },
"referenz_odo_km": null,
"saetze": {
"sommer": {
"km": 0,
"bild": "/local/bilder/rad-sommer.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "275/30 R20 97Y XL",
"druck_vorne": "2,5",
"druck_hinten": "2,4",
"kommentar": ""
},
"winter": {
"km": 0,
"bild": "/local/bilder/rad-winter.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "265/35 R19",
"druck_vorne": "2,6",
"druck_hinten": "2,5",
"kommentar": ""
}
}
},
"service": {
"vereinbart": {},
"autohaus": {
"name": "",
"adresse": "",
"tel": "",
"mail": "",
"pos": null
},
"buch": []
},
"technik": [
{ "gruppe": "Motor", "posten": [
["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"],
["Hubraum", "2 894 cm³"],
["Leistung", "331 kW / 450 PS bei 5 7006 700 min⁻¹"],
["Maximales Drehmoment", "600 Nm bei 2 0005 000 min⁻¹"],
["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"],
["Abgasnorm", "EU6 AP"],
["Motormanagement", "vollelektronisch"]
]},
{ "gruppe": "Kraftübertragung", "posten": [
["Getriebe", "8-Gang tiptronic"],
["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"],
["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"]
]},
{ "gruppe": "Fahrleistungen", "posten": [
["0 auf 100 km/h", "3,9 s"],
["Höchstgeschwindigkeit", "290 km/h"],
["Effizienzklasse", "E"],
["Fahrgeräusch", "70 dB(A)"]
]},
{ "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [
["Fahrwerk", "RS-Sportfahrwerk"],
["Vorderachse", "5-Lenker, Rohrstabilisator"],
["Hinterachse", "5-Lenker, Rohrstabilisator"],
["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"],
["Wendekreis", "ca. 11,7 m"],
["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"],
["Vorn", "Faustsattel, Scheibe innenbelüftet"],
["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"]
]},
{ "gruppe": "Räder und Reifen", "posten": [
["Räder", "Leichtmetall 9J × 20, Audi Sport"],
["Reifen", "275/30 R20 97Y XL"],
["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"],
["Radschrauben", "Standard"],
["Anzugsmoment", "120 Nm"]
]},
{ "gruppe": "Maße, Gewichte, Volumen", "posten": [
["Leergewicht", "1 820 kg"],
["Zulässiges Gesamtgewicht", "2 350 kg"],
["Dachlast / Stützlast", "90 / 80 kg"],
["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"],
["Anhängelast ungebremst", "750 kg"],
["Gepäckraum", "495 / 1 495 l"],
["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"]
]},
{ "gruppe": "Bordnetz", "posten": [
["Batterie", "420 A, 75 Ah"],
["Generator", "100150 A"],
["Kältemittel", "R1234yf"]
]},
{ "gruppe": "Wartung", "posten": [
["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"],
["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"]
]}
],
"ausstattung": [
{ "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] },
{ "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] },
{ "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] },
{ "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] },
{ "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] },
{ "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] }
]
}
+109 -259
View File
@@ -1,90 +1,72 @@
<#
.SYNOPSIS
Installiert "Mein Audi" (DataMetric360) in einem Rutsch auf einer neuen
Home-Assistant-Instanz - die Schritte 1-3 aus ANLEITUNG.md.
Installiert "Mein Audi" (DataMetric360) als Home-Assistant-Integration -
der Weg ohne HACS.
.BESCHREIBUNG
Nimmt dem Erstaufsetzen die Handarbeit ab:
1. pyscript\ -> <Ziel>\pyscript\
2. data\ -> <Ziel>\audi_dashboard\ (Ordner wird umbenannt)
fahrzeugprofil.example.json -> fahrzeugprofil.json
3. www\ -> <Ziel>\www\
4. configuration_snippet.yaml wird in <Ziel>\configuration.yaml eingefügt
5. ha_token.txt wird geschrieben, wenn -Token übergeben wurde
Kopiert genau einen Ordner:
custom_components\audi_dashboard\ -> <Ziel>\custom_components\
Mehr ist nicht nötig. Alles, was früher an dieser Stelle von Hand gemacht
werden musste, macht die Integration inzwischen selbst: sie meldet ihr Panel
an, liefert ihre Oberflächen-Dateien selbst aus, legt beim ersten Start ein
Fahrzeugprofil aus der Vorlage an und bringt pypdf über ihre manifest.json
mit, das Home Assistant beim ersten Laden nachinstalliert.
WOFÜR ES DIESES SKRIPT ÜBERHAUPT NOCH GIBT
------------------------------------------
Der eigentliche Weg ist HACS. HACS spricht aber ausschließlich mit GitHub
(github.com/api.github.com stecken fest im Code) - solange dieses Projekt
nur auf der eigenen Gitea-Instanz liegt, findet HACS es nicht. Dieses Skript
ist der Weg dorthin, der ohne GitHub auskommt. Es installiert exakt
denselben Ordner, den auch HACS installieren würde; ein späterer Wechsel auf
HACS überschreibt ihn einfach.
SICHERHEIT GEGENÜBER DER BESTEHENDEN HA-INSTALLATION
----------------------------------------------------
Das Skript ist so gebaut, dass es eine laufende Home-Assistant-Installation
(auch HA OS) nicht gefährden kann. Konkret und nachprüfbar:
Die riskanteste Stelle der früheren Fassung gibt es nicht mehr: sie musste
die configuration.yaml ergänzen (pyscript:, panel_custom:), und eine kaputte
configuration.yaml ist der eine Weg, auf dem ein Installer Home Assistant am
Starten hindern kann. Diese Fassung fasst die Datei überhaupt nicht mehr an.
- Es LÖSCHT NIE etwas. Im gesamten Skript kommt kein Remove-Item vor, und
kopiert wird bewusst ohne robocopy /MIR - fremde Dateien in pyscript\
und www\ bleiben liegen, es wird nur ergänzt.
- Es fasst .storage\ nicht an: keine Integrationen, keine Geräte, keine
Entitäten, keine Benutzer, keine Automatisierungen. automations.yaml,
scripts.yaml, scenes.yaml und secrets.yaml werden nicht gelesen und
nicht geschrieben.
Was bleibt, ist nachprüfbar:
- Geschrieben wird ausschließlich in <Ziel>\custom_components\audi_dashboard.
Kein anderer Pfad wird angefasst - nicht .storage\, nicht
configuration.yaml, nicht automations.yaml, nicht www\, nicht
audi_dashboard\ (die Fahrzeugdaten).
- Entfernt wird nur der eigene Ordner, und nur, wenn darin eine
manifest.json mit "domain": "audi_dashboard" liegt. Ein fremder Ordner
unter diesem Namen führt zum Abbruch, nicht zum Löschen.
- Es startet Home Assistant nicht neu und greift nicht in den laufenden
Betrieb ein. Bis zum manuellen Neustart ändert sich am Verhalten der
Instanz nichts.
- Die einzige Datei außerhalb der eigenen Ordner, die überhaupt verändert
wird, ist configuration.yaml - und das ist der einzige Weg, auf dem
dieses Skript HA am Starten hindern könnte. Deshalb dort drei Netze:
eine zeitgestempelte Sicherung vor jeder Änderung, ein Abbruch ohne
jede Änderung falls dort schon fremde pyscript:/panel_custom:-Blöcke
stehen, und eine Rücklese-Prüfung nach dem Schreiben, die bei der
kleinsten Abweichung automatisch zurückrollt.
Betrieb ein.
- -Pruefen zeigt den kompletten Ablauf, ohne irgendetwas zu schreiben.
Im Zweifel damit anfangen.
Was das Skript NICHT kann: pyscript installieren (das macht HACS),
pypdf nachinstallieren (braucht eine Shell auf der HA-Maschine) und HA
neu starten. Diese Schritte stehen am Ende als Restliste.
Warum der Zielordner vor dem Kopieren geleert wird (und nicht nur
überschrieben): bleibt eine Datei aus einer älteren Fassung liegen, die es
im neuen Stand nicht mehr gibt, lädt Home Assistant sie trotzdem mit. Das
ist genau der Fehler, den man hinterher am schwersten findet. HACS macht es
aus demselben Grund so.
Grundsatz: nichts kaputtmachen, was schon da ist. Das Skript ist darauf
ausgelegt, gefahrlos mehrfach zu laufen (etwa nach einem Abbruch):
- Bestandsdaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl,
entitaeten.json, ha_token.txt) werden NIE überschrieben. Wer eine
laufende Installation neu bespielt, behält sein Fahrzeugprofil, seine
Fahrten, Tankvorgänge und Sensorzuordnung.
- configuration.yaml wird vor jeder Änderung nach configuration.yaml.bak
kopiert. Der eingefügte Teil steht zwischen zwei Markierungszeilen und
wird bei einem erneuten Lauf ersetzt statt ein zweites Mal angehängt.
- Stehen `pyscript:` oder `panel_custom:` dort schon aus anderer Quelle,
fasst das Skript die Datei NICHT an, sondern zeigt an, was von Hand zu
ergänzen ist. Zwei gleiche Top-Level-Schlüssel wären ungültiges YAML und
HA würde nicht mehr starten - das ist keine Entscheidung, die ein
Installer still für jemanden treffen sollte.
Nicht automatisierbar und deshalb am Ende als Restliste ausgegeben:
HA-Neustart, `pip install pypdf` (braucht eine Shell auf der HA-Maschine)
und das Zuordnen der Sensoren im Setup-Menü der App.
Die Fahrzeugdaten liegen woanders (<Ziel>\audi_dashboard\) und werden von
diesem Skript nie berührt - ein Update kann sie deshalb gar nicht treffen.
.PARAMETER Ziel
config-Verzeichnis der HA-Instanz, z. B. "\\homeassistant.local\config"
(Samba-Add-on) oder ein lokal gemounteter Pfad. Ohne Angabe sucht das
Skript die üblichen Samba-Namen selbst ab.
.PARAMETER Token
Long-Lived Access Token aus HA (Profil-Avatar -> "Long-lived access
tokens"). Wird nur nach audi_dashboard\ha_token.txt geschrieben und für das
Kilometerstand-Screening gebraucht. Ohne Angabe wird die Datei nicht
angelegt und der Schritt landet in der Restliste. Eine schon vorhandene
Datei bleibt in jedem Fall unangetastet.
.PARAMETER Pruefen
Nur anzeigen, was passieren würde - es wird nichts geschrieben.
.BEISPIEL
.\install.ps1
.\install.ps1 -Ziel "\\192.168.1.50\config"
.\install.ps1 -Ziel "\\homeassistant.local\config" -Token "eyJhb..."
.\install.ps1 -Pruefen
#>
param(
[string]$Ziel,
[string]$Token,
[switch]$Pruefen
)
@@ -96,7 +78,6 @@ function Gut($text) { Write-Host " [ok] $text" -ForegroundColor Green }
function Info($text) { Write-Host " [info] $text" -ForegroundColor DarkGray }
function Warnung($text) { Write-Host " [!] $text" -ForegroundColor Yellow }
# Sammelt alles ein, was der Mensch am Ende noch selbst tun muss.
$restliste = New-Object System.Collections.ArrayList
Write-Host ""
@@ -104,6 +85,23 @@ Write-Host " Mein Audi - Installation" -ForegroundColor White
Write-Host " ========================" -ForegroundColor White
if ($Pruefen) { Warnung "Pruefmodus - es wird nichts geschrieben." }
# ------------------------------------------------------------ Quelle finden
# Zwei Lagen, damit sowohl der Aufruf aus dem Projektordner heraus als auch
# ein separat weitergegebenes Paket funktioniert.
$quellKandidaten = @(
(Join-Path $hier "custom_components\audi_dashboard"),
(Join-Path $hier "..\..\custom_components\audi_dashboard")
)
$quelle = $null
foreach ($k in $quellKandidaten) {
if (Test-Path (Join-Path $k "manifest.json")) { $quelle = (Resolve-Path $k).Path; break }
}
if (-not $quelle) {
throw "custom_components\audi_dashboard nicht gefunden - dieses Skript gehört in den Projektordner (oder das Paket daneben)."
}
$version = (Get-Content (Join-Path $quelle "manifest.json") -Raw | ConvertFrom-Json).version
Gut "Quelle: $quelle (Version $version)"
# ---------------------------------------------------------------- Ziel finden
if (-not $Ziel) {
Schritt "Suche Home Assistant ..."
@@ -132,229 +130,81 @@ if (-not $Ziel) {
if (-not (Test-Path $Ziel)) {
throw "Ziel nicht erreichbar: $Ziel - Samba-Share verbunden? Pfad korrekt?"
}
Gut "Ziel: $Ziel"
# Ein config-Verzeichnis ohne configuration.yaml ist mit hoher Wahrscheinlichkeit
# der falsche Ordner - lieber hier abbrechen als Dateien irgendwohin streuen.
$configYaml = Join-Path $Ziel "configuration.yaml"
if (-not (Test-Path $configYaml)) {
if (-not (Test-Path (Join-Path $Ziel "configuration.yaml"))) {
throw "In $Ziel liegt keine configuration.yaml - ist das wirklich das config-Verzeichnis von HA?"
}
Gut "Ziel: $Ziel"
# pyscript wird über HACS installiert, nicht von hier - ohne die Integration
# läuft das komplette Backend nicht, deshalb früh darauf hinweisen.
if (-not (Test-Path (Join-Path $Ziel "custom_components\pyscript"))) {
Warnung "custom_components\pyscript fehlt - pyscript ist offenbar noch nicht über HACS installiert."
[void]$restliste.Add("pyscript über HACS installieren (HACS -> Integrationen -> 'pyscript' -> Download), danach dieses Skript erneut laufen lassen.")
# ------------------------------------------------------- Zielordner prüfen
$zielOrdner = Join-Path $Ziel "custom_components\audi_dashboard"
$vorhandeneVersion = $null
if (Test-Path $zielOrdner) {
$zielManifest = Join-Path $zielOrdner "manifest.json"
if (-not (Test-Path $zielManifest)) {
throw "In $zielOrdner liegt keine manifest.json. Das ist nicht unser Ordner - Abbruch, es wird nichts angefasst."
}
$vorhandenesManifest = Get-Content $zielManifest -Raw | ConvertFrom-Json
if ($vorhandenesManifest.domain -ne "audi_dashboard") {
throw "$zielOrdner gehört zur Integration '$($vorhandenesManifest.domain)', nicht zu 'audi_dashboard' - Abbruch."
}
$vorhandeneVersion = $vorhandenesManifest.version
Info "bereits installiert: Version $vorhandeneVersion"
}
# --------------------------------------------------------------- 1. pyscript
Schritt "1/5 pyscript-Backend kopieren"
# ----------------------------------------------------------------- Kopieren
Schritt "Integration kopieren"
if ($Pruefen) {
Info "würde kopieren: $hier\pyscript\* -> $Ziel\pyscript\"
if ($vorhandeneVersion) { Info "würde ersetzen: $zielOrdner (Version $vorhandeneVersion -> $version)" }
else { Info "würde anlegen: $zielOrdner (Version $version)" }
Info "würde sonst nichts anfassen - keine configuration.yaml, keine Fahrzeugdaten"
} else {
New-Item -ItemType Directory -Force -Path (Join-Path $Ziel "pyscript") | Out-Null
# Bewusst kein robocopy /MIR: das würde fremde Skripte im selben Ordner
# löschen. Hier wird nur ergänzt und überschrieben.
Copy-Item (Join-Path $hier "pyscript\*") (Join-Path $Ziel "pyscript") -Recurse -Force
Gut "pyscript\ kopiert"
}
# ------------------------------------------------------- 2. data -> audi_dashboard
Schritt "2/5 Datenverzeichnis anlegen (data -> audi_dashboard)"
$datenZiel = Join-Path $Ziel "audi_dashboard"
# Dateien, die einer laufenden Installation gehören - nie überschreiben.
$bestandsdaten = @(
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
"ha_token.txt"
)
if (-not $Pruefen) { New-Item -ItemType Directory -Force -Path $datenZiel | Out-Null }
foreach ($datei in Get-ChildItem (Join-Path $hier "data") -File) {
# fahrzeugprofil.example.json ist die Vorlage und heißt am Ziel anders.
$zielName = $datei.Name
if ($zielName -eq "fahrzeugprofil.example.json") { $zielName = "fahrzeugprofil.json" }
$zielPfad = Join-Path $datenZiel $zielName
if (($bestandsdaten -contains $zielName) -and (Test-Path $zielPfad)) {
Info "$zielName existiert bereits - bleibt unverändert"
continue
New-Item -ItemType Directory -Force -Path (Join-Path $Ziel "custom_components") | Out-Null
if (Test-Path $zielOrdner) {
# Die Prüfung oben hat bestätigt, dass das unser Ordner ist.
Remove-Item $zielOrdner -Recurse -Force
}
if ($Pruefen) {
Info "würde schreiben: $zielPfad"
} else {
Copy-Item $datei.FullName $zielPfad -Force
Gut "$zielName"
Copy-Item $quelle $zielOrdner -Recurse -Force
# Übersetzte Reste einer früheren Fassung gehören nicht ins Ziel.
Get-ChildItem $zielOrdner -Recurse -Directory -Filter "__pycache__" |
ForEach-Object { Remove-Item $_.FullName -Recurse -Force }
# Rücklesen: liegt im Ziel wirklich das, was gemeint war?
$kontrolle = Join-Path $zielOrdner "manifest.json"
if (-not (Test-Path $kontrolle)) {
throw "Nach dem Kopieren fehlt $kontrolle - die Installation ist unvollständig."
}
}
# --------------------------------------------------------------- 3. Frontend
Schritt "3/5 Frontend kopieren"
if ($Pruefen) {
Info "würde kopieren: $hier\www\* -> $Ziel\www\"
} else {
New-Item -ItemType Directory -Force -Path (Join-Path $Ziel "www") | Out-Null
Copy-Item (Join-Path $hier "www\*") (Join-Path $Ziel "www") -Recurse -Force
# Wie in update.ps1: die Versionsdatei bestimmt, ob Browser den neuen Code
# ziehen. Immer frisch schreiben, sonst zeigt ein Browser, der die App
# schon einmal geladen hatte, weiter den alten Stand.
# Zwei Zahlen mit verschiedenen Aufgaben, siehe VERSIONIERUNG.md:
# version = Unix-Sekunden, bricht den Browser-Cache, bei jedem Deploy neu
# app = der Stand aus der Datei VERSION, identifiziert die Fassung
# Die App-Version kommt aus der mitgelieferten Datei und wird ÜBERNOMMEN,
# nicht neu erfunden - sonst verlöre die iOS-App ihren Vergleichswert und
# könnte ihre eigene Veraltung nicht mehr erkennen.
$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$appVersion = ""
$quellVersion = Join-Path $hier "www\audi-dashboard-version.json"
if (Test-Path $quellVersion) {
try { $appVersion = (Get-Content $quellVersion -Raw | ConvertFrom-Json).app } catch { $appVersion = "" }
$geschrieben = (Get-Content $kontrolle -Raw | ConvertFrom-Json).version
if ($geschrieben -ne $version) {
throw "Nach dem Kopieren steht Version '$geschrieben' statt '$version' im Ziel - die Installation ist unvollständig."
}
$versionsDatei = Join-Path $Ziel "www\audi-dashboard-version.json"
$inhaltVersion = if ($appVersion) { "{ ""version"": $version, ""app"": ""$appVersion"" }" }
else { "{ ""version"": $version }" }
[System.IO.File]::WriteAllText($versionsDatei, $inhaltVersion, (New-Object System.Text.UTF8Encoding($false)))
Gut "www\ kopiert (Version $version)"
}
# ---------------------------------------------------- 4. configuration.yaml
Schritt "4/5 configuration.yaml ergänzen"
$markeAuf = "# >>> Mein Audi (DataMetric360) - automatisch eingefügt, nicht von Hand bearbeiten"
$markeZu = "# <<< Mein Audi (DataMetric360)"
# Beide Dateien ausdrücklich als UTF-8 lesen: Get-Content nimmt in
# PowerShell 5.1 sonst die ANSI-Codepage an und macht aus "Ergänzung" ein
# "Ergänzung", das dann so in der configuration.yaml landet.
$utf8OhneBom = New-Object System.Text.UTF8Encoding($false)
$snippet = [System.IO.File]::ReadAllText((Join-Path $hier "configuration_snippet.yaml"), $utf8OhneBom)
$inhalt = [System.IO.File]::ReadAllText($configYaml, $utf8OhneBom)
if ($null -eq $inhalt) { $inhalt = "" }
$block = "$markeAuf`r`n$snippet`r`n$markeZu`r`n"
$schonDa = $inhalt.Contains($markeAuf)
# Top-Level-Schlüssel aus fremder Quelle? Dann Finger weg (siehe Kopfkommentar).
$ohneUnserenBlock = $inhalt
if ($schonDa) {
$ohneUnserenBlock = [regex]::Replace(
$inhalt,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
""
)
}
$fremdePyscript = [regex]::IsMatch($ohneUnserenBlock, "(?m)^pyscript:")
$fremdePanel = [regex]::IsMatch($ohneUnserenBlock, "(?m)^panel_custom:")
if ($fremdePyscript -or $fremdePanel) {
Warnung "configuration.yaml enthält bereits eigene Einträge - sie wird NICHT verändert."
if ($fremdePyscript) { Info "gefunden: pyscript:" }
if ($fremdePanel) { Info "gefunden: panel_custom:" }
Write-Host ""
Write-Host " Bitte von Hand zusammenführen (Inhalt aus configuration_snippet.yaml):" -ForegroundColor Yellow
Write-Host " - unter pyscript: allow_all_imports: true"
Write-Host " - hass_is_global: true"
Write-Host " - unter panel_custom: den Listeneintrag 'audi-dashboard-panel'"
[void]$restliste.Add("configuration.yaml von Hand ergänzen (siehe Hinweis oben) - zwei gleiche Top-Level-Schlüssel wären ungültiges YAML.")
} elseif ($Pruefen) {
if ($schonDa) { Info "würde den vorhandenen Mein-Audi-Block ersetzen" }
else { Info "würde den Mein-Audi-Block anhängen" }
} else {
# Zeitgestempelte Sicherung statt einer festen .bak: die feste Datei wurde
# bei einem zweiten Lauf von der bereits geänderten Fassung überschrieben -
# ausgerechnet das Original, auf das man im Notfall zurückwill, war dann weg.
$sicherung = "$configYaml.bak"
$stempel = Get-Date -Format "yyyyMMdd-HHmmss"
$sicherungZeit = "$configYaml.$stempel.bak"
Copy-Item $configYaml $sicherungZeit -Force
if (-not (Test-Path $sicherung)) { Copy-Item $configYaml $sicherung -Force }
Info "Sicherung: $(Split-Path $sicherungZeit -Leaf)"
if ($schonDa) {
$neu = [regex]::Replace(
$inhalt,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
[System.Text.RegularExpressions.MatchEvaluator]{ param($m) $block }
)
} else {
$trenner = ""
if ($inhalt.Length -gt 0 -and -not $inhalt.EndsWith("`n")) { $trenner = "`r`n" }
$neu = $inhalt + $trenner + "`r`n" + $block
}
# Ohne BOM schreiben - HA liest die Datei als YAML, ein BOM hat da nichts
# zu suchen (Out-File -Encoding utf8 setzt in PowerShell 5.1 eines).
[System.IO.File]::WriteAllText($configYaml, $neu, (New-Object System.Text.UTF8Encoding($false)))
# Rücklesen und prüfen, bevor wir die Datei so stehen lassen. Eine kaputte
# configuration.yaml ist der einzige Weg, auf dem dieses Skript eine
# laufende Home-Assistant-Installation lahmlegen könnte (HA startet dann
# nicht mehr) - deshalb wird das Ergebnis hier verifiziert und im
# Zweifelsfall sofort zurückgerollt, statt es dem Nutzer zu überlassen.
$kontrolle = [System.IO.File]::ReadAllText($configYaml, $utf8OhneBom)
$bestandNoch = [regex]::Replace(
$kontrolle,
"(?ms)" + [regex]::Escape($markeAuf) + ".*?" + [regex]::Escape($markeZu) + "\r?\n?",
""
)
$treffer = ([regex]::Matches($kontrolle, [regex]::Escape($markeAuf))).Count
$heil = $true
$grund = ""
if ($kontrolle.Length -lt $inhalt.Length) {
$heil = $false; $grund = "die Datei ist kürzer geworden als vorher"
} elseif ($treffer -ne 1) {
$heil = $false; $grund = "der Mein-Audi-Block steht $treffer mal statt genau einmal darin"
} elseif ($bestandNoch.Trim() -ne $ohneUnserenBlock.Trim()) {
$heil = $false; $grund = "der bisherige Inhalt der Datei hat sich verändert"
}
if (-not $heil) {
Copy-Item $sicherungZeit $configYaml -Force
Warnung "configuration.yaml wurde zurückgerollt - $grund."
Write-Host " Die Datei ist unverändert wie vor dem Start dieses Skripts." -ForegroundColor Yellow
[void]$restliste.Add("configuration.yaml von Hand ergänzen (Inhalt aus configuration_snippet.yaml) - der automatische Weg wurde aus Sicherheitsgründen zurückgenommen.")
} elseif ($schonDa) {
Gut "vorhandenen Block ersetzt (geprüft)"
} else {
Gut "Block angehängt (geprüft)"
}
}
# --------------------------------------------------------------- 5. Token
Schritt "5/5 Zugriffstoken"
$tokenDatei = Join-Path $datenZiel "ha_token.txt"
if (Test-Path $tokenDatei) {
Info "ha_token.txt existiert bereits - bleibt unverändert"
} elseif ($Token) {
if ($Pruefen) {
Info "würde schreiben: $tokenDatei"
} else {
[System.IO.File]::WriteAllText($tokenDatei, $Token.Trim(), (New-Object System.Text.UTF8Encoding($false)))
Gut "ha_token.txt geschrieben"
}
} else {
Info "kein -Token übergeben"
[void]$restliste.Add("Long-Lived Access Token anlegen (HA: Profil-Avatar unten links -> 'Long-lived access tokens' -> Token erstellen) und den Wert in $tokenDatei speichern - nur den Token, keine Anführungszeichen. Wird fürs Kilometerstand-Screening gebraucht.")
$anzahl = (Get-ChildItem $zielOrdner -Recurse -File).Count
Gut "custom_components\audi_dashboard (Version $version, $anzahl Dateien)"
}
# --------------------------------------------------------------- Restliste
if (-not $vorhandeneVersion) {
[void]$restliste.Add("Home Assistant neu starten: Einstellungen -> System -> Neu starten. (Beim ersten Start ladt HA die Abhaengigkeit pypdf nach - das kann eine Minute dauern.)")
[void]$restliste.Add("Integration hinzufuegen: Einstellungen -> Geraete & Dienste -> Integration hinzufuegen -> 'Audi Dashboard'. Danach steht 'Mein Audi' in der Seitenleiste.")
[void]$restliste.Add("In der App: Einstellungen -> Fahrzeug einrichten -> Setup - Sensoren zuordnen. Im Auslieferstand ist KEIN Sensor vorbelegt; zwingend fuer die Fahrterkennung ist der Zuendungs-/ACC-Sensor (binary_sensor, meist vom Teltonika FMM003). Ein Neustart ist dafuer nicht mehr noetig - Aenderungen wirken sofort.")
} else {
[void]$restliste.Add("Home Assistant neu starten: Einstellungen -> System -> Neu starten. (Oder kuerzer: Einstellungen -> Geraete & Dienste -> Audi Dashboard -> Neu laden.)")
}
# Bewusst nicht automatisch eingefügt: der recorder-Block ist eine Entscheidung
# über den Plattenplatz der Instanz (ein Jahr Fahrzeugverlauf sind grob 1-1,5 GB,
# siehe Kopf von recorder_snippet.yaml), und viele Instanzen haben bereits einen
# eigenen recorder:-Block mit anderen Einstellungen. Zusammenführen ist Handarbeit.
[void]$restliste.Add("ZEITKRITISCH - Datenaufbewahrung: den Block aus recorder_snippet.yaml in die configuration.yaml übernehmen. Home Assistant löscht Sensor-Verläufe sonst nach 10 Tagen; was weg ist, lässt sich auch mit 'Daten importieren aus Home Assistant' nicht mehr nachtragen. Je früher der Block drin ist, desto mehr Vergangenheit bleibt erhalten. VORHER Einstellungen -> System -> Speicher prüfen: ein Jahr Verlauf braucht grob 1-1,5 GB. Bei wenig freiem Platz (SD-Karte, kleine eMMC) mit purge_keep_days: 90 anfangen - Details im Kopf der Datei.")
[void]$restliste.Add("Home Assistant neu starten: Einstellungen -> System -> Neu starten.")
[void]$restliste.Add("Nur für Tankbeleg-Upload: auf der HA-Maschine 'pip install pypdf' ausführen (Terminal-&-SSH-Add-on oder docker exec).")
[void]$restliste.Add("Nach dem Neustart in der App: Einstellungen -> Fahrzeug einrichten -> Setup - Sensoren zuordnen. Im Auslieferstand ist KEIN Sensor vorbelegt; zwingend für die Fahrterkennung ist der Zündungs-/ACC-Sensor (binary_sensor, meist vom Teltonika FMM003). Danach einmal neu starten - die drei trigger-gebundenen Felder (Zündung, Kilometerstand, Tankfüllstand) werden erst dann wirksam.")
[void]$restliste.Add("Optional, sobald die Sensoren zugeordnet sind: Einstellungen -> Einrichten -> 'Daten importieren aus Home Assistant' holt Fahrten, Tankvorgänge und Spannungswerte aus dem bereits aufgezeichneten HA-Verlauf nach.")
[void]$restliste.Add("ZEITKRITISCH - Datenaufbewahrung: den Block aus recorder_snippet.yaml in die configuration.yaml uebernehmen. Home Assistant loescht Sensor-Verlaeufe sonst nach 10 Tagen; was weg ist, laesst sich auch mit 'Daten importieren aus Home Assistant' nicht mehr nachtragen. Je frueher der Block drin ist, desto mehr Vergangenheit bleibt erhalten. VORHER Einstellungen -> System -> Speicher pruefen: ein Jahr Verlauf braucht grob 1-1,5 GB. Bei wenig freiem Platz (SD-Karte, kleine eMMC) mit purge_keep_days: 90 anfangen - Details im Kopf der Datei.")
[void]$restliste.Add("Optional, sobald die Sensoren zugeordnet sind: Einstellungen -> Einrichten -> 'Daten importieren aus Home Assistant' holt Fahrten, Tankvorgaenge und Spannungswerte aus dem bereits aufgezeichneten HA-Verlauf nach.")
Write-Host ""
if ($Pruefen) {
Write-Host " Pruefmodus beendet - es wurde nichts geschrieben." -ForegroundColor Yellow
} else {
Write-Host " Dateien sind kopiert." -ForegroundColor Green
Write-Host " Die Integration ist installiert." -ForegroundColor Green
}
Write-Host ""
Write-Host " Das fehlt noch:" -ForegroundColor White
@@ -364,6 +214,6 @@ foreach ($punkt in $restliste) {
$nr++
}
Write-Host ""
Write-Host " Danach erscheint 'Mein Audi' in der Seitenleiste." -ForegroundColor White
Write-Host " Spätere Updates: update.ps1 statt install.ps1 - das lässt audi_dashboard\ in Ruhe."
Write-Host " Fuer spaetere Updates reicht derselbe Aufruf noch einmal." -ForegroundColor White
Write-Host " Die Fahrzeugdaten unter audi_dashboard\ bleiben dabei unangetastet."
Write-Host ""
@@ -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")
@@ -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)")
@@ -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
<pfad-zur-pdf>`, 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")
@@ -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 <img> 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})")
@@ -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)
@@ -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(<Generator>): 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")
@@ -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()
@@ -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}")
@@ -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"
@@ -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}
@@ -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)
@@ -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()
@@ -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]}"
@@ -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.<satz>.
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.<satz>.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")
@@ -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)"
)
@@ -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/<Zeitstempel>/ 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}")
-102
View File
@@ -1,102 +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/* -> <Ziel>/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/* -> <Ziel>/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 (\\<HA-IP>\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 = identifiziert die Fassung; wird aus der mitgelieferten Datei
# übernommen, nicht neu erfunden
# An der App-Version erkennt die iOS-App, ob sie noch dem ausgelieferten Stand
# entspricht. Ginge sie hier verloren, wäre der Vergleich still abgeschaltet.
$version = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$appVersion = ""
if (Test-Path "$hier\www\audi-dashboard-version.json") {
try { $appVersion = (Get-Content "$hier\www\audi-dashboard-version.json" -Raw | ConvertFrom-Json).app } catch { $appVersion = "" }
}
$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."
@@ -1,47 +0,0 @@
/* ================================================================
Audi-Dashboard Lade-Stub für panel_custom
================================================================
Diese Datei selbst ändert sich praktisch nie und bleibt deshalb auch mit
der langen Standard-Cache-Zeit von Home Assistant für /local/ (31 Tage,
Cache-Control: public, max-age=2678400 - an der Testinstanz nachgemessen,
nicht vermutet) unproblematisch: sie lädt bei jedem vollständigen
Seitenaufruf zuerst audi-dashboard-version.json mit cache:"no-store" -
das erzwingt eine echte Netzwerkanfrage, unabhängig vom HTTP-Cache -, und
reicht deren Versionsnummer als Query-Parameter an den eigentlichen
Code (audi-dashboard-app.js) weiter. Jede neue Versionsnummer ist aus
Sicht des Browsers eine neue URL und wird deshalb nie aus einem alten
Cache bedient.
Update-Ablauf ohne Neustart und ohne Cache-Probleme:
1. audi-dashboard-app.js und/oder audi-dashboard.css ersetzen
2. die Zahl in audi-dashboard-version.json um 1 erhöhen
3. im Browser einmal ganz normal neu laden (kein Hard-Refresh nötig)
Diese Datei hier (audi-dashboard-panel.js) bleibt dabei unangetastet -
configuration.yaml und damit ein HA-Neustart sind für Frontend-Updates
nicht mehr nötig, nur noch für den allerersten Einbau (§10 Punkt 1).
Bekannte HA-Eigenheit (an der Testinstanz reproduziert, nicht vermutet):
ha-panel-custom cached geladene Skripte nur für den alten js_url-Typ,
nicht für module_url (siehe dessen eigener Quellcode - für "js" gibt es
ein url-indiziertes Cache-Objekt, für "module" fehlt das komplett). Bei
einem direkten/harten Seitenaufruf (nicht bei SPA-interner Navigation)
kann das dazu führen, dass dieses Skript zweimal als <script type=module>
eingefügt wird, bevor das erste fertig geladen hat - beobachtet als zwei
Netzwerk-Requests für diese Datei und ein hängenbleibendes "Lädt …" ohne
Fehler in der eigentlichen App. Der Cache unten macht starten() robust
gegen so einen doppelten Aufruf: der zweite Aufruf wartet einfach auf das
Ergebnis des ersten, statt selbst nochmal zu fetchen/zu importieren. */
window.__audiDashboardStarten =
window.__audiDashboardStarten ||
(async () => {
let version = Date.now(); // Fallback, falls version.json nicht erreichbar ist
try {
const antwort = await fetch("/local/audi-dashboard-version.json", { cache: "no-store" });
const daten = await antwort.json();
version = daten.version;
} catch (e) {
console.warn("audi_dashboard: version.json nicht erreichbar, nutze Zeitstempel als Cache-Buster", e);
}
await import(`/local/audi-dashboard-app.js?v=${version}`);
})();
await window.__audiDashboardStarten;
@@ -1 +0,0 @@
{"version": 1787016000, "app": "2026.08.23.1"}
@@ -1,115 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DataMetric360 — Gerät verbinden</title>
<!--
Erzeugt den Einrichtungs-QR-Code für die App, vollständig auf dem Gerät.
Der Punkt dabei: Ein QR-Generator im Internet bekäme den Zugangstoken zu
sehen. Diese Seite lädt ihre Bibliothek aus dem eigenen Home Assistant
(dm360-qrcode-lib.js daneben) und stellt keine einzige Netzanfrage — der
Token verlässt das eigene Netz nicht.
Ablegen unter /config/www/, aufrufbar als /local/dm360-qr.html.
Gegenstück in der App: UMSETZUNGSPLAN.md Phase 10 Stufe 2.
-->
<script src="./dm360-qrcode-lib.js"></script>
<style>
:root { color-scheme: dark; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #161b23; color: #fff;
font: 400 15px/1.55 Helvetica, Arial, sans-serif;
}
main { width: min(420px, 92vw); padding: 24px; }
h1 { font-size: 22px; font-weight: 300; margin: 0 0 6px; }
p { color: #9aa1ad; font-size: 13.5px; margin: 0 0 18px; text-wrap: pretty; }
label { display: block; font-size: 10px; letter-spacing: .2em;
text-transform: uppercase; color: #8a94a3; margin: 14px 0 6px; }
input {
width: 100%; box-sizing: border-box; padding: 11px 12px; font-size: 16px;
background: #1f2733; color: #fff; border: 1px solid rgba(255,255,255,.1);
border-radius: 8px; outline: none;
}
input:focus-visible { outline: 2px solid #F50537; outline-offset: 2px; }
button {
margin-top: 18px; width: 100%; padding: 13px; font-size: 15px;
background: #fff; color: #000; border: 0; border-radius: 999px; cursor: pointer;
}
#ausgabe { margin-top: 22px; display: none; text-align: center; }
#ausgabe.sichtbar { display: block; }
#ausgabe img { width: 100%; max-width: 320px; height: auto;
image-rendering: pixelated; background: #fff;
padding: 12px; border-radius: 12px; box-sizing: border-box; }
.warnung { color: #ffaa00; font-size: 12px; margin-top: 14px; }
.fehler { color: #fd2c4e; font-size: 13px; margin-top: 14px; }
</style>
</head>
<body>
<main>
<h1>Gerät verbinden</h1>
<p>
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.
</p>
<label for="adresse">Server-Adresse</label>
<input id="adresse" type="url" placeholder="https://api.datametric360.app" />
<label for="token">Zugriffstoken</label>
<input id="token" type="password" placeholder="in Home Assistant unter Profil → Sicherheit" />
<button id="erzeugen" type="button">Code anzeigen</button>
<div id="ausgabe"></div>
<p id="meldung" class="fehler" hidden></p>
</main>
<script>
const ausgabe = document.getElementById("ausgabe")
const meldung = document.getElementById("meldung")
function melde(text) {
meldung.textContent = text
meldung.hidden = !text
}
document.getElementById("erzeugen").addEventListener("click", () => {
melde("")
ausgabe.classList.remove("sichtbar")
const url = document.getElementById("adresse").value.trim().replace(/\/+$/, "")
const token = document.getElementById("token").value.trim()
if (!url || !token) {
melde("Bitte Adresse und Token eintragen.")
return
}
const inhalt = JSON.stringify({ url, token })
try {
// Version 0 heißt: kleinstmögliche Version selbst wählen.
// Fehlerkorrektur L, weil der Code direkt vom Bildschirm abgelesen
// wird — es gibt keinen zerkratzten Aufkleber zu retten.
const qr = qrcode(0, "L")
qr.addData(inhalt)
qr.make()
ausgabe.innerHTML = qr.createImgTag(6, 0, "Einrichtungs-Code")
ausgabe.insertAdjacentHTML(
"beforeend",
'<p class="warnung">Wer diesen Code abfotografiert, hat vollen Zugriff auf die ' +
"Schnittstelle. Nach dem Einrichten die Seite schließen.</p>",
)
ausgabe.classList.add("sichtbar")
} catch (fehler) {
melde(
"Der Code konnte nicht erzeugt werden: " +
String((fehler && fehler.message) || fehler) +
". Ist der Token vollständig eingefügt?",
)
}
})
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-113
View File
@@ -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")
-62
View File
@@ -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)")
-275
View File
@@ -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
<pfad-zur-pdf>`, 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")
@@ -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 <img> 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})")
-31
View File
@@ -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)
-244
View File
@@ -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(<Generator>): 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")

Some files were not shown because too many files have changed in this diff Show More