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
+13 -1
View File
@@ -255,7 +255,15 @@ export interface DongleWert {
offen: unknown;
/** Unser eigener Wert, sofern es einen zu vergleichen gibt. */
unser: number | null;
abweichung: boolean;
/** Dreiwertig: true weicht ab, false stimmt überein, **null kein
Urteil** — der Wert wurde nie vom Gerät bestätigt, oder es ist
gerade eine Änderung zu ihm unterwegs. Bis zum 04.09.2026 war das
ein Bool, und `false` behauptete Übereinstimmung auch gegen einen
tagealten Wert. */
abweichung: boolean | null;
/** Wann das Gerät diesen Wert zuletzt bestätigt hat (flespis
`updated`) — null heißt: nie. */
gemeldet_am?: string | null;
}
export interface DongleStand {
@@ -264,6 +272,10 @@ export interface DongleStand {
anzahl?: number;
werte?: DongleWert[];
abweichungen?: number;
/** Zeilen ohne Urteil: nie bestätigt oder gerade unterwegs. */
unbekannt?: number;
/** Welche Zwischenspeicher dieser Abruf geleert hat. */
geleert?: string[];
/** Wie viele der gezeigten Werte noch zum Gerät unterwegs sind. */
offen?: number;
fehler: string | null;
+16 -3
View File
@@ -25,6 +25,19 @@ import { HistorienImport } from "./HistorienImport"
import type { ServicebuchEintrag } from "./Service"
import { zugangLesenSynchron, zugangVergessen } from "./zugang"
/* Wann das Gerät einen Wert zuletzt bestätigt hat — oder dass es das nie
getan hat. Steht dort, wo vorher nichts stand, wenn kein Urteil möglich
ist: ein stiller Gleichstand mit einem veralteten Wert ist schlimmer als
gar kein Vergleich (04.09.2026). Wortgleich mit dongleStand() im Panel. */
function dongleStand(w: { gemeldet_am?: string | null }): string {
if (!w.gemeldet_am) return "nie bestätigt"
const d = new Date(w.gemeldet_am)
if (Number.isNaN(d.getTime())) return "nie bestätigt"
const tage = Math.floor((Date.now() - d.getTime()) / 86400000)
const wann = datum(d)
return tage >= 1 ? `bestätigt ${wann} (${tage} T. alt)` : `bestätigt ${wann}`
}
export function Einstellungen({
tabBeschriftung,
setzeTabBeschriftung,
@@ -964,13 +977,13 @@ export function Einstellungen({
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
warnung={Boolean(w.abweichung)}
warnung={w.abweichung === true}
zusatz={
w.offen != null
? `${w.offen}, unterwegs`
: w.abweichung
: w.abweichung === true
? `(wir: ${w.unser})`
: undefined
: dongleStand(w)
}
/>
))}
+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.
@@ -1 +1 @@
{"version":"2026.9.4.25","sha256":"7a53dd0854bfe487c6efc1fc91581e616dc3464bd31baa3356743883f9477331","bytes":324151,"gebaut":"2026-09-04T23:01:16Z"}
{"version":"2026.9.4.26","sha256":"0e65b78a949052abdcb6765a9a08e1526e67bec911657073d35f7158f51ba0e4","bytes":324250,"gebaut":"2026-09-04T23:10:56Z"}
@@ -3500,6 +3500,19 @@ function csvDatei(name, kopf, zeilen) {
a.download = name; document.body.appendChild(a); a.click(); a.remove();
}
/* Wann das Geraet einen Wert zuletzt bestaetigt hat - oder dass es das nie
getan hat. Steht dort, wo frueher nichts stand, wenn kein Urteil
moeglich ist: ein stiller Gleichstand mit einem veralteten Wert ist
schlimmer als gar kein Vergleich (04.09.2026). */
function dongleStand(w) {
if (!w.gemeldet_am) return "nie bestätigt";
const d = new Date(w.gemeldet_am);
if (!gueltigesDatum(d)) return "nie bestätigt";
const tage = Math.floor((Date.now() - d.getTime()) / 86400000);
const wann = dedat(d);
return tage >= 1 ? `bestätigt ${wann} (${tage} T. alt)` : `bestätigt ${wann}`;
}
function vEinst() {
return `
<div class="tile"><span class="label">Fahrzeug einrichten</span>
@@ -3759,12 +3772,13 @@ function vEinst() {
${DONGLE.stand && (DONGLE.stand.werte || []).length ? `<dl class="rows">
${DONGLE.stand.werte.map((w) => `<div class="row">
<dt>${esc(w.titel)} <small style="color:var(--fg3)">${esc(w.nummer)}</small></dt>
<dd${w.abweichung ? ` style="color:var(--warn)"` : ""}>${
<dd${w.abweichung === true ? ` style="color:var(--warn)"` : ""}>${
w.geraet === null || w.geraet === undefined ? "" : esc(String(w.geraet))
}${w.einheit ? " " + esc(w.einheit) : ""}${
w.offen !== null && w.offen !== undefined
? ` <small>→ ${esc(String(w.offen))}, unterwegs</small>`
: w.abweichung ? ` <small>(wir: ${esc(String(w.unser))})</small>` : ""
: w.abweichung === true ? ` <small>(wir: ${esc(String(w.unser))})</small>`
: ` <small style="color:var(--fg3)">${dongleStand(w)}</small>`
}</dd>
</div>`).join("")}
</dl>` : ""}
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.4.25",
"version": "2026.9.4.26",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": [