diff --git a/AGENTS.md b/AGENTS.md index d8d888a..205cb6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9151,3 +9151,59 @@ flespi legt beim Schreiben das **ganze** Objekt als `pending` ab, also stand auc unterscheidet. Gegen die echten Daten nachgewiesen (`flespi.py` einzeln geladen, ohne Home Assistant): `Sleep Timeout (103): 10 → 15 unterwegs [wir: 15]`, `Sleep Mode (102): 2` ohne Zusatz. + +## CA. Audit nach dem Dongle-Umbau: vier Befunde, alle behoben (2026.9.3.12/.13) + +Rundumblick auf Wunsch des Eigentümers. **Sauber:** 28 Backend-Dateien +`py_compile`, beide Frontend-Dateien als **Modul** geparst (Abschnitt AQ), +`tsc --noEmit`, `vite build`, 182/182 Tests. Dienste in `const.py`, `dienste.py` +(Schema und Registrierung) und `services.yaml` decken sich vollständig in beide +Richtungen; Katalog und Dataclass in `einstellungen.py` je 27 Einträge ohne +Abweichung, jedes Feld mit Beispielnamen, beide Listenfelder mit so vielen +Beispielen wie Positionen. Kein verwaister Name in `flespi.py`, und `ABSTAND_S`/ +`_zu_alt`/`gelegenheit()` sind mit der Warteschlange restlos verschwunden. + +**Befund 1 (schwer): jedes Profil-Speichern wartete auf flespi.** +`profil_schreiben` **awaitete** `wunsch_uebernehmen()` — bis zu drei HTTP-Runden +mit je 20 s Zeitlimit. Am Panel hängen **39 Aufrufstellen** von +`profilSpeichern()`, eine je Feldänderung: ein langsames flespi hätte die +Oberfläche bei jedem getippten Feld blockiert. Jetzt eine eigene Aufgabe +(`hass.async_create_task`) — das Speichern selbst ist zu dem Zeitpunkt längst +erledigt. + +**Befund 2: ein gescheiterter Schreibversuch wurde bei jedem Speichern +wiederholt.** Der Fehlerstand hat kein `werte`, an dem der Wächter hätte +hängenbleiben können — mit abgelehntem Token wäre jede Feldänderung eine neue +Anfrage gewesen. Der Fehlerstand merkt sich jetzt unter `versucht`, WAS +gescheitert ist; ein neuer Reglerwert, ein erfolgreiches Lesen oder ein Neustart +heben die Sperre von selbst auf. Im selben Zug schreibt +`schlaf_timeout_schreiben()` nicht mehr, wenn der Wert bereits als `pending` +bereitliegt (ein **anderer** ausstehender Wert wird weiterhin überschrieben — +unserer ist der jüngere). + +**Befund 3: die App behauptete etwas, das sie nicht wissen kann.** Der +Versionshinweis meldete „Diese Fassung ändert auch Natives — dafür muss die App +neu aufgespielt werden". Das war für `2026.9.3.11` schlicht falsch: eine reine +Backend-Korrektur, nur das Bündel war nach dem Versionssprung nicht neu gebaut. +Zwei echte Ursachen dahinter: + +* `buendelPasst()` versprach im Kommentar, ein älteres Bündel abzulehnen + („wäre ein Rückschritt"), prüfte aber nur `buendel.version !== eigene` — ein + **älteres** Bündel kam damit genauso durch wie ein neueres. Es vergleicht jetzt + zusätzlich gegen die Serverfassung; ohne bekannte Serverfassung (offline, altes + Backend) bleibt es beim alten Verhalten, denn ein Vergleich, den man nicht + anstellen kann, darf den Knopf nicht wegnehmen. **Drei Regressionstests**, der + entscheidende gegen den alten Stand als fehlschlagend nachgewiesen. +* Der Text sagt nicht mehr, *warum* nichts bereitliegt: „Dafür liegt hier kein + passendes Bündel — sie muss über Xcode neu aufgespielt werden." + +**Befund 4, aus meiner eigenen Änderung desselben Tages:** das Regler-Minimum ist +von 0 auf 1 gewandert (das Gerät kennt kein Schlaf-Timeout unter einer Minute), +aber ein **bereits gespeicherter** Wert darunter lief ungeprüft durch — der +Daumen rastet bei 1 ein, die Anzeige daneben sagt „0 Min.". Beide Oberflächen +klemmen den gelesenen Wert jetzt auf 1–60. + +**Merkposten, zum zweiten Mal in diesem Projekt (siehe BC und BV):** ein +Versionssprung ohne `npm run ota` lässt das Bündel zurück — und seit Befund 3 +sagt die App dann wenigstens die Wahrheit darüber. Die Reihenfolge ist: erhöhen, +**dann** Bündel bauen, dann ausliefern. diff --git a/companion-app/src/daten/ota.test.ts b/companion-app/src/daten/ota.test.ts index d65d46e..d819fcc 100644 --- a/companion-app/src/daten/ota.test.ts +++ b/companion-app/src/daten/ota.test.ts @@ -40,4 +40,26 @@ describe("buendelPasst", () => { expect(buendelPasst(buendel({ sha256: "" }), "2026.8.23.2")).toBe(false) expect(buendelPasst(buendel({ version: "" }), "2026.8.23.2")).toBe(false) }) + + /* Der eigentliche Zweck, und bis zum 03.09.2026 nur ein Kommentar: das + Bündel muss zu der Fassung gehören, die der Server ausliefert. Ohne + diese Prüfung meldete die App "diese Fassung ändert auch Natives", + obwohl nur das Bündel nach einem Versionssprung nicht neu gebaut war. */ + it("akzeptiert, wenn das Bündel die Fassung des Servers ist", () => { + expect(buendelPasst(buendel({ version: "2026.9.3.11" }), "2026.9.3.10", "2026.9.3.11")).toBe( + true, + ) + }) + + it("lehnt ab, wenn das Bündel hinter dem Server zurückliegt", () => { + expect(buendelPasst(buendel({ version: "2026.9.3.10" }), "2026.9.3.9", "2026.9.3.11")).toBe( + false, + ) + }) + + it("prüft ohne bekannte Serverfassung nur gegen die eigene", () => { + // Offline oder altes Backend: einen Vergleich, den man nicht anstellen + // kann, darf der Knopf nicht ausbaden. + expect(buendelPasst(buendel({ version: "2026.9.3.11" }), "2026.9.3.10", null)).toBe(true) + }) }) diff --git a/companion-app/src/daten/ota.ts b/companion-app/src/daten/ota.ts index c4fb615..39bba7a 100644 --- a/companion-app/src/daten/ota.ts +++ b/companion-app/src/daten/ota.ts @@ -74,11 +74,23 @@ export async function startklarMelden(): Promise { als "nicht vergleichbar", nicht als "abweichend". Alles andere böte einen Update-Knopf an, dessen Ziel man mit der laufenden Fassung gar nicht abgleichen konnte. */ -export function buendelPasst(buendel: Buendelangabe | null, eigene: string | null): boolean { +export function buendelPasst( + buendel: Buendelangabe | null, + eigene: string | null, + serverVersion?: string | null, +): boolean { if (!buendel?.version || !buendel.url || !buendel.sha256 || !eigene) return false - // Das Bündel muss zu der Fassung gehören, die diese Installation - // ausliefert. Läge dort ein älteres, wäre ein "Update" ein Rückschritt. - return buendel.version !== eigene + // Es muss etwas ANDERES sein als das, was gerade läuft - sonst gibt es + // nichts zu holen. + if (buendel.version === eigene) return false + // Und es muss die Fassung sein, die diese Installation ausliefert. Der + // Kommentar hier versprach das schon ("läge dort ein älteres, wäre ein + // Update ein Rückschritt"), geprüft wurde es nie: ein blosses !== laesst + // ein aelteres Buendel genauso durch wie ein neueres. Ohne bekannte + // Serverfassung bleibt es beim alten Verhalten - ein Vergleich, den man + // nicht anstellen kann, darf den Knopf nicht wegnehmen. + if (serverVersion == null) return true + return buendel.version === serverVersion } export class OtaFehler extends Error {} diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts index b9897da..d355376 100644 --- a/companion-app/src/daten/profilAdapter.ts +++ b/companion-app/src/daten/profilAdapter.ts @@ -150,7 +150,10 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen { // Der Wert hat die Zeit ueberlebt, in der die Einstellung nicht angeboten // wurde (31.08. bis 03.09.2026) - deshalb steht er in vielen Profilen // schon, und der Standard greift nur bei neuen. - pausenzeitMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15, + // Auf den Bereich des Reglers geklemmt - siehe profilZuConfig() im Panel: + // vor dem 03.09.2026 war 0 ("sofort") moeglich, das Geraet kennt aber kein + // Schlaf-Timeout unter einer Minute. + pausenzeitMin: Math.min(60, Math.max(1, zahl(einst, "fahrten_pausenzeit_min") ?? 15)), } } diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 6fc4d3e..0dc70ac 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -76,7 +76,8 @@ export function Einstellungen({ const [backupImportFehler, setzeBackupImportFehler] = useState(null) const backupDateiwahl = useRef(null) - const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion()) + const otaUpdateVerfuegbar = + otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion(), serverVersion) const otaAusloesen = async () => { if (!otaBuendel) return diff --git a/companion-app/src/screens/Versionshinweis.tsx b/companion-app/src/screens/Versionshinweis.tsx index 0f1b1ff..38102ba 100644 --- a/companion-app/src/screens/Versionshinweis.tsx +++ b/companion-app/src/screens/Versionshinweis.tsx @@ -14,12 +14,17 @@ * ZWEI FÄLLE, ZWEI TEXTE * ---------------------- * Liegt ein passendes OTA-Bündel bereit, kann die App sich selbst erneuern — - * dann steht hier der Knopf, der genau das tut. Fehlt eines, hat sich Natives - * geändert (Plugins, Berechtigungen, die Hülle selbst), und dagegen hilft nur - * neu aufspielen. Beides zu vermengen wäre die schlechtere Auskunft: ein + * dann steht hier der Knopf, der genau das tut. Fehlt eines, hilft nur neu + * aufspielen. Beides zu vermengen wäre die schlechtere Auskunft: ein * "Jetzt aktualisieren", das nichts ändern kann, oder ein "bitte manuell", * obwohl ein Knopf genügt hätte. * + * Der Text sagt bewusst NICHT mehr "diese Fassung ändert auch Natives". Das + * kann die App nicht wissen, und es war am 03.09.2026 schlicht falsch: die + * Fassung war eine reine Backend-Korrektur, nur das Bündel war nach dem + * Versionssprung nicht neu gebaut worden. Ein fehlendes Bündel heisst + * "hier liegt nichts Passendes" — warum, weiss nur, wer es gebaut hat. + * * EINMAL, NICHT BEI JEDEM START * ----------------------------- * Weggetippt wird die gesehene Serverfassung gemerkt. Erst eine *andere* @@ -90,7 +95,7 @@ export function Versionshinweis({ const offen = nativ && !weggetippt && hinweisFaellig(versionsstand, serverVersion, gesehenLesen()) - const perOta = buendelPasst(otaBuendel, eigeneVersion()) + const perOta = buendelPasst(otaBuendel, eigeneVersion(), serverVersion) const schliessen = () => { if (laeuft) return @@ -121,7 +126,7 @@ export function Versionshinweis({ description={ perOta ? `Diese App läuft auf ${eigeneVersion() ?? "einer unbekannten Fassung"}, bereit liegt ${serverVersion}. Sie kann sich direkt hier erneuern.` - : `Diese App läuft auf ${eigeneVersion() ?? "einer unbekannten Fassung"}, der Server liefert ${serverVersion}. Diese Fassung ändert auch Natives — dafür muss die App neu aufgespielt werden, ein Update in der App genügt nicht.` + : `Diese App läuft auf ${eigeneVersion() ?? "einer unbekannten Fassung"}, der Server liefert ${serverVersion}. Dafür liegt hier kein passendes Bündel — sie muss über Xcode neu aufgespielt werden.` } actions={ perOta diff --git a/custom_components/audi_dashboard/dienste.py b/custom_components/audi_dashboard/dienste.py index 2199070..b922a96 100644 --- a/custom_components/audi_dashboard/dienste.py +++ b/custom_components/audi_dashboard/dienste.py @@ -242,11 +242,15 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None: await identitaet.fin_ableiten_und_uebernehmen(k.hass, k.zuordnung, k.ablage) await k.profil_veroeffentlichen() # Die Reglerstellung "Fahrt beenden" ist zugleich der Sollwert für den - # Schlaf-Timeout des Dongles (103). Vorgemerkt statt geschrieben: - # schreiben geht nur bei wachem Gerät, siehe flespi.py. Muss NACH - # profil_veroeffentlichen() stehen - erst dort liest der Koordinator + # Schlaf-Timeout des Dongles (103) - siehe flespi.py. Muss NACH + # profil_veroeffentlichen() stehen: erst dort liest der Koordinator # die neue Reglerstellung mit. - await flespi.wunsch_uebernehmen(k) + # + # Als eigene Aufgabe und ausdrücklich NICHT abgewartet: an diesem + # Dienst hängen ~40 Aufrufstellen im Panel, eine je Feldänderung, und + # jede würde sonst auf bis zu drei HTTP-Runden zu flespi warten. Das + # Speichern selbst ist zu diesem Zeitpunkt längst erledigt. + k.hass.async_create_task(flespi.wunsch_uebernehmen(k)) async def dongle_lesen(_aufruf: ServiceCall) -> None: """Liest die Konfiguration des Dongles - auf ausdrücklichen Knopfdruck. diff --git a/custom_components/audi_dashboard/flespi.py b/custom_components/audi_dashboard/flespi.py index 6809ae7..2e65274 100644 --- a/custom_components/audi_dashboard/flespi.py +++ b/custom_components/audi_dashboard/flespi.py @@ -415,11 +415,20 @@ async def schlaf_timeout_schreiben(k: Koordinator, minuten: int) -> dict: vorher_roh = await _alle_einstellungen(k.hass, token, geraet) vorher = _sicht(vorher_roh, "current") offen_vorher = _sicht(vorher_roh, "pending") - if ( + schon_da = ( vorher.get(SCHLAF_SCHLUESSEL) == minuten and offen_vorher.get(SCHLAF_SCHLUESSEL) is None - ): - _LOGGER.debug("Sleep Timeout steht bereits auf %s Min. - nichts zu tun", minuten) + ) + # Liegt der Wert schon bereit, waere ein zweites Schreiben nur Funkzeit. + # Ein ANDERER ausstehender Wert wird dagegen ueberschrieben - unserer ist + # der juengere. + unterwegs = offen_vorher.get(SCHLAF_SCHLUESSEL) == minuten + if schon_da or unterwegs: + _LOGGER.debug( + "Sleep Timeout %s Min. steht bereits %s - nichts zu tun", + minuten, + "am Gerät" if schon_da else "bereit", + ) return _stand(geraet, vorher_roh, wunsch_timeout(k)) # Das ganze Objekt zurückschreiben, nur mit geändertem Feld: `type` und die @@ -498,6 +507,11 @@ async def wunsch_uebernehmen(k: Koordinator) -> None: # ausstehender Wert (`offen`) zählt dabei wie ein gesetzter - er ist schon # unterwegs. if k.flespi_stand: + # Schon einmal mit genau diesem Ziel gescheitert: nicht bei jeder + # Gelegenheit erneut anrennen. Ein neuer Reglerwert, ein erfolgreiches + # Lesen oder ein Neustart heben die Sperre von selbst auf. + if k.flespi_stand.get("versucht") == ziel: + return for zeile in k.flespi_stand.get("werte", []): if zeile["schluessel"] != SCHLAF_SCHLUESSEL: continue @@ -512,8 +526,13 @@ async def wunsch_uebernehmen(k: Koordinator) -> None: stand = await schlaf_timeout_schreiben(k, ziel) except FlespiFehler as fehler: _LOGGER.warning("Sleep Timeout konnte nicht gesetzt werden: %s", fehler) + # "versucht" haelt fest, WAS gescheitert ist. Ohne das versuchte + # es der Waechter unten bei jedem Profil-Speichern erneut - der + # Fehlerstand hat kein "werte", an dem er haengenbleiben koennte, + # und ein kaputter Token haette so jede Feldaenderung eine Anfrage + # gekostet. await k.flespi_stand_setzen( - {"fehler": str(fehler), "gelesen_am": jetzt_iso()} + {"fehler": str(fehler), "gelesen_am": jetzt_iso(), "versucht": ziel} ) return await k.flespi_stand_setzen(stand) diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 946118e..039ef04 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.3.10","sha256":"f1a9a5c2ff1ef7fbd29c679e1b1a374d77c57da4f49f864b3758219483bbb013","bytes":269156,"gebaut":"2026-09-03T17:59:17Z"} \ No newline at end of file +{"version":"2026.9.3.13","sha256":"9b8881ea19de328d9b239f93edadc14927e33478565b66679ead690362b8ccf3","bytes":269175,"gebaut":"2026-09-03T19:02:24Z"} \ 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 74bf7cb..ed7df88 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 d7974ae..6b223af 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -203,8 +203,12 @@ function profilZuConfig(p) { // Der Wert hat die Zeit ueberlebt, in der die Einstellung nicht // angeboten wurde (31.08. bis 03.09.2026); der Standard greift nur bei // neuen Profilen. Siehe fahrterkennung._pausenzeit_s im Backend. + // Auf den Bereich des Reglers geklemmt: der ging bis zum 03.09.2026 ab 0 + // ("sofort"), das Geraet kennt aber kein Schlaf-Timeout unter einer + // Minute. Ein gespeicherter alter Wert stuende sonst als "0 Min." neben + // einem Daumen, der bei 1 einrastet. pausenzeit: Number.isFinite(p.einstellungen.fahrten_pausenzeit_min) - ? p.einstellungen.fahrten_pausenzeit_min : 15, + ? Math.min(60, Math.max(1, p.einstellungen.fahrten_pausenzeit_min)) : 15, }; } diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 9212c5e..35e9748 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.3.11", + "version": "2026.9.3.13", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"],