diff --git a/AGENTS.md b/AGENTS.md index 29cb513..e075e4b 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** (Nachlauf-Abzug nur mit zugeordnetem `TRIP_SENSOR`, `2026.9.1.9`; +**Last updated: 2026-09-01** (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 aus der Aufzeichnung nachgeholt, Manifest `2026.9.1.7`, @@ -6728,6 +6729,83 @@ Nachgewiesen: Manifest, `bundle.json` und die ausgelieferte Zip tragen alle `202 denselben SHA-256 `c2f9192e…`; `tsc --noEmit` sauber, 165/165 Tests grün, Panel als **Modul** geparst (Abschnitt AQ), `audi_ha_test` fehlerfrei gestartet. +## BE. Die Fahrtstrecke auf 100 Meter: CAN als Anker, GNSS als Nachkommastelle (2026.9.1.10) + +Zwei echte Fahrten am 01.09.2026 haben die Datenform geliefert, auf die Abschnitt BA gewartet hat. +Beide Male schließt die Rechnung, und beide Male auf eine andere Weise — deshalb war das Warten +richtig. + +### Was gemessen wurde + +| Quelle | Fahrt 1 (13:42, mit Rücksetzung) | Fahrt 2 (16:20, sauber) | +|---|---|---| +| **Zählerzuwächse mit Rücksprungbehandlung** | **6,958** | **6,816** | +| GPS-Spur | 7,01 | 6,74 | +| Geschwindigkeit integriert | 6,91 | 6,70 | +| Teilstrecken summiert | 6,928 | 6,614 | +| CAN-Kilometerstand (ganze km) | 7 | 6 | +| Google Maps | 7,1 | — | + +### Der Zähler, nicht die Teilstrecken — und warum ich zweimal falsch lag + +Erst hielt ich den **Gesamtzähler** für robuster (Abschnitt BA), nach Fahrt 1 die **Teilstrecken**, +nach Fahrt 2 wieder den Zähler. Die Auflösung: es sind keine zwei Messungen. An jedem einzelnen +Datensatz gilt `Teilstrecke == Differenz des Zählers`, auf drei Nachkommastellen genau. Sie +unterscheiden sich nur darin, **wie sie kaputtgehen**: + +- Der **Zähler** verliert bei einer Rücksetzung alles Vorherige (Fahrt 1: 0,415 km). +- Die **Teilstrecken** verlieren einzelne Datensätze — in Fahrt 2 waren **4 von 50 null**, obwohl + gefahren wurde, Summe dadurch 0,2 km zu niedrig. + +`strecke_aus_zaehler()` summiert deshalb die **Zuwächse** und behandelt einen Rücksprung als +Rücksetzung (der neue Wert ist dann selbst der Zuwachs). Damit fällt beides weg: unempfindlich +gegen Rücksetzungen, und keine verlorenen Datensätze. Ohne Rücksetzung degeneriert es zu Ende +minus Anfang. + +Der Eigentümer hat dazu angemerkt, dass im Endzustand alle Fahrten wie Fahrt 2 verlaufen — er +steckt den Dongle nur in der Entwicklungsphase um. Die Rücksprungbehandlung ist damit kein +Kernstück mehr, sondern ein billiges Netz für Batteriewechsel und Werkstattbesuch. + +### Woher die Rücksetzung mitten in der Fahrt kam + +Um 13:43:20, bei 65 km/h und laufendem Motor, ging `total_calculated_mileage` von 0,415 auf 0. Im +**selben Datensatz** zählte `total_vehicle_mileage_read_from_can` zum ersten Mal hoch +(61809 → 61810), ebenso `distance_traveled_since_codes_cleared`. Kein Verbindungsabbruch, kein +Neustart, Bordspannung stabil bei 14,4 V — das Gerät übernahm offenbar seinen konfigurierten +Startwert (`11807 = 0`), als der CAN-Kilometerstand erstmals gültig wurde. Nicht bewiesen; ein +Prüfstein wäre, `11807` auf den echten Kilometerstand zu setzen und zu sehen, ob der Sprung dann +dorthin geht statt auf null. + +### Die Verfeinerung ersetzt nicht, sie prüft + +`_gnss_verfeinern()` setzt `distance_km` nur dann auf den feinen Wert, wenn er **innerhalb der +Rundungsunschärfe des Ankers** liegt (`GNSS_TOLERANZ_KM = 1.0` — beide Enden des CAN-Werts sind auf +ganze Kilometer gerundet). Sonst behält der Fahrzeugwert recht und es bleibt bei `"odometer"`. + +Live nachgewiesen, beides im selben Lauf: + +- Fahrt 2: `Strecke auf 6.896 km verfeinert (Kilometerstand sagte 7.0 km)`, `km_quelle` jetzt + `"odometer+gnss"`. +- Drei ältere Fahrten aus der Zeit vor der GNSS-Umstellung: `GNSS-Strecke 209178.0 km weicht um + mehr als 1.0 km vom Kilometerstand (21.0 km) ab - der Fahrzeugwert bleibt stehen`. Genau dafür + ist die Schranke da. + +### Nebenbei bestätigt + +- **`NACHLAUF_S = 900` stimmt.** Fahrt 2: Zündung aus 16:33:39, Trip-Signal aus 16:48:44 — 905 s + Abstand. Das gespeicherte Fahrtende steht auf **16:33:36**, also drei Sekunden neben der + tatsächlichen Zündung. +- **Der Rückwärtssprung-Schutz griff live** und genau wie vorhergesagt: Fahrt 1 lief über einen + Fahrzeugwechsel (209177 → 61816), `-147361 km` wurden verworfen, die Fahrt blieb „offen". +- **Die RPM-Zündung prellt nicht** — zwei echte Wechsel über beide Fahrten, kein Paar unter zwei + Sekunden. `Avg Const = 10` (1 s) bleibt. +- **Das Bewegungstor funktioniert** seit `138 = 10` (Beschleunigungssensor + CAN Speed statt GNSS): + `movement` meldet wieder Stillstand, vorher hing es tagelang auf „on". + +Verifiziert: `strecke_aus_zaehler()` gegen beide echten Fahrten plus sechs Randfälle +(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. + ## Working conventions (observed — keep them) - German is the project language: identifiers, comments, commits, UI texts. Exceptions: diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 54f0335..eea4d2d 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -40,7 +40,10 @@ export interface Fahrt { duration_s: number; distance_km: number | null; /** "odometer" ist der einzige Wert, den das Backend je erzeugt (§7.1). */ - km_quelle: "odometer" | "gps" | null; + /* "odometer+gnss": der Kilometerstand des Fahrzeugs als Anker, die + Nachkommastelle vom GNSS-Zaehler des Geraets - siehe screening.py. + "sensor"/"manuell" kommen aus dem Rueckblick bzw. von Hand. */ + km_quelle: "odometer" | "odometer+gnss" | "gps" | "sensor" | "manuell" | null; odo_start: number | null; odo_end: number | null; art: Fahrtart; diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 0312e64..777907f 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.9","sha256":"bdf895932e11e336474b4441a66912dd273e5f22b3bbf82e9b1cd29d6447a652","bytes":265713,"gebaut":"2026-09-01T10:24:49Z"} \ No newline at end of file +{"version":"2026.9.1.10","sha256":"ea987e424774f4d2d0e7ea87f38f4b265f0605730515cacfd7193b2bb309b647","bytes":265700,"gebaut":"2026-09-01T15:30: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 821e858..c4d2efc 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 47ec707..d2bdaa4 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.9", + "version": "2026.9.1.10", "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 187538c..e94207a 100644 --- a/custom_components/audi_dashboard/screening.py +++ b/custom_components/audi_dashboard/screening.py @@ -32,9 +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, naechster_wert, route_aus_verlauf, verbrauch_aus_literstaenden, @@ -159,6 +161,14 @@ async def durchfuehren(k: Koordinator) -> None: ) fahrt["avg_speed_kmh"] = schnitt + # Erst nach den Kilometerstaenden: die Verfeinerung braucht die grobe + # Strecke als Anker und laeuft nur auf Fahrten, die sie schon haben. + gnss_sensor = k.zuordnung.werte.GNSS_KM_SENSOR + if gnss_sensor: + for fahrt in fahrten: + if fahrt.get("km_quelle") == "odometer": + await _gnss_verfeinern(k, gnss_sensor, fahrt) + tempo_sensor = k.zuordnung.werte.GESCHWINDIGKEIT_SENSOR if tempo_sensor: ohne_vmax = [f for f in fahrten if f.get("vmax_kmh") is None] @@ -223,6 +233,52 @@ async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None: fahrt.update(aenderungen) +async def _gnss_verfeinern(k: Koordinator, gnss_sensor: str, fahrt: dict) -> None: + """Ersetzt die auf ganze Kilometer gerundete Strecke durch die metergenaue + des Geraets - aber nur, wenn beide zueinander passen. + + Der Kilometerstand des Fahrzeugs bleibt der Anker: er ist der Tacho, er + driftet nicht, und er ist die Zahl, die der Fahrer im Cockpit sieht. Nur + loest er eben ganze Kilometer auf. Der GNSS-Zaehler des Geraets loest auf + einen Meter auf, kann aber driften und Empfang verlieren. + + Deshalb keine Ersetzung, sondern eine Pruefung: die feine Zahl gilt, solange + sie innerhalb der Rundungsunschaerfe des groben Ankers liegt + (GNSS_TOLERANZ_KM). Liegt sie darueber, hat der Anker recht und die feine + Zahl wird verworfen - dann fehlte Empfang, oder der Zaehler ist gedriftet. + + Laeuft nur auf Fahrten mit km_quelle == \"odometer\", also genau einmal je + Fahrt: danach steht dort \"odometer+gnss\" (oder es blieb bei \"odometer\", + wenn nichts Brauchbares vorlag, und ein spaeterer Lauf versucht es erneut, + sobald mehr Verlauf da ist).""" + start = _als_zeit(fahrt.get("ts_start")) + ende = _als_zeit(fahrt.get("ts_end")) + grob = fahrt.get("distance_km") + if start is None or ende is None or grob is None: + return + + punkte = await verlauf_lesen(k.hass, gnss_sensor, start, ende) + fein = strecke_aus_zaehler(punkte) + if fein is None: + 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"} + 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, + ) + + async def _position_screenen( k: Koordinator, lat_sensor: str, lon_sensor: str, fahrt: dict ) -> None: diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py index 395108e..abbdc03 100644 --- a/custom_components/audi_dashboard/verlauf.py +++ b/custom_components/audi_dashboard/verlauf.py @@ -272,6 +272,51 @@ def route_aus_verlauf( return punkte if len(punkte) >= 2 else None +# Wie weit die GNSS-Strecke vom Kilometerstand des Fahrzeugs abweichen darf. +# +# Beide Enden des Kilometerstands sind auf ganze Kilometer gerundet (am +# echten Fahrzeug nachgemessen: 147 Schritte von exakt 1,0 km, kein einziger +# feinerer). Die wahre Strecke liegt damit irgendwo in einem Fenster von +# +/- 1 km um die Differenz. Was darin liegt, ist mit dem Fahrzeugwert +# vereinbar; was darueber hinausgeht, ist es nicht - dann stimmt etwas nicht, +# und der Fahrzeugwert behaelt recht. +GNSS_TOLERANZ_KM = 1.0 + + +def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None: + """Gefahrene Strecke aus einem laufenden Kilometerzaehler des Geraets. + + Summiert die Zuwaechse statt einfach Ende minus Anfang zu rechnen, weil der + Zaehler zurueckspringen kann. Ein Ruecksprung ist kein Rueckwaertsfahren, + sondern eine Ruecksetzung - der neue Wert ist dann selbst der Zuwachs seit + der Ruecksetzung, alles davor wurde bereits gezaehlt. + + Am 01.09.2026 an zwei echten Fahrten gemessen: + + * Fahrt 1, mit Ruecksetzung mitten in der Fahrt (das Geraet uebernahm + seinen konfigurierten Startwert 0, als der CAN-Kilometerstand zum ersten + Mal hochzaehlte): 0,415 km vor der Ruecksetzung, 6,543 km danach - macht + 6,958 km. Google Maps sagt 7,1 km fuer dieselbe Strecke. Ohne die + Ruecksprungbehandlung waeren es 6,543 km gewesen, also 0,4 km zu wenig. + * Fahrt 2, ohne Ruecksetzung: 6,816 km. Dort degeneriert die Summe zu + Ende minus Anfang, wie es sein soll. + + Bewusst NICHT ueber die Teilstrecken je Datensatz (segment_mileage), die + dasselbe Geraet mitliefert: die verlieren einzelne Datensaetze. In Fahrt 2 + waren 4 von 50 Teilstrecken null, obwohl gefahren wurde, und die Summe lag + dadurch 0,2 km zu niedrig. Der Zaehler zaehlt ueber solche Aussetzer hinweg. + + None, wenn weniger als zwei Zahlenwerte vorliegen.""" + werte = [zahl(wert) for _ts, wert in punkte] + werte = [w for w in werte if w is not None] + if len(werte) < 2: + return None + strecke = 0.0 + for vorher, jetzt in zip(werte, werte[1:]): + strecke += jetzt if jetzt < vorher else jetzt - vorher + return round(strecke, 3) + + def hoechstwert_im_fenster( verlauf: list[Verlaufspunkt], start: datetime.datetime,