Hauptuntersuchung: Ableitung aus Erstzulassung und Wartungsplan, drittes Feld entfernt

Gemeldet: Wartungsplan leer, Erstzulassung 01.03.2025, erwartet 01.03.2028 -
gezeigt wurden drei verschiedene Antworten (Uebersicht 01.08.2026, Mein Audi
"August 2026", Service "kein Eintrag im Wartungsplan").

Ursache war das Profilfeld hauptuntersuchung_faellig ("08/2026"), das die
Ableitung ueberstimmte, in keiner Oberflaeche ein Eingabefeld hatte und im
Backend kein Schema. Drei Bildschirme verarbeiteten denselben Wert
verschieden. Das Feld ist samt Lese- und Schreibweg entfernt; es gibt jetzt
zwei Quellen: Wartungsplan + 24 Monate, sonst Erstzulassung + 36 Monate.

Dabei mitbehoben:

* Die Service-Kachel verschluckte abgeleitete Termine, sobald kein
  Wartungsplan-Eintrag dahinterstand.
* monatePlus() in der App verlor einen Tag ueber die Zeitumstellung (drei von
  fuenf gemessenen Faellen) - betraf Oelwechsel- und Inspektionsprognose
  genauso. Das Panel rechnete dort seit jeher richtig.
* Die App verlangte fuer jeden Wartungsplan-Eintrag eine Kilometerangabe. Eine
  Hauptuntersuchung ist rein datumsbasiert; ohne km waere der erste HU-Eintrag
  ignoriert worden. Ausserdem nahm das Panel den ersten Treffer der Liste, die
  App den juengsten - beide nehmen jetzt den juengsten.
* Monatsgenauigkeit ("03/2025") bleibt auf beiden Seiten erhalten.

Neu: paritaet_hu.test.ts vergleicht die App gegen die aus der Panel-Quelle
herausgeschnittenen Originalfunktionen, 15 Faelle. Gesamt 292 App-Tests, 73
Python-Tests mit 231 Untertests, 0 Tracebacks. Live an der Testinstanz auf
allen drei gemeldeten Bildschirmen geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 01:56:11 +02:00
parent 5621b818cc
commit 863284e540
25 changed files with 505 additions and 243 deletions
+4
View File
@@ -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
+135
View File
@@ -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 `<input>` 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 <ziel> -name
manifest.json | wc -l` muss `1` ergeben.
@@ -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))
})
}
})
-3
View File
@@ -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
+52 -4
View File
@@ -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()
})
})
+86 -11
View File
@@ -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
}
+4 -1
View File
@@ -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") {
+9 -4
View File
@@ -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,
},
]
+6 -11
View File
@@ -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) =>
<span>Hauptuntersuchung</span>
</div>
{huGueltig ? (
<div className="dm-tgwert">{datum(fahrzeug.hu)}</div>
<div className="dm-tgwert">{datum(huFaellig)}</div>
) : (
<div className="dm-tgwert dm-tgwert--leer">kein Eintrag im Wartungsplan</div>
)}
-1
View File
@@ -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",
},
@@ -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"]},
@@ -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
+3 -3
View File
@@ -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",
@@ -1 +1 @@
{"version":"2026.9.4.26","sha256":"0e65b78a949052abdcb6765a9a08e1526e67bec911657073d35f7158f51ba0e4","bytes":324250,"gebaut":"2026-09-04T23:10:56Z"}
{"version":"2026.9.5.3","sha256":"6c9e0c3ef0429fd6ec6264af982494b9c699e8b3483fea145946d991805b0e47","bytes":324399,"gebaut":"2026-09-04T23:51:45Z"}
@@ -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)}<span class="unit">km</span>`
: (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 `<div class="termingruppe">
<div class="tg-kopf">${kopf}</div>
<div class="tg-wert">${datumText(t.datum, t.genau)}</div>
</div>`;
if (!iv && !hatMeldung) return `<div class="termingruppe">
<div class="tg-kopf">${kopf}</div>
<div class="tg-wert leer">kein Eintrag im Wartungsplan</div>
@@ -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() {
+1 -1
View File
@@ -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)
@@ -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),
@@ -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)
@@ -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": [
@@ -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.
+22 -1
View File
@@ -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]
@@ -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 = ""
+10 -9
View File
@@ -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
+9 -35
View File
@@ -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()