diff --git a/AGENTS.md b/AGENTS.md index 985cf5c..0601113 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** (Fahrtbeginn und -ende kommen jetzt vom Gerät (`MELDEZEIT_SENSOR`), +**Last updated: 2026-09-01** (Fahrtende ohne den 900-s-Nachlauf des Geräts, Manifest `2026.9.1.4`, +siehe Abschnitt AZ. Davor am selben Tag: Fahrtbeginn und -ende kommen vom Gerät (`MELDEZEIT_SENSOR`), Manifest `2026.9.1.3`, siehe Abschnitt AY: dazu die Trennung von Trip- und Zündungsrolle und die Gerätezeit für Tankvorgänge. Davor am selben Tag: Phantomfahrten beim Neustart abgestellt, Tankstand auf `wert_bei()` nachgezogen, zwölf Nullfahrten gelöscht, „Open items" von vier MQTT-Karteileichen befreit; @@ -6518,6 +6519,54 @@ sonst testet man gegen ganze Kilometer. `11807` (Odometer Value) steht auf 0. Für die geplante Rechnung ist das gleichgültig, weil sie mit Zuwächsen arbeitet; wer den absoluten Wert lesbar haben will, trägt dort den echten Kilometerstand ein. +## AZ. Das Fahrtende ohne den Nachlauf des Geräts (2026.9.1.4) + +Das Trip-Signal endet nicht mit der Zündung, sondern `Ignition OFF Timeout` später — beim +FMM003 900 s. Als Auslöser ist es deshalb ideal (es prellt nicht, es bringt die Pausentoleranz +mit), aber das Fahrtende lag systematisch **15 Minuten zu spät**. + +`_echtes_ende()` zieht `NACHLAUF_S = 900` ab. Die Rechnung stammt vollständig aus der Logik des +Geräts und überlebt deshalb ein Funkloch: der Datensatz kommt später, trägt aber seine eigene +Zeit (Abschnitt AY). + +**Die Klemme ist der wichtigere Teil.** War das Signal nicht länger als der Nachlauf, wurde nicht +gefahren — dann wird das Ende auf den Beginn geklemmt und `MINDESTDAUER_S` verwirft den Vorgang. +Ohne sie entstünde aus dreißig Sekunden Zündung eine Viertelstunde Fahrt, also genau die Sorte +Nulleintrag, die Abschnitt AW ausgeräumt hat. + +**Warum keine Einstellung in der Oberfläche.** Die Zahl gehört zum Gerät, nicht zum Fahrzeug und +nicht zum Nutzer — dieselbe Linie wie `UNPLAUSIBLE_KMH` und `MIN_STRECKE_VERBRAUCH_KM`. flespi +kann die Gerätekonfiguration zwar liefern (`GET /gw/devices/{id}/settings/all`, 127 benannte +Einstellungen, darunter `trip_scenario.ign_off_timeout`), aber dafür bräuchte die Integration +einen zweiten Zugang samt Token; sie liest heute ausschließlich Telemetrie. Verworfen als +unverhältnismäßig. + +### Die Gegenprobe meldet, statt zu korrigieren + +`_nachlauf_gegenpruefen()` vergleicht das gerechnete Ende mit dem letzten beobachteten +Zündungswechsel auf „aus" und **warnt ab 120 s Abweichung, ohne etwas zu ändern**. + +Der Grund für „nur warnen": wie genau „Zündung aus" bekannt ist, hängt am Operanden des +Zündungs-I/O-Elements. Er steht in der `.cfg` auf **3**; nach der Reihenfolge in der +Teltonika-Dokumentation (On Exit, On Entrance, On Both, Monitoring, On Hysteresis, On Change, On +Delta Change) ist das **Monitoring** — laut Wiki „No event at all. Values are recorded only when +other triggers worked". Der Wechsel erzeugte dann keinen eigenen Datensatz, sondern führe im +nächsten mit, und der beobachtete Zeitpunkt wäre zu spät statt exakt. Der Standard ist 5 (On +Change). **Offen und am Gerät zu prüfen** — explizit nummeriert steht die Operandenliste nicht in +der Dokumentation, und die Messung ist durch eigene Testschaltvorgänge verunreinigt. Steht der +Operand tatsächlich auf Monitoring, wäre `On Change` die bessere Einstellung: ein zusätzlicher +Datensatz je Zündungswechsel, keine zusätzliche Übertragung (Priorität bleibt Low). + +Verifiziert in `audi_ha_test`: + +| Fall | Ergebnis | +|---|---| +| Signal 1200 s (5 min Fahrt + Nachlauf) | Fahrt **300 s** gespeichert | +| Signal 930 s (30 s Zündung) | **keine** Fahrt, Bestand 12 → 12 | +| Gegenprobe | Warnung mit 887 s Abweichung ausgelöst und protokolliert | + +Testfahrt anschließend über `fahrt_loeschen` entfernt; Bestand unverändert 11 Fahrten. + ## Working conventions (observed — keep them) - German is the project language: identifiers, comments, commits, UI texts. Exceptions: diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index 1caa8fd..3b30729 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -50,6 +50,29 @@ _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 +# Datensaetze schreibt - beim Fahren alle 10 s, im Stand deutlich seltener. +NACHLAUF_TOLERANZ_S = 120 + + def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: str) -> dict: """Das vollständige Fahrt-Schema mit allem, was noch nicht bekannt ist, auf None. Bewusst alle Felder ausgeschrieben: die Oberfläche darf sich auf @@ -83,6 +106,71 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: +async def _echtes_ende( + k: Koordinator, + start_ts: datetime.datetime, + signal_ende: datetime.datetime, +) -> datetime.datetime: + """Das Fahrtende ohne den Nachlauf des Geraets. + + Das Trip-Signal endet NACHLAUF_S nach der Zuendung; 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()). + + War die ganze Fahrt kuerzer als der Nachlauf, bleibt nichts uebrig - dann war + die Zuendung nur kurz an, ohne dass gefahren wurde. Das Ende wird auf den + Beginn geklemmt, und MINDESTDAUER_S in fahrt_beenden() verwirft den Vorgang + anschliessend. Ohne diese Klemme entstuende aus dreissig Sekunden Zuendung + eine Viertelstunde Fahrt.""" + gerechnet = signal_ende - datetime.timedelta(seconds=NACHLAUF_S) + if gerechnet <= start_ts: + _LOGGER.info( + "Trip-Signal lief %s s, also nicht laenger als der Nachlauf (%s s) - " + "es wurde nicht gefahren", + int((signal_ende - start_ts).total_seconds()), NACHLAUF_S, + ) + return start_ts + await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet) + 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) + aus = [ts for ts, wert in punkte if wert == "off"] + if not aus: + return + abweichung = abs((aus[-1] - 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, @@ -163,16 +251,20 @@ async def zuendung_geaendert( # zu warten haette daraus eine halbe Stunde gemacht und zwei # aufeinanderfolgende Fahrten verschmolzen, die das Geraet sauber # getrennt hatte. - ende = await _geraetezeit(k, ereigniszeit, jetzt) + 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 - # dann ist die Ankunftszeit die ehrlichere Angabe. - if ende < k.fahrt_start_ts: + if signal_ende < k.fahrt_start_ts: _LOGGER.warning( "Meldezeit %s liegt vor dem Fahrtbeginn %s - nehme die Ankunft", - ende.isoformat(), k.fahrt_start_ts.isoformat(), + signal_ende.isoformat(), k.fahrt_start_ts.isoformat(), ) - ende = jetzt + signal_ende = jetzt + # Erst jetzt den Nachlauf des Geraets abziehen: signal_ende ist der + # Zeitpunkt, zu dem das TRIP-SIGNAL endete, nicht der, zu dem das + # Fahrzeug abgestellt wurde. + ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende) await fahrt_beenden(k, k.fahrt_start_ts, ende) await k.fahrzeugstatus_veroeffentlichen() diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 4a286d8..ea1afd4 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.3", + "version": "2026.9.1.4", "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"],