Selbst-Update-Sicherung, Service-Prognose ohne Servicebuch und Sicherheit neu gestaltet

- aktualisierung.py: Backup-Ordner verliert sein manifest.json beim Sichern,
  sonst laedt HA ihn nach einem Neustart als zweite audi_dashboard-Integration
  (Ursache des raetselhaften "gleicher Pfad"-Umbenennungs-Fehlers).
- service.ts/audi-dashboard-app.js: Uebersicht faellt jetzt wie Service schon
  auf die Fahrzeugmeldung zurueck, wenn kein Servicebucheintrag existiert;
  eigenes (kuerzeres) Oelwechsel-Intervall wird jetzt auch ohne Servicebuch-
  eintrag naeherungsweise aus der Herstellermeldung hochgerechnet, statt
  weiterhin unveraendert die Herstellervorgabe zu zeigen.
- Sicherheit neu gestaltet: vier Sammelzeilen (Fahrzeug verriegelt / Tueren
  und Klappen geschlossen / Fenster und Dach geschlossen / Kein Licht) statt
  einer flachen Liste, mit neuen Tuerschloss-, Dach- und Standlicht-Sensor-
  rollen, Kreis-Haekchen-Symbolen und einer Detailseite je Tuer/Motorhaube/
  Kofferraum in beiden Frontends.

Details, Verifikation und Sensor-Rollen in AGENTS.md (Abschnitte J/M/N).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:29:37 +02:00
parent 146f809e9b
commit 5a048488ea
23 changed files with 866 additions and 97 deletions
+175
View File
@@ -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)
+4 -1
View File
@@ -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
@@ -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)
+94
View File
@@ -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")
})
})
+69 -7
View File
@@ -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.
+3
View File
@@ -33,6 +33,7 @@ export type SeitenName =
| "fill"
// Sonstige
| "sicherheit"
| "tuerenklappen"
| "live"
| "einst"
@@ -64,6 +65,7 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
sbuch: "service",
einst: "home",
sicherheit: "home",
tuerenklappen: "sicherheit",
live: "home",
}
@@ -90,6 +92,7 @@ export const TITEL: Record<SeitenName, string> = {
trip: "Fahrt",
fill: "Tankvorgang",
sicherheit: "Fahrzeugstatus",
tuerenklappen: "Türen und Klappen",
live: "Fahrt läuft",
einst: "Einstellungen",
}
+50 -39
View File
@@ -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)}` } : {})}
/>
<Tile>
<span className="ads-eyebrow">Einzelprüfungen</span>
<ul className="dm-pruefliste">
{punkte.map((punkt) => (
<li key={punkt.label} className="dm-pruefliste__zeile">
<span
className={
punkt.ok === true
? "dm-punkt dm-punkt--ok"
: punkt.ok === false
? "dm-punkt dm-punkt--offen"
: "dm-punkt dm-punkt--unbekannt"
}
aria-hidden="true"
/>
<span className="dm-pruefliste__label">{punkt.label}</span>
<span className="dm-pruefliste__wert">
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
</span>
</li>
))}
</ul>
<div className="dm-sicherheitsliste">
<div className="dm-sicherheitszeile">
<StatusKreis ok={verriegelt} />
<span className="dm-sicherheitszeile__text">
{verriegelt === null ? "Verriegelung unbekannt" : verriegelt ? "Fahrzeug verriegelt" : "Fahrzeug nicht verriegelt"}
</span>
</div>
<button type="button" className="dm-sicherheitszeile" onClick={() => geheZu("tuerenklappen")}>
<StatusKreis ok={tuerenKlappen} />
<span className="dm-sicherheitszeile__text">
{tuerenKlappen === null
? "Türen/Klappen unbekannt"
: tuerenKlappen
? "Türen und Klappen geschlossen"
: "Türen oder Klappen offen"}
</span>
<svg className="dm-sicherheitszeile__chev" viewBox="0 0 6 10" width="6" height="10" aria-hidden="true">
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
</button>
<div className="dm-sicherheitszeile">
<StatusKreis ok={fensterDach} />
<span className="dm-sicherheitszeile__text">
{fensterDach === null ? "Fenster/Dach unbekannt" : fensterDach ? "Fenster und Dach geschlossen" : "Fenster oder Dach offen"}
</span>
</div>
<div className="dm-sicherheitszeile">
<StatusKreis ok={licht} />
<span className="dm-sicherheitszeile__text">
{licht === null ? "Licht unbekannt" : licht ? "Kein Licht" : "Licht eingeschaltet"}
</span>
</div>
</div>
</Tile>
{unbekannt > 0 && (
<p className="dm-fussnote">
{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.
</p>
)}
</>
)
}
+10 -6
View File
@@ -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
@@ -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 (
<Leerzustand
titel="Keine Zustandsdaten"
text="Das Fahrzeug meldet derzeit keine Tür- oder Klappendaten. Sobald es das tut, stehen sie hier."
/>
)
}
return (
<Tile>
<span className="ads-eyebrow">Türen und Klappen</span>
<ul className="dm-pruefliste">
{punkte.map((punkt) => (
<li key={punkt.label} className="dm-pruefliste__zeile">
<span
className={
punkt.ok === true
? "dm-punkt dm-punkt--ok"
: punkt.ok === false
? "dm-punkt dm-punkt--offen"
: "dm-punkt dm-punkt--unbekannt"
}
aria-hidden="true"
/>
<span className="dm-pruefliste__label">{punkt.label}</span>
<span className="dm-pruefliste__wert">
{punkt.ok === true ? "zu" : punkt.ok === false ? "offen" : "unbekannt"}
</span>
</li>
))}
</ul>
</Tile>
)
}
+47
View File
@@ -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 (
<span className={klasse} aria-hidden="true">
<svg viewBox="0 0 16 16" width="13" height="13">
{ok === null ? (
<path
d="M5.7 6a2.3 2.3 0 1 1 2.7 2.27c-.5.09-.7.5-.7 1.03v.2M8 11.6v.01"
stroke="#fff"
strokeWidth="1.5"
strokeLinecap="round"
fill="none"
/>
) : ok ? (
<path
d="M3 8.3 6.2 11.5 13 4.5"
stroke="#fff"
strokeWidth="1.8"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path d="M4 4l8 8M12 4l-8 8" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" />
)}
</svg>
</span>
)
}
/** 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)
}
+4 -1
View File
@@ -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 <Uebersicht geheZu={geheZu} />
case "sicherheit":
return <Fahrzeugstatus />
return <Fahrzeugstatus geheZu={geheZu} />
case "tuerenklappen":
return <TuerenKlappen />
case "audi":
return <MeinAudi geheZu={geheZu} />
case "ident":
+20 -3
View File
@@ -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 () => {
+42
View File
@@ -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 {
+6 -4
View File
@@ -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,
@@ -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}"
@@ -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,
@@ -1 +1 @@
{"version":"2026.8.24.6","sha256":"e0d8c7a6f4cc7fddc59b252e02e4ebf50002f3bd4e317ddb315670ef13299d75","bytes":232382,"gebaut":"2026-08-24T10:44:09Z"}
{"version":"2026.8.24.8","sha256":"3eddc79605bc979da46476cf000c2fb501b8bc510148da60b56b0b49b9b603c6","bytes":233244,"gebaut":"2026-08-24T12:43:13Z"}
@@ -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
? '<path d="M5.7 6a2.3 2.3 0 1 1 2.7 2.27c-.5.09-.7.5-.7 1.03v.2M8 11.6v.01" stroke="#fff" stroke-width="1.5" stroke-linecap="round" fill="none"/>'
: ok
? '<path d="M3 8.3 6.2 11.5 13 4.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
: '<path d="M4 4l8 8M12 4l-8 8" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>';
return `<span class="statuskreis ${klasse}" aria-hidden="true"><svg viewBox="0 0 16 16" width="13" height="13">${glyph}</svg></span>`;
}
/* 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)}
<span class="sicherheitszeile-text">${esc(text)}</span>
${klickbar ? '<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg>' : ""}
</${klickbar ? "button" : "div"}>`;
};
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die vier Sammelzeilen -
dieselbe Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
return `
<div class="szene bleed"><div class="carbox">${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
<div class="tile" style="display:flex;align-items:center;gap:12px;padding:16px 20px">
<span class="dot ${g === null ? "warn" : g ? "" : "bad"}"></span>
${statusKreis(g)}
<span style="font-size:15px;color:var(--fg)">${esc(sammelText)}</span>
</div>
<div class="tile"><span class="label">Geprüfte Punkte</span>
<div class="tile sicherheitsliste">
${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")}
</div>`;
}
/* 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 `
<div class="tile"><span class="label">Türen und Klappen</span>
<dl class="rows" style="margin-top:10px">
${eintraege.length ? eintraege.map((e, i, arr) => `<div class="row" ${i === arr.length - 1 ? 'style="border-bottom:0"' : ""}>
<dt>${esc(e.label)}</dt>
<dd style="display:flex;align-items:center;justify-content:flex-end;gap:8px">
<span class="dot ${e.ok === null ? "warn" : e.ok ? "" : "bad"}"></span>
${e.ok === null ? "unbekannt" : e.ok ? "in Ordnung" : "prüfen"}
${e.ok === null ? "unbekannt" : e.ok ? "zu" : "offen"}
</dd>
</div>`).join("") : `<div class="row" style="border-bottom:0"><dt style="color:var(--fg3)">Keine Daten verfügbar</dt></div>`}
</dl>
@@ -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: '<circle cx="10" cy="10" r="4"/><path d="M10 1v2M10 17v2M1 10h2M17 10h2M3.9 3.9l1.4 1.4M14.7 14.7l1.4 1.4M16.1 3.9l-1.4 1.4M5.3 14.7l-1.4 1.4"/>',
@@ -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(); }
@@ -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; }
@@ -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"],
@@ -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
+31 -1
View File
@@ -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,