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 <[email protected]>
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
|
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.
|
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)
|
### 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
|
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
|
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).
|
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)
|
## Working conventions (observed — keep them)
|
||||||
|
|||||||
@@ -88,10 +88,13 @@ export interface Tankvorgang {
|
|||||||
edited_fields?: string[];
|
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 {
|
export interface SicherheitsPunkt {
|
||||||
label: string;
|
label: string;
|
||||||
ok: boolean | null;
|
ok: boolean | null;
|
||||||
|
gruppe: "verriegelt" | "tueren_klappen" | "fenster_dach" | "licht";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
|
/** Feldnamen an der laufenden Instanz abgelesen, nicht aus der Spezifikation
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe("profilZuFahrzeug mit Fahrzeugstatus", () => {
|
|||||||
tankprozent: 62,
|
tankprozent: 62,
|
||||||
reichweite_km: 385,
|
reichweite_km: 385,
|
||||||
gesichert: true,
|
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.odo).toBe(48250)
|
||||||
expect(f.odoBekannt).toBe(true)
|
expect(f.odoBekannt).toBe(true)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
kmProTagAusFahrten,
|
kmProTagAusFahrten,
|
||||||
letzterOelwechsel,
|
letzterOelwechsel,
|
||||||
meldungsPrognose,
|
meldungsPrognose,
|
||||||
|
meldungsPrognoseEigenesIntervall,
|
||||||
naechsterService,
|
naechsterService,
|
||||||
|
oelMeldungsPrognose,
|
||||||
oelwechselPrognose,
|
oelwechselPrognose,
|
||||||
} from "./service"
|
} from "./service"
|
||||||
import type { Fahrzeug } from "./profilAdapter"
|
import type { Fahrzeug } from "./profilAdapter"
|
||||||
@@ -252,4 +254,96 @@ describe("meldungsPrognose", () => {
|
|||||||
it("liefert nichts ohne jede brauchbare Angabe", () => {
|
it("liefert nichts ohne jede brauchbare Angabe", () => {
|
||||||
expect(meldungsPrognose(null, null, fahrten, jetzt)).toBeNull()
|
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
|
hochgerechnete Kilometergrenze - der Nutzer fährt zu wenig, um die
|
||||||
km-Grenze rechtzeitig zu erreichen. */
|
km-Grenze rechtzeitig zu erreichen. */
|
||||||
durchZeitlimit: boolean
|
durchZeitlimit: boolean
|
||||||
|
/** Restkilometer bis zur Fälligkeit, falls bekannt. */
|
||||||
|
restKm: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
|
/** Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
|
||||||
@@ -169,14 +171,74 @@ export function meldungsPrognose(
|
|||||||
jetzt = new Date(),
|
jetzt = new Date(),
|
||||||
): Meldungsprognose | null {
|
): Meldungsprognose | null {
|
||||||
const zeitDatum = faelligTs ? faelligTs.slice(0, 10) : 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)
|
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)
|
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 kmDatum = new Date(jetzt.getTime() + (restKm / rate) * TAG_MS).toISOString().slice(0, 10)
|
||||||
const durchZeitlimit = !!zeitDatum && zeitDatum < kmDatum
|
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 */
|
/* ------------------------------------------------------ Nächster Service */
|
||||||
@@ -219,8 +281,8 @@ export function naechsterService(
|
|||||||
if (oel) {
|
if (oel) {
|
||||||
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
|
kandidaten.push({ art: "Ölwechsel", restKm: oel.restKm, datum: oel.datum })
|
||||||
} else {
|
} else {
|
||||||
const m = meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten, jetzt)
|
const m = oelMeldungsPrognose(fahrzeug, fahrten, jetzt)
|
||||||
if (m) kandidaten.push({ art: "Ölwechsel", restKm: fahrzeug.oelwechselFaelligKm, datum: m.datum })
|
if (m) kandidaten.push({ art: "Ölwechsel", restKm: m.restKm, datum: m.datum })
|
||||||
}
|
}
|
||||||
|
|
||||||
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
|
const insp = inspektionPrognose(fahrzeug, buch, jetzt)
|
||||||
@@ -228,7 +290,7 @@ export function naechsterService(
|
|||||||
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
|
kandidaten.push({ art: "Inspektion", restKm: insp.restKm, datum: insp.datum })
|
||||||
} else {
|
} else {
|
||||||
const m = meldungsPrognose(fahrzeug.inspektionFaelligTs, fahrzeug.inspektionFaelligKm, fahrten, jetzt)
|
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.
|
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type SeitenName =
|
|||||||
| "fill"
|
| "fill"
|
||||||
// Sonstige
|
// Sonstige
|
||||||
| "sicherheit"
|
| "sicherheit"
|
||||||
|
| "tuerenklappen"
|
||||||
| "live"
|
| "live"
|
||||||
| "einst"
|
| "einst"
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
|
|||||||
sbuch: "service",
|
sbuch: "service",
|
||||||
einst: "home",
|
einst: "home",
|
||||||
sicherheit: "home",
|
sicherheit: "home",
|
||||||
|
tuerenklappen: "sicherheit",
|
||||||
live: "home",
|
live: "home",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +92,7 @@ export const TITEL: Record<SeitenName, string> = {
|
|||||||
trip: "Fahrt",
|
trip: "Fahrt",
|
||||||
fill: "Tankvorgang",
|
fill: "Tankvorgang",
|
||||||
sicherheit: "Fahrzeugstatus",
|
sicherheit: "Fahrzeugstatus",
|
||||||
|
tuerenklappen: "Türen und Klappen",
|
||||||
live: "Fahrt läuft",
|
live: "Fahrt läuft",
|
||||||
einst: "Einstellungen",
|
einst: "Einstellungen",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Fahrzeugstatus im Detail. Vorlage: `vSicherheit()` im alten Panel.
|
* Fahrzeugstatus im Detail. Vorlage: `vSicherheit()` im alten Panel.
|
||||||
*
|
*
|
||||||
* Zeigt die 16 Einzelprüfungen aus `_sicherheitscheck`. Entscheidend:
|
* Vier Sammelzeilen statt der früheren flachen Liste: Fahrzeug verriegelt /
|
||||||
* `ok === null` heißt „Sensor meldet nichts" und wird als unbekannt
|
* Türen und Klappen geschlossen / Fenster und Dach geschlossen / Licht
|
||||||
* dargestellt — nie als grün geraten. Deshalb ist auch die Gesamtaussage
|
* ausgeschaltet - jede fasst mehrere Punkte aus `_sicherheitscheck` über
|
||||||
* unbekannt, sobald eine einzige Prüfung unbekannt ist.
|
* 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 { StatusRow, Tile } from "@audi-dash/ui"
|
||||||
|
|
||||||
import { useDaten } from "../daten/DatenKontext"
|
import { useDaten } from "../daten/DatenKontext"
|
||||||
import { datumZeit } from "../format"
|
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()
|
const { fahrzeug, statusStand } = useDaten()
|
||||||
if (!fahrzeug) return null
|
if (!fahrzeug) return null
|
||||||
|
|
||||||
@@ -27,8 +32,10 @@ export function Fahrzeugstatus() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unbekannt = punkte.filter((p) => p.ok === null).length
|
const verriegelt = gruppenErgebnis(punkte.filter((p) => p.gruppe === "verriegelt"))
|
||||||
const offen = punkte.filter((p) => p.ok === false)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -36,45 +43,49 @@ export function Fahrzeugstatus() {
|
|||||||
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
|
status={fahrzeug.gesichert === true ? "ok" : fahrzeug.gesichert === false ? "bad" : "warn"}
|
||||||
title={
|
title={
|
||||||
fahrzeug.gesichert === true
|
fahrzeug.gesichert === true
|
||||||
? "Alles verschlossen"
|
? "Fahrzeug ist sicher abgestellt"
|
||||||
: fahrzeug.gesichert === false
|
: fahrzeug.gesichert === false
|
||||||
? `${offen.length} Stelle${offen.length === 1 ? "" : "n"} offen`
|
? "Bitte prüfen"
|
||||||
: "Zustand nicht vollständig bekannt"
|
: "Zustand nicht vollständig bekannt"
|
||||||
}
|
}
|
||||||
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
{...(statusStand ? { subtitle: `Stand ${datumZeit(statusStand)}` } : {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tile>
|
<Tile>
|
||||||
<span className="ads-eyebrow">Einzelprüfungen</span>
|
<div className="dm-sicherheitsliste">
|
||||||
<ul className="dm-pruefliste">
|
<div className="dm-sicherheitszeile">
|
||||||
{punkte.map((punkt) => (
|
<StatusKreis ok={verriegelt} />
|
||||||
<li key={punkt.label} className="dm-pruefliste__zeile">
|
<span className="dm-sicherheitszeile__text">
|
||||||
<span
|
{verriegelt === null ? "Verriegelung unbekannt" : verriegelt ? "Fahrzeug verriegelt" : "Fahrzeug nicht verriegelt"}
|
||||||
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>
|
</span>
|
||||||
</li>
|
</div>
|
||||||
))}
|
<button type="button" className="dm-sicherheitszeile" onClick={() => geheZu("tuerenklappen")}>
|
||||||
</ul>
|
<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>
|
</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,
|
letzterInspektion,
|
||||||
letzterOelwechsel,
|
letzterOelwechsel,
|
||||||
meldungsPrognose,
|
meldungsPrognose,
|
||||||
|
oelMeldungsPrognose,
|
||||||
oelwechselPrognose,
|
oelwechselPrognose,
|
||||||
} from "../daten/service"
|
} from "../daten/service"
|
||||||
import { datum, de, isoTag } from "../format"
|
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)}`
|
? `${de(prognose.restKm)} km · ${datum(prognose.datum)}`
|
||||||
: null
|
: null
|
||||||
// Ganz ohne Servicebucheintrag (prognose null) übernimmt die
|
// Ganz ohne Servicebucheintrag (prognose null) übernimmt die
|
||||||
// Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet mit
|
// Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei Herstellervorgabe
|
||||||
// der echten Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate.
|
// unverändert, bei eigenem (kürzerem) Intervall über
|
||||||
const oelMeldungProg = oelHatMeldung && !prognose
|
// oelMeldungsPrognose()/meldungsPrognoseEigenesIntervall() auf das eigene
|
||||||
? meldungsPrognose(fahrzeug.oelwechselFaelligTs, fahrzeug.oelwechselFaelligKm, fahrten)
|
// Intervall hochgerechnet, statt es bis zum ersten Servicebucheintrag zu
|
||||||
: null
|
// 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
|
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
|
||||||
// Herstellerintervall - die Restkilometerzahl daneben wäre nur eine zweite,
|
// Herstellerintervall - die Restkilometerzahl daneben wäre nur eine zweite,
|
||||||
// verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim eigenen
|
// 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)}`
|
? `voraussichtlich am ${datum(prognose.datum)}`
|
||||||
: `${de(prognose.restKm)} km, voraussichtlich am ${datum(prognose.datum)}`
|
: `${de(prognose.restKm)} km, voraussichtlich am ${datum(prognose.datum)}`
|
||||||
: oelMeldungProg
|
: oelMeldungProg
|
||||||
|
? fahrzeug.oel.modus === "hersteller" || oelMeldungProg.restKm == null
|
||||||
? `voraussichtlich am ${datum(oelMeldungProg.datum)}`
|
? `voraussichtlich am ${datum(oelMeldungProg.datum)}`
|
||||||
|
: `${de(oelMeldungProg.restKm)} km, voraussichtlich am ${datum(oelMeldungProg.datum)}`
|
||||||
: null
|
: null
|
||||||
// Der Ölwechsel folgt in "Einrichten" wahlweise der Herstellervorgabe oder
|
// Der Ölwechsel folgt in "Einrichten" wahlweise der Herstellervorgabe oder
|
||||||
// einem kürzeren eigenen Intervall (fahrzeug.oel.modus) - die Inspektion hat
|
// 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 { ActionButton, Tile } from "@audi-dash/ui"
|
||||||
|
|
||||||
|
import type { SicherheitsPunkt } from "../api"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
|
* Leerzustand. Aus dem Design-Auftrag: freundlich formuliert, mit einem
|
||||||
* Hinweis, was als Nächstes passiert — nie nur „keine Daten".
|
* 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 {
|
export function bestaetigen(frage: string): boolean {
|
||||||
return window.confirm(frage)
|
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 { Statistik } from "./Statistik"
|
||||||
import { TankDetail } from "./TankDetail"
|
import { TankDetail } from "./TankDetail"
|
||||||
import { Tanken } from "./Tanken"
|
import { Tanken } from "./Tanken"
|
||||||
|
import { TuerenKlappen } from "./TuerenKlappen"
|
||||||
import { Uebersicht } from "./Uebersicht"
|
import { Uebersicht } from "./Uebersicht"
|
||||||
import {
|
import {
|
||||||
Beitrag,
|
Beitrag,
|
||||||
@@ -60,7 +61,9 @@ export function SeiteFuer(props: SeitenProps) {
|
|||||||
case "home":
|
case "home":
|
||||||
return <Uebersicht geheZu={geheZu} />
|
return <Uebersicht geheZu={geheZu} />
|
||||||
case "sicherheit":
|
case "sicherheit":
|
||||||
return <Fahrzeugstatus />
|
return <Fahrzeugstatus geheZu={geheZu} />
|
||||||
|
case "tuerenklappen":
|
||||||
|
return <TuerenKlappen />
|
||||||
case "audi":
|
case "audi":
|
||||||
return <MeinAudi geheZu={geheZu} />
|
return <MeinAudi geheZu={geheZu} />
|
||||||
case "ident":
|
case "ident":
|
||||||
|
|||||||
@@ -99,11 +99,28 @@ describe("Inhalte kommen wirklich aus den Daten", () => {
|
|||||||
expect(geheZu).not.toHaveBeenCalled()
|
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")
|
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())
|
await waitFor(() => expect(container.querySelector(".dm-pruefliste")).not.toBeNull())
|
||||||
expect(container.querySelectorAll(".dm-punkt--unbekannt").length).toBe(1)
|
expect(screen.getByText("Motorhaube")).toBeTruthy()
|
||||||
expect(screen.getByText("unbekannt")).toBeTruthy()
|
expect(screen.queryByText("Standlicht")).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("gruppiert Fahrten nach Jahr", async () => {
|
it("gruppiert Fahrten nach Jahr", async () => {
|
||||||
|
|||||||
@@ -486,6 +486,48 @@
|
|||||||
border: 1.5px solid var(--fg3);
|
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 */
|
/* ------------------------------------------------------------ Galerie */
|
||||||
|
|
||||||
.dm-galerie {
|
.dm-galerie {
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ export const beispielStatus: Fahrzeugstatus = {
|
|||||||
batteriespannung: null,
|
batteriespannung: null,
|
||||||
gesichert: true,
|
gesichert: true,
|
||||||
sicherheitscheck: [
|
sicherheitscheck: [
|
||||||
{ label: "Tür vorne links", ok: true },
|
{ label: "Tür vorne links", ok: true, gruppe: "tueren_klappen" },
|
||||||
{ label: "Tür vorne rechts", ok: true },
|
{ label: "Tür vorne rechts", ok: true, gruppe: "tueren_klappen" },
|
||||||
{ label: "Fenster vorne links", ok: false },
|
{ label: "Motorhaube", ok: null, gruppe: "tueren_klappen" },
|
||||||
{ label: "Motorhaube", ok: null },
|
{ 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_ts: "2027-03-14T00:00:00+00:00",
|
||||||
oelwechsel_faellig_km: 11750,
|
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.
|
bis feststeht, dass der Download vollständig und plausibel ist.
|
||||||
2. Erst danach ein atomarer Tausch per os.rename, nicht Löschen+Kopieren:
|
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 -
|
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
|
3. Schlägt der zweite Rename fehl, wird der erste zurückgenommen, statt
|
||||||
einen halb ersetzten Ordner zurückzulassen.
|
einen halb ersetzten Ordner zurückzulassen.
|
||||||
4. Kein automatischer Reload oder Neustart. Ein Reload aus dem eigenen,
|
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}"
|
f"Konnte den bestehenden Ordner nicht sichern: {fehler}"
|
||||||
) from 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:
|
try:
|
||||||
os.rename(staging_ordner, integrationsordner)
|
os.rename(staging_ordner, integrationsordner)
|
||||||
except OSError as fehler:
|
except OSError as fehler:
|
||||||
# Rollback: die alte Fassung zurückbenennen, statt HA ohne
|
# Rollback: erst das Manifest wiederherstellen, dann die alte
|
||||||
# Integrationsordner dastehen zu lassen.
|
# 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)
|
os.rename(backup_ordner, integrationsordner)
|
||||||
raise AktualisierungsFehler(
|
raise AktualisierungsFehler(
|
||||||
f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}"
|
f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}"
|
||||||
|
|||||||
@@ -81,6 +81,19 @@ class Sensorzuordnung:
|
|||||||
HECKKLAPPE_SENSOR: str = ""
|
HECKKLAPPE_SENSOR: str = ""
|
||||||
HAUBE_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
|
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
|
||||||
# App-eigene, aus dem Servicebuch berechnete Prognose).
|
# App-eigene, aus dem Servicebuch berechnete Prognose).
|
||||||
NAECHSTER_OELWECHSEL_SENSOR: str = ""
|
NAECHSTER_OELWECHSEL_SENSOR: str = ""
|
||||||
@@ -160,6 +173,26 @@ FELDER: list[dict] = [
|
|||||||
"hinweis": "\"aus\"/off = zu.",
|
"hinweis": "\"aus\"/off = zu.",
|
||||||
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
|
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
|
||||||
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
|
"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",
|
{"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.",
|
"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,
|
"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) {
|
function meldungsPrognose(art) {
|
||||||
const fm = fahrzeugMeldungRoh(art);
|
const fm = fahrzeugMeldungRoh(art);
|
||||||
const zeitDatum = fm.ts && gueltigesDatum(new Date(fm.ts)) ? new Date(fm.ts) : null;
|
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);
|
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();
|
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 kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
|
||||||
const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
|
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() {
|
function oelwechselPrognose() {
|
||||||
@@ -1437,29 +1476,82 @@ function vHome() {
|
|||||||
${teaser()}`;
|
${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() {
|
function vSicherheit() {
|
||||||
const eintraege = CAR.sicherheitscheck;
|
|
||||||
const g = CAR.gesichert;
|
const g = CAR.gesichert;
|
||||||
const offen = eintraege.filter((e) => e.ok === false).length;
|
const verriegelt = sicherheitsGruppe("verriegelt");
|
||||||
const unbekannt = eintraege.filter((e) => e.ok === null).length;
|
const tuerenKlappen = sicherheitsGruppe("tueren_klappen");
|
||||||
const sammelText = g === null
|
const fensterDach = sicherheitsGruppe("fenster_dach");
|
||||||
? `${unbekannt} von ${eintraege.length} Punkten liefern derzeit keinen Wert`
|
const licht = sicherheitsGruppe("licht");
|
||||||
: g ? "Fahrzeug ist sicher abgestellt" : `${offen} Punkt${offen === 1 ? "" : "e"} prüfen`;
|
const sammelText = g === null ? "Zustand nicht vollständig bekannt" : g ? "Fahrzeug ist sicher abgestellt" : "Bitte prüfen";
|
||||||
/* C6: Draufsicht oben, grüne Sammelmeldung, dann die Prüfliste - dieselbe
|
// Jede Zeile nennt beim Nutzer-Zustand ("ok") genau die Formulierung, die
|
||||||
Reihenfolge wie der Fahrzeugstatus in der myAudi-App. */
|
// 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 `
|
return `
|
||||||
<div class="szene bleed"><div class="carbox">${bildMitPlatzhalter(`/local/bilder/draufsicht.webp?v=${bildVersion}`, "draufsicht.webp", "Draufsicht", "carfix klein")}</div></div>
|
<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">
|
<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>
|
<span style="font-size:15px;color:var(--fg)">${esc(sammelText)}</span>
|
||||||
</div>
|
</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">
|
<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"' : ""}>
|
${eintraege.length ? eintraege.map((e, i, arr) => `<div class="row" ${i === arr.length - 1 ? 'style="border-bottom:0"' : ""}>
|
||||||
<dt>${esc(e.label)}</dt>
|
<dt>${esc(e.label)}</dt>
|
||||||
<dd style="display:flex;align-items:center;justify-content:flex-end;gap:8px">
|
<dd style="display:flex;align-items:center;justify-content:flex-end;gap:8px">
|
||||||
<span class="dot ${e.ok === null ? "warn" : e.ok ? "" : "bad"}"></span>
|
<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>
|
</dd>
|
||||||
</div>`).join("") : `<div class="row" style="border-bottom:0"><dt style="color:var(--fg3)">Keine Daten verfügbar</dt></div>`}
|
</div>`).join("") : `<div class="row" style="border-bottom:0"><dt style="color:var(--fg3)">Keine Daten verfügbar</dt></div>`}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -1837,10 +1929,15 @@ function vService() {
|
|||||||
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
|
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
|
||||||
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
|
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
|
||||||
// Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt
|
// Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt
|
||||||
// die Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet
|
// die Fahrzeugmeldung selbst die Rolle der Prognosequelle - bei
|
||||||
// mit der echten Fahrleistung aus dem Fahrtenlog statt einer
|
// Herstellervorgabe unverändert, beim Ölwechsel mit eigenem (kürzerem)
|
||||||
// Servicebuch-Rate - siehe meldungsPrognose().
|
// Intervall über oelMeldungsPrognose() auf dieses umgerechnet, statt es
|
||||||
const meldungProg = hatMeldung && !eigenOel && !eigenInsp ? meldungsPrognose(t.art) : null;
|
// 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
|
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
|
||||||
// Herstellerintervall - die Restkilometerzahl daneben waere nur eine
|
// Herstellerintervall - die Restkilometerzahl daneben waere nur eine
|
||||||
// zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
|
// zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
|
||||||
@@ -1850,7 +1947,11 @@ function vService() {
|
|||||||
? `voraussichtlich am ${dedat(eigenOel.datum)}`
|
? `voraussichtlich am ${dedat(eigenOel.datum)}`
|
||||||
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
|
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
|
||||||
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
|
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
|
||||||
: meldungProg ? `voraussichtlich am ${dedat(meldungProg.datum)}` : "";
|
: 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
|
const wert = hatMeldung
|
||||||
? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}`
|
? `${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));
|
: (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.zuerst = oelTermin.datum && oelProg.datum >= oelTermin.datum ? "zeit" : "km";
|
||||||
oelTermin.ziel = oelTermin.zuerst === "km" ? oelProg.datum : oelTermin.datum;
|
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 [
|
return [
|
||||||
oelTermin,
|
oelTermin,
|
||||||
bauen("Inspektion", letzterEintrag("inspektion"), 30000, 24),
|
inspTermin,
|
||||||
bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
|
bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
|
||||||
CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null),
|
CAR.erstzulassung && CAR.erstzulassung.includes("/") ? `01.${CAR.erstzulassung.replace("/", ".")}` : null),
|
||||||
];
|
];
|
||||||
@@ -3404,6 +3522,7 @@ const ZURUECK = {
|
|||||||
Zurück-Pfeil. */
|
Zurück-Pfeil. */
|
||||||
sicherheit: "home",
|
sicherheit: "home",
|
||||||
standort: "home",
|
standort: "home",
|
||||||
|
tuerenklappen: "sicherheit",
|
||||||
};
|
};
|
||||||
const ICON = {
|
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"/>',
|
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 === "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 === "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 === "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 === "vers") { head = ["Fahrzeug", "Versicherung/Steuer"]; v.innerHTML = vVers(); }
|
||||||
else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); }
|
else if (route.name === "beitrag") { head = ["Versicherung", "Beitrag anpassen"]; v.innerHTML = vBeitrag(); }
|
||||||
else if (route.name === "vertragsdetails") { head = ["Versicherung", "Vertragsdetails"]; v.innerHTML = vVertragsdetails(); }
|
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
|
:has() grenzt das Umbrechen auf genau die Zeilen ein, die eine Fussnote
|
||||||
fuehren - alle anderen .row bleiben unveraendert einzeilig. */
|
fuehren - alle anderen .row bleiben unveraendert einzeilig. */
|
||||||
.row:has(.row-fussnote) { flex-wrap: wrap; }
|
.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 {
|
.row-fussnote {
|
||||||
flex: 1 0 100%;
|
flex: 1 0 100%;
|
||||||
text-align: left;
|
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 { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; background: var(--ok); display: inline-block; }
|
||||||
.dot.warn { background: var(--warn); }
|
.dot.warn { background: var(--warn); }
|
||||||
.dot.bad { background: var(--bad); }
|
.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 .t { font-size: 14px; color: var(--fg); }
|
||||||
.status { transition: opacity .15s; }
|
.status { transition: opacity .15s; }
|
||||||
.status:active { opacity: .55; }
|
.status:active { opacity: .55; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "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",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": ["@paul"],
|
"codeowners": ["@paul"],
|
||||||
|
|||||||
@@ -94,26 +94,49 @@ def _standort(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _sicherheitscheck(hass: HomeAssistant, werte: Sensorzuordnung) -> list[dict]:
|
def _sicherheitscheck(hass: HomeAssistant, werte: Sensorzuordnung) -> list[dict]:
|
||||||
"""Die einzeln geprüften Punkte hinter "Sicher abgestellt", fürs Frontend
|
"""Die einzeln geprüften Punkte hinter "Sicher abgestellt", fürs Frontend -
|
||||||
(Klick auf den Status öffnet diese Liste mit einem grünen/roten/grauen
|
gruppiert über das Feld "gruppe" in vier Sammelzeilen (Fahrzeug verriegelt
|
||||||
Punkt je Zeile).
|
/ 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
|
"ok" ist None, wenn der Sensor fehlt oder nicht verfügbar ist - genau
|
||||||
daraus leitet sich auch die zusammengefasste Kennzahl unten ab, damit
|
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] = []
|
eintraege: list[dict] = []
|
||||||
for pos, sensor in zip(POSITIONEN, werte.TUER_SENSOREN):
|
for pos, sensor in zip(POSITIONEN, werte.TUER_SENSOREN):
|
||||||
w = zustand_oder_none(hass, sensor)
|
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):
|
for pos, sensor in zip(POSITIONEN, werte.FENSTER_SENSOREN):
|
||||||
w = zustand_oder_none(hass, sensor)
|
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 (
|
for label, sensor in (
|
||||||
("Heckklappe", werte.HECKKLAPPE_SENSOR),
|
("Kofferraum", werte.HECKKLAPPE_SENSOR),
|
||||||
("Motorhaube", werte.HAUBE_SENSOR),
|
("Motorhaube", werte.HAUBE_SENSOR),
|
||||||
):
|
):
|
||||||
w = zustand_oder_none(hass, 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
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
_HIER = os.path.dirname(os.path.abspath(__file__))
|
_HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(
|
sys.path.insert(
|
||||||
@@ -81,13 +82,42 @@ class ErfolgreicherTausch(unittest.TestCase):
|
|||||||
self.assertFalse(os.path.exists(os.path.join(self.integration, "alte_datei.py")))
|
self.assertFalse(os.path.exists(os.path.join(self.integration, "alte_datei.py")))
|
||||||
|
|
||||||
# Alte Fassung bleibt vollständig und unverändert im Backup-Ordner.
|
# 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.assertIn(b'"2026.1.1.1"', f.read())
|
||||||
self.assertTrue(os.path.exists(os.path.join(self.backup, "alte_datei.py")))
|
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.
|
# Kein Staging-Rest übrig.
|
||||||
self.assertFalse(os.path.exists(self.staging))
|
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):
|
def test_entfernt_pycache_aus_dem_staging_ordner(self):
|
||||||
zip_bytes = _zip_bauen({
|
zip_bytes = _zip_bauen({
|
||||||
"manifest.json": _GUELTIGES_MANIFEST,
|
"manifest.json": _GUELTIGES_MANIFEST,
|
||||||
|
|||||||
Reference in New Issue
Block a user