Files
audi-app/custom_components/audi_dashboard/flespi.py
T
tobias 2736c3d6e0 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>
2026-09-03 21:03:17 +02:00

553 lines
21 KiB
Python

"""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)