diff --git a/AGENTS.md b/AGENTS.md index 1a8bf19..ba25df8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.md b/README.md index 2dba268..bf69296 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/VERSIONIERUNG.md b/VERSIONIERUNG.md index 72a04b9..f6d5dc7 100644 --- a/VERSIONIERUNG.md +++ b/VERSIONIERUNG.md @@ -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. diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index b44529a..29d26e3 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -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 { + 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 { + 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 diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 5be5ee6..f8fd40b 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -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 */ diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx index 9f1aff3..b553c7b 100644 --- a/companion-app/src/daten/DatenKontext.tsx +++ b/companion-app/src/daten/DatenKontext.tsx @@ -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([]) const [serverVersion, setzeServerVersion] = useState(null) const [otaBuendel, setzeOtaBuendel] = useState(null) + const [integrationUpdate, setzeIntegrationUpdate] = useState( + 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, diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts index 8d562a4..3e622f1 100644 --- a/companion-app/src/daten/service.test.ts +++ b/companion-app/src/daten/service.test.ts @@ -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() }) }) diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts index 2d88f16..96a9895 100644 --- a/companion-app/src/daten/service.ts +++ b/companion-app/src/daten/service.ts @@ -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) diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 49bed5e..14fbb79 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -38,9 +38,11 @@ export function Einstellungen({ tankvorgaenge, rohprofil, otaBuendel, + integrationUpdate, api, profilSpeichern, jetztAktualisieren, + neuLaden, } = useDaten() const [theme, themeSetzen] = useTheme() const [entwurf, setzeEntwurf] = useState(null) @@ -49,6 +51,8 @@ export function Einstellungen({ const [importOffen, setzeImportOffen] = useState(false) const [otaLaeuft, setzeOtaLaeuft] = useState(false) const [otaFehler, setzeOtaFehler] = useState(null) + const [updatePruefenLaeuft, setzeUpdatePruefenLaeuft] = useState(false) + const [updateInstallierenLaeuft, setzeUpdateInstallierenLaeuft] = useState(false) const dateiwahl = useRef(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({ )} + + Integration-Update + {integrationUpdate?.installiert ? ( + <> +

+ Version {integrationUpdate.version} installiert. Home Assistant neu starten, damit + die neue Fassung geladen wird — Einstellungen → System → Neu starten. +

+
+ void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}> + {updatePruefenLaeuft ? "Prüfe …" : "Erneut prüfen"} + +
+ + ) : integrationUpdate?.verfuegbar ? ( + <> + } + /> +

+ 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. +

+
+ void updateInstallierenAusloesen()} + disabled={updateInstallierenLaeuft} + > + {updateInstallierenLaeuft ? "Installiere …" : "Update installieren"} + +
+ + ) : ( + <> +

+ {integrationUpdate + ? `Integration ist aktuell (${integrationUpdate.geprueft_am ? datumZeit(integrationUpdate.geprueft_am) : ""}).` + : "Noch nicht geprüft."} +

+
+ void updatePruefenAusloesen()} disabled={updatePruefenLaeuft}> + {updatePruefenLaeuft ? "Prüfe …" : "Auf Update prüfen"} + +
+ + )} + {integrationUpdate?.fehler &&

{integrationUpdate.fehler}

} +
+ Zugang 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,7 +82,9 @@ 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)}` - : null + : 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 // kein einstellbares Intervall, rechnet also immer mit der Herstellervorgabe @@ -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) => Ölwechsel - {oelHatEintrag ? ( + {oelHatEintrag || oelHatMeldung ? ( <> {oelHerstellervorgabe &&
Herstellervorgabe
}
{oelWert}
@@ -123,7 +144,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => Inspektion - {inspHatEintrag ? ( + {inspHatEintrag || inspHatMeldung ? ( <>
Herstellervorgabe
{inspWert}
diff --git a/companion-app/src/screens/Uebersicht.tsx b/companion-app/src/screens/Uebersicht.tsx index 3553777..e741517 100644 --- a/companion-app/src/screens/Uebersicht.tsx +++ b/companion-app/src/screens/Uebersicht.tsx @@ -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() diff --git a/companion-app/vite.config.ts b/companion-app/vite.config.ts index d09f043..74482e4 100644 --- a/companion-app/vite.config.ts +++ b/companion-app/vite.config.ts @@ -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 diff --git a/custom_components/audi_dashboard/__init__.py b/custom_components/audi_dashboard/__init__.py index ea90a77..b071d72 100644 --- a/custom_components/audi_dashboard/__init__.py +++ b/custom_components/audi_dashboard/__init__.py @@ -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) diff --git a/custom_components/audi_dashboard/aktualisierung.py b/custom_components/audi_dashboard/aktualisierung.py new file mode 100644 index 0000000..0a4a7a6 --- /dev/null +++ b/custom_components/audi_dashboard/aktualisierung.py @@ -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 diff --git a/custom_components/audi_dashboard/bilder.py b/custom_components/audi_dashboard/bilder.py index 9887da1..a29e9b2 100644 --- a/custom_components/audi_dashboard/bilder.py +++ b/custom_components/audi_dashboard/bilder.py @@ -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 diff --git a/custom_components/audi_dashboard/config_flow.py b/custom_components/audi_dashboard/config_flow.py index c17dea7..609278d 100644 --- a/custom_components/audi_dashboard/config_flow.py +++ b/custom_components/audi_dashboard/config_flow.py @@ -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, + ), + ) diff --git a/custom_components/audi_dashboard/const.py b/custom_components/audi_dashboard/const.py index 48def17..fe7952d 100644 --- a/custom_components/audi_dashboard/const.py +++ b/custom_components/audi_dashboard/const.py @@ -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" diff --git a/custom_components/audi_dashboard/dienste.py b/custom_components/audi_dashboard/dienste.py index 9000872..3e8fa72 100644 --- a/custom_components/audi_dashboard/dienste.py +++ b/custom_components/audi_dashboard/dienste.py @@ -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(): diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 1db02b7..33c011f 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.8.24.1","sha256":"967ab36fc866187cf5fdc03d95a3ec21cb6e42003d8c012f8f67b99c67373324","bytes":230770,"gebaut":"2026-08-24T07:16:44Z"} \ No newline at end of file +{"version":"2026.8.24.4","sha256":"cbce1baa785a3577fc65168b9f8b2094ecd844aadc2ac36f99d6be8ccb4cb8c1","bytes":231567,"gebaut":"2026-08-24T09:39:59Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index f2b81fb..aa45278 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 7086940..689c9db 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -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 = `
${iconMarkup}${t.art}
`; + // 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 `
+ if (!iv && !hatMeldung) return `
${kopf}
kein Eintrag im Servicebuch
`; - const anzeige = iv.hersteller || iv.eigen; - const fm = fahrzeugMeldungRoh(t.art); - const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null; + 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 `
${kopf} @@ -2331,11 +2382,37 @@ function vEinst() {
Fahrzeugdaten
FMM003Zündung · Batterie
Position
FMM003GPS-Tracker
- Updates laufen über HACS - - HACS meldet selbst, wenn eine neuere Fassung bereitliegt, und installiert sie. - Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben dabei unangetastet — - die liegen außerhalb des Integrationsordners. +
+
Integration-Update + ${(() => { + 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 ` + Version ${esc(u.version)} installiert. Home Assistant neu starten, damit die neue + Fassung geladen wird - Einstellungen -> System -> Neu starten. + `; + } + if (u && u.verfuegbar) { + return `
Verfügbare Version
${esc(u.version)}
+ + 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. + `; + } + return ` + ${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. + `; + })()} + ${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler + ? `${esc(INTEGRATION_UPDATE.fehler)}` + : ""}
`; } @@ -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) { diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index ac6ac2a..f38bee6 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -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() diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 215dd8a..8a90256 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -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"], diff --git a/custom_components/audi_dashboard/services.yaml b/custom_components/audi_dashboard/services.yaml index 11a8f89..3a8abd0 100644 --- a/custom_components/audi_dashboard/services.yaml +++ b/custom_components/audi_dashboard/services.yaml @@ -10,6 +10,8 @@ jetzt_aktualisieren: screening_jetzt: backup_jetzt: +update_pruefen: +update_installieren: profil_schreiben: fields: diff --git a/custom_components/audi_dashboard/strings.json b/custom_components/audi_dashboard/strings.json index 8b17676..4506780 100644 --- a/custom_components/audi_dashboard/strings.json +++ b/custom_components/audi_dashboard/strings.json @@ -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." } } } diff --git a/custom_components/audi_dashboard/translations/de.json b/custom_components/audi_dashboard/translations/de.json index 8b17676..4506780 100644 --- a/custom_components/audi_dashboard/translations/de.json +++ b/custom_components/audi_dashboard/translations/de.json @@ -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." } } } diff --git a/tests/aktualisierung/test_aktualisierung.py b/tests/aktualisierung/test_aktualisierung.py new file mode 100644 index 0000000..d0d7fb4 --- /dev/null +++ b/tests/aktualisierung/test_aktualisierung.py @@ -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 + /custom_components/audi_dashboard/ - 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()