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)
|
# Arbeitsdateien dieser Werkzeugkette (Patch-Skripte, Zwischenstaende)
|
||||||
.tmp-*
|
.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).
|
* "Jetzt lesen" als echtes Lesen (Cache loeschen, dann holen).
|
||||||
* Der Waechter darf keinen Gleichstand melden, solange unbekannt ist, wann der
|
* Der Waechter darf keinen Gleichstand melden, solange unbekannt ist, wann der
|
||||||
Wert zuletzt vom Geraet bestaetigt wurde.
|
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
|
fin: string
|
||||||
details: string
|
details: string
|
||||||
erstzulassung: string
|
erstzulassung: string
|
||||||
hu: string
|
|
||||||
odo: number
|
odo: number
|
||||||
odoBekannt: boolean
|
odoBekannt: boolean
|
||||||
tankPct: number
|
tankPct: number
|
||||||
@@ -174,7 +173,6 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
|
|||||||
fin: text(fahrzeug, "fin"),
|
fin: text(fahrzeug, "fin"),
|
||||||
details: text(fahrzeug, "details"),
|
details: text(fahrzeug, "details"),
|
||||||
erstzulassung: text(fahrzeug, "erstzulassung"),
|
erstzulassung: text(fahrzeug, "erstzulassung"),
|
||||||
hu: text(fahrzeug, "hauptuntersuchung_faellig"),
|
|
||||||
odo: status.km ?? 0,
|
odo: status.km ?? 0,
|
||||||
odoBekannt: status.km !== null && status.km !== undefined,
|
odoBekannt: status.km !== null && status.km !== undefined,
|
||||||
tankPct: status.tankprozent ?? 0,
|
tankPct: status.tankprozent ?? 0,
|
||||||
@@ -247,7 +245,6 @@ export function zusammenfuehren(
|
|||||||
f["tankvolumen_liter"] = einstellungen.tankvolumen
|
f["tankvolumen_liter"] = einstellungen.tankvolumen
|
||||||
f["fin"] = fahrzeug.fin
|
f["fin"] = fahrzeug.fin
|
||||||
f["erstzulassung"] = fahrzeug.erstzulassung
|
f["erstzulassung"] = fahrzeug.erstzulassung
|
||||||
f["hauptuntersuchung_faellig"] = fahrzeug.hu
|
|
||||||
|
|
||||||
e["uebersichtsbild"] = einstellungen.startbild
|
e["uebersichtsbild"] = einstellungen.startbild
|
||||||
e["backup_intervall"] = einstellungen.backupIntervall
|
e["backup_intervall"] = einstellungen.backupIntervall
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
hauptuntersuchungFaellig,
|
||||||
inspektionPrognose,
|
inspektionPrognose,
|
||||||
kmProTagAusFahrten,
|
kmProTagAusFahrten,
|
||||||
letzterOelwechsel,
|
letzterOelwechsel,
|
||||||
@@ -17,7 +18,6 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
|||||||
fin: "",
|
fin: "",
|
||||||
details: "",
|
details: "",
|
||||||
erstzulassung: "",
|
erstzulassung: "",
|
||||||
hu: "",
|
|
||||||
odo,
|
odo,
|
||||||
odoBekannt: true,
|
odoBekannt: true,
|
||||||
tankPct: 0,
|
tankPct: 0,
|
||||||
@@ -147,15 +147,15 @@ describe("naechsterService", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
|
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)
|
const s = naechsterService(f, [], [], jetzt)
|
||||||
expect(s).not.toBeNull()
|
expect(s).not.toBeNull()
|
||||||
expect(s!.art).toBe("Hauptuntersuchung")
|
expect(s!.art).toBe("Hauptuntersuchung")
|
||||||
expect(s!.restKm).toBeNull()
|
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()
|
expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -350,3 +350,51 @@ describe("oelMeldungsPrognose", () => {
|
|||||||
expect(p!.datum).toBe("2027-08-11")
|
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.
|
* Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { alsZeitpunkt, isoTag } from "../format"
|
||||||
import type { Fahrzeug } from "./profilAdapter"
|
import type { Fahrzeug } from "./profilAdapter"
|
||||||
|
|
||||||
const TAG_MS = 86_400_000
|
const TAG_MS = 86_400_000
|
||||||
@@ -30,11 +31,28 @@ interface Buchhaltung {
|
|||||||
art?: string
|
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 {
|
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
|
if (Number.isNaN(d.getTime())) return isoDatum
|
||||||
d.setMonth(d.getMonth() + monate)
|
const zwei = (n: number) => String(n).padStart(2, "0")
|
||||||
return d.toISOString().slice(0, 10)
|
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort
|
/** 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 })
|
if (m) kandidaten.push({ art: "Inspektion", restKm: m.restKm, datum: m.datum })
|
||||||
}
|
}
|
||||||
|
|
||||||
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
|
/* Über dieselbe Ableitung wie Service und Mein Audi. Vorher stand hier
|
||||||
const hu = new Date(fahrzeug.hu)
|
`new Date(fahrzeug.hu)` auf einem Profilfeld, das die Ableitung gar nicht
|
||||||
if (fahrzeug.hu && !Number.isNaN(hu.getTime())) {
|
erst befragt hat - daher zeigte die Übersicht am 05.09.2026 den 01.08.2026,
|
||||||
kandidaten.push({
|
während Service "kein Eintrag im Wartungsplan" meldete. Drei Bildschirme,
|
||||||
art: "Hauptuntersuchung",
|
drei Antworten auf dieselbe Frage. */
|
||||||
restKm: null,
|
const hu = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung)
|
||||||
datum: hu.toISOString().slice(0, 10),
|
if (hu) {
|
||||||
})
|
kandidaten.push({ art: "Hauptuntersuchung", restKm: null, datum: hu })
|
||||||
}
|
}
|
||||||
|
|
||||||
return kandidaten
|
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
|
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 === null || wert === undefined || wert === "") return null
|
||||||
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" }
|
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" }
|
||||||
if (typeof wert === "number") {
|
if (typeof wert === "number") {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
|
|||||||
import { Tile } from "@audi-dash/ui"
|
import { Tile } from "@audi-dash/ui"
|
||||||
|
|
||||||
import { useDaten } from "../daten/DatenKontext"
|
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 { datum, de, deOderStrich, eur } from "../format"
|
||||||
import type { SeitenName } from "../navigation"
|
import type { SeitenName } from "../navigation"
|
||||||
import { Wertzeile, Werteliste } from "./bausteine"
|
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"),
|
Der Wert ist bewusst die reale Fahrzeugmeldung (Datum plus „in X km"),
|
||||||
nicht die App-Prognose; fehlt sie, bleibt ein Strich. Die eigene Prognose
|
nicht die App-Prognose; fehlt sie, bleibt ein Strich. Die eigene Prognose
|
||||||
steht klein unter dem Namen (prognoseZeile() dort). */
|
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 oelProg = oelwechselPrognose(fahrzeug, buch)
|
||||||
const inspProg = inspektionPrognose(fahrzeug, buch)
|
const inspProg = inspektionPrognose(fahrzeug, buch)
|
||||||
const termine: { art: string; prognose: string | null; haupt: string; neben: string | null }[] = [
|
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,
|
: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Die Hauptuntersuchung meldet das Fahrzeug nie — hier zählt allein das
|
// Die Hauptuntersuchung meldet das Fahrzeug nie - sie wird immer
|
||||||
// im Profil hinterlegte Datum.
|
// abgeleitet, aus dem Wartungsplan oder der Erstzulassung.
|
||||||
art: "Hauptuntersuchung",
|
art: "Hauptuntersuchung",
|
||||||
prognose: null,
|
prognose: null,
|
||||||
haupt: fahrzeug.hu ? datum(fahrzeug.hu) : "–",
|
haupt: huFaellig ? datum(huFaellig) : "–",
|
||||||
neben: null,
|
neben: null,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,15 +14,7 @@ import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
|
|||||||
|
|
||||||
import { kartendienstUrl } from "../daten/tankstelle"
|
import { kartendienstUrl } from "../daten/tankstelle"
|
||||||
import { useDaten } from "../daten/DatenKontext"
|
import { useDaten } from "../daten/DatenKontext"
|
||||||
import {
|
import { hauptuntersuchungFaellig, inspektionPrognose, letzterInspektion, letzterOelwechsel, meldungsPrognose, oelMeldungsPrognose, oelwechselPrognose, serviceKandidaten } from "../daten/service"
|
||||||
inspektionPrognose,
|
|
||||||
letzterInspektion,
|
|
||||||
letzterOelwechsel,
|
|
||||||
meldungsPrognose,
|
|
||||||
oelMeldungsPrognose,
|
|
||||||
oelwechselPrognose,
|
|
||||||
serviceKandidaten,
|
|
||||||
} from "../daten/service"
|
|
||||||
import { datum, de, eur, isoTag } from "../format"
|
import { datum, de, eur, isoTag } from "../format"
|
||||||
import type { SeitenName } from "../navigation"
|
import type { SeitenName } from "../navigation"
|
||||||
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
|
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
|
||||||
@@ -129,7 +121,10 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
|||||||
? `voraussichtlich am ${datum(inspMeldungProg.datum)}`
|
? `voraussichtlich am ${datum(inspMeldungProg.datum)}`
|
||||||
: null
|
: 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())
|
const huGueltig = !!huDatum && !Number.isNaN(huDatum.getTime())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -174,7 +169,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
|||||||
<span>Hauptuntersuchung</span>
|
<span>Hauptuntersuchung</span>
|
||||||
</div>
|
</div>
|
||||||
{huGueltig ? (
|
{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>
|
<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",
|
details: "2.9 TFSI quattro competition",
|
||||||
kennzeichen: "M AB 1234",
|
kennzeichen: "M AB 1234",
|
||||||
erstzulassung: "2023-04-01",
|
erstzulassung: "2023-04-01",
|
||||||
hauptuntersuchung_faellig: "2027-04-01",
|
|
||||||
tankvolumen_liter: 58,
|
tankvolumen_liter: 58,
|
||||||
ausfuehrung: "competition",
|
ausfuehrung: "competition",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,19 +91,6 @@ class Sensorzuordnung:
|
|||||||
# Verbraucher.
|
# Verbraucher.
|
||||||
GNSS_TEILSTRECKE_SENSOR: str = ""
|
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
|
# Der Zeitstempel, den das Gerät dem Datensatz selbst mitgegeben hat
|
||||||
# (Unix-Sekunden, UTC). Optional, aber der einzige Weg zu einem
|
# (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,
|
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km"], "liste": False, "pflicht": False,
|
||||||
"beispiel": "segment_mileage",
|
"beispiel": "segment_mileage",
|
||||||
"stichworte": ["segment", "teilstrecke", "abschnitt", "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",
|
{"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,
|
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
|
||||||
"beispiel": "engine_ignition_or_acc_status",
|
"beispiel": "engine_ignition_or_acc_status",
|
||||||
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
|
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from .ablage import neue_id
|
|||||||
from .veroeffentlichung import zustand_oder_none
|
from .veroeffentlichung import zustand_oder_none
|
||||||
from .verlauf import (
|
from .verlauf import (
|
||||||
MINDESTDAUER_S,
|
MINDESTDAUER_S,
|
||||||
fahrtsignal,
|
|
||||||
geraetezeit,
|
geraetezeit,
|
||||||
geraetezeit_plausibel,
|
geraetezeit_plausibel,
|
||||||
verlauf_lesen,
|
verlauf_lesen,
|
||||||
@@ -52,22 +51,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
# Einstellung fahrten_pausenzeit_min in beiden Oberflächen wieder anbieten.
|
# 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
|
# Ab welcher Abweichung zwischen gerechnetem und beobachtetem Ende gewarnt
|
||||||
# wird. Grosszuegig, weil die Zuendung nur so genau bekannt ist, wie das Geraet
|
# 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
|
# 02.09.2026 kippte die Zuendung je einmal in den ersten 25 Sekunden fuer unter
|
||||||
# eine Sekunde weg und kam sofort zurueck.
|
# eine Sekunde weg und kam sofort zurueck.
|
||||||
#
|
#
|
||||||
# Der gemeldete Zeitpunkt liegt damit systematisch zu spaet, und zwar in BEIDEN
|
# Der gemeldete Zeitpunkt liegt damit systematisch zu spaet und wird abgezogen.
|
||||||
# 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
|
# (Bis zum 05.09.2026 gab es einen zweiten Weg ueber das Trip-Signal des
|
||||||
# NACHLAUF_S spaeter. Wer das Trip-Signal als Ausloeser nimmt, muss deshalb
|
# Geraets, dessen Ende an derselben verzoegerten Zuendung hing und deshalb noch
|
||||||
# beide Spannen abziehen.
|
# 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
|
# 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
|
# 180 s (aus den Geraetekonfigurationen rekonstruiert). Fahrten aus der Zeit
|
||||||
# dazwischen enden 180 s zu spaet - korrigiert wird ab jetzt, rueckwirkend nur
|
# dazwischen enden 180 s zu spaet - korrigiert wird ab jetzt, rueckwirkend nur
|
||||||
# von Hand.
|
# von Hand.
|
||||||
#
|
#
|
||||||
# Wie NACHLAUF_S die Konfiguration des Geraets und bewusst keine Einstellung in
|
# Die Konfiguration des Geraets (Parameter 400) und bewusst keine Einstellung
|
||||||
# der Oberflaeche.
|
# in der Oberflaeche.
|
||||||
ZUENDUNG_NACHLAUF_S = 180
|
ZUENDUNG_NACHLAUF_S = 180
|
||||||
|
|
||||||
# Wie lange nach einem vorlaeufigen Ende ein spaeter eintreffendes Zuendungs-Aus
|
# Wie lange nach einem vorlaeufigen Ende ein spaeter eintreffendes Zuendungs-Aus
|
||||||
@@ -216,7 +200,7 @@ async def _echtes_ende(
|
|||||||
) -> datetime.datetime:
|
) -> datetime.datetime:
|
||||||
"""Das Fahrtende ohne den Nachlauf des Geraets.
|
"""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
|
Zeitpunkt, zu dem wirklich abgestellt wurde. Die Rechnung stammt vollstaendig
|
||||||
aus der Logik des Geraets und ueberlebt deshalb auch ein Funkloch: der
|
aus der Logik des Geraets und ueberlebt deshalb auch ein Funkloch: der
|
||||||
Datensatz kommt spaeter, traegt aber seine eigene Zeit (siehe geraetezeit()).
|
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
|
anschliessend. Ohne diese Klemme entstuende aus dreissig Sekunden Zuendung
|
||||||
eine Viertelstunde Fahrt.
|
eine Viertelstunde Fahrt.
|
||||||
|
|
||||||
ZWEI Nachlaeufe, nicht einer. Das Zuendungselement meldet selbst schon
|
Das Zuendungselement meldet selbst schon verzoegert
|
||||||
verzoegert (ZUENDUNG_NACHLAUF_S), und das Trip-Ende haengt an dieser bereits
|
(ZUENDUNG_NACHLAUF_S); genau diese Spanne wird abgezogen. Bis zum
|
||||||
verzoegerten Zuendung - wer ueber das Trip-Signal kommt, muss deshalb beide
|
03.09.2026 geschah das nicht, und seit der Ignition OFF Delay am
|
||||||
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
|
|
||||||
01.09.2026 auf 180 s steht, endeten die Fahrten dadurch 180 s zu spaet."""
|
01.09.2026 auf 180 s steht, endeten die Fahrten dadurch 180 s zu spaet."""
|
||||||
nachlauf = ZUENDUNG_NACHLAUF_S
|
nachlauf = ZUENDUNG_NACHLAUF_S
|
||||||
ueber_trip = bool(k.zuordnung.werte.TRIP_SENSOR)
|
|
||||||
if ueber_trip:
|
|
||||||
nachlauf += NACHLAUF_S
|
|
||||||
if not nachlauf:
|
if not nachlauf:
|
||||||
return signal_ende
|
return signal_ende
|
||||||
gerechnet = signal_ende - datetime.timedelta(seconds=nachlauf)
|
gerechnet = signal_ende - datetime.timedelta(seconds=nachlauf)
|
||||||
@@ -247,8 +226,6 @@ async def _echtes_ende(
|
|||||||
int((signal_ende - start_ts).total_seconds()), nachlauf,
|
int((signal_ende - start_ts).total_seconds()), nachlauf,
|
||||||
)
|
)
|
||||||
return start_ts
|
return start_ts
|
||||||
if ueber_trip:
|
|
||||||
await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet)
|
|
||||||
|
|
||||||
# Nie hinter dem letzten Lebenszeichen des Geraets.
|
# Nie hinter dem letzten Lebenszeichen des Geraets.
|
||||||
#
|
#
|
||||||
@@ -273,49 +250,6 @@ async def _echtes_ende(
|
|||||||
return gerechnet
|
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(
|
async def _geraetezeit(
|
||||||
k: Koordinator,
|
k: Koordinator,
|
||||||
ereigniszeit: datetime.datetime | None,
|
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"
|
# 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
|
# 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,
|
# JEDEM Neustart eine Phantomfahrt - daher der Eintrag vom 30.08.2026,
|
||||||
# 19:43 bis 08:37 des Folgetags, 12,9 Stunden, 0 km.
|
# 19:43 bis 08:37 des Folgetags, 12,9 Stunden, 0 km.
|
||||||
#
|
#
|
||||||
@@ -459,11 +393,8 @@ async def _signalwechsel(
|
|||||||
return
|
return
|
||||||
# Ein neues "aus" ersetzt ein aelteres, das noch wartet.
|
# Ein neues "aus" ersetzt ein aelteres, das noch wartet.
|
||||||
k.warte_ende_ab_abbrechen()
|
k.warte_ende_ab_abbrechen()
|
||||||
# Ob hier noch gewartet wird, entscheidet _pausenzeit_s(): liegt ein
|
# Wie lange gewartet wird, entscheidet _pausenzeit_s() - die
|
||||||
# Trip-Signal an, bringt das Geraet seine Pausentoleranz selbst mit,
|
# Wartezeit ist unsere, das Geraet bringt keine mit.
|
||||||
# und eine zweite obendrauf haette zwei getrennte Fahrten verschmolzen.
|
|
||||||
# Loest die Zuendung aus, gibt es diese Toleranz nicht - dann ist die
|
|
||||||
# Wartezeit unsere.
|
|
||||||
signal_ende = await _geraetezeit(k, ereigniszeit, jetzt)
|
signal_ende = await _geraetezeit(k, ereigniszeit, jetzt)
|
||||||
# Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die
|
# Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die
|
||||||
# Meldezeit aus einem älteren Datensatz stammt als der Fahrtbeginn -
|
# 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.isoformat(), k.fahrt_start_ts.isoformat(),
|
||||||
)
|
)
|
||||||
signal_ende = jetzt
|
signal_ende = jetzt
|
||||||
# Erst jetzt die Nachlaeufe des Geraets abziehen: signal_ende ist der
|
# Erst jetzt den Nachlauf des Geraets abziehen: signal_ende ist der
|
||||||
# Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung oder
|
# Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung -,
|
||||||
# das noch spaetere Trip-Ende -, nicht der, zu dem das Fahrzeug
|
# nicht der, zu dem das Fahrzeug abgestellt wurde.
|
||||||
# abgestellt wurde.
|
|
||||||
ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende)
|
ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende)
|
||||||
pause_s = await _pausenzeit_s(k)
|
pause_s = await _pausenzeit_s(k)
|
||||||
if pause_s:
|
if pause_s:
|
||||||
@@ -568,14 +498,13 @@ async def _zuendung_kehrt_zurueck(
|
|||||||
async def _pausenzeit_s(k: Koordinator) -> int:
|
async def _pausenzeit_s(k: Koordinator) -> int:
|
||||||
"""Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden.
|
"""Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden.
|
||||||
|
|
||||||
Null, solange ein Trip-Signal zugeordnet ist: dann wartet das Geraet
|
So lange darf eine zurueckkehrende Zuendung noch dieselbe Fahrt sein -
|
||||||
bereits (Ignition OFF Timeout), und beides zusammen haette aus einer
|
Tanken, kurze Besorgung. Der Wert kommt aus dem Regler "Fahrt beenden",
|
||||||
Viertelstunde eine halbe gemacht. Genau daran ist die Wartezeit am
|
derselbe, der auch den Schlaf-Timeout des Dongles stellt (flespi.py).
|
||||||
31.08.2026 entfallen - sie kommt hier nur fuer den Weg ueber die Zuendung
|
|
||||||
zurueck.
|
Gemessen wird er in GERAETEZEIT, nicht an der Wanduhr - siehe
|
||||||
|
_zuendung_kehrt_zurueck().
|
||||||
"""
|
"""
|
||||||
if k.zuordnung.werte.TRIP_SENSOR:
|
|
||||||
return 0
|
|
||||||
try:
|
try:
|
||||||
profil = await k.ablage.profil_lesen()
|
profil = await k.ablage.profil_lesen()
|
||||||
wert = (profil.get("einstellungen") or {}).get("fahrten_pausenzeit_min")
|
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")
|
_LOGGER.info("\"Fahrt beenden\" gedrueckt, aber es laeuft keine Fahrt")
|
||||||
return False
|
return False
|
||||||
start_ts = k.fahrt_start_ts
|
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(
|
_LOGGER.info(
|
||||||
"Fahrt seit %s von Hand beendet, vorlaeufiges Ende %s",
|
"Fahrt seit %s von Hand beendet, vorlaeufiges Ende %s",
|
||||||
start_ts.isoformat(), ende.isoformat(),
|
start_ts.isoformat(), ende.isoformat(),
|
||||||
@@ -839,7 +768,7 @@ async def nach_neustart_fortsetzen(k: Koordinator) -> None:
|
|||||||
if k.fahrt_start_ts is None:
|
if k.fahrt_start_ts is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
sensor = fahrtsignal(k.zuordnung.werte)
|
sensor = k.zuordnung.werte.ZUENDUNG_SENSOR
|
||||||
if zustand_oder_none(k.hass, sensor) == "on":
|
if zustand_oder_none(k.hass, sensor) == "on":
|
||||||
return # fährt noch - der Beobachter übernimmt wie sonst auch
|
return # fährt noch - der Beobachter übernimmt wie sonst auch
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
WARUM ÜBERHAUPT
|
WARUM ÜBERHAUPT
|
||||||
---------------
|
---------------
|
||||||
Zwei Zahlen dieser Integration gehören eigentlich dem Gerät und stehen bei uns
|
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)
|
nur als Konstante bzw. Einstellung: die Wartezeit bis zum Fahrtende
|
||||||
und die Wartezeit bis zum Fahrtende. Ändert jemand die Gegenstücke am Dongle,
|
(`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
|
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
|
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
|
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",
|
"nummer": "11804",
|
||||||
"titel": "Trip: Ignition OFF Timeout",
|
"titel": "Trip: Ignition OFF Timeout",
|
||||||
"einheit": "s",
|
"einheit": "s",
|
||||||
"unser": "NACHLAUF_S",
|
"unser": None,
|
||||||
},
|
},
|
||||||
"trip_scenario.start_speed": {
|
"trip_scenario.start_speed": {
|
||||||
"nummer": "11803",
|
"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,
|
finAutomatisch: p.fahrzeug.fin_automatisch !== false,
|
||||||
details: p.fahrzeug.details,
|
details: p.fahrzeug.details,
|
||||||
erstzulassung: p.fahrzeug.erstzulassung,
|
erstzulassung: p.fahrzeug.erstzulassung,
|
||||||
hu: p.fahrzeug.hauptuntersuchung_faellig,
|
|
||||||
odo: status.km ?? 0,
|
odo: status.km ?? 0,
|
||||||
odoBekannt: status.km != null,
|
odoBekannt: status.km != null,
|
||||||
tankPct: status.tankprozent ?? 0,
|
tankPct: status.tankprozent ?? 0,
|
||||||
@@ -287,7 +286,6 @@ function configCarZuProfil(vorherigesProfil) {
|
|||||||
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
|
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
|
||||||
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
|
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
|
||||||
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
|
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
|
||||||
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
|
|
||||||
p.fahrzeug.fin = CAR.fin;
|
p.fahrzeug.fin = CAR.fin;
|
||||||
p.fahrzeug.fin_automatisch = CAR.finAutomatisch;
|
p.fahrzeug.fin_automatisch = CAR.finAutomatisch;
|
||||||
p.fahrzeug.erstzulassung = CAR.erstzulassung;
|
p.fahrzeug.erstzulassung = CAR.erstzulassung;
|
||||||
@@ -2362,7 +2360,7 @@ function vHome() {
|
|||||||
const datum = t.kmDatum || t.datum;
|
const datum = t.kmDatum || t.datum;
|
||||||
const hauptwert = restKm !== null
|
const hauptwert = restKm !== null
|
||||||
? `${de(restKm)}<span class="unit">km</span>`
|
? `${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
|
// Ohne Restkilometer steht das Datum selbst im Zahlenfeld - auf Nutzerwunsch
|
||||||
// jetzt in derselben Groesse wie die km-Zahl (vorher 30px, damit "15.04.2027"
|
// 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
|
// 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 fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
|
||||||
const hatMeldung = fm.km != null || fmDatum;
|
const hatMeldung = fm.km != null || fmDatum;
|
||||||
const iv = intervalle(t);
|
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">
|
if (!iv && !hatMeldung) return `<div class="termingruppe">
|
||||||
<div class="tg-kopf">${kopf}</div>
|
<div class="tg-kopf">${kopf}</div>
|
||||||
<div class="tg-wert leer">kein Eintrag im Wartungsplan</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)];
|
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
|
1. der letzte Servicebuch-Eintrag plus **24** Monate.
|
||||||
Pruefbericht und ist damit die belastbarste Angabe - bis zum
|
2. die Erstzulassung plus **36** Monate.
|
||||||
04.09.2026 hat das Panel sie gar nicht benutzt, obwohl sie
|
|
||||||
gespeichert war (Befund des Eigentuemers).
|
Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines
|
||||||
2. der letzte Servicebuch-Eintrag plus 24 Monate.
|
Neuwagens ist nach drei Jahren faellig, jede weitere nach zwei.
|
||||||
3. die Erstzulassung plus 24 Monate.
|
|
||||||
|
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) {
|
function hauptuntersuchungsTermin(bauen) {
|
||||||
const gesetzt = datumAusText(CAR.hu);
|
const eintrag = letzteHauptuntersuchung();
|
||||||
if (gesetzt) {
|
if (eintrag) {
|
||||||
return { art: "Hauptuntersuchung", km: null, datum: gesetzt.zeit, kmDatum: null,
|
return bauen("Hauptuntersuchung", eintrag, null, HU_MONATE_FOLGE);
|
||||||
zuerst: "zeit", basis: null, ziel: gesetzt.zeit, genau: gesetzt.genau };
|
|
||||||
}
|
}
|
||||||
const erst = datumAusText(CAR.erstzulassung);
|
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);
|
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() {
|
function termine() {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def _meldezeit(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
|
|||||||
|
|
||||||
def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
|
def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
|
||||||
register = er.async_get(hass)
|
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:
|
if not eid:
|
||||||
continue
|
continue
|
||||||
eintrag = register.async_get(eid)
|
eintrag = register.async_get(eid)
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
|
|||||||
from .verlauf import (
|
from .verlauf import (
|
||||||
MINDESTDAUER_S,
|
MINDESTDAUER_S,
|
||||||
durchschnitt_kmh,
|
durchschnitt_kmh,
|
||||||
fahrtsignal,
|
|
||||||
ist_gefahren,
|
ist_gefahren,
|
||||||
strecke_waehlen,
|
strecke_waehlen,
|
||||||
zaehlerstrecke,
|
zaehlerstrecke,
|
||||||
@@ -499,7 +498,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
|
|||||||
# Rückblick muss dieselbe Uhr benutzen wie der Livepfad.
|
# Rückblick muss dieselbe Uhr benutzen wie der Livepfad.
|
||||||
mz = werte.MELDEZEIT_SENSOR
|
mz = werte.MELDEZEIT_SENSOR
|
||||||
verlaeufe = {
|
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),
|
"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),
|
"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),
|
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis, mz),
|
||||||
|
|||||||
@@ -297,13 +297,10 @@ class Koordinator:
|
|||||||
self._beobachter.clear()
|
self._beobachter.clear()
|
||||||
|
|
||||||
werte = self.zuordnung.werte
|
werte = self.zuordnung.werte
|
||||||
# Fahrten löst der Trip-Status aus (oder die Zündung, wenn er fehlt).
|
# Die Zuendung loest die Fahrten aus - seit dem 05.09.2026 als
|
||||||
self._beobachten(verlauf.fahrtsignal(werte), self._fahrtsignal_geaendert)
|
# einzige. Vorher konnte ein zugeordneter Trip-Sensor sie
|
||||||
# Die Zündung selbst nur noch für die Anzeige "fährt/steht" - sofort
|
# verdraengen; siehe den Kommentar an _fahrtsignal_geaendert().
|
||||||
# neu veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu
|
self._beobachten(werte.ZUENDUNG_SENSOR, self._fahrtsignal_geaendert)
|
||||||
# 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)
|
|
||||||
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
|
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
|
||||||
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
|
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
|
||||||
self._beobachten(werte.TANK_LITER_SENSOR, self._tankvolumen_geaendert)
|
self._beobachten(werte.TANK_LITER_SENSOR, self._tankvolumen_geaendert)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "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",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
|
|||||||
@@ -203,19 +203,6 @@ async def verlauf_lesen(
|
|||||||
return []
|
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:
|
def als_kilometerstand(wert: object) -> float | None:
|
||||||
"""Ein eingegebener Kilometerstand, oder None wenn er unbekannt ist.
|
"""Ein eingegebener Kilometerstand, oder None wenn er unbekannt ist.
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ der Reihenfolge vorheriger Aufrufe.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from . import einstellungen
|
from . import einstellungen
|
||||||
from .ablage import Ablage
|
from .ablage import Ablage
|
||||||
from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung
|
from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _ist_leer(wert: object) -> bool:
|
def _ist_leer(wert: object) -> bool:
|
||||||
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
|
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
|
||||||
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...], und
|
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...], und
|
||||||
@@ -47,8 +52,24 @@ class Zuordnung:
|
|||||||
|
|
||||||
async def anwenden(self) -> None:
|
async def anwenden(self) -> None:
|
||||||
"""Setzt für jedes bekannte Feld den wirksamen Wert - den Override,
|
"""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()
|
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:
|
for key in SCHLUESSEL:
|
||||||
wert = overrides.get(key)
|
wert = overrides.get(key)
|
||||||
standard = STANDARDWERTE[key]
|
standard = STANDARDWERTE[key]
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ LEBENSZEICHEN = datetime.datetime(2026, 9, 3, 8, 20, 0, tzinfo=datetime.UTC)
|
|||||||
|
|
||||||
|
|
||||||
class FakeWerte:
|
class FakeWerte:
|
||||||
TRIP_SENSOR = ""
|
|
||||||
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
||||||
MELDEZEIT_SENSOR = ""
|
MELDEZEIT_SENSOR = ""
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ T_WECHSEL = datetime.datetime(2026, 9, 2, 21, 22, 12, tzinfo=datetime.UTC)
|
|||||||
|
|
||||||
|
|
||||||
class FakeWerte:
|
class FakeWerte:
|
||||||
TRIP_SENSOR = "binary_sensor.trip"
|
|
||||||
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
||||||
|
|
||||||
|
|
||||||
@@ -50,10 +49,9 @@ class FakeKoordinator:
|
|||||||
self.fahrt_sperre = asyncio.Lock()
|
self.fahrt_sperre = asyncio.Lock()
|
||||||
self.beendet = []
|
self.beendet = []
|
||||||
self.veroeffentlicht = 0
|
self.veroeffentlicht = 0
|
||||||
# Dieser Test beschreibt die Welt MIT Trip-Signal: dort bringt das
|
# Gegenstand ist hier die SPERRE, nicht die Wartezeit - die hat
|
||||||
# Geraet seine Pausentoleranz mit, _pausenzeit_s() gibt 0 zurueck und
|
# test_zuendungspause.py. Deshalb ist die Pausenzeit unten auf 0
|
||||||
# es wird sofort geschlossen. Gegenstand ist hier die Sperre, nicht
|
# gesetzt, dann schliesst _signalwechsel() sofort.
|
||||||
# die Wartezeit - die hat test_zuendungspause.py.
|
|
||||||
self.zuordnung = FakeZuordnung()
|
self.zuordnung = FakeZuordnung()
|
||||||
|
|
||||||
# Seit die Pausenregel in Geraetezeit rechnet, liest _signalwechsel() das
|
# Seit die Pausenregel in Geraetezeit rechnet, liest _signalwechsel() das
|
||||||
@@ -72,7 +70,10 @@ class FakeKoordinator:
|
|||||||
|
|
||||||
class SignalwechselTest(unittest.IsolatedAsyncioTestCase):
|
class SignalwechselTest(unittest.IsolatedAsyncioTestCase):
|
||||||
async def asyncSetUp(self):
|
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):
|
async def geraetezeit(k, ereigniszeit, standard):
|
||||||
# Der eigentliche Punkt: dieser Schritt wartet wirklich (in echt
|
# Der eigentliche Punkt: dieser Schritt wartet wirklich (in echt
|
||||||
@@ -88,12 +89,12 @@ class SignalwechselTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
k.beendet.append((start_ts, ende_ts))
|
k.beendet.append((start_ts, ende_ts))
|
||||||
await k.fahrt_start_setzen(None)
|
await k.fahrt_start_setzen(None)
|
||||||
|
|
||||||
f._geraetezeit, f._echtes_ende, f.fahrt_beenden = (
|
f._geraetezeit, f._echtes_ende, f.fahrt_beenden, f._pausenzeit_s = (
|
||||||
geraetezeit, echtes_ende, beenden,
|
geraetezeit, echtes_ende, beenden, pausenzeit,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def asyncTearDown(self):
|
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):
|
async def test_off_und_on_in_derselben_sekunde(self):
|
||||||
"""Der genaue Fall vom 02.09.2026: die neue Fahrt darf nicht verloren
|
"""Der genaue Fall vom 02.09.2026: die neue Fahrt darf nicht verloren
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
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
|
seinem "Ignition OFF Timeout" von 900 s - und der Schlaf-Timeout des Geraets
|
||||||
@@ -46,8 +46,7 @@ WARTEN = 0.15
|
|||||||
|
|
||||||
|
|
||||||
class FakeWerte:
|
class FakeWerte:
|
||||||
def __init__(self, trip=""):
|
def __init__(self):
|
||||||
self.TRIP_SENSOR = trip
|
|
||||||
self.ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
self.ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
||||||
# Ohne Meldezeit-Sensor keine Geraetezeit und keine Klemme - die Tests
|
# Ohne Meldezeit-Sensor keine Geraetezeit und keine Klemme - die Tests
|
||||||
# pruefen die Nachlaufrechnung, nicht die Zuordnung.
|
# pruefen die Nachlaufrechnung, nicht die Zuordnung.
|
||||||
@@ -55,8 +54,8 @@ class FakeWerte:
|
|||||||
|
|
||||||
|
|
||||||
class FakeZuordnung:
|
class FakeZuordnung:
|
||||||
def __init__(self, trip=""):
|
def __init__(self):
|
||||||
self.werte = FakeWerte(trip)
|
self.werte = FakeWerte()
|
||||||
|
|
||||||
|
|
||||||
class FakeAblage:
|
class FakeAblage:
|
||||||
@@ -72,10 +71,10 @@ class FakeAblage:
|
|||||||
|
|
||||||
|
|
||||||
class FakeKoordinator:
|
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_start_ts = start_ts
|
||||||
self.fahrt_sperre = asyncio.Lock()
|
self.fahrt_sperre = asyncio.Lock()
|
||||||
self.zuordnung = FakeZuordnung(trip)
|
self.zuordnung = FakeZuordnung()
|
||||||
self.ablage = FakeAblage(minuten, fahrten)
|
self.ablage = FakeAblage(minuten, fahrten)
|
||||||
self.beendet = []
|
self.beendet = []
|
||||||
self._aufgabe = None
|
self._aufgabe = None
|
||||||
@@ -111,14 +110,12 @@ class FakeKoordinator:
|
|||||||
class Basis(unittest.IsolatedAsyncioTestCase):
|
class Basis(unittest.IsolatedAsyncioTestCase):
|
||||||
def _pause_kurz(self):
|
def _pause_kurz(self):
|
||||||
async def pausenzeit(k):
|
async def pausenzeit(k):
|
||||||
return 0 if k.zuordnung.werte.TRIP_SENSOR else PAUSE_S
|
return PAUSE_S
|
||||||
|
|
||||||
return pausenzeit
|
return pausenzeit
|
||||||
|
|
||||||
async def asyncSetUp(self):
|
async def asyncSetUp(self):
|
||||||
self._echt = (
|
self._echt = (f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s)
|
||||||
f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def geraetezeit(k, ereigniszeit, standard):
|
async def geraetezeit(k, ereigniszeit, standard):
|
||||||
return ereigniszeit or standard
|
return ereigniszeit or standard
|
||||||
@@ -127,11 +124,6 @@ class Basis(unittest.IsolatedAsyncioTestCase):
|
|||||||
k.beendet.append((start_ts, ende_ts))
|
k.beendet.append((start_ts, ende_ts))
|
||||||
await k.fahrt_start_setzen(None)
|
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):
|
async def nach_pause(k, start_ts, ende_ts, pause_s):
|
||||||
# Wie das Original, nur mit kurzer Wanduhrzeit - die echte
|
# Wie das Original, nur mit kurzer Wanduhrzeit - die echte
|
||||||
# Pausenzeit steckt weiterhin in der Entscheidung, nicht im Warten.
|
# Pausenzeit steckt weiterhin in der Entscheidung, nicht im Warten.
|
||||||
@@ -141,14 +133,11 @@ class Basis(unittest.IsolatedAsyncioTestCase):
|
|||||||
f._geraetezeit = geraetezeit
|
f._geraetezeit = geraetezeit
|
||||||
f.fahrt_beenden = beenden
|
f.fahrt_beenden = beenden
|
||||||
f._pausenzeit_s = self._pause_kurz()
|
f._pausenzeit_s = self._pause_kurz()
|
||||||
f._nachlauf_gegenpruefen = gegenpruefen
|
|
||||||
self._echt_nach_pause = f._nach_pause_beenden
|
self._echt_nach_pause = f._nach_pause_beenden
|
||||||
f._nach_pause_beenden = nach_pause
|
f._nach_pause_beenden = nach_pause
|
||||||
|
|
||||||
async def asyncTearDown(self):
|
async def asyncTearDown(self):
|
||||||
(
|
(f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s) = self._echt
|
||||||
f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen,
|
|
||||||
) = self._echt
|
|
||||||
f._nach_pause_beenden = self._echt_nach_pause
|
f._nach_pause_beenden = self._echt_nach_pause
|
||||||
|
|
||||||
|
|
||||||
@@ -187,11 +176,6 @@ class Wartezeit(Basis):
|
|||||||
self.assertEqual(k.beendet, [])
|
self.assertEqual(k.beendet, [])
|
||||||
self.assertEqual(k.fahrt_start_ts, T0)
|
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):
|
class Nachlauf(Basis):
|
||||||
@@ -202,13 +186,6 @@ class Nachlauf(Basis):
|
|||||||
_, ende = k.beendet[0]
|
_, ende = k.beendet[0]
|
||||||
self.assertEqual(ende, AUS - datetime.timedelta(seconds=180))
|
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):
|
async def test_zu_kurz_wird_auf_den_beginn_geklemmt(self):
|
||||||
k = FakeKoordinator(start_ts=AUS - datetime.timedelta(seconds=60))
|
k = FakeKoordinator(start_ts=AUS - datetime.timedelta(seconds=60))
|
||||||
@@ -412,9 +389,6 @@ class Einstellung(unittest.IsolatedAsyncioTestCase):
|
|||||||
k = FakeKoordinator(minuten=25)
|
k = FakeKoordinator(minuten=25)
|
||||||
self.assertEqual(await f._pausenzeit_s(k), 25 * 60)
|
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):
|
async def test_unsinn_faellt_auf_den_standard_zurueck(self):
|
||||||
k = FakeKoordinator()
|
k = FakeKoordinator()
|
||||||
|
|||||||
Reference in New Issue
Block a user