7f386d19ea
Das Fahrtfenster stand seit dem 01.09. in der Gerätezeit, der Verlauf war nach Ankunftszeit sortiert. Zwei Uhren, und das Gerät puffert: was verspätet ankam, fiel aus einem Fenster heraus, in dem es der Sache nach lag. Am 03.09. hat das eine Fahrt gekostet - 26 von 48 GPS-Punkten und 3,0 statt 7 km. verlauf_lesen() stempelt jeden Punkt jetzt mit der Gerätezeit seines Datensatzes. Eine Stelle, an der alle Verbraucher vorbeikommen. Dazu zwei Riegel aus derselben Untersuchung: ein Rückschritt von 43 Metern ist keine Zählerrücksetzung mehr (aus 87 Metern wurden vorher 13,7 km), und das Fahrtende wird auf das letzte Lebenszeichen des Geräts geklemmt - der Nachlauf ist eine Rechnung, kein Messwert. Am echten Recorder nachgerechnet: Tacho 3,0 -> 7,0 km, GNSS 2,952 -> 6,877 km, Route 26 -> 47 Punkte, Ende 23:21:57 -> 23:17:48. Beim ersten Lauf haben zwei Fahrten eine Strecke bekommen, die vorher gar keine hatten. Nicht das Gerät war schuld: es hat lückenlos aufgezeichnet. Die zehn Minuten Funkstille waren eine offene, aber tote TCP-Sitzung (flespi-Log: 703 s, 16 Nachrichten) - kein Funkloch. Begründung und die Empfehlung fürs Gerät stehen in AGENTS.md, Abschnitt CB. Dazu die neun Paritätsbefunde, alle Richtung Panel gelöst - darunter ein unmaskierter Punkt in beiden Codebasen, der aus "vor 3 Tage" ein "vor 3 Tag" machte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
339 lines
12 KiB
Python
339 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Der Knopf "Fahrt beenden" und das Nachziehen des vorlaeufigen Endes.
|
|
|
|
Der Fall, fuer den es den Knopf gibt: das Fahrzeug steht in einer Tiefgarage
|
|
ohne Empfang. Das Geraet kann sein Zuendungs-Aus nicht melden, die Fahrt bliebe
|
|
offen, bis irgendwann gepufferte Datensaetze eintreffen. Der Knopf schliesst
|
|
sie auf das letzte Lebenszeichen - mehr weiss in dem Moment niemand.
|
|
|
|
Genau deshalb ist dieses Ende VORLAEUFIG: faehrt das Fahrzeug danach noch aus
|
|
der Garage heraus, kommt der Rest nach, und dann gehoert das Ende nachgezogen
|
|
statt verworfen. Geprueft wird beides - dass der Knopf das Richtige schliesst,
|
|
und dass die spaete Meldung die Fahrt korrigiert statt eine neue anzulegen.
|
|
|
|
Aufruf: python3 tests/fahrterkennung/test_fahrtende_knopf.py
|
|
(braucht das homeassistant-Paket, weil die Integration es importiert - also
|
|
z. B. im Test-Container)
|
|
"""
|
|
|
|
import asyncio
|
|
import datetime
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
_HIER = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(_HIER)))
|
|
|
|
from custom_components.audi_dashboard import fahrterkennung as f # noqa: E402
|
|
|
|
T0 = datetime.datetime(2026, 9, 3, 8, 0, 0, tzinfo=datetime.UTC)
|
|
LEBENSZEICHEN = datetime.datetime(2026, 9, 3, 8, 20, 0, tzinfo=datetime.UTC)
|
|
|
|
|
|
class FakeWerte:
|
|
TRIP_SENSOR = ""
|
|
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
|
MELDEZEIT_SENSOR = ""
|
|
|
|
|
|
class FakeZuordnung:
|
|
werte = FakeWerte()
|
|
|
|
|
|
class FakeAblage:
|
|
def __init__(self, fahrten=None):
|
|
self.fahrten = fahrten or []
|
|
self.aktualisiert = []
|
|
|
|
async def profil_lesen(self):
|
|
return {"einstellungen": {"fahrten_pausenzeit_min": 15}}
|
|
|
|
async def fahrten_lesen(self):
|
|
return self.fahrten
|
|
|
|
async def fahrt_anhaengen(self, fahrt):
|
|
self.fahrten.append(fahrt)
|
|
|
|
async def fahrt_aktualisieren(self, trip_id, aenderungen):
|
|
self.aktualisiert.append((trip_id, aenderungen))
|
|
for fahrt in self.fahrten:
|
|
if fahrt.get("trip_id") == trip_id:
|
|
fahrt.update(aenderungen)
|
|
return True
|
|
return False
|
|
|
|
|
|
class FakeZustand:
|
|
"""Ein Eintrag in hass.states - nur das, was hier gelesen wird."""
|
|
|
|
def __init__(self, gemeldet):
|
|
self.last_reported = gemeldet
|
|
self.last_updated = gemeldet
|
|
|
|
|
|
class FakeStates:
|
|
def __init__(self, zustand):
|
|
self._zustand = zustand
|
|
|
|
def get(self, entity_id):
|
|
return self._zustand
|
|
|
|
|
|
class FakeHass:
|
|
def __init__(self, zustand=None):
|
|
self.states = FakeStates(zustand)
|
|
|
|
|
|
class FakeKoordinator:
|
|
def __init__(self, start_ts=T0, fahrten=None):
|
|
self.fahrt_start_ts = start_ts
|
|
self.fahrt_sperre = asyncio.Lock()
|
|
self.zuordnung = FakeZuordnung()
|
|
self.ablage = FakeAblage(fahrten)
|
|
self.hass = object()
|
|
self.veroeffentlicht = 0
|
|
self.abgebrochen = 0
|
|
|
|
def warte_ende_ab_abbrechen(self):
|
|
self.abgebrochen += 1
|
|
|
|
async def fahrt_start_setzen(self, ts):
|
|
self.fahrt_start_ts = ts
|
|
|
|
async def fahrzeugstatus_veroeffentlichen(self):
|
|
pass
|
|
|
|
async def fahrten_veroeffentlichen(self):
|
|
self.veroeffentlicht += 1
|
|
|
|
|
|
def fahrt(trip_id, start, ende, vorlaeufig):
|
|
return {
|
|
"trip_id": trip_id,
|
|
"ts_start": start.isoformat(),
|
|
"ts_end": ende.isoformat(),
|
|
"duration_s": int((ende - start).total_seconds()),
|
|
"ende_vorlaeufig": vorlaeufig,
|
|
}
|
|
|
|
|
|
class Basis(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self):
|
|
self._echt = (
|
|
f._letztes_lebenszeichen,
|
|
f._geraetezeit,
|
|
f.screening.durchfuehren,
|
|
f.reifen.aktiver_satz,
|
|
)
|
|
|
|
async def lebenszeichen(k, sensor, seit):
|
|
return LEBENSZEICHEN
|
|
|
|
async def geraetezeit(k, ereigniszeit, standard):
|
|
return ereigniszeit or standard
|
|
|
|
async def durchfuehren(k):
|
|
return None
|
|
|
|
def aktiver_satz(profil):
|
|
return "sommer"
|
|
|
|
f._letztes_lebenszeichen = lebenszeichen
|
|
f._geraetezeit = geraetezeit
|
|
f.screening.durchfuehren = durchfuehren
|
|
f.reifen.aktiver_satz = aktiver_satz
|
|
|
|
async def asyncTearDown(self):
|
|
(
|
|
f._letztes_lebenszeichen,
|
|
f._geraetezeit,
|
|
f.screening.durchfuehren,
|
|
f.reifen.aktiver_satz,
|
|
) = self._echt
|
|
|
|
|
|
class Knopf(Basis):
|
|
async def test_schliesst_auf_das_letzte_lebenszeichen(self):
|
|
"""Nicht auf 'jetzt': das Fahrzeug stand still, als es still stand -
|
|
und genau dann liegen die beiden weit auseinander."""
|
|
k = FakeKoordinator()
|
|
self.assertTrue(await f.jetzt_beenden(k))
|
|
self.assertEqual(len(k.ablage.fahrten), 1)
|
|
gespeichert = k.ablage.fahrten[0]
|
|
self.assertEqual(gespeichert["ts_end"], LEBENSZEICHEN.isoformat())
|
|
self.assertTrue(gespeichert["ende_vorlaeufig"])
|
|
self.assertIsNone(k.fahrt_start_ts)
|
|
|
|
async def test_ohne_laufende_fahrt_passiert_nichts(self):
|
|
k = FakeKoordinator(start_ts=None)
|
|
self.assertFalse(await f.jetzt_beenden(k))
|
|
self.assertEqual(k.ablage.fahrten, [])
|
|
|
|
async def test_bricht_eine_wartende_ende_aufgabe_ab(self):
|
|
"""Sonst schloesse die Wartezeit die Fahrt ein zweites Mal."""
|
|
k = FakeKoordinator()
|
|
await f.jetzt_beenden(k)
|
|
self.assertEqual(k.abgebrochen, 1)
|
|
|
|
|
|
class Nachziehen(Basis):
|
|
async def _spaetes_aus(self, k, gemeldet):
|
|
await f._spaetes_aus(k, gemeldet, gemeldet)
|
|
|
|
async def test_spaeteres_aus_zieht_das_vorlaeufige_ende_nach(self):
|
|
"""Der eigentliche Zweck: nach der Tiefgarage kommt der Rest gepuffert
|
|
nach, und die Fahrt endet dort, wo sie wirklich endete."""
|
|
k = FakeKoordinator(
|
|
start_ts=None,
|
|
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, True)],
|
|
)
|
|
# Gemeldet wird das verzoegerte Zuendungs-Aus; echt ist es
|
|
# ZUENDUNG_NACHLAUF_S frueher.
|
|
echt = LEBENSZEICHEN + datetime.timedelta(minutes=12)
|
|
gemeldet = echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
|
|
await self._spaetes_aus(k, gemeldet)
|
|
|
|
self.assertEqual(len(k.ablage.aktualisiert), 1)
|
|
_, aenderungen = k.ablage.aktualisiert[0]
|
|
self.assertEqual(aenderungen["ts_end"], echt.isoformat())
|
|
self.assertEqual(aenderungen["duration_s"], int((echt - T0).total_seconds()))
|
|
self.assertFalse(aenderungen["ende_vorlaeufig"])
|
|
self.assertNotIn(
|
|
"edited_fields",
|
|
aenderungen,
|
|
"ts_end darf nicht als Handeingabe gelten - das blockierte die "
|
|
"naechste Korrektur",
|
|
)
|
|
|
|
async def test_regulaer_geschlossene_fahrt_wird_nicht_angefasst(self):
|
|
k = FakeKoordinator(
|
|
start_ts=None,
|
|
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, False)],
|
|
)
|
|
echt = LEBENSZEICHEN + datetime.timedelta(minutes=12)
|
|
await self._spaetes_aus(
|
|
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
|
|
)
|
|
self.assertEqual(k.ablage.aktualisiert, [])
|
|
|
|
async def test_frueheres_aus_verkuerzt_die_fahrt_nicht(self):
|
|
k = FakeKoordinator(
|
|
start_ts=None,
|
|
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, True)],
|
|
)
|
|
echt = LEBENSZEICHEN - datetime.timedelta(minutes=5)
|
|
await self._spaetes_aus(
|
|
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
|
|
)
|
|
self.assertEqual(k.ablage.aktualisiert, [])
|
|
|
|
async def test_zu_weit_danach_wird_nicht_korrigiert(self):
|
|
"""Jenseits des Korrekturfensters gehoert das Aus wahrscheinlich zu
|
|
einer ganz anderen Fahrt."""
|
|
k = FakeKoordinator(
|
|
start_ts=None,
|
|
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, True)],
|
|
)
|
|
echt = LEBENSZEICHEN + datetime.timedelta(seconds=f.KORREKTUR_FENSTER_S + 60)
|
|
with self.assertLogs(f._LOGGER, level="WARNING"):
|
|
await self._spaetes_aus(
|
|
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
|
|
)
|
|
self.assertEqual(k.ablage.aktualisiert, [])
|
|
|
|
async def test_keine_verlaengerung_ueber_eine_andere_fahrt_hinweg(self):
|
|
"""Faengt zwischen dem vorlaeufigen Ende und dem Aus eine andere Fahrt
|
|
an, gehoert das Aus zu ihr - zwei sich ueberlappende Fahrten waeren
|
|
schlimmer als eine zu kurze."""
|
|
alt_start = T0 - datetime.timedelta(hours=3)
|
|
k = FakeKoordinator(
|
|
start_ts=None,
|
|
fahrten=[
|
|
fahrt("t-alt", alt_start, alt_start + datetime.timedelta(minutes=10), True),
|
|
fahrt("t-neu", T0, LEBENSZEICHEN, False),
|
|
],
|
|
)
|
|
echt = LEBENSZEICHEN + datetime.timedelta(minutes=5)
|
|
await self._spaetes_aus(
|
|
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
|
|
)
|
|
self.assertEqual(
|
|
k.ablage.aktualisiert, [], "dazwischen liegt der Beginn von t-neu"
|
|
)
|
|
|
|
async def test_aeltere_vorlaeufige_fahrt_wird_gefunden(self):
|
|
"""Die Suche ist NICHT auf die juengste Fahrt beschraenkt: eine aeltere
|
|
regulaer geschlossene Fahrt darf die Korrektur nicht verdecken."""
|
|
frueher = T0 - datetime.timedelta(hours=5)
|
|
k = FakeKoordinator(
|
|
start_ts=None,
|
|
fahrten=[
|
|
fahrt("t-frueh", frueher, frueher + datetime.timedelta(minutes=20), False),
|
|
fahrt("t-hand", T0, LEBENSZEICHEN, True),
|
|
],
|
|
)
|
|
echt = LEBENSZEICHEN + datetime.timedelta(minutes=8)
|
|
await self._spaetes_aus(
|
|
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
|
|
)
|
|
self.assertEqual(len(k.ablage.aktualisiert), 1)
|
|
trip_id, aenderungen = k.ablage.aktualisiert[0]
|
|
self.assertEqual(trip_id, "t-hand")
|
|
self.assertEqual(aenderungen["ts_end"], echt.isoformat())
|
|
|
|
|
|
class Lebenszeichen(unittest.IsolatedAsyncioTestCase):
|
|
"""Woher das Fahrtende kommt, wenn der Knopf gedrueckt wird.
|
|
|
|
Am 03.09.2026 hat genau das eine falsche Fahrt erzeugt: der Knopf traf
|
|
einen Neustart, der recorder war noch nicht bereit, verlauf_lesen() kam
|
|
nach 2:47 leer zurueck - und "leer" ist von "nichts aufgezeichnet" nicht zu
|
|
unterscheiden. Das Ende wurde "jetzt": 74 Minuten statt 30.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self._echt = f.verlauf_lesen
|
|
|
|
def tearDown(self):
|
|
f.verlauf_lesen = self._echt
|
|
|
|
@staticmethod
|
|
def _k(punkte, zustand):
|
|
async def lesen(hass, sensor, start, ende, meldezeit=None):
|
|
return punkte
|
|
|
|
f.verlauf_lesen = lesen
|
|
k = FakeKoordinator()
|
|
k.hass = FakeHass(zustand)
|
|
return k
|
|
|
|
async def test_aufzeichnung_gewinnt(self):
|
|
punkt = T0 + datetime.timedelta(minutes=30)
|
|
k = self._k([(punkt, "on")], FakeZustand(T0 + datetime.timedelta(hours=2)))
|
|
self.assertEqual(await f._letztes_lebenszeichen(k, "binary_sensor.x", T0), punkt)
|
|
|
|
async def test_ohne_aufzeichnung_zaehlt_der_zustandsspeicher(self):
|
|
"""Der eigentliche Fix: nicht "jetzt", sondern wann die Entitaet
|
|
zuletzt gemeldet hat - das weiss hass.states ohne Datenbank."""
|
|
gemeldet = T0 + datetime.timedelta(minutes=30)
|
|
k = self._k([], FakeZustand(gemeldet))
|
|
self.assertEqual(await f._letztes_lebenszeichen(k, "binary_sensor.x", T0), gemeldet)
|
|
|
|
async def test_meldung_vor_dem_fahrtbeginn_zaehlt_nicht(self):
|
|
"""Sie kann nicht das Ende sein - die Fahrt begann ja spaeter."""
|
|
k = self._k([], FakeZustand(T0 - datetime.timedelta(hours=1)))
|
|
with self.assertLogs(f._LOGGER, level="WARNING"):
|
|
ende = await f._letztes_lebenszeichen(k, "binary_sensor.x", T0)
|
|
self.assertGreater(ende, T0)
|
|
|
|
async def test_ohne_jede_quelle_wird_gewarnt(self):
|
|
""""Jetzt" bleibt der letzte Ausweg - aber nicht mehr stillschweigend."""
|
|
k = self._k([], None)
|
|
with self.assertLogs(f._LOGGER, level="WARNING"):
|
|
await f._letztes_lebenszeichen(k, "binary_sensor.x", T0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|