Audit nach dem Dongle-Umbau: vier Befunde (2026.9.3.13)
1. profil_schreiben awaitete wunsch_uebernehmen und damit bis zu drei HTTP-Runden zu flespi - bei 39 Aufrufstellen von profilSpeichern() im Panel je Feldaenderung. Laeuft jetzt als eigene Aufgabe. 2. Ein gescheiterter Schreibversuch wurde bei jedem Speichern wiederholt, weil der Fehlerstand kein "werte" hat. Er merkt sich jetzt unter "versucht", was gescheitert ist. Dazu: kein zweites Schreiben, wenn der Wert schon als pending bereitliegt. 3. buendelPasst() versprach im Kommentar, ein aelteres Buendel abzulehnen, pruefte aber nur Ungleichheit - deshalb meldete die App "diese Fassung aendert auch Natives", obwohl nur das Buendel nach einem Versionssprung nicht neu gebaut war. Vergleicht jetzt gegen die Serverfassung, drei Regressionstests (der entscheidende gegen den alten Stand rot). Der Text behauptet keine Ursache mehr, die die App nicht kennen kann. 4. Das Regler-Minimum ging heute von 0 auf 1, ein bereits gespeicherter Wert darunter lief ungeprueft durch. Beide Oberflaechen klemmen jetzt auf 1-60. 182/182 Tests, 28 Backend-Dateien py_compile, beide Frontends als Modul geparst, Dienst- und Katalog-Konsistenz in beide Richtungen geprueft, Buendel auf derselben Fassung wie das Manifest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,11 +74,23 @@ export async function startklarMelden(): Promise<void> {
|
||||
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 {}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ export function Einstellungen({
|
||||
const [backupImportFehler, setzeBackupImportFehler] = useState<string | null>(null)
|
||||
const backupDateiwahl = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
|
||||
const otaUpdateVerfuegbar =
|
||||
otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion(), serverVersion)
|
||||
|
||||
const otaAusloesen = async () => {
|
||||
if (!otaBuendel) return
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.9.3.10","sha256":"f1a9a5c2ff1ef7fbd29c679e1b1a374d77c57da4f49f864b3758219483bbb013","bytes":269156,"gebaut":"2026-09-03T17:59:17Z"}
|
||||
{"version":"2026.9.3.13","sha256":"9b8881ea19de328d9b239f93edadc14927e33478565b66679ead690362b8ccf3","bytes":269175,"gebaut":"2026-09-03T19:02:24Z"}
|
||||
Binary file not shown.
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user