Ölwechsel/Inspektion-Anzeige repariert, Selbst-Update der Integration gebaut
Zwei getrennte Themen in einem Commit, beide in derselben Sitzung entstanden:
1. Ölwechsel/Inspektion wurden komplett ausgeblendet ("kein Eintrag im
Servicebuch"), sobald kein Servicebucheintrag vorlag - selbst wenn der
zugeordnete Sensor eine gültige Fälligkeit meldete. In beiden Frontends
prüfte die Anzeige nur den Servicebuch-Zweig, bevor sie die
Fahrzeugmeldung überhaupt las. Jetzt steht die Fahrzeugmeldung für sich;
fehlt zusätzlich ein Servicebucheintrag, übernimmt eine neue,
fahrtenlog-basierte Prognose (kmProTagAusFahrten()/meldungsPrognose())
die Hochrechnung statt der Servicebuch-Rate - deckelt auf die vom
Fahrzeug selbst gemeldete Zeitgrenze, falls zu wenig gefahren wird.
2. install.ps1 als Update-Weg wird von Windows Smart App Control blockiert,
ohne Umgehungsmöglichkeit. Die Integration lädt sich jetzt auf
Tastendruck selbst von Gitea (aktualisierung.py), verifiziert das
Manifest vor jedem Tausch und tauscht per os.rename mit automatischem
Rollback bei Fehlern - install.ps1 bleibt nur noch für die
Erstinstallation nötig. Zugangstoken über einen neuen OptionsFlow in
entry.options, nie in configuration.yaml.
Nebenbei: mehrere seit der HACS-Ausschluss-Entscheidung liegen gebliebene
falsche HACS-Referenzen in Code-Kommentaren und einem UI-Text korrigiert.
Details, Sicherheitsbegründung und Verifikationsstand in AGENTS.md,
Abschnitte I und J.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1926,6 +1926,30 @@ Gitea-vs-mirrored-GitHub changes it: HACS refuses private repos regardless of wh
|
||||
mirrored, and the private/public constraint (licensed Audi assets in the repo) isn't going away.
|
||||
`install.ps1` is the permanent, only install path, not a stand-in.
|
||||
|
||||
**Stale HACS claims found and fixed in code comments/UI text (2026-08-24).** The docs-level cleanup
|
||||
above happened the same day as this section was written, but a full-repo grep afterward (prompted by
|
||||
the owner spotting live UI text still claiming "Updates laufen über HACS") turned up leftovers the
|
||||
first pass missed, because it focused on the standalone docs (`README.md`, `INSTALL.md`, etc.) and
|
||||
not source comments or UI strings. Fixed: the panel's Einstellungen → Version tile literally told the
|
||||
user HACS would notice and install updates itself — replaced with the actual mechanism
|
||||
(`install.ps1`, panel ships inside the integration and can't go stale on its own). Also fixed,
|
||||
all now pointing at `install.ps1`/"jeder Auslieferung" instead of "HACS"/"HACS-Update": comments in
|
||||
`audi-dashboard-app.js` (the version-tracking block and the retired-updater history note),
|
||||
`companion-app/vite.config.ts` (manifest-version header comment), `const.py` (`STATIK_URL`,
|
||||
`BUENDEL_ORDNER`, `BILDER_ORDNER` comments), `bilder.py` (module docstring), `__init__.py`
|
||||
(`cache_headers=False` rationale), and a diagram branch in `VERSIONIERUNG.md` that stated "HACS
|
||||
vergleicht sie gegen das Repository und meldet Updates" as flat fact, contradicting the boxed note
|
||||
one screen above it. Left alone: `install.ps1`'s own header (already correctly explains the
|
||||
exclusion), `ota-paket.ps1`'s comment (already correct), and one debugging note in
|
||||
`audi-dashboard-app.js` (~line 4720) that recalls what was actually running in a specific test
|
||||
container during a past incident — a historical fact about that container, not a claim about how
|
||||
this project works, so not in scope for this cleanup. Also out of scope, a different topic entirely:
|
||||
mentions of the *retired vehicle-data-source* HACS integration `TommiG1/HA_VAG-EU-Data-Act` in older
|
||||
planning docs (`SPECIFICATION.md`, `COMPANION_APP_ARCHITECTURE.md`, `bauauftrag.md/html`,
|
||||
`testumgebung/`) — that HACS integration is unrelated to whether HACS can install *this* app; it was
|
||||
FMM003's predecessor as a data source, already documented as retired elsewhere in those same files.
|
||||
Manifest bumped to `2026.8.24.3` and `npm run ota` rerun (the panel text change is user-visible).
|
||||
|
||||
### 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
|
||||
@@ -2131,6 +2155,147 @@ was 1.00:1, white on white. Both pills now follow the app's own button language
|
||||
as a button fill. No companion-app port: the location sheet's share/route pills do not exist there,
|
||||
which the parity rule exempts as panel-only.
|
||||
|
||||
### I) Ölwechsel/Inspektion could vanish entirely with a mapped sensor and no Servicebuch (found and fixed 2026-08-24)
|
||||
|
||||
Owner reported: "no inspektion and ölwechsel shown even if there is the right sensor and
|
||||
herstellervorgabe." Both were true — sensor mapped correctly, "Herstellervorgabe" selected — and the
|
||||
row still just said "kein Eintrag im Servicebuch" (no Servicebuch entry).
|
||||
|
||||
**Root cause, identical in both codebases.** The Ölwechsel/Inspektion display combines two
|
||||
independent sources on purpose (see the header comment in `companion-app/src/screens/Service.tsx`):
|
||||
the vehicle's own reported due date/km (`fahrzeugMeldungRoh()` / `fahrzeug.oelwechselFaelligTs` etc.,
|
||||
sourced from the mapped HA sensor) is meant to stand *alongside* the app's own Servicebuch-based
|
||||
forecast, not depend on it. But the actual gating checked only the Servicebuch side:
|
||||
- Panel (`frontend/audi-dashboard-app.js`, `vService()`): `const iv = intervalle(t); if (!iv) return
|
||||
...„kein Eintrag im Servicebuch"...` — and `intervalle()` returns `null` whenever there's no
|
||||
Servicebuch basis, full stop. The vehicle-reported `fm`/`hatMeldung` was computed *after* this early
|
||||
return, so a mapped sensor with valid data never even got read for a fahrzeug with an empty
|
||||
Servicebuch — exactly the state of a freshly set-up car.
|
||||
- companion-app (`src/screens/Service.tsx`): same shape, `{oelHatEintrag ? ... : "kein Eintrag im
|
||||
Servicebuch"}` — gated purely on a Servicebuch entry existing, ignoring `oelHatMeldung`/
|
||||
`inspHatMeldung` computed one line above it. `src/daten/service.ts`'s `naechsterService()` (feeds
|
||||
the Home-screen "Nächster Service" tile) had the same gap one level up: it only ever built
|
||||
candidates from `oelwechselPrognose()`/`inspektionPrognose()` (both Servicebuch-only), never
|
||||
falling back to `fahrzeug.oelwechselFaelligTs`/`inspektionFaelligTs`.
|
||||
|
||||
**Fixed by reordering, not by adding a new data path** — the sensor data was already being read
|
||||
correctly by the backend and delivered to both frontends; only the display-gating ignored it.
|
||||
Panel: `fm`/`hatMeldung` now computed *before* the early-return check, which now reads `if (!iv &&
|
||||
!hatMeldung)`; `anzeige`/`herstellervorgabe` guarded for `iv` possibly being `null` (both remain safe
|
||||
because the code paths that dereference `iv` only run when `hatMeldung` is false, and the new guard
|
||||
guarantees `iv` is non-null whenever that's the case). companion-app: `oelHatEintrag ||
|
||||
oelHatMeldung` / `inspHatEintrag || inspHatMeldung` as the render condition; `naechsterService()`
|
||||
gained an `else if (fahrzeug.oelwechselFaelligTs)` / `...inspektionFaelligTs` fallback branch per
|
||||
service type, added only when the Servicebuch-based prognose is `null` — a real Servicebuch entry
|
||||
still wins when both exist (own forecast is finer-grained, factoring actual driven km/day). The
|
||||
Hauptuntersuchung path is unaffected either way: `fahrzeugMeldungRoh("Hauptuntersuchung")` always
|
||||
returns `{ts: null, km: null}`, so `hatMeldung` is always `false` for it — same behavior as before.
|
||||
|
||||
Verified: `tsc --noEmit` clean, all 120 companion-app tests pass (including 3 new regression tests in
|
||||
`service.test.ts` covering the meldung-only fallback for both service types and confirming a real
|
||||
Servicebuch prognose still takes priority when both are present), panel JS re-checked with `node
|
||||
--check`. `manifest.json` bumped to `2026.8.24.2` and `npm run ota` rerun (own rule from section H,
|
||||
enforced by `install.ps1` since the same day) since this touched both the panel and the companion-app.
|
||||
|
||||
### J) Self-update: the integration pulls its own updates from Gitea (built 2026-08-24)
|
||||
|
||||
**Why.** `install.ps1` (section H) stopped being a reliable update path: Windows Smart App Control
|
||||
blocks its execution, and unlike SmartScreen there is no "run anyway" override — once fully
|
||||
enabled, Smart App Control can only be turned off by reinstalling Windows. The owner's actual daily
|
||||
pain wasn't the block itself but the whole cycle it forced: open Gitea, download a zip, unpack it,
|
||||
run the (now-blocked) installer, re-enter the Samba password every time (see section H's Samba-auth
|
||||
fix earlier the same day). Owner chose, from three options laid out (free workarounds first / code-sign
|
||||
the installer / have Home Assistant pull its own updates), the third: **the integration now updates
|
||||
itself.** `install.ps1` remains necessary for the *first* install only — the integration has to
|
||||
already be running before it can update itself — which is the rare case; the daily-update tedium is
|
||||
what this closes.
|
||||
|
||||
**Safety design — stricter than install.ps1, because it runs inside what it replaces.**
|
||||
`install.ps1` is an external process against a foreign HA instance; this feature's code lives
|
||||
*inside* `custom_components/audi_dashboard/` and replaces that very folder while its own service
|
||||
call is still executing. `aktualisierung.py`'s module docstring carries the full reasoning; the
|
||||
shape:
|
||||
1. Download and verification happen entirely in a sibling staging folder
|
||||
(`custom_components/audi_dashboard_update_staging/`) — the live folder is never touched until the
|
||||
download is confirmed complete and plausible.
|
||||
2. The swap is `os.rename`, not delete-then-copy: the live folder is renamed to
|
||||
`audi_dashboard_backup` (kept, not deleted — reversible) before the staging folder is renamed into
|
||||
its place. If the second rename fails, the first is undone automatically rather than leaving a
|
||||
half-replaced folder.
|
||||
3. The downloaded `manifest.json` is checked for `domain == "audi_dashboard"` and a `version` field
|
||||
before either rename runs — matches install.ps1's own "won't touch a folder that isn't
|
||||
provably ours" rule (section G/H).
|
||||
4. **No automatic reload or restart.** A reload triggered from inside the same service call that's
|
||||
still running would risk being interrupted by its own reload. The UI shows a "restart Home
|
||||
Assistant" message instead — the same pattern install.ps1's Restliste already uses.
|
||||
5. Nothing outside `custom_components/` is touched — same invariant as install.ps1.
|
||||
6. The check step (a Gitea network call) runs only on explicit button press, never on the
|
||||
coordinator's normal publish tick — same "no unsolicited background action" stance as the OTA
|
||||
`autoUpdate: false` decision.
|
||||
|
||||
Renaming the very folder the running code was imported from is safe on Linux (this project runs on
|
||||
Home Assistant OS / Docker): Python holds no open file handles on `.py` sources after import
|
||||
completes, and `os.rename` only operates on path strings, not live handles.
|
||||
|
||||
**Token storage.** No prior mechanism existed for secrets in this integration — `config_flow.py`'s
|
||||
`entry.data` was always `{}`, no `OptionsFlow` existed. Added `AudiDashboardOptionsFlow`
|
||||
(Settings → Devices & Services → Audi Dashboard → Configure), one masked field
|
||||
(`CONF_GITEA_TOKEN` in `const.py`), stored in `entry.options` — HA's own encrypted config-entry
|
||||
store, never `configuration.yaml`. A read-only-scoped Gitea token is enough; the form text says so.
|
||||
|
||||
**What was built:**
|
||||
- `custom_components/audi_dashboard/aktualisierung.py` — `version_pruefen()` (one lightweight Gitea
|
||||
Contents-API call for just `manifest.json`, no repo download) and `update_installieren()`
|
||||
(downloads the full repo archive via Gitea's archive endpoint — Gitea has no single-folder
|
||||
download — delegates to the blocking `entpacken_pruefen_tauschen()` via
|
||||
`hass.async_add_executor_job`). The blocking function takes all paths as parameters rather than
|
||||
reading module constants directly, specifically so it's testable against a temp directory without
|
||||
touching the real integration folder.
|
||||
- Two new services (`update_pruefen`, `update_installieren`), registered in `dienste.py` following
|
||||
the existing `SCHEMATA`/`behandler`/`_als_dienst()` pattern; both zero-field, both catch
|
||||
`aktualisierung.AktualisierungsFehler` and turn it into a user-readable message instead of an
|
||||
unhandled exception.
|
||||
- `koordinator.py`: `alles_veroeffentlichen()`'s `E_APP_VERSION` publish was factored out into its
|
||||
own `app_version_veroeffentlichen()` method and extended with `integration_update`. This holds the
|
||||
*last* check/install result (`self.letzte_update_pruefung`) — deliberately **not** recomputed on
|
||||
every publish tick, only ever set by the two new service handlers, so normal operation triggers zero
|
||||
Gitea calls.
|
||||
- Both frontends got a new "Integration-Update" tile (companion-app: `Einstellungen.tsx` +
|
||||
`IntegrationUpdateAngabe` type in `api/types.ts` + `integrationUpdate` in `DatenKontext.tsx`; panel:
|
||||
new tile next to the existing Version tile in `audi-dashboard-app.js`, backed by a new
|
||||
`INTEGRATION_UPDATE` global read from `daten.integration_update`, wired to the existing
|
||||
`data-*`/`serviceRufen()`/`ereignisseVerdrahten()` click-dispatch pattern) — not an extension of the
|
||||
existing OTA "App-Update" tile, which is a different concept (the Capacitor iOS shell going stale)
|
||||
that doesn't apply to a browser-rendered panel.
|
||||
|
||||
**Verified, honestly scoped by what this session's sandbox could actually run:**
|
||||
- The safety-critical pure logic (`entpacken_pruefen_tauschen()`) has 7 unit tests
|
||||
(`tests/aktualisierung/test_aktualisierung.py`, `unittest`, same style as
|
||||
`tests/belegparser/test_shell_beleg_parser.py`) covering: successful swap with the old version
|
||||
preserved in the backup folder, `__pycache__` cleanup, wrong domain rejected, missing `version`
|
||||
field rejected, missing `manifest.json` rejected, corrupt zip rejected, archive missing the
|
||||
expected path rejected — and every rejection case asserts the live folder is byte-for-byte
|
||||
untouched. Run inside the project's existing throwaway `ghcr.io/home-assistant/home-assistant:stable`
|
||||
test container (`audi_ha_test`) via `docker exec`, since no Python interpreter exists in the
|
||||
session sandbox itself.
|
||||
- The complete integration (config flow, both services, koordinator changes, both frontends) was
|
||||
deployed into that same container and restarted from a clean state, twice, ending on
|
||||
`Audi Dashboard 2026.8.24.4 eingerichtet` with zero errors or tracebacks — confirms the
|
||||
`OptionsFlow`/service registration/coordinator wiring is structurally correct against a real,
|
||||
current (2026.8.0) Home Assistant.
|
||||
- companion-app: `tsc --noEmit` clean, all 127 tests pass.
|
||||
- **Not verified**: an actual successful download+swap against the real private Gitea repo, since
|
||||
that needs the owner's own token — entering it is the owner's action via the HA UI, not something
|
||||
to hand to an assistant in chat. The failure paths (missing token, bad token, unreachable Gitea) are
|
||||
covered by the unit tests and code review, not by a live call against Gitea. First real end-to-end
|
||||
use is the owner's to do once a token is configured — worth a first careful try on the test
|
||||
container before the real instance, same caution as every other install.ps1-era change in this
|
||||
project.
|
||||
|
||||
**Docs updated to stop calling install.ps1 "the only way":** `README.md`, `VERSIONIERUNG.md` — both
|
||||
now say install.ps1 is the only way *to install*; the self-update is now the preferred way *to
|
||||
update* an already-running instance.
|
||||
|
||||
---
|
||||
|
||||
## Working conventions (observed — keep them)
|
||||
|
||||
@@ -26,8 +26,18 @@ Audi-Hausschrift und die Typenschilder, die nur für diese eine private
|
||||
Installation lizenziert sind.
|
||||
|
||||
Der Installer unten ist deshalb nicht die zweite Wahl, sondern der einzige
|
||||
Weg. Er ist genauso wenig Handarbeit wie ein HACS-Update — derselbe Aufruf
|
||||
installiert und aktualisiert.
|
||||
Weg für die **Erstinstallation** — die Integration muss laufen, bevor sie
|
||||
sich selbst aktualisieren kann.
|
||||
|
||||
Für **Updates danach** gibt es seit 2026-08-24 einen zweiten, bevorzugten
|
||||
Weg: die Integration lädt ihre neueste Fassung direkt von Gitea und ersetzt
|
||||
sich selbst (Einstellungen → Geräte & Dienste → Audi Dashboard →
|
||||
Konfigurieren, dort ein Gitea-Zugriffstoken eintragen; danach in der App
|
||||
unter Einstellungen → Integration-Update). Grund: Windows Smart App Control
|
||||
blockiert die Ausführung von `install.ps1` zuverlässig und lässt sich —
|
||||
anders als der SmartScreen davor — nicht per „trotzdem ausführen" umgehen.
|
||||
Der Installer unten bleibt der Weg für die Erstinstallation und für den
|
||||
seltenen Fall, dass das Selbst-Update selbst nicht mehr erreichbar ist.
|
||||
|
||||
Von einem Windows-Rechner aus, der das `config`-Verzeichnis der HA-Instanz
|
||||
erreicht (Samba-Add-on oder gemounteter Pfad):
|
||||
|
||||
+12
-6
@@ -55,7 +55,6 @@ Vergleich still falsch geworden statt laut.
|
||||
manifest.json { "version": "2026.8.23.2" }
|
||||
│
|
||||
├─► Home Assistant führt sie als Version der Integration
|
||||
│ └─► HACS vergleicht sie gegen das Repository und meldet Updates
|
||||
│
|
||||
├─► die Integration veröffentlicht sie als
|
||||
│ sensor.audi_dashboard_app_version
|
||||
@@ -97,7 +96,12 @@ ohne Netz darf keinen Fehlalarm auslösen.
|
||||
Bündel) — ein Widerspruch, aus dem es ohne diesen Schritt keinen Ausweg
|
||||
gibt. `install.ps1` prüft das seit 2026-08-24 selbst und warnt, falls
|
||||
vergessen — aber besser, es passiert gar nicht erst.
|
||||
3. Integration ausliefern: `homeassistant\installationspaket\Installieren.cmd`
|
||||
3. Integration ausliefern: nach `git push` entweder in der App selbst unter
|
||||
Einstellungen → Integration-Update auf „Auf Update prüfen" → „Update
|
||||
installieren" (seit 2026-08-24, ersetzt install.ps1 als laufenden
|
||||
Update-Weg — Windows Smart App Control blockiert dessen Ausführung
|
||||
zuverlässig), danach Home Assistant neu starten; oder weiterhin
|
||||
`homeassistant\installationspaket\Installieren.cmd` von Hand
|
||||
(HACS scheidet aus — siehe Kasten oben, das Repository ist privat).
|
||||
4. iOS-App für Xcode neu bauen (`npm run build`), damit eine über App Store
|
||||
Connect / Sideload signierte Fassung dieselbe Zahl einkompiliert bekommt.
|
||||
@@ -148,7 +152,9 @@ Fahrteintragung ungefragt die Oberfläche austauscht, wäre die falsche Sorte
|
||||
Hilfsbereitschaft.
|
||||
|
||||
**HACS spielt hier keine Rolle und wird es auch nicht.** Das Update-Bündel
|
||||
reist mit `install.ps1` (dem einzigen Installationsweg, siehe oben) genauso
|
||||
mit wie mit einem hypothetischen HACS-Download — der Mechanismus hängt nicht
|
||||
daran, wie die Integration selbst auf die Instanz kommt, nur daran, dass das
|
||||
Bündel im Integrationsordner liegt.
|
||||
reist mit jedem Weg, der die Integration ausliefert, automatisch mit —
|
||||
`install.ps1` für die Erstinstallation ebenso wie das Selbst-Update seit
|
||||
2026-08-24 (aktualisierung.py, ersetzt install.ps1 als laufenden Update-Weg,
|
||||
siehe Abschnitt J in AGENTS.md) — der Mechanismus hängt nicht daran, wie die
|
||||
Integration selbst auf die Instanz kommt, nur daran, dass das Bündel im
|
||||
Integrationsordner liegt.
|
||||
|
||||
@@ -197,6 +197,26 @@ export class DataMetricApi {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Selbst-Update der
|
||||
Integration (aktualisierung.py) - Ersatz für install.ps1, das Windows
|
||||
Smart App Control blockiert. Bewusst NICHT über die Warteschlange, wie
|
||||
historieImportieren() oben: beides sind ausdrückliche, sofortige
|
||||
Aktionen, kein Zustand, den man im Funkloch absetzt und später
|
||||
nachgeholt haben will. Ergebnis kommt über
|
||||
appVersionAngabeLesen()/daten.integration_update zurück, nicht über den
|
||||
Rückgabewert dieser Aufrufe. */
|
||||
|
||||
/** Fragt nur nach, ob eine neuere Fassung vorliegt - lädt nichts herunter. */
|
||||
updatePruefen(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_pruefen");
|
||||
}
|
||||
|
||||
/** Lädt die neueste Fassung und ersetzt den Integrationsordner. Home
|
||||
Assistant muss danach neu gestartet werden. */
|
||||
updateInstallieren(): Promise<unknown> {
|
||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "update_installieren");
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Import aus dem HA-Verlauf
|
||||
Bewusst NICHT über die Warteschlange, anders als die übrigen
|
||||
schreibenden Vorgänge: der Import ist keine Eingabe, die man im Funkloch
|
||||
|
||||
@@ -172,10 +172,26 @@ export interface Buendelangabe {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Ergebnis der letzten "Auf Update prüfen"/"Update installieren"-Aktion
|
||||
(custom_components/audi_dashboard/aktualisierung.py, dienste.py). `null`,
|
||||
solange noch nie geprüft wurde - ein normaler Zustand, kein Fehler.
|
||||
Ersetzt install.ps1 als Update-Weg, weil Windows Smart App Control es
|
||||
zuverlässig blockiert. */
|
||||
export interface IntegrationUpdateAngabe {
|
||||
verfuegbar: boolean;
|
||||
version: string | null;
|
||||
geprueft_am: string;
|
||||
/** true unmittelbar nach einer erfolgreichen Installation - dann ist ein
|
||||
Neustart von Home Assistant nötig, damit die neue Fassung geladen wird. */
|
||||
installiert: boolean;
|
||||
fehler: string | null;
|
||||
}
|
||||
|
||||
/** Nutzlast von sensor.audi_dashboard_app_version. */
|
||||
export interface AppVersionAngabe {
|
||||
app: string | null;
|
||||
buendel: Buendelangabe | null;
|
||||
integration_update: IntegrationUpdateAngabe | null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- Entitäts-Verzeichnis */
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type Buendelangabe,
|
||||
type Fahrt,
|
||||
type Fahrzeugstatus,
|
||||
type IntegrationUpdateAngabe,
|
||||
type Profil,
|
||||
type Tankvorgang,
|
||||
type Verbindungszustand,
|
||||
@@ -50,6 +51,9 @@ export interface DatenWert {
|
||||
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
|
||||
Verbindung. */
|
||||
otaBuendel: Buendelangabe | null
|
||||
/** Ergebnis der letzten Selbst-Update-Prüfung/-Installation der
|
||||
Integration (aktualisierung.py), oder null vor der ersten Prüfung. */
|
||||
integrationUpdate: IntegrationUpdateAngabe | null
|
||||
|
||||
einstellungen: Einstellungen | null
|
||||
fahrzeug: Fahrzeug | null
|
||||
@@ -93,6 +97,9 @@ export function DatenAnbieter({
|
||||
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
|
||||
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
|
||||
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
|
||||
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
|
||||
// wenn zwischendurch ein Push hereinkam.
|
||||
@@ -121,6 +128,7 @@ export function DatenAnbieter({
|
||||
const versionsangabe = await api.appVersionAngabeLesen()
|
||||
setzeServerVersion(versionsangabe?.app ?? null)
|
||||
setzeOtaBuendel(versionsangabe?.buendel ?? null)
|
||||
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
|
||||
} catch (fehler) {
|
||||
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
|
||||
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
|
||||
@@ -219,6 +227,7 @@ export function DatenAnbieter({
|
||||
statusStand,
|
||||
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
|
||||
otaBuendel,
|
||||
integrationUpdate,
|
||||
einstellungen,
|
||||
fahrzeug,
|
||||
fahrten,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { inspektionPrognose, letzterOelwechsel, naechsterService, oelwechselPrognose } from "./service"
|
||||
import {
|
||||
inspektionPrognose,
|
||||
kmProTagAusFahrten,
|
||||
letzterOelwechsel,
|
||||
meldungsPrognose,
|
||||
naechsterService,
|
||||
oelwechselPrognose,
|
||||
} from "./service"
|
||||
import type { Fahrzeug } from "./profilAdapter"
|
||||
|
||||
function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
||||
@@ -128,7 +135,7 @@ describe("naechsterService", () => {
|
||||
const insp = inspektionPrognose(fahrzeug(46000), BUCH, jetzt)
|
||||
const erwarteteArt = oel!.datum <= insp!.datum ? "Ölwechsel" : "Inspektion"
|
||||
|
||||
const s = naechsterService(fahrzeug(46000), BUCH, jetzt)
|
||||
const s = naechsterService(fahrzeug(46000), BUCH, [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe(erwarteteArt)
|
||||
expect(s!.datum).toBe(erwarteteArt === "Ölwechsel" ? oel!.datum : insp!.datum)
|
||||
@@ -136,7 +143,7 @@ describe("naechsterService", () => {
|
||||
|
||||
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
|
||||
const f = { ...fahrzeug(46000), hu: "2026-09-01" }
|
||||
const s = naechsterService(f, [], jetzt)
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Hauptuntersuchung")
|
||||
expect(s!.restKm).toBeNull()
|
||||
@@ -144,6 +151,105 @@ describe("naechsterService", () => {
|
||||
})
|
||||
|
||||
it("liefert nichts ohne Servicebuch und ohne gepflegte Hauptuntersuchung", () => {
|
||||
expect(naechsterService(fahrzeug(46000), [], jetzt)).toBeNull()
|
||||
expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull()
|
||||
})
|
||||
|
||||
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Ölwechsel-Fälligkeit zurück", () => {
|
||||
// Ein frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor, aber noch
|
||||
// ganz ohne Servicebuch, muss die Meldung trotzdem zeigen - siehe
|
||||
// AGENTS.md, Abschnitt H, "no inspektion and ölwechsel shown ...".
|
||||
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2027-03-14T00:00:00+00:00", oelwechselFaelligKm: 11750 }
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Ölwechsel")
|
||||
expect(s!.datum).toBe("2027-03-14")
|
||||
expect(s!.restKm).toBe(11750)
|
||||
})
|
||||
|
||||
it("fällt ohne Servicebucheintrag auf die vom Fahrzeug gemeldete Inspektions-Fälligkeit zurück", () => {
|
||||
const f = { ...fahrzeug(46000), inspektionFaelligTs: "2027-01-01T00:00:00+00:00", inspektionFaelligKm: 5000 }
|
||||
const s = naechsterService(f, [], [], jetzt)
|
||||
expect(s).not.toBeNull()
|
||||
expect(s!.art).toBe("Inspektion")
|
||||
expect(s!.datum).toBe("2027-01-01")
|
||||
expect(s!.restKm).toBe(5000)
|
||||
})
|
||||
|
||||
it("bevorzugt die eigene Prognose gegenüber der Fahrzeugmeldung, wenn beide vorliegen", () => {
|
||||
// Servicebucheintrag vorhanden -> oelwechselPrognose() greift; die
|
||||
// Meldung (weit in der Zukunft) darf sie nicht verdrängen.
|
||||
const f = { ...fahrzeug(46000), oelwechselFaelligTs: "2099-01-01T00:00:00+00:00", oelwechselFaelligKm: 99999 }
|
||||
const oel = oelwechselPrognose(f, BUCH, jetzt)
|
||||
expect(oel).not.toBeNull()
|
||||
const s = naechsterService(f, BUCH, [], jetzt)
|
||||
expect(s!.datum).not.toBe("2099-01-01")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kmProTagAusFahrten", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
|
||||
it("rechnet aus abgeschlossenen Fahrten seit der ältesten die km/Tag-Rate", () => {
|
||||
// Exakt 30 Tage vor jetzt (gleiche Uhrzeit), 900 km insgesamt -> 30 km/Tag.
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
expect(kmProTagAusFahrten(fahrten, jetzt)).toBeCloseTo(30, 5)
|
||||
})
|
||||
|
||||
it("ignoriert offene Fahrten und Fahrten ohne bekannte Distanz", () => {
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T08:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-10T08:00:00", distance_km: null, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-11T08:00:00", distance_km: 9999, status: "offen" },
|
||||
]
|
||||
// Nur die erste Fahrt zählt -> dieselbe Rate wie ohne die beiden anderen.
|
||||
expect(kmProTagAusFahrten(fahrten, jetzt)).toBe(
|
||||
kmProTagAusFahrten([fahrten[0]!], jetzt),
|
||||
)
|
||||
})
|
||||
|
||||
it("liefert nichts ohne Fahrten oder ohne belastbaren Zeitraum", () => {
|
||||
expect(kmProTagAusFahrten([], jetzt)).toBeNull()
|
||||
expect(kmProTagAusFahrten([{ ts_start: jetzt.toISOString(), distance_km: 5, status: "abgeschlossen" }], jetzt))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("meldungsPrognose", () => {
|
||||
const jetzt = new Date("2026-08-11T12:00:00")
|
||||
// 30 km/Tag, siehe kmProTagAusFahrten-Tests oben.
|
||||
const fahrten = [
|
||||
{ ts_start: "2026-07-12T12:00:00", distance_km: 400, status: "abgeschlossen" },
|
||||
{ ts_start: "2026-08-01T08:00:00", distance_km: 500, status: "abgeschlossen" },
|
||||
]
|
||||
|
||||
it("rechnet die Restkilometer mit der Fahrleistung aus dem Fahrtenlog auf ein Datum hoch", () => {
|
||||
// 300 km Rest bei 30 km/Tag -> 10 Tage.
|
||||
const p = meldungsPrognose("2099-01-01T00:00:00+00:00", 300, fahrten, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2026-08-21")
|
||||
expect(p!.durchZeitlimit).toBe(false)
|
||||
})
|
||||
|
||||
it("deckelt auf die vom Fahrzeug gemeldete Zeitgrenze, wenn zu wenig gefahren wird", () => {
|
||||
// 30000 km Rest bei 30 km/Tag läge Jahre in der Zukunft - die gemeldete
|
||||
// Zeitgrenze (fünf Tage später) greift zuerst.
|
||||
const p = meldungsPrognose("2026-08-16T00:00:00+00:00", 30000, fahrten, jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2026-08-16")
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
})
|
||||
|
||||
it("fällt ohne Fahrtenlog auf die reine Zeitgrenze der Meldung zurück", () => {
|
||||
const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.datum).toBe("2027-01-01")
|
||||
expect(p!.durchZeitlimit).toBe(true)
|
||||
})
|
||||
|
||||
it("liefert nichts ohne jede brauchbare Angabe", () => {
|
||||
expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +126,59 @@ export function inspektionPrognose(
|
||||
return servicePrognose(fahrzeug, letzterEintrag(buch, /inspektion/i), 30000, 24, jetzt)
|
||||
}
|
||||
|
||||
interface Fahrtdaten {
|
||||
ts_start: string
|
||||
distance_km: number | null
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog, unabhängig vom
|
||||
Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz ohne
|
||||
Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
|
||||
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
|
||||
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
|
||||
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
|
||||
export function kmProTagAusFahrten(fahrten: readonly Fahrtdaten[], jetzt = new Date()): number | null {
|
||||
const gueltig = fahrten.filter((f) => f.status !== "offen" && f.distance_km != null)
|
||||
if (!gueltig.length) return null
|
||||
const km = gueltig.reduce((s, f) => s + (f.distance_km ?? 0), 0)
|
||||
const aeltesteZeit = Math.min(...gueltig.map((f) => new Date(f.ts_start).getTime()))
|
||||
const tage = (jetzt.getTime() - aeltesteZeit) / TAG_MS
|
||||
return tage >= 1 ? km / tage : null
|
||||
}
|
||||
|
||||
export interface Meldungsprognose {
|
||||
/** ISO-Tagesdatum. */
|
||||
datum: string
|
||||
/** Die vom Fahrzeug gemeldete Zeitgrenze hat gegriffen, nicht die
|
||||
hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die
|
||||
km-Grenze rechtzeitig zu erreichen. */
|
||||
durchZeitlimit: boolean
|
||||
}
|
||||
|
||||
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
|
||||
Servicebucheintrag (siehe Aufrufer unten und in Service.tsx) - ein echter
|
||||
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
|
||||
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
|
||||
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
|
||||
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze. */
|
||||
export function meldungsPrognose(
|
||||
faelligTs: string | null,
|
||||
faelligKm: number | null,
|
||||
fahrten: readonly Fahrtdaten[],
|
||||
jetzt = new Date(),
|
||||
): Meldungsprognose | null {
|
||||
const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : null
|
||||
if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
|
||||
const restKm = Math.abs(faelligKm)
|
||||
if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false }
|
||||
const rate = kmProTagAusFahrten(fahrten, jetzt)
|
||||
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null
|
||||
const kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
|
||||
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
|
||||
return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Nächster Service */
|
||||
|
||||
export type Serviceart = "Ölwechsel" | "Inspektion" | "Hauptuntersuchung"
|
||||
@@ -143,23 +196,40 @@ export interface NaechsterService {
|
||||
* Der zeitlich nächste anstehende Service über alle drei Arten hinweg —
|
||||
* Gegenstück zu `naechsterTermin()` im Panel.
|
||||
*
|
||||
* Ölwechsel und Inspektion kommen aus der eigenen Prognose (Servicebuch plus
|
||||
* tatsächliche Fahrleistung), die Hauptuntersuchung aus dem im Profil
|
||||
* gepflegten Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht,
|
||||
* und hochrechnen lässt sie sich auch nicht.
|
||||
* Ölwechsel und Inspektion kommen vorrangig aus der eigenen Prognose
|
||||
* (Servicebuch plus tatsächliche Fahrleistung); ohne Servicebucheintrag dazu
|
||||
* fällt jede der beiden einzeln auf meldungsPrognose() zurück - die vom
|
||||
* Fahrzeug selbst gemeldete Fälligkeit, hochgerechnet mit der Fahrleistung
|
||||
* aus dem Fahrtenlog. Ein frisch eingerichtetes Fahrzeug mit zugeordnetem
|
||||
* Sensor, aber noch ganz ohne Servicebuch, zeigte hier vorher "kein Eintrag
|
||||
* im Servicebuch", obwohl die Fälligkeit längst bekannt war. Die
|
||||
* Hauptuntersuchung kommt weiterhin nur aus dem im Profil gepflegten
|
||||
* Termin: sie hat kein Kilometerziel, das Fahrzeug meldet sie nicht, und
|
||||
* hochrechnen lässt sie sich auch nicht.
|
||||
*/
|
||||
export function naechsterService(
|
||||
fahrzeug: Fahrzeug,
|
||||
buch: readonly Buchhaltung[],
|
||||
fahrten: readonly Fahrtdaten[] = [],
|
||||
jetzt = new Date(),
|
||||
): NaechsterService | null {
|
||||
const kandidaten: NaechsterService[] = []
|
||||
|
||||
const oel = oelwechselPrognose(fahrzeug, buch, jetzt)
|
||||
if (oel) kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
|
||||
if (oel) {
|
||||
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
|
||||
} else {
|
||||
const m = meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
|
||||
if (m) kandidaten.push({ art: "Ölwechsel", restKm: fahrzeug.oelwechselFaelligKm, datum: m.datum })
|
||||
}
|
||||
|
||||
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
|
||||
if (insp) kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
|
||||
if (insp) {
|
||||
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
|
||||
} else {
|
||||
const m = meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten, jetzt)
|
||||
if (m) kandidaten.push({ art: "Inspektion", restKm: fahrzeug.inspektionFaelligKm, datum: m.datum })
|
||||
}
|
||||
|
||||
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
|
||||
const hu = new Date(fahrzeug.hu)
|
||||
|
||||
@@ -38,9 +38,11 @@ export function Einstellungen({
|
||||
tankvorgaenge,
|
||||
rohprofil,
|
||||
otaBuendel,
|
||||
integrationUpdate,
|
||||
api,
|
||||
profilSpeichern,
|
||||
jetztAktualisieren,
|
||||
neuLaden,
|
||||
} = useDaten()
|
||||
const [theme, themeSetzen] = useTheme()
|
||||
const [entwurf, setzeEntwurf] = useState<EinstellungenWerte | null>(null)
|
||||
@@ -49,6 +51,8 @@ export function Einstellungen({
|
||||
const [importOffen, setzeImportOffen] = useState(false)
|
||||
const [otaLaeuft, setzeOtaLaeuft] = useState(false)
|
||||
const [otaFehler, setzeOtaFehler] = useState<string | null>(null)
|
||||
const [updatePruefenLaeuft, setzeUpdatePruefenLaeuft] = useState(false)
|
||||
const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false)
|
||||
const dateiwahl = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
|
||||
@@ -72,6 +76,33 @@ export function Einstellungen({
|
||||
}
|
||||
}
|
||||
|
||||
// Selbst-Update der Integration (aktualisierung.py) - Ersatz für
|
||||
// install.ps1, das Windows Smart App Control blockiert. Beide Aktionen
|
||||
// laufen sofort (keine Warteschlange, siehe api/index.ts) und laden
|
||||
// danach über neuLaden() den neuen Stand von
|
||||
// sensor.audi_dashboard_app_version - der Fehlertext bei fehlendem Token
|
||||
// kommt von dort mit, eine eigene "ist ein Token eingetragen?"-Prüfung
|
||||
// gibt es bewusst nicht.
|
||||
const updatePruefenAusloesen = async () => {
|
||||
setzeUpdatePruefenLaeuft(true)
|
||||
try {
|
||||
await api.updatePruefen()
|
||||
await neuLaden()
|
||||
} finally {
|
||||
setzeUpdatePruefenLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateInstallierenAusloesen = async () => {
|
||||
setzeUpdateInstallierenLaeuft(true)
|
||||
try {
|
||||
await api.updateInstallieren()
|
||||
await neuLaden()
|
||||
} finally {
|
||||
setzeUpdateInstallierenLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!einstellungen || !fahrzeug) return null
|
||||
const werte = entwurf ?? einstellungen
|
||||
|
||||
@@ -410,6 +441,56 @@ export function Einstellungen({
|
||||
</Tile>
|
||||
)}
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Integration-Update</span>
|
||||
{integrationUpdate?.installiert ? (
|
||||
<>
|
||||
<p className="dm-fussnote">
|
||||
Version {integrationUpdate.version} installiert. Home Assistant neu starten, damit
|
||||
die neue Fassung geladen wird — Einstellungen → System → Neu starten.
|
||||
</p>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton onClick={() => void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}>
|
||||
{updatePruefenLaeuft ? "Prüfe …" : "Erneut prüfen"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
) : integrationUpdate?.verfuegbar ? (
|
||||
<>
|
||||
<Werteliste
|
||||
kinder={<Wertzeile label="Verfügbare Version" wert={integrationUpdate.version ?? "?"} />}
|
||||
/>
|
||||
<p className="dm-fussnote">
|
||||
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
|
||||
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten und Tankvorgänge sind davon
|
||||
nicht betroffen.
|
||||
</p>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton
|
||||
onClick={() => void updateInstallierenAusloesen()}
|
||||
disabled={updateInstallierenLaeuft}
|
||||
>
|
||||
{updateInstallierenLaeuft ? "Installiere …" : "Update installieren"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="dm-fussnote">
|
||||
{integrationUpdate
|
||||
? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).`
|
||||
: "Noch nicht geprüft."}
|
||||
</p>
|
||||
<div className="dm-knopfreihe">
|
||||
<ActionButton onClick={() => void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}>
|
||||
{updatePruefenLaeuft ? "Prüfe …" : "Auf Update prüfen"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{integrationUpdate?.fehler && <p className="dm-fehler">{integrationUpdate.fehler}</p>}
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Zugang</span>
|
||||
<Werteliste
|
||||
|
||||
@@ -13,7 +13,13 @@ import { useState } from "react"
|
||||
import { ActionButton, Feld, Tile } from "@audi-dash/ui"
|
||||
|
||||
import { useDaten } from "../daten/DatenKontext"
|
||||
import { inspektionPrognose, letzterInspektion, letzterOelwechsel, oelwechselPrognose } from "../daten/service"
|
||||
import {
|
||||
inspektionPrognose,
|
||||
letzterInspektion,
|
||||
letzterOelwechsel,
|
||||
meldungsPrognose,
|
||||
oelwechselPrognose,
|
||||
} from "../daten/service"
|
||||
import { datum, de, isoTag } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
|
||||
@@ -37,7 +43,7 @@ interface Autohaus {
|
||||
}
|
||||
|
||||
export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug } = useDaten()
|
||||
const { fahrzeug, fahrten } = useDaten()
|
||||
if (!fahrzeug) return null
|
||||
|
||||
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
@@ -62,6 +68,12 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
: prognose
|
||||
? `${de(prognose.restKm)} km · ${datum(prognose.datum)}`
|
||||
: null
|
||||
// Ganz ohne Servicebucheintrag (prognose null) übernimmt die
|
||||
// Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet mit
|
||||
// der echten Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate.
|
||||
const oelMeldungProg = oelHatMeldung && !prognose
|
||||
? meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten)
|
||||
: null
|
||||
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
|
||||
// Herstellerintervall - die Restkilometerzahl daneben wäre nur eine zweite,
|
||||
// verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim eigenen
|
||||
@@ -70,6 +82,8 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
? fahrzeug.oel.modus === "hersteller"
|
||||
? `voraussichtlich am ${datum(prognose.datum)}`
|
||||
: `${de(prognose.restKm)} km, voraussichtlich am ${datum(prognose.datum)}`
|
||||
: oelMeldungProg
|
||||
? `voraussichtlich am ${datum(oelMeldungProg.datum)}`
|
||||
: null
|
||||
// Der Ölwechsel folgt in "Einrichten" wahlweise der Herstellervorgabe oder
|
||||
// einem kürzeren eigenen Intervall (fahrzeug.oel.modus) - die Inspektion hat
|
||||
@@ -92,7 +106,14 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
: inspektionsprognose
|
||||
? datum(inspektionsprognose.datum)
|
||||
: null
|
||||
const inspFussnote = inspektionsprognose ? `voraussichtlich am ${datum(inspektionsprognose.datum)}` : null
|
||||
const inspMeldungProg = inspHatMeldung && !inspektionsprognose
|
||||
? meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten)
|
||||
: null
|
||||
const inspFussnote = inspektionsprognose
|
||||
? `voraussichtlich am ${datum(inspektionsprognose.datum)}`
|
||||
: inspMeldungProg
|
||||
? `voraussichtlich am ${datum(inspMeldungProg.datum)}`
|
||||
: null
|
||||
|
||||
const huDatum = fahrzeug.hu ? new Date(fahrzeug.hu) : null
|
||||
const huGueltig = !!huDatum && !Number.isNaN(huDatum.getTime())
|
||||
@@ -107,7 +128,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
<SymbolOelwechsel groesse={26} />
|
||||
<span>Ölwechsel</span>
|
||||
</div>
|
||||
{oelHatEintrag ? (
|
||||
{oelHatEintrag || oelHatMeldung ? (
|
||||
<>
|
||||
{oelHerstellervorgabe && <div className="dm-tgvorgabe">Herstellervorgabe</div>}
|
||||
<div className="dm-tgwert">{oelWert}</div>
|
||||
@@ -123,7 +144,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
<SymbolInspektion groesse={20} />
|
||||
<span>Inspektion</span>
|
||||
</div>
|
||||
{inspHatEintrag ? (
|
||||
{inspHatEintrag || inspHatMeldung ? (
|
||||
<>
|
||||
<div className="dm-tgvorgabe">Herstellervorgabe</div>
|
||||
<div className="dm-tgwert">{inspWert}</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
.trim()
|
||||
|
||||
const buch = (fahrzeug.service["buch"] ?? []) as ServicebuchEintrag[]
|
||||
const service = naechsterService(fahrzeug, buch)
|
||||
const service = naechsterService(fahrzeug, buch, fahrten)
|
||||
|
||||
const letzteFahrt = fahrten
|
||||
.slice()
|
||||
|
||||
@@ -5,18 +5,20 @@ import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
|
||||
// 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
|
||||
// Zahl, 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.
|
||||
//
|
||||
// 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.
|
||||
// Projektstamm. Die ist mit dem Umbau zur Integration überflüssig geworden:
|
||||
// Home Assistant selbst verlangt ein version-Feld in jeder manifest.json,
|
||||
// unabhängig von HACS (das für dieses private Repository ohnehin dauerhaft
|
||||
// ausscheidet, siehe AGENTS.md Abschnitt H). 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
|
||||
|
||||
@@ -104,10 +104,10 @@ async def _statik_anmelden(hass: HomeAssistant) -> None:
|
||||
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."""
|
||||
cache_headers=False ist Absicht: die Dateien werden von install.ps1
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Selbst-Update der Integration direkt von Gitea.
|
||||
|
||||
Ersetzt install.ps1 als Update-Weg - nicht als Erstinstallations-Weg, die
|
||||
Integration muss bereits laufen, um sich selbst aktualisieren zu können.
|
||||
Grund: Windows Smart App Control blockiert die Ausführung von install.ps1
|
||||
zuverlässig und lässt sich, anders als der SmartScreen davor, nicht per
|
||||
"trotzdem ausführen" umgehen.
|
||||
|
||||
SICHERHEIT: dieses Modul läuft, anders als install.ps1, INNERHALB des
|
||||
Prozesses, den es aktualisiert - der eigene Ordner wird ersetzt, während der
|
||||
Dienstaufruf noch läuft. Deshalb strenger als install.ps1s
|
||||
"leeren-dann-kopieren":
|
||||
|
||||
1. Laden und Prüfen passiert komplett in einem Geschwisterordner
|
||||
(audi_dashboard_update_staging). Der Live-Ordner bleibt unangetastet,
|
||||
bis feststeht, dass der Download vollständig und plausibel ist.
|
||||
2. Erst danach ein atomarer Tausch per os.rename, nicht Löschen+Kopieren:
|
||||
der alte Ordner wird zu audi_dashboard_backup umbenannt statt gelöscht -
|
||||
reversibel, falls das Update Probleme macht.
|
||||
3. Schlägt der zweite Rename fehl, wird der erste zurückgenommen, statt
|
||||
einen halb ersetzten Ordner zurückzulassen.
|
||||
4. Kein automatischer Reload oder Neustart. Ein Reload aus dem eigenen,
|
||||
noch laufenden Dienstaufruf heraus anzustoßen wäre riskant - der
|
||||
Handler könnte mitten in der Ausführung durch den eigenen Reload
|
||||
unterbrochen werden. Die Oberfläche zeigt stattdessen "Home Assistant
|
||||
neu starten" an, genau wie install.ps1 es über seine Restliste tut.
|
||||
5. Nichts außerhalb des eigenen Ordners: configuration.yaml, .storage/,
|
||||
www/ und der separate Fahrzeugdaten-Ordner werden nirgends geöffnet.
|
||||
|
||||
Das Umbenennen des eigenen, gerade ausgeführten Ordners ist auf Linux (Home
|
||||
Assistant OS/Docker, worauf dieses Projekt läuft) unkritisch: Python hält
|
||||
nach dem Import keine offenen Dateizugriffe auf die .py-Quelltexte mehr,
|
||||
und der Verzeichnisname selbst ist nur eine Zeichenkette in os.rename() -
|
||||
kein Handle, das durch die Umbenennung ungültig würde.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REPO_BESITZER = "paul"
|
||||
_REPO_NAME = "audi-app"
|
||||
_ZWEIG = "main"
|
||||
_GITEA_BASIS = "https://gitea.nothaft.cloud/api/v1"
|
||||
_MANIFEST_PFAD_IM_REPO = "custom_components/audi_dashboard/manifest.json"
|
||||
_INTEGRATIONS_PFAD_IM_REPO = "custom_components/audi_dashboard/"
|
||||
|
||||
# Absolute Pfade, einmal zur Modul-Ladezeit bestimmt - __file__ liegt selbst
|
||||
# im Live-Ordner, dessen Elternordner (custom_components/) ist der Ort für
|
||||
# Staging- und Backup-Geschwisterordner.
|
||||
INTEGRATIONSORDNER = os.path.dirname(os.path.abspath(__file__))
|
||||
_STAGING_ORDNER = os.path.join(
|
||||
os.path.dirname(INTEGRATIONSORDNER), "audi_dashboard_update_staging"
|
||||
)
|
||||
_BACKUP_ORDNER = os.path.join(os.path.dirname(INTEGRATIONSORDNER), "audi_dashboard_backup")
|
||||
|
||||
|
||||
class AktualisierungsFehler(Exception):
|
||||
"""Jeder Fehlerfall beim Prüfen oder Installieren - Netzwerk, falscher
|
||||
Token, kaputtes Zip, falsche Domain, fehlgeschlagener Tausch. Eine
|
||||
eigene Klasse statt roher Exceptions, damit der Dienstaufruf sie gezielt
|
||||
abfangen und der Oberfläche eine verständliche Meldung zeigen kann,
|
||||
statt eines unbehandelten Fehlers."""
|
||||
|
||||
|
||||
def _header(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"token {token}"}
|
||||
|
||||
|
||||
async def version_pruefen(hass: HomeAssistant, token: str, eigene_version: str) -> dict[str, Any]:
|
||||
"""Fragt nur die manifest.json von Gitea ab (ein kleiner Request, kein
|
||||
Repo-Download) und vergleicht die Version gegen die installierte.
|
||||
|
||||
Läuft ausschließlich auf Tastendruck aus der Oberfläche - niemals aus
|
||||
dem Koordinator-Takt heraus, sonst würde jeder normale Veröffentlichungs-
|
||||
Zyklus einen Netzwerkaufruf zu Gitea auslösen."""
|
||||
if not token:
|
||||
raise AktualisierungsFehler(
|
||||
"Kein Gitea-Token hinterlegt - unter Einstellungen -> Geräte & Dienste -> "
|
||||
"Audi Dashboard -> Konfigurieren eintragen."
|
||||
)
|
||||
|
||||
url = f"{_GITEA_BASIS}/repos/{_REPO_BESITZER}/{_REPO_NAME}/contents/{_MANIFEST_PFAD_IM_REPO}"
|
||||
session = async_get_clientsession(hass)
|
||||
try:
|
||||
async with session.get(url, headers=_header(token), params={"ref": _ZWEIG}) as antwort:
|
||||
if antwort.status == 401:
|
||||
raise AktualisierungsFehler("Token ungültig oder abgelaufen.")
|
||||
if antwort.status == 404:
|
||||
raise AktualisierungsFehler("Repository oder Pfad bei Gitea nicht gefunden.")
|
||||
if antwort.status != 200:
|
||||
raise AktualisierungsFehler(f"Gitea antwortete mit Status {antwort.status}.")
|
||||
rumpf = await antwort.json()
|
||||
except AktualisierungsFehler:
|
||||
raise
|
||||
except Exception as fehler: # aiohttp-/Verbindungsfehler aller Art
|
||||
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
|
||||
|
||||
try:
|
||||
roh = base64.b64decode(rumpf["content"])
|
||||
remote_version = json.loads(roh)["version"]
|
||||
except (KeyError, ValueError, TypeError) as fehler:
|
||||
raise AktualisierungsFehler(f"Antwort von Gitea nicht lesbar: {fehler}") from fehler
|
||||
|
||||
return {
|
||||
"verfuegbar": remote_version != eigene_version,
|
||||
"version": remote_version,
|
||||
"geprueft_am": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def update_installieren(hass: HomeAssistant, token: str) -> dict[str, Any]:
|
||||
"""Lädt das komplette Repo-Archiv und delegiert Entpacken/Prüfen/Tauschen
|
||||
an eine blockierende Funktion im Executor - Datei- und Zip-Operationen
|
||||
gehören nicht in den Event-Loop."""
|
||||
if not token:
|
||||
raise AktualisierungsFehler(
|
||||
"Kein Gitea-Token hinterlegt - unter Einstellungen -> Geräte & Dienste -> "
|
||||
"Audi Dashboard -> Konfigurieren eintragen."
|
||||
)
|
||||
|
||||
url = f"{_GITEA_BASIS}/repos/{_REPO_BESITZER}/{_REPO_NAME}/archive/{_ZWEIG}.zip"
|
||||
session = async_get_clientsession(hass)
|
||||
try:
|
||||
async with session.get(url, headers=_header(token)) as antwort:
|
||||
if antwort.status == 401:
|
||||
raise AktualisierungsFehler("Token ungültig oder abgelaufen.")
|
||||
if antwort.status != 200:
|
||||
raise AktualisierungsFehler(f"Gitea antwortete mit Status {antwort.status}.")
|
||||
zip_bytes = await antwort.read()
|
||||
except AktualisierungsFehler:
|
||||
raise
|
||||
except Exception as fehler:
|
||||
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
|
||||
|
||||
neue_version = await hass.async_add_executor_job(
|
||||
entpacken_pruefen_tauschen, zip_bytes, INTEGRATIONSORDNER, _STAGING_ORDNER, _BACKUP_ORDNER
|
||||
)
|
||||
return {"version": neue_version}
|
||||
|
||||
|
||||
def entpacken_pruefen_tauschen(
|
||||
zip_bytes: bytes, integrationsordner: str, staging_ordner: str, backup_ordner: str
|
||||
) -> str:
|
||||
"""Blockierend: entpackt nur custom_components/audi_dashboard/ aus dem
|
||||
Archiv in staging_ordner, prüft das dortige manifest.json, und tauscht
|
||||
per os.rename mit integrationsordner. Wirft AktualisierungsFehler bei
|
||||
jedem Problem - und zwar VOR jedem Tausch, siehe Moduldocstring.
|
||||
|
||||
Bewusst ohne hass-Abhängigkeit und mit allen Pfaden als Parameter statt
|
||||
fester Modul-Konstanten, damit sie sich isoliert mit einem selbstgebauten
|
||||
Zip gegen einen Temp-Ordner testen lässt, ohne den echten
|
||||
Integrationsordner anzufassen."""
|
||||
if os.path.isdir(staging_ordner):
|
||||
shutil.rmtree(staging_ordner)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(zip_bytes)) as archiv:
|
||||
namen = archiv.namelist()
|
||||
if not namen:
|
||||
raise AktualisierungsFehler("Leeres Archiv erhalten.")
|
||||
# Gitea packt alles unter einem Wurzelordner wie "audi-app-main/"
|
||||
# - das wird hier gesucht statt geraten.
|
||||
treffer = [
|
||||
n for n in namen if _INTEGRATIONS_PFAD_IM_REPO in n and not n.endswith("/")
|
||||
]
|
||||
if not treffer:
|
||||
raise AktualisierungsFehler(
|
||||
f"{_INTEGRATIONS_PFAD_IM_REPO} nicht im heruntergeladenen Archiv gefunden."
|
||||
)
|
||||
wurzel_marker = treffer[0].split(_INTEGRATIONS_PFAD_IM_REPO, 1)[0] \
|
||||
+ _INTEGRATIONS_PFAD_IM_REPO
|
||||
os.makedirs(staging_ordner, exist_ok=True)
|
||||
for name in treffer:
|
||||
if wurzel_marker not in name:
|
||||
continue
|
||||
rel = name.split(wurzel_marker, 1)[1]
|
||||
if not rel:
|
||||
continue
|
||||
ziel = os.path.join(staging_ordner, rel.replace("/", os.sep))
|
||||
os.makedirs(os.path.dirname(ziel), exist_ok=True)
|
||||
with archiv.open(name) as quelle, open(ziel, "wb") as senke:
|
||||
shutil.copyfileobj(quelle, senke)
|
||||
except zipfile.BadZipFile as fehler:
|
||||
shutil.rmtree(staging_ordner, ignore_errors=True)
|
||||
raise AktualisierungsFehler(f"Kein gültiges Zip-Archiv: {fehler}") from fehler
|
||||
|
||||
manifest_pfad = os.path.join(staging_ordner, "manifest.json")
|
||||
if not os.path.exists(manifest_pfad):
|
||||
shutil.rmtree(staging_ordner, ignore_errors=True)
|
||||
raise AktualisierungsFehler("Entpackter Ordner enthält keine manifest.json.")
|
||||
with open(manifest_pfad, encoding="utf-8") as datei:
|
||||
manifest = json.load(datei)
|
||||
if manifest.get("domain") != "audi_dashboard":
|
||||
shutil.rmtree(staging_ordner, ignore_errors=True)
|
||||
raise AktualisierungsFehler(
|
||||
f"Heruntergeladenes Manifest gehört zu Domain '{manifest.get('domain')}', "
|
||||
"nicht 'audi_dashboard' - Abbruch, nichts wurde angefasst."
|
||||
)
|
||||
neue_version = manifest.get("version")
|
||||
if not neue_version:
|
||||
shutil.rmtree(staging_ordner, ignore_errors=True)
|
||||
raise AktualisierungsFehler("Heruntergeladenes Manifest hat kein version-Feld.")
|
||||
|
||||
# __pycache__ aus dem Staging-Ordner entfernen - dieselbe Sorgfalt wie
|
||||
# install.ps1s Aufräumschritt nach dem Kopieren.
|
||||
for wurzel, ordner, _dateien in os.walk(staging_ordner):
|
||||
if "__pycache__" in ordner:
|
||||
shutil.rmtree(os.path.join(wurzel, "__pycache__"), ignore_errors=True)
|
||||
|
||||
if os.path.isdir(backup_ordner):
|
||||
shutil.rmtree(backup_ordner)
|
||||
try:
|
||||
os.rename(integrationsordner, backup_ordner)
|
||||
except OSError as fehler:
|
||||
shutil.rmtree(staging_ordner, ignore_errors=True)
|
||||
raise AktualisierungsFehler(
|
||||
f"Konnte den bestehenden Ordner nicht sichern: {fehler}"
|
||||
) from fehler
|
||||
|
||||
try:
|
||||
os.rename(staging_ordner, integrationsordner)
|
||||
except OSError as fehler:
|
||||
# Rollback: die alte Fassung zurückbenennen, statt HA ohne
|
||||
# Integrationsordner dastehen zu lassen.
|
||||
os.rename(backup_ordner, integrationsordner)
|
||||
raise AktualisierungsFehler(
|
||||
f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}"
|
||||
) from fehler
|
||||
|
||||
return neue_version
|
||||
@@ -14,9 +14,10 @@ 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/.
|
||||
würde der nächste install.ps1-Lauf sie mitlöschen (er leert den Zielordner
|
||||
vor dem Kopieren, siehe install.ps1). /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
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
"""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.
|
||||
Bewusst ohne Eingabefelder im Ersteinrichtungs-Schritt. 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.
|
||||
|
||||
Der OptionsFlow unten ist die einzige Ausnahme: ein Gitea-Zugriffstoken für
|
||||
das Selbst-Update (siehe aktualisierung.py) - ein echtes Geheimnis, das
|
||||
deshalb in HAs eigenem, verschlüsseltem Config-Entry-Speicher landet
|
||||
(entry.options), nicht in configuration.yaml.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import CONF_GITEA_TOKEN, DOMAIN
|
||||
|
||||
|
||||
class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -35,3 +44,35 @@ class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_create_entry(title="Audi Dashboard", data={})
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
return AudiDashboardOptionsFlow()
|
||||
|
||||
|
||||
class AudiDashboardOptionsFlow(OptionsFlow):
|
||||
"""Einstellungen -> Geräte & Dienste -> Audi Dashboard -> Konfigurieren.
|
||||
|
||||
Ein Feld: der Gitea-Token fürs Selbst-Update. Leer lassen deaktiviert
|
||||
die Update-Prüfung wieder (aktualisierung.py verlangt einen nicht-leeren
|
||||
Token, sonst gibt es eine verständliche Fehlermeldung statt eines
|
||||
unbehandelten Fehlers)."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema({
|
||||
vol.Optional(CONF_GITEA_TOKEN, default=""): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
}),
|
||||
self.config_entry.options,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -32,22 +32,22 @@ 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.
|
||||
# gehören jetzt zum Installationspaket der Integration und werden von
|
||||
# install.ps1 mitgeliefert, statt separat nach www/ kopiert zu werden.
|
||||
STATIK_URL = "/audi_dashboard_static"
|
||||
STATIK_ORDNER = "frontend"
|
||||
|
||||
# Das OTA-Bündel der iOS-App: dieselbe Oberfläche, als Zip zum Nachladen.
|
||||
# Liegt bewusst mit im Integrationsordner - dann liefert HACS es bei jedem
|
||||
# Update mit aus, und Bündel und Integration können gar nicht auseinander-
|
||||
# laufen (siehe companion-app/scripts/ota-paket.ps1).
|
||||
# Liegt bewusst mit im Integrationsordner - dann liefert install.ps1 es bei
|
||||
# jeder Auslieferung mit aus, und Bündel und Integration können gar nicht
|
||||
# auseinanderlaufen (siehe companion-app/scripts/ota-paket.ps1).
|
||||
BUENDEL_ORDNER = "frontend/app"
|
||||
BUENDEL_URL = f"{STATIK_URL}/app/bundle.zip"
|
||||
BUENDEL_INFO = "bundle.json"
|
||||
|
||||
# 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
|
||||
# Lägen sie im Integrationsordner, würde der nächste install.ps1-Lauf sie
|
||||
# mitlöschen - genau das darf nicht passieren.
|
||||
BILDER_ORDNER = "www/bilder"
|
||||
|
||||
@@ -89,3 +89,11 @@ D_REIFEN_KM_SETZEN = "reifen_km_setzen"
|
||||
D_BACKUP_JETZT = "backup_jetzt"
|
||||
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
|
||||
D_HISTORIE_IMPORTIEREN = "historie_importieren"
|
||||
D_UPDATE_PRUEFEN = "update_pruefen"
|
||||
D_UPDATE_INSTALLIEREN = "update_installieren"
|
||||
|
||||
# Schlüssel für den Gitea-Zugriffstoken in entry.options (siehe
|
||||
# config_flow.py's AudiDashboardOptionsFlow und aktualisierung.py). Hier statt
|
||||
# in config_flow.py definiert, damit aktualisierung.py/dienste.py ihn ohne
|
||||
# Umweg über den Options-Flow importieren können.
|
||||
CONF_GITEA_TOKEN = "gitea_token"
|
||||
|
||||
@@ -15,7 +15,9 @@ nicht die Schema-Prüfung.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
@@ -24,9 +26,19 @@ 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 . import (
|
||||
aktualisierung,
|
||||
belege,
|
||||
bilder,
|
||||
fahrterkennung,
|
||||
historienimport,
|
||||
reifen,
|
||||
screening,
|
||||
sicherung,
|
||||
)
|
||||
from .const import (
|
||||
BILDER_ORDNER,
|
||||
CONF_GITEA_TOKEN,
|
||||
D_BACKUP_JETZT,
|
||||
D_BACKUP_WIEDERHERSTELLEN,
|
||||
D_BELEG_HOCHLADEN,
|
||||
@@ -45,10 +57,14 @@ from .const import (
|
||||
D_TANKVORGANG_AKTUALISIEREN,
|
||||
D_TANKVORGANG_LOESCHEN,
|
||||
D_TANKVORGANG_MANUELL,
|
||||
D_UPDATE_INSTALLIEREN,
|
||||
D_UPDATE_PRUEFEN,
|
||||
DOMAIN,
|
||||
)
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 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.
|
||||
@@ -126,6 +142,8 @@ SCHEMATA: dict[str, vol.Schema] = {
|
||||
vol.Required("start"): cv.string,
|
||||
vol.Required("ende"): cv.string,
|
||||
}),
|
||||
D_UPDATE_PRUEFEN: vol.Schema({}),
|
||||
D_UPDATE_INSTALLIEREN: vol.Schema({}),
|
||||
}
|
||||
|
||||
Fabrik = Callable[[ServiceCall], Coroutine[Any, Any, None]]
|
||||
@@ -202,6 +220,59 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
||||
"audi_dashboard_historienimport",
|
||||
)
|
||||
|
||||
async def update_pruefen(_aufruf: ServiceCall) -> None:
|
||||
"""Fragt bei Gitea nach, ob eine neuere Fassung vorliegt - ohne
|
||||
etwas herunterzuladen. Läuft nur auf Tastendruck, siehe
|
||||
aktualisierung.py's Moduldocstring."""
|
||||
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
|
||||
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
try:
|
||||
ergebnis = await aktualisierung.version_pruefen(hass, token, k.version)
|
||||
k.letzte_update_pruefung = {
|
||||
"verfuegbar": ergebnis["verfuegbar"],
|
||||
"version": ergebnis["version"],
|
||||
"geprueft_am": ergebnis["geprueft_am"],
|
||||
"installiert": False,
|
||||
"fehler": None,
|
||||
}
|
||||
except aktualisierung.AktualisierungsFehler as fehler:
|
||||
_LOGGER.warning("Update-Prüfung fehlgeschlagen: %s", fehler)
|
||||
k.letzte_update_pruefung = {
|
||||
"verfuegbar": False,
|
||||
"version": None,
|
||||
"geprueft_am": jetzt,
|
||||
"installiert": False,
|
||||
"fehler": str(fehler),
|
||||
}
|
||||
await k.app_version_veroeffentlichen()
|
||||
|
||||
async def update_installieren(_aufruf: ServiceCall) -> None:
|
||||
"""Lädt die neueste Fassung von Gitea und ersetzt den
|
||||
Integrationsordner. Home Assistant muss danach von Hand neu
|
||||
gestartet werden - kein automatischer Reload aus dem eigenen,
|
||||
noch laufenden Aufruf heraus, siehe aktualisierung.py."""
|
||||
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
|
||||
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
try:
|
||||
ergebnis = await aktualisierung.update_installieren(hass, token)
|
||||
k.letzte_update_pruefung = {
|
||||
"verfuegbar": False,
|
||||
"version": ergebnis["version"],
|
||||
"geprueft_am": jetzt,
|
||||
"installiert": True,
|
||||
"fehler": None,
|
||||
}
|
||||
except aktualisierung.AktualisierungsFehler as fehler:
|
||||
_LOGGER.warning("Update-Installation fehlgeschlagen: %s", fehler)
|
||||
k.letzte_update_pruefung = {
|
||||
"verfuegbar": (k.letzte_update_pruefung or {}).get("verfuegbar", False),
|
||||
"version": (k.letzte_update_pruefung or {}).get("version"),
|
||||
"geprueft_am": jetzt,
|
||||
"installiert": False,
|
||||
"fehler": str(fehler),
|
||||
}
|
||||
await k.app_version_veroeffentlichen()
|
||||
|
||||
bilder_ordner = hass.config.path(BILDER_ORDNER)
|
||||
|
||||
behandler: dict[str, Callable[[ServiceCall], Coroutine[Any, Any, None]]] = {
|
||||
@@ -245,6 +316,8 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
||||
D_BACKUP_WIEDERHERSTELLEN: _als_dienst(
|
||||
lambda a: sicherung.wiederherstellen(k, dict(a.data))
|
||||
),
|
||||
D_UPDATE_PRUEFEN: update_pruefen,
|
||||
D_UPDATE_INSTALLIEREN: update_installieren,
|
||||
}
|
||||
|
||||
for name, funktion in behandler.items():
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.8.24.1","sha256":"967ab36fc866187cf5fdc03d95a3ec21cb6e42003d8c012f8f67b99c67373324","bytes":230770,"gebaut":"2026-08-24T07:16:44Z"}
|
||||
{"version":"2026.8.24.4","sha256":"cbce1baa785a3577fc65168b9f8b2094ecd844aadc2ac36f99d6be8ccb4cb8c1","bytes":231567,"gebaut":"2026-08-24T09:39:59Z"}
|
||||
Binary file not shown.
@@ -1262,6 +1262,42 @@ function servicePrognose(stichwort, intervallKm, intervallMonate) {
|
||||
return { km: zielKm, restKm, datum };
|
||||
}
|
||||
|
||||
/* Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog (TRIPS), unabhängig
|
||||
vom Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz
|
||||
ohne Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
|
||||
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
|
||||
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
|
||||
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
|
||||
function kmProTagAusFahrten() {
|
||||
const gueltig = TRIPS.filter((t) => t.status !== "offen" && t.distance_km != null);
|
||||
if (!gueltig.length) return null;
|
||||
const km = gueltig.reduce((s, t) => s + t.distance_km, 0);
|
||||
const aeltesteZeit = Math.min(...gueltig.map((t) => new Date(t.ts_start).getTime()));
|
||||
const tage = (heute().getTime() - aeltesteZeit) / dTag;
|
||||
return tage >= 1 ? km / tage : null;
|
||||
}
|
||||
|
||||
/* Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
|
||||
Servicebucheintrag (siehe Aufrufer in vService()) - ein echter
|
||||
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
|
||||
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
|
||||
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
|
||||
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze - fährt der
|
||||
Nutzer zu wenig, um die km-Grenze rechtzeitig zu erreichen, ist die
|
||||
gemeldete Zeitgrenze maßgeblich, nicht die hochgerechnete km-Grenze. */
|
||||
function meldungsPrognose(art) {
|
||||
const fm = fahrzeugMeldungRoh(art);
|
||||
const zeitDatum = fm.ts && gueltigesDatum(new Date(fm.ts)) ? new Date(fm.ts) : null;
|
||||
if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
|
||||
const restKm = Math.abs(fm.km);
|
||||
if (restKm <= 0) return { datum: heute(), durchZeitlimit: false };
|
||||
const rate = kmProTagAusFahrten();
|
||||
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
|
||||
const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
|
||||
const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
|
||||
return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit };
|
||||
}
|
||||
|
||||
function oelwechselPrognose() {
|
||||
const oel = CAR.oel;
|
||||
return servicePrognose("öl",
|
||||
@@ -1776,21 +1812,35 @@ function vService() {
|
||||
const artIcon = ART_ICON[t.art];
|
||||
const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : "";
|
||||
const kopf = `<div class="tg-name">${iconMarkup}<span>${t.art}</span></div>`;
|
||||
// Fahrzeugmeldung zuerst prüfen: sie steht für sich, auch ganz ohne
|
||||
// Servicebucheintrag - siehe fahrzeugMeldungRoh()'s Kommentar. Ein
|
||||
// frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor und
|
||||
// "Herstellervorgabe", aber ohne einen einzigen Servicebucheintrag,
|
||||
// zeigte vorher trotz korrekt gemeldeter Fälligkeit nur "kein Eintrag
|
||||
// im Servicebuch" - iv war null und der frühe Return griff, bevor fm
|
||||
// überhaupt gelesen wurde.
|
||||
const fm = fahrzeugMeldungRoh(t.art);
|
||||
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
|
||||
const hatMeldung = fm.km != null || fmDatum;
|
||||
const iv = intervalle(t);
|
||||
if (!iv) return `<div class="termingruppe">
|
||||
if (!iv && !hatMeldung) return `<div class="termingruppe">
|
||||
<div class="tg-kopf">${kopf}</div>
|
||||
<div class="tg-wert leer">kein Eintrag im Servicebuch</div>
|
||||
</div>`;
|
||||
const anzeige = iv.hersteller || iv.eigen;
|
||||
const fm = fahrzeugMeldungRoh(t.art);
|
||||
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
|
||||
const anzeige = iv ? (iv.hersteller || iv.eigen) : null;
|
||||
// Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
|
||||
// eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
|
||||
// Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
|
||||
// dem Datum. Hauptuntersuchung bleibt bewusst ohne.
|
||||
// dem Datum. Hauptuntersuchung bleibt bewusst ohne. Beide liefern null
|
||||
// ohne Servicebucheintrag (servicePrognose() braucht letzterEintrag) -
|
||||
// dann bleibt nur die Fahrzeugmeldung als Wert übrig, siehe wert unten.
|
||||
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
|
||||
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
|
||||
const hatMeldung = fm.km != null || fmDatum;
|
||||
// Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt
|
||||
// die Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet
|
||||
// mit der echten Fahrleistung aus dem Fahrtenlog statt einer
|
||||
// Servicebuch-Rate - siehe meldungsPrognose().
|
||||
const meldungProg = hatMeldung && !eigenOel && !eigenInsp ? meldungsPrognose(t.art) : null;
|
||||
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
|
||||
// Herstellerintervall - die Restkilometerzahl daneben waere nur eine
|
||||
// zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
|
||||
@@ -1799,7 +1849,8 @@ function vService() {
|
||||
? (CAR.oel.modus === "hersteller"
|
||||
? `voraussichtlich am ${dedat(eigenOel.datum)}`
|
||||
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
|
||||
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : "";
|
||||
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
|
||||
: meldungProg ? `voraussichtlich am ${dedat(meldungProg.datum)}` : "";
|
||||
const wert = hatMeldung
|
||||
? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}`
|
||||
: (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum));
|
||||
@@ -1808,7 +1859,7 @@ function vService() {
|
||||
// Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller
|
||||
// als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback
|
||||
// (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht.
|
||||
const herstellervorgabe = hatMeldung || iv.hersteller;
|
||||
const herstellervorgabe = hatMeldung || (iv && iv.hersteller);
|
||||
return `<div class="termingruppe">
|
||||
<div class="tg-kopf">
|
||||
${kopf}
|
||||
@@ -2331,11 +2382,37 @@ function vEinst() {
|
||||
<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>
|
||||
<span class="label" style="margin-top:14px">Updates laufen über HACS</span>
|
||||
</div>
|
||||
<div class="tile"><span class="label">Integration-Update</span>
|
||||
${(() => {
|
||||
const u = INTEGRATION_UPDATE;
|
||||
// Ersetzt install.ps1 als laufenden Update-Weg (Windows Smart App
|
||||
// Control blockiert dessen Ausführung zuverlässig) - install.ps1
|
||||
// bleibt nur noch für die Erstinstallation nötig, bevor die
|
||||
// Integration überhaupt läuft und sich selbst aktualisieren kann.
|
||||
if (u && u.installiert) {
|
||||
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
|
||||
Version ${esc(u.version)} installiert. Home Assistant neu starten, damit die neue
|
||||
Fassung geladen wird - Einstellungen -> System -> Neu starten.</span>
|
||||
<button class="aktion" style="margin-top:14px" data-update="pruefen">Erneut prüfen</button>`;
|
||||
}
|
||||
if (u && u.verfuegbar) {
|
||||
return `<dl class="rows"><div class="row" style="border-bottom:0"><dt>Verfügbare Version</dt><dd>${esc(u.version)}</dd></div></dl>
|
||||
<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>
|
||||
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
|
||||
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten, Tankvorgänge und eigene
|
||||
Fotos bleiben dabei unangetastet - die liegen außerhalb des Integrationsordners.</span>
|
||||
<button class="aktion" style="margin-top:14px" data-update="installieren">Update installieren</button>`;
|
||||
}
|
||||
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
|
||||
${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."}
|
||||
Ein Gitea-Zugriffstoken muss zuvor unter Einstellungen -> Geräte & Dienste -> Audi
|
||||
Dashboard -> Konfigurieren eingetragen sein.</span>
|
||||
<button class="aktion" style="margin-top:14px" data-update="pruefen">Auf Update prüfen</button>`;
|
||||
})()}
|
||||
${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler
|
||||
? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(INTEGRATION_UPDATE.fehler)}</span>`
|
||||
: ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -3022,18 +3099,25 @@ let entwurfIndex = null;
|
||||
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];
|
||||
|
||||
/* 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.
|
||||
Version). Kommt aus sensor.audi_dashboard_app_version, also direkt aus der
|
||||
manifest.json der Integration.
|
||||
|
||||
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. */
|
||||
keine Installationsform hatte, die Home Assistant selbst kennt - jetzt
|
||||
gibt es eine: install.ps1 kopiert genau einen Ordner, das Panel darin
|
||||
inklusive, und ist damit der einzige Installations- wie Update-Weg (HACS
|
||||
scheidet für dieses private Repository dauerhaft aus, siehe AGENTS.md
|
||||
Abschnitt H). Eine zweite, selbstgebaute Update-Mechanik daneben wäre
|
||||
mehr Angriffsfläche als Nutzen - der eigene Updater ist deshalb
|
||||
ersatzlos entfallen. */
|
||||
let APP_VERSION = null;
|
||||
// Ergebnis der letzten "Auf Update prüfen"/"Update installieren"
|
||||
// (custom_components/audi_dashboard/aktualisierung.py) - null, solange nie
|
||||
// geprüft wurde. Ersetzt install.ps1 als Update-Weg, das Windows Smart App
|
||||
// Control zuverlässig blockiert; siehe vVersion()/Einstellungen-Ansicht.
|
||||
let INTEGRATION_UPDATE = null;
|
||||
|
||||
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
|
||||
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
|
||||
@@ -4392,6 +4476,9 @@ function ereignisseVerdrahten() {
|
||||
a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove();
|
||||
return;
|
||||
}
|
||||
const upd = e.target.closest("[data-update]");
|
||||
if (upd && upd.dataset.update === "pruefen") { serviceRufen("update_pruefen", {}); return; }
|
||||
if (upd && upd.dataset.update === "installieren") { serviceRufen("update_installieren", {}); return; }
|
||||
const cs = e.target.closest("[data-csv]");
|
||||
if (cs) {
|
||||
const w = cs.dataset.csv;
|
||||
@@ -4755,6 +4842,11 @@ async function datenLaden(erzwingen) {
|
||||
const entitaetenState = HASS.states[E.entitaeten] || NACHGELADEN.entitaeten;
|
||||
const versionState = HASS.states[E.appVersion];
|
||||
if (versionState && versionState.state) APP_VERSION = versionState.state;
|
||||
if (versionState && versionState.attributes) {
|
||||
INTEGRATION_UPDATE = versionState.attributes.daten
|
||||
? versionState.attributes.daten.integration_update ?? null
|
||||
: null;
|
||||
}
|
||||
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
|
||||
|
||||
if (belegState && belegState !== letzterBelegState) {
|
||||
|
||||
@@ -99,6 +99,13 @@ class Koordinator:
|
||||
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
|
||||
# Ergebnis des letzten "Auf Update prüfen"/"Update installieren" -
|
||||
# nur auf Tastendruck gesetzt (siehe dienste.py), nie im normalen
|
||||
# Veröffentlichungs-Takt neu berechnet, damit keine automatischen
|
||||
# Gitea-Abfragen im Hintergrund laufen. app_version_veroeffentlichen()
|
||||
# liest diesen Wert bei jedem Takt erneut mit, damit er nicht
|
||||
# zwischen zwei Prüfungen verschwindet.
|
||||
self.letzte_update_pruefung: dict | None = None
|
||||
|
||||
self._takte: list[CALLBACK_TYPE] = []
|
||||
self._beobachter: list[CALLBACK_TYPE] = []
|
||||
@@ -329,13 +336,25 @@ class Koordinator:
|
||||
return None
|
||||
return {**info, "url": BUENDEL_URL}
|
||||
|
||||
async def alles_veroeffentlichen(self) -> None:
|
||||
async def app_version_veroeffentlichen(self) -> None:
|
||||
buendel = await self.hass.async_add_executor_job(self._buendel_lesen)
|
||||
# Die App liest beides aus derselben Entität: welchen Stand diese
|
||||
# Installation ausliefert, und ob ein nachladbares Bündel danebenliegt.
|
||||
# Zusammen an einer Stelle, damit die App nicht zwei Quellen abgleichen
|
||||
# muss, die auseinanderlaufen können.
|
||||
self.setzen(E_APP_VERSION, self.version, {"app": self.version, "buendel": buendel})
|
||||
# Die App liest alle drei aus derselben Entität: welchen Stand diese
|
||||
# Installation ausliefert, ob ein nachladbares OTA-Bündel danebenliegt,
|
||||
# und ob eine neuere Fassung bei Gitea bereitliegt. Zusammen an einer
|
||||
# Stelle, damit die App nicht mehrere Quellen abgleichen muss, die
|
||||
# auseinanderlaufen können.
|
||||
self.setzen(
|
||||
E_APP_VERSION,
|
||||
self.version,
|
||||
{
|
||||
"app": self.version,
|
||||
"buendel": buendel,
|
||||
"integration_update": self.letzte_update_pruefung,
|
||||
},
|
||||
)
|
||||
|
||||
async def alles_veroeffentlichen(self) -> None:
|
||||
await self.app_version_veroeffentlichen()
|
||||
await self.profil_veroeffentlichen()
|
||||
await self.fahrten_veroeffentlichen()
|
||||
await self.tankvorgaenge_veroeffentlichen()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.8.24.1",
|
||||
"version": "2026.8.24.4",
|
||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||
"codeowners": ["@paul"],
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
jetzt_aktualisieren:
|
||||
screening_jetzt:
|
||||
backup_jetzt:
|
||||
update_pruefen:
|
||||
update_installieren:
|
||||
|
||||
profil_schreiben:
|
||||
fields:
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Selbst-Update",
|
||||
"description": "Zugriffstoken für gitea.nothaft.cloud, damit die Integration ihre eigene neueste Fassung laden und sich selbst ersetzen kann - ersetzt install.ps1, das Windows Smart App Control zuverlässig blockiert. Ein Token mit reinem Lesezugriff auf das Repository genügt. Leer lassen deaktiviert die Update-Prüfung.",
|
||||
"data": {
|
||||
"gitea_token": "Gitea-Zugriffstoken"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"jetzt_aktualisieren": {
|
||||
"name": "Jetzt aktualisieren",
|
||||
@@ -169,6 +180,14 @@
|
||||
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
|
||||
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
|
||||
}
|
||||
},
|
||||
"update_pruefen": {
|
||||
"name": "Auf Update prüfen",
|
||||
"description": "Fragt bei Gitea die neueste manifest.json ab und vergleicht die Version - lädt dabei noch nichts herunter."
|
||||
},
|
||||
"update_installieren": {
|
||||
"name": "Update installieren",
|
||||
"description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Selbst-Update",
|
||||
"description": "Zugriffstoken für gitea.nothaft.cloud, damit die Integration ihre eigene neueste Fassung laden und sich selbst ersetzen kann - ersetzt install.ps1, das Windows Smart App Control zuverlässig blockiert. Ein Token mit reinem Lesezugriff auf das Repository genügt. Leer lassen deaktiviert die Update-Prüfung.",
|
||||
"data": {
|
||||
"gitea_token": "Gitea-Zugriffstoken"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"jetzt_aktualisieren": {
|
||||
"name": "Jetzt aktualisieren",
|
||||
@@ -169,6 +180,14 @@
|
||||
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
|
||||
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
|
||||
}
|
||||
},
|
||||
"update_pruefen": {
|
||||
"name": "Auf Update prüfen",
|
||||
"description": "Fragt bei Gitea die neueste manifest.json ab und vergleicht die Version - lädt dabei noch nichts herunter."
|
||||
},
|
||||
"update_installieren": {
|
||||
"name": "Update installieren",
|
||||
"description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regressionstest für aktualisierung.py's entpacken_pruefen_tauschen().
|
||||
|
||||
Testet die Sicherheitslogik des Selbst-Updates komplett isoliert: kein
|
||||
Gitea-Zugriff, kein Home Assistant, nur ein selbstgebautes Zip gegen einen
|
||||
Temp-Ordner. Deckt genau die Fälle ab, die vor jedem Tausch abgefangen
|
||||
werden müssen (siehe Moduldocstring in aktualisierung.py):
|
||||
- erfolgreicher Tausch inklusive Backup der alten Fassung
|
||||
- falsche Domain im heruntergeladenen Manifest
|
||||
- fehlendes version-Feld
|
||||
- fehlende manifest.json überhaupt
|
||||
- kaputtes Zip
|
||||
- der gesuchte Pfad fehlt im Archiv
|
||||
|
||||
Aufruf: python3 tests/aktualisierung/test_aktualisierung.py
|
||||
(braucht das homeassistant-Paket, weil aktualisierung.py es importiert -
|
||||
in der Integration selbst immer vorhanden, hier z. B. im Test-Container)
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
_HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(
|
||||
0, os.path.join(os.path.dirname(os.path.dirname(_HIER)), "custom_components", "audi_dashboard")
|
||||
)
|
||||
|
||||
import aktualisierung as a # noqa: E402
|
||||
|
||||
|
||||
def _zip_bauen(dateien: dict[str, bytes], wurzel: str = "audi-app-main") -> bytes:
|
||||
"""Baut ein Zip mit den gegebenen Dateien unter
|
||||
<wurzel>/custom_components/audi_dashboard/<pfad> - genau die Form, in
|
||||
der Gitea ein Repo-Archiv liefert."""
|
||||
puffer = io.BytesIO()
|
||||
with zipfile.ZipFile(puffer, "w") as archiv:
|
||||
for pfad, inhalt in dateien.items():
|
||||
archiv.writestr(f"{wurzel}/custom_components/audi_dashboard/{pfad}", inhalt)
|
||||
return puffer.getvalue()
|
||||
|
||||
|
||||
_GUELTIGES_MANIFEST = b'{"domain": "audi_dashboard", "version": "2099.1.1.1"}'
|
||||
|
||||
|
||||
class ErfolgreicherTausch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.arbeitsordner = tempfile.mkdtemp()
|
||||
self.integration = os.path.join(self.arbeitsordner, "audi_dashboard")
|
||||
self.staging = os.path.join(self.arbeitsordner, "audi_dashboard_update_staging")
|
||||
self.backup = os.path.join(self.arbeitsordner, "audi_dashboard_backup")
|
||||
os.makedirs(self.integration)
|
||||
with open(os.path.join(self.integration, "manifest.json"), "wb") as f:
|
||||
f.write(b'{"domain": "audi_dashboard", "version": "2026.1.1.1"}')
|
||||
with open(os.path.join(self.integration, "alte_datei.py"), "wb") as f:
|
||||
f.write(b"# alte Fassung\n")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.arbeitsordner, ignore_errors=True)
|
||||
|
||||
def test_tauscht_und_sichert_die_alte_fassung(self):
|
||||
zip_bytes = _zip_bauen({
|
||||
"manifest.json": _GUELTIGES_MANIFEST,
|
||||
"neue_datei.py": b"# neue Fassung\n",
|
||||
"frontend/app.js": b"// panel\n",
|
||||
})
|
||||
version = a.entpacken_pruefen_tauschen(
|
||||
zip_bytes, self.integration, self.staging, self.backup
|
||||
)
|
||||
self.assertEqual(version, "2099.1.1.1")
|
||||
|
||||
# Live-Ordner trägt jetzt die neue Fassung.
|
||||
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
|
||||
self.assertIn(b'"2099.1.1.1"', f.read())
|
||||
self.assertTrue(os.path.exists(os.path.join(self.integration, "neue_datei.py")))
|
||||
self.assertTrue(os.path.exists(os.path.join(self.integration, "frontend", "app.js")))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.integration, "alte_datei.py")))
|
||||
|
||||
# Alte Fassung bleibt vollständig und unverändert im Backup-Ordner.
|
||||
with open(os.path.join(self.backup, "manifest.json"), "rb") as f:
|
||||
self.assertIn(b'"2026.1.1.1"', f.read())
|
||||
self.assertTrue(os.path.exists(os.path.join(self.backup, "alte_datei.py")))
|
||||
|
||||
# Kein Staging-Rest übrig.
|
||||
self.assertFalse(os.path.exists(self.staging))
|
||||
|
||||
def test_entfernt_pycache_aus_dem_staging_ordner(self):
|
||||
zip_bytes = _zip_bauen({
|
||||
"manifest.json": _GUELTIGES_MANIFEST,
|
||||
"__pycache__/alte.cpython-313.pyc": b"\x00\x00",
|
||||
})
|
||||
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.integration, "__pycache__")))
|
||||
|
||||
|
||||
class AbgelehnteFaelle(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.arbeitsordner = tempfile.mkdtemp()
|
||||
self.integration = os.path.join(self.arbeitsordner, "audi_dashboard")
|
||||
self.staging = os.path.join(self.arbeitsordner, "audi_dashboard_update_staging")
|
||||
self.backup = os.path.join(self.arbeitsordner, "audi_dashboard_backup")
|
||||
os.makedirs(self.integration)
|
||||
with open(os.path.join(self.integration, "manifest.json"), "wb") as f:
|
||||
f.write(b'{"domain": "audi_dashboard", "version": "2026.1.1.1"}')
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.arbeitsordner, ignore_errors=True)
|
||||
|
||||
def _unangetastet(self):
|
||||
"""Nach jedem abgelehnten Fall muss der Live-Ordner exakt so
|
||||
dastehen wie vorher - keiner der Fehlerfälle darf vor dem Tausch
|
||||
etwas am Live-Ordner verändern (siehe Moduldocstring)."""
|
||||
with open(os.path.join(self.integration, "manifest.json"), "rb") as f:
|
||||
self.assertIn(b'"2026.1.1.1"', f.read())
|
||||
self.assertFalse(os.path.exists(self.backup))
|
||||
|
||||
def test_lehnt_falsche_domain_ab(self):
|
||||
zip_bytes = _zip_bauen({
|
||||
"manifest.json": b'{"domain": "hacs", "version": "2099.1.1.1"}',
|
||||
})
|
||||
with self.assertRaises(a.AktualisierungsFehler):
|
||||
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
|
||||
self._unangetastet()
|
||||
|
||||
def test_lehnt_fehlendes_version_feld_ab(self):
|
||||
zip_bytes = _zip_bauen({
|
||||
"manifest.json": b'{"domain": "audi_dashboard"}',
|
||||
})
|
||||
with self.assertRaises(a.AktualisierungsFehler):
|
||||
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
|
||||
self._unangetastet()
|
||||
|
||||
def test_lehnt_fehlende_manifest_ab(self):
|
||||
zip_bytes = _zip_bauen({"irgendeine_datei.py": b"# leer\n"})
|
||||
with self.assertRaises(a.AktualisierungsFehler):
|
||||
a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup)
|
||||
self._unangetastet()
|
||||
|
||||
def test_lehnt_kaputtes_zip_ab(self):
|
||||
with self.assertRaises(a.AktualisierungsFehler):
|
||||
a.entpacken_pruefen_tauschen(
|
||||
b"das ist kein Zip", self.integration, self.staging, self.backup
|
||||
)
|
||||
self._unangetastet()
|
||||
|
||||
def test_lehnt_archiv_ohne_gesuchten_pfad_ab(self):
|
||||
puffer = io.BytesIO()
|
||||
with zipfile.ZipFile(puffer, "w") as archiv:
|
||||
archiv.writestr("irgendein-anderes-repo-main/README.md", b"# anderes Projekt\n")
|
||||
with self.assertRaises(a.AktualisierungsFehler):
|
||||
a.entpacken_pruefen_tauschen(
|
||||
puffer.getvalue(), self.integration, self.staging, self.backup
|
||||
)
|
||||
self._unangetastet()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user