Files
audi-app/custom_components/audi_dashboard/flespi.py
T
tobias 255a593b50 Ein Update-Knopf, Modell als Auswahlfeld, abgestellt schliesst fahrend aus
- Die Update-Kachel hat genau einen Knopf am Ende. Er traegt in jedem
  Zustand den naechsten faelligen Schritt (suchen, installieren, Neustart,
  Neuladen); Beschriftung und Fuellung wechseln mit. Die Abschnitte
  darueber behalten ihre Werte und verlieren nur ihre Knoepfe.
- Abstand unter der Trennlinie von 28 auf 16 px: das padding-top verhindert
  das Kollabieren des margin der Werteliste, also zaehlt jetzt nur noch das
  padding.
- Modell ist in der App ein Auswahlfeld statt eines Textfelds, wie im
  Panel. Die Liste liegt als Zweitschrift in daten/fahrzeugmodelle.ts;
  fahrzeugmodelle.test.ts liest die Panel-Quelle ein und vergleicht sie
  Eintrag fuer Eintrag, damit die beiden nicht auseinanderlaufen.
- Faehrt der Wagen, sagen Uebersicht und Sicherheitsseite das, statt
  "sicher abgestellt" zu behaupten. Die Zuendung meldet der Dongle in
  Sekunden, Tuer- und Schlossmeldungen kommen verzoegert - die schnellere
  Quelle hat Vorrang, weil sonst das Wort nicht stimmt.
- tankerkennung: der Tiefststand wird vor dem Anlegen gesetzt. Ein
  gepufferter Schwung Datensaetze legte sonst denselben Tankvorgang
  zweimal an, weil der zweite Zustandswechsel waehrend des Wartens noch
  den alten Tiefststand las (04.09.2026, 29,8 l und 29,7 l).
- flespi: nach dem Leeren des Zwischenspeichers wird nur abgeglichen, wenn
  ein Geraetewert vorliegt. Sonst ueberschrieb der Waechterfehler den
  frisch gelesenen Stand in der Kachel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:12:51 +02:00

708 lines
28 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: die Wartezeit bis zum Fahrtende
(`fahrterkennung.ZUENDUNG_NACHLAUF_S` und der Regler). Ä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": None,
},
"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 _stempel(eintraege: list[dict]) -> dict[str, float]:
"""Wann das GERAET jede Einstellung zuletzt bestaetigt hat.
flespi legt zu jedem Eintrag ein `updated` (Unix-Sekunde). Das ist der
Zeitpunkt, zu dem der Wert vom Geraet kam - nicht der unseres Abrufs.
Genau daran fehlte es am 04.09.2026: `trip_scenario` stand auf dem Stand
vom 01.09. 13:15, waehrend die aufgespielte Konfiguration das Szenario
laengst abgeschaltet hatte. Der Waechter verglich 900 gegen 900 und meldete
Uebereinstimmung - gegen einen dreieinhalb Tage alten Wert.
"""
return {
e["name"]: e["updated"]
for e in eintraege
if e.get("name") and isinstance(e.get("updated"), (int, float))
}
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,
stempel: dict[str, float] | None = None,
) -> list[dict]:
"""Die interessanten Einstellungen mit unserem eigenen Wert daneben.
`abweichung` ist DREIWERTIG: True (weicht ab), False (stimmt ueberein) und
**None (kein Urteil)**. None steht dort, wo ein Vergleich nichts aussagen
wuerde - weil das Geraet den Wert nie bestaetigt hat, oder weil gerade eine
Aenderung zu ihm unterwegs ist und der Wert sich ohnehin gleich aendert.
Warum das noetig wurde: bis zum 04.09.2026 war `abweichung` ein blosses
Bool, und `False` hiess "stimmt ueberein" - auch dann, wenn der verglichene
Wert Tage alt war. Ein stiller Gleichstand mit einem veralteten Wert ist
schlimmer als gar kein Vergleich: er behauptet Sicherheit, wo keine ist.
"""
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.
# Der Name der Einstellung ist der Teil vor dem ersten Punkt -
# "sleep_mode.mode.timeout" gehoert zu "sleep_mode".
name = schluessel.split(".")[0]
gemeldet = (stempel or {}).get(name)
wartet = schluessel in offen and offen.get(schluessel) != geraet
vergleichbar = isinstance(geraet, (int, float)) and isinstance(unser, (int, float))
if not vergleichbar or gemeldet is None or wartet:
# Kein Gegenstueck bei uns, nie bestaetigt, oder gerade unterwegs.
abweichung: bool | None = None
else:
abweichung = 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,
# Wann das Geraet diesen Wert zuletzt bestaetigt hat. None
# heisst: nie - dann steht daneben kein Urteil, sondern nichts.
"gemeldet_am": (
datetime.datetime.fromtimestamp(gemeldet, datetime.UTC).isoformat()
if gemeldet is not None
else None
),
}
)
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,
geleert: list[str] | None = None,
) -> dict:
aktuell = _sicht(eintraege, "current")
offen = _sicht(eintraege, "pending")
werte = vergleichen(aktuell, offen, wunsch, _stempel(eintraege))
return {
"geraet": geraet,
"gelesen_am": jetzt_iso(),
"anzahl": len(eintraege),
"werte": werte,
# Welche Zwischenspeicher dieser Abruf geleert hat - leer, wenn nur
# gelesen wurde.
"geleert": geleert or [],
"abweichungen": sum(1 for z in werte if z["abweichung"] is True),
# Zeilen ohne Urteil: nie bestaetigt oder gerade unterwegs.
"unbekannt": sum(1 for z in werte if z["abweichung"] is None),
"offen": sum(1 for z in werte if z["offen"] is not None),
"fehler": None,
}
# Die Namen der Einstellungen, die wir ueberwachen - der Teil vor dem ersten
# Punkt in INTERESSANT ("sleep_mode.mode.timeout" -> "sleep_mode").
UEBERWACHT = sorted({schluessel.split(".")[0] for schluessel in INTERESSANT})
async def _cache_leeren(
hass: HomeAssistant, token: str, geraet: int, eintraege: list[dict]
) -> list[str]:
"""Loescht flespis Zwischenspeicher fuer die ueberwachten Einstellungen.
WAS DAS TUT UND WAS NICHT
-------------------------
`DELETE /gw/devices/{id}/settings/{name}` wirft den GESPEICHERTEN Wert weg,
nicht den im Geraet. flespi fragt ihn beim naechsten Verbinden neu ab. Es
ist die API-Entsprechung des Knopfes "clear cache and synchronize" im
flespi-Panel.
Ohne das liest jeder Abruf nur den Zwischenspeicher zurueck. Am 04.09.2026
stand dort fuer `trip_scenario` der Stand vom 01.09. 13:15 - dreieinhalb
Tage alt, und das Szenario war laengst abgeschaltet.
AUSSTEHENDE WERTE BLEIBEN UNANGETASTET
--------------------------------------
Dasselbe DELETE verwirft auch ein `pending`. Stuende gerade eine Aenderung
in der Warteschlange zum Geraet - wie `1003`/`1004` am 04.09. acht Stunden
lang -, waere sie damit weg, ohne dass es jemand merkt. Solche
Einstellungen werden uebersprungen; ihr Wert aendert sich ohnehin gleich.
"""
geleert: list[str] = []
for eintrag in eintraege:
name = eintrag.get("name")
if name not in UEBERWACHT:
continue
if eintrag.get("pending") is not None:
_LOGGER.info(
"%s hat einen ausstehenden Wert - Zwischenspeicher nicht "
"geleert, sonst waere die Aenderung verworfen", name,
)
continue
try:
await _anfrage(hass, token, "DELETE", f"/gw/devices/{geraet}/settings/{name}")
except FlespiFehler as fehler:
# Ein misslungenes Leeren ist kein Grund, das Lesen abzubrechen -
# dann steht eben der alte Wert da, so wie bisher auch.
_LOGGER.warning("Zwischenspeicher von %s nicht geleert: %s", name, fehler)
continue
geleert.append(name)
return geleert
async def lesen(k: Koordinator, frisch: bool = False) -> dict:
"""Holt die Konfiguration und gibt den fertigen Anzeigestand zurück.
Mit `frisch` wird vorher der Zwischenspeicher geleert (siehe
`_cache_leeren`). Die Werte, die danach zurueckkommen, koennen dadurch
fehlen - dann hat das Geraet sie noch nicht bestaetigt, und genau das
soll man sehen.
"""
token, geraet = await _geraet_und_token(k)
eintraege = await _alle_einstellungen(k.hass, token, geraet)
geleert: list[str] = []
if frisch:
geleert = await _cache_leeren(k.hass, token, geraet, eintraege)
if geleert:
eintraege = await _alle_einstellungen(k.hass, token, geraet)
return _stand(geraet, eintraege, wunsch_timeout(k), geleert)
# --------------------------------------------------------------- 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.
#
# Kennen wir das Objekt nicht, wird NICHT geschrieben. Zwei Gründe, und der
# zweite wiegt schwerer:
#
# 1. flespi lehnt ein `mode` ohne `type` ab (400, "the following properties
# are missing: type"). Bis zum 05.09.2026 schickte der else-Zweig genau
# das - am laufenden System ausgelöst, weil "Jetzt lesen" den
# Zwischenspeicher leert und damit den vorigen Wert entfernt.
# 2. `type` IST der Schlafmodus (2 = Deep Sleep, 4 = Ultra Deep Sleep). Ihn
# zu erraten hiesse, den Schlafmodus des Geräts zu verstellen - das darf
# ohne ausdrückliche Freigabe des Eigentümers nie passieren. Lieber gar
# nicht schreiben und es sagen.
alt = (_roh(vorher_roh, SCHLAF_NAME) or {}).get(SCHLAF_OBJEKT)
if not isinstance(alt, dict) or "type" not in alt:
raise FlespiFehler(
"Der Schlafmodus des Geräts ist gerade unbekannt - ohne ihn würde ein "
"Schreiben den Modus selbst verstellen. Erst \"Jetzt lesen\", sobald sich "
"der Dongle das nächste Mal meldet, dann erneut versuchen."
)
neu = {**alt, 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:
# Auf Knopfdruck ein ECHTES Lesen: erst den Zwischenspeicher leeren,
# dann holen. Sonst kaeme nur zurueck, was flespi ohnehin schon hatte.
stand = await lesen(k, frisch=True)
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.
#
# ABER nur, wenn nach dem Leeren überhaupt schon ein Gerätewert
# zurückgekommen ist. Schläft der Dongle, ist der Wert nicht abweichend,
# sondern schlicht unbekannt - ein Schreibversuch scheitert dann am Schutz
# in schlaf_timeout_schreiben() und ersetzte den eben gelesenen Stand durch
# eine Fehlermeldung. Am 05.09.2026 genau so passiert: nach "Jetzt lesen"
# stand in der Kachel nur noch "Der Schlafmodus des Geräts ist gerade
# unbekannt", statt der Werte.
if _schlafwert_bekannt(stand):
await wunsch_uebernehmen(k)
else:
_LOGGER.debug(
"Nach dem Leeren liegt noch kein Gerätewert vor - kein Abgleich,"
" bis der Dongle sich meldet."
)
def _schlafwert_bekannt(stand: dict) -> bool:
"""Ob der Anzeigestand einen Schlaf-Timeout vom Gerät kennt.
Ein ausstehender Wert (`offen`) zählt mit: er ist unterwegs, und das
dazugehörige `mode`-Objekt liegt dann in flespi vor."""
for zeile in stand.get("werte", []):
if zeile.get("schluessel") != SCHLAF_SCHLUESSEL:
continue
return zeile.get("geraet") is not None or zeile.get("offen") is not None
return False