863284e540
Gemeldet: Wartungsplan leer, Erstzulassung 01.03.2025, erwartet 01.03.2028 -
gezeigt wurden drei verschiedene Antworten (Uebersicht 01.08.2026, Mein Audi
"August 2026", Service "kein Eintrag im Wartungsplan").
Ursache war das Profilfeld hauptuntersuchung_faellig ("08/2026"), das die
Ableitung ueberstimmte, in keiner Oberflaeche ein Eingabefeld hatte und im
Backend kein Schema. Drei Bildschirme verarbeiteten denselben Wert
verschieden. Das Feld ist samt Lese- und Schreibweg entfernt; es gibt jetzt
zwei Quellen: Wartungsplan + 24 Monate, sonst Erstzulassung + 36 Monate.
Dabei mitbehoben:
* Die Service-Kachel verschluckte abgeleitete Termine, sobald kein
Wartungsplan-Eintrag dahinterstand.
* monatePlus() in der App verlor einen Tag ueber die Zeitumstellung (drei von
fuenf gemessenen Faellen) - betraf Oelwechsel- und Inspektionsprognose
genauso. Das Panel rechnete dort seit jeher richtig.
* Die App verlangte fuer jeden Wartungsplan-Eintrag eine Kilometerangabe. Eine
Hauptuntersuchung ist rein datumsbasiert; ohne km waere der erste HU-Eintrag
ignoriert worden. Ausserdem nahm das Panel den ersten Treffer der Liste, die
App den juengsten - beide nehmen jetzt den juengsten.
* Monatsgenauigkeit ("03/2025") bleibt auf beiden Seiten erhalten.
Neu: paritaet_hu.test.ts vergleicht die App gegen die aus der Panel-Quelle
herausgeschnittenen Originalfunktionen, 15 Faelle. Gesamt 292 App-Tests, 73
Python-Tests mit 231 Untertests, 0 Tracebacks. Live an der Testinstanz auf
allen drei gemeldeten Bildschirmen geprueft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
668 lines
26 KiB
Python
668 lines
26 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.
|
|
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:
|
|
# 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.
|
|
await wunsch_uebernehmen(k)
|