Hauptuntersuchung: Ableitung aus Erstzulassung und Wartungsplan, drittes Feld entfernt

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

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

Dabei mitbehoben:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 01:56:11 +02:00
parent 5621b818cc
commit 863284e540
25 changed files with 505 additions and 243 deletions
@@ -91,19 +91,6 @@ class Sensorzuordnung:
# Verbraucher.
GNSS_TEILSTRECKE_SENSOR: str = ""
# Das Trip-Signal des Geräts: es beginnt erst, wenn Zündung UND Bewegung
# UND "Start Speed" zusammenkommen, und endet erst nach dem
# "Ignition OFF Timeout" des Geräts. Damit bringt es die Pausentoleranz
# selbst mit, statt dass wir einen zweiten Zeitgeber bauen.
#
# Bewusst getrennt von ZUENDUNG_SENSOR: die rohe Zündung prellt (am
# 29.08.2026 drei Wechsel in 30 ms) und stand am 30./31.08. dreizehn
# Stunden am Stück auf "an", ohne dass gefahren wurde. Als Auslöser für
# Fahrten taugt sie nicht, als Anzeige "Zündung an" sehr wohl.
#
# Leer: dann übernimmt ZUENDUNG_SENSOR die Fahrterkennung wie früher -
# bestehende Installationen laufen ohne Zutun weiter.
TRIP_SENSOR: str = ""
# Der Zeitstempel, den das Gerät dem Datensatz selbst mitgegeben hat
# (Unix-Sekunden, UTC). Optional, aber der einzige Weg zu einem
@@ -226,13 +213,8 @@ FELDER: list[dict] = [
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km"], "liste": False, "pflicht": False,
"beispiel": "segment_mileage",
"stichworte": ["segment", "teilstrecke", "abschnitt", "mileage"]},
{"key": "TRIP_SENSOR", "label": "Trip-Status", "gruppe": "fahrterkennung",
"hinweis": "on = Fahrt läuft. Das gefilterte Fahrtsignal des Geräts - erkennt Fahrtbeginn und -ende. Ohne Zuordnung übernimmt die Zündung diese Aufgabe.",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"beispiel": "trip_status_true_if_trip_started_false_if_stopped",
"stichworte": ["trip", "fahrt", "trip status", "journey"]},
{"key": "ZUENDUNG_SENSOR", "label": "Zündung", "gruppe": "fahrterkennung",
"hinweis": "on = Zündung an. Für die Anzeige \"fährt/steht\"; ohne zugeordneten Trip-Status erkennt sie zusätzlich Fahrtbeginn und -ende.",
"hinweis": "on = Zündung an. Für die Anzeige \"fährt/steht\"; sie erkennt zugleich Fahrtbeginn und -ende.",
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
"beispiel": "engine_ignition_or_acc_status",
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
@@ -29,7 +29,6 @@ from .ablage import neue_id
from .veroeffentlichung import zustand_oder_none
from .verlauf import (
MINDESTDAUER_S,
fahrtsignal,
geraetezeit,
geraetezeit_plausibel,
verlauf_lesen,
@@ -52,22 +51,6 @@ _LOGGER = logging.getLogger(__name__)
# Einstellung fahrten_pausenzeit_min in beiden Oberflächen wieder anbieten.
# Der Nachlauf des Geraets zwischen Zuendung aus und Trip-Ende.
#
# Das Trip-Signal des FMM003 endet nicht mit der Zuendung, sondern erst nach
# seinem "Ignition OFF Timeout" (Trip \ Odometer). Es ist damit als Ausloeser
# ideal - es prellt nicht und bringt die Pausentoleranz mit -, liefert aber ein
# Fahrtende, das systematisch um genau diese Spanne zu spaet liegt.
#
# Der Wert ist die Konfiguration des Geraets, nicht unsere Wahl: er steht dort
# auf 900 s. Aendert er sich, gehoert diese Zahl mitgeaendert - sie laesst sich
# nicht aus den Daten ableiten (siehe _nachlauf_gegenpruefen unten, die genau
# das versucht und meldet, wenn es nicht mehr zusammenpasst).
#
# Bewusst KEINE Einstellung in der Oberflaeche: die Zahl gehoert zum Geraet,
# nicht zum Fahrzeug oder zum Nutzer - dieselbe Linie wie bei UNPLAUSIBLE_KMH
# und MIN_STRECKE_VERBRAUCH_KM in verlauf.py.
NACHLAUF_S = 900
# Ab welcher Abweichung zwischen gerechnetem und beobachtetem Ende gewarnt
# wird. Grosszuegig, weil die Zuendung nur so genau bekannt ist, wie das Geraet
@@ -83,19 +66,20 @@ NACHLAUF_TOLERANZ_S = 120
# 02.09.2026 kippte die Zuendung je einmal in den ersten 25 Sekunden fuer unter
# eine Sekunde weg und kam sofort zurueck.
#
# Der gemeldete Zeitpunkt liegt damit systematisch zu spaet, und zwar in BEIDEN
# Wegen: auch das Trip-Ende des Geraets haengt an der verzoegerten Zuendung.
# Nachgemessen am 02.09.2026: Zuendung aus 21:43:15, Trip aus 21:58:16 - genau
# NACHLAUF_S spaeter. Wer das Trip-Signal als Ausloeser nimmt, muss deshalb
# beide Spannen abziehen.
# Der gemeldete Zeitpunkt liegt damit systematisch zu spaet und wird abgezogen.
#
# (Bis zum 05.09.2026 gab es einen zweiten Weg ueber das Trip-Signal des
# Geraets, dessen Ende an derselben verzoegerten Zuendung hing und deshalb noch
# einmal 900 s spaeter kam. Das Trip-Szenario ist am Geraet abgeschaltet und
# der Sensor aus der App entfernt - siehe AGENTS.md.)
#
# Der Wert stand bis zum 31.08.2026 auf 0 und seit dem 01.09.2026 09:38 auf
# 180 s (aus den Geraetekonfigurationen rekonstruiert). Fahrten aus der Zeit
# dazwischen enden 180 s zu spaet - korrigiert wird ab jetzt, rueckwirkend nur
# von Hand.
#
# Wie NACHLAUF_S die Konfiguration des Geraets und bewusst keine Einstellung in
# der Oberflaeche.
# Die Konfiguration des Geraets (Parameter 400) und bewusst keine Einstellung
# in der Oberflaeche.
ZUENDUNG_NACHLAUF_S = 180
# Wie lange nach einem vorlaeufigen Ende ein spaeter eintreffendes Zuendungs-Aus
@@ -216,7 +200,7 @@ async def _echtes_ende(
) -> datetime.datetime:
"""Das Fahrtende ohne den Nachlauf des Geraets.
Das Trip-Signal endet NACHLAUF_S nach der Zuendung; abgezogen ergibt das den
Die Zuendung wird vom Geraet verzoegert gemeldet; abgezogen ergibt das den
Zeitpunkt, zu dem wirklich abgestellt wurde. Die Rechnung stammt vollstaendig
aus der Logik des Geraets und ueberlebt deshalb auch ein Funkloch: der
Datensatz kommt spaeter, traegt aber seine eigene Zeit (siehe geraetezeit()).
@@ -227,16 +211,11 @@ async def _echtes_ende(
anschliessend. Ohne diese Klemme entstuende aus dreissig Sekunden Zuendung
eine Viertelstunde Fahrt.
ZWEI Nachlaeufe, nicht einer. Das Zuendungselement meldet selbst schon
verzoegert (ZUENDUNG_NACHLAUF_S), und das Trip-Ende haengt an dieser bereits
verzoegerten Zuendung - wer ueber das Trip-Signal kommt, muss deshalb beide
Spannen abziehen, wer ueber die Zuendung kommt, nur die eine. Bis zum
03.09.2026 wurde nur NACHLAUF_S abgezogen; seit der Ignition OFF Delay am
Das Zuendungselement meldet selbst schon verzoegert
(ZUENDUNG_NACHLAUF_S); genau diese Spanne wird abgezogen. Bis zum
03.09.2026 geschah das nicht, und seit der Ignition OFF Delay am
01.09.2026 auf 180 s steht, endeten die Fahrten dadurch 180 s zu spaet."""
nachlauf = ZUENDUNG_NACHLAUF_S
ueber_trip = bool(k.zuordnung.werte.TRIP_SENSOR)
if ueber_trip:
nachlauf += NACHLAUF_S
if not nachlauf:
return signal_ende
gerechnet = signal_ende - datetime.timedelta(seconds=nachlauf)
@@ -247,8 +226,6 @@ async def _echtes_ende(
int((signal_ende - start_ts).total_seconds()), nachlauf,
)
return start_ts
if ueber_trip:
await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet)
# Nie hinter dem letzten Lebenszeichen des Geraets.
#
@@ -273,49 +250,6 @@ async def _echtes_ende(
return gerechnet
async def _nachlauf_gegenpruefen(
k: Koordinator,
start_ts: datetime.datetime,
signal_ende: datetime.datetime,
gerechnet: datetime.datetime,
) -> None:
"""Vergleicht das gerechnete Ende mit dem beobachteten Zuendungswechsel und
meldet, wenn beide nicht zusammenpassen. Aendert nichts - die Rechnung
bleibt massgeblich.
Warum nur eine Meldung und keine Korrektur: wie genau der Zeitpunkt "Zuendung
aus" bekannt ist, haengt an der I/O-Einstellung des Geraets. Steht der
Operand des Zuendungselements auf "Monitoring", erzeugt ein Wechsel keinen
eigenen Datensatz, sondern faehrt nur im naechsten mit - dann ist der
beobachtete Zeitpunkt zu spaet, oft genau der des Trip-Endes. Auf "On Change"
waere er exakt. Solange das nicht feststeht, waere ein Umschalten auf den
beobachteten Wert eine Verschlechterung; eine Abweichung zu protokollieren
ist es nie."""
sensor = k.zuordnung.werte.ZUENDUNG_SENSOR
if not sensor or sensor == fahrtsignal(k.zuordnung.werte):
return
punkte = await verlauf_lesen(
k.hass, sensor, start_ts, signal_ende, k.zuordnung.werte.MELDEZEIT_SENSOR
)
aus = [ts for ts, wert in punkte if wert == "off"]
if not aus:
return
# Der beobachtete Wechsel traegt den Nachlauf des Zuendungselements noch in
# sich, das gerechnete Ende nicht mehr - erst ohne ihn meinen beide Zahlen
# denselben Zeitpunkt. Ohne diese Zeile meldete die Pruefung seit dem
# 01.09.2026 bei JEDER Fahrt eine Abweichung von 180 s.
beobachtet = aus[-1] - datetime.timedelta(seconds=ZUENDUNG_NACHLAUF_S)
abweichung = abs((beobachtet - gerechnet).total_seconds())
if abweichung > NACHLAUF_TOLERANZ_S:
_LOGGER.warning(
"Fahrtende gerechnet auf %s (Trip-Ende minus %s s), die Zuendung ging "
"laut Aufzeichnung aber um %s aus - %.0f s Abweichung. Entweder stimmt "
"NACHLAUF_S nicht mehr mit dem Geraet ueberein, oder das "
"Zuendungselement schreibt keine eigenen Datensaetze.",
gerechnet.isoformat(), NACHLAUF_S, aus[-1].isoformat(), abweichung,
)
async def _geraetezeit(
k: Koordinator,
ereigniszeit: datetime.datetime | None,
@@ -352,7 +286,7 @@ async def zuendung_geaendert(
#
# Ohne diese Prüfung las der Beobachter das als "Zündung ging gerade an"
# und legte eine Fahrt an, deren Beginn schlicht der Zeitpunkt des
# Neustarts war. Steht das Trip-Signal ohnehin auf "an", entstand so bei
# Neustarts war. Stand die Zuendung ohnehin auf "an", entstand so bei
# JEDEM Neustart eine Phantomfahrt - daher der Eintrag vom 30.08.2026,
# 19:43 bis 08:37 des Folgetags, 12,9 Stunden, 0 km.
#
@@ -459,11 +393,8 @@ async def _signalwechsel(
return
# Ein neues "aus" ersetzt ein aelteres, das noch wartet.
k.warte_ende_ab_abbrechen()
# Ob hier noch gewartet wird, entscheidet _pausenzeit_s(): liegt ein
# Trip-Signal an, bringt das Geraet seine Pausentoleranz selbst mit,
# und eine zweite obendrauf haette zwei getrennte Fahrten verschmolzen.
# Loest die Zuendung aus, gibt es diese Toleranz nicht - dann ist die
# Wartezeit unsere.
# Wie lange gewartet wird, entscheidet _pausenzeit_s() - die
# Wartezeit ist unsere, das Geraet bringt keine mit.
signal_ende = await _geraetezeit(k, ereigniszeit, jetzt)
# Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die
# Meldezeit aus einem älteren Datensatz stammt als der Fahrtbeginn -
@@ -474,10 +405,9 @@ async def _signalwechsel(
signal_ende.isoformat(), k.fahrt_start_ts.isoformat(),
)
signal_ende = jetzt
# Erst jetzt die Nachlaeufe des Geraets abziehen: signal_ende ist der
# Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung oder
# das noch spaetere Trip-Ende -, nicht der, zu dem das Fahrzeug
# abgestellt wurde.
# Erst jetzt den Nachlauf des Geraets abziehen: signal_ende ist der
# Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung -,
# nicht der, zu dem das Fahrzeug abgestellt wurde.
ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende)
pause_s = await _pausenzeit_s(k)
if pause_s:
@@ -568,14 +498,13 @@ async def _zuendung_kehrt_zurueck(
async def _pausenzeit_s(k: Koordinator) -> int:
"""Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden.
Null, solange ein Trip-Signal zugeordnet ist: dann wartet das Geraet
bereits (Ignition OFF Timeout), und beides zusammen haette aus einer
Viertelstunde eine halbe gemacht. Genau daran ist die Wartezeit am
31.08.2026 entfallen - sie kommt hier nur fuer den Weg ueber die Zuendung
zurueck.
So lange darf eine zurueckkehrende Zuendung noch dieselbe Fahrt sein -
Tanken, kurze Besorgung. Der Wert kommt aus dem Regler "Fahrt beenden",
derselbe, der auch den Schlaf-Timeout des Dongles stellt (flespi.py).
Gemessen wird er in GERAETEZEIT, nicht an der Wanduhr - siehe
_zuendung_kehrt_zurueck().
"""
if k.zuordnung.werte.TRIP_SENSOR:
return 0
try:
profil = await k.ablage.profil_lesen()
wert = (profil.get("einstellungen") or {}).get("fahrten_pausenzeit_min")
@@ -812,7 +741,7 @@ async def jetzt_beenden(k: Koordinator) -> bool:
_LOGGER.info("\"Fahrt beenden\" gedrueckt, aber es laeuft keine Fahrt")
return False
start_ts = k.fahrt_start_ts
ende = await _letztes_lebenszeichen(k, fahrtsignal(k.zuordnung.werte), start_ts)
ende = await _letztes_lebenszeichen(k, k.zuordnung.werte.ZUENDUNG_SENSOR, start_ts)
_LOGGER.info(
"Fahrt seit %s von Hand beendet, vorlaeufiges Ende %s",
start_ts.isoformat(), ende.isoformat(),
@@ -839,7 +768,7 @@ async def nach_neustart_fortsetzen(k: Koordinator) -> None:
if k.fahrt_start_ts is None:
return
sensor = fahrtsignal(k.zuordnung.werte)
sensor = k.zuordnung.werte.ZUENDUNG_SENSOR
if zustand_oder_none(k.hass, sensor) == "on":
return # fährt noch - der Beobachter übernimmt wie sonst auch
+3 -3
View File
@@ -3,8 +3,8 @@
WARUM ÜBERHAUPT
---------------
Zwei Zahlen dieser Integration gehören eigentlich dem Gerät und stehen bei uns
nur als Konstante bzw. Einstellung: `NACHLAUF_S` (Trip: Ignition OFF Timeout)
und die Wartezeit bis zum Fahrtende. Ändert jemand die Gegenstücke am Dongle,
nur als Konstante bzw. Einstellung: die Wartezeit bis zum Fahrtende
(`fahrterkennung.ZUENDUNG_NACHLAUF_S` und der Regler). Ändert jemand die Gegenstücke am Dongle,
rechnet die Fahrterkennung still weiter mit dem alten Wert - und niemand merkt
es. Genau das ist am 01.09.2026 passiert, als `400` von 0 auf 180 ging: seither
endeten alle Fahrten 180 s zu spät, gefunden erst zwei Tage später beim
@@ -133,7 +133,7 @@ INTERESSANT: dict[str, dict] = {
"nummer": "11804",
"titel": "Trip: Ignition OFF Timeout",
"einheit": "s",
"unser": "NACHLAUF_S",
"unser": None,
},
"trip_scenario.start_speed": {
"nummer": "11803",
@@ -1 +1 @@
{"version":"2026.9.4.26","sha256":"0e65b78a949052abdcb6765a9a08e1526e67bec911657073d35f7158f51ba0e4","bytes":324250,"gebaut":"2026-09-04T23:10:56Z"}
{"version":"2026.9.5.3","sha256":"6c9e0c3ef0429fd6ec6264af982494b9c699e8b3483fea145946d991805b0e47","bytes":324399,"gebaut":"2026-09-04T23:51:45Z"}
@@ -225,7 +225,6 @@ function profilZuCar(p, status) {
finAutomatisch: p.fahrzeug.fin_automatisch !== false,
details: p.fahrzeug.details,
erstzulassung: p.fahrzeug.erstzulassung,
hu: p.fahrzeug.hauptuntersuchung_faellig,
odo: status.km ?? 0,
odoBekannt: status.km != null,
tankPct: status.tankprozent ?? 0,
@@ -287,7 +286,6 @@ function configCarZuProfil(vorherigesProfil) {
p.fahrzeug.zusatz = CONFIG.fahrzeugzusatz;
p.fahrzeug.untertitel = CONFIG.fahrzeuguntertitel;
p.fahrzeug.kennzeichen = CONFIG.kennzeichen;
p.fahrzeug.hauptuntersuchung_faellig = CAR.hu;
p.fahrzeug.fin = CAR.fin;
p.fahrzeug.fin_automatisch = CAR.finAutomatisch;
p.fahrzeug.erstzulassung = CAR.erstzulassung;
@@ -2362,7 +2360,7 @@ function vHome() {
const datum = t.kmDatum || t.datum;
const hauptwert = restKm !== null
? `${de(restKm)}<span class="unit">km</span>`
: (datum ? dedat(datum) : "");
: (datum === t.datum ? datumText(t.datum, t.genau) : (datum ? dedat(datum) : ""));
// Ohne Restkilometer steht das Datum selbst im Zahlenfeld - auf Nutzerwunsch
// jetzt in derselben Groesse wie die km-Zahl (vorher 30px, damit "15.04.2027"
// auf 375px nicht umbricht - am laufenden Panel geprueft, dass es bei 40px
@@ -2924,6 +2922,17 @@ function vService() {
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
const hatMeldung = fm.km != null || fmDatum;
const iv = intervalle(t);
/* Ohne Wartungsplan-Eintrag und ohne Fahrzeugmeldung bleibt bei der
Hauptuntersuchung trotzdem ein Termin uebrig: der aus der
Erstzulassung abgeleitete (siehe hauptuntersuchungsTermin()). Bis zum
05.09.2026 hat diese Kachel ihn verschluckt und "kein Eintrag im
Wartungsplan" gezeigt, waehrend die Uebersicht denselben Termin
auswies - gemeldet vom Eigentuemer, zwei Bildschirme, zwei
Antworten. */
if (!iv && !hatMeldung && t.datum) return `<div class="termingruppe">
<div class="tg-kopf">${kopf}</div>
<div class="tg-wert">${datumText(t.datum, t.genau)}</div>
</div>`;
if (!iv && !hatMeldung) return `<div class="termingruppe">
<div class="tg-kopf">${kopf}</div>
<div class="tg-wert leer">kein Eintrag im Wartungsplan</div>
@@ -3122,27 +3131,56 @@ function gewaehlterTermin() {
return auswahl[Math.min(svAuswahl, auswahl.length - 1)];
}
/* Die Hauptuntersuchung hat drei moegliche Quellen, in dieser Reihenfolge:
/* Die Hauptuntersuchung hat ZWEI Quellen, in dieser Reihenfolge:
1. die im Profil EINGETRAGENE Faelligkeit (CAR.hu). Sie steht auf dem
Pruefbericht und ist damit die belastbarste Angabe - bis zum
04.09.2026 hat das Panel sie gar nicht benutzt, obwohl sie
gespeichert war (Befund des Eigentuemers).
2. der letzte Servicebuch-Eintrag plus 24 Monate.
3. die Erstzulassung plus 24 Monate.
1. der letzte Servicebuch-Eintrag plus **24** Monate.
2. die Erstzulassung plus **36** Monate.
Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines
Neuwagens ist nach drei Jahren faellig, jede weitere nach zwei.
Es gibt KEINE dritte Quelle mehr. Bis zum 05.09.2026 stand hier zuerst
ein Profilfeld `hauptuntersuchung_faellig`, das alles andere ueberstimmt
hat. Dieses Feld hatte in keiner der beiden Oberflaechen ein
Eingabefeld und im Backend kein Schema - es lag nur als toter Schluessel
in fahrzeugprofil.json und war von dort weder zu sehen noch zu aendern.
Gemeldet vom Eigentuemer am 05.09.2026: Wartungsplan leer, Erstzulassung
01.03.2025, angezeigt wurde trotzdem August 2026 - das war jener tote
Schluessel ("08/2026"). Das Feld ist samt Lese- und Schreibweg
entfernt; die Ableitung ist die einzige Quelle. */
const HU_MONATE_FOLGE = 24;
const HU_MONATE_ERSTE = 36;
/* Der JUENGSTE Hauptuntersuchungs-Eintrag des Wartungsplans.
Nicht ueber letzterEintrag(): das nimmt mit .find() den ersten Treffer in
Listenreihenfolge, waehrend die App nach Datum absteigend sortiert. Bei
mehreren Eintraegen haetten beide Seiten verschiedene Termine gezeigt,
ohne dass man es der Anzeige ansieht. */
function letzteHauptuntersuchung() {
let beste = null;
let besteZeit = null;
for (const e of CAR.service.buch) {
if (!e || typeof e.art !== "string" || !e.art.toLowerCase().includes("hauptunter")) continue;
const z = datumAusText(e.datum);
if (!z) continue;
if (besteZeit === null || z.zeit > besteZeit) { beste = e; besteZeit = z.zeit; }
}
return beste;
}
Zu 3.: das ging vorher NUR mit einer Erstzulassung im Format MM/JJJJ.
Ein vollstaendiges Datum (01.03.2025) - eine voellig richtige Eingabe -
liess den Termin still leer. datumAusText() nimmt jetzt beide Formen. */
function hauptuntersuchungsTermin(bauen) {
const gesetzt = datumAusText(CAR.hu);
if (gesetzt) {
return { art: "Hauptuntersuchung", km: null, datum: gesetzt.zeit, kmDatum: null,
zuerst: "zeit", basis: null, ziel: gesetzt.zeit, genau: gesetzt.genau };
const eintrag = letzteHauptuntersuchung();
if (eintrag) {
return bauen("Hauptuntersuchung", eintrag, null, HU_MONATE_FOLGE);
}
const erst = datumAusText(CAR.erstzulassung);
return bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
const termin = bauen("Hauptuntersuchung", null, null, HU_MONATE_ERSTE,
erst ? erst.zeit : null);
// Eine Erstzulassung als "03/2025" nennt keinen Tag. Dann nennt auch der
// abgeleitete Termin keinen - "01.03.2028" waere eine Erfindung.
if (erst && erst.genau === "monat") termin.genau = "monat";
return termin;
}
function termine() {
+1 -1
View File
@@ -78,7 +78,7 @@ def _meldezeit(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
register = er.async_get(hass)
for eid in (werte.MELDEZEIT_SENSOR, verlauf.fahrtsignal(werte), werte.ZUENDUNG_SENSOR):
for eid in (werte.MELDEZEIT_SENSOR, werte.ZUENDUNG_SENSOR):
if not eid:
continue
eintrag = register.async_get(eid)
@@ -48,7 +48,6 @@ from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
from .verlauf import (
MINDESTDAUER_S,
durchschnitt_kmh,
fahrtsignal,
ist_gefahren,
strecke_waehlen,
zaehlerstrecke,
@@ -499,7 +498,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
# Rückblick muss dieselbe Uhr benutzen wie der Livepfad.
mz = werte.MELDEZEIT_SENSOR
verlaeufe = {
"zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis, mz),
"zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis, mz),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis, mz),
"gnss_km": await verlauf_lesen(k.hass, werte.GNSS_KM_SENSOR, von, bis, mz),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis, mz),
@@ -297,13 +297,10 @@ class Koordinator:
self._beobachter.clear()
werte = self.zuordnung.werte
# Fahrten löst der Trip-Status aus (oder die Zündung, wenn er fehlt).
self._beobachten(verlauf.fahrtsignal(werte), self._fahrtsignal_geaendert)
# Die Zündung selbst nur noch für die Anzeige "fährt/steht" - sofort
# neu veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu
# warten. Entfällt, wenn sie ohnehin schon das Fahrtsignal ist.
if werte.ZUENDUNG_SENSOR != verlauf.fahrtsignal(werte):
self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_angezeigt)
# Die Zuendung loest die Fahrten aus - seit dem 05.09.2026 als
# einzige. Vorher konnte ein zugeordneter Trip-Sensor sie
# verdraengen; siehe den Kommentar an _fahrtsignal_geaendert().
self._beobachten(werte.ZUENDUNG_SENSOR, self._fahrtsignal_geaendert)
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
self._beobachten(werte.TANK_LITER_SENSOR, self._tankvolumen_geaendert)
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.4.26",
"version": "2026.9.5.3",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [
@@ -203,19 +203,6 @@ async def verlauf_lesen(
return []
def fahrtsignal(werte: object) -> str:
"""Die Entität, die Fahrtbeginn und -ende auslöst.
Der Trip-Status des Geräts, wenn er zugeordnet ist - sonst die Zündung,
wie vor der Trennung der beiden Rollen. Damit laufen bestehende
Installationen unverändert weiter, ohne dass jemand etwas zuordnen muss.
Eine gemeinsame Stelle, weil sonst Livepfad (fahrterkennung.py) und
Rückblick (historienimport.py) auseinanderlaufen könnten - dieselbe Regel
wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S."""
return getattr(werte, "TRIP_SENSOR", "") or getattr(werte, "ZUENDUNG_SENSOR", "")
def als_kilometerstand(wert: object) -> float | None:
"""Ein eingegebener Kilometerstand, oder None wenn er unbekannt ist.
+22 -1
View File
@@ -22,11 +22,16 @@ der Reihenfolge vorheriger Aufrufe.
from __future__ import annotations
import logging
from . import einstellungen
from .ablage import Ablage
from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung
_LOGGER = logging.getLogger(__name__)
def _ist_leer(wert: object) -> bool:
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...], und
@@ -47,8 +52,24 @@ class Zuordnung:
async def anwenden(self) -> None:
"""Setzt für jedes bekannte Feld den wirksamen Wert - den Override,
wenn einer hinterlegt ist, sonst den eingebauten Standardwert."""
wenn einer hinterlegt ist, sonst den eingebauten Standardwert.
Räumt dabei einmalig auf: Schlüssel, die der Katalog nicht mehr kennt,
werden aus entitaeten.json entfernt. `speichern()` filtert sie schon
beim Schreiben heraus, aber eine bestehende Datei behielt sie bis zum
nächsten Speichern - und ein Feld, das es nicht mehr gibt, soll auch
nicht mehr in den Daten stehen. Angefallen ist das mit dem Entfernen
von TRIP_SENSOR am 05.09.2026."""
overrides = await self._ablage.zuordnung_lesen()
verwaist = [k for k in overrides if k not in SCHLUESSEL]
if verwaist:
_LOGGER.info(
"Zuordnung: %s kennt der Katalog nicht mehr - entfernt",
", ".join(sorted(verwaist)),
)
await self._ablage.zuordnung_schreiben(
{k: v for k, v in overrides.items() if k in SCHLUESSEL}
)
for key in SCHLUESSEL:
wert = overrides.get(key)
standard = STANDARDWERTE[key]