242312441d
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.
150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Regressionstest: zwei Wechsel des Fahrtsignals in derselben Sekunde.
|
|
|
|
Der Fall, der ihn nötig gemacht hat (02.09.2026, im Verlauf nachgemessen):
|
|
das Gerät hatte gepuffert, deshalb kamen "Fahrt zu Ende" und "Fahrt begonnen"
|
|
gemeinsam um 21:22:12 bei Home Assistant an. Home Assistant startet für jeden
|
|
Zustandswechsel eine eigene Aufgabe, und der Beende-Zweig wartet mehrfach
|
|
(Meldezeit, Verlauf, Schreiben, Screening). Das "on" überholte ihn, sah
|
|
fahrt_start_ts noch gesetzt und fiel durch beide Zweige - die anschliessende
|
|
36-Minuten-Fahrt wurde live nie erfasst und tauchte erst über den Rückblick auf.
|
|
|
|
Geprüft wird deshalb genau die Verschränkung, nicht die Fahrterkennung als
|
|
Ganzes: die langsamen Teile (_geraetezeit, _echtes_ende, fahrt_beenden) sind
|
|
ersetzt, übrig bleibt die Frage, ob der zweite Wechsel den ersten abwarten muss.
|
|
|
|
Aufruf: python3 tests/fahrterkennung/test_signalwechsel.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, 2, 16, 32, 18, tzinfo=datetime.UTC)
|
|
T_WECHSEL = datetime.datetime(2026, 9, 2, 21, 22, 12, tzinfo=datetime.UTC)
|
|
|
|
|
|
class FakeWerte:
|
|
TRIP_SENSOR = "binary_sensor.trip"
|
|
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
|
|
|
|
|
|
class FakeZuordnung:
|
|
werte = FakeWerte()
|
|
|
|
|
|
class FakeKoordinator:
|
|
"""Nur das, was der Signalpfad anfasst."""
|
|
|
|
def __init__(self, start_ts):
|
|
self.fahrt_start_ts = start_ts
|
|
self.fahrt_sperre = asyncio.Lock()
|
|
self.beendet = []
|
|
self.veroeffentlicht = 0
|
|
# Dieser Test beschreibt die Welt MIT Trip-Signal: dort bringt das
|
|
# Geraet seine Pausentoleranz mit, _pausenzeit_s() gibt 0 zurueck und
|
|
# es wird sofort geschlossen. Gegenstand ist hier die Sperre, nicht
|
|
# 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
|
|
|
|
async def fahrt_start_setzen(self, ts):
|
|
self.fahrt_start_ts = ts
|
|
|
|
async def fahrzeugstatus_veroeffentlichen(self):
|
|
self.veroeffentlicht += 1
|
|
|
|
|
|
class SignalwechselTest(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self):
|
|
self._echt = (f._geraetezeit, f._echtes_ende, f.fahrt_beenden)
|
|
|
|
async def geraetezeit(k, ereigniszeit, standard):
|
|
# Der eigentliche Punkt: dieser Schritt wartet wirklich (in echt
|
|
# bis zu MELDEZEIT_FRIST_S = 2 s auf die Meldezeit des Geräts).
|
|
await asyncio.sleep(0.05)
|
|
return ereigniszeit or standard
|
|
|
|
async def echtes_ende(k, start, signal_ende):
|
|
return signal_ende
|
|
|
|
async def beenden(k, start_ts, ende_ts):
|
|
await asyncio.sleep(0.05) # Schreiben und Screening brauchen Zeit
|
|
k.beendet.append((start_ts, ende_ts))
|
|
await k.fahrt_start_setzen(None)
|
|
|
|
f._geraetezeit, f._echtes_ende, f.fahrt_beenden = (
|
|
geraetezeit, echtes_ende, beenden,
|
|
)
|
|
|
|
async def asyncTearDown(self):
|
|
f._geraetezeit, f._echtes_ende, f.fahrt_beenden = self._echt
|
|
|
|
async def test_off_und_on_in_derselben_sekunde(self):
|
|
"""Der genaue Fall vom 02.09.2026: die neue Fahrt darf nicht verloren
|
|
gehen, während die alte noch geschlossen wird."""
|
|
k = FakeKoordinator(T0)
|
|
|
|
aus = asyncio.create_task(f.zuendung_geaendert(k, "off", "on", T_WECHSEL))
|
|
# Dem "off" gerade so viel Vorlauf, dass es die Sperre hält und im
|
|
# ersten await steht - genau die Lage, in der das "on" eintrifft.
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
an = asyncio.create_task(f.zuendung_geaendert(k, "on", "off", T_WECHSEL))
|
|
await asyncio.gather(aus, an)
|
|
|
|
self.assertEqual(len(k.beendet), 1, "die alte Fahrt muss genau einmal enden")
|
|
self.assertEqual(k.beendet[0][0], T0)
|
|
self.assertIsNotNone(
|
|
k.fahrt_start_ts,
|
|
"nach dem Paar muss die neue Fahrt laufen - sonst ist sie verloren",
|
|
)
|
|
self.assertEqual(k.fahrt_start_ts, T_WECHSEL)
|
|
|
|
async def test_nacheinander_unveraendert(self):
|
|
"""Der gewöhnliche Ablauf darf sich durch die Sperre nicht ändern."""
|
|
k = FakeKoordinator(T0)
|
|
await f.zuendung_geaendert(k, "off", "on", T_WECHSEL)
|
|
self.assertEqual(len(k.beendet), 1)
|
|
self.assertIsNone(k.fahrt_start_ts)
|
|
|
|
await f.zuendung_geaendert(k, "on", "off", T_WECHSEL)
|
|
self.assertEqual(k.fahrt_start_ts, T_WECHSEL)
|
|
|
|
async def test_zweites_on_startet_keine_zweite_fahrt(self):
|
|
k = FakeKoordinator(T0)
|
|
await f.zuendung_geaendert(k, "on", "off", T_WECHSEL)
|
|
self.assertEqual(k.fahrt_start_ts, T0, "die laufende Fahrt bleibt stehen")
|
|
self.assertEqual(k.beendet, [])
|
|
|
|
async def test_ohne_vorzustand_passiert_nichts(self):
|
|
"""Registrierung beim Start ist kein Wechsel - sonst Phantomfahrten."""
|
|
k = FakeKoordinator(None)
|
|
await f.zuendung_geaendert(k, "on", None, T_WECHSEL)
|
|
self.assertIsNone(k.fahrt_start_ts)
|
|
|
|
async def test_funkstille_wird_ignoriert(self):
|
|
k = FakeKoordinator(T0)
|
|
await f.zuendung_geaendert(k, "unavailable", "on", T_WECHSEL)
|
|
self.assertEqual(k.fahrt_start_ts, T0)
|
|
self.assertEqual(k.beendet, [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|