diff --git a/AGENTS.md b/AGENTS.md index 0590f64..febac8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12407,3 +12407,55 @@ Das gepufferte Zündung-Aus kam am 06.09. um 14:28 mit Gerätezeit 23:21:29 an, Schnitt. Die Umstempelung auf Gerätezeit hat hier nicht gegriffen (`ZUORDNUNG_MAX_S = 1.0`). Das ist dieselbe Familie wie der Befund zu `_letztes_lebenszeichen()` und noch nicht behoben. + +## DF. Das Ende einer Fahrt, die das Gerät verschlafen hat (2026.9.6.3) + +Abschnitt DE hat die Kilometer gerettet, aber im selben Durchgang stand die +Rückfahrt vom 05.09. mit **15 h 14 min Dauer und 0,1 km/h Schnitt** im +Bestand. Drei Fehler, alle behoben. + +### 1. „Keine Meldung" ist kein „Zündung aus" — auch beim Neustart-Nachlauf + +`nach_neustart_fortsetzen()` prüfte `== "on"` und schloss bei **allem +anderen** die laufende Fahrt — also auch bei `None`, `unavailable`, +`unknown`. Genau die Regel, die `zuendung_geaendert()` seit jeher befolgt +(„Funkstille ist keine Aussage"), fehlte hier. Steht die flespi-Anbindung beim +Hochfahren noch nicht bereit, las die Funktion „nicht on" und beendete eine +laufende Fahrt mit einem Ende aus `_letztes_lebenszeichen()` — im schlimmsten +Fall „jetzt". Jetzt wird nur bei einem ausdrücklichen `"off"` geschlossen. + +### 2. Das letzte Lebenszeichen kam aus der falschen Entität + +`_letztes_lebenszeichen()` las nur den Verlauf der **Zündungsentität**. Die +schreibt aber nur bei einem WECHSEL eine Zeile: bleibt die Zündung stundenlang +an, ist ihr letzter Verlaufspunkt der Fahrtbeginn — und ein Ende daraus wäre +eine Fahrtdauer von null. Am 06.09.2026 gemessen: die Fahrt vom Vorabend hatte +seit 23:13:08 **genau drei Zeilen, alle aus derselben Sekunde**, obwohl das +Gerät danach noch zwölf Minuten lang sendete. + +Zusätzliche Quelle ist jetzt die **Meldezeit-Entität** (`_meldezeiten()`): sie +bekommt zu JEDEM Datensatz einen neuen Wert, und dieser Wert *ist* die +Gerätezeit. Genommen wird das späteste Lebenszeichen aus beiden Quellen. + +### 3. Strecke und Dauer decken verschiedene Zeiträume ab + +Mit einer Lücke enthält `distance_km` die verschlafenen Kilometer, `duration_s` +aber nicht — ihr Quotient ist bedeutungslos. Zwei Folgen: + +* **Der Durchschnitt entfällt.** Die Rückfahrt käme auf 92 km/h für eine + 33-km/h-Fahrt. „Liegt nicht vor" ist die einzige ehrliche Angabe. +* **Die Plausibilitätsprüfung in `_vollstaendig()` rechnet ohne die Lücke.** + Sonst verwirft eine Phantomgeschwindigkeit die Kilometerstände — am + 06.09.2026 genau so passiert. + +### Stand + +Live in `audi_ha_test` am gerenderten Panel nachgemessen: *Startkilometer +21.347 km · Endkilometer 21.355 km · Nicht aufgezeichnet 6 km am Anfang · +Dauer 5 Min. · Ø Geschwindigkeit liegt nicht vor.* Vorher: 15 h 14 min und +0,1 km/h. 0 Tracebacks, 303 App-Tests und alle fünf Backend-Testdateien grün. + +**Was offen bleibt:** der Fahrtbeginn steht weiter dort, wo das Gerät aufwachte +— neun Minuten und sechs Kilometer zu spät. Ihn aus der Motorlaufzeit +zurückzurechnen hat der Eigentümer abgelehnt, solange es eine gerechnete statt +gemeldete Zahl wäre. diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index db76829..196002c 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -776,8 +776,21 @@ async def nach_neustart_fortsetzen(k: Koordinator) -> None: return sensor = k.zuordnung.werte.ZUENDUNG_SENSOR - if zustand_oder_none(k.hass, sensor) == "on": - return # fährt noch - der Beobachter übernimmt wie sonst auch + zustand = zustand_oder_none(k.hass, sensor) + if zustand != "off": + # "on" heisst: faehrt noch, der Beobachter uebernimmt wie sonst auch. + # + # Alles ANDERE - None, "unavailable", "unknown" - heisst Funkstille, + # nicht "aus". Genau diese Regel steht in zuendung_geaendert() seit + # jeher, hier fehlte sie: stand die Entitaet beim Hochfahren noch nicht + # bereit (die flespi-Anbindung baut ihre MQTT-Verbindung erst auf), + # las diese Funktion "nicht on" und schloss eine laufende Fahrt - mit + # einem Ende aus _letztes_lebenszeichen(), im schlimmsten Fall "jetzt". + # + # Eine wirklich beendete Fahrt geht dadurch nicht verloren: kommt das + # gepufferte "aus" spaeter an, schliesst der Beobachter sie, und + # _spaetes_aus() faengt sogar den Fall ab, dass sie da schon zu ist. + return # Dieselbe Sperre wie im Beobachter: der erste Wechsel des Fahrtsignals # kann eintreffen, während dieser Nachlauf noch schreibt. @@ -822,11 +835,30 @@ async def _letztes_lebenszeichen( Kilometerstände ohnehin nach. """ jetzt = datetime.datetime.now(datetime.UTC) + kandidaten: list[datetime.datetime] = [] + punkte = await verlauf_lesen( k.hass, sensor, seit, jetzt, k.zuordnung.werte.MELDEZEIT_SENSOR ) if punkte: - return punkte[-1][0] + kandidaten.append(punkte[-1][0]) + + # Die Zuendungsentitaet schreibt nur bei einem WECHSEL eine Zeile. Bleibt + # die Zuendung stundenlang an, ist ihr letzter Verlaufspunkt der Beginn der + # Fahrt - und ein Ende daraus waere die Fahrtdauer null. + # + # Am 06.09.2026 gemessen: die Fahrt vom Vorabend hatte seit 23:13:08 genau + # drei Zeilen, alle aus derselben Sekunde, obwohl das Geraet danach noch + # zwoelf Minuten lang Daten schickte. + # + # Die Meldezeit-Entitaet dagegen bekommt zu JEDEM Datensatz einen neuen + # Wert - und dieser Wert IST die Geraetezeit. Sie ist damit die ehrlichste + # Antwort auf "bis wann haben wir das Fahrzeug beobachtet". + for zeitpunkt in await _meldezeiten(k, seit, jetzt): + kandidaten.append(zeitpunkt) + + if kandidaten: + return max(kandidaten) zustand = k.hass.states.get(sensor) if sensor else None if zustand is not None: @@ -848,6 +880,30 @@ async def _letztes_lebenszeichen( return jetzt +async def _meldezeiten( + k: Koordinator, seit: datetime.datetime, bis: datetime.datetime +) -> list[datetime.datetime]: + """Die Geraetezeiten aller Datensaetze im Fenster, aufsteigend. + + Die Werte der Meldezeit-Entitaet SIND Unix-Sekunden in UTC (siehe + verlauf.geraetezeit()) - hier wird deshalb nicht umgestempelt, sondern der + Wert selbst gelesen. Werte ausserhalb des Fensters fallen heraus: ein + gepufferter Datensatz kann eine Geraetezeit vor dem Fahrtbeginn tragen.""" + sensor = k.zuordnung.werte.MELDEZEIT_SENSOR + if not sensor: + return [] + zeiten: list[datetime.datetime] = [] + for _ankunft, roh in await verlauf_lesen(k.hass, sensor, seit, bis): + try: + wert = datetime.datetime.fromtimestamp(float(roh), datetime.UTC) + except (TypeError, ValueError, OSError, OverflowError): + continue + if seit <= wert <= bis: + zeiten.append(wert) + zeiten.sort() + return zeiten + + async def fahrt_beenden( k: Koordinator, start_ts: datetime.datetime, diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 7289973..ba19906 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.6.2","sha256":"9c9f09af98149f065e1f874c452d71f0ebb5232334a4740a8f2fe272e161a10b","bytes":378683,"gebaut":"2026-09-06T14:47:37Z"} \ No newline at end of file +{"version":"2026.9.6.3","sha256":"03801eda3b7914d3dd9a51262edd1462df47747919742828f446f79323384ddf","bytes":378674,"gebaut":"2026-09-06T15:06:31Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 850719e..8bb4038 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 930aeb4..20dad12 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.6.2", + "version": "2026.9.6.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/screening.py b/custom_components/audi_dashboard/screening.py index a8ff68c..bf7a405 100644 --- a/custom_components/audi_dashboard/screening.py +++ b/custom_components/audi_dashboard/screening.py @@ -115,13 +115,20 @@ def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict: aenderungen["odo_start"] = None aenderungen["odo_end"] = None return aenderungen - geschwindigkeit = durchschnitt_kmh(distanz, fahrt.get("duration_s")) + # Nur der aufgezeichnete Teil darf gegen die Dauer geprueft werden. + # Die verschlafenen Kilometer sind zwar gefahren worden, aber in einer + # Zeit, die NICHT in duration_s steckt - der Beginn liegt ja erst dort, + # wo das Geraet aufgewacht ist. Beides zusammen ergaebe eine + # Phantomgeschwindigkeit; am 06.09.2026 hat genau das eine Fahrt aus + # der Testphase verworfen (siehe _aufzeichnungsluecken_schliessen). + gemessen = round(distanz - (fahrt.get("luecke_km") or 0), 1) + geschwindigkeit = durchschnitt_kmh(gemessen, fahrt.get("duration_s")) if geschwindigkeit is not None and geschwindigkeit > UNPLAUSIBLE_KMH: _LOGGER.warning( "Fahrt %s: Kilometerstand verworfen - unmögliche %s km/h im " "Schnitt (%s km in %s s), vermutlich ein Ausreißer im " "Verlauf oder eine falsch abgegrenzte Fahrt.", - fahrt.get("trip_id"), geschwindigkeit, distanz, fahrt.get("duration_s"), + fahrt.get("trip_id"), geschwindigkeit, gemessen, fahrt.get("duration_s"), ) aenderungen["odo_start"] = None aenderungen["odo_end"] = None @@ -180,6 +187,18 @@ async def durchfuehren(k: Koordinator) -> None: # die beide schon dastehen. Deshalb vor dem Geschwindigkeitssensor und # unabhaengig davon, ob einer zugeordnet ist. for fahrt in fahrten: + if fahrt.get("luecke_km"): + # Strecke und Dauer decken hier verschiedene Zeitraeume ab: die + # Strecke enthaelt die verschlafenen Kilometer, die Dauer nicht. + # Ein Quotient daraus waere eine Zahl ohne Bedeutung - die Fahrt + # vom 05.09.2026 kaeme auf 92 km/h fuer eine 33-km/h-Fahrt. + # "Liegt nicht vor" ist die einzige ehrliche Angabe. + if fahrt.get("avg_speed_kmh") is not None: + await k.ablage.fahrt_aktualisieren( + fahrt["trip_id"], {"avg_speed_kmh": None} + ) + fahrt["avg_speed_kmh"] = None + continue if fahrt.get("avg_speed_kmh") is not None: continue schnitt = durchschnitt_kmh(fahrt.get("distance_km"), fahrt.get("duration_s"))