diff --git a/AGENTS.md b/AGENTS.md index 4c81edc..cfcbc16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11117,3 +11117,116 @@ Standort-Blatt sei fehlerhaft, weil "Route" und "Teilen" hinter der Tab-Leiste liegen. Das war falsch - die 96-px-Vorschau ist Absicht, aufgezogen sitzt das Blatt sauber 30 px ueber der Leiste. Mein Klick-Test war der Fehler, nicht die App: das Blatt hoert auf Zeigerereignisse, nicht auf `click`. + +## CQ. Zwei Fahrten wurden zu einer, weil die Pausenregel an der Wanduhr hing (2026.9.4.24) + +Gemeldet als "erneut Fehler/Einschlafen unterwegs". Die Untersuchung hat drei +meiner eigenen Erklaerungen widerlegt, bevor die richtige uebrig blieb - die +falschen stehen hier mit, weil sie der lehrreiche Teil sind. + +### Was NICHT die Ursache war + +* **"Der Dongle schlaeft unterwegs ein."** Nachgerechnet: das Geraet war nach + dem Abstellen noch rund zwoelf Minuten wach (Schlaf-Timeout `103` = 15 Min. + ab Zuendungs-Aus) und hatte einen Datensatz hoher Prioritaet in der Hand. + Es hat in dieser Zeit keine Verbindung aufgebaut. Nicht der Schlaf. +* **"`close_code 17` ist neu, das kommt vom frisch gesetzten Ping."** Ueber + alle 4190 Protokolleintraege gezaehlt: erstmals **11.08.2026**, 23 Mal. + Nichts Neues. +* **"Der Sendetakt im Stand steht auf 24 h."** Das stand in flespis `current` - + und `current` ist, was das Geraet ZULETZT GEMELDET hat. Die aufgespielte + Konfiguration hat dort 0. Der Eigentuemer hat es richtiggestellt: **"die + gelesenen werte sind veraltet"**. + +**Merkposten:** `flespi.INTERESSANT` vergleicht `trip_scenario.ign_off_timeout` +gegen unsere Konstante `NACHLAUF_S`. Es las 900, unsere Konstante ist 900, also +meldete der Waechter `abweichung: false`. Das Geraet hat dort **0** - das +Trip-Szenario ist abgeschaltet. Der Waechter, der genau dieses Auseinanderlaufen +melden soll, ist auf einen veralteten Wert hereingefallen, weil er nie fragt, +wie alt `current` ist. **Ein stiller Gleichstand mit einem veralteten Wert ist +schlimmer als gar kein Vergleich.** (Noch offen.) + +### Was die Ursache war - gemessen, nicht erschlossen + +Das Geraet war von 18:25 bis 23:36 ohne Verbindung (fest im geparkten Auto, +niemand hat etwas angefasst). Als es sich meldete, kamen zwei Zuendungs- +ereignisse in **derselben Sekunde** bei Home Assistant an: + +| Ankunft | Geraetezeit | Rueckstand | +|---|---|---| +| 23:36:50 | **18:23:32** | 5 h 13 - das Aus der ersten Fahrt | +| 23:36:50 | **23:35:43** | 67 s - das Ein der zweiten | + +Die Pausenregel fragt: "kommt die Zuendung innerhalb von 15 Minuten zurueck?" +Sie mass das an der **Ankunft**. Nach Ankunft: 0 Sekunden, also dieselbe Fahrt. +Nach **Geraetezeit**: 5 Stunden 12, also zwei Fahrten. + +Ergebnis: **ein Eintrag von 18:10 bis 23:42, 5 h 33, ohne Strecke** - und die +Fahrt um 23:30 gab es gar nicht. Sie steckte darin. + +Dazu kamen zwei Stellen im Code: + +1. `zuendung_geaendert()` rief **bedingungslos** `warte_ende_ab_abbrechen()`. + Das "an" loeschte damit das wartende Ende des "aus" derselben Sekunde. +2. Der Fall "**an, waehrend eine Fahrt laeuft**" fiel durch alle drei Zweige + von `_signalwechsel()` - wortlos. + +Zu 2. steht im Code seit dem 02.09. ein langer Kommentar: damals hat genau das +"eine ganze Fahrt gekostet". Eingebaut wurde daraufhin eine **Sperre**, damit +die Ereignisse sich nicht ueberholen. Die Reihenfolge stimmt seither - **der +Ausgang nicht.** Die Sperre hat das Symptom behandelt. + +### Die Korrektur + +`_zuendung_kehrt_zurueck()` entscheidet jetzt in **Geraetezeit**: + +* Der Koordinator fuehrt mit, welches Ende gerade seine Pausenzeit absitzt + (`ende_wartet`, Beginn und Ende beide in Geraetezeit). Ohne diese Angabe + konnte der Beobachter nur abbrechen. +* Liegt zwischen dem echten Ende und dem neuen "an" **mehr** als die + Pausenzeit, wird die alte Fahrt auf ihr bereits berechnetes Ende geschlossen + und eine neue begonnen. Sonst bleibt es wie bisher dieselbe Fahrt. +* Gemessen wird ab dem **echten Ende** (Zuendungs-Aus minus Nachlauf), nicht ab + dem Signal - sonst waere die Grenze um 180 s verschoben. +* Ohne wartendes Ende passiert weiterhin nichts. Fehlt das "aus" ganz, waere + ein Schnitt auf Verdacht schlimmer als eine zu lange Fahrt. + +Der bedingungslose Abbruch ist weg; abgebrochen wird jetzt in jedem Zweig +einzeln, dort wo die Geraetezeit bekannt ist. + +**Das behebt die Verspaetung nicht - es macht sie harmlos.** Ein fuenf Stunden +zu spaet gelieferter Stapel ergibt jetzt zwei richtige Fahrten statt einer +falschen. + +### Die Tests mussten zwei Dinge trennen + +`PAUSE = 0.15` war bisher beides zugleich: das Fenster der Pausenregel und die +Wanduhrzeit, die der Test absitzt. Seit die Regel in Geraetezeit rechnet, geht +das nicht mehr - mit 0,15 s als Fenster haette der Test etwas anderes geprueft +als der Betrieb tut. Jetzt: `PAUSE_S = 900` fuer die Entscheidung, +`WARTEN = 0.15` fuer das Warten (ueber ein kurzes `_nach_pause_beenden`). + +### Verifiziert + +**18 Tests** in `test_zuendungspause.py` (von 14), dazu 5 + 13 in den beiden +anderen Dateien - alle gruen. `py_compile` sauber. `audi_ha_test` auf +`2026.9.4.24`, **0 Tracebacks**. + +**Gegenprobe am alten Stand gelaufen** (der neue Zweig durch das alte +`warte_ende_ab_abbrechen()` ersetzt): **2 der 4 neuen Tests scheitern**, mit der +Korrektur laufen alle 18. Die beiden anderen neuen Tests sind Gegenproben in +die andere Richtung (kurze Rueckkehr, fehlendes "aus") und gruen in beiden +Staenden - so sollen sie sein. + +### Was offen bleibt + +* Der flespi-Waechter meldet Gleichstand gegen veraltete Werte (oben). +* `NACHLAUF_S = 900` ist als "Trip: Ignition OFF Timeout" beschriftet; das + Geraet hat dort 0. Die 900, die es wirklich gibt, sind der Schlaf-Timeout - + ein anderer Parameter. +* **Am Dongle wurde nichts geaendert.** Der Eigentuemer hat am Morgen des + 04.09. `1003` (Network Ping) von 0 auf 60 und `1004` (Ack Type) auf 1 + gesetzt; zugestellt wurden sie um 18:08, mitten in der ersten Fahrt. Bei der + zweiten Fahrt hielt die Sitzung 18 Minuten durch und schloss normal, der + Rueckstand lag durchgehend bei 1-3 Sekunden. **Das wirkt** - die + Zustellluecke selbst ist damit aber noch nicht als behoben bewiesen. diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index 8713c8f..510a150 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -363,9 +363,12 @@ async def zuendung_geaendert( if alt is None: return - # Eine noch wartende Ende-Bestätigung aus einer vorherigen Änderung - # abbrechen - das ist der Mechanismus hinter der Pausenregel. - k.warte_ende_ab_abbrechen() + # Frueher stand hier ein bedingungsloses warte_ende_ab_abbrechen(). Das + # war der Fehler vom 04.09.2026: ein gepuffertes "an" loeschte damit das + # wartende Ende eines gepufferten "aus", das in DERSELBEN Sekunde + # angekommen war - obwohl zwischen beiden in Geraetezeit fuenf Stunden + # lagen. Abgebrochen wird jetzt in _signalwechsel(), wo die Geraetezeit + # bekannt ist und die Pausenregel wirklich rechnen kann. # Von hier an nur einer zur Zeit. Home Assistant startet für jeden # Zustandswechsel eine eigene Aufgabe, und dieser Rumpf wartet mehrfach: @@ -405,6 +408,7 @@ async def _signalwechsel( jetzt = datetime.datetime.now(datetime.UTC) if an_jetzt and not fahrt_laeuft: + k.warte_ende_ab_abbrechen() beginn = await _geraetezeit(k, ereigniszeit, jetzt) await k.fahrt_start_setzen(beginn) _LOGGER.info( @@ -418,6 +422,8 @@ async def _signalwechsel( return if not an_jetzt and fahrt_laeuft: + # Ein neues "aus" ersetzt ein aelteres, das noch wartet. + k.warte_ende_ab_abbrechen() # Ob hier noch gewartet wird, entscheidet _pausenzeit_s(): liegt ein # Trip-Signal an, bringt das Geraet seine Pausentoleranz selbst mit, # und eine zweite obendrauf haette zwei getrennte Fahrten verschmolzen. @@ -444,17 +450,86 @@ async def _signalwechsel( "Zuendung aus, Fahrtende waere %s - warte %s s, ob sie zurueckkommt", ende.isoformat(), pause_s, ) - k.warte_ende_ab(_nach_pause_beenden(k, k.fahrt_start_ts, ende, pause_s)) + k.warte_ende_ab( + _nach_pause_beenden(k, k.fahrt_start_ts, ende, pause_s), + (k.fahrt_start_ts, ende), + ) await k.fahrzeugstatus_veroeffentlichen() return await fahrt_beenden(k, k.fahrt_start_ts, ende) + if an_jetzt and fahrt_laeuft: + await _zuendung_kehrt_zurueck(k, ereigniszeit, jetzt) + if not an_jetzt and not fahrt_laeuft: + k.warte_ende_ab_abbrechen() await _spaetes_aus(k, ereigniszeit, jetzt) await k.fahrzeugstatus_veroeffentlichen() +async def _zuendung_kehrt_zurueck( + k: Koordinator, ereigniszeit: datetime.datetime | None, jetzt: datetime.datetime +) -> None: + """Die Zuendung geht an, waehrend bei uns noch eine Fahrt laeuft. + + ZWEI FAELLE, UND SIE UNTERSCHEIDEN SICH NUR IN DER GERAETEZEIT + ------------------------------------------------------------- + (a) Der Fahrer war kurz weg - tanken, Brot holen. Dann gehoert das hier + noch zur selben Fahrt, und das wartende Ende faellt weg. + (b) Die Fahrt war laengst zu Ende, aber ihr "aus" kam verspaetet - und das + "an" der NAECHSTEN Fahrt gleich hinterher. Dann sind es zwei Fahrten. + + Am 04.09.2026 gemessen: beide Ereignisse kamen in DERSELBEN Sekunde bei + Home Assistant an (23:36:50), ihre Geraetezeiten lagen aber 5 Stunden 12 + auseinander - 18:23:32 und 23:35:43. Nach der Ankunft gerechnet ist das + Fall (a), nach der Geraetezeit Fall (b). Gerechnet wurde nach der Ankunft, + und deshalb hat eine Fahrt von 18:10 die naechste um 23:30 verschluckt: + ein Eintrag ueber 5 Stunden 33, ohne Strecke, und die zweite Fahrt gab es + gar nicht. + + Entschieden wird deshalb in Geraetezeit - der Zeit, zu der das Fahrzeug + wirklich stand und wirklich wieder losfuhr. + + OHNE WARTENDES ENDE passiert weiterhin nichts. Dann gab es kein "aus", auf + das sich ein "an" beziehen koennte: entweder ist die Zuendung nie aus + gewesen (dann ist das hier eine Wiederholung), oder das "aus" fehlt ganz - + und eine Fahrt auf Verdacht zu zerschneiden waere schlimmer, als sie zu + lang stehen zu lassen. + """ + wartet = k.ende_wartet + if wartet is None: + return + start_ts, ende_ts = wartet + if k.fahrt_start_ts != start_ts: + # Zwischenzeitlich hat jemand anders geschlossen - dann gehoert das + # wartende Ende nicht mehr zu dieser Fahrt. + k.warte_ende_ab_abbrechen() + return + + beginn = await _geraetezeit(k, ereigniszeit, jetzt) + pause_s = await _pausenzeit_s(k) + stand_s = (beginn - ende_ts).total_seconds() + k.warte_ende_ab_abbrechen() + + if stand_s <= pause_s: + _LOGGER.info( + "Zuendung nach %.0f s wieder an - innerhalb der Pause von %s s, " + "dieselbe Fahrt", + stand_s, pause_s, + ) + return + + _LOGGER.info( + "Zuendung nach %.0f s wieder an - laenger als die Pause von %s s. " + "Fahrt seit %s wird auf %s geschlossen, neue Fahrt ab %s", + stand_s, pause_s, start_ts.isoformat(), ende_ts.isoformat(), + beginn.isoformat(), + ) + await fahrt_beenden(k, start_ts, ende_ts) + await k.fahrt_start_setzen(beginn) + + async def _pausenzeit_s(k: Koordinator) -> int: """Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden. diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 1e62b4e..32b3c06 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.4.23","sha256":"7cc61f10ec846824ae34bc033115c795ab9026e7d56a72cdf9ec4cb6032e8559","bytes":324157,"gebaut":"2026-09-04T16:07:49Z"} \ No newline at end of file +{"version":"2026.9.4.24","sha256":"2d303b02b3d5ba9d2432856a528b32ed30c657a4ff43e1f39744d4f5c9f8c04a","bytes":324162,"gebaut":"2026-09-04T22:39:43Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 96937d1..440ad1c 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index 96c3682..cd12555 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -168,6 +168,15 @@ class Koordinator: self._takte: list[CALLBACK_TYPE] = [] self._beobachter: list[CALLBACK_TYPE] = [] self._ende_aufgabe: asyncio.Task | None = None + # Welches Ende gerade seine Pausenzeit absitzt: (Fahrtbeginn, Ende), + # beides in GERAETEZEIT. None = es wartet keines. + # + # Warum das hier steht und nicht nur in der Aufgabe: kommt die Zuendung + # zurueck, muss der Beobachter entscheiden, ob das noch dieselbe Fahrt + # ist - und dafuer braucht er das wartende Ende. Ohne diese Angabe + # konnte er nur abbrechen, und genau daran sind am 04.09.2026 zwei + # Fahrten zu einer verschmolzen (siehe _signalwechsel). + self.ende_wartet: tuple[datetime.datetime, datetime.datetime] | None = None # ------------------------------------------------------------ Lebenszyklus @@ -517,8 +526,13 @@ class Koordinator: self.tiefststand_liter = wert await self._laufzeit_sichern() - def warte_ende_ab(self, koroutine: Coroutine[Any, Any, None]) -> None: + def warte_ende_ab( + self, + koroutine: Coroutine[Any, Any, None], + wartet: tuple[datetime.datetime, datetime.datetime] | None = None, + ) -> None: self.warte_ende_ab_abbrechen() + self.ende_wartet = wartet self._ende_aufgabe = self.entry.async_create_background_task( self.hass, koroutine, "audi_dashboard_fahrtende" ) @@ -527,6 +541,7 @@ class Koordinator: if self._ende_aufgabe is not None and not self._ende_aufgabe.done(): self._ende_aufgabe.cancel() self._ende_aufgabe = None + self.ende_wartet = None # ---------------------------------------------------------- Veröffentlichen diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 48ba1a0..af94af3 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.4.23", + "version": "2026.9.4.24", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [ diff --git a/tests/fahrterkennung/test_signalwechsel.py b/tests/fahrterkennung/test_signalwechsel.py index 26fd473..eb92975 100644 --- a/tests/fahrterkennung/test_signalwechsel.py +++ b/tests/fahrterkennung/test_signalwechsel.py @@ -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 diff --git a/tests/fahrterkennung/test_zuendungspause.py b/tests/fahrterkennung/test_zuendungspause.py index 998ff7f..d55bed2 100644 --- a/tests/fahrterkennung/test_zuendungspause.py +++ b/tests/fahrterkennung/test_zuendungspause.py @@ -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."""