diff --git a/AGENTS.md b/AGENTS.md index 110f49a..8120efb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-09-03** (Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie +**Last updated: 2026-09-03** (Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und +schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie zurueck, `2026.9.3.8`, Abschnitt BY. Davor: „Fahrt beenden“ auf Knopfdruck mit vorlaeufigem Ende, Schieberegler in 5-Minuten-Schritten und im Tagmodus sichtbar, `2026.9.3.7`, Abschnitt BX. Davor: Die Fahrt endet wieder bei uns statt im Dongle - Zuendung als Ausloeser, @@ -9002,3 +9003,112 @@ Passwortfeld mit passendem Platzhalter und die Knöpfe Speichern/Löschen. Einen gesetzt → `gesetzt · …0000`, und **der Wert selbst taucht in den veröffentlichten Attributen nirgends auf** (auf genau diese Zeichenkette geprüft, nicht nur angesehen). Danach über die Sicherheitsabfrage „Token löschen?" entfernt → wieder `nicht gesetzt`, der Gitea-Token unberührt. + +--- + +## BZ. Der Regler stellt den Schlaf-Timeout des Dongles (2026.9.3.10) + +### Was der Regler jetzt bedeutet + +„Fahrt beenden" in den Einstellungen ist **eine** Zahl für zwei Dinge, die +denselben Sachverhalt beschreiben (Vorgabe des Eigentümers): + +* Schlaf-Timeout des FMM003 (`103`, Minuten). So lange bleibt der Dongle nach + dem Abstellen wach und wartet darauf, dass es gleich weitergeht. Danach + schläft er — das spart Strom, kostet beim Fortsetzen aber Sekunden fürs + Aufwachen. +* Unsere eigene Wartezeit, bevor eine Fahrt geschlossen wird. + +Die 180 s `Ignition OFF Delay` (`400`) laufen davor. **Der Nutzer sieht sie +nicht** — sie werden nur von der Fahrtdauer abgezogen +(`fahrterkennung.ZUENDUNG_NACHLAUF_S`, unverändert). + +Der Regler geht deshalb von **1 bis 60 in Einerschritten** statt 0–60 in +Fünferschritten: das Gerät kennt kein Timeout 0 (Minimum 1, Maximum 3000), und +„sofort" gibt es damit nicht mehr. + +### Die gemessene API — drei Annahmen waren falsch + +Alles im laufenden Container gegen den echten Account geprüft, nicht aus der +Dokumentation abgeschrieben: + +| angenommen | tatsächlich | +|---|---| +| Wert unter `value` | Wert unter **`current`**, Ausstehendes unter **`pending`** | +| Einstellung `sleep_settings` mit `{mode, timeout}` | **`sleep_mode`**, Wert eine Ebene tiefer unter `mode` | +| PUT-Rumpf = der Wert | `{"properties": …, "address": …}` — ohne `address` kommt `400 the following properties are missing: address` | + +`GET /gw/devices/{id}/settings/all` liefert die gefüllten Einträge; der Abruf +einer **einzelnen** Einstellung kam leer zurück (`current: null`). Gelesen wird +deshalb immer `all`. + +Der `Ignition OFF Delay` (`400`) taucht unter keinem sicher zuzuordnenden Namen +auf und wird deshalb **nicht** angezeigt — lieber eine Zeile weniger als eine +falsch beschriftete. Verglichen werden `103` (gegen den Regler) und `11804` +(gegen `NACHLAUF_S`); dazu angezeigt: `102`, `11803`, `11806`. + +### Die eigene Warteschlange ist wieder entfallen + +Erst war ein Auftragsbuch im Koordinator gebaut, weil „Lesen und Schreiben gehen +nur bei aktiver Zündung — ausserhalb schläft der Dongle". **Das gilt für das +Gerät, nicht für diese Schnittstelle**, und das ist gemessen: die vollständige +Konfiguration kam herein, während das Fahrzeug seit dem Vortag stand. + +flespi ist eine Ebene darüber. Es kennt `current` und `pending`, und +`"address": "connection"` heisst: zustellen, sobald sich das Gerät meldet. Ein +zweites Auftragsbuch bei uns wäre eine zweite Warteschlange für dieselbe +Aufgabe — und eine, die nur raten könnte, was die erste schon weiss. + +Entfallen sind damit `flespi_auftrag`, `flespi_auftrag_setzen()`, +`wach_geworden()` und der Haken an beiden Zündungs-Beobachtern. Geblieben ist +`flespi_stand` (überlebt den Neustart) und `flespi_sperre`. + +**Geschrieben wird jetzt sofort** — nach jedem Profil-Speichern und einmal nach +dem Start, über `wunsch_uebernehmen()`. Das ist bewusst zustandsvergleichend +statt ereignisgetrieben: es muss auch dann zum Ziel führen, wenn eine frühere +Änderung nie ankam. Der billige Vergleich gegen den gespeicherten Stand geht +voraus — stimmt er (auch über ein noch ausstehendes `pending`), kostet ein +Profil-Speichern **keine einzige Anfrage**. Live belegt: der zweite Neustart +schrieb keine Zeile mehr. + +### Gegenlesen heisst hier zweierlei + +Vorgabe des Eigentümers war: nach dem Schreiben lesen und belegen, dass sich +genau die eine Stelle geändert hat. Mit `pending` bekommt das eine zweite gültige +Ausprägung, und beide zählen als Erfolg: + +* `current` trägt den neuen Wert → das Gerät war wach und hat ihn. +* `pending` trägt ihn → flespi hält ihn vor. **Der Normalfall bei stehendem + Fahrzeug.** + +Steht er in keinem von beiden, ist der Vorgang nicht angekommen und das wird als +Fehler gemeldet. Ändert sich zusätzlich etwas ausserhalb von `sleep_mode`, gibt +es eine Warnung — kein Abbruch: geschrieben ist zu dem Zeitpunkt schon. + +### Live nachgewiesen, und was nicht + +Gegen den echten flespi-Account und den echten Dongle (Gerät 8767678): + +* Token sieht **genau ein** Gerät → `geraet_finden()` löst die Nummer selbst + auf, in den Optionen muss nichts stehen. +* 127 Einstellungen gelesen, **bei parkendem Fahrzeug**. +* Regler 15 → PUT akzeptiert → `pending.mode.timeout = 15` bei flespi, + `current` blieb 10, und `type`/`bluetooth_on`/`periodic_wakeup` wurden + unverändert mitgeschrieben. Genau dafür wird das ganze Objekt + zurückgeschrieben statt nur des Feldes. +* Zweiter Neustart: keine erneute Anfrage (Idempotenz). + +**Das Gerät hat dabei eine Änderung bekommen** — den Wert, den der Regler ohnehin +schon zeigte. Das ist die gewollte Selbstheilung, aber es ist eine Änderung am +Fahrzeug und steht deshalb hier. + +**Nicht geprüft:** wie die neue Kachel aussieht. In dieser Sitzung gab es keinen +angemeldeten Browser; Panel und App sind über Modul-Parse, `tsc --noEmit` und +179/179 Tests belegt, nicht über einen Screenshot. Ebenfalls offen: ob das Gerät +das `pending` beim nächsten Verbinden wirklich übernimmt — das zeigt erst die +nächste Fahrt. + +**Vorbestehende Lücke, nicht in diesem Zug geschlossen:** `translations/de.json` +kennt die zuletzt hinzugekommenen Dienste nicht (`fahrt_jetzt_beenden`, +`zugang_setzen`, `csv_importieren`, jetzt auch `dongle_lesen`). Sie erscheinen in +den Entwicklerwerkzeugen ohne Klarnamen. diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index 3e59869..038c608 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -351,6 +351,16 @@ export class DataMetricApi { return this.rest.dienstAufrufen(DIENST_DOMAIN, "zugang_setzen", { dienst, token }); } + /** Liest die Konfiguration des Dongles - und schreibt dabei eine vorgemerkte + Aenderung, falls eine offen ist (siehe flespi.py). + + Wie zugangSetzen bewusst NICHT ueber die Warteschlange: der Abruf gelingt + nur bei laufender Zuendung, und Stunden spaeter aus dem Funkloch nachgeholt + traefe er das Geraet garantiert im Schlaf an. */ + dongleLesen(): Promise { + return this.rest.dienstAufrufen(DIENST_DOMAIN, "dongle_lesen", {}); + } + historieImportieren(start: string, ende: string): Promise { return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", { start, diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 9e219c0..1eb6530 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -234,12 +234,50 @@ export interface ZugangAngabe { rest: string | null; } +/** Was am Dongle steht und was noch aussteht (flespi.py). + + `aktiv` ist false, solange kein flespi-Token hinterlegt ist - dann gibt es + nichts zu zeigen. `stand` traegt seinen eigenen Zeitstempel: eine Angabe von + gestern soll nicht wie eine frische aussehen. Was noch zum Geraet unterwegs + ist, steht je Wert in `offen` - flespi haelt es vor, wir nicht. */ +export interface DongleWert { + schluessel: string; + /** Die Nummer im flespi-Configurator - danach sucht man dort. */ + nummer: string; + titel: string; + einheit: string; + geraet: unknown; + /** Was noch zum Gerät unterwegs ist (flespis `pending`) - null, wenn nichts + aussteht. */ + offen: unknown; + /** Unser eigener Wert, sofern es einen zu vergleichen gibt. */ + unser: number | null; + abweichung: boolean; +} + +export interface DongleStand { + geraet?: number; + gelesen_am: string; + anzahl?: number; + werte?: DongleWert[]; + abweichungen?: number; + /** Wie viele der gezeigten Werte noch zum Gerät unterwegs sind. */ + offen?: number; + fehler: string | null; +} + +export interface DongleAngabe { + aktiv: boolean; + stand: DongleStand | null; +} + /** Nutzlast von sensor.audi_dashboard_app_version. */ export interface AppVersionAngabe { app: string | null; buendel: Buendelangabe | null; integration_update: IntegrationUpdateAngabe | null; zugaenge?: ZugangAngabe[]; + dongle?: DongleAngabe; } /* -------------------------------------------------- Entitäts-Verzeichnis */ diff --git a/companion-app/src/daten/DatenKontext.tsx b/companion-app/src/daten/DatenKontext.tsx index 830b894..52df619 100644 --- a/companion-app/src/daten/DatenKontext.tsx +++ b/companion-app/src/daten/DatenKontext.tsx @@ -26,6 +26,7 @@ import { type Fahrt, type Fahrzeugstatus, type IntegrationUpdateAngabe, + type DongleAngabe, type ZugangAngabe, type Profil, type Tankvorgang, @@ -71,6 +72,9 @@ export interface DatenWert { Integration (aktualisierung.py), oder null vor der ersten Prüfung. */ integrationUpdate: IntegrationUpdateAngabe | null zugaenge: ZugangAngabe[] + /** Konfiguration des Dongles und eine ggf. vorgemerkte Aenderung + (flespi.py). null, solange kein flespi-Token hinterlegt ist. */ + dongle: DongleAngabe | null einstellungen: Einstellungen | null fahrzeug: Fahrzeug | null @@ -118,6 +122,7 @@ export function DatenAnbieter({ const [serverVersion, setzeServerVersion] = useState(null) const [otaBuendel, setzeOtaBuendel] = useState(null) const [zugaenge, setzeZugaenge] = useState([]) + const [dongle, setzeDongle] = useState(null) const [integrationUpdate, setzeIntegrationUpdate] = useState( null, ) @@ -162,6 +167,7 @@ export function DatenAnbieter({ setzeOtaBuendel(versionsangabe?.buendel ?? null) setzeIntegrationUpdate(versionsangabe?.integration_update ?? null) setzeZugaenge(versionsangabe?.zugaenge ?? []) + setzeDongle(versionsangabe?.dongle ?? null) return true } catch (fehler) { // Ein voruebergehender Ausfall ist kein Ladefehler, solange schon @@ -294,6 +300,7 @@ export function DatenAnbieter({ otaBuendel, integrationUpdate, zugaenge, + dongle, einstellungen, fahrzeug, fahrten, diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 65fcefe..6fc4d3e 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -15,7 +15,7 @@ import { Datumsfeld } from "./Datumsfeld" import { useDaten } from "../daten/DatenKontext" import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota" import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter" -import { datum, datumZeit, isoTag } from "../format" +import { datum, datumZeit, isoTag, standAlter } from "../format" import { useTheme } from "../theme" import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine" import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder" @@ -43,6 +43,7 @@ export function Einstellungen({ integrationUpdate, serverVersion, zugaenge, + dongle, verbindung, api, profilSpeichern, @@ -231,6 +232,28 @@ export function Einstellungen({ const [tokenLaeuft, setzeTokenLaeuft] = useState(null) const [tokenFehler, setzeTokenFehler] = useState(null) + // Wie tokenLaeuft daneben: der Abruf geht ueber zwei Netze (wir -> flespi -> + // Geraet) und darf nicht wie ein toter Knopf aussehen. Das Ergebnis selbst + // kommt ueber die veroeffentlichte Entitaet zurueck (flespi.py), hier steht + // nur, was den Aufruf gar nicht erst erreicht hat. + const [dongleLaeuft, setzeDongleLaeuft] = useState(false) + const [dongleFehler, setzeDongleFehler] = useState(null) + + const dongleAbrufen = async () => { + setzeDongleLaeuft(true) + setzeDongleFehler(null) + try { + await api.dongleLesen() + await neuLaden() + } catch (fehler) { + setzeDongleFehler( + fehler instanceof Error ? fehler.message : "Der Abruf ist gescheitert.", + ) + } finally { + setzeDongleLaeuft(false) + } + } + const tokenSchreiben = async (dienst: string, wert: string) => { setzeTokenLaeuft(dienst) setzeTokenFehler(null) @@ -375,22 +398,61 @@ export function Einstellungen({ Fahrt beenden aendern({ pausenzeitMin: min })} - anzeige={werte.pausenzeitMin === 0 ? "sofort" : werte.pausenzeitMin + " Min."} + anzeige={werte.pausenzeitMin + " Min."} aria-label="Minuten bis zum automatischen Fahrtende" /> {/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden Text unter einer Einstellung ist der Footer der Gruppe. */} Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die - Fahrt automatisch beendet werden soll. + Fahrt automatisch beendet werden soll. Derselbe Wert stellt den Schlaf-Timeout + des Dongles: so lange bleibt er nach dem Abstellen wach und wartet darauf, dass + es gleich weitergeht. Danach schläft er — das spart Strom, kostet beim Fortsetzen + aber ein paar Sekunden fürs Aufwachen. + {dongle?.aktiv && ( + + Dongle + {dongle.stand?.fehler &&

{dongle.stand.fehler}

} + {(dongle.stand?.werte ?? []).length > 0 && ( + ( + + ))} + /> + )} + void dongleAbrufen()}> + {dongleLaeuft ? "Liest …" : "Jetzt lesen"} + + {dongleFehler &&

{dongleFehler}

} + + {dongle.stand?.gelesen_am + ? `Gelesen ${standAlter(dongle.stand.gelesen_am)}. ` + : "Noch nicht gelesen. "} + Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich + das nächste Mal meldet, steht die Änderung als „unterwegs" daneben. + +
+ )} + Darstellung None: await k.ablage.profil_schreiben(_objekt(aufruf.data["profil_json"])) 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 + # die neue Reglerstellung mit. + await flespi.wunsch_uebernehmen(k) + + async def dongle_lesen(_aufruf: ServiceCall) -> None: + """Liest die Konfiguration des Dongles - auf ausdrücklichen Knopfdruck. + + Nötig ist der Knopf nicht: flespi antwortet auch bei schlafendem Gerät + aus seinem eigenen Speicher (siehe flespi.py). Er ist dafür da, + nachzusehen, ob eine geschriebene Änderung inzwischen am Gerät + angekommen ist. + + Ein Fehler wird als Anzeigestand hinterlegt statt geworfen: er gehört + in die Kachel, aus der der Knopf gedrückt wurde, nicht ins Protokoll.""" + try: + await flespi.jetzt_lesen(k) + except flespi.FlespiFehler as fehler: + _LOGGER.warning("Geräte-Konfiguration nicht gelesen: %s", fehler) + await k.flespi_stand_setzen( + {"fehler": str(fehler), "gelesen_am": flespi.jetzt_iso()} + ) async def entitaeten_schreiben(aufruf: ServiceCall) -> None: """Speichert die im Setup-Menü gewählte Sensor-Zuordnung, wendet sie @@ -362,6 +389,7 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None: D_ZUGANG_SETZEN: _als_dienst( lambda a: zugaenge.setzen(k, a.data["dienst"], a.data.get("token", "")) ), + D_DONGLE_LESEN: dongle_lesen, D_FAHRT_LOESCHEN: _als_dienst( lambda a: fahrterkennung.loeschen(k, a.data["trip_id"]) ), diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index b2fe58a..e0f0c1d 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -485,18 +485,47 @@ async def _spaetes_aus( ende = await _geraetezeit(k, ereigniszeit, jetzt) echt = ende - datetime.timedelta(seconds=ZUENDUNG_NACHLAUF_S) - juengste = None + # Zwei verschiedene Fragen, deshalb zwei Suchen ueber denselben Bestand: + # + # juengstes_ende beantwortet "gehoert das Aus zur zuletzt geschlossenen + # Fahrt?" - das ist der Verwerfen-Zweig unten. + # + # kandidat beantwortet "gibt es eine von Hand beendete Fahrt, deren Ende + # dieses Aus nachtraegt?". Bewusst NICHT auf die juengste Fahrt beschraenkt: + # trifft das gepufferte Aus erst ein, nachdem schon eine weitere Fahrt + # aufgezeichnet wurde, waere die vorlaeufige sonst fuer immer zu kurz. Es + # gewinnt die vorlaeufige Fahrt, deren Ende dem Aus am naechsten VOR ihm + # liegt - jede spaetere kann nicht gemeint sein. + fahrten = await k.ablage.fahrten_lesen() juengstes_ende = None - for fahrt in await k.ablage.fahrten_lesen(): + kandidat = None + kandidat_ende = None + for fahrt in fahrten: roh = fahrt.get("ts_end") if not roh: continue ts = zeit_normalisiert(roh) if juengstes_ende is None or ts > juengstes_ende: - juengste, juengstes_ende = fahrt, ts + juengstes_ende = ts + if fahrt.get("ende_vorlaeufig") and ts < echt: + if kandidat_ende is None or ts > kandidat_ende: + kandidat, kandidat_ende = fahrt, ts - if juengste is not None and await _vorlaeufiges_ende_korrigieren( - k, juengste, juengstes_ende, echt + # Eine Fahrt darf nicht ueber eine andere hinweg verlaengert werden. Faengt + # zwischen dem vorlaeufigen Ende und dem Aus eine Fahrt an, gehoert das Aus + # zu ihr oder zu etwas Spaeterem - jedenfalls nicht mehr zur vorlaeufigen, + # und zwei sich ueberlappende Fahrten waeren schlimmer als eine zu kurze. + if kandidat is not None and _fahrt_dazwischen(fahrten, kandidat, kandidat_ende, echt): + _LOGGER.info( + "Zuendungs-Aus (Geraetezeit %s) koennte die vorlaeufige Fahrt %s " + "verlaengern, aber dazwischen liegt bereits eine andere Fahrt - " + "nicht korrigiert", + echt.isoformat(), kandidat["trip_id"], + ) + kandidat = None + + if kandidat is not None and await _vorlaeufiges_ende_korrigieren( + k, kandidat, kandidat_ende, echt ): return @@ -517,6 +546,25 @@ async def _spaetes_aus( ) +def _fahrt_dazwischen( + fahrten: list[dict], + kandidat: dict, + kandidat_ende: datetime.datetime, + echt: datetime.datetime, +) -> bool: + """Faengt zwischen dem vorlaeufigen Ende und dem Aus eine andere Fahrt an?""" + for fahrt in fahrten: + if fahrt.get("trip_id") == kandidat.get("trip_id"): + continue + roh = fahrt.get("ts_start") + if not roh: + continue + beginn = zeit_normalisiert(roh) + if kandidat_ende < beginn <= echt: + return True + return False + + async def _vorlaeufiges_ende_korrigieren( k: Koordinator, fahrt: dict, @@ -532,10 +580,17 @@ async def _vorlaeufiges_ende_korrigieren( verworfen. Drei Bedingungen, jede aus einem eigenen Grund: nur eine als vorlaeufig - gekennzeichnete Fahrt (eine regulaer geschlossene ist bereits richtig), - nur ein SPAETERES Ende (frueher waere kein Nachtrag, sondern eine - Verkuerzung), und nur innerhalb von KORREKTUR_FENSTER_S (sonst gehoert das - Aus wahrscheinlich zu einer ganz anderen Fahrt). + gekennzeichnete Fahrt (eine regulaer geschlossene ist bereits richtig - ihr + Ende IST dieses Signal, ein zweites waere nur die gepufferte Kopie), nur ein + SPAETERES Ende (frueher waere kein Nachtrag, sondern eine Verkuerzung), und + nur innerhalb von KORREKTUR_FENSTER_S. + + Zum Fenster: gemessen wird zwischen dem vorlaeufigen Ende und der + GERAETEZEIT des Aus - nicht zwischen Knopfdruck und Eintreffen. Ein + Datensatz, der eine Woche in der Tiefgarage liegt, traegt trotzdem seinen + eigenen Zeitstempel von damals (siehe verlauf.geraetezeit); das Fenster + begrenzt also, wie lange nach dem letzten Lebenszeichen noch gefahren + worden sein kann, nicht wie lange das Geraet puffern darf. ts_end wandert bewusst NICHT in edited_fields: das Feld ist keine Handeingabe, und dort eingetragen wuerde es genau diese Korrektur beim diff --git a/custom_components/audi_dashboard/flespi.py b/custom_components/audi_dashboard/flespi.py new file mode 100644 index 0000000..4375750 --- /dev/null +++ b/custom_components/audi_dashboard/flespi.py @@ -0,0 +1,525 @@ +"""Liest und schreibt die Konfiguration des FMM003 über flespi. + +WARUM ÜBERHAUPT +--------------- +Zwei Zahlen dieser Integration gehören eigentlich dem Gerät und stehen bei uns +nur als Konstante bzw. Einstellung: `NACHLAUF_S` (Trip: Ignition OFF Timeout) +und die Wartezeit bis zum Fahrtende. Ändert jemand die Gegenstücke am Dongle, +rechnet die Fahrterkennung still weiter mit dem alten Wert - und niemand merkt +es. Genau das ist am 01.09.2026 passiert, als `400` von 0 auf 180 ging: seither +endeten alle Fahrten 180 s zu spät, gefunden erst zwei Tage später beim +Nachmessen. + +DER REGLER IST DIE EINE STELLE +------------------------------ +"Fahrt beenden" in den Einstellungen ist der Schlaf-Timeout des Dongles +(Parameter `103`, in Minuten) - und zugleich unsere eigene Wartezeit, bevor wir +eine Fahrt schliessen. Beides ist derselbe Sachverhalt: + +* So lange bleibt der Dongle nach dem Zündungs-Aus wach und wartet darauf, dass + es unmittelbar weitergeht. Schläft er erst, dauert das Aufwachen einige + Sekunden - bei einem Kaltstart egal, beim Fortsetzen einer Fahrt nicht. +* Genau so lange darf für uns eine zurückkehrende Zündung noch dieselbe Fahrt + sein. + +Vor dem Timeout laufen zusätzlich die 180 s `Ignition OFF Delay` (`400`) des +Geräts. Die muss der Nutzer nicht kennen - sie werden von der Fahrtdauer +abgezogen (`fahrterkennung.ZUENDUNG_NACHLAUF_S`), nicht angezeigt. + +Denselben Sachverhalt an zwei Stellen einzustellen ist die Sorte Doppelung, die +in diesem Projekt schon mehrfach auseinandergelaufen ist. Der Regler schreibt +deshalb beides: unsere Wartezeit und `103`. + +FLESPI PUFFERT SELBST - DESHALB KEINE EIGENE WARTESCHLANGE +----------------------------------------------------------- +Erst war hier ein eigenes Auftragsbuch gebaut, weil "Lesen und Schreiben gehen +nur bei aktiver Zündung" - der Dongle schläft ja. **Das gilt für das Gerät, aber +nicht für diese Schnittstelle**, und das ist gemessen, nicht angenommen: die +vollständige Konfiguration kam am 03.09.2026 herein, während das Fahrzeug seit +dem Vortag stand. + +flespi ist eine Ebene über dem Gerät. Es kennt zu jeder Einstellung `current` +(was das Gerät zuletzt gemeldet hat) und `pending` (was noch zu ihm unterwegs +ist), und `"address": "connection"` heisst ausdrücklich: zustellen, sobald sich +das Gerät das nächste Mal meldet. Ein zweites Auftragsbuch bei uns wäre eine +zweite Warteschlange für dieselbe Aufgabe - mit dem zusätzlichen Nachteil, dass +sie nur raten könnte, was die erste schon weiss. + +Geschrieben wird deshalb sofort, und was noch aussteht, steht in `pending` und +wird so auch angezeigt. + +WAS DIE ANTWORT WIRKLICH ENTHÄLT +-------------------------------- +Auch das ist gemessen, und es hat zwei Annahmen widerlegt (deshalb steht es hier +und nicht nur im Code): + +* Der Wert steht in **`current`**, nicht in `value`. +* Die Einstellung heisst **`sleep_mode`**, nicht `sleep_settings`, und der Wert + liegt eine Ebene tiefer unter `mode`. +* Ein PUT braucht `{"properties": ..., "address": ...}` - ohne `address` + antwortet flespi mit `400 the following properties are missing: address`. +* `GET .../settings/all` liefert die gefüllten Einträge; ein Abruf einer + einzelnen Einstellung kam leer zurück. Gelesen wird deshalb immer `all`. + +Der `Ignition OFF Delay` (`400`) taucht unter keinem Namen auf, den wir sicher +zuordnen könnten. Er wird deshalb **nicht** angezeigt - lieber eine Zeile +weniger als eine falsch beschriftete. + +WAS DER TOKEN DARF +------------------ +Ein flespi-Token lässt sich per ACL begrenzen. Dieser Weg braucht Lesen **und** +Schreiben der Geräte-Konfiguration; mehr sollte er nicht dürfen, dann kann auch +ein Fehler in diesem Modul nichts anderes am Fahrzeug verändern. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +from typing import TYPE_CHECKING + +import aiohttp +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_FLESPI_GERAET, CONF_FLESPI_TOKEN + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from .koordinator import Koordinator + +_LOGGER = logging.getLogger(__name__) + +BASIS = "https://flespi.io" +ZEITLIMIT = aiohttp.ClientTimeout(total=20) + +# Die Einstellung, die der Regler schreibt. Der Wert liegt verschachtelt: +# current = {"mode": {"timeout": 15, "type": 2, ...}}. +SCHLAF_NAME = "sleep_mode" +SCHLAF_OBJEKT = "mode" +SCHLAF_FELD = "timeout" +SCHLAF_SCHLUESSEL = f"{SCHLAF_NAME}.{SCHLAF_OBJEKT}.{SCHLAF_FELD}" + +# Grenzen des Geräts (Angabe des Eigentümers): 1 bis 3000 Minuten. Unser Regler +# bleibt deutlich enger; diese Grenzen sind nur der Riegel davor, dass eine 0 +# oder ein Ausreisser an das Gerät geht. +TIMEOUT_MIN = 1 +TIMEOUT_MAX = 3000 + +# "sobald sich das Gerät das nächste Mal meldet" - die Zustellart, die den +# schlafenden Dongle überhaupt erreichbar macht. Siehe Modulkopf. +ZUSTELLUNG = "connection" + +# Was angezeigt wird, mit der Nummer aus dem Configurator daneben - danach sucht +# der Eigentümer dort. Die Pfade sind die gemessenen, nicht die vermuteten. +# +# "unser" nennt die Konstante dieser Integration, gegen die verglichen wird; +# None heisst: nur anzeigen, es gibt nichts zu vergleichen. +INTERESSANT: dict[str, dict] = { + SCHLAF_SCHLUESSEL: { + "nummer": "103", + "titel": "Sleep Timeout", + "einheit": "Min.", + "unser": "regler", + }, + "sleep_mode.mode.type": { + "nummer": "102", + "titel": "Sleep Mode", + "einheit": "", + "unser": None, + }, + "trip_scenario.ign_off_timeout": { + "nummer": "11804", + "titel": "Trip: Ignition OFF Timeout", + "einheit": "s", + "unser": "NACHLAUF_S", + }, + "trip_scenario.start_speed": { + "nummer": "11803", + "titel": "Trip: Start Speed", + "einheit": "km/h", + "unser": None, + }, + "odometer_fmb.source": { + "nummer": "11806", + "titel": "Odometer-Quelle", + "einheit": "", + "unser": None, + }, +} + + +class FlespiFehler(Exception): + """Der Vorgang ist gescheitert - mit einem Satz, der dem Nutzer etwas sagt.""" + + +# ------------------------------------------------------------------ Zugang + + +def zugang(k: Koordinator) -> tuple[str, int | None] | None: + """Token und Geräte-Nummer, oder None wenn kein Token hinterlegt ist. + + Die Nummer darf fehlen - dann wird sie einmal ermittelt, sofern der Token + genau ein Gerät sieht (siehe `geraet_finden`). + """ + token = (k.entry.options.get(CONF_FLESPI_TOKEN) or "").strip() + if not token: + return None + roh = k.entry.options.get(CONF_FLESPI_GERAET) + try: + geraet = int(roh) if roh else None + except (TypeError, ValueError): + geraet = None + return token, geraet + + +# ------------------------------------------------------------------ HTTP + + +def _kopf(token: str) -> dict[str, str]: + return {"Authorization": f"FlespiToken {token}"} + + +def _fehlertext(status: int) -> str | None: + if status in (401, 403): + return ( + "flespi hat den Token abgelehnt. Er muss die Geräte-Konfiguration " + "lesen und schreiben dürfen." + ) + if status == 404: + return "flespi kennt dieses Gerät nicht. Stimmt die Geräte-Nummer?" + return None + + +async def _anfrage( + hass: HomeAssistant, token: str, verb: str, pfad: str, nutzlast: dict | None = None +) -> dict: + session = async_get_clientsession(hass) + try: + async with session.request( + verb, + f"{BASIS}{pfad}", + headers=_kopf(token), + json=nutzlast, + timeout=ZEITLIMIT, + ) as antwort: + text = _fehlertext(antwort.status) + if text: + raise FlespiFehler(text) + if antwort.status != 200: + # flespi nennt im Rumpf den Grund - der ist für die Fehlersuche + # mehr wert als die blosse Nummer (so kam die fehlende + # "address" heraus, siehe Modulkopf). + grund = (await antwort.text())[:200] + raise FlespiFehler(f"flespi antwortete mit {antwort.status}: {grund}") + return await antwort.json() + except asyncio.TimeoutError as fehler: + raise FlespiFehler("flespi hat nicht rechtzeitig geantwortet.") from fehler + except aiohttp.ClientError as fehler: + raise FlespiFehler(f"flespi war nicht erreichbar ({fehler}).") from fehler + + +async def geraet_finden(hass: HomeAssistant, token: str) -> int: + """Die Geräte-Nummer, wenn der Token genau ein Gerät sieht. + + Sieht er mehrere, wird **nicht** geraten - dann muss die Nummer eingetragen + werden. Ein falsch gewähltes Gerät läse und schriebe die Konfiguration + eines fremden Trackers, und das fiele niemandem auf. + """ + daten = await _anfrage(hass, token, "GET", "/gw/devices/all") + geraete = daten.get("result") or [] + if not geraete: + raise FlespiFehler("Dieser Token sieht kein einziges Gerät.") + if len(geraete) > 1: + namen = ", ".join(str(g.get("name") or g.get("id")) for g in geraete[:5]) + raise FlespiFehler( + f"Dieser Token sieht {len(geraete)} Geräte ({namen}) - bitte die " + "Geräte-Nummer in den Einstellungen der Integration eintragen." + ) + return int(geraete[0]["id"]) + + +# ------------------------------------------------------ Antwort aufbereiten + + +def _flachen(ziel: dict[str, object], praefix: str, wert: object) -> None: + """Verschachtelte Objekte als "a.b.c" ablegen, Blätter als Wert. + + `sleep_mode` bringt seinen Wert eine Ebene tiefer unter `mode`; ohne diese + Abflachung wäre der Timeout nicht einzeln ansprechbar. + """ + ziel[praefix] = wert + if isinstance(wert, dict): + for name, unter in wert.items(): + _flachen(ziel, f"{praefix}.{name}", unter) + + +def _sicht(eintraege: list[dict], feld: str) -> dict[str, object]: + """Alle Einstellungen als "Name -> Wert", gelesen aus `current` bzw. `pending`.""" + flach: dict[str, object] = {} + for eintrag in eintraege: + name = eintrag.get("name") + if not name: + continue + wert = eintrag.get(feld) + if wert is None: + continue + _flachen(flach, name, wert) + return flach + + +def _roh(eintraege: list[dict], name: str) -> dict | None: + for eintrag in eintraege: + if eintrag.get("name") == name: + wert = eintrag.get("current") + return wert if isinstance(wert, dict) else None + return None + + +def vergleichen( + aktuell: dict[str, object], + offen: dict[str, object], + wunsch: int | None, +) -> list[dict]: + """Die interessanten Einstellungen mit unserem eigenen Wert daneben.""" + from . import fahrterkennung + + zeilen = [] + for schluessel, angabe in INTERESSANT.items(): + if schluessel not in aktuell and schluessel not in offen: + continue + geraet = aktuell.get(schluessel) + if angabe["unser"] == "regler": + unser: object = wunsch + elif angabe["unser"]: + unser = getattr(fahrterkennung, angabe["unser"], None) + else: + 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) + ) + zeilen.append( + { + "schluessel": schluessel, + "nummer": angabe["nummer"], + "titel": angabe["titel"], + "einheit": angabe["einheit"], + "geraet": geraet, + # Was noch zum Gerät unterwegs ist - None, wenn nichts aussteht. + "offen": offen.get(schluessel), + "unser": unser, + "abweichung": abweichung, + } + ) + return zeilen + + +def jetzt_iso() -> str: + """Jetzt, als ISO-Zeichenkette in UTC - der Zeitstempel jedes Anzeigestands.""" + return datetime.datetime.now(datetime.UTC).isoformat() + + +# ------------------------------------------------------------------ Lesen + + +async def _geraet_und_token(k: Koordinator) -> tuple[str, int]: + daten = zugang(k) + if daten is None: + raise FlespiFehler( + "Kein flespi-Token hinterlegt - unter Einstellungen → Zugänge eintragen." + ) + token, geraet = daten + if not geraet: + geraet = await geraet_finden(k.hass, token) + return token, geraet + + +async def _alle_einstellungen( + hass: HomeAssistant, token: str, geraet: int +) -> list[dict]: + daten = await _anfrage(hass, token, "GET", f"/gw/devices/{geraet}/settings/all") + eintraege = daten.get("result") or [] + if not eintraege: + raise FlespiFehler("flespi hat keine Einstellungen zu diesem Gerät.") + return eintraege + + +def _stand(geraet: int, eintraege: list[dict], wunsch: int | None) -> dict: + aktuell = _sicht(eintraege, "current") + offen = _sicht(eintraege, "pending") + werte = vergleichen(aktuell, offen, wunsch) + return { + "geraet": geraet, + "gelesen_am": jetzt_iso(), + "anzahl": len(eintraege), + "werte": werte, + "abweichungen": sum(1 for z in werte if z["abweichung"]), + "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.""" + token, geraet = await _geraet_und_token(k) + eintraege = await _alle_einstellungen(k.hass, token, geraet) + return _stand(geraet, eintraege, wunsch_timeout(k)) + + +# --------------------------------------------------------------- Schreiben + + +def wunsch_timeout(k: Koordinator) -> int | None: + """Der Wert, den `103` haben soll - aus der Reglerstellung. + + None, solange das Profil noch nicht gelesen wurde; dann gibt es nichts zu + vergleichen und nichts zu schreiben. + """ + minuten = k.pausenzeit_min + if minuten is None: + return None + return max(TIMEOUT_MIN, min(TIMEOUT_MAX, int(minuten))) + + +async def schlaf_timeout_schreiben(k: Koordinator, minuten: int) -> dict: + """Setzt `103` am Gerät - lesen, schreiben, gegenlesen. + + Gegengelesen wird, weil eine Änderung am Fahrzeug nur dann vertretbar ist, + wenn nachher belegt ist, dass sich genau die eine gewollte Stelle geändert + hat (Vorgabe des Eigentümers). "Geändert" heisst hier zweierlei, und beides + zählt als Erfolg: + + * `current` trägt den neuen Wert - das Gerät war wach und hat ihn schon. + * `pending` trägt ihn - flespi hält ihn vor und stellt ihn zu, sobald sich + das Gerät meldet. Das ist der Normalfall bei stehendem Fahrzeug. + + Steht er hinterher in keinem von beiden, ist der Schreibvorgang nicht + angekommen und das wird als Fehler gemeldet, nicht als Erfolg. + """ + minuten = max(TIMEOUT_MIN, min(TIMEOUT_MAX, int(minuten))) + token, geraet = await _geraet_und_token(k) + + vorher_roh = await _alle_einstellungen(k.hass, token, geraet) + vorher = _sicht(vorher_roh, "current") + offen_vorher = _sicht(vorher_roh, "pending") + if ( + 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) + return _stand(geraet, vorher_roh, wunsch_timeout(k)) + + # Das ganze Objekt zurückschreiben, nur mit geändertem Feld: `type` und die + # übrigen Felder gehören dazu und dürfen beim Schreiben nicht verlorengehen. + alt = (_roh(vorher_roh, SCHLAF_NAME) or {}).get(SCHLAF_OBJEKT) + neu = ( + {**alt, SCHLAF_FELD: minuten} + if isinstance(alt, dict) + else {SCHLAF_FELD: minuten} + ) + await _anfrage( + k.hass, + token, + "PUT", + f"/gw/devices/{geraet}/settings/{SCHLAF_NAME}", + {"properties": {SCHLAF_OBJEKT: neu}, "address": ZUSTELLUNG}, + ) + + nachher_roh = await _alle_einstellungen(k.hass, token, geraet) + nachher = _sicht(nachher_roh, "current") + offen = _sicht(nachher_roh, "pending") + + if nachher.get(SCHLAF_SCHLUESSEL) == minuten: + _LOGGER.info( + "Sleep Timeout (103) am Gerät %s auf %s Min. gesetzt - das Gerät hat ihn", + geraet, + minuten, + ) + elif offen.get(SCHLAF_SCHLUESSEL) == minuten: + _LOGGER.info( + "Sleep Timeout (103) auf %s Min. bei flespi hinterlegt - das Gerät " + "bekommt ihn, sobald es sich meldet", + minuten, + ) + else: + raise FlespiFehler( + "Der Schreibvorgang ist nicht angekommen: das Gerät steht weiter auf " + f"{vorher.get(SCHLAF_SCHLUESSEL)} und es liegt nichts bereit." + ) + + # Nur melden, nicht abbrechen: das Schreiben ist zu diesem Zeitpunkt + # geschehen, und ein Abbruch würde daran nichts mehr ändern - eine Warnung + # dagegen führt zu der Stelle, an der jemand nachsehen muss. + fremd = [ + s + for s in sorted(set(vorher) | set(nachher)) + if vorher.get(s) != nachher.get(s) and not s.startswith(SCHLAF_NAME) + ] + if fremd: + _LOGGER.warning( + "Am Gerät hat sich mehr geändert als gewollt: %s", ", ".join(fremd[:5]) + ) + + return _stand(geraet, nachher_roh, wunsch_timeout(k)) + + +async def wunsch_uebernehmen(k: Koordinator) -> None: + """Bringt `103` auf die Reglerstellung, wenn beide auseinanderliegen. + + Wird nach jedem Profil-Schreiben und einmal nach dem Start aufgerufen und + ist deshalb bewusst zustandsvergleichend statt ereignisgetrieben: sie muss + auch dann zum Ziel führen, wenn eine frühere Änderung nie ankam (flespi + nicht erreichbar, Neustart mittendrin). + + Ohne hinterlegten Token passiert gar nichts - dann ist der Regler wie bisher + nur unsere eigene Wartezeit. + """ + if zugang(k) is None: + return + ziel = wunsch_timeout(k) + if ziel is None: + return + + # Erst der billige Vergleich gegen den zuletzt gelesenen Stand: stimmt er + # schon, kostet das Speichern eines Profils keine einzige Anfrage. Ein noch + # ausstehender Wert (`offen`) zählt dabei wie ein gesetzter - er ist schon + # unterwegs. + if k.flespi_stand: + for zeile in k.flespi_stand.get("werte", []): + if zeile["schluessel"] != SCHLAF_SCHLUESSEL: + continue + steht = zeile["offen"] if zeile["offen"] is not None else zeile["geraet"] + if isinstance(steht, (int, float)) and int(steht) == ziel: + return + + if k.flespi_sperre.locked(): + return + async with k.flespi_sperre: + try: + stand = await schlaf_timeout_schreiben(k, ziel) + except FlespiFehler as fehler: + _LOGGER.warning("Sleep Timeout konnte nicht gesetzt werden: %s", fehler) + await k.flespi_stand_setzen( + {"fehler": str(fehler), "gelesen_am": jetzt_iso()} + ) + return + await k.flespi_stand_setzen(stand) + + +async def jetzt_lesen(k: Koordinator) -> None: + """Der Knopf "Jetzt lesen": Stand holen und hinterlegen. + + Fehler fliegen weiter - hier hat jemand gedrückt und wartet auf eine + Antwort, auch auf ein "ging nicht". + """ + async with k.flespi_sperre: + stand = await lesen(k) + 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. + await wunsch_uebernehmen(k) diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 4d99edb..946118e 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.8","sha256":"f1d22bf45afb23685ed3c0a660b4ef5908e0194f19a1461af6ae928d0163ecdc","bytes":268607,"gebaut":"2026-09-03T13:06:29Z"} \ No newline at end of file +{"version":"2026.9.3.10","sha256":"f1a9a5c2ff1ef7fbd29c679e1b1a374d77c57da4f49f864b3758219483bbb013","bytes":269156,"gebaut":"2026-09-03T17:59:17Z"} \ 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 f55de70..74bf7cb 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 1185853..d7974ae 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -1834,9 +1834,9 @@ function terminText(t) { Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24, dann Tage. */ -function standAlterText() { - if (LETZTES_UPDATE_TS == null) return "unbekannt"; - const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_TS) / 1000)); +function alterText(ts) { + if (ts == null || !Number.isFinite(ts)) return "unbekannt"; + const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000)); if (sekunden < 60) return `vor ${sekunden} Sek.`; const minuten = Math.floor(sekunden / 60); if (minuten < 60) return `vor ${minuten} Min.`; @@ -1844,6 +1844,9 @@ function standAlterText() { if (stunden < 24) return `vor ${stunden} Std.`; return `vor ${Math.floor(stunden / 24)} Tage`; } +function standAlterText() { + return alterText(LETZTES_UPDATE_TS); +} function standAlterTicken() { if (!ROOT || !CAR) return; const sy = $("#sync span"); if (sy) sy.textContent = standAlterText(); @@ -3100,16 +3103,47 @@ function vEinst() { ` : ""}
Fahrt beenden
- - ${CONFIG.pausenzeit === 0 ? "sofort" : CONFIG.pausenzeit + " Min."} + ${CONFIG.pausenzeit} Min.
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die - Fahrt automatisch beendet werden soll. + Fahrt automatisch beendet werden soll. Derselbe Wert stellt den Schlaf-Timeout + des Dongles: so lange bleibt er nach dem Abstellen wach und wartet darauf, + dass es gleich weitergeht. Danach schläft er — das spart Strom, kostet beim + Fortsetzen aber ein paar Sekunden fürs Aufwachen.
+ ${DONGLE && DONGLE.aktiv ? `
Dongle + ${DONGLE.stand && DONGLE.stand.fehler + ? `${esc(DONGLE.stand.fehler)}` + : ""} + ${DONGLE.stand && (DONGLE.stand.werte || []).length ? `
+ ${DONGLE.stand.werte.map((w) => `
+
${esc(w.titel)} ${esc(w.nummer)}
+ ${ + w.geraet === null || w.geraet === undefined ? "–" : esc(String(w.geraet)) + }${w.einheit ? " " + esc(w.einheit) : ""}${ + w.offen !== null && w.offen !== undefined + ? ` → ${esc(String(w.offen))}, unterwegs` + : w.abweichung ? ` (wir: ${esc(String(w.unser))})` : "" + } +
`).join("")} +
` : ""} + + + ${DONGLE.stand && DONGLE.stand.gelesen_am + ? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)))}. ` + : "Noch nicht gelesen. "}Lesen und Schreiben gehen nur bei laufender Zündung — + Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich + das nächste Mal meldet, steht die Änderung als „unterwegs" daneben. +
` : ""}
Darstellung
@@ -4158,6 +4192,13 @@ let INTEGRATION_UPDATE = null; /* Nur "gesetzt/nicht gesetzt" je Dienst - der Token selbst kommt nie ueber die Leitung, siehe zugaenge.py im Backend. */ let ZUGAENGE = []; +/* Was am Dongle steht, was noch aussteht - siehe flespi.py im Backend. + null, solange kein flespi-Token hinterlegt ist. */ +let DONGLE = null; +// true, waehrend "Jetzt lesen" laeuft. Wie INTEGRATION_UPDATE_LAEUFT und aus +// demselben Grund: der Abruf geht ueber zwei Netze (wir -> flespi -> Geraet) +// und darf nicht wie ein toter Knopf aussehen. +let DONGLE_LAEUFT = false; // "pruefen" | "installieren" | "neustart" | null - während einer dieser drei // Aktionen läuft, siehe vEinst()'s Integration-Update-Kachel. Eigens statt // über serviceRufen() (das feuert-und-vergisst), weil diese drei Aktionen @@ -6001,6 +6042,18 @@ function ereignisseVerdrahten() { // div ohne data-go, aber die Reihenfolge haelt das auch dann, wenn die // Kachel spaeter einmal anders gebaut wird. if (e.target.closest("[data-fahrtbeenden]")) { serviceRufen("fahrt_jetzt_beenden", {}); return; } + if (e.target.closest("[data-dongle]")) { + // Nicht ueber serviceRufen(): das feuert und vergisst, hier soll der Knopf + // aber zeigen, dass gerade zwei Netze befragt werden - und danach wieder + // freigeben. Das Ergebnis (auch ein Fehler) kommt ueber die veroeffentlichte + // Entitaet zurueck, siehe flespi.py; deshalb hier nur der Ladezustand. + if (DONGLE_LAEUFT) return; + DONGLE_LAEUFT = true; render(); + HASS.callService(DOMAIN, "dongle_lesen", {}) + .catch((f) => console.error("dongle_lesen", f)) + .finally(() => { DONGLE_LAEUFT = false; render(); }); + return; + } const tokSpeichern = e.target.closest("[data-tokenspeichern]"); if (tokSpeichern) { const dienst = tokSpeichern.dataset.tokenspeichern; @@ -6391,6 +6444,9 @@ async function datenLaden(erzwingen) { ZUGAENGE = versionState.attributes.daten ? versionState.attributes.daten.zugaenge ?? [] : []; + DONGLE = versionState.attributes.daten + ? versionState.attributes.daten.dongle ?? null + : null; } if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten; diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index 10f077e..1dc1a13 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -50,6 +50,7 @@ from homeassistant.helpers.storage import Store from . import ( batterie, fahrterkennung, + flespi, identitaet, reifen, screening, @@ -145,6 +146,20 @@ class Koordinator: # liest diesen Wert bei jedem Takt erneut mit, damit er nicht # zwischen zwei Prüfungen verschwindet. self.letzte_update_pruefung: dict | None = None + # Die Reglerstellung "Fahrt beenden" in Minuten, aus dem Profil + # mitgelesen. Sie ist zugleich der Sollwert fuer den Schlaf-Timeout + # des Dongles (103) - siehe flespi.py. None = noch nicht gelesen. + self.pausenzeit_min: int | None = None + # Der zuletzt gelesene Stand der Geraete-Konfiguration. Ueberlebt einen + # Neustart, damit die Anzeige nicht auf "unbekannt" zurueckfaellt, + # obwohl sie erst eine Stunde alt ist - und damit wunsch_uebernehmen() + # ohne Netzabfrage weiss, ob ueberhaupt etwas zu tun ist. Was noch zum + # Geraet unterwegs ist, steht darin als "offen" (siehe flespi.py); + # eine eigene Warteschlange gibt es bewusst nicht. + self.flespi_stand: dict | None = None + # Zwei Wege koennen gleichzeitig schreiben wollen (Profil gespeichert, + # Knopf gedrueckt) - der zweite haette nichts mehr zu tun. + self.flespi_sperre = asyncio.Lock() self._takte: list[CALLBACK_TYPE] = [] self._beobachter: list[CALLBACK_TYPE] = [] @@ -198,6 +213,10 @@ class Koordinator: # Fahrt warten. Reicht das Ortsbudget eines Laufs nicht, plant das # Screening selbst einen zweiten ein (screening._nachfassen_planen). await screening.durchfuehren(self) + # Weicht die Reglerstellung vom zuletzt gelesenen Geraetewert ab, + # wird die Aenderung hier vorgemerkt - auch wenn sie waehrend eines + # Ausfalls gesetzt wurde oder ein frueherer Auftrag nie zum Zug kam. + await flespi.wunsch_uebernehmen(self) async def beenden(self) -> None: if self.screening_nachlauf is not None: @@ -385,6 +404,7 @@ class Koordinator: ) geladen = None self.geparkt_seit_ts = geladen + self.flespi_stand = gespeichert.get("flespi_stand") async def _laufzeit_sichern(self) -> None: await self._store.async_save({ @@ -394,8 +414,14 @@ class Koordinator: "geparkt_seit_ts": ( self.geparkt_seit_ts.isoformat() if self.geparkt_seit_ts else None ), + "flespi_stand": self.flespi_stand, }) + async def flespi_stand_setzen(self, stand: dict | None) -> None: + self.flespi_stand = stand + await self._laufzeit_sichern() + await self.app_version_veroeffentlichen() + async def fahrt_start_setzen(self, ts: datetime.datetime | None) -> None: self.fahrt_start_ts = ts await self._laufzeit_sichern() @@ -501,6 +527,15 @@ class Koordinator: if profil is None: return self.setzen(E_PROFIL, "aktuell", profil) + # Die Reglerstellung mitlesen: sie ist der Sollwert für den + # Schlaf-Timeout des Dongles, und flespi.py hat keinen eigenen + # Zugriff auf das Profil. + try: + self.pausenzeit_min = int( + (profil.get("einstellungen") or {})["fahrten_pausenzeit_min"] + ) + except (KeyError, TypeError, ValueError): + self.pausenzeit_min = None # Die drei Reifen-Kontrollwerte hängen am selben Profil - zusammen # veröffentlichen, damit sie nicht auseinanderlaufen können. # Die beiden km-Stände bleiben Zahlen (die Entitäten tragen die @@ -595,6 +630,12 @@ class Koordinator: # der Token selbst verlaesst die Integration nie, siehe # zugaenge.py. "zugaenge": zugaenge.stand(self), + # Was am Dongle steht, was noch aussteht, und wie alt die + # Angabe ist - siehe flespi.py. + "dongle": { + "aktiv": flespi.zugang(self) is not None, + "stand": self.flespi_stand, + }, }, ) diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 1328193..1c3b139 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.8", + "version": "2026.9.3.10", "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"], diff --git a/custom_components/audi_dashboard/services.yaml b/custom_components/audi_dashboard/services.yaml index 8d42781..a985a0b 100644 --- a/custom_components/audi_dashboard/services.yaml +++ b/custom_components/audi_dashboard/services.yaml @@ -345,3 +345,6 @@ zugang_setzen: required: false selector: text: + +dongle_lesen: + fields: {} diff --git a/custom_components/audi_dashboard/zugaenge.py b/custom_components/audi_dashboard/zugaenge.py index 3e49cd6..695bb56 100644 --- a/custom_components/audi_dashboard/zugaenge.py +++ b/custom_components/audi_dashboard/zugaenge.py @@ -61,7 +61,11 @@ DIENSTE: dict[str, dict] = { "flespi": { "schluessel": CONF_FLESPI_TOKEN, "titel": "flespi", - "zweck": "Liest die Konfiguration des Dongles (noch nicht in Gebrauch).", + "zweck": ( + "Liest die Konfiguration des Dongles und schreibt den Schlaf-Timeout. " + "Der Token braucht dafür Lese- und Schreibrecht auf die " + "Geräte-Konfiguration." + ), "erforderlich": False, }, } diff --git a/tests/fahrterkennung/test_fahrtende_knopf.py b/tests/fahrterkennung/test_fahrtende_knopf.py index 8a4452a..8a3994c 100644 --- a/tests/fahrterkennung/test_fahrtende_knopf.py +++ b/tests/fahrterkennung/test_fahrtende_knopf.py @@ -220,9 +220,10 @@ class Nachziehen(Basis): ) self.assertEqual(k.ablage.aktualisiert, []) - async def test_nur_die_juengste_fahrt_wird_korrigiert(self): - """Eine aeltere vorlaeufige Fahrt darf ein spaetes Aus nicht an sich - ziehen - sonst wanderte ihr Ende ueber die naechste Fahrt hinweg.""" + async def test_keine_verlaengerung_ueber_eine_andere_fahrt_hinweg(self): + """Faengt zwischen dem vorlaeufigen Ende und dem Aus eine andere Fahrt + an, gehoert das Aus zu ihr - zwei sich ueberlappende Fahrten waeren + schlimmer als eine zu kurze.""" alt_start = T0 - datetime.timedelta(hours=3) k = FakeKoordinator( start_ts=None, @@ -236,9 +237,29 @@ class Nachziehen(Basis): k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S) ) self.assertEqual( - k.ablage.aktualisiert, [], "die juengste Fahrt ist regulaer geschlossen" + k.ablage.aktualisiert, [], "dazwischen liegt der Beginn von t-neu" ) + async def test_aeltere_vorlaeufige_fahrt_wird_gefunden(self): + """Die Suche ist NICHT auf die juengste Fahrt beschraenkt: eine aeltere + regulaer geschlossene Fahrt darf die Korrektur nicht verdecken.""" + frueher = T0 - datetime.timedelta(hours=5) + k = FakeKoordinator( + start_ts=None, + fahrten=[ + fahrt("t-frueh", frueher, frueher + datetime.timedelta(minutes=20), False), + fahrt("t-hand", T0, LEBENSZEICHEN, True), + ], + ) + echt = LEBENSZEICHEN + datetime.timedelta(minutes=8) + await self._spaetes_aus( + k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S) + ) + self.assertEqual(len(k.ablage.aktualisiert), 1) + trip_id, aenderungen = k.ablage.aktualisiert[0] + self.assertEqual(trip_id, "t-hand") + self.assertEqual(aenderungen["ts_end"], echt.isoformat()) + if __name__ == "__main__": unittest.main(verbosity=2)