diff --git a/AGENTS.md b/AGENTS.md index cfcbc16..db0409d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11230,3 +11230,89 @@ Staenden - so sollen sie sein. 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. + +### Nachtrag: eine Neuschreibung ist kein Wechsel (2026.9.4.25) + +Beim Nachgehen der Frage des Eigentuemers - *"warum kommt bei laufendem Motor +ueberhaupt ein Ignition-Signal?"* - kam heraus: **es kommt keines.** + +Ueber die Zustandszeilen der Zuendung am 04.09.2026 gezaehlt, getrennt nach +echtem Wertwechsel und blosser Neuschreibung: + +| | | +|---|---| +| Zeilen gesamt | 22 | +| **echte Wechsel** | **6** | +| Neuschreibungen desselben Werts | 16 | + +Und die sechs Wechsel liegen alle ausserhalb der Fahrten: drei beim Anstecken +(18:10:27 bis :30, drei Sekunden Einschwingen), zwei beim gepufferten Stapel +(23:36:50) und einer am Ziel (23:46:04). **Waehrend beider Fahrten kein +einziger.** Das Signal ist unterwegs stabil; das Flackern gehoert zum +Anstecken und entfaellt, sobald der Dongle im Fahrzeug bleibt. + +**Der Beobachter unterscheidet das aber nicht.** `_fahrtsignal_geaendert()` +reicht `neu` und `alt` ungefiltert weiter, jede Neuschreibung laeuft durch die +volle Fahrterkennung. Faellt eine Neuschreibung von "aus" in die laufende +Wartezeit, brach Zweig 2 die wartende Bestaetigung ab und begann die 15 +Minuten von vorn - bei genuegend vielen nie ein Ende. + +**Am 04.09. ist es nicht passiert** (die Neuschreibungen lagen 19,7 Minuten +nach dem echten "aus", also hinter dem Fenster). Das Loch war trotzdem da. + +`_signalwechsel()` bekommt jetzt mit, ob es ein echter Wechsel war. Eine +Neuschreibung, waehrend fuer DIESE Fahrt schon eine Bestaetigung wartet, laesst +sie stehen. + +**Warum nicht einfach `if neu == alt: return` ganz oben:** `_spaetes_aus()` +verlaesst sich ausdruecklich darauf, ein Zuendungs-Aus auch dann zu sehen, wenn +es "unter Umstaenden zusammen mit dem naechsten on" ankommt - und entscheidet +ueber die Geraetezeit, nicht ueber die Reihenfolge. Ein zweites, spaeter +gepuffertes Aus bei bereits stehendem "off" waere ein `off -> off` und wuerde +von so einem Filter verschluckt. Genau daran haengt das Nachtragen vorlaeufiger +Enden. Die Pruefung sitzt deshalb **nur in Zweig 2**. + +**21 Tests** in `test_zuendungspause.py` (von 18). Gegenprobe mit +ausgeschalteter Pruefung: **1 Test scheitert**, mit ihr laufen alle 21. Dazu +ein Test, der belegt, dass ein `off -> off` ohne laufende Fahrt weiterhin bei +`_spaetes_aus()` ankommt - sonst haette ich mir genau das kaputtgemacht, wovor +der Kommentar dort warnt. + +### Zum Knopf "Fahrt beenden" - meine Beschreibung war zu weit + +Ich hatte geschrieben, das vorlaeufige Ende sei dafuer da, "dass ein spaeter +nachgereichtes Zuendungs-Aus es korrigiert". Der Eigentuemer hat widersprochen, +und er hat recht. `_vorlaeufiges_ende_korrigieren()` zieht **nur nach hinten** +(`if echt <= ende_bisher: return False` - *"frueher waere kein Nachtrag, +sondern eine Verkuerzung"*). + +Am Beispiel des Eigentuemers durchgerechnet - Tankstelle, Motor aus 11:59, +Knopf 12:01, Zuendungs-Aus entsteht 12:02: + + echt = 12:02 - 180 s = 11:59 <= vorlaeufiges Ende 12:00 -> keine Korrektur + +Die bewusste Entscheidung an der Zapfsaeule bleibt also stehen. Korrigiert wird +nur, wenn danach **wirklich weitergefahren** wurde und der Rest gepuffert +nachkommt. Und in beiden Reihenfolgen - Knopf vor oder nach den 180 s - gibt es +keine Wartezeit, und das naechste Zuendungssignal beginnt eine neue Fahrt. + +### Der flespi-Cache laesst sich zwingen + +Der Eigentuemer hat auf den Panel-Knopf "clear cache and synchronize" +hingewiesen. Die API-Entsprechung ist belegt (zwei Quellen): + +**`DELETE /gw/devices/{id}/settings/{name}`** - *"delete the cached value and +thus force flespi to re-read the actual value from the device"*. Es loescht +flespis Zwischenspeicher, **nicht** die Einstellung im Geraet. + +**Haken, der in die Umsetzung gehoert:** dasselbe DELETE verwirft auch einen +**ausstehenden** Wert. Stuende gerade eine Aenderung in der Warteschlange - wie +`1003`/`1004` am 04.09. acht Stunden lang -, waere sie damit weg, ohne dass es +jemand merkt. Ein Cache-Loeschen muss jede Einstellung mit `pending` +ueberspringen. + +Noch nicht gebaut. Offen bleiben damit: + +* "Jetzt lesen" als echtes Lesen (Cache loeschen, dann holen). +* Der Waechter darf keinen Gleichstand melden, solange unbekannt ist, wann der + Wert zuletzt vom Geraet bestaetigt wurde. diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index 510a150..3872746 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -392,11 +392,19 @@ async def zuendung_geaendert( # bisher und der nächste Auslöser schliesst die Fahrt nach. Ein früheres # Löschen hätte sie in dem Fall verloren. async with k.fahrt_sperre: - await _signalwechsel(k, neu == "on", ereigniszeit) + # `neu != alt` heisst: das ist wirklich ein Wechsel. Home Assistant + # schreibt denselben Wert auch neu, wenn sich nur ein Attribut geruehrt + # hat - am 04.09.2026 gezaehlt: von 22 Zustandszeilen der Zuendung + # waren nur 6 echte Wechsel, die uebrigen 16 blosse Neuschreibungen. + # Was das unterscheidet, braucht nur Zweig 2 (siehe dort). + await _signalwechsel(k, neu == "on", ereigniszeit, neu != alt) async def _signalwechsel( - k: Koordinator, an_jetzt: bool, ereigniszeit: datetime.datetime | None + k: Koordinator, + an_jetzt: bool, + ereigniszeit: datetime.datetime | None, + wechsel: bool = True, ) -> None: # Ob eine Fahrt läuft, entscheidet der eigene Zwischenstand # (fahrt_start_ts) - nicht der vorherige Rohwert der Entität. Der wäre bei @@ -422,6 +430,33 @@ async def _signalwechsel( return if not an_jetzt and fahrt_laeuft: + # Eine blosse Neuschreibung von "aus", waehrend schon eine Bestaetigung + # fuer DIESE Fahrt wartet, aendert nichts - und darf die Wartezeit + # deshalb nicht von vorn beginnen lassen. + # + # Ohne diese Pruefung schob jede Neuschreibung das Schliessen um + # weitere 15 Minuten hinaus. Am 04.09.2026 ist es nicht passiert - die + # Neuschreibungen lagen 19,7 Minuten nach dem echten "aus" und damit + # hinter dem Fenster -, aber das Loch war da. + # + # NUR HIER, nicht am Anfang von zuendung_geaendert(): _spaetes_aus() + # weiter unten verlaesst sich ausdruecklich darauf, ein Zuendungs-Aus + # auch dann zu sehen, wenn es "unter Umstaenden zusammen mit dem + # naechsten on" ankommt. Ein zweites, spaeter gepuffertes Aus bei + # bereits stehendem "off" waere ein off -> off - und genau daran haengt + # das Nachtragen vorlaeufiger Enden. + if ( + not wechsel + and k.ende_wartet is not None + and k.ende_wartet[0] == k.fahrt_start_ts + ): + _LOGGER.debug( + "Zuendung erneut als aus gemeldet, ohne Wechsel - die " + "Bestaetigung fuer die Fahrt seit %s laeuft weiter", + k.fahrt_start_ts.isoformat(), + ) + await k.fahrzeugstatus_veroeffentlichen() + return # Ein neues "aus" ersetzt ein aelteres, das noch wartet. k.warte_ende_ab_abbrechen() # Ob hier noch gewartet wird, entscheidet _pausenzeit_s(): liegt ein diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 32b3c06..1b46a41 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.24","sha256":"2d303b02b3d5ba9d2432856a528b32ed30c657a4ff43e1f39744d4f5c9f8c04a","bytes":324162,"gebaut":"2026-09-04T22:39:43Z"} \ No newline at end of file +{"version":"2026.9.4.25","sha256":"7a53dd0854bfe487c6efc1fc91581e616dc3464bd31baa3356743883f9477331","bytes":324151,"gebaut":"2026-09-04T23:01:16Z"} \ 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 440ad1c..f731cab 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/manifest.json b/custom_components/audi_dashboard/manifest.json index af94af3..7a7d3e2 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.24", + "version": "2026.9.4.25", "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_zuendungspause.py b/tests/fahrterkennung/test_zuendungspause.py index d55bed2..be4a100 100644 --- a/tests/fahrterkennung/test_zuendungspause.py +++ b/tests/fahrterkennung/test_zuendungspause.py @@ -337,6 +337,74 @@ class GepufferterStapel(Basis): self.assertEqual(k.beendet, []) self.assertEqual(k.fahrt_start_ts, beginn) + +class Neuschreibung(Basis): + """Home Assistant schreibt denselben Wert auch neu. + + Am 04.09.2026 gezaehlt: von 22 Zustandszeilen der Zuendung waren nur 6 + echte Wechsel. Die uebrigen 16 waren Neuschreibungen - der Wert selbst + hatte sich nicht geruehrt. + + Der Beobachter unterscheidet das nicht, er reicht neu und alt durch. Faellt + so eine Neuschreibung von "aus" in die laufende Wartezeit, begann die + Wartezeit von vorn - und bei genuegend vielen nie ein Ende. + """ + + async def test_neuschreibung_verlaengert_die_wartezeit_nicht(self): + k = FakeKoordinator() + await f.zuendung_geaendert(k, "off", "on", AUS) + wartet = k.ende_wartet + self.assertIsNotNone(wartet, "nach dem Aus wartet eine Bestaetigung") + + # Dieselbe Meldung noch einmal, ohne Wertwechsel. + await f.zuendung_geaendert(k, "off", "off", AUS + datetime.timedelta(seconds=30)) + self.assertIs( + k.ende_wartet, wartet, "es wartet weiterhin dieselbe Bestaetigung" + ) + + await k.ausklingen() + self.assertEqual(len(k.beendet), 1, "und sie kommt zum Zug") + self.assertEqual( + k.beendet[0][1], + AUS - datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S), + "mit dem Ende aus dem ERSTEN Aus, nicht dem der Neuschreibung", + ) + + async def test_echtes_zweites_aus_zaehlt_weiterhin(self): + """Die Gegenprobe: ein Wechsel darf die Bestaetigung sehr wohl ersetzen.""" + k = FakeKoordinator() + await f.zuendung_geaendert(k, "off", "on", AUS) + erstes = k.ende_wartet + await f.zuendung_geaendert(k, "on", "off", AUS + datetime.timedelta(seconds=30)) + await f.zuendung_geaendert(k, "off", "on", AUS + datetime.timedelta(seconds=60)) + self.assertIsNotNone(k.ende_wartet) + self.assertNotEqual( + k.ende_wartet, erstes, "ein echtes zweites Aus setzt ein neues Ende" + ) + + async def test_neuschreibung_ohne_laufende_fahrt_geht_weiter_durch(self): + """_spaetes_aus() braucht auch ein off -> off. + + Ein zweites, spaeter gepuffertes Aus traegt das Ende einer von Hand + beendeten Fahrt nach. Ein Filter ganz oben haette genau das + verschluckt - deshalb sitzt die Pruefung nur in Zweig 2. + """ + gesehen = [] + + async def spaetes_aus(k, ereigniszeit, jetzt): + gesehen.append(ereigniszeit) + + echt = f._spaetes_aus + f._spaetes_aus = spaetes_aus + try: + k = FakeKoordinator(start_ts=None) + await f.zuendung_geaendert(k, "off", "off", AUS) + self.assertEqual( + len(gesehen), 1, "ohne laufende Fahrt muss das Aus ankommen" + ) + finally: + f._spaetes_aus = echt + class Einstellung(unittest.IsolatedAsyncioTestCase): """_pausenzeit_s selbst - hier ungepatcht."""