diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index fced60c..390e2ab 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -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; diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 89a2f23..c50ab38 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -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) } /> ))} diff --git a/custom_components/audi_dashboard/flespi.py b/custom_components/audi_dashboard/flespi.py index 2e65274..c61d6b4 100644 --- a/custom_components/audi_dashboard/flespi.py +++ b/custom_components/audi_dashboard/flespi.py @@ -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. diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 1b46a41..a963fad 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.25","sha256":"7a53dd0854bfe487c6efc1fc91581e616dc3464bd31baa3356743883f9477331","bytes":324151,"gebaut":"2026-09-04T23:01:16Z"} \ No newline at end of file +{"version":"2026.9.4.26","sha256":"0e65b78a949052abdcb6765a9a08e1526e67bec911657073d35f7158f51ba0e4","bytes":324250,"gebaut":"2026-09-04T23:10:56Z"} \ 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 f731cab..cfcff53 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/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 1652718..ac2a442 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -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 `