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>
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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={})
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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)." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Audi Dashboard",
|
||||
"homeassistant": "2025.1.0",
|
||||
"render_readme": true
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. 30–40 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 1–1,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 1–1,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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()
|
||||
@@ -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."
|
||||
@@ -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 1–3 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 1–1,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 1–1,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
|
||||
1–1,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.
|
||||
|
||||
@@ -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 700–6 700 min⁻¹"],
|
||||
["Maximales Drehmoment", "600 Nm bei 2 000–5 000 min⁻¹"],
|
||||
["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"],
|
||||
["Abgasnorm", "EU6 AP"],
|
||||
["Motormanagement", "vollelektronisch"]
|
||||
]},
|
||||
{ "gruppe": "Kraftübertragung", "posten": [
|
||||
["Getriebe", "8-Gang tiptronic"],
|
||||
["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"],
|
||||
["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"]
|
||||
]},
|
||||
{ "gruppe": "Fahrleistungen", "posten": [
|
||||
["0 auf 100 km/h", "3,9 s"],
|
||||
["Höchstgeschwindigkeit", "290 km/h"],
|
||||
["Effizienzklasse", "E"],
|
||||
["Fahrgeräusch", "70 dB(A)"]
|
||||
]},
|
||||
{ "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [
|
||||
["Fahrwerk", "RS-Sportfahrwerk"],
|
||||
["Vorderachse", "5-Lenker, Rohrstabilisator"],
|
||||
["Hinterachse", "5-Lenker, Rohrstabilisator"],
|
||||
["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"],
|
||||
["Wendekreis", "ca. 11,7 m"],
|
||||
["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"],
|
||||
["Vorn", "Faustsattel, Scheibe innenbelüftet"],
|
||||
["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"]
|
||||
]},
|
||||
{ "gruppe": "Räder und Reifen", "posten": [
|
||||
["Räder", "Leichtmetall 9J × 20, Audi Sport"],
|
||||
["Reifen", "275/30 R20 97Y XL"],
|
||||
["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"],
|
||||
["Radschrauben", "Standard"],
|
||||
["Anzugsmoment", "120 Nm"]
|
||||
]},
|
||||
{ "gruppe": "Maße, Gewichte, Volumen", "posten": [
|
||||
["Leergewicht", "1 820 kg"],
|
||||
["Zulässiges Gesamtgewicht", "2 350 kg"],
|
||||
["Dachlast / Stützlast", "90 / 80 kg"],
|
||||
["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"],
|
||||
["Anhängelast ungebremst", "750 kg"],
|
||||
["Gepäckraum", "495 / 1 495 l"],
|
||||
["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"]
|
||||
]},
|
||||
{ "gruppe": "Bordnetz", "posten": [
|
||||
["Batterie", "420 A, 75 Ah"],
|
||||
["Generator", "100–150 A"],
|
||||
["Kältemittel", "R1234yf"]
|
||||
]},
|
||||
{ "gruppe": "Wartung", "posten": [
|
||||
["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"],
|
||||
["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"]
|
||||
]}
|
||||
],
|
||||
|
||||
"ausstattung": [
|
||||
{ "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] },
|
||||
{ "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] },
|
||||
{ "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] },
|
||||
{ "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] },
|
||||
{ "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] },
|
||||
{ "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] }
|
||||
]
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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>
|
||||
@@ -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")
|
||||