Eine Neuschreibung ist kein Wechsel - Wartezeit startet nicht mehr neu

Home Assistant schreibt denselben Zustandswert 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 Neuschreibungen - und
alle 6 Wechsel lagen ausserhalb der Fahrten. Das Zuendungssignal ist
unterwegs stabil.

Der Beobachter unterschied das nicht. Fiel eine Neuschreibung von "aus" in
die laufende Wartezeit, brach Zweig 2 die wartende Bestaetigung ab und
begann die 15 Minuten von vorn. Am 04.09. ist es nicht passiert - die
Neuschreibungen lagen hinter dem Fenster -, aber das Loch war da.

_signalwechsel() bekommt jetzt mit, ob es ein echter Wechsel war. Eine
Neuschreibung, waehrend fuer diese Fahrt schon eine Bestaetigung wartet,
laesst sie stehen.

Bewusst NICHT als Filter ganz oben: _spaetes_aus() verlaesst sich darauf,
ein Zuendungs-Aus auch als off -> off zu sehen - daran haengt das
Nachtragen vorlaeufiger Enden. Die Pruefung sitzt nur in Zweig 2, und ein
eigener Test belegt, dass der andere Weg weiter offen ist.

21 Tests (von 18), Gegenprobe mit ausgeschalteter Pruefung scheitert wie
erwartet an einem. py_compile sauber, audi_ha_test auf 2026.9.4.25 ohne
Traceback.

AGENTS.md haelt ausserdem fest: die Beschreibung des Beenden-Knopfes war zu
weit gefasst (die Korrektur zieht nur nach hinten, nie nach vorn), und der
flespi-Cache laesst sich ueber DELETE auf die Einstellung zum Neulesen
zwingen - mit dem Haken, dass dabei ausstehende Werte verloren gingen.
This commit is contained in:
2026-09-05 01:02:11 +02:00
parent 242312441d
commit 9ac69cdf44
6 changed files with 193 additions and 4 deletions
+86
View File
@@ -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.
@@ -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
@@ -1 +1 @@
{"version":"2026.9.4.24","sha256":"2d303b02b3d5ba9d2432856a528b32ed30c657a4ff43e1f39744d4f5c9f8c04a","bytes":324162,"gebaut":"2026-09-04T22:39:43Z"}
{"version":"2026.9.4.25","sha256":"7a53dd0854bfe487c6efc1fc91581e616dc3464bd31baa3356743883f9477331","bytes":324151,"gebaut":"2026-09-04T23:01:16Z"}
@@ -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": [
@@ -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."""