2736c3d6e0
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>
553 lines
21 KiB
Python
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)
|