"""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. # flespi legt beim Schreiben das GANZE Objekt als pending ab, # also stünde auch jedes unveränderte Nachbarfeld als # "unterwegs" da ("Sleep Mode 2 -> 2"). Gezeigt wird deshalb # nur, was sich tatsächlich unterscheidet. "offen": ( offen.get(schluessel) if schluessel in offen and offen.get(schluessel) != geraet else None ), "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") schon_da = ( vorher.get(SCHLAF_SCHLUESSEL) == minuten and offen_vorher.get(SCHLAF_SCHLUESSEL) is None ) # 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 # ü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: # 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 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) # "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(), "versucht": ziel} ) 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)