flespi: echtes Lesen statt Zwischenspeicher, und kein Gleichstand ohne Grundlage

Der Waechter meldete am 04.09.2026 "stimmt ueberein" fuer
trip_scenario.ign_off_timeout: 900 gegen unsere Konstante NACHLAUF_S = 900.
Das Geraet hat dort 0 - das Trip-Szenario ist abgeschaltet. flespis
Zwischenspeicher trug den Stand vom 01.09. 13:15, dreieinhalb Tage alt.
Der Eigentuemer hat es aufgedeckt: "die gelesenen werte sind veraltet".

Zwei Aenderungen:

1. "Jetzt lesen" leert erst den Zwischenspeicher der ueberwachten
   Einstellungen (DELETE /gw/devices/{id}/settings/{name} - die
   API-Entsprechung des Panel-Knopfes "clear cache and synchronize"; loescht
   den gespeicherten Wert, nicht den im Geraet) und holt dann neu.
   Einstellungen mit einem AUSSTEHENDEN Wert werden uebersprungen: dasselbe
   DELETE wuerde ihn verwerfen, und die Aenderung kaeme nie an - wie
   1003/1004, die am 04.09. acht Stunden in der Warteschlange standen.

2. `abweichung` ist dreiwertig: true, false und **null (kein Urteil)**. Null
   steht dort, wo ein Vergleich nichts aussagt - der Wert wurde nie vom
   Geraet bestaetigt, oder es ist gerade eine Aenderung unterwegs. Dazu
   traegt jede Zeile `gemeldet_am` (flespis `updated`), und beide
   Oberflaechen zeigen es an: "bestaetigt 01.09.2026 (4 T. alt)" bzw.
   "nie bestaetigt".

Ein stiller Gleichstand mit einem veralteten Wert ist schlimmer als gar kein
Vergleich: er behauptet Sicherheit, wo keine ist.

tsc sauber, 271 Tests, vite build sauber, Panel als Modul geparst,
py_compile sauber, audi_ha_test auf 2026.9.4.26 ohne Traceback.
This commit is contained in:
2026-09-05 01:11:23 +02:00
parent 9ac69cdf44
commit 5621b818cc
7 changed files with 175 additions and 21 deletions
+128 -13
View File
@@ -269,6 +269,23 @@ def _sicht(eintraege: list[dict], feld: str) -> dict[str, object]:
return flach
def _stempel(eintraege: list[dict]) -> dict[str, float]:
"""Wann das GERAET jede Einstellung zuletzt bestaetigt hat.
flespi legt zu jedem Eintrag ein `updated` (Unix-Sekunde). Das ist der
Zeitpunkt, zu dem der Wert vom Geraet kam - nicht der unseres Abrufs.
Genau daran fehlte es am 04.09.2026: `trip_scenario` stand auf dem Stand
vom 01.09. 13:15, waehrend die aufgespielte Konfiguration das Szenario
laengst abgeschaltet hatte. Der Waechter verglich 900 gegen 900 und meldete
Uebereinstimmung - gegen einen dreieinhalb Tage alten Wert.
"""
return {
e["name"]: e["updated"]
for e in eintraege
if e.get("name") and isinstance(e.get("updated"), (int, float))
}
def _roh(eintraege: list[dict], name: str) -> dict | None:
for eintrag in eintraege:
if eintrag.get("name") == name:
@@ -281,8 +298,20 @@ def vergleichen(
aktuell: dict[str, object],
offen: dict[str, object],
wunsch: int | None,
stempel: dict[str, float] | None = None,
) -> list[dict]:
"""Die interessanten Einstellungen mit unserem eigenen Wert daneben."""
"""Die interessanten Einstellungen mit unserem eigenen Wert daneben.
`abweichung` ist DREIWERTIG: True (weicht ab), False (stimmt ueberein) und
**None (kein Urteil)**. None steht dort, wo ein Vergleich nichts aussagen
wuerde - weil das Geraet den Wert nie bestaetigt hat, oder weil gerade eine
Aenderung zu ihm unterwegs ist und der Wert sich ohnehin gleich aendert.
Warum das noetig wurde: bis zum 04.09.2026 war `abweichung` ein blosses
Bool, und `False` hiess "stimmt ueberein" - auch dann, wenn der verglichene
Wert Tage alt war. Ein stiller Gleichstand mit einem veralteten Wert ist
schlimmer als gar kein Vergleich: er behauptet Sicherheit, wo keine ist.
"""
from . import fahrterkennung
zeilen = []
@@ -298,11 +327,17 @@ def vergleichen(
unser = None
# Nur vergleichen, wenn beide Zahlen sind - ein Modus wie "Deep Sleep"
# hat kein Gegenstück bei uns.
abweichung = (
isinstance(geraet, (int, float))
and isinstance(unser, (int, float))
and int(geraet) != int(unser)
)
# Der Name der Einstellung ist der Teil vor dem ersten Punkt -
# "sleep_mode.mode.timeout" gehoert zu "sleep_mode".
name = schluessel.split(".")[0]
gemeldet = (stempel or {}).get(name)
wartet = schluessel in offen and offen.get(schluessel) != geraet
vergleichbar = isinstance(geraet, (int, float)) and isinstance(unser, (int, float))
if not vergleichbar or gemeldet is None or wartet:
# Kein Gegenstueck bei uns, nie bestaetigt, oder gerade unterwegs.
abweichung: bool | None = None
else:
abweichung = int(geraet) != int(unser)
zeilen.append(
{
"schluessel": schluessel,
@@ -322,6 +357,13 @@ def vergleichen(
),
"unser": unser,
"abweichung": abweichung,
# Wann das Geraet diesen Wert zuletzt bestaetigt hat. None
# heisst: nie - dann steht daneben kein Urteil, sondern nichts.
"gemeldet_am": (
datetime.datetime.fromtimestamp(gemeldet, datetime.UTC).isoformat()
if gemeldet is not None
else None
),
}
)
return zeilen
@@ -357,26 +399,97 @@ async def _alle_einstellungen(
return eintraege
def _stand(geraet: int, eintraege: list[dict], wunsch: int | None) -> dict:
def _stand(
geraet: int,
eintraege: list[dict],
wunsch: int | None,
geleert: list[str] | None = None,
) -> dict:
aktuell = _sicht(eintraege, "current")
offen = _sicht(eintraege, "pending")
werte = vergleichen(aktuell, offen, wunsch)
werte = vergleichen(aktuell, offen, wunsch, _stempel(eintraege))
return {
"geraet": geraet,
"gelesen_am": jetzt_iso(),
"anzahl": len(eintraege),
"werte": werte,
"abweichungen": sum(1 for z in werte if z["abweichung"]),
# Welche Zwischenspeicher dieser Abruf geleert hat - leer, wenn nur
# gelesen wurde.
"geleert": geleert or [],
"abweichungen": sum(1 for z in werte if z["abweichung"] is True),
# Zeilen ohne Urteil: nie bestaetigt oder gerade unterwegs.
"unbekannt": sum(1 for z in werte if z["abweichung"] is None),
"offen": sum(1 for z in werte if z["offen"] is not None),
"fehler": None,
}
async def lesen(k: Koordinator) -> dict:
"""Holt die Konfiguration und gibt den fertigen Anzeigestand zurück."""
# Die Namen der Einstellungen, die wir ueberwachen - der Teil vor dem ersten
# Punkt in INTERESSANT ("sleep_mode.mode.timeout" -> "sleep_mode").
UEBERWACHT = sorted({schluessel.split(".")[0] for schluessel in INTERESSANT})
async def _cache_leeren(
hass: HomeAssistant, token: str, geraet: int, eintraege: list[dict]
) -> list[str]:
"""Loescht flespis Zwischenspeicher fuer die ueberwachten Einstellungen.
WAS DAS TUT UND WAS NICHT
-------------------------
`DELETE /gw/devices/{id}/settings/{name}` wirft den GESPEICHERTEN Wert weg,
nicht den im Geraet. flespi fragt ihn beim naechsten Verbinden neu ab. Es
ist die API-Entsprechung des Knopfes "clear cache and synchronize" im
flespi-Panel.
Ohne das liest jeder Abruf nur den Zwischenspeicher zurueck. Am 04.09.2026
stand dort fuer `trip_scenario` der Stand vom 01.09. 13:15 - dreieinhalb
Tage alt, und das Szenario war laengst abgeschaltet.
AUSSTEHENDE WERTE BLEIBEN UNANGETASTET
--------------------------------------
Dasselbe DELETE verwirft auch ein `pending`. Stuende gerade eine Aenderung
in der Warteschlange zum Geraet - wie `1003`/`1004` am 04.09. acht Stunden
lang -, waere sie damit weg, ohne dass es jemand merkt. Solche
Einstellungen werden uebersprungen; ihr Wert aendert sich ohnehin gleich.
"""
geleert: list[str] = []
for eintrag in eintraege:
name = eintrag.get("name")
if name not in UEBERWACHT:
continue
if eintrag.get("pending") is not None:
_LOGGER.info(
"%s hat einen ausstehenden Wert - Zwischenspeicher nicht "
"geleert, sonst waere die Aenderung verworfen", name,
)
continue
try:
await _anfrage(hass, token, "DELETE", f"/gw/devices/{geraet}/settings/{name}")
except FlespiFehler as fehler:
# Ein misslungenes Leeren ist kein Grund, das Lesen abzubrechen -
# dann steht eben der alte Wert da, so wie bisher auch.
_LOGGER.warning("Zwischenspeicher von %s nicht geleert: %s", name, fehler)
continue
geleert.append(name)
return geleert
async def lesen(k: Koordinator, frisch: bool = False) -> dict:
"""Holt die Konfiguration und gibt den fertigen Anzeigestand zurück.
Mit `frisch` wird vorher der Zwischenspeicher geleert (siehe
`_cache_leeren`). Die Werte, die danach zurueckkommen, koennen dadurch
fehlen - dann hat das Geraet sie noch nicht bestaetigt, und genau das
soll man sehen.
"""
token, geraet = await _geraet_und_token(k)
eintraege = await _alle_einstellungen(k.hass, token, geraet)
return _stand(geraet, eintraege, wunsch_timeout(k))
geleert: list[str] = []
if frisch:
geleert = await _cache_leeren(k.hass, token, geraet, eintraege)
if geleert:
eintraege = await _alle_einstellungen(k.hass, token, geraet)
return _stand(geraet, eintraege, wunsch_timeout(k), geleert)
# --------------------------------------------------------------- Schreiben
@@ -545,7 +658,9 @@ async def jetzt_lesen(k: Koordinator) -> None:
Antwort, auch auf ein "ging nicht".
"""
async with k.flespi_sperre:
stand = await lesen(k)
# Auf Knopfdruck ein ECHTES Lesen: erst den Zwischenspeicher leeren,
# dann holen. Sonst kaeme nur zurueck, was flespi ohnehin schon hatte.
stand = await lesen(k, frisch=True)
await k.flespi_stand_setzen(stand)
# Ein frischer Stand kann eine Abweichung erst sichtbar machen - dann gehört
# sie gleich behoben, statt bis zum nächsten Speichern zu warten.