diff --git a/.gitignore b/.gitignore index 7e68ef6..fc8a402 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ Thumbs.db # Arbeitsdateien dieser Werkzeugkette (Patch-Skripte, Zwischenstaende) .tmp-* + +# Python-Bytecode, entsteht beim Kompilieren und gehoert nicht ins Repo +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md index db0409d..78d8fbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11316,3 +11316,138 @@ Noch nicht gebaut. Offen bleiben damit: * "Jetzt lesen" als echtes Lesen (Cache loeschen, dann holen). * Der Waechter darf keinen Gleichstand melden, solange unbekannt ist, wann der Wert zuletzt vom Geraet bestaetigt wurde. + +## CR. Ein Feld ohne Eingabefeld hat drei Bildschirme belogen (2026.9.5.3) + +Meldung des Eigentuemers, 05.09.2026: Wartungsplan leer, Erstzulassung +`01.03.2025`, erwartet also die Hauptuntersuchung am 01.03.2028. Gezeigt wurde +stattdessen dreierlei: + +| Bildschirm | Anzeige | +|---|---| +| Uebersicht | `01.08.2026` | +| Mein Audi | `August 2026` | +| Service | `kein Eintrag im Wartungsplan` | + +Drei Antworten auf dieselbe Frage. Die Ursache war eine einzige Zeile in +`/config/audi_dashboard/fahrzeugprofil.json`: + + "hauptuntersuchung_faellig": "08/2026" + +### Warum das niemand korrigieren konnte + +Dieses Feld hatte **in keiner der beiden Oberflaechen ein Eingabefeld** und im +Backend kein Schema - gemessen, nicht vermutet: kein `` im Panel, keine +Zeile in `Einstellungen.tsx`, kein Treffer in den `.py`-Dateien. Es wurde nur +gelesen und beim Speichern unveraendert zurueckgeschrieben. Ein Wert, den man +sieht, aber nicht anfassen kann. + +Und er stand in der Ableitung **ganz vorn**: beide Codebasen fragten zuerst +dieses Feld und erst dann Wartungsplan und Erstzulassung. Solange dort +`08/2026` stand, war jede Ableitung wirkungslos. + +Die drei verschiedenen Anzeigen erklaeren sich aus drei verschiedenen +Verarbeitungen desselben Wertes: + +* **Uebersicht** rief `new Date(fahrzeug.hu)` auf - an der Ableitung vorbei. + `new Date("08/2026")` ergibt in V8 den 01.08.2026. +* **Mein Audi** ging ueber die Ableitung, die `08/2026` als monatsgenau + erkannte und korrekt "August 2026" schrieb. +* **Service** prueft mit `new Date(huFaellig)`; fuer `"08/2026"` liefert das + `Invalid Date`, also der Leertext. + +### Die Regel, die jetzt gilt + +Vorgabe des Eigentuemers: *"die eingabe reicht fuers erste mal in der +Erstzulassung und danach ueber den Wartungsplan"*. Zwei Quellen, keine dritte: + +1. juengster Wartungsplan-Eintrag + **24** Monate +2. sonst Erstzulassung + **36** Monate + +Die 36 gegen 24 sind kein Versehen - die erste Hauptuntersuchung eines +Neuwagens ist nach drei Jahren faellig, jede weitere nach zwei. + +Das Profilfeld ist samt Lese- und Schreibweg entfernt (Panel, `profilAdapter`, +`Fahrzeug`-Typ, Beispieldaten) und der tote Schluessel aus der Profildatei der +Testinstanz geloescht. **Die reale Instanz traegt ihn noch** - er wird jetzt +nirgends mehr gelesen, aber er liegt dort. + +### Drei weitere Fehler, die dabei ans Licht kamen + +**Die Service-Kachel verschluckte abgeleitete Termine.** `intervalle(t)` gibt +`null` zurueck, wenn kein Wartungsplan-Eintrag dahintersteht, und der fruehe +Return schrieb dann "kein Eintrag im Wartungsplan" - auch wenn `t.datum` laengst +einen Termin trug. Behoben: der Return greift nur noch, wenn wirklich kein +Datum da ist. + +**`monatePlus()` in der App verlor einen Tag ueber die Zeitumstellung.** Sie las +mit `new Date(isoDatum)` (UTC-Mitternacht) und formatierte mit +`toISOString()` zurueck. Fielen Start und Ziel in verschiedene +Zeitzonen-Halbjahre, verschob das um eine Stunde - und die reicht ueber +Mitternacht. Gemessen, drei von fuenf: + + 2025-01-15 + 6 Monate -> 2025-07-14 statt 2025-07-15 + 2025-11-20 + 6 Monate -> 2026-05-19 statt 2026-05-20 + 2025-02-10 + 4 Monate -> 2025-06-09 statt 2025-06-10 + +Das betraf **Oelwechsel- und Inspektionsprognose genauso**, nicht nur die +Hauptuntersuchung. Das Panel rechnete an derselben Stelle von jeher in Ortszeit +und war richtig - eine stille Paritaetsabweichung. Jetzt rechnen beide in +Ortszeit. + +**Der Wartungsplan-Eintrag brauchte in der App eine Kilometerangabe.** +`letzterEintrag()` filtert auf `e.km != null`, weil Oelwechsel und Inspektion +km-Prognosen rechnen. Die Hauptuntersuchung ist rein datumsbasiert; ohne +km-Eintrag haette die App den Eintrag ignoriert und weiter aus der +Erstzulassung gerechnet - drei Jahre statt zwei -, waehrend das Panel ihn +benutzte. Das haette **genau beim ersten HU-Eintrag** zugeschlagen, also bei +dem Weg, den der Eigentuemer beschrieben hat. `letzteHauptuntersuchung()` hat +jetzt einen eigenen Filter ohne km. Dazu nahm das Panel mit `.find()` den +ersten Treffer in Listenreihenfolge, die App den juengsten nach Datum - beide +nehmen jetzt den juengsten. + +### Monatsgenauigkeit bleibt erhalten + +Das Eingabefeld der Erstzulassung schlaegt `MM/JJJJ` vor. Aus `03/2025` darf +kein Termin mit Tag entstehen - `01.03.2028` waere eine Erfindung. Das Panel +fuehrt die Genauigkeit als `genau` am Termin mit, die App als ISO-Monat im Text +(`"2028-03"`, den `datum()` als "Maerz 2028" ausgibt). Verschiedene Form, +gleiche Aussage. + +### Geprueft + +`companion-app/src/daten/paritaet_hu.test.ts` schneidet `datumAusText()`, +`monatePlus()`, `dat()` und die Datumsmuster aus der **Panel-Quelle** heraus und +laesst sie gegen die App laufen - 15 Faelle, darunter Schaltjahr-Ueberlauf +(29.02.2024 + 24 Monate -> 01.03.2026 auf beiden Seiten), beide Schreibweisen +derselben Erstzulassung und alle drei Zeitumstellungs-Faelle. Dazu sieben neue +Faelle in `service.test.ts`. Gesamt 292 Tests gruen. + +Live an der Testinstanz mit den echten Daten des Eigentuemers geprueft, alle +drei gemeldeten Bildschirme: + +| Bildschirm | vorher | jetzt | +|---|---|---| +| Uebersicht | `01.08.2026` (HU als naechster Termin) | `23.07.2027` (Oelwechsel; die HU liegt 2028 dahinter) | +| Service | `kein Eintrag im Wartungsplan` | `01.03.2028` | +| Mein Audi | `August 2026` | `01.03.2028` | + +0 Tracebacks nach dem Neustart. + +### Zwei Fallen der Umgebung, die eine Auslieferung verschleiert haben + +Beim Ausliefern sah es dreimal so aus, als sei die neue Fassung im Container - +sie war es nicht: + +* **MSYS-Pfadumwandlung.** `docker exec ... rm -rf /config/www/dmapp` ohne + Quoting macht Git Bash zu einem Windows-Pfad; das `rm` trifft ins Leere, + und das folgende `docker cp` legt seinen Inhalt dann **in** den bestehenden + Ordner (`dmapp/dist/`, `audi_dashboard/audi_dashboard/`). Pfade gehoeren in + `sh -c '...'`, sonst greift die Umwandlung. +* **Binaerpipe in `docker exec -i`.** `tar -cf - | docker exec -i ... tar -xf -` + meldet Erfolg und uebertraegt nichts. Nicht benutzen; `docker cp` auf ein + **nicht vorhandenes** Ziel ist der verlaessliche Weg. + +Beides hat dazu gefuehrt, dass ich zwischenzeitlich alte Dateien gelesen und +fuer neue gehalten habe. Gegenprobe, die das aufdeckt: `find -name +manifest.json | wc -l` muss `1` ergeben. diff --git a/companion-app/src/daten/paritaet_hu.test.ts b/companion-app/src/daten/paritaet_hu.test.ts new file mode 100644 index 0000000..c228aad --- /dev/null +++ b/companion-app/src/daten/paritaet_hu.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" +import { hauptuntersuchungFaellig } from "./service" + +/* Vergleicht die Ableitung der Hauptuntersuchung Zeile für Zeile gegen die + ECHTEN Panel-Funktionen, aus der Panel-Quelle herausgeschnitten. */ +const quelle = readFileSync( + "../custom_components/audi_dashboard/frontend/audi-dashboard-app.js", "utf8") +const block = (kopf: string) => { + const i = quelle.indexOf(kopf) + if (i < 0) throw new Error("fehlt: " + kopf) + let tiefe = 0 + for (let k = quelle.indexOf("{", i); k < quelle.length; k++) { + if (quelle[k] === "{") tiefe++ + else if (quelle[k] === "}" && !--tiefe) return quelle.slice(i, k + 1) + } + throw new Error("unvollstaendig: " + kopf) +} +const zeile = (muster: RegExp) => { + const m = muster.exec(quelle) + if (!m) throw new Error("fehlt: " + muster) + return m[0] +} + +const panel = new Function(` + ${zeile(/^const DATUM_ISO = .*$/m)} + ${zeile(/^const DATUM_TAG = .*$/m)} + ${zeile(/^const DATUM_MONAT = .*$/m)} + ${zeile(/^const DATUM_ISO_MONAT = .*$/m)} + ${block("function datumBauen")} + ${zeile(/^const gueltigesDatum = .*$/m)} + ${block("function datumAusText")} + ${block("function dat(")} + ${zeile(/^function monatePlus.*$/m)} + return { datumAusText, monatePlus }; +`)() as { + datumAusText: (w: string) => { zeit: Date; genau: string } | null + monatePlus: (d: string | Date, m: number) => Date +} + +const iso = (d: Date | null) => d === null ? null + : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` + +/* Wie hauptuntersuchungsTermin() im Panel, nur ohne dessen CAR-Zugriff. + + Beide Seiten führen die Genauigkeit mit, nur in verschiedener Form: das + Panel als Merkmal `genau` am Termin, die App als ISO-Monat im Text + ("2028-03"). Verglichen wird deshalb die normalisierte Form - dieselbe + Aussage, nicht dieselbe Darstellung. */ +const panelHU = (erst: string, eintragDe?: string) => { + if (eintragDe) return iso(panel.monatePlus(eintragDe, 24)) + const z = panel.datumAusText(erst) + if (!z) return null + const ziel = iso(panel.monatePlus(z.zeit, 36)) + return z.genau === "monat" ? ziel!.slice(0, 7) : ziel +} + +describe("Hauptuntersuchung: Panel und App rechnen gleich", () => { + for (const erst of ["01.03.2025", "2025-03-01", "15.01.2025", "20.11.2025", + "10.02.2025", "29.02.2024", "31.12.2023", "03/2025", + "31.02.2025", "", "Unsinn"]) { + it(`Erstzulassung ${JSON.stringify(erst)}`, () => { + expect(hauptuntersuchungFaellig([], erst)).toBe(panelHU(erst)) + }) + } + const paare: [string, string][] = [["15.01.2025", "2025-01-15"], ["20.11.2025", "2025-11-20"], + ["10.02.2025", "2025-02-10"], ["29.02.2024", "2024-02-29"]] + for (const [de, isoDatum] of paare) { + it(`Wartungsplan ${de}`, () => { + expect(hauptuntersuchungFaellig([{ art: "Hauptuntersuchung", datum: isoDatum }], "01.03.2025")) + .toBe(panelHU("01.03.2025", de)) + }) + } +}) diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts index d355376..b9aff49 100644 --- a/companion-app/src/daten/profilAdapter.ts +++ b/companion-app/src/daten/profilAdapter.ts @@ -58,7 +58,6 @@ export interface Fahrzeug { fin: string details: string erstzulassung: string - hu: string odo: number odoBekannt: boolean tankPct: number @@ -174,7 +173,6 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze fin: text(fahrzeug, "fin"), details: text(fahrzeug, "details"), erstzulassung: text(fahrzeug, "erstzulassung"), - hu: text(fahrzeug, "hauptuntersuchung_faellig"), odo: status.km ?? 0, odoBekannt: status.km !== null && status.km !== undefined, tankPct: status.tankprozent ?? 0, @@ -247,7 +245,6 @@ export function zusammenfuehren( f["tankvolumen_liter"] = einstellungen.tankvolumen f["fin"] = fahrzeug.fin f["erstzulassung"] = fahrzeug.erstzulassung - f["hauptuntersuchung_faellig"] = fahrzeug.hu e["uebersichtsbild"] = einstellungen.startbild e["backup_intervall"] = einstellungen.backupIntervall diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts index 25ccc6b..532d92d 100644 --- a/companion-app/src/daten/service.test.ts +++ b/companion-app/src/daten/service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { + hauptuntersuchungFaellig, inspektionPrognose, kmProTagAusFahrten, letzterOelwechsel, @@ -17,7 +18,6 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug { fin: "", details: "", erstzulassung: "", - hu: "", odo, odoBekannt: true, tankPct: 0, @@ -147,15 +147,15 @@ describe("naechsterService", () => { }) it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => { - const f = { ...fahrzeug(46000), hu: "2026-09-01" } + const f = { ...fahrzeug(46000), erstzulassung: "01.03.2025" } const s = naechsterService(f, [], [], jetzt) expect(s).not.toBeNull() expect(s!.art).toBe("Hauptuntersuchung") expect(s!.restKm).toBeNull() - expect(s!.datum).toBe("2026-09-01") + expect(s!.datum).toBe("2028-03-01") }) - it("liefert nichts ohne Servicebuch und ohne gepflegte Hauptuntersuchung", () => { + it("liefert nichts ohne Servicebuch und ohne Erstzulassung", () => { expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull() }) @@ -350,3 +350,51 @@ describe("oelMeldungsPrognose", () => { expect(p!.datum).toBe("2027-08-11") }) }) + +/* Gemeldet vom Eigentümer am 05.09.2026: Wartungsplan leer, Erstzulassung + 01.03.2025 - erwartet 01.03.2028. Angezeigt wurden stattdessen DREI + verschiedene Antworten: Übersicht "01.08.2026", Mein Audi "August 2026", + Service "kein Eintrag im Wartungsplan". Ursache war ein Profilfeld + `hauptuntersuchung_faellig` ("08/2026"), das die Ableitung überstimmte, + selbst aber in keiner Oberfläche ein Eingabefeld hatte. Vorgabe seither: + "die eingabe reicht fürs erste mal in der Erstzulassung und danach über den + Wartungsplan" - zwei Quellen, keine dritte. */ +describe("Hauptuntersuchung", () => { + const buchMit = (datum: string) => [{ art: "Hauptuntersuchung", datum, km: 40000 }] + + it("leitet die erste aus der Erstzulassung ab: plus 36 Monate", () => { + expect(hauptuntersuchungFaellig([], "01.03.2025")).toBe("2028-03-01") + }) + + it("liest die Erstzulassung in jeder Schreibweise gleich", () => { + // Vor dem 05.09.2026 ergab dieselbe Erstzulassung je nach Schreibweise + // 2028-02-28 oder 2028-03-01 - toISOString() rechnete nach UTC. + for (const schreibweise of ["01.03.2025", "2025-03-01"]) { + expect(hauptuntersuchungFaellig([], schreibweise)).toBe("2028-03-01") + } + }) + + it("rechnet ab dem Wartungsplan-Eintrag mit 24 Monaten statt 36", () => { + expect(hauptuntersuchungFaellig(buchMit("2025-03-01"), "01.03.2025")).toBe("2027-03-01") + }) + + it("lässt den Wartungsplan die Erstzulassung schlagen", () => { + // Die Erstzulassung zählt nur bis zur ersten eingetragenen Untersuchung. + const spaeter = hauptuntersuchungFaellig(buchMit("2026-06-15"), "01.03.2025") + expect(spaeter).toBe("2028-06-15") + }) + + it("verliert keinen Tag über die Zeitumstellung", () => { + // monatePlus() rechnete früher über UTC und verlor dabei einen Tag, + // sobald Start und Ziel in verschiedenen Zeitzonen-Halbjahren lagen. + expect(hauptuntersuchungFaellig(buchMit("2025-01-15"), "")).toBe("2027-01-15") + expect(hauptuntersuchungFaellig(buchMit("2025-11-20"), "")).toBe("2027-11-20") + expect(hauptuntersuchungFaellig(buchMit("2025-02-10"), "")).toBe("2027-02-10") + }) + + it("sagt nichts, wenn es weder Eintrag noch Erstzulassung gibt", () => { + expect(hauptuntersuchungFaellig([], "")).toBeNull() + expect(hauptuntersuchungFaellig([], null)).toBeNull() + expect(hauptuntersuchungFaellig([], "keine Ahnung")).toBeNull() + }) +}) diff --git a/companion-app/src/daten/service.ts b/companion-app/src/daten/service.ts index f0b626c..c49fb3c 100644 --- a/companion-app/src/daten/service.ts +++ b/companion-app/src/daten/service.ts @@ -11,6 +11,7 @@ * Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls. */ +import { alsZeitpunkt, isoTag } from "../format" import type { Fahrzeug } from "./profilAdapter" const TAG_MS = 86_400_000 @@ -30,11 +31,28 @@ interface Buchhaltung { art?: string } +/* Durchgehend in ORTSZEIT gerechnet, ohne Umweg über UTC. + + Vorher stand hier `new Date(isoDatum)` (das liest einen reinen Tag als + UTC-Mitternacht) und am Ende `toISOString()` (das rechnet zurück). Fällt + Start und Ziel in verschiedene Zeitumstellungen, verschiebt das um eine + Stunde - und die reicht über Mitternacht. Am 05.09.2026 gemessen, drei + von fünf Fällen daneben: + + 2025-01-15 + 6 Monate -> 2025-07-14 statt 2025-07-15 + 2025-11-20 + 6 Monate -> 2026-05-19 statt 2026-05-20 + 2025-02-10 + 4 Monate -> 2025-06-09 statt 2025-06-10 + + Betraf Ölwechsel- und Inspektionsprognose gleichermaßen. Das Panel + rechnet in `monatePlus()` von jeher in Ortszeit und war damit richtig - + eine stille Paritätsabweichung. */ function monatePlus(isoDatum: string, monate: number): string { - const d = new Date(isoDatum) + const teile = /^(\d{4})-(\d{2})-(\d{2})/.exec(isoDatum) + if (!teile) return isoDatum + const d = new Date(Number(teile[1]), Number(teile[2]) - 1 + monate, Number(teile[3])) if (Number.isNaN(d.getTime())) return isoDatum - d.setMonth(d.getMonth() + monate) - return d.toISOString().slice(0, 10) + const zwei = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}` } /** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort @@ -314,15 +332,72 @@ export function serviceKandidaten( if (m) kandidaten.push({ art: "Inspektion", restKm: m.restKm, datum: m.datum }) } - // fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein. - const hu = new Date(fahrzeug.hu) - if (fahrzeug.hu && !Number.isNaN(hu.getTime())) { - kandidaten.push({ - art: "Hauptuntersuchung", - restKm: null, - datum: hu.toISOString().slice(0, 10), - }) + /* Über dieselbe Ableitung wie Service und Mein Audi. Vorher stand hier + `new Date(fahrzeug.hu)` auf einem Profilfeld, das die Ableitung gar nicht + erst befragt hat - daher zeigte die Übersicht am 05.09.2026 den 01.08.2026, + während Service "kein Eintrag im Wartungsplan" meldete. Drei Bildschirme, + drei Antworten auf dieselbe Frage. */ + const hu = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung) + if (hu) { + kandidaten.push({ art: "Hauptuntersuchung", restKm: null, datum: hu }) } return kandidaten } + +/* Die Hauptuntersuchung hat ZWEI Quellen — wortgleich mit + hauptuntersuchungsTermin() im Panel: + + 1. der letzte Servicebuch-Eintrag plus **24** Monate. + 2. die Erstzulassung plus **36** Monate. + + Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines + Neuwagens ist nach drei Jahren fällig, jede weitere nach zwei (Vorgabe des + Eigentümers, 05.09.2026). + + Ein drittes, überstimmendes Profilfeld gibt es nicht mehr — Begründung im + Panel bei hauptuntersuchungsTermin(). */ +export const HU_MONATE_FOLGE = 24 +export const HU_MONATE_ERSTE = 36 + +/** Der jüngste Servicebucheintrag, dessen Art auf eine Hauptuntersuchung + hindeutet. + + Verlangt bewusst KEINE Kilometerangabe, anders als letzterEintrag(): eine + Hauptuntersuchung ist rein datumsbasiert, ihr Kilometerstand geht in keine + Rechnung ein. Bis zum 05.09.2026 lief sie über letzterEintrag() und wurde + damit stillschweigend übergangen, sobald der Eintrag kein km-Feld hatte - + die App wäre dann auf die Erstzulassung zurückgefallen und hätte drei + Jahre statt zwei gerechnet, während das Panel den Eintrag benutzte. */ +export function letzteHauptuntersuchung(buch: readonly Buchhaltung[]): Buchhaltung | null { + const treffer = buch + .filter((e) => typeof e.art === "string" && /hauptunter/i.test(e.art) && e.datum) + .sort((a, b) => String(b.datum).localeCompare(String(a.datum))) + return treffer[0] ?? null +} + +/** + * Die Fälligkeit der Hauptuntersuchung, oder null wenn sich keine bestimmen + * lässt. + * + * `null` heißt wirklich "unbekannt": es gibt weder einen Servicebuch-Eintrag + * noch eine lesbare Erstzulassung. Dann steht in der Oberfläche ein Strich - + * eine erfundene Fälligkeit wäre schlechter. + */ +export function hauptuntersuchungFaellig( + buch: readonly Buchhaltung[], + erstzulassung: string | null | undefined, +): string | null { + const eintrag = letzteHauptuntersuchung(buch) + if (eintrag?.datum) return monatePlus(eintrag.datum, HU_MONATE_FOLGE) + /* alsZeitpunkt() ist derselbe Parser, den auch die Anzeige benutzt, und + liefert die Genauigkeit gleich mit: eine Erstzulassung "03/2025" nennt + keinen Tag, also nennt der abgeleitete Termin auch keinen - "01.03.2028" + wäre eine Erfindung. Das Ergebnis ist dann ein ISO-Monat ("2028-03"), + den datum() als "März 2028" ausgibt. isoTag() rechnet den + Zeitzonenversatz heraus; toISOString() allein verschöbe den Tag. */ + const z = alsZeitpunkt(erstzulassung) + if (!z) return null + const ziel = monatePlus(isoTag(z.zeit), HU_MONATE_ERSTE) + return z.genau === "monat" ? ziel.slice(0, 7) : ziel +} diff --git a/companion-app/src/format.ts b/companion-app/src/format.ts index 20b445b..5fa7ee4 100644 --- a/companion-app/src/format.ts +++ b/companion-app/src/format.ts @@ -150,7 +150,10 @@ function bauen(jahr: number, monat: number, tag: number): Date | null { return d } -function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | null { +/** Wortgleich mit datumAusText() im Panel. Exportiert, weil die Ableitung + der Hauptuntersuchung dieselbe Genauigkeit braucht wie die Anzeige: aus + einer Erstzulassung "03/2025" darf kein Termin mit Tag entstehen. */ +export function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | null { if (wert === null || wert === undefined || wert === "") return null if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" } if (typeof wert === "number") { diff --git a/companion-app/src/screens/MeinAudi.tsx b/companion-app/src/screens/MeinAudi.tsx index 9d723fc..5f061bb 100644 --- a/companion-app/src/screens/MeinAudi.tsx +++ b/companion-app/src/screens/MeinAudi.tsx @@ -18,7 +18,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react" import { Tile } from "@audi-dash/ui" import { useDaten } from "../daten/DatenKontext" -import { inspektionPrognose, oelwechselPrognose } from "../daten/service" +import { hauptuntersuchungFaellig, inspektionPrognose, oelwechselPrognose } from "../daten/service" import { datum, de, deOderStrich, eur } from "../format" import type { SeitenName } from "../navigation" import { Wertzeile, Werteliste } from "./bausteine" @@ -108,6 +108,11 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) = Der Wert ist bewusst die reale Fahrzeugmeldung (Datum plus „in X km"), nicht die App-Prognose; fehlt sie, bleibt ein Strich. Die eigene Prognose steht klein unter dem Namen (prognoseZeile() dort). */ + /* Nicht mehr nur der eingetragene Wert: fehlt er, wird abgeleitet - + Servicebuch + 24 Monate, sonst Erstzulassung + 36. Wortgleich mit + hauptuntersuchungsTermin() im Panel; bis zum 05.09.2026 leitete nur + das Panel ab, die App zeigte einen Strich. */ + const huFaellig = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung) const oelProg = oelwechselPrognose(fahrzeug, buch) const inspProg = inspektionPrognose(fahrzeug, buch) const termine: { art: string; prognose: string | null; haupt: string; neben: string | null }[] = [ @@ -132,11 +137,11 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) = : null, }, { - // Die Hauptuntersuchung meldet das Fahrzeug nie — hier zählt allein das - // im Profil hinterlegte Datum. + // Die Hauptuntersuchung meldet das Fahrzeug nie - sie wird immer + // abgeleitet, aus dem Wartungsplan oder der Erstzulassung. art: "Hauptuntersuchung", prognose: null, - haupt: fahrzeug.hu ? datum(fahrzeug.hu) : "–", + haupt: huFaellig ? datum(huFaellig) : "–", neben: null, }, ] diff --git a/companion-app/src/screens/Service.tsx b/companion-app/src/screens/Service.tsx index b5b90d1..aa0cdc8 100644 --- a/companion-app/src/screens/Service.tsx +++ b/companion-app/src/screens/Service.tsx @@ -14,15 +14,7 @@ import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui" import { kartendienstUrl } from "../daten/tankstelle" import { useDaten } from "../daten/DatenKontext" -import { - inspektionPrognose, - letzterInspektion, - letzterOelwechsel, - meldungsPrognose, - oelMeldungsPrognose, - oelwechselPrognose, - serviceKandidaten, -} from "../daten/service" +import { hauptuntersuchungFaellig, inspektionPrognose, letzterInspektion, letzterOelwechsel, meldungsPrognose, oelMeldungsPrognose, oelwechselPrognose, serviceKandidaten } from "../daten/service" import { datum, de, eur, isoTag } from "../format" import type { SeitenName } from "../navigation" import { SymbolInspektion, SymbolOelwechsel } from "../symbole" @@ -129,7 +121,10 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => ? `voraussichtlich am ${datum(inspMeldungProg.datum)}` : null - const huDatum = fahrzeug.hu ? new Date(fahrzeug.hu) : null + /* Siehe MeinAudi.tsx: fehlt die eingetragene Faelligkeit, wird sie + abgeleitet - beide Bildschirme muessen dieselbe zeigen. */ + const huFaellig = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung) + const huDatum = huFaellig ? new Date(huFaellig) : null const huGueltig = !!huDatum && !Number.isNaN(huDatum.getTime()) return ( @@ -174,7 +169,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) => Hauptuntersuchung {huGueltig ? ( -
{datum(fahrzeug.hu)}
+
{datum(huFaellig)}
) : (
kein Eintrag im Wartungsplan
)} diff --git a/companion-app/src/tests/beispieldaten.ts b/companion-app/src/tests/beispieldaten.ts index e0d9488..ff4070c 100644 --- a/companion-app/src/tests/beispieldaten.ts +++ b/companion-app/src/tests/beispieldaten.ts @@ -35,7 +35,6 @@ export const beispielProfil: Profil = { details: "2.9 TFSI quattro competition", kennzeichen: "M AB 1234", erstzulassung: "2023-04-01", - hauptuntersuchung_faellig: "2027-04-01", tankvolumen_liter: 58, ausfuehrung: "competition", }, diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py index afa5d73..b5aad46 100644 --- a/custom_components/audi_dashboard/einstellungen.py +++ b/custom_components/audi_dashboard/einstellungen.py @@ -91,19 +91,6 @@ class Sensorzuordnung: # Verbraucher. GNSS_TEILSTRECKE_SENSOR: str = "" - # Das Trip-Signal des Geräts: es beginnt erst, wenn Zündung UND Bewegung - # UND "Start Speed" zusammenkommen, und endet erst nach dem - # "Ignition OFF Timeout" des Geräts. Damit bringt es die Pausentoleranz - # selbst mit, statt dass wir einen zweiten Zeitgeber bauen. - # - # Bewusst getrennt von ZUENDUNG_SENSOR: die rohe Zündung prellt (am - # 29.08.2026 drei Wechsel in 30 ms) und stand am 30./31.08. dreizehn - # Stunden am Stück auf "an", ohne dass gefahren wurde. Als Auslöser für - # Fahrten taugt sie nicht, als Anzeige "Zündung an" sehr wohl. - # - # Leer: dann übernimmt ZUENDUNG_SENSOR die Fahrterkennung wie früher - - # bestehende Installationen laufen ohne Zutun weiter. - TRIP_SENSOR: str = "" # Der Zeitstempel, den das Gerät dem Datensatz selbst mitgegeben hat # (Unix-Sekunden, UTC). Optional, aber der einzige Weg zu einem @@ -226,13 +213,8 @@ FELDER: list[dict] = [ "domains": ["sensor"], "device_classes": ["distance"], "units": ["km"], "liste": False, "pflicht": False, "beispiel": "segment_mileage", "stichworte": ["segment", "teilstrecke", "abschnitt", "mileage"]}, - {"key": "TRIP_SENSOR", "label": "Trip-Status", "gruppe": "fahrterkennung", - "hinweis": "on = Fahrt läuft. Das gefilterte Fahrtsignal des Geräts - erkennt Fahrtbeginn und -ende. Ohne Zuordnung übernimmt die Zündung diese Aufgabe.", - "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False, - "beispiel": "trip_status_true_if_trip_started_false_if_stopped", - "stichworte": ["trip", "fahrt", "trip status", "journey"]}, {"key": "ZUENDUNG_SENSOR", "label": "Zündung", "gruppe": "fahrterkennung", - "hinweis": "on = Zündung an. Für die Anzeige \"fährt/steht\"; ohne zugeordneten Trip-Status erkennt sie zusätzlich Fahrtbeginn und -ende.", + "hinweis": "on = Zündung an. Für die Anzeige \"fährt/steht\"; sie erkennt zugleich Fahrtbeginn und -ende.", "domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True, "beispiel": "engine_ignition_or_acc_status", "stichworte": ["zündung", "ignition", "acc", "motor", "engine"]}, diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index 3872746..6f9bb49 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -29,7 +29,6 @@ from .ablage import neue_id from .veroeffentlichung import zustand_oder_none from .verlauf import ( MINDESTDAUER_S, - fahrtsignal, geraetezeit, geraetezeit_plausibel, verlauf_lesen, @@ -52,22 +51,6 @@ _LOGGER = logging.getLogger(__name__) # Einstellung fahrten_pausenzeit_min in beiden Oberflächen wieder anbieten. -# Der Nachlauf des Geraets zwischen Zuendung aus und Trip-Ende. -# -# Das Trip-Signal des FMM003 endet nicht mit der Zuendung, sondern erst nach -# seinem "Ignition OFF Timeout" (Trip \ Odometer). Es ist damit als Ausloeser -# ideal - es prellt nicht und bringt die Pausentoleranz mit -, liefert aber ein -# Fahrtende, das systematisch um genau diese Spanne zu spaet liegt. -# -# Der Wert ist die Konfiguration des Geraets, nicht unsere Wahl: er steht dort -# auf 900 s. Aendert er sich, gehoert diese Zahl mitgeaendert - sie laesst sich -# nicht aus den Daten ableiten (siehe _nachlauf_gegenpruefen unten, die genau -# das versucht und meldet, wenn es nicht mehr zusammenpasst). -# -# Bewusst KEINE Einstellung in der Oberflaeche: die Zahl gehoert zum Geraet, -# nicht zum Fahrzeug oder zum Nutzer - dieselbe Linie wie bei UNPLAUSIBLE_KMH -# und MIN_STRECKE_VERBRAUCH_KM in verlauf.py. -NACHLAUF_S = 900 # Ab welcher Abweichung zwischen gerechnetem und beobachtetem Ende gewarnt # wird. Grosszuegig, weil die Zuendung nur so genau bekannt ist, wie das Geraet @@ -83,19 +66,20 @@ NACHLAUF_TOLERANZ_S = 120 # 02.09.2026 kippte die Zuendung je einmal in den ersten 25 Sekunden fuer unter # eine Sekunde weg und kam sofort zurueck. # -# Der gemeldete Zeitpunkt liegt damit systematisch zu spaet, und zwar in BEIDEN -# Wegen: auch das Trip-Ende des Geraets haengt an der verzoegerten Zuendung. -# Nachgemessen am 02.09.2026: Zuendung aus 21:43:15, Trip aus 21:58:16 - genau -# NACHLAUF_S spaeter. Wer das Trip-Signal als Ausloeser nimmt, muss deshalb -# beide Spannen abziehen. +# Der gemeldete Zeitpunkt liegt damit systematisch zu spaet und wird abgezogen. +# +# (Bis zum 05.09.2026 gab es einen zweiten Weg ueber das Trip-Signal des +# Geraets, dessen Ende an derselben verzoegerten Zuendung hing und deshalb noch +# einmal 900 s spaeter kam. Das Trip-Szenario ist am Geraet abgeschaltet und +# der Sensor aus der App entfernt - siehe AGENTS.md.) # # Der Wert stand bis zum 31.08.2026 auf 0 und seit dem 01.09.2026 09:38 auf # 180 s (aus den Geraetekonfigurationen rekonstruiert). Fahrten aus der Zeit # dazwischen enden 180 s zu spaet - korrigiert wird ab jetzt, rueckwirkend nur # von Hand. # -# Wie NACHLAUF_S die Konfiguration des Geraets und bewusst keine Einstellung in -# der Oberflaeche. +# Die Konfiguration des Geraets (Parameter 400) und bewusst keine Einstellung +# in der Oberflaeche. ZUENDUNG_NACHLAUF_S = 180 # Wie lange nach einem vorlaeufigen Ende ein spaeter eintreffendes Zuendungs-Aus @@ -216,7 +200,7 @@ async def _echtes_ende( ) -> datetime.datetime: """Das Fahrtende ohne den Nachlauf des Geraets. - Das Trip-Signal endet NACHLAUF_S nach der Zuendung; abgezogen ergibt das den + Die Zuendung wird vom Geraet verzoegert gemeldet; abgezogen ergibt das den Zeitpunkt, zu dem wirklich abgestellt wurde. Die Rechnung stammt vollstaendig aus der Logik des Geraets und ueberlebt deshalb auch ein Funkloch: der Datensatz kommt spaeter, traegt aber seine eigene Zeit (siehe geraetezeit()). @@ -227,16 +211,11 @@ async def _echtes_ende( anschliessend. Ohne diese Klemme entstuende aus dreissig Sekunden Zuendung eine Viertelstunde Fahrt. - ZWEI Nachlaeufe, nicht einer. Das Zuendungselement meldet selbst schon - verzoegert (ZUENDUNG_NACHLAUF_S), und das Trip-Ende haengt an dieser bereits - verzoegerten Zuendung - wer ueber das Trip-Signal kommt, muss deshalb beide - Spannen abziehen, wer ueber die Zuendung kommt, nur die eine. Bis zum - 03.09.2026 wurde nur NACHLAUF_S abgezogen; seit der Ignition OFF Delay am + Das Zuendungselement meldet selbst schon verzoegert + (ZUENDUNG_NACHLAUF_S); genau diese Spanne wird abgezogen. Bis zum + 03.09.2026 geschah das nicht, und seit der Ignition OFF Delay am 01.09.2026 auf 180 s steht, endeten die Fahrten dadurch 180 s zu spaet.""" nachlauf = ZUENDUNG_NACHLAUF_S - ueber_trip = bool(k.zuordnung.werte.TRIP_SENSOR) - if ueber_trip: - nachlauf += NACHLAUF_S if not nachlauf: return signal_ende gerechnet = signal_ende - datetime.timedelta(seconds=nachlauf) @@ -247,8 +226,6 @@ async def _echtes_ende( int((signal_ende - start_ts).total_seconds()), nachlauf, ) return start_ts - if ueber_trip: - await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet) # Nie hinter dem letzten Lebenszeichen des Geraets. # @@ -273,49 +250,6 @@ async def _echtes_ende( return gerechnet -async def _nachlauf_gegenpruefen( - k: Koordinator, - start_ts: datetime.datetime, - signal_ende: datetime.datetime, - gerechnet: datetime.datetime, -) -> None: - """Vergleicht das gerechnete Ende mit dem beobachteten Zuendungswechsel und - meldet, wenn beide nicht zusammenpassen. Aendert nichts - die Rechnung - bleibt massgeblich. - - Warum nur eine Meldung und keine Korrektur: wie genau der Zeitpunkt "Zuendung - aus" bekannt ist, haengt an der I/O-Einstellung des Geraets. Steht der - Operand des Zuendungselements auf "Monitoring", erzeugt ein Wechsel keinen - eigenen Datensatz, sondern faehrt nur im naechsten mit - dann ist der - beobachtete Zeitpunkt zu spaet, oft genau der des Trip-Endes. Auf "On Change" - waere er exakt. Solange das nicht feststeht, waere ein Umschalten auf den - beobachteten Wert eine Verschlechterung; eine Abweichung zu protokollieren - ist es nie.""" - sensor = k.zuordnung.werte.ZUENDUNG_SENSOR - if not sensor or sensor == fahrtsignal(k.zuordnung.werte): - return - punkte = await verlauf_lesen( - k.hass, sensor, start_ts, signal_ende, k.zuordnung.werte.MELDEZEIT_SENSOR - ) - aus = [ts for ts, wert in punkte if wert == "off"] - if not aus: - return - # Der beobachtete Wechsel traegt den Nachlauf des Zuendungselements noch in - # sich, das gerechnete Ende nicht mehr - erst ohne ihn meinen beide Zahlen - # denselben Zeitpunkt. Ohne diese Zeile meldete die Pruefung seit dem - # 01.09.2026 bei JEDER Fahrt eine Abweichung von 180 s. - beobachtet = aus[-1] - datetime.timedelta(seconds=ZUENDUNG_NACHLAUF_S) - abweichung = abs((beobachtet - gerechnet).total_seconds()) - if abweichung > NACHLAUF_TOLERANZ_S: - _LOGGER.warning( - "Fahrtende gerechnet auf %s (Trip-Ende minus %s s), die Zuendung ging " - "laut Aufzeichnung aber um %s aus - %.0f s Abweichung. Entweder stimmt " - "NACHLAUF_S nicht mehr mit dem Geraet ueberein, oder das " - "Zuendungselement schreibt keine eigenen Datensaetze.", - gerechnet.isoformat(), NACHLAUF_S, aus[-1].isoformat(), abweichung, - ) - - async def _geraetezeit( k: Koordinator, ereigniszeit: datetime.datetime | None, @@ -352,7 +286,7 @@ async def zuendung_geaendert( # # Ohne diese Prüfung las der Beobachter das als "Zündung ging gerade an" # und legte eine Fahrt an, deren Beginn schlicht der Zeitpunkt des - # Neustarts war. Steht das Trip-Signal ohnehin auf "an", entstand so bei + # Neustarts war. Stand die Zuendung ohnehin auf "an", entstand so bei # JEDEM Neustart eine Phantomfahrt - daher der Eintrag vom 30.08.2026, # 19:43 bis 08:37 des Folgetags, 12,9 Stunden, 0 km. # @@ -459,11 +393,8 @@ async def _signalwechsel( return # Ein neues "aus" ersetzt ein aelteres, das noch wartet. k.warte_ende_ab_abbrechen() - # Ob hier noch gewartet wird, entscheidet _pausenzeit_s(): liegt ein - # Trip-Signal an, bringt das Geraet seine Pausentoleranz selbst mit, - # und eine zweite obendrauf haette zwei getrennte Fahrten verschmolzen. - # Loest die Zuendung aus, gibt es diese Toleranz nicht - dann ist die - # Wartezeit unsere. + # Wie lange gewartet wird, entscheidet _pausenzeit_s() - die + # Wartezeit ist unsere, das Geraet bringt keine mit. signal_ende = await _geraetezeit(k, ereigniszeit, jetzt) # Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die # Meldezeit aus einem älteren Datensatz stammt als der Fahrtbeginn - @@ -474,10 +405,9 @@ async def _signalwechsel( signal_ende.isoformat(), k.fahrt_start_ts.isoformat(), ) signal_ende = jetzt - # Erst jetzt die Nachlaeufe des Geraets abziehen: signal_ende ist der - # Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung oder - # das noch spaetere Trip-Ende -, nicht der, zu dem das Fahrzeug - # abgestellt wurde. + # Erst jetzt den Nachlauf des Geraets abziehen: signal_ende ist der + # Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung -, + # nicht der, zu dem das Fahrzeug abgestellt wurde. ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende) pause_s = await _pausenzeit_s(k) if pause_s: @@ -568,14 +498,13 @@ async def _zuendung_kehrt_zurueck( async def _pausenzeit_s(k: Koordinator) -> int: """Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden. - Null, solange ein Trip-Signal zugeordnet ist: dann wartet das Geraet - bereits (Ignition OFF Timeout), und beides zusammen haette aus einer - Viertelstunde eine halbe gemacht. Genau daran ist die Wartezeit am - 31.08.2026 entfallen - sie kommt hier nur fuer den Weg ueber die Zuendung - zurueck. + So lange darf eine zurueckkehrende Zuendung noch dieselbe Fahrt sein - + Tanken, kurze Besorgung. Der Wert kommt aus dem Regler "Fahrt beenden", + derselbe, der auch den Schlaf-Timeout des Dongles stellt (flespi.py). + + Gemessen wird er in GERAETEZEIT, nicht an der Wanduhr - siehe + _zuendung_kehrt_zurueck(). """ - if k.zuordnung.werte.TRIP_SENSOR: - return 0 try: profil = await k.ablage.profil_lesen() wert = (profil.get("einstellungen") or {}).get("fahrten_pausenzeit_min") @@ -812,7 +741,7 @@ async def jetzt_beenden(k: Koordinator) -> bool: _LOGGER.info("\"Fahrt beenden\" gedrueckt, aber es laeuft keine Fahrt") return False start_ts = k.fahrt_start_ts - ende = await _letztes_lebenszeichen(k, fahrtsignal(k.zuordnung.werte), start_ts) + ende = await _letztes_lebenszeichen(k, k.zuordnung.werte.ZUENDUNG_SENSOR, start_ts) _LOGGER.info( "Fahrt seit %s von Hand beendet, vorlaeufiges Ende %s", start_ts.isoformat(), ende.isoformat(), @@ -839,7 +768,7 @@ async def nach_neustart_fortsetzen(k: Koordinator) -> None: if k.fahrt_start_ts is None: return - sensor = fahrtsignal(k.zuordnung.werte) + sensor = k.zuordnung.werte.ZUENDUNG_SENSOR if zustand_oder_none(k.hass, sensor) == "on": return # fährt noch - der Beobachter übernimmt wie sonst auch diff --git a/custom_components/audi_dashboard/flespi.py b/custom_components/audi_dashboard/flespi.py index c61d6b4..c19954e 100644 --- a/custom_components/audi_dashboard/flespi.py +++ b/custom_components/audi_dashboard/flespi.py @@ -3,8 +3,8 @@ WARUM ÜBERHAUPT --------------- Zwei Zahlen dieser Integration gehören eigentlich dem Gerät und stehen bei uns -nur als Konstante bzw. Einstellung: `NACHLAUF_S` (Trip: Ignition OFF Timeout) -und die Wartezeit bis zum Fahrtende. Ändert jemand die Gegenstücke am Dongle, +nur als Konstante bzw. Einstellung: die Wartezeit bis zum Fahrtende +(`fahrterkennung.ZUENDUNG_NACHLAUF_S` und der Regler). Ändert jemand die Gegenstücke am Dongle, rechnet die Fahrterkennung still weiter mit dem alten Wert - und niemand merkt es. Genau das ist am 01.09.2026 passiert, als `400` von 0 auf 180 ging: seither endeten alle Fahrten 180 s zu spät, gefunden erst zwei Tage später beim @@ -133,7 +133,7 @@ INTERESSANT: dict[str, dict] = { "nummer": "11804", "titel": "Trip: Ignition OFF Timeout", "einheit": "s", - "unser": "NACHLAUF_S", + "unser": None, }, "trip_scenario.start_speed": { "nummer": "11803", diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index a963fad..749b47e 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.4.26","sha256":"0e65b78a949052abdcb6765a9a08e1526e67bec911657073d35f7158f51ba0e4","bytes":324250,"gebaut":"2026-09-04T23:10:56Z"} \ No newline at end of file +{"version":"2026.9.5.3","sha256":"6c9e0c3ef0429fd6ec6264af982494b9c699e8b3483fea145946d991805b0e47","bytes":324399,"gebaut":"2026-09-04T23:51:45Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index cfcff53..8f5e0ea 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index ac2a442..e10d47f 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -225,7 +225,6 @@ function profilZuCar(p, status) { finAutomatisch: p.fahrzeug.fin_automatisch !== false, details: p.fahrzeug.details, erstzulassung: p.fahrzeug.erstzulassung, - hu: p.fahrzeug.hauptuntersuchung_faellig, odo: status.km ?? 0, odoBekannt: status.km != null, tankPct: status.tankprozent ?? 0, @@ -287,7 +286,6 @@ function configCarZuProfil(vorherigesProfil) { p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz; p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel; p.fahrzeug.kennzeichen = CONFIG.kennzeichen; - p.fahrzeug.hauptuntersuchung_faellig = CAR.hu; p.fahrzeug.fin = CAR.fin; p.fahrzeug.fin_automatisch = CAR.finAutomatisch; p.fahrzeug.erstzulassung = CAR.erstzulassung; @@ -2362,7 +2360,7 @@ function vHome() { const datum = t.kmDatum || t.datum; const hauptwert = restKm !== null ? `${de(restKm)}km` - : (datum ? dedat(datum) : "–"); + : (datum === t.datum ? datumText(t.datum, t.genau) : (datum ? dedat(datum) : "–")); // Ohne Restkilometer steht das Datum selbst im Zahlenfeld - auf Nutzerwunsch // jetzt in derselben Groesse wie die km-Zahl (vorher 30px, damit "15.04.2027" // auf 375px nicht umbricht - am laufenden Panel geprueft, dass es bei 40px @@ -2924,6 +2922,17 @@ function vService() { const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null; const hatMeldung = fm.km != null || fmDatum; const iv = intervalle(t); + /* Ohne Wartungsplan-Eintrag und ohne Fahrzeugmeldung bleibt bei der + Hauptuntersuchung trotzdem ein Termin uebrig: der aus der + Erstzulassung abgeleitete (siehe hauptuntersuchungsTermin()). Bis zum + 05.09.2026 hat diese Kachel ihn verschluckt und "kein Eintrag im + Wartungsplan" gezeigt, waehrend die Uebersicht denselben Termin + auswies - gemeldet vom Eigentuemer, zwei Bildschirme, zwei + Antworten. */ + if (!iv && !hatMeldung && t.datum) return `
+
${kopf}
+
${datumText(t.datum, t.genau)}
+
`; if (!iv && !hatMeldung) return `
${kopf}
kein Eintrag im Wartungsplan
@@ -3122,27 +3131,56 @@ function gewaehlterTermin() { return auswahl[Math.min(svAuswahl, auswahl.length - 1)]; } -/* Die Hauptuntersuchung hat drei moegliche Quellen, in dieser Reihenfolge: +/* Die Hauptuntersuchung hat ZWEI Quellen, in dieser Reihenfolge: - 1. die im Profil EINGETRAGENE Faelligkeit (CAR.hu). Sie steht auf dem - Pruefbericht und ist damit die belastbarste Angabe - bis zum - 04.09.2026 hat das Panel sie gar nicht benutzt, obwohl sie - gespeichert war (Befund des Eigentuemers). - 2. der letzte Servicebuch-Eintrag plus 24 Monate. - 3. die Erstzulassung plus 24 Monate. + 1. der letzte Servicebuch-Eintrag plus **24** Monate. + 2. die Erstzulassung plus **36** Monate. + + Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines + Neuwagens ist nach drei Jahren faellig, jede weitere nach zwei. + + Es gibt KEINE dritte Quelle mehr. Bis zum 05.09.2026 stand hier zuerst + ein Profilfeld `hauptuntersuchung_faellig`, das alles andere ueberstimmt + hat. Dieses Feld hatte in keiner der beiden Oberflaechen ein + Eingabefeld und im Backend kein Schema - es lag nur als toter Schluessel + in fahrzeugprofil.json und war von dort weder zu sehen noch zu aendern. + Gemeldet vom Eigentuemer am 05.09.2026: Wartungsplan leer, Erstzulassung + 01.03.2025, angezeigt wurde trotzdem August 2026 - das war jener tote + Schluessel ("08/2026"). Das Feld ist samt Lese- und Schreibweg + entfernt; die Ableitung ist die einzige Quelle. */ +const HU_MONATE_FOLGE = 24; +const HU_MONATE_ERSTE = 36; + +/* Der JUENGSTE Hauptuntersuchungs-Eintrag des Wartungsplans. + + Nicht ueber letzterEintrag(): das nimmt mit .find() den ersten Treffer in + Listenreihenfolge, waehrend die App nach Datum absteigend sortiert. Bei + mehreren Eintraegen haetten beide Seiten verschiedene Termine gezeigt, + ohne dass man es der Anzeige ansieht. */ +function letzteHauptuntersuchung() { + let beste = null; + let besteZeit = null; + for (const e of CAR.service.buch) { + if (!e || typeof e.art !== "string" || !e.art.toLowerCase().includes("hauptunter")) continue; + const z = datumAusText(e.datum); + if (!z) continue; + if (besteZeit === null || z.zeit > besteZeit) { beste = e; besteZeit = z.zeit; } + } + return beste; +} - Zu 3.: das ging vorher NUR mit einer Erstzulassung im Format MM/JJJJ. - Ein vollstaendiges Datum (01.03.2025) - eine voellig richtige Eingabe - - liess den Termin still leer. datumAusText() nimmt jetzt beide Formen. */ function hauptuntersuchungsTermin(bauen) { - const gesetzt = datumAusText(CAR.hu); - if (gesetzt) { - return { art: "Hauptuntersuchung", km: null, datum: gesetzt.zeit, kmDatum: null, - zuerst: "zeit", basis: null, ziel: gesetzt.zeit, genau: gesetzt.genau }; + const eintrag = letzteHauptuntersuchung(); + if (eintrag) { + return bauen("Hauptuntersuchung", eintrag, null, HU_MONATE_FOLGE); } const erst = datumAusText(CAR.erstzulassung); - return bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24, + const termin = bauen("Hauptuntersuchung", null, null, HU_MONATE_ERSTE, erst ? erst.zeit : null); + // Eine Erstzulassung als "03/2025" nennt keinen Tag. Dann nennt auch der + // abgeleitete Termin keinen - "01.03.2028" waere eine Erfindung. + if (erst && erst.genau === "monat") termin.genau = "monat"; + return termin; } function termine() { diff --git a/custom_components/audi_dashboard/geraet.py b/custom_components/audi_dashboard/geraet.py index 2250072..699d307 100644 --- a/custom_components/audi_dashboard/geraet.py +++ b/custom_components/audi_dashboard/geraet.py @@ -78,7 +78,7 @@ def _meldezeit(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None: def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None: register = er.async_get(hass) - for eid in (werte.MELDEZEIT_SENSOR, verlauf.fahrtsignal(werte), werte.ZUENDUNG_SENSOR): + for eid in (werte.MELDEZEIT_SENSOR, werte.ZUENDUNG_SENSOR): if not eid: continue eintrag = register.async_get(eid) diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py index 09fd3d9..002aded 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -48,7 +48,6 @@ from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent from .verlauf import ( MINDESTDAUER_S, durchschnitt_kmh, - fahrtsignal, ist_gefahren, strecke_waehlen, zaehlerstrecke, @@ -499,7 +498,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None: # Rückblick muss dieselbe Uhr benutzen wie der Livepfad. mz = werte.MELDEZEIT_SENSOR verlaeufe = { - "zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis, mz), + "zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis, mz), "km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis, mz), "gnss_km": await verlauf_lesen(k.hass, werte.GNSS_KM_SENSOR, von, bis, mz), "tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis, mz), diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index cd12555..df2f73e 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -297,13 +297,10 @@ class Koordinator: self._beobachter.clear() werte = self.zuordnung.werte - # Fahrten löst der Trip-Status aus (oder die Zündung, wenn er fehlt). - self._beobachten(verlauf.fahrtsignal(werte), self._fahrtsignal_geaendert) - # Die Zündung selbst nur noch für die Anzeige "fährt/steht" - sofort - # neu veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu - # warten. Entfällt, wenn sie ohnehin schon das Fahrtsignal ist. - if werte.ZUENDUNG_SENSOR != verlauf.fahrtsignal(werte): - self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_angezeigt) + # Die Zuendung loest die Fahrten aus - seit dem 05.09.2026 als + # einzige. Vorher konnte ein zugeordneter Trip-Sensor sie + # verdraengen; siehe den Kommentar an _fahrtsignal_geaendert(). + self._beobachten(werte.ZUENDUNG_SENSOR, self._fahrtsignal_geaendert) self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert) self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert) self._beobachten(werte.TANK_LITER_SENSOR, self._tankvolumen_geaendert) diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index d344718..98cce5d 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.4.26", + "version": "2026.9.5.3", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [ diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py index 374b624..726295d 100644 --- a/custom_components/audi_dashboard/verlauf.py +++ b/custom_components/audi_dashboard/verlauf.py @@ -203,19 +203,6 @@ async def verlauf_lesen( return [] -def fahrtsignal(werte: object) -> str: - """Die Entität, die Fahrtbeginn und -ende auslöst. - - Der Trip-Status des Geräts, wenn er zugeordnet ist - sonst die Zündung, - wie vor der Trennung der beiden Rollen. Damit laufen bestehende - Installationen unverändert weiter, ohne dass jemand etwas zuordnen muss. - - Eine gemeinsame Stelle, weil sonst Livepfad (fahrterkennung.py) und - Rückblick (historienimport.py) auseinanderlaufen könnten - dieselbe Regel - wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S.""" - return getattr(werte, "TRIP_SENSOR", "") or getattr(werte, "ZUENDUNG_SENSOR", "") - - def als_kilometerstand(wert: object) -> float | None: """Ein eingegebener Kilometerstand, oder None wenn er unbekannt ist. diff --git a/custom_components/audi_dashboard/zuordnung.py b/custom_components/audi_dashboard/zuordnung.py index 57ea25d..5628352 100644 --- a/custom_components/audi_dashboard/zuordnung.py +++ b/custom_components/audi_dashboard/zuordnung.py @@ -22,11 +22,16 @@ der Reihenfolge vorheriger Aufrufe. from __future__ import annotations +import logging + from . import einstellungen from .ablage import Ablage from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung +_LOGGER = logging.getLogger(__name__) + + def _ist_leer(wert: object) -> bool: """Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...], und @@ -47,8 +52,24 @@ class Zuordnung: async def anwenden(self) -> None: """Setzt für jedes bekannte Feld den wirksamen Wert - den Override, - wenn einer hinterlegt ist, sonst den eingebauten Standardwert.""" + wenn einer hinterlegt ist, sonst den eingebauten Standardwert. + + Räumt dabei einmalig auf: Schlüssel, die der Katalog nicht mehr kennt, + werden aus entitaeten.json entfernt. `speichern()` filtert sie schon + beim Schreiben heraus, aber eine bestehende Datei behielt sie bis zum + nächsten Speichern - und ein Feld, das es nicht mehr gibt, soll auch + nicht mehr in den Daten stehen. Angefallen ist das mit dem Entfernen + von TRIP_SENSOR am 05.09.2026.""" overrides = await self._ablage.zuordnung_lesen() + verwaist = [k for k in overrides if k not in SCHLUESSEL] + if verwaist: + _LOGGER.info( + "Zuordnung: %s kennt der Katalog nicht mehr - entfernt", + ", ".join(sorted(verwaist)), + ) + await self._ablage.zuordnung_schreiben( + {k: v for k, v in overrides.items() if k in SCHLUESSEL} + ) for key in SCHLUESSEL: wert = overrides.get(key) standard = STANDARDWERTE[key] diff --git a/tests/fahrterkennung/test_fahrtende_knopf.py b/tests/fahrterkennung/test_fahrtende_knopf.py index 3c738da..21cc07b 100644 --- a/tests/fahrterkennung/test_fahrtende_knopf.py +++ b/tests/fahrterkennung/test_fahrtende_knopf.py @@ -32,7 +32,6 @@ LEBENSZEICHEN = datetime.datetime(2026, 9, 3, 8, 20, 0, tzinfo=datetime.UTC) class FakeWerte: - TRIP_SENSOR = "" ZUENDUNG_SENSOR = "binary_sensor.zuendung" MELDEZEIT_SENSOR = "" diff --git a/tests/fahrterkennung/test_signalwechsel.py b/tests/fahrterkennung/test_signalwechsel.py index eb92975..8b9781d 100644 --- a/tests/fahrterkennung/test_signalwechsel.py +++ b/tests/fahrterkennung/test_signalwechsel.py @@ -34,7 +34,6 @@ T_WECHSEL = datetime.datetime(2026, 9, 2, 21, 22, 12, tzinfo=datetime.UTC) class FakeWerte: - TRIP_SENSOR = "binary_sensor.trip" ZUENDUNG_SENSOR = "binary_sensor.zuendung" @@ -50,10 +49,9 @@ class FakeKoordinator: self.fahrt_sperre = asyncio.Lock() self.beendet = [] self.veroeffentlicht = 0 - # Dieser Test beschreibt die Welt MIT Trip-Signal: dort bringt das - # Geraet seine Pausentoleranz mit, _pausenzeit_s() gibt 0 zurueck und - # es wird sofort geschlossen. Gegenstand ist hier die Sperre, nicht - # die Wartezeit - die hat test_zuendungspause.py. + # Gegenstand ist hier die SPERRE, nicht die Wartezeit - die hat + # test_zuendungspause.py. Deshalb ist die Pausenzeit unten auf 0 + # gesetzt, dann schliesst _signalwechsel() sofort. self.zuordnung = FakeZuordnung() # Seit die Pausenregel in Geraetezeit rechnet, liest _signalwechsel() das @@ -72,7 +70,10 @@ class FakeKoordinator: class SignalwechselTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): - self._echt = (f._geraetezeit, f._echtes_ende, f.fahrt_beenden) + self._echt = (f._geraetezeit, f._echtes_ende, f.fahrt_beenden, f._pausenzeit_s) + + async def pausenzeit(k): + return 0 async def geraetezeit(k, ereigniszeit, standard): # Der eigentliche Punkt: dieser Schritt wartet wirklich (in echt @@ -88,12 +89,12 @@ class SignalwechselTest(unittest.IsolatedAsyncioTestCase): k.beendet.append((start_ts, ende_ts)) await k.fahrt_start_setzen(None) - f._geraetezeit, f._echtes_ende, f.fahrt_beenden = ( - geraetezeit, echtes_ende, beenden, + f._geraetezeit, f._echtes_ende, f.fahrt_beenden, f._pausenzeit_s = ( + geraetezeit, echtes_ende, beenden, pausenzeit, ) async def asyncTearDown(self): - f._geraetezeit, f._echtes_ende, f.fahrt_beenden = self._echt + f._geraetezeit, f._echtes_ende, f.fahrt_beenden, f._pausenzeit_s = self._echt async def test_off_und_on_in_derselben_sekunde(self): """Der genaue Fall vom 02.09.2026: die neue Fahrt darf nicht verloren diff --git a/tests/fahrterkennung/test_zuendungspause.py b/tests/fahrterkennung/test_zuendungspause.py index be4a100..f123c0c 100644 --- a/tests/fahrterkennung/test_zuendungspause.py +++ b/tests/fahrterkennung/test_zuendungspause.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Die Wartezeit nach dem Zuendungs-Aus, und die beiden Nachlaeufe des Geraets. +"""Die Wartezeit nach dem Zuendungs-Aus und der Nachlauf des Geraets. Warum es das gibt (03.09.2026): das Trip-Signal des FMM003 endet erst nach seinem "Ignition OFF Timeout" von 900 s - und der Schlaf-Timeout des Geraets @@ -46,8 +46,7 @@ WARTEN = 0.15 class FakeWerte: - def __init__(self, trip=""): - self.TRIP_SENSOR = trip + def __init__(self): self.ZUENDUNG_SENSOR = "binary_sensor.zuendung" # Ohne Meldezeit-Sensor keine Geraetezeit und keine Klemme - die Tests # pruefen die Nachlaufrechnung, nicht die Zuordnung. @@ -55,8 +54,8 @@ class FakeWerte: class FakeZuordnung: - def __init__(self, trip=""): - self.werte = FakeWerte(trip) + def __init__(self): + self.werte = FakeWerte() class FakeAblage: @@ -72,10 +71,10 @@ class FakeAblage: class FakeKoordinator: - def __init__(self, start_ts=T0, trip="", minuten=15, fahrten=None): + def __init__(self, start_ts=T0, minuten=15, fahrten=None): self.fahrt_start_ts = start_ts self.fahrt_sperre = asyncio.Lock() - self.zuordnung = FakeZuordnung(trip) + self.zuordnung = FakeZuordnung() self.ablage = FakeAblage(minuten, fahrten) self.beendet = [] self._aufgabe = None @@ -111,14 +110,12 @@ class FakeKoordinator: class Basis(unittest.IsolatedAsyncioTestCase): def _pause_kurz(self): async def pausenzeit(k): - return 0 if k.zuordnung.werte.TRIP_SENSOR else PAUSE_S + return PAUSE_S return pausenzeit async def asyncSetUp(self): - self._echt = ( - f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen, - ) + self._echt = (f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s) async def geraetezeit(k, ereigniszeit, standard): return ereigniszeit or standard @@ -127,11 +124,6 @@ class Basis(unittest.IsolatedAsyncioTestCase): k.beendet.append((start_ts, ende_ts)) await k.fahrt_start_setzen(None) - async def gegenpruefen(k, start_ts, signal_ende, gerechnet): - # Liest den Verlauf und aendert nichts an der Rechnung (siehe dort); - # ohne echtes hass-Objekt ist sie hier nur im Weg. - return None - async def nach_pause(k, start_ts, ende_ts, pause_s): # Wie das Original, nur mit kurzer Wanduhrzeit - die echte # Pausenzeit steckt weiterhin in der Entscheidung, nicht im Warten. @@ -141,14 +133,11 @@ class Basis(unittest.IsolatedAsyncioTestCase): f._geraetezeit = geraetezeit f.fahrt_beenden = beenden f._pausenzeit_s = self._pause_kurz() - f._nachlauf_gegenpruefen = gegenpruefen self._echt_nach_pause = f._nach_pause_beenden f._nach_pause_beenden = nach_pause async def asyncTearDown(self): - ( - f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen, - ) = self._echt + (f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s) = self._echt f._nach_pause_beenden = self._echt_nach_pause @@ -187,11 +176,6 @@ class Wartezeit(Basis): self.assertEqual(k.beendet, []) self.assertEqual(k.fahrt_start_ts, T0) - async def test_ohne_wartezeit_bleibt_es_wie_bisher(self): - """Mit zugeordnetem Trip-Signal wartet das Geraet - wir nicht.""" - k = FakeKoordinator(trip="binary_sensor.trip") - await f.zuendung_geaendert(k, "off", "on", AUS) - self.assertEqual(len(k.beendet), 1, "ohne Pause wird sofort geschlossen") class Nachlauf(Basis): @@ -202,13 +186,6 @@ class Nachlauf(Basis): _, ende = k.beendet[0] self.assertEqual(ende, AUS - datetime.timedelta(seconds=180)) - async def test_tripweg_zieht_beide_nachlaeufe_ab(self): - """900 s Trip-Nachlauf plus 180 s des Zuendungselements: das Trip-Ende - haengt selbst an der bereits verzoegerten Zuendung.""" - k = FakeKoordinator(trip="binary_sensor.trip") - await f.zuendung_geaendert(k, "off", "on", AUS) - _, ende = k.beendet[0] - self.assertEqual(ende, AUS - datetime.timedelta(seconds=1080)) async def test_zu_kurz_wird_auf_den_beginn_geklemmt(self): k = FakeKoordinator(start_ts=AUS - datetime.timedelta(seconds=60)) @@ -412,9 +389,6 @@ class Einstellung(unittest.IsolatedAsyncioTestCase): k = FakeKoordinator(minuten=25) self.assertEqual(await f._pausenzeit_s(k), 25 * 60) - async def test_trip_signal_schlaegt_die_einstellung(self): - k = FakeKoordinator(minuten=25, trip="binary_sensor.trip") - self.assertEqual(await f._pausenzeit_s(k), 0) async def test_unsinn_faellt_auf_den_standard_zurueck(self): k = FakeKoordinator()