Pausenregel rechnet in Geraetezeit - zwei Fahrten bleiben zwei

Am 04.09.2026 war der Dongle von 18:25 bis 23:36 ohne Verbindung. Als er
sich meldete, kamen das Zuendungs-Aus der ersten Fahrt und das Ein der
zweiten in DERSELBEN Sekunde bei Home Assistant an - ihre Geraetezeiten
lagen 5 Stunden 12 auseinander (18:23:32 und 23:35:43).

Die Pausenregel mass an der Ankunft: 0 Sekunden, also dieselbe Fahrt.
Ergebnis war ein Eintrag von 18:10 bis 23:42 ueber 5 h 33 ohne Strecke,
und die Fahrt um 23:30 gab es gar nicht.

Zwei Stellen im Code trugen dazu bei:
- zuendung_geaendert() rief bedingungslos warte_ende_ab_abbrechen(); das
  "an" loeschte damit das wartende Ende des "aus" derselben Sekunde.
- Der Fall "an, waehrend eine Fahrt laeuft" fiel wortlos durch alle drei
  Zweige von _signalwechsel(). Dazu steht seit dem 02.09. ein Kommentar im
  Code - damals wurde eine Sperre eingebaut, die die Reihenfolge richtig
  stellt, aber nicht den Ausgang.

Neu entscheidet _zuendung_kehrt_zurueck() in Geraetezeit. Der Koordinator
fuehrt mit, welches Ende gerade seine Pausenzeit absitzt (ende_wartet).
Liegt zwischen dem echten Ende und dem neuen "an" mehr als die Pausenzeit,
wird die alte Fahrt auf ihr berechnetes Ende geschlossen und eine neue
begonnen. Gemessen wird ab dem echten Ende, nicht ab dem Signal. Ohne
wartendes Ende passiert nichts - ein Schnitt auf Verdacht waere schlimmer
als eine zu lange Fahrt.

Das behebt die Verspaetung nicht, es macht sie harmlos.

Die Tests mussten zwei Dinge trennen, die vorher eine Zahl waren: PAUSE_S
(900, das Fenster der Regel) und WARTEN (0,15 s, die Wanduhrzeit des Tests).

18 Tests in test_zuendungspause.py (von 14), py_compile sauber,
audi_ha_test auf 2026.9.4.24 ohne Traceback. Gegenprobe am alten Stand:
2 der 4 neuen Tests scheitern dort, mit der Korrektur laufen alle 18.
This commit is contained in:
2026-09-05 00:40:59 +02:00
parent b0ec8b4982
commit 242312441d
8 changed files with 337 additions and 15 deletions
@@ -56,6 +56,10 @@ class FakeKoordinator:
# die Wartezeit - die hat test_zuendungspause.py.
self.zuordnung = FakeZuordnung()
# Seit die Pausenregel in Geraetezeit rechnet, liest _signalwechsel() das
# wartende Ende. Hier wartet nie eines - None heisst genau das.
ende_wartet = None
def warte_ende_ab_abbrechen(self):
pass
+123 -8
View File
@@ -33,9 +33,16 @@ from custom_components.audi_dashboard import fahrterkennung as f # noqa: E402
T0 = datetime.datetime(2026, 9, 2, 16, 32, 18, tzinfo=datetime.UTC)
AUS = datetime.datetime(2026, 9, 2, 16, 54, 21, tzinfo=datetime.UTC)
# So kurz, dass der Test schnell bleibt - die Laenge ist hier nicht der Punkt,
# sondern ob sie abgebrochen wird.
PAUSE = 0.15
# Zwei verschiedene Dinge, seit die Pausenregel in GERAETEZEIT rechnet:
#
# PAUSE_S ist das Fenster, in dem eine zurueckkehrende Zuendung noch dieselbe
# Fahrt ist - gemessen an den Zeitstempeln des Geraets. Es muss realistisch
# sein, sonst prueft der Test etwas anderes als der Betrieb tut.
#
# WARTEN ist die Wanduhrzeit, die der Test tatsaechlich absitzt. Sie darf kurz
# sein; ob abgebrochen wird, haengt nicht an ihrer Laenge.
PAUSE_S = 900
WARTEN = 0.15
class FakeWerte:
@@ -72,15 +79,18 @@ class FakeKoordinator:
self.ablage = FakeAblage(minuten, fahrten)
self.beendet = []
self._aufgabe = None
self.ende_wartet = None
def warte_ende_ab(self, koroutine):
def warte_ende_ab(self, koroutine, wartet=None):
self.warte_ende_ab_abbrechen()
self.ende_wartet = wartet
self._aufgabe = asyncio.ensure_future(koroutine)
def warte_ende_ab_abbrechen(self):
if self._aufgabe is not None and not self._aufgabe.done():
self._aufgabe.cancel()
self._aufgabe = None
self.ende_wartet = None
async def fahrt_start_setzen(self, ts):
self.fahrt_start_ts = ts
@@ -101,7 +111,7 @@ class FakeKoordinator:
class Basis(unittest.IsolatedAsyncioTestCase):
def _pause_kurz(self):
async def pausenzeit(k):
return 0 if k.zuordnung.werte.TRIP_SENSOR else PAUSE
return 0 if k.zuordnung.werte.TRIP_SENSOR else PAUSE_S
return pausenzeit
@@ -122,15 +132,24 @@ class Basis(unittest.IsolatedAsyncioTestCase):
# ohne echtes hass-Objekt ist sie hier nur im Weg.
return None
async def nach_pause(k, start_ts, ende_ts, pause_s):
# Wie das Original, nur mit kurzer Wanduhrzeit - die echte
# Pausenzeit steckt weiterhin in der Entscheidung, nicht im Warten.
await asyncio.sleep(WARTEN)
await asyncio.shield(f._jetzt_schliessen(k, start_ts, ende_ts))
f._geraetezeit = geraetezeit
f.fahrt_beenden = beenden
f._pausenzeit_s = self._pause_kurz()
f._nachlauf_gegenpruefen = gegenpruefen
self._echt_nach_pause = f._nach_pause_beenden
f._nach_pause_beenden = nach_pause
async def asyncTearDown(self):
(
f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen,
) = self._echt
f._nach_pause_beenden = self._echt_nach_pause
class Wartezeit(Basis):
@@ -148,9 +167,9 @@ class Wartezeit(Basis):
"""Der eigentliche Zweck der Wartezeit."""
k = FakeKoordinator()
await f.zuendung_geaendert(k, "off", "on", AUS)
await asyncio.sleep(PAUSE / 3)
await asyncio.sleep(WARTEN / 3)
await f.zuendung_geaendert(k, "on", "off", AUS + datetime.timedelta(seconds=20))
await asyncio.sleep(PAUSE * 2)
await asyncio.sleep(WARTEN * 2)
self.assertEqual(k.beendet, [], "eine zurueckgekehrte Zuendung beendet nichts")
self.assertEqual(k.fahrt_start_ts, T0, "es bleibt dieselbe Fahrt")
@@ -161,7 +180,7 @@ class Wartezeit(Basis):
t = AUS
for _ in range(4):
await f.zuendung_geaendert(k, "off", "on", t)
await asyncio.sleep(PAUSE / 5)
await asyncio.sleep(WARTEN / 5)
t += datetime.timedelta(seconds=45)
await f.zuendung_geaendert(k, "on", "off", t)
t += datetime.timedelta(seconds=200)
@@ -222,6 +241,102 @@ class SpaetesAus(Basis):
)
class GepufferterStapel(Basis):
"""Der Fehler vom 04.09.2026: zwei Fahrten wurden zu einer.
Das Geraet war fuenf Stunden ohne Verbindung. Als es sich meldete, kamen
das Zuendungs-Aus der ersten Fahrt und das Zuendungs-Ein der zweiten in
DERSELBEN Sekunde bei Home Assistant an (23:36:50). Ihre Geraetezeiten
lagen 5 Stunden 12 auseinander: 18:23:32 und 23:35:43.
Nach der Ankunft gerechnet sieht das aus wie eine Zuendung, die sofort
zurueckkommt - also dieselbe Fahrt. Genau so wurde gerechnet, und dabei ist
aus zwei Fahrten eine geworden: 18:10 bis 23:42, 5 Stunden 33, ohne
Strecke. Die zweite Fahrt gab es gar nicht.
Die Ankunftszeit spielt hier bewusst KEINE Rolle - beide Aufrufe folgen
unmittelbar aufeinander, so wie sie es an dem Abend taten.
"""
async def test_zwei_fahrten_bleiben_zwei(self):
beginn1 = datetime.datetime(2026, 9, 4, 16, 10, 27, tzinfo=datetime.UTC)
aus1 = datetime.datetime(2026, 9, 4, 18, 23, 32, tzinfo=datetime.UTC)
an2 = datetime.datetime(2026, 9, 4, 21, 35, 43, tzinfo=datetime.UTC)
k = FakeKoordinator(start_ts=beginn1)
await f.zuendung_geaendert(k, "off", "on", aus1)
await f.zuendung_geaendert(k, "on", "off", an2)
self.assertEqual(
len(k.beendet), 1, "die erste Fahrt muss geschlossen worden sein"
)
start, ende = k.beendet[0]
self.assertEqual(start, beginn1)
self.assertEqual(
ende, aus1 - datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S),
"geschlossen wird auf das Zuendungs-Aus minus Nachlauf",
)
self.assertEqual(
k.fahrt_start_ts, an2, "und die zweite Fahrt beginnt mit ihrem eigenen an"
)
async def test_kurze_rueckkehr_bleibt_eine_fahrt(self):
"""Die Gegenprobe - sonst wuerde jeder Tankstopp die Fahrt zerreissen.
Derselbe Ablauf, nur liegen die Geraetezeiten acht Minuten
auseinander statt fuenf Stunden.
"""
beginn = datetime.datetime(2026, 9, 4, 16, 10, 27, tzinfo=datetime.UTC)
aus = datetime.datetime(2026, 9, 4, 18, 23, 32, tzinfo=datetime.UTC)
an = aus + datetime.timedelta(minutes=8)
k = FakeKoordinator(start_ts=beginn)
await f.zuendung_geaendert(k, "off", "on", aus)
await f.zuendung_geaendert(k, "on", "off", an)
self.assertEqual(k.beendet, [], "innerhalb der Pause wird nichts geschlossen")
self.assertEqual(k.fahrt_start_ts, beginn, "es bleibt dieselbe Fahrt")
async def test_genau_an_der_grenze_bleibt_es_eine_fahrt(self):
"""Die Pausenzeit gehoert noch zur Fahrt, nicht schon zur naechsten.
Gemessen wird ab dem ECHTEN Ende (Zuendungs-Aus minus Nachlauf), nicht
ab dem Zuendungssignal - sonst waere die Grenze um 180 s verschoben.
"""
beginn = datetime.datetime(2026, 9, 4, 16, 10, 27, tzinfo=datetime.UTC)
aus = datetime.datetime(2026, 9, 4, 18, 23, 32, tzinfo=datetime.UTC)
echtes_ende = aus - datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
k = FakeKoordinator(start_ts=beginn)
await f.zuendung_geaendert(k, "off", "on", aus)
await f.zuendung_geaendert(
k, "on", "off", echtes_ende + datetime.timedelta(seconds=PAUSE_S)
)
self.assertEqual(k.beendet, [], "genau auf der Grenze noch dieselbe Fahrt")
k2 = FakeKoordinator(start_ts=beginn)
await f.zuendung_geaendert(k2, "off", "on", aus)
await f.zuendung_geaendert(
k2, "on", "off", echtes_ende + datetime.timedelta(seconds=PAUSE_S + 1)
)
self.assertEqual(len(k2.beendet), 1, "eine Sekunde darueber sind es zwei")
async def test_ohne_wartendes_ende_wird_nichts_zerschnitten(self):
"""Ein "an" ohne vorheriges "aus" darf keine Fahrt beenden.
Fehlt das Aus ganz - weil es nie ankam -, waere ein Schnitt auf
Verdacht schlimmer als eine zu lange Fahrt. Dann passiert nichts,
wie bisher.
"""
beginn = datetime.datetime(2026, 9, 4, 16, 10, 27, tzinfo=datetime.UTC)
k = FakeKoordinator(start_ts=beginn)
await f.zuendung_geaendert(
k, "on", "off", beginn + datetime.timedelta(hours=5)
)
self.assertEqual(k.beendet, [])
self.assertEqual(k.fahrt_start_ts, beginn)
class Einstellung(unittest.IsolatedAsyncioTestCase):
"""_pausenzeit_s selbst - hier ungepatcht."""