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