Das Ende einer verschlafenen Fahrt richtig bestimmen

Drei Fehler, die zusammen eine 5-Minuten-Fahrt als 15 Stunden mit 0,1 km/h
im Bestand stehen liessen:

- nach_neustart_fortsetzen() schloss eine laufende Fahrt bei ALLEM ausser
  "on" - auch bei Funkstille. Jetzt nur noch bei ausdruecklichem "off".
- _letztes_lebenszeichen() las nur die Zuendungsentitaet, die nur bei einem
  Wechsel schreibt. Zusaetzliche Quelle ist die Meldezeit-Entitaet, deren
  Werte die Geraetezeit jedes Datensatzes sind.
- Strecke und Dauer decken bei einer Luecke verschiedene Zeitraeume ab: der
  Durchschnitt entfaellt, und die Plausibilitaetspruefung rechnet ohne die
  verschlafenen Kilometer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 17:08:52 +02:00
parent d6e27666a3
commit bcfd54489b
6 changed files with 134 additions and 7 deletions
@@ -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,
@@ -1 +1 @@
{"version":"2026.9.6.2","sha256":"9c9f09af98149f065e1f874c452d71f0ebb5232334a4740a8f2fe272e161a10b","bytes":378683,"gebaut":"2026-09-06T14:47:37Z"}
{"version":"2026.9.6.3","sha256":"03801eda3b7914d3dd9a51262edd1462df47747919742828f446f79323384ddf","bytes":378674,"gebaut":"2026-09-06T15:06:31Z"}
@@ -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": [
+21 -2
View File
@@ -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"))