diff --git a/AGENTS.md b/AGENTS.md index ad2f695..8fa4873 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-09-01** (Fahrtstrecke auf 100 m: CAN als Anker, GNSS als Nachkommastelle, +**Last updated: 2026-09-01** (Rueckblick ohne Nullfahrten, Streckenwahl gemeinsam in `verlauf.py`, +`2026.9.1.14`, Abschnitt BF. Davor: Fahrtstrecke auf 100 m: CAN als Anker, GNSS als Nachkommastelle, `2026.9.1.10`, Abschnitt BE. Davor: Nachlauf-Abzug nur mit zugeordnetem `TRIP_SENSOR`, `2026.9.1.9`; davor „502" beim Neustart entschärft, OTA-Bündel auf `2026.9.1.8` nachgezogen, Abschnitt BC. Davor: „Geparkt seit" kommt von der Zündung und wird nach einem Neustart @@ -6833,6 +6834,51 @@ Verifiziert: `strecke_aus_zaehler()` gegen beide echten Fahrten plus sechs Randf (zwei Rücksetzungen, ein Punkt, leer, unlesbare Werte); `py_compile`, `tsc --noEmit` sauber, Bündel und Manifest auf `2026.9.1.10` mit demselben SHA-256. +## BF. Der Rueckblick legt keine Nullfahrten mehr an — und misst jetzt auf Meter (2026.9.1.12–.14) + +Der Eigentuemer hatte auf der realen Instanz alle Nullfahrten geloescht, und **bei jedem Import kam +eine davon wieder**: 31.08.2026, 14:48–15:21, 33 Minuten, 209177 → 209177 km, vmax 3 km/h. +`MINDESTDAUER_S` liess sie durch, weil sie mit 33 Minuten lang genug war; eine Pruefung auf die +Strecke gab es im Rueckblick nie. + +### Erst zu grob gedacht, dann korrigiert + +Mein erster Wurf verwarf jedes Fenster mit `distanz == 0`. Der Eigentuemer hat richtig eingewandt: +seit der GNSS-Verfeinerung (Abschnitt BE) messen wir Meter — `„0,0 möchte ich nicht, ab 0,1 schon"`. +Eine Fahrt von 400 Metern steht im CAN-Wert als Null und waere verlorengegangen, obwohl gefahren +wurde. + +Der Rueckblick liest deshalb jetzt ebenfalls den GNSS-Zaehler und entscheidet auf **eine +Nachkommastelle**. + +### Eine Entscheidung, eine Stelle + +Dabei stand die Toleranzlogik kurz doppelt im Code — in `screening.py` und in +`historienimport.py`. Genau die Doppelung, die dieses Projekt bei `UNPLAUSIBLE_KMH` und +`MINDESTDAUER_S` schon einmal teuer bezahlt hat. Sie liegt jetzt in `verlauf.py`: + +- **`strecke_waehlen(grob, fein)`** — die feine Zahl gilt, solange sie in der Rundungsunschaerfe des + Ankers liegt, oder wenn es gar keinen Anker gibt. Sonst hat der Fahrzeugwert recht. +- **`ist_gefahren(distanz)`** — ab 0,1 km ja, darunter nein. Eine **unbekannte** Strecke gilt als + gefahren: eine Fahrt zu verwerfen, die man nicht gemessen hat, waere schlechter als eine ohne + Streckenangabe zu behalten. Nur die gemessene Null ist ein Nein. + +Beide Wege rufen dieselben Funktionen; der Rueckblick meldet die uebersprungenen Fenster als eigene +Zahl (`„Zuendungen ohne gefahrene Strecke"`) in beiden Oberflaechen, damit er nichts stillschweigend +weglaesst. + +### Verifiziert — und was nicht + +- **13 Faelle gegen `strecke_waehlen`/`ist_gefahren`**, darunter die beiden echten Fahrten, die + 400-Meter-Fahrt, die 100-Meter-Untergrenze, 40 Meter (keine Fahrt), fehlender Anker, fehlender + GNSS-Wert, gedrifteter Zaehler und beide Seiten der Toleranzgrenze. +- **Import live**: `fahrten_nicht_gefahren: 1`, null Fahrten mit 0 km im Bestand. +- **Livepfad live**: vier alte Fahrten korrekt abgelehnt, Meldung jetzt ohne die Konstante im Text. +- **Nicht live gezeigt:** ein Fenster mit 0,1–0,9 km, das der Import nun *behaelt*. Der Recorder + enthaelt kein geschlossenes Fenster dieser Groesse — die Rangierfahrten vom 31.08. liegen alle + innerhalb einer einzigen langen `trip_status`-Phase. Nachweisbar bleibt es an den 13 Faellen; live + zeigt es sich bei der naechsten kurzen Fahrt. + ## Working conventions (observed — keep them) - German is the project language: identifiers, comments, commits, UI texts. Exceptions: diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 820463b..e73bdd1 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.1.12","sha256":"5a2d5547cbc6db6850e46892ea422de08642f86fc634b5887322389b4e0af589","bytes":265724,"gebaut":"2026-09-01T16:35:22Z"} \ No newline at end of file +{"version":"2026.9.1.14","sha256":"f1fa3a91435bfc80f3f4245e52c488e3487fe12e5031349e1d7d20ca757d4b7d","bytes":265727,"gebaut":"2026-09-01T16:47:50Z"} \ 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 2014068..c0a7d63 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/historienimport.py b/custom_components/audi_dashboard/historienimport.py index 7ed12cc..3431851 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -48,6 +48,9 @@ from .verlauf import ( MINDESTDAUER_S, durchschnitt_kmh, fahrtsignal, + ist_gefahren, + strecke_aus_zaehler, + strecke_waehlen, hoechstwert_im_fenster, route_aus_verlauf, UNPLAUSIBLE_KMH, @@ -163,6 +166,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict: bestehende = await k.ablage.fahrten_lesen() km_verlauf = verlaeufe["km"] + gnss_verlauf = verlaeufe.get("gnss_km") or [] lat_verlauf = verlaeufe["lat"] lon_verlauf = verlaeufe["lon"] tank_liter_verlauf = verlaeufe["tank_liter"] @@ -186,10 +190,22 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict: if odo_start is not None and odo_end is not None and odo_end >= odo_start: distanz = round(odo_end - odo_start, 1) - if distanz == 0: - # Der Kilometerstand stand an beiden Enden gleich: das Fahrzeug hat - # sich nicht bewegt. Zündung an, aber nicht gefahren - Radio, Tür - # öffnen, warten. Keine Fahrt. + # Der Kilometerstand des Fahrzeugs löst nur ganze Kilometer auf. Wo der + # geräteeigene GNSS-Zähler zugeordnet ist, liefert er Meter - dieselbe + # Verfeinerung wie im Livepfad (screening._gnss_verfeinern), damit + # Rückblick und Livepfad dieselbe Fahrt nicht unterschiedlich bewerten. + # + # Sie entscheidet hier zusätzlich über Sein oder Nichtsein: eine Fahrt + # von 400 Metern steht im CAN-Wert als Null und wäre sonst verworfen + # worden, obwohl gefahren wurde. + fein = strecke_aus_zaehler( + [p for p in gnss_verlauf if f_start <= p[0] <= f_ende] + ) + distanz, gnss_verwendet = strecke_waehlen(distanz, fein) + + if not ist_gefahren(distanz): + # Gemessene Null: das Fahrzeug hat sich nicht bewegt. Zündung an, + # aber nicht gefahren - Radio, Tür öffnen, warten. Keine Fahrt. # # Die Dauer allein reicht als Prüfung nicht: der Eigentümer hatte # nach dem Löschen aller Nullfahrten bei JEDEM Import wieder eine @@ -197,12 +213,14 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict: # 209177 -> 209177 km, vmax 3 km/h). MINDESTDAUER_S ließ sie durch, # weil sie mit 33 Minuten lang genug war. # - # Nur die GEMESSENE Null wird verworfen, nicht die unbekannte: - # liegt kein Kilometerstand vor, bleibt distanz None und die Fahrt - # wird angelegt. Der Preis, bewusst getragen: eine echte Fahrt unter - # einem Kilometer, bei der der Zähler nicht umsprang, geht dabei - # verloren - sie wäre aber genau der Nulleintrag, den der - # Eigentümer nicht im Bestand haben will. + # Auf eine Nachkommastelle gerundet, nicht auf Gleichheit mit 0: + # ab 0,1 km ist es eine Fahrt (Ansage des Eigentümers, 01.09.2026), + # darunter zeigt die Oberfläche ohnehin "0,0 km" an und der Eintrag + # sähe aus wie die Nullfahrten, die er gerade ausgeräumt hat. + # + # Nur die GEMESSENE Null wird verworfen, nicht die unbekannte: liegt + # weder Kilometerstand noch GNSS-Zähler vor, bleibt distanz None und + # die Fahrt wird angelegt. nicht_gefahren += 1 continue @@ -221,7 +239,13 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict: fahrt = leere_fahrt(f_start, f_ende, "import") fahrt.update({ "distance_km": distanz, - "km_quelle": "sensor" if distanz is not None else None, + "km_quelle": ( + None + if distanz is None + else "odometer+gnss" + if gnss_verwendet + else "sensor" + ), "odo_start": odo_start, "odo_end": odo_end, "avg_speed_kmh": durchschnitt, @@ -468,6 +492,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None: verlaeufe = { "zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis), "km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis), + "gnss_km": await verlauf_lesen(k.hass, werte.GNSS_KM_SENSOR, von, bis), "tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis), "tank_liter": await verlauf_lesen(k.hass, werte.TANK_LITER_SENSOR, von, bis), "tank_distanz": await verlauf_lesen(k.hass, werte.TANK_DISTANZ_SENSOR, von, bis), diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 7914d4f..e2d70d8 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.1.12", + "version": "2026.9.1.14", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"], diff --git a/custom_components/audi_dashboard/screening.py b/custom_components/audi_dashboard/screening.py index e94207a..60417f9 100644 --- a/custom_components/audi_dashboard/screening.py +++ b/custom_components/audi_dashboard/screening.py @@ -32,11 +32,11 @@ import logging from typing import TYPE_CHECKING from .verlauf import ( - GNSS_TOLERANZ_KM, UNPLAUSIBLE_KMH, durchschnitt_kmh, hoechstwert_im_fenster, strecke_aus_zaehler, + strecke_waehlen, naechster_wert, route_aus_verlauf, verbrauch_aus_literstaenden, @@ -259,23 +259,22 @@ async def _gnss_verfeinern(k: Koordinator, gnss_sensor: str, fahrt: dict) -> Non punkte = await verlauf_lesen(k.hass, gnss_sensor, start, ende) fein = strecke_aus_zaehler(punkte) - if fein is None: + gewaehlt, genommen = strecke_waehlen(grob, fein) + if not genommen: + if fein is not None: + _LOGGER.info( + "Fahrt %s: GNSS-Strecke %s km passt nicht zum Kilometerstand " + "(%s km) - der Fahrzeugwert bleibt stehen", + fahrt.get("trip_id"), fein, grob, + ) return - if abs(fein - grob) > GNSS_TOLERANZ_KM: - _LOGGER.info( - "Fahrt %s: GNSS-Strecke %s km weicht um mehr als %s km vom " - "Kilometerstand (%s km) ab - der Fahrzeugwert bleibt stehen", - fahrt.get("trip_id"), fein, GNSS_TOLERANZ_KM, grob, - ) - return - - aenderungen = {"distance_km": fein, "km_quelle": "odometer+gnss"} + aenderungen = {"distance_km": gewaehlt, "km_quelle": "odometer+gnss"} await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen) fahrt.update(aenderungen) _LOGGER.info( "Fahrt %s: Strecke auf %s km verfeinert (Kilometerstand sagte %s km)", - fahrt.get("trip_id"), fein, grob, + fahrt.get("trip_id"), gewaehlt, grob, ) diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py index abbdc03..7a50ce5 100644 --- a/custom_components/audi_dashboard/verlauf.py +++ b/custom_components/audi_dashboard/verlauf.py @@ -283,6 +283,46 @@ def route_aus_verlauf( GNSS_TOLERANZ_KM = 1.0 +def strecke_waehlen( + grob: float | None, fein: float | None +) -> tuple[float | None, bool]: + """Welche der beiden Streckenangaben gilt - und wurde die feine genommen? + + `grob` ist die Differenz der Fahrzeug-Kilometerstände: der Anker. Er ist + der Tacho, er driftet nicht, und er ist die Zahl im Cockpit - aber er löst + nur ganze Kilometer auf. `fein` ist die Strecke aus dem GNSS-Zähler des + Geräts: metergenau, dafür anfällig für fehlenden Empfang und Drift. + + Die feine gilt, solange sie innerhalb der Rundungsunschärfe des Ankers + liegt (GNSS_TOLERANZ_KM) - oder wenn es gar keinen Anker gibt, denn dann + ist irgendeine Messung besser als keine. Liegt sie darüber, hat der Anker + recht: dann fehlte Empfang oder der Zähler ist gedriftet. + + Gemeinsam für Livepfad (screening.py) und Rückblick (historienimport.py) - + dieselbe Regel wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S: beide Wege + dürfen dieselbe Fahrt nie unterschiedlich bewerten.""" + if fein is None: + return grob, False + if grob is None or abs(fein - grob) <= GNSS_TOLERANZ_KM: + return fein, True + return grob, False + + +def ist_gefahren(distanz: float | None) -> bool: + """Hat sich das Fahrzeug bewegt? + + Auf eine Nachkommastelle gerundet: ab 0,1 km ist es eine Fahrt, darunter + nicht (Ansage des Eigentümers, 01.09.2026). Unter 50 Metern zeigt die + Oberfläche ohnehin \"0,0 km\" an, und der Eintrag sähe aus wie die + Nullfahrten, die aus dem Bestand geräumt wurden. + + Eine UNBEKANNTE Strecke gilt als gefahren: liegt weder Kilometerstand noch + GNSS-Zähler vor, wissen wir es nicht - und eine Fahrt zu verwerfen, die man + nicht gemessen hat, wäre schlechter als eine ohne Streckenangabe zu + behalten. Nur die gemessene Null ist ein Nein.""" + return distanz is None or round(distanz, 1) != 0 + + def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None: """Gefahrene Strecke aus einem laufenden Kilometerzaehler des Geraets.