diff --git a/AGENTS.md b/AGENTS.md index ac2f219..916a6f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2309,6 +2309,38 @@ error, not as a permanent reminder cluttering the tile before the first check idle branch; the same information already surfaces via `integration_update.fehler` when it's actually true. companion-app never had this line in the first place, so no parity fix needed there. +**A real bug found the same day, from a genuinely confusing error report.** After a successful +install-then-restart cycle, a later "Update installieren" attempt failed with: +`Konnte den bestehenden Ordner nicht sichern: [Errno 2] No such file or directory: +'/config/custom_components/audi_dashboard_backup' -> '/config/custom_components/audi_dashboard_backup'` +— note both sides of the `os.rename` are the *same path*, which shouldn't be possible given +`INTEGRATIONSORDNER`/`_BACKUP_ORDNER` are built from clearly different string literals. Root cause, +found via `docker logs`: the log line's logger name was `custom_components.audi_dashboard_backup.dienste`, +not `custom_components.audi_dashboard.dienste`. Home Assistant scans **every** folder directly under +`custom_components/` for a `manifest.json` and loads whatever `domain` it declares — by folder +content, not folder name. The `audi_dashboard_backup` folder left behind by a successful swap is a +byte-for-byte copy of the integration, manifest included, so across a restart HA loaded it as a +*second* `audi_dashboard` integration. Python then imported its `dienste.py` as its own module +(`custom_components.audi_dashboard_backup.dienste`), whose `__file__`-derived `INTEGRATIONSORDNER` +*is* the backup folder — making `_BACKUP_ORDNER` collapse onto the same path for that rogue copy. +This duplicate-domain state also explained two other symptoms reported the same session: the panel +intermittently doing nothing visible on "Auf Update prüfen"/"Update installieren" (whichever +integration's service registration won the race), and the frontend getting stuck on the bootstrap +"Lädt …" screen after a restart (confirmed live: with the stray `audi_dashboard_backup` folder still +present, `audi_dashboard` appeared **twice** in the loader's warnings and the panel never finished +loading; deleting the stray folder and restarting fixed both immediately). + +**Fix**, in `entpacken_pruefen_tauschen()`: right after the backup rename succeeds, its +`manifest.json` is itself renamed to `manifest.json.bak` — structurally invalid as an integration, so +HA's scanner skips it. On the rollback path (second rename fails), the `.bak` is restored to +`manifest.json` *before* the backup folder is renamed back into place, so a restored live folder is +never left without a valid manifest. Covered by two new unit tests (manifest absent from the backup +folder after a successful swap; manifest correctly restored after a forced rollback via a mocked +`os.rename`) — `tests/aktualisierung/test_aktualisierung.py` now has 8 tests, all passing. Verified +live: deployed the fix, deleted the stray backup folder from the test container, restarted — single +`audi_dashboard` loader warning, clean `eingerichtet` line, frontend loads normally. Manifest bumped +to `2026.8.24.7` (see section M below, same version bump covers both fixes). + ### K) vCard import for the workshop contact (built 2026-08-24) Service → Autohaus (gear icon) now has a "vCard importieren" button next to the existing form @@ -2404,6 +2436,149 @@ the pre-restart file swap). companion-app: `tsc --noEmit` clean, full suite stil bumped to `2026.8.24.6` and `npm run ota` rerun per the standing rule in `VERSIONIERUNG.md` — both frontends changed (new spinner/restart-button behavior is real, visible UI, not internal-only). +### M) Übersicht/Service mismatch on a blank install: two real bugs (2026-08-24, same day) + +The owner tested on a **blank installation** (no Servicebuch entries yet) and found two bugs: + +1. **Service showed the vehicle's own km/date; Übersicht showed something else (or nothing).** Root + cause was worse on the panel than in companion-app: `termine()` (panel) built its candidate list + purely from `letzterEintrag()` (a real Servicebuch entry) — with none, `km`/`datum`/`ziel` all + stayed `null`, so `vHome()` (Übersicht) rendered a broken/empty service block, while `vService()` + already had its own separate `meldungsPrognose()` fallback and displayed correctly. companion-app's + `naechsterService()` did already call `meldungsPrognose()` as a fallback, but paired the *raw* + signed `fahrzeug.oelwechselFaelligKm` with a *computed* projected date, which could read + inconsistently with `Service.tsx`'s own primary line. Fixed by adding the same meldung-based + fallback to `termine()` (new block right after `oelTermin`/`inspTermin` are built: `if + (termin.basis) return;` then reads `oelMeldungsPrognose()`/`meldungsPrognose()`), and by having + `meldungsPrognose()` always return `restKm` (`Math.abs(faelligKm)`) so both codebases use one + consistent value instead of recomputing it differently at each call site. Verified live: emptied + the test container's `service.buch` array (backed up first, restored after), confirmed Service and + Übersicht showed byte-identical numbers (8.687 km / 28.03.2027) where Übersicht previously would + have shown nothing usable at all. + +2. **The Ölwechsel forecast ignored the owner's own (shorter) interval from "Einrichten" until a + Servicebuch entry existed** — it silently kept showing the manufacturer's own countdown instead, + with no restKm shown either. Root cause: `meldungsPrognose()`/`fahrzeugMeldungRoh()` only ever + relay the vehicle sensor's own manufacturer-interval countdown; nothing about them reads + `oel.modus`/`oel.km`/`oel.monate`. Without a Servicebuch entry there's no known "last real oil + change" to anchor a custom-interval projection from — the owner chose (asked directly, see + conversation) to estimate one anyway: back-calculate an assumed last-service point as + `(manufacturer's own due point) − (manufacturer's own interval length)`, then project the owner's + shorter interval from that same assumed point. An approximation, not exact — it can never be as + good as a real Servicebuch entry — but it's the only way to show a number that respects the + owner's chosen interval before the first entry exists, instead of silently ignoring the choice. + Implemented once in `service.ts` + (`meldungsPrognoseEigenesIntervall()` + `oelMeldungsPrognose()`, the latter dispatching to the + existing `meldungsPrognose()` unchanged when `modus === "hersteller"`) and mirrored 1:1 in the + panel (same two functions, same math, `Date`-based month arithmetic instead of `service.ts`'s + ISO-string `monatePlus()` since the panel's own `monatePlus()` only accepts `TT.MM.JJJJ` strings + and the vehicle meldung's timestamp is ISO). Both `Service.tsx`/`vService()`'s footnote for this + fallback case now show `restKm` alongside the date when the owner's interval is shorter than the + manufacturer's, exactly like the existing Servicebuch-based prognose already does. Inspektion is + unaffected — it has no owner-selectable interval, always the manufacturer's own. + + Only Ölwechsel has a selectable interval, so this only applies there. If `oel.herstellerKm`/ + `herstellerMonate` are themselves unknown, both functions degrade to the plain (unchanged) + `meldungsPrognose()` rather than guessing further. + +Verified: companion-app — 4 new tests for `meldungsPrognoseEigenesIntervall()`/`oelMeldungsPrognose()` +(restKm/date math, the "already overdue by the owner's interval" clamp-to-zero case, no-fahrten +fallback, graceful degradation without hersteller values), full suite green at 142/142, `tsc --noEmit` +clean. Panel: `node --check` clean; live-verified in the test container using the exact same +blank-install simulation as bug 1 above — with `service.buch` emptied and the container's real +`oel` settings (`modus: "eigen"`, `10.000 km`/`12 Monate`, manufacturer `30.000 km`/`24 Monate`), the +footnote showed **8.700 km, voraussichtlich am 23.07.2027** — the derived number from +`28.700 − 30.000 + 10.000`, using the vehicle's real raw meldung — closely matching (not identical to, +as expected for an approximation) the real Servicebuch-based value of 8.687 km / 28.03.2027 seen with +the Servicebuch restored. Restored the container's real profile afterward; no live data was left +altered. + +Manifest bumped to `2026.8.24.7` (covers this section and section J's manifest-neutering fix above — +one version bump, both landed the same day); superseded by `2026.8.24.8` in section N below the same +day. `npm run ota` rerun once, covering both this section's and section N's frontend changes together +(see section N). + +### N) Sicherheit neu gestaltet: verriegelt / Türen & Klappen / Fenster & Dach / Licht (2026-08-24, same day) + +The owner asked for a rework of the "Sicherheit" screen (`vSicherheit()`/`Fahrzeugstatus.tsx`), which +until now showed one flat list of every door/window/heckklappe/haube check with no grouping and no +lock, roof, or light status at all. Requested: four named collector rows — **Fahrzeug verriegelt** +(new lock sensors, state "abgeschlossen"), **Türen und Klappen geschlossen** (with an arrow to a +detail view of every door, Motorhaube, and Kofferraum individually), **Fenster und Dach geschlossen** +(Dach = Schiebedach), **Licht ausgeschaltet** (Standlicht, showing the text **"Kein Licht"** for the +good state, not a generic "in Ordnung"). The owner also supplied a concrete mockup: green +checkmark-circles instead of the old plain-color dot, only the second row carrying a chevron. + +Went through plan mode given the cross-codebase, backend+frontend scope. Before planning, checked the +real entities available in the test container's `core.entity_registry` (`cupra_eu_data_act` platform) +to ground the sensor-role design in what actually exists rather than guessing: per-door +`binary_sensor.*_door_lock` (`device_class: lock`), `..._tailgate_lock`, `..._hood_lock`, `..._sunroof` +(`device_class: window`), `..._parking_lights` (`device_class: light`). HA's own convention for all of +these is `off` = the good state (locked/closed/no light) — the exact same `state == "off"` → `ok=True` +check this integration already uses for door/window sensors. No new check pattern needed, only new +sensor roles. + +**Backend:** +- `einstellungen.py` — five new `Sensorzuordnung` fields: `TUERSCHLOSS_SENSOREN` (4 positions, mirrors + `TUER_SENSOREN`), `HECKKLAPPE_SCHLOSS_SENSOR`, `HAUBE_SCHLOSS_SENSOR` (paired with the existing + open-sensors), `DACH_SENSOR`, `LICHT_SENSOR`. Matching `FELDER` catalog entries under the existing + `"sicherheit"` group — the Setup-Menü is entirely `FELDER`-driven (confirmed: its group headers come + straight from this list), so the new roles appeared there with zero extra frontend work. +- `veroeffentlichung.py`'s `_sicherheitscheck()` — extended with the five new checks, each entry now + additionally tagged `"gruppe"`: `"verriegelt"` (all 6 locks), `"tueren_klappen"` (4 doors + Motorhaube + + the renamed "Kofferraum" — was "Heckklappe"; the underlying `HECKKLAPPE_SENSOR` field itself is + unchanged, so an existing assignment isn't silently broken by the relabel), `"fenster_dach"` (4 + windows + Dach), `"licht"` (Standlicht alone). `gesichert` is untouched — `all()` over the full flat + list is mathematically identical to "AND of all four group results," so no logic change, just more + entries in the same list. +- **Consequence flagged to the owner**: because every entry in the flat list (including brand-new, + unassigned ones) counts toward `gesichert`, any existing installation shows "Zustand nicht + vollständig bekannt" for the overall status until the three new roles are assigned in Setup — same + "unknown beats assumed-good" principle this project has followed throughout, but worth knowing before + it looks like a regression. + +**Frontend (both codebases):** a small grouping helper (`sicherheitsGruppe()` in the panel, +`gruppenErgebnis()` + filter in companion-app) turns the flat list into a group's aggregate `ok` +(`null` the moment one entry is unknown, or if the group has zero entries at all — never `true` via an +empty-array vacuous truth). Both `vSicherheit()`/`Fahrzeugstatus.tsx` rewritten to the four-row layout; +each row's own label text changes with its state (e.g. "Kein Licht" / "Licht eingeschaltet" / "Licht +unbekannt") rather than pairing a fixed label with a separate status word — matches the mockup, which +shows no secondary value text at all. New checkmark-circle indicator (`statusKreis()` in the panel, +`StatusKreis` component in companion-app's `bausteine.tsx`) — green check / red cross / amber +question-mark SVG in a filled circle, replacing the old plain dot for this screen only; the rest of the +app keeps `.dot`/`.dm-punkt` unchanged. New route `"tuerenklappen"` in both routing tables +(`ZURUECK`/`navigation.ts`), reachable only from the "Türen und Klappen geschlossen" row; its detail +page is the old flat-list style filtered to just that group (`vTuerenKlappen()`, new +`screens/TuerenKlappen.tsx`). + +**Verified:** +- Backend: `python3 -m py_compile` on both changed modules, clean restart in the test container with a + single `audi_dashboard` loader warning (no duplicate-domain regression from section J/M's fix). +- companion-app: `tsc --noEmit` clean, full suite green at 145/145 (added: grouping/unknown-propagation + test, chevron-navigates-only-this-row test, detail-page-shows-only-its-group test; updated the one + pre-existing test and the `beispieldaten.ts`/`profilAdapter.test.ts` fixtures for the now-required + `gruppe` field on `SicherheitsPunkt`). +- Panel: `node --check` clean. +- Live end-to-end in the browser against the test container: before assigning the new sensor roles, all + three new rows correctly showed "unbekannt" (amber) and the overall status read "Zustand nicht + vollständig bekannt" — confirming the flagged consequence above is real and correctly wired, not + theoretical. Opened Setup, scrolled to "Sicherheit": all five new fields were **already + auto-suggested** to the exact real entities found earlier (the existing stichworte/device_class + matching worked without any manual searching) — saved (one pre-existing, unrelated "doppelt + zugeordnet" warning about `Front left door`/`Front left window`, present before this change, dismissed + via the existing "Trotzdem speichern" override). After saving: "Fahrzeug nicht verriegelt" showed red + (the demo lock entities report unlocked — correct, not a bug), "Türen und Klappen geschlossen" and + "Fenster und Dach geschlossen" showed green, **"Kein Licht" showed green** — the exact wording asked + for. Clicked the chevron on row 2: navigated to "Türen und Klappen" showing all four doors, Kofferraum, + and Motorhaube individually, all green "zu" — confirmed the detail page and the "Kofferraum" relabel + both work. + +Manifest bumped to `2026.8.24.8`. `npm run ota` rerun (`scripts/ota-paket.ps1 -Bauen`): picked up +`2026.8.24.8` automatically from the manifest, built clean, `bundle.zip` (233.244 Bytes) + sha256 + +`bundle.json` written — covers both this section's and section M's frontend changes, since neither had +triggered a bundle rebuild yet. `Installieren.cmd` picks the bundle up automatically; no separate step +needed for a self-update-based deployment. + --- ## Working conventions (observed — keep them) diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index f8fd40b..a17f1cb 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -88,10 +88,13 @@ export interface Tankvorgang { edited_fields?: string[]; } -/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt". */ +/** Einzelprüfung aus _sicherheitscheck: ok===null heißt "Sensor unbekannt". + "gruppe" fasst mehrere Punkte zu einer Sammelzeile zusammen (verriegelt / + tueren_klappen / fenster_dach / licht), siehe Fahrzeugstatus.tsx. */ export interface SicherheitsPunkt { label: string; ok: boolean | null; + gruppe: "verriegelt" | "tueren_klappen" | "fenster_dach" | "licht"; } /** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation diff --git a/companion-app/src/daten/profilAdapter.test.ts b/companion-app/src/daten/profilAdapter.test.ts index e4957e4..f802b9e 100644 --- a/companion-app/src/daten/profilAdapter.test.ts +++ b/companion-app/src/daten/profilAdapter.test.ts @@ -94,7 +94,7 @@ describe("profilZuFahrzeug mit Fahrzeugstatus", () => { tankprozent: 62, reichweite_km: 385, gesichert: true, - sicherheitscheck: [{ label: "Tür vorne links", ok: true }], + sicherheitscheck: [{ label: "Tür vorne links", ok: true, gruppe: "tueren_klappen" }], }) expect(f.odo).toBe(48250) expect(f.odoBekannt).toBe(true) diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts index 3e622f1..544cf9a 100644 --- a/companion-app/src/daten/service.test.ts +++ b/companion-app/src/daten/service.test.ts @@ -5,7 +5,9 @@ import { kmProTagAusFahrten, letzterOelwechsel, meldungsPrognose, + meldungsPrognoseEigenesIntervall, naechsterService, + oelMeldungsPrognose, oelwechselPrognose, } from "./service" import type { Fahrzeug } from "./profilAdapter" @@ -252,4 +254,96 @@ describe("meldungsPrognose", () => { it("liefert nichts ohne jede brauchbare Angabe", () => { expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull() }) + + it("gibt die Restkilometer mit zurück", () => { + const p = meldungsPrognose("2027-01-01T00:00:00+00:00", 5000, [], jetzt) + expect(p!.restKm).toBe(5000) + }) +}) + +describe("meldungsPrognoseEigenesIntervall", () => { + 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 Restkilometer und Zeitgrenze auf das eigene, kürzere Intervall um", () => { + // Herstellermeldung: 25.000 km / 24 Monate Rest bis zum 2027-08-11 - + // daraus ergibt sich ein angenommener letzter Service vor 2025-08-11. + // Eigenes Intervall (15.000 km / 12 Monate) ab demselben Punkt: Zeit- + // grenze 2026-08-11 (heute), Restkilometer 25000 - 30000 + 15000 = 10000. + const p = meldungsPrognoseEigenesIntervall( + "2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, fahrten, jetzt, + ) + expect(p).not.toBeNull() + expect(p!.restKm).toBe(10000) + // 10.000 km bei 30 km/Tag läge weit in der Zukunft - die zurückgerechnete + // eigene Zeitgrenze (heute) greift zuerst. + expect(p!.durchZeitlimit).toBe(true) + expect(p!.datum).toBe("2026-08-11") + }) + + it("deckelt die Restkilometer bei null, wenn das eigene Intervall rechnerisch schon überschritten ist", () => { + // Erst 10.000 von 30.000 km seit der Herstellerfälligkeit gefahren - + // das kürzere eigene Intervall (15.000 km) ist rechnerisch längst fällig. + const p = meldungsPrognoseEigenesIntervall( + "2027-08-11T00:00:00+00:00", 10000, 30000, 24, 15000, 12, fahrten, jetzt, + ) + expect(p).not.toBeNull() + expect(p!.restKm).toBe(0) + expect(p!.durchZeitlimit).toBe(false) + }) + + it("fällt ohne Fahrtenlog auf die zurückgerechnete eigene Zeitgrenze zurück", () => { + const p = meldungsPrognoseEigenesIntervall( + "2027-08-11T00:00:00+00:00", 25000, 30000, 24, 15000, 12, [], jetzt, + ) + expect(p).not.toBeNull() + expect(p!.datum).toBe("2026-08-11") + expect(p!.durchZeitlimit).toBe(true) + }) + + it("liefert nichts ohne bekannte Herstellerintervall-Länge", () => { + expect( + meldungsPrognoseEigenesIntervall("2027-08-11T00:00:00+00:00", 25000, null, 24, 15000, 12, fahrten, jetzt), + ).toBeNull() + }) +}) + +describe("oelMeldungsPrognose", () => { + const jetzt = new Date("2026-08-11T12:00:00") + 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("übernimmt bei Herstellervorgabe unverändert die reine Fahrzeugmeldung", () => { + const f = fahrzeug(10000, "hersteller") + f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00" + f.oelwechselFaelligKm = 25000 + const p = oelMeldungsPrognose(f, fahrten, jetzt) + expect(p!.restKm).toBe(25000) + expect(p!.datum).toBe("2027-08-11") + }) + + it("rechnet bei eigenem Intervall auf dessen kürzere Vorgabe um", () => { + const f = fahrzeug(10000, "eigen") + f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00" + f.oelwechselFaelligKm = 25000 + const p = oelMeldungsPrognose(f, fahrten, jetzt) + expect(p!.restKm).toBe(10000) + expect(p!.datum).toBe("2026-08-11") + }) + + it("fällt bei eigenem Intervall ohne Herstellerwerte auf die reine Meldung zurück", () => { + const f = fahrzeug(10000, "eigen") + f.oel.herstellerKm = null + f.oelwechselFaelligTs = "2027-08-11T00:00:00+00:00" + f.oelwechselFaelligKm = 25000 + const p = oelMeldungsPrognose(f, fahrten, jetzt) + expect(p!.restKm).toBe(25000) + expect(p!.datum).toBe("2027-08-11") + }) }) diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts index 96a9895..0e6a3ad 100644 --- a/companion-app/src/daten/service.ts +++ b/companion-app/src/daten/service.ts @@ -154,6 +154,8 @@ export interface Meldungsprognose { hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die km-Grenze rechtzeitig zu erreichen. */ durchZeitlimit: boolean + /** Restkilometer bis zur Fälligkeit, falls bekannt. */ + restKm: number | null } /** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne @@ -169,14 +171,74 @@ export function meldungsPrognose( jetzt = new Date(), ): Meldungsprognose | null { const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : null - if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null + if (faelligKm == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null const restKm = Math.abs(faelligKm) - if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false } + if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm } const rate = kmProTagAusFahrten(fahrten, jetzt) - if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null + if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : 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 } + return { datum: durchZeitlimit ? (zeitDatum as string) : kmDatum, durchZeitlimit, restKm } +} + +/** Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die + Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in + "Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service + wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall- + Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und + das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet - + eine Näherung (der wahre letzte Service kann näher oder ferner liegen), + aber ohne sie würde die eigene Intervallwahl bis zum ersten + Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */ +export function meldungsPrognoseEigenesIntervall( + faelligTs: string | null, + faelligKm: number | null, + herstellerKm: number | null, + herstellerMonate: number | null, + eigenKm: number | null, + eigenMonate: number | null, + fahrten: readonly Fahrtdaten[], + jetzt = new Date(), +): Meldungsprognose | null { + if (faelligKm == null || !herstellerKm || eigenKm == null) return null + const restKm = Math.max(0, Math.abs(faelligKm) - herstellerKm + eigenKm) + const zeitDatum = + faelligTs && herstellerMonate != null && eigenMonate != null + ? monatePlus(faelligTs, eigenMonate - herstellerMonate) + : null + if (restKm <= 0) return { datum: jetzt.toISOString().slice(0, 10), durchZeitlimit: false, restKm } + const rate = kmProTagAusFahrten(fahrten, jetzt) + if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : 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, restKm } +} + +/** Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei + Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe), + bei eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall() + hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne + Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst + (kein Sensor dafür), fällt das sanft auf die reine Meldung zurück. */ +export function oelMeldungsPrognose( + fahrzeug: Fahrzeug, + fahrten: readonly Fahrtdaten[], + jetzt = new Date(), +): Meldungsprognose | null { + const oel = fahrzeug.oel + const roh = () => meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt) + if (oel.modus === "hersteller") return roh() + const eigen = meldungsPrognoseEigenesIntervall( + fahrzeug.oelwechselFaelligTs, + fahrzeug.oelwechselFaelligKm, + oel.herstellerKm, + oel.herstellerMonate, + oel.km, + oel.monate, + fahrten, + jetzt, + ) + return eigen ?? roh() } /* ------------------------------------------------------ Nächster Service */ @@ -219,8 +281,8 @@ export function naechsterService( 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 m = oelMeldungsPrognose(fahrzeug, fahrten, jetzt) + if (m) kandidaten.push({ art: "Ölwechsel", restKm: m.restKm, datum: m.datum }) } const insp = inspektionPrognose(fahrzeug, buch, jetzt) @@ -228,7 +290,7 @@ export function naechsterService( 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 }) + if (m) kandidaten.push({ art: "Inspektion", restKm: m.restKm, datum: m.datum }) } // fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein. diff --git a/companion-app/src/navigation.ts b/companion-app/src/navigation.ts index 92481c4..bde607d 100644 --- a/companion-app/src/navigation.ts +++ b/companion-app/src/navigation.ts @@ -33,6 +33,7 @@ export type SeitenName = | "fill" // Sonstige | "sicherheit" + | "tuerenklappen" | "live" | "einst" @@ -64,6 +65,7 @@ export const ZURUECK: Partial> = { sbuch: "service", einst: "home", sicherheit: "home", + tuerenklappen: "sicherheit", live: "home", } @@ -90,6 +92,7 @@ export const TITEL: Record = { trip: "Fahrt", fill: "Tankvorgang", sicherheit: "Fahrzeugstatus", + tuerenklappen: "Türen und Klappen", live: "Fahrt läuft", einst: "Einstellungen", } diff --git a/companion-app/src/screens/Fahrzeugstatus.tsx b/companion-app/src/screens/Fahrzeugstatus.tsx index d253895..7426183 100644 --- a/companion-app/src/screens/Fahrzeugstatus.tsx +++ b/companion-app/src/screens/Fahrzeugstatus.tsx @@ -1,19 +1,24 @@ /** * Fahrzeugstatus im Detail. Vorlage: `vSicherheit()` im alten Panel. * - * Zeigt die 16 Einzelprüfungen aus `_sicherheitscheck`. Entscheidend: - * `ok === null` heißt „Sensor meldet nichts" und wird als unbekannt - * dargestellt — nie als grün geraten. Deshalb ist auch die Gesamtaussage - * unbekannt, sobald eine einzige Prüfung unbekannt ist. + * Vier Sammelzeilen statt der früheren flachen Liste: Fahrzeug verriegelt / + * Türen und Klappen geschlossen / Fenster und Dach geschlossen / Licht + * ausgeschaltet - jede fasst mehrere Punkte aus `_sicherheitscheck` über + * deren `gruppe`-Feld zusammen (siehe `gruppenErgebnis()` in bausteine.tsx). + * Entscheidend bleibt: `ok === null` heißt „Sensor meldet nichts" und wird + * als unbekannt dargestellt — nie als grün geraten. Nur "Türen und Klappen + * geschlossen" führt zu einer Detailseite (TuerenKlappen.tsx) mit jeder Tür, + * Motorhaube und dem Kofferraum einzeln. */ import { StatusRow, Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" import { datumZeit } from "../format" -import { Leerzustand } from "./bausteine" +import type { SeitenName } from "../navigation" +import { gruppenErgebnis, Leerzustand, StatusKreis } from "./bausteine" -export function Fahrzeugstatus() { +export function Fahrzeugstatus({ geheZu }: { geheZu: (name: SeitenName) => void }) { const { fahrzeug, statusStand } = useDaten() if (!fahrzeug) return null @@ -27,8 +32,10 @@ export function Fahrzeugstatus() { ) } - const unbekannt = punkte.filter((p) => p.ok === null).length - const offen = punkte.filter((p) => p.ok === false) + const verriegelt = gruppenErgebnis(punkte.filter((p) => p.gruppe === "verriegelt")) + const tuerenKlappen = gruppenErgebnis(punkte.filter((p) => p.gruppe === "tueren_klappen")) + const fensterDach = gruppenErgebnis(punkte.filter((p) => p.gruppe === "fenster_dach")) + const licht = gruppenErgebnis(punkte.filter((p) => p.gruppe === "licht")) return ( <> @@ -36,45 +43,49 @@ export function Fahrzeugstatus() { status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"} title={ fahrzeug.gesichert === true - ? "Alles verschlossen" + ? "Fahrzeug ist sicher abgestellt" : fahrzeug.gesichert === false - ? `${offen.length} Stelle${offen.length === 1 ? "" : "n"} offen` + ? "Bitte prüfen" : "Zustand nicht vollständig bekannt" } {...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})} /> - Einzelprüfungen -
    - {punkte.map((punkt) => ( -
  • -
  • - ))} -
+
+
+ + + {verriegelt === null ? "Verriegelung unbekannt" : verriegelt ? "Fahrzeug verriegelt" : "Fahrzeug nicht verriegelt"} + +
+ +
+ + + {fensterDach === null ? "Fenster/Dach unbekannt" : fensterDach ? "Fenster und Dach geschlossen" : "Fenster oder Dach offen"} + +
+
+ + + {licht === null ? "Licht unbekannt" : licht ? "Kein Licht" : "Licht eingeschaltet"} + +
+
- - {unbekannt > 0 && ( -

- {unbekannt === 1 ? "Eine Prüfung ist" : `${unbekannt} Prüfungen sind`} unbekannt, weil das - Fahrzeug dazu nichts meldet. Solange das so ist, gilt die Gesamtaussage bewusst als - unbekannt statt als sicher. -

- )} ) } diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx index c37badc..a46b5e7 100644 --- a/companion-app/src/screens/Service.tsx +++ b/companion-app/src/screens/Service.tsx @@ -18,6 +18,7 @@ import { letzterInspektion, letzterOelwechsel, meldungsPrognose, + oelMeldungsPrognose, oelwechselPrognose, } from "../daten/service" import { datum, de, isoTag } from "../format" @@ -70,11 +71,12 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => ? `${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 + // Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei Herstellervorgabe + // unverändert, bei eigenem (kürzerem) Intervall über + // oelMeldungsPrognose()/meldungsPrognoseEigenesIntervall() auf das eigene + // Intervall hochgerechnet, statt es bis zum ersten Servicebucheintrag zu + // ignorieren (siehe deren Kommentare in service.ts). + const oelMeldungProg = oelHatMeldung && !prognose ? oelMeldungsPrognose(fahrzeug, 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 @@ -84,7 +86,9 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => ? `voraussichtlich am ${datum(prognose.datum)}` : `${de(prognose.restKm)} km, voraussichtlich am ${datum(prognose.datum)}` : oelMeldungProg - ? `voraussichtlich am ${datum(oelMeldungProg.datum)}` + ? fahrzeug.oel.modus === "hersteller" || oelMeldungProg.restKm == null + ? `voraussichtlich am ${datum(oelMeldungProg.datum)}` + : `${de(oelMeldungProg.restKm)} km, 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 diff --git a/companion-app/src/screens/TuerenKlappen.tsx b/companion-app/src/screens/TuerenKlappen.tsx new file mode 100644 index 0000000..20d1a87 --- /dev/null +++ b/companion-app/src/screens/TuerenKlappen.tsx @@ -0,0 +1,52 @@ +/** + * Detailseite zu "Türen und Klappen geschlossen" - jede Tür einzeln, + * Motorhaube, Kofferraum. Einzige der vier Sammelzeilen aus Fahrzeugstatus.tsx + * mit eigener Detailseite. Vorlage: die frühere flache Liste in + * Fahrzeugstatus.tsx (vor der Aufteilung in vier Sammelzeilen). + */ + +import { Tile } from "@audi-dash/ui" + +import { useDaten } from "../daten/DatenKontext" +import { Leerzustand } from "./bausteine" + +export function TuerenKlappen() { + const { fahrzeug } = useDaten() + if (!fahrzeug) return null + + const punkte = fahrzeug.sicherheitscheck.filter((p) => p.gruppe === "tueren_klappen") + if (punkte.length === 0) { + return ( + + ) + } + + return ( + + Türen und Klappen +
    + {punkte.map((punkt) => ( +
  • +
  • + ))} +
+
+ ) +} diff --git a/companion-app/src/screens/bausteine.tsx b/companion-app/src/screens/bausteine.tsx index 24f034b..882f7e4 100644 --- a/companion-app/src/screens/bausteine.tsx +++ b/companion-app/src/screens/bausteine.tsx @@ -7,6 +7,8 @@ import type { ReactNode } from "react" import { ActionButton, Tile } from "@audi-dash/ui" +import type { SicherheitsPunkt } from "../api" + /** * Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem * Hinweis, was als Nächstes passiert — nie nur „keine Daten". @@ -94,3 +96,48 @@ export function Werteliste({ kinder }: { kinder: ReactNode }) { export function bestaetigen(frage: string): boolean { return window.confirm(frage) } + +/** + * Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt) + * statt eines einfarbigen Punkts, für die Sicherheit-Seiten (Fahrzeugstatus, + * TuerenKlappen) - Zustand ist so schon am Symbol selbst erkennbar, nicht nur + * an der Farbe (Vorlage vom Nutzer geliefert). + */ +export function StatusKreis({ ok }: { ok: boolean | null }) { + const klasse = + ok === null ? "dm-statuskreis dm-statuskreis--warn" : ok ? "dm-statuskreis" : "dm-statuskreis dm-statuskreis--bad" + return ( + + ) +} + +/** Fasst ok über eine Gruppe zusammen: unbekannt, sobald ein Punkt unbekannt + ist oder die Gruppe ganz ohne Einträge dasteht (kein Sensor dieser Rolle + zugeordnet) - nie true durch eine leere Liste erschlichen. */ +export function gruppenErgebnis(punkte: readonly SicherheitsPunkt[]): boolean | null { + if (punkte.length === 0 || punkte.some((p) => p.ok === null)) return null + return punkte.every((p) => p.ok === true) +} diff --git a/companion-app/src/screens/register.tsx b/companion-app/src/screens/register.tsx index c019469..d67d1f9 100644 --- a/companion-app/src/screens/register.tsx +++ b/companion-app/src/screens/register.tsx @@ -18,6 +18,7 @@ import { Service, Servicebuch, Werkstatt } from "./Service" import { Statistik } from "./Statistik" import { TankDetail } from "./TankDetail" import { Tanken } from "./Tanken" +import { TuerenKlappen } from "./TuerenKlappen" import { Uebersicht } from "./Uebersicht" import { Beitrag, @@ -60,7 +61,9 @@ export function SeiteFuer(props: SeitenProps) { case "home": return case "sicherheit": - return + return + case "tuerenklappen": + return case "audi": return case "ident": diff --git a/companion-app/src/screens/screens.test.tsx b/companion-app/src/screens/screens.test.tsx index be522ac..b17f9ec 100644 --- a/companion-app/src/screens/screens.test.tsx +++ b/companion-app/src/screens/screens.test.tsx @@ -99,11 +99,28 @@ describe("Inhalte kommen wirklich aus den Daten", () => { expect(geheZu).not.toHaveBeenCalled() }) - it("zeigt einen unbekannten Prüfpunkt als unbekannt, nicht als sicher", async () => { + it("zeigt eine Gruppe mit unbekanntem Punkt als unbekannt, nicht als sicher", async () => { const { container } = zeige("sicherheit") + await waitFor(() => expect(container.querySelector(".dm-sicherheitsliste")).not.toBeNull()) + // Motorhaube (ok: null) steckt in "tueren_klappen" - die ganze Gruppe + // gilt damit als unbekannt, nicht als sicher geraten. + expect(screen.getByText("Türen/Klappen unbekannt")).toBeTruthy() + }) + + it("Sicherheit: Türen und Klappen navigiert zur Detailseite, die anderen Zeilen nicht", async () => { + const geheZu = vi.fn() + const { container } = zeige("sicherheit", undefined, geheZu) + await waitFor(() => expect(container.querySelector(".dm-sicherheitsliste")).not.toBeNull()) + + fireEvent.click(screen.getByText("Türen/Klappen unbekannt")) + expect(geheZu).toHaveBeenCalledWith("tuerenklappen") + }) + + it("Türen und Klappen: zeigt nur die Punkte dieser Gruppe", async () => { + const { container } = zeige("tuerenklappen") await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull()) - expect(container.querySelectorAll(".dm-punkt--unbekannt").length).toBe(1) - expect(screen.getByText("unbekannt")).toBeTruthy() + expect(screen.getByText("Motorhaube")).toBeTruthy() + expect(screen.queryByText("Standlicht")).toBeNull() }) it("gruppiert Fahrten nach Jahr", async () => { diff --git a/companion-app/src/stile/screens.css b/companion-app/src/stile/screens.css index 8b46391..0ea60fd 100644 --- a/companion-app/src/stile/screens.css +++ b/companion-app/src/stile/screens.css @@ -486,6 +486,48 @@ border: 1.5px solid var(--fg3); } +/* Kreis-Häkchen für die vier Sammelzeilen der Sicherheit-Seite - Zustand ist + so schon am Symbol erkennbar, nicht nur an der Farbe (siehe .dm-punkt + oben, das bleibt für die Detailliste unverändert). */ +.dm-statuskreis { + flex: none; + width: 22px; + height: 22px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--ok); +} +.dm-statuskreis--warn { background: var(--warn); } +.dm-statuskreis--bad { background: var(--bad); } +.dm-statuskreis svg { display: block; } + +.dm-sicherheitsliste { + display: flex; + flex-direction: column; +} +.dm-sicherheitszeile { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 12px 0; + border-bottom: 1px solid var(--line); + width: 100%; + background: none; + border-left: none; + border-right: none; + border-top: none; + font: inherit; + color: inherit; + text-align: left; +} +.dm-sicherheitszeile:last-child { border-bottom: 0; } +button.dm-sicherheitszeile { cursor: pointer; } +button.dm-sicherheitszeile:active { background: var(--tile-2); } +.dm-sicherheitszeile__text { flex: 1; font-size: 14.5px; } +.dm-sicherheitszeile__chev { margin-left: auto; flex: none; color: var(--fg3); } + /* ------------------------------------------------------------ Galerie */ .dm-galerie { diff --git a/companion-app/src/tests/beispieldaten.ts b/companion-app/src/tests/beispieldaten.ts index 6ca44c4..18e7b08 100644 --- a/companion-app/src/tests/beispieldaten.ts +++ b/companion-app/src/tests/beispieldaten.ts @@ -13,10 +13,12 @@ export const beispielStatus: Fahrzeugstatus = { batteriespannung: null, gesichert: true, sicherheitscheck: [ - { label: "Tür vorne links", ok: true }, - { label: "Tür vorne rechts", ok: true }, - { label: "Fenster vorne links", ok: false }, - { label: "Motorhaube", ok: null }, + { label: "Tür vorne links", ok: true, gruppe: "tueren_klappen" }, + { label: "Tür vorne rechts", ok: true, gruppe: "tueren_klappen" }, + { label: "Motorhaube", ok: null, gruppe: "tueren_klappen" }, + { label: "Fenster vorne links", ok: false, gruppe: "fenster_dach" }, + { label: "Türschloss vorne links", ok: true, gruppe: "verriegelt" }, + { label: "Standlicht", ok: true, gruppe: "licht" }, ], oelwechsel_faellig_ts: "2027-03-14T00:00:00+00:00", oelwechsel_faellig_km: 11750, diff --git a/custom_components/audi_dashboard/aktualisierung.py b/custom_components/audi_dashboard/aktualisierung.py index 0a4a7a6..e12bdbd 100644 --- a/custom_components/audi_dashboard/aktualisierung.py +++ b/custom_components/audi_dashboard/aktualisierung.py @@ -16,7 +16,11 @@ Dienstaufruf noch läuft. Deshalb strenger als install.ps1s 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. + reversibel, falls das Update Probleme macht. Dessen manifest.json wird + dabei zu manifest.json.bak umbenannt: HA erkennt Integrationen an jedem + manifest.json unter custom_components/, unabhängig vom Ordnernamen - + ohne diesen Schritt lädt ein Neustart mit vorhandenem Backup-Ordner + eine zweite Integration mit derselben Domain. 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, @@ -231,11 +235,29 @@ def entpacken_pruefen_tauschen( f"Konnte den bestehenden Ordner nicht sichern: {fehler}" ) from fehler + # HA scannt JEDEN Ordner unter custom_components/ mit manifest.json als + # eigene Integration - nach der Domain im Manifest, nicht nach dem + # Ordnernamen. Ohne diesen Schritt wäre der Backup-Ordner beim nächsten + # Neustart eine zweite Integration mit domain "audi_dashboard": HA + # importiert dann auch deren dienste.py als eigenes Modul + # (custom_components.audi_dashboard_backup.dienste), dessen __file__ im + # Backup-Ordner liegt - INTEGRATIONSORDNER und _BACKUP_ORDNER fallen für + # diese Kopie auf denselben Pfad zusammen, und ihre Dienst-Registrierung + # kollidiert mit der echten Integration. Das Manifest bleibt als .bak + # erhalten, falls die Fassung zurückgerollt werden muss. + backup_manifest = os.path.join(backup_ordner, "manifest.json") + if os.path.isfile(backup_manifest): + os.rename(backup_manifest, backup_manifest + ".bak") + try: os.rename(staging_ordner, integrationsordner) except OSError as fehler: - # Rollback: die alte Fassung zurückbenennen, statt HA ohne - # Integrationsordner dastehen zu lassen. + # Rollback: erst das Manifest wiederherstellen, dann die alte + # Fassung zurückbenennen - sonst stünde HA mit einem Ordner ohne + # manifest.json da. + backup_manifest_bak = backup_manifest + ".bak" + if os.path.isfile(backup_manifest_bak): + os.rename(backup_manifest_bak, backup_manifest) os.rename(backup_ordner, integrationsordner) raise AktualisierungsFehler( f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}" diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py index b7f3c72..fee6837 100644 --- a/custom_components/audi_dashboard/einstellungen.py +++ b/custom_components/audi_dashboard/einstellungen.py @@ -81,6 +81,19 @@ class Sensorzuordnung: HECKKLAPPE_SENSOR: str = "" HAUBE_SENSOR: str = "" + # Schlösser (getrennt von den Öffnungssensoren oben - zu heißt nicht + # verriegelt). Je vier Türschlösser, dazu Heckklappe/Motorhaube einzeln, + # wie bei den Öffnungssensoren. + TUERSCHLOSS_SENSOREN: list[str] = field(default_factory=list) + HECKKLAPPE_SCHLOSS_SENSOR: str = "" + HAUBE_SCHLOSS_SENSOR: str = "" + + # Schiebedach (offen/zu) und Standlicht (an/aus) - ergänzen die + # Fenster-Gruppe bzw. stehen für sich, siehe _sicherheitscheck() in + # veroeffentlichung.py. + DACH_SENSOR: str = "" + LICHT_SENSOR: str = "" + # Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die # App-eigene, aus dem Servicebuch berechnete Prognose). NAECHSTER_OELWECHSEL_SENSOR: str = "" @@ -160,6 +173,26 @@ FELDER: list[dict] = [ "hinweis": "\"aus\"/off = zu.", "domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False, "stichworte": ["haube", "hood", "bonnet", "motorhaube"]}, + {"key": "TUERSCHLOSS_SENSOREN", "label": "Türschlösser", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = verriegelt. Für \"Fahrzeug verriegelt\" wichtig.", + "domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": True, "positionen": POSITIONEN, + "pflicht": False, "stichworte": ["türschloss", "schloss", "lock", "verriegelt"]}, + {"key": "HECKKLAPPE_SCHLOSS_SENSOR", "label": "Kofferraum-Schloss", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = verriegelt.", + "domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["kofferraum", "heckklappe", "tailgate", "schloss", "lock"]}, + {"key": "HAUBE_SCHLOSS_SENSOR", "label": "Motorhauben-Schloss", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = verriegelt.", + "domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["haube", "hood", "schloss", "lock"]}, + {"key": "DACH_SENSOR", "label": "Schiebedach", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = zu.", + "domains": ["binary_sensor"], "device_classes": ["window", "door", "opening"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["dach", "schiebedach", "sunroof", "roof"]}, + {"key": "LICHT_SENSOR", "label": "Standlicht", "gruppe": "sicherheit", + "hinweis": "\"aus\"/off = kein Licht.", + "domains": ["binary_sensor"], "device_classes": ["light"], "units": [], "liste": False, "pflicht": False, + "stichworte": ["licht", "standlicht", "light", "parking lights"]}, {"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht", "hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.", "domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False, diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index c2f1b75..212b356 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.6","sha256":"e0d8c7a6f4cc7fddc59b252e02e4ebf50002f3bd4e317ddb315670ef13299d75","bytes":232382,"gebaut":"2026-08-24T10:44:09Z"} \ No newline at end of file +{"version":"2026.8.24.8","sha256":"3eddc79605bc979da46476cf000c2fb501b8bc510148da60b56b0b49b9b603c6","bytes":233244,"gebaut":"2026-08-24T12:43:13Z"} \ 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 6c15fa1..d485b28 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 8a3ad33..46e88ef 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -1288,14 +1288,53 @@ function kmProTagAusFahrten() { 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; + if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm: null } : null; const restKm = Math.abs(fm.km); - if (restKm <= 0) return { datum: heute(), durchZeitlimit: false }; + if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm }; const rate = kmProTagAusFahrten(); - if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null; + if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null; const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag); const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum); - return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit }; + return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm }; +} + +/* Eigenes (kürzeres) Intervall ganz ohne Servicebucheintrag: die + Fahrzeugmeldung kennt nur das Herstellerintervall, nicht die in + "Einrichten" gewählte kürzere Vorgabe. Ein angenommener letzter Service + wird aus der gemeldeten Fälligkeit und der bekannten Herstellerintervall- + Länge zurückgerechnet ("Fälligkeitspunkt minus Herstellerintervall"), und + das eigene Intervall ab demselben angenommenen Punkt neu hochgerechnet - + eine Näherung (der wahre letzte Service kann näher oder ferner liegen), + aber ohne sie würde die eigene Intervallwahl bis zum ersten + Servicebucheintrag komplett ignoriert (siehe oelMeldungsPrognose()). */ +function meldungsPrognoseEigenesIntervall(fm, herstellerKm, herstellerMonate, eigenKm, eigenMonate) { + if (fm.km == null || !herstellerKm || eigenKm == null) return null; + const restKm = Math.max(0, Math.abs(fm.km) - herstellerKm + eigenKm); + let zeitDatum = null; + if (fm.ts && gueltigesDatum(new Date(fm.ts)) && herstellerMonate != null && eigenMonate != null) { + zeitDatum = new Date(fm.ts); + zeitDatum.setMonth(zeitDatum.getMonth() + (eigenMonate - herstellerMonate)); + } + if (restKm <= 0) return { datum: heute(), durchZeitlimit: false, restKm }; + const rate = kmProTagAusFahrten(); + if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true, restKm } : null; + const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag); + const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum); + return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit, restKm }; +} + +/* Fällt ganz ohne Servicebucheintrag auf die Fahrzeugmeldung zurück - bei + Herstellervorgabe unverändert (die Meldung IST die Herstellervorgabe), bei + eigenem (kürzerem) Intervall über meldungsPrognoseEigenesIntervall() + hochgerechnet, damit die Intervallwahl in "Einrichten" auch ganz ohne + Servicebucheintrag etwas bewirkt. Fehlen die Herstellerwerte selbst (kein + Sensor dafür), fällt das sanft auf die reine Meldung zurück. */ +function oelMeldungsPrognose() { + const oel = CAR.oel; + if (oel.modus === "hersteller") return meldungsPrognose("Ölwechsel"); + const fm = fahrzeugMeldungRoh("Ölwechsel"); + const eigen = meldungsPrognoseEigenesIntervall(fm, oel.herstellerKm, oel.herstellerMonate, oel.km, oel.monate); + return eigen || meldungsPrognose("Ölwechsel"); } function oelwechselPrognose() { @@ -1437,29 +1476,82 @@ function vHome() { ${teaser()}`; } +/* Kreis-Häkchen (ok) / Kreis-Kreuz (nicht ok) / Kreis-Fragezeichen (unbekannt) + statt eines einfarbigen Punkts - Zustand ist so schon am Symbol selbst + erkennbar, nicht nur an der Farbe. Nur für die Sicherheit-Ansicht (vSicherheit()/ + vTuerenKlappen()) gedacht, der übrige Bestand behält den einfachen .dot. */ +function statusKreis(ok) { + const klasse = ok === null ? "warn" : ok ? "" : "bad"; + const glyph = ok === null + ? '' + : ok + ? '' + : ''; + return ``; +} + +/* Gruppiert die flache CAR.sicherheitscheck-Liste (siehe _sicherheitscheck() + in veroeffentlichung.py) nach ihrem "gruppe"-Feld. ok ist null, sobald ein + einziger Punkt der Gruppe unbekannt ist, oder wenn die Gruppe ganz ohne + Einträge dasteht (kein Sensor dieser Rolle im Setup-Menü zugeordnet) - + nie true durch eine leere Liste erschlichen. */ +function sicherheitsGruppe(gruppe) { + const eintraege = CAR.sicherheitscheck.filter((e) => e.gruppe === gruppe); + const ok = eintraege.length === 0 || eintraege.some((e) => e.ok === null) + ? null + : eintraege.every((e) => e.ok); + return { eintraege, ok }; +} + function vSicherheit() { - const eintraege = CAR.sicherheitscheck; const g = CAR.gesichert; - const offen = eintraege.filter((e) => e.ok === false).length; - const unbekannt = eintraege.filter((e) => e.ok === null).length; - const sammelText = g === null - ? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert` - : g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`; - /* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe - Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */ + const verriegelt = sicherheitsGruppe("verriegelt"); + const tuerenKlappen = sicherheitsGruppe("tueren_klappen"); + const fensterDach = sicherheitsGruppe("fenster_dach"); + const licht = sicherheitsGruppe("licht"); + const sammelText = g === null ? "Zustand nicht vollständig bekannt" : g ? "Fahrzeug ist sicher abgestellt" : "Bitte prüfen"; + // Jede Zeile nennt beim Nutzer-Zustand ("ok") genau die Formulierung, die + // der Nutzer vorgegeben hat - beim Licht ist das "Kein Licht", nicht + // "Licht ausgeschaltet" (das beschreibt nur die Prüfung, nicht den Wert). + const zeile = (label, ok, labelBad, labelWarn, route) => { + const text = ok === null ? labelWarn : ok ? label : labelBad; + const klickbar = !!route; + return `<${klickbar ? "button" : "div"} class="row sicherheitszeile"${klickbar ? ` data-go="${route}:x"` : ""}> + ${statusKreis(ok)} + ${esc(text)} + ${klickbar ? '' : ""} + `; + }; + /* C6: Draufsicht oben, grüne Sammelmeldung, dann die vier Sammelzeilen - + dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */ return `
${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}
- + ${statusKreis(g)} ${esc(sammelText)}
-
Geprüfte Punkte +
+ ${zeile("Fahrzeug verriegelt", verriegelt.ok, "Fahrzeug nicht verriegelt", "Verriegelung unbekannt")} + ${zeile("Türen und Klappen geschlossen", tuerenKlappen.ok, "Türen oder Klappen offen", "Türen/Klappen unbekannt", "tuerenklappen")} + ${zeile("Fenster und Dach geschlossen", fensterDach.ok, "Fenster oder Dach offen", "Fenster/Dach unbekannt")} + ${zeile("Kein Licht", licht.ok, "Licht eingeschaltet", "Licht unbekannt")} +
`; +} + +/* Detailansicht zu "Türen und Klappen geschlossen" - jede Tür einzeln, + Motorhaube, Kofferraum. Einzige der vier Sammelzeilen mit eigener + Detailseite (siehe vSicherheit()), im Stil der früheren flachen + "Geprüfte Punkte"-Liste. */ +function vTuerenKlappen() { + const eintraege = sicherheitsGruppe("tueren_klappen").eintraege; + return ` +
Türen und Klappen
${eintraege.length ? eintraege.map((e, i, arr) => `
${esc(e.label)}
- ${e.ok === null ? "unbekannt" : e.ok ? "in Ordnung" : "prüfen"} + ${e.ok === null ? "unbekannt" : e.ok ? "zu" : "offen"}
`).join("") : `
Keine Daten verfügbar
`}
@@ -1837,10 +1929,15 @@ function vService() { const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null; const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null; // 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; + // die Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei + // Herstellervorgabe unverändert, beim Ölwechsel mit eigenem (kürzerem) + // Intervall über oelMeldungsPrognose() auf dieses umgerechnet, statt es + // bis zum ersten Servicebucheintrag zu ignorieren (siehe deren + // Kommentar). Hochgerechnet wird dabei mit der echten Fahrleistung aus + // dem Fahrtenlog statt einer Servicebuch-Rate. + const meldungProg = hatMeldung && !eigenOel && !eigenInsp + ? (t.art === "Ölwechsel" ? oelMeldungsPrognose() : 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 @@ -1850,7 +1947,11 @@ function vService() { ? `voraussichtlich am ${dedat(eigenOel.datum)}` : `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`) : eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` - : meldungProg ? `voraussichtlich am ${dedat(meldungProg.datum)}` : ""; + : meldungProg + ? (t.art === "Ölwechsel" && CAR.oel.modus !== "hersteller" && meldungProg.restKm != null + ? `${de(meldungProg.restKm)} km, voraussichtlich am ${dedat(meldungProg.datum)}` + : `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)); @@ -1961,9 +2062,26 @@ function termine() { oelTermin.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km"; oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum; } + const inspTermin = bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24); + // Ganz ohne Servicebucheintrag (kein basis) übernimmt die Fahrzeugmeldung + // selbst die Rolle der Prognosequelle - genau wie vService()'s eigenes + // meldungProg das schon tut (siehe dessen Kommentar dort). Ohne diesen + // Fallback hier zeigte "Übersicht" trotz korrekt gemeldeter Fälligkeit nur + // "kein Eintrag im Servicebuch", während "Service" dieselbe Meldung + // längst richtig anzeigte - zwei Ansichten, ein Datenstand, zwei + // widersprüchliche Aussagen. + [oelTermin, inspTermin].forEach((termin) => { + if (termin.basis) return; + const m = termin.art === "Ölwechsel" ? oelMeldungsPrognose() : meldungsPrognose(termin.art); + if (!m) return; + termin.km = m.restKm != null && CAR.odoBekannt ? CAR.odo + m.restKm : null; + termin.kmDatum = m.datum; + termin.zuerst = "km"; + termin.ziel = m.datum; + }); return [ oelTermin, - bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24), + inspTermin, bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24, CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null), ]; @@ -3404,6 +3522,7 @@ const ZURUECK = { Zurück-Pfeil. */ sicherheit: "home", standort: "home", + tuerenklappen: "sicherheit", }; const ICON = { sonne: '', @@ -3748,6 +3867,7 @@ function render() { else if (route.name === "einst") { head = ["App", "Einstellungen"]; v.innerHTML = vEinst(); } else if (route.name === "ident") { head = ["Fahrzeug", "Identität und Technik"]; v.innerHTML = vIdent(); } else if (route.name === "sicherheit") { head = ["Fahrzeug", "Sicherheit"]; v.innerHTML = vSicherheit(); } + else if (route.name === "tuerenklappen") { head = ["Fahrzeug", "Türen und Klappen"]; v.innerHTML = vTuerenKlappen(); } else if (route.name === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); } else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); } else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); } diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css index 0f01763..9c31da8 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css @@ -411,6 +411,26 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; } :has() grenzt das Umbrechen auf genau die Zeilen ein, die eine Fussnote fuehren - alle anderen .row bleiben unveraendert einzeilig. */ .row:has(.row-fussnote) { flex-wrap: wrap; } +/* Sammelzeilen der Sicherheit-Ansicht (vSicherheit()): Kreis-Häkchen + Text + + optionaler Chevron statt dt/dd. Erbt Abstand/Trennlinie von .row, ersetzt + aber dessen space-between-Layout und setzt die Button-Vorgaben zurück, wenn + die Zeile klickbar ist (Türen und Klappen). */ +.sicherheitszeile { + justify-content: flex-start; + width: 100%; + background: none; + border: none; + border-bottom: 1px solid var(--line); + font: inherit; + color: inherit; + text-align: left; + cursor: default; +} +button.sicherheitszeile { cursor: pointer; } +button.sicherheitszeile:active { background: var(--tile-2); } +.sicherheitsliste .sicherheitszeile:last-child { border-bottom: 0; } +.sicherheitszeile-text { flex: 1; font-size: 14.5px; color: var(--fg); } +.sicherheitszeile .chev { margin-left: auto; } .row-fussnote { flex: 1 0 100%; text-align: left; @@ -451,6 +471,12 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; } .dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; background: var(--ok); display: inline-block; } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); } +/* Kreis-Häkchen statt einfarbigem Punkt - für die Sicherheit-Ansicht + (vSicherheit()/vTuerenKlappen()), nach Nutzer-Vorlage: erfüllt/offen/ + unbekannt sofort am Symbol erkennbar, nicht nur an der Farbe. */ +.statuskreis { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; background: var(--ok); display: inline-flex; align-items: center; justify-content: center; } +.statuskreis.warn { background: var(--warn); } +.statuskreis.bad { background: var(--bad); } .status .t { font-size: 14px; color: var(--fg); } .status { transition: opacity .15s; } .status:active { opacity: .55; } diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 6520c29..dc1e023 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.6", + "version": "2026.8.24.8", "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/veroeffentlichung.py b/custom_components/audi_dashboard/veroeffentlichung.py index c4199ea..998d577 100644 --- a/custom_components/audi_dashboard/veroeffentlichung.py +++ b/custom_components/audi_dashboard/veroeffentlichung.py @@ -94,26 +94,49 @@ def _standort(hass: HomeAssistant, werte: Sensorzuordnung) -> dict: def _sicherheitscheck(hass: HomeAssistant, werte: Sensorzuordnung) -> list[dict]: - """Die einzeln geprüften Punkte hinter "Sicher abgestellt", fürs Frontend - (Klick auf den Status öffnet diese Liste mit einem grünen/roten/grauen - Punkt je Zeile). + """Die einzeln geprüften Punkte hinter "Sicher abgestellt", fürs Frontend - + gruppiert über das Feld "gruppe" in vier Sammelzeilen (Fahrzeug verriegelt + / Türen und Klappen geschlossen / Fenster und Dach geschlossen / Licht + ausgeschaltet), die die Oberfläche durch Gruppieren dieser flachen Liste + berechnet, statt hier schon vier separate Listen zu bauen - eine flache + Liste bleibt die einfachere, additive Änderung gegenüber dem, was das + Frontend vorher schon kannte. "ok" ist None, wenn der Sensor fehlt oder nicht verfügbar ist - genau daraus leitet sich auch die zusammengefasste Kennzahl unten ab, damit - beide nie auseinanderlaufen können.""" + beide nie auseinanderlaufen können. Alle binary_sensor-device_classes + hier (door/window/opening/lock/light) folgen HAs eigener Konvention "off + = der gute Zustand" (zu/verriegelt/kein Licht) - ein einziges Prüfmuster + für alle Zeilen, kein Sonderfall je Art.""" eintraege: list[dict] = [] for pos, sensor in zip(POSITIONEN, werte.TUER_SENSOREN): w = zustand_oder_none(hass, sensor) - eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"}) + eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off", "gruppe": "tueren_klappen"}) for pos, sensor in zip(POSITIONEN, werte.FENSTER_SENSOREN): w = zustand_oder_none(hass, sensor) - eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"}) + eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off", "gruppe": "fenster_dach"}) + # "Kofferraum" statt "Heckklappe": alltagssprachlicher Name für dieselbe + # Öffnung beim Avant/Kombi - das Sensorfeld HECKKLAPPE_SENSOR selbst + # bleibt unverändert, um eine bestehende Zuordnung nicht zu brechen. for label, sensor in ( - ("Heckklappe", werte.HECKKLAPPE_SENSOR), + ("Kofferraum", werte.HECKKLAPPE_SENSOR), ("Motorhaube", werte.HAUBE_SENSOR), ): w = zustand_oder_none(hass, sensor) - eintraege.append({"label": label, "ok": None if w is None else w == "off"}) + eintraege.append({"label": label, "ok": None if w is None else w == "off", "gruppe": "tueren_klappen"}) + for pos, sensor in zip(POSITIONEN, werte.TUERSCHLOSS_SENSOREN): + w = zustand_oder_none(hass, sensor) + eintraege.append({"label": f"Türschloss {pos}", "ok": None if w is None else w == "off", "gruppe": "verriegelt"}) + for label, sensor in ( + ("Kofferraum-Schloss", werte.HECKKLAPPE_SCHLOSS_SENSOR), + ("Motorhauben-Schloss", werte.HAUBE_SCHLOSS_SENSOR), + ): + w = zustand_oder_none(hass, sensor) + eintraege.append({"label": label, "ok": None if w is None else w == "off", "gruppe": "verriegelt"}) + w = zustand_oder_none(hass, werte.DACH_SENSOR) + eintraege.append({"label": "Dach", "ok": None if w is None else w == "off", "gruppe": "fenster_dach"}) + w = zustand_oder_none(hass, werte.LICHT_SENSOR) + eintraege.append({"label": "Standlicht", "ok": None if w is None else w == "off", "gruppe": "licht"}) return eintraege diff --git a/tests/aktualisierung/test_aktualisierung.py b/tests/aktualisierung/test_aktualisierung.py index d0d7fb4..7ecc2e4 100644 --- a/tests/aktualisierung/test_aktualisierung.py +++ b/tests/aktualisierung/test_aktualisierung.py @@ -24,6 +24,7 @@ import sys import tempfile import unittest import zipfile +from unittest import mock _HIER = os.path.dirname(os.path.abspath(__file__)) sys.path.insert( @@ -81,13 +82,42 @@ class ErfolgreicherTausch(unittest.TestCase): 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: + with open(os.path.join(self.backup, "manifest.json.bak"), "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"))) + # manifest.json selbst existiert im Backup-Ordner NICHT mehr - sonst + # würde HA ihn beim nächsten Neustart als zweite Integration mit + # derselben Domain laden (siehe Moduldocstring in aktualisierung.py). + self.assertFalse(os.path.exists(os.path.join(self.backup, "manifest.json"))) + # Kein Staging-Rest übrig. self.assertFalse(os.path.exists(self.staging)) + def test_rollback_stellt_manifest_der_alten_fassung_wieder_her(self): + """Schlägt der zweite Rename fehl, muss der Backup-Ordner nicht nur + zurückbenannt werden, sondern auch sein manifest.json - sonst stünde + die wiederhergestellte alte Fassung ohne gültiges Manifest da.""" + zip_bytes = _zip_bauen({ + "manifest.json": _GUELTIGES_MANIFEST, + "neue_datei.py": b"# neue Fassung\n", + }) + echter_rename = os.rename + + def zweiten_rename_kaputt_machen(src, dst): + if src == self.staging and dst == self.integration: + raise OSError("simulierter Fehler") + echter_rename(src, dst) + + with mock.patch("aktualisierung.os.rename", side_effect=zweiten_rename_kaputt_machen): + with self.assertRaises(a.AktualisierungsFehler): + a.entpacken_pruefen_tauschen(zip_bytes, self.integration, self.staging, self.backup) + + with open(os.path.join(self.integration, "manifest.json"), "rb") as f: + self.assertIn(b'"2026.1.1.1"', f.read()) + self.assertTrue(os.path.exists(os.path.join(self.integration, "alte_datei.py"))) + self.assertFalse(os.path.exists(self.backup)) + def test_entfernt_pycache_aus_dem_staging_ordner(self): zip_bytes = _zip_bauen({ "manifest.json": _GUELTIGES_MANIFEST,