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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user