${kopf}
kein Eintrag im Wartungsplan
@@ -3122,27 +3131,56 @@ function gewaehlterTermin() {
return auswahl[Math.min(svAuswahl, auswahl.length - 1)];
}
-/* Die Hauptuntersuchung hat drei moegliche Quellen, in dieser Reihenfolge:
+/* Die Hauptuntersuchung hat ZWEI Quellen, in dieser Reihenfolge:
- 1. die im Profil EINGETRAGENE Faelligkeit (CAR.hu). Sie steht auf dem
- Pruefbericht und ist damit die belastbarste Angabe - bis zum
- 04.09.2026 hat das Panel sie gar nicht benutzt, obwohl sie
- gespeichert war (Befund des Eigentuemers).
- 2. der letzte Servicebuch-Eintrag plus 24 Monate.
- 3. die Erstzulassung plus 24 Monate.
+ 1. der letzte Servicebuch-Eintrag plus **24** Monate.
+ 2. die Erstzulassung plus **36** Monate.
+
+ Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines
+ Neuwagens ist nach drei Jahren faellig, jede weitere nach zwei.
+
+ Es gibt KEINE dritte Quelle mehr. Bis zum 05.09.2026 stand hier zuerst
+ ein Profilfeld `hauptuntersuchung_faellig`, das alles andere ueberstimmt
+ hat. Dieses Feld hatte in keiner der beiden Oberflaechen ein
+ Eingabefeld und im Backend kein Schema - es lag nur als toter Schluessel
+ in fahrzeugprofil.json und war von dort weder zu sehen noch zu aendern.
+ Gemeldet vom Eigentuemer am 05.09.2026: Wartungsplan leer, Erstzulassung
+ 01.03.2025, angezeigt wurde trotzdem August 2026 - das war jener tote
+ Schluessel ("08/2026"). Das Feld ist samt Lese- und Schreibweg
+ entfernt; die Ableitung ist die einzige Quelle. */
+const HU_MONATE_FOLGE = 24;
+const HU_MONATE_ERSTE = 36;
+
+/* Der JUENGSTE Hauptuntersuchungs-Eintrag des Wartungsplans.
+
+ Nicht ueber letzterEintrag(): das nimmt mit .find() den ersten Treffer in
+ Listenreihenfolge, waehrend die App nach Datum absteigend sortiert. Bei
+ mehreren Eintraegen haetten beide Seiten verschiedene Termine gezeigt,
+ ohne dass man es der Anzeige ansieht. */
+function letzteHauptuntersuchung() {
+ let beste = null;
+ let besteZeit = null;
+ for (const e of CAR.service.buch) {
+ if (!e || typeof e.art !== "string" || !e.art.toLowerCase().includes("hauptunter")) continue;
+ const z = datumAusText(e.datum);
+ if (!z) continue;
+ if (besteZeit === null || z.zeit > besteZeit) { beste = e; besteZeit = z.zeit; }
+ }
+ return beste;
+}
- Zu 3.: das ging vorher NUR mit einer Erstzulassung im Format MM/JJJJ.
- Ein vollstaendiges Datum (01.03.2025) - eine voellig richtige Eingabe -
- liess den Termin still leer. datumAusText() nimmt jetzt beide Formen. */
function hauptuntersuchungsTermin(bauen) {
- const gesetzt = datumAusText(CAR.hu);
- if (gesetzt) {
- return { art: "Hauptuntersuchung", km: null, datum: gesetzt.zeit, kmDatum: null,
- zuerst: "zeit", basis: null, ziel: gesetzt.zeit, genau: gesetzt.genau };
+ const eintrag = letzteHauptuntersuchung();
+ if (eintrag) {
+ return bauen("Hauptuntersuchung", eintrag, null, HU_MONATE_FOLGE);
}
const erst = datumAusText(CAR.erstzulassung);
- return bauen("Hauptuntersuchung", letzterEintrag("hauptuntersuchung"), null, 24,
+ const termin = bauen("Hauptuntersuchung", null, null, HU_MONATE_ERSTE,
erst ? erst.zeit : null);
+ // Eine Erstzulassung als "03/2025" nennt keinen Tag. Dann nennt auch der
+ // abgeleitete Termin keinen - "01.03.2028" waere eine Erfindung.
+ if (erst && erst.genau === "monat") termin.genau = "monat";
+ return termin;
}
function termine() {
diff --git a/custom_components/audi_dashboard/geraet.py b/custom_components/audi_dashboard/geraet.py
index 2250072..699d307 100644
--- a/custom_components/audi_dashboard/geraet.py
+++ b/custom_components/audi_dashboard/geraet.py
@@ -78,7 +78,7 @@ def _meldezeit(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
def _geraet_id(hass: HomeAssistant, werte: Sensorzuordnung) -> str | None:
register = er.async_get(hass)
- for eid in (werte.MELDEZEIT_SENSOR, verlauf.fahrtsignal(werte), werte.ZUENDUNG_SENSOR):
+ for eid in (werte.MELDEZEIT_SENSOR, werte.ZUENDUNG_SENSOR):
if not eid:
continue
eintrag = register.async_get(eid)
diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py
index 09fd3d9..002aded 100644
--- a/custom_components/audi_dashboard/historienimport.py
+++ b/custom_components/audi_dashboard/historienimport.py
@@ -48,7 +48,6 @@ from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
from .verlauf import (
MINDESTDAUER_S,
durchschnitt_kmh,
- fahrtsignal,
ist_gefahren,
strecke_waehlen,
zaehlerstrecke,
@@ -499,7 +498,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
# Rückblick muss dieselbe Uhr benutzen wie der Livepfad.
mz = werte.MELDEZEIT_SENSOR
verlaeufe = {
- "zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis, mz),
+ "zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis, mz),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis, mz),
"gnss_km": await verlauf_lesen(k.hass, werte.GNSS_KM_SENSOR, von, bis, mz),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis, mz),
diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py
index cd12555..df2f73e 100644
--- a/custom_components/audi_dashboard/koordinator.py
+++ b/custom_components/audi_dashboard/koordinator.py
@@ -297,13 +297,10 @@ class Koordinator:
self._beobachter.clear()
werte = self.zuordnung.werte
- # Fahrten löst der Trip-Status aus (oder die Zündung, wenn er fehlt).
- self._beobachten(verlauf.fahrtsignal(werte), self._fahrtsignal_geaendert)
- # Die Zündung selbst nur noch für die Anzeige "fährt/steht" - sofort
- # neu veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu
- # warten. Entfällt, wenn sie ohnehin schon das Fahrtsignal ist.
- if werte.ZUENDUNG_SENSOR != verlauf.fahrtsignal(werte):
- self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_angezeigt)
+ # Die Zuendung loest die Fahrten aus - seit dem 05.09.2026 als
+ # einzige. Vorher konnte ein zugeordneter Trip-Sensor sie
+ # verdraengen; siehe den Kommentar an _fahrtsignal_geaendert().
+ self._beobachten(werte.ZUENDUNG_SENSOR, self._fahrtsignal_geaendert)
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
self._beobachten(werte.TANK_LITER_SENSOR, self._tankvolumen_geaendert)
diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json
index d344718..98cce5d 100644
--- a/custom_components/audi_dashboard/manifest.json
+++ b/custom_components/audi_dashboard/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
- "version": "2026.9.4.26",
+ "version": "2026.9.5.3",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [
diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py
index 374b624..726295d 100644
--- a/custom_components/audi_dashboard/verlauf.py
+++ b/custom_components/audi_dashboard/verlauf.py
@@ -203,19 +203,6 @@ async def verlauf_lesen(
return []
-def fahrtsignal(werte: object) -> str:
- """Die Entität, die Fahrtbeginn und -ende auslöst.
-
- Der Trip-Status des Geräts, wenn er zugeordnet ist - sonst die Zündung,
- wie vor der Trennung der beiden Rollen. Damit laufen bestehende
- Installationen unverändert weiter, ohne dass jemand etwas zuordnen muss.
-
- Eine gemeinsame Stelle, weil sonst Livepfad (fahrterkennung.py) und
- Rückblick (historienimport.py) auseinanderlaufen könnten - dieselbe Regel
- wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S."""
- return getattr(werte, "TRIP_SENSOR", "") or getattr(werte, "ZUENDUNG_SENSOR", "")
-
-
def als_kilometerstand(wert: object) -> float | None:
"""Ein eingegebener Kilometerstand, oder None wenn er unbekannt ist.
diff --git a/custom_components/audi_dashboard/zuordnung.py b/custom_components/audi_dashboard/zuordnung.py
index 57ea25d..5628352 100644
--- a/custom_components/audi_dashboard/zuordnung.py
+++ b/custom_components/audi_dashboard/zuordnung.py
@@ -22,11 +22,16 @@ der Reihenfolge vorheriger Aufrufe.
from __future__ import annotations
+import logging
+
from . import einstellungen
from .ablage import Ablage
from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung
+_LOGGER = logging.getLogger(__name__)
+
+
def _ist_leer(wert: object) -> bool:
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...], und
@@ -47,8 +52,24 @@ class Zuordnung:
async def anwenden(self) -> None:
"""Setzt für jedes bekannte Feld den wirksamen Wert - den Override,
- wenn einer hinterlegt ist, sonst den eingebauten Standardwert."""
+ wenn einer hinterlegt ist, sonst den eingebauten Standardwert.
+
+ Räumt dabei einmalig auf: Schlüssel, die der Katalog nicht mehr kennt,
+ werden aus entitaeten.json entfernt. `speichern()` filtert sie schon
+ beim Schreiben heraus, aber eine bestehende Datei behielt sie bis zum
+ nächsten Speichern - und ein Feld, das es nicht mehr gibt, soll auch
+ nicht mehr in den Daten stehen. Angefallen ist das mit dem Entfernen
+ von TRIP_SENSOR am 05.09.2026."""
overrides = await self._ablage.zuordnung_lesen()
+ verwaist = [k for k in overrides if k not in SCHLUESSEL]
+ if verwaist:
+ _LOGGER.info(
+ "Zuordnung: %s kennt der Katalog nicht mehr - entfernt",
+ ", ".join(sorted(verwaist)),
+ )
+ await self._ablage.zuordnung_schreiben(
+ {k: v for k, v in overrides.items() if k in SCHLUESSEL}
+ )
for key in SCHLUESSEL:
wert = overrides.get(key)
standard = STANDARDWERTE[key]
diff --git a/tests/fahrterkennung/test_fahrtende_knopf.py b/tests/fahrterkennung/test_fahrtende_knopf.py
index 3c738da..21cc07b 100644
--- a/tests/fahrterkennung/test_fahrtende_knopf.py
+++ b/tests/fahrterkennung/test_fahrtende_knopf.py
@@ -32,7 +32,6 @@ LEBENSZEICHEN = datetime.datetime(2026, 9, 3, 8, 20, 0, tzinfo=datetime.UTC)
class FakeWerte:
- TRIP_SENSOR = ""
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
MELDEZEIT_SENSOR = ""
diff --git a/tests/fahrterkennung/test_signalwechsel.py b/tests/fahrterkennung/test_signalwechsel.py
index eb92975..8b9781d 100644
--- a/tests/fahrterkennung/test_signalwechsel.py
+++ b/tests/fahrterkennung/test_signalwechsel.py
@@ -34,7 +34,6 @@ T_WECHSEL = datetime.datetime(2026, 9, 2, 21, 22, 12, tzinfo=datetime.UTC)
class FakeWerte:
- TRIP_SENSOR = "binary_sensor.trip"
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
@@ -50,10 +49,9 @@ class FakeKoordinator:
self.fahrt_sperre = asyncio.Lock()
self.beendet = []
self.veroeffentlicht = 0
- # Dieser Test beschreibt die Welt MIT Trip-Signal: dort bringt das
- # Geraet seine Pausentoleranz mit, _pausenzeit_s() gibt 0 zurueck und
- # es wird sofort geschlossen. Gegenstand ist hier die Sperre, nicht
- # die Wartezeit - die hat test_zuendungspause.py.
+ # Gegenstand ist hier die SPERRE, nicht die Wartezeit - die hat
+ # test_zuendungspause.py. Deshalb ist die Pausenzeit unten auf 0
+ # gesetzt, dann schliesst _signalwechsel() sofort.
self.zuordnung = FakeZuordnung()
# Seit die Pausenregel in Geraetezeit rechnet, liest _signalwechsel() das
@@ -72,7 +70,10 @@ class FakeKoordinator:
class SignalwechselTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
- self._echt = (f._geraetezeit, f._echtes_ende, f.fahrt_beenden)
+ self._echt = (f._geraetezeit, f._echtes_ende, f.fahrt_beenden, f._pausenzeit_s)
+
+ async def pausenzeit(k):
+ return 0
async def geraetezeit(k, ereigniszeit, standard):
# Der eigentliche Punkt: dieser Schritt wartet wirklich (in echt
@@ -88,12 +89,12 @@ class SignalwechselTest(unittest.IsolatedAsyncioTestCase):
k.beendet.append((start_ts, ende_ts))
await k.fahrt_start_setzen(None)
- f._geraetezeit, f._echtes_ende, f.fahrt_beenden = (
- geraetezeit, echtes_ende, beenden,
+ f._geraetezeit, f._echtes_ende, f.fahrt_beenden, f._pausenzeit_s = (
+ geraetezeit, echtes_ende, beenden, pausenzeit,
)
async def asyncTearDown(self):
- f._geraetezeit, f._echtes_ende, f.fahrt_beenden = self._echt
+ f._geraetezeit, f._echtes_ende, f.fahrt_beenden, f._pausenzeit_s = self._echt
async def test_off_und_on_in_derselben_sekunde(self):
"""Der genaue Fall vom 02.09.2026: die neue Fahrt darf nicht verloren
diff --git a/tests/fahrterkennung/test_zuendungspause.py b/tests/fahrterkennung/test_zuendungspause.py
index be4a100..f123c0c 100644
--- a/tests/fahrterkennung/test_zuendungspause.py
+++ b/tests/fahrterkennung/test_zuendungspause.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Die Wartezeit nach dem Zuendungs-Aus, und die beiden Nachlaeufe des Geraets.
+"""Die Wartezeit nach dem Zuendungs-Aus und der Nachlauf des Geraets.
Warum es das gibt (03.09.2026): das Trip-Signal des FMM003 endet erst nach
seinem "Ignition OFF Timeout" von 900 s - und der Schlaf-Timeout des Geraets
@@ -46,8 +46,7 @@ WARTEN = 0.15
class FakeWerte:
- def __init__(self, trip=""):
- self.TRIP_SENSOR = trip
+ def __init__(self):
self.ZUENDUNG_SENSOR = "binary_sensor.zuendung"
# Ohne Meldezeit-Sensor keine Geraetezeit und keine Klemme - die Tests
# pruefen die Nachlaufrechnung, nicht die Zuordnung.
@@ -55,8 +54,8 @@ class FakeWerte:
class FakeZuordnung:
- def __init__(self, trip=""):
- self.werte = FakeWerte(trip)
+ def __init__(self):
+ self.werte = FakeWerte()
class FakeAblage:
@@ -72,10 +71,10 @@ class FakeAblage:
class FakeKoordinator:
- def __init__(self, start_ts=T0, trip="", minuten=15, fahrten=None):
+ def __init__(self, start_ts=T0, minuten=15, fahrten=None):
self.fahrt_start_ts = start_ts
self.fahrt_sperre = asyncio.Lock()
- self.zuordnung = FakeZuordnung(trip)
+ self.zuordnung = FakeZuordnung()
self.ablage = FakeAblage(minuten, fahrten)
self.beendet = []
self._aufgabe = None
@@ -111,14 +110,12 @@ class FakeKoordinator:
class Basis(unittest.IsolatedAsyncioTestCase):
def _pause_kurz(self):
async def pausenzeit(k):
- return 0 if k.zuordnung.werte.TRIP_SENSOR else PAUSE_S
+ return PAUSE_S
return pausenzeit
async def asyncSetUp(self):
- self._echt = (
- f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen,
- )
+ self._echt = (f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s)
async def geraetezeit(k, ereigniszeit, standard):
return ereigniszeit or standard
@@ -127,11 +124,6 @@ class Basis(unittest.IsolatedAsyncioTestCase):
k.beendet.append((start_ts, ende_ts))
await k.fahrt_start_setzen(None)
- async def gegenpruefen(k, start_ts, signal_ende, gerechnet):
- # Liest den Verlauf und aendert nichts an der Rechnung (siehe dort);
- # ohne echtes hass-Objekt ist sie hier nur im Weg.
- return None
-
async def nach_pause(k, start_ts, ende_ts, pause_s):
# Wie das Original, nur mit kurzer Wanduhrzeit - die echte
# Pausenzeit steckt weiterhin in der Entscheidung, nicht im Warten.
@@ -141,14 +133,11 @@ class Basis(unittest.IsolatedAsyncioTestCase):
f._geraetezeit = geraetezeit
f.fahrt_beenden = beenden
f._pausenzeit_s = self._pause_kurz()
- f._nachlauf_gegenpruefen = gegenpruefen
self._echt_nach_pause = f._nach_pause_beenden
f._nach_pause_beenden = nach_pause
async def asyncTearDown(self):
- (
- f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen,
- ) = self._echt
+ (f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s) = self._echt
f._nach_pause_beenden = self._echt_nach_pause
@@ -187,11 +176,6 @@ class Wartezeit(Basis):
self.assertEqual(k.beendet, [])
self.assertEqual(k.fahrt_start_ts, T0)
- async def test_ohne_wartezeit_bleibt_es_wie_bisher(self):
- """Mit zugeordnetem Trip-Signal wartet das Geraet - wir nicht."""
- k = FakeKoordinator(trip="binary_sensor.trip")
- await f.zuendung_geaendert(k, "off", "on", AUS)
- self.assertEqual(len(k.beendet), 1, "ohne Pause wird sofort geschlossen")
class Nachlauf(Basis):
@@ -202,13 +186,6 @@ class Nachlauf(Basis):
_, ende = k.beendet[0]
self.assertEqual(ende, AUS - datetime.timedelta(seconds=180))
- async def test_tripweg_zieht_beide_nachlaeufe_ab(self):
- """900 s Trip-Nachlauf plus 180 s des Zuendungselements: das Trip-Ende
- haengt selbst an der bereits verzoegerten Zuendung."""
- k = FakeKoordinator(trip="binary_sensor.trip")
- await f.zuendung_geaendert(k, "off", "on", AUS)
- _, ende = k.beendet[0]
- self.assertEqual(ende, AUS - datetime.timedelta(seconds=1080))
async def test_zu_kurz_wird_auf_den_beginn_geklemmt(self):
k = FakeKoordinator(start_ts=AUS - datetime.timedelta(seconds=60))
@@ -412,9 +389,6 @@ class Einstellung(unittest.IsolatedAsyncioTestCase):
k = FakeKoordinator(minuten=25)
self.assertEqual(await f._pausenzeit_s(k), 25 * 60)
- async def test_trip_signal_schlaegt_die_einstellung(self):
- k = FakeKoordinator(minuten=25, trip="binary_sensor.trip")
- self.assertEqual(await f._pausenzeit_s(k), 0)
async def test_unsinn_faellt_auf_den_standard_zurueck(self):
k = FakeKoordinator()