Regler stellt den Schlaf-Timeout des Dongles (2026.9.3.10)

Der Regler "Fahrt beenden" ist jetzt eine Zahl fuer zwei Dinge: unsere eigene
Wartezeit bis zum Fahrtende und der Schlaf-Timeout des FMM003 (103). So lange
bleibt der Dongle wach und wartet darauf, dass es gleich weitergeht; danach
schlaeft er. Die 180 s Ignition OFF Delay laufen davor und bleiben unsichtbar -
sie werden nur von der Fahrtdauer abgezogen. Der Regler geht deshalb von 1 bis
60 in Einerschritten; das Geraet kennt kein Timeout 0.

flespi.py liest und schreibt die Geraete-Konfiguration. Drei Annahmen sind an
der echten API gefallen und stehen als Messung im Modulkopf: der Wert liegt in
current (nicht value), die Einstellung heisst sleep_mode mit dem Wert eine Ebene
tiefer unter mode, und ein PUT braucht {"properties": ..., "address": ...}.

Die eigene Auftrags-Warteschlange ist wieder entfallen. Sie war fuer "das Geraet
schlaeft" gebaut - das gilt aber nur fuer das Geraet, nicht fuer die
Schnittstelle: die vollstaendige Konfiguration kam herein, waehrend das Fahrzeug
seit dem Vortag stand. flespi puffert selbst (pending + address=connection), ein
zweites Auftragsbuch waere eine zweite Warteschlange fuer dieselbe Aufgabe.

Gegengelesen wird nach jedem Schreiben; als Erfolg zaehlt current ODER pending -
letzteres ist bei stehendem Fahrzeug der Normalfall.

Live gegen den echten Account belegt: Token sieht genau ein Geraet, 127
Einstellungen bei parkendem Fahrzeug gelesen, Regler 15 geschrieben und als
pending bestaetigt (Nachbarfelder unveraendert), zweiter Neustart ohne erneute
Anfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 20:00:39 +02:00
parent 8c96d9b78e
commit 4d4890034a
18 changed files with 999 additions and 30 deletions
+111 -1
View File
@@ -1,6 +1,7 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-09-03** (Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie
**Last updated: 2026-09-03** (Der Regler stellt den Schlaf-Timeout des Dongles, flespi lesend und
schreibend, die eigene Warteschlange wieder entfallen, `2026.9.3.10`, Abschnitt BZ. Davor: Zugaenge: die Dienste-Token aus App und Panel bedienbar, der Wert kommt nie
zurueck, `2026.9.3.8`, Abschnitt BY. Davor: „Fahrt beenden“ auf Knopfdruck mit vorlaeufigem Ende, Schieberegler in
5-Minuten-Schritten und im Tagmodus sichtbar, `2026.9.3.7`, Abschnitt BX.
Davor: Die Fahrt endet wieder bei uns statt im Dongle - Zuendung als Ausloeser,
@@ -9002,3 +9003,112 @@ Passwortfeld mit passendem Platzhalter und die Knöpfe Speichern/Löschen. Einen
gesetzt → `gesetzt · …0000`, und **der Wert selbst taucht in den veröffentlichten Attributen
nirgends auf** (auf genau diese Zeichenkette geprüft, nicht nur angesehen). Danach über die
Sicherheitsabfrage „Token löschen?" entfernt → wieder `nicht gesetzt`, der Gitea-Token unberührt.
---
## BZ. Der Regler stellt den Schlaf-Timeout des Dongles (2026.9.3.10)
### Was der Regler jetzt bedeutet
„Fahrt beenden" in den Einstellungen ist **eine** Zahl für zwei Dinge, die
denselben Sachverhalt beschreiben (Vorgabe des Eigentümers):
* Schlaf-Timeout des FMM003 (`103`, Minuten). So lange bleibt der Dongle nach
dem Abstellen wach und wartet darauf, dass es gleich weitergeht. Danach
schläft er — das spart Strom, kostet beim Fortsetzen aber Sekunden fürs
Aufwachen.
* Unsere eigene Wartezeit, bevor eine Fahrt geschlossen wird.
Die 180 s `Ignition OFF Delay` (`400`) laufen davor. **Der Nutzer sieht sie
nicht** — sie werden nur von der Fahrtdauer abgezogen
(`fahrterkennung.ZUENDUNG_NACHLAUF_S`, unverändert).
Der Regler geht deshalb von **1 bis 60 in Einerschritten** statt 060 in
Fünferschritten: das Gerät kennt kein Timeout 0 (Minimum 1, Maximum 3000), und
„sofort" gibt es damit nicht mehr.
### Die gemessene API — drei Annahmen waren falsch
Alles im laufenden Container gegen den echten Account geprüft, nicht aus der
Dokumentation abgeschrieben:
| angenommen | tatsächlich |
|---|---|
| Wert unter `value` | Wert unter **`current`**, Ausstehendes unter **`pending`** |
| Einstellung `sleep_settings` mit `{mode, timeout}` | **`sleep_mode`**, Wert eine Ebene tiefer unter `mode` |
| PUT-Rumpf = der Wert | `{"properties": …, "address": …}` — ohne `address` kommt `400 the following properties are missing: address` |
`GET /gw/devices/{id}/settings/all` liefert die gefüllten Einträge; der Abruf
einer **einzelnen** Einstellung kam leer zurück (`current: null`). Gelesen wird
deshalb immer `all`.
Der `Ignition OFF Delay` (`400`) taucht unter keinem sicher zuzuordnenden Namen
auf und wird deshalb **nicht** angezeigt — lieber eine Zeile weniger als eine
falsch beschriftete. Verglichen werden `103` (gegen den Regler) und `11804`
(gegen `NACHLAUF_S`); dazu angezeigt: `102`, `11803`, `11806`.
### Die eigene Warteschlange ist wieder entfallen
Erst war ein Auftragsbuch im Koordinator gebaut, weil „Lesen und Schreiben gehen
nur bei aktiver Zündung — ausserhalb schläft der Dongle". **Das gilt für das
Gerät, nicht für diese Schnittstelle**, und das ist gemessen: die vollständige
Konfiguration kam herein, während das Fahrzeug seit dem Vortag stand.
flespi ist eine Ebene darüber. Es kennt `current` und `pending`, und
`"address": "connection"` heisst: zustellen, sobald sich das Gerät meldet. Ein
zweites Auftragsbuch bei uns wäre eine zweite Warteschlange für dieselbe
Aufgabe — und eine, die nur raten könnte, was die erste schon weiss.
Entfallen sind damit `flespi_auftrag`, `flespi_auftrag_setzen()`,
`wach_geworden()` und der Haken an beiden Zündungs-Beobachtern. Geblieben ist
`flespi_stand` (überlebt den Neustart) und `flespi_sperre`.
**Geschrieben wird jetzt sofort** — nach jedem Profil-Speichern und einmal nach
dem Start, über `wunsch_uebernehmen()`. Das ist bewusst zustandsvergleichend
statt ereignisgetrieben: es muss auch dann zum Ziel führen, wenn eine frühere
Änderung nie ankam. Der billige Vergleich gegen den gespeicherten Stand geht
voraus — stimmt er (auch über ein noch ausstehendes `pending`), kostet ein
Profil-Speichern **keine einzige Anfrage**. Live belegt: der zweite Neustart
schrieb keine Zeile mehr.
### Gegenlesen heisst hier zweierlei
Vorgabe des Eigentümers war: nach dem Schreiben lesen und belegen, dass sich
genau die eine Stelle geändert hat. Mit `pending` bekommt das eine zweite gültige
Ausprägung, und beide zählen als Erfolg:
* `current` trägt den neuen Wert → das Gerät war wach und hat ihn.
* `pending` trägt ihn → flespi hält ihn vor. **Der Normalfall bei stehendem
Fahrzeug.**
Steht er in keinem von beiden, ist der Vorgang nicht angekommen und das wird als
Fehler gemeldet. Ändert sich zusätzlich etwas ausserhalb von `sleep_mode`, gibt
es eine Warnung — kein Abbruch: geschrieben ist zu dem Zeitpunkt schon.
### Live nachgewiesen, und was nicht
Gegen den echten flespi-Account und den echten Dongle (Gerät 8767678):
* Token sieht **genau ein** Gerät → `geraet_finden()` löst die Nummer selbst
auf, in den Optionen muss nichts stehen.
* 127 Einstellungen gelesen, **bei parkendem Fahrzeug**.
* Regler 15 → PUT akzeptiert → `pending.mode.timeout = 15` bei flespi,
`current` blieb 10, und `type`/`bluetooth_on`/`periodic_wakeup` wurden
unverändert mitgeschrieben. Genau dafür wird das ganze Objekt
zurückgeschrieben statt nur des Feldes.
* Zweiter Neustart: keine erneute Anfrage (Idempotenz).
**Das Gerät hat dabei eine Änderung bekommen** — den Wert, den der Regler ohnehin
schon zeigte. Das ist die gewollte Selbstheilung, aber es ist eine Änderung am
Fahrzeug und steht deshalb hier.
**Nicht geprüft:** wie die neue Kachel aussieht. In dieser Sitzung gab es keinen
angemeldeten Browser; Panel und App sind über Modul-Parse, `tsc --noEmit` und
179/179 Tests belegt, nicht über einen Screenshot. Ebenfalls offen: ob das Gerät
das `pending` beim nächsten Verbinden wirklich übernimmt — das zeigt erst die
nächste Fahrt.
**Vorbestehende Lücke, nicht in diesem Zug geschlossen:** `translations/de.json`
kennt die zuletzt hinzugekommenen Dienste nicht (`fahrt_jetzt_beenden`,
`zugang_setzen`, `csv_importieren`, jetzt auch `dongle_lesen`). Sie erscheinen in
den Entwicklerwerkzeugen ohne Klarnamen.
+10
View File
@@ -351,6 +351,16 @@ export class DataMetricApi {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "zugang_setzen", { dienst, token });
}
/** Liest die Konfiguration des Dongles - und schreibt dabei eine vorgemerkte
Aenderung, falls eine offen ist (siehe flespi.py).
Wie zugangSetzen bewusst NICHT ueber die Warteschlange: der Abruf gelingt
nur bei laufender Zuendung, und Stunden spaeter aus dem Funkloch nachgeholt
traefe er das Geraet garantiert im Schlaf an. */
dongleLesen(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "dongle_lesen", {});
}
historieImportieren(start: string, ende: string): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
start,
+38
View File
@@ -234,12 +234,50 @@ export interface ZugangAngabe {
rest: string | null;
}
/** Was am Dongle steht und was noch aussteht (flespi.py).
`aktiv` ist false, solange kein flespi-Token hinterlegt ist - dann gibt es
nichts zu zeigen. `stand` traegt seinen eigenen Zeitstempel: eine Angabe von
gestern soll nicht wie eine frische aussehen. Was noch zum Geraet unterwegs
ist, steht je Wert in `offen` - flespi haelt es vor, wir nicht. */
export interface DongleWert {
schluessel: string;
/** Die Nummer im flespi-Configurator - danach sucht man dort. */
nummer: string;
titel: string;
einheit: string;
geraet: unknown;
/** Was noch zum Gerät unterwegs ist (flespis `pending`) - null, wenn nichts
aussteht. */
offen: unknown;
/** Unser eigener Wert, sofern es einen zu vergleichen gibt. */
unser: number | null;
abweichung: boolean;
}
export interface DongleStand {
geraet?: number;
gelesen_am: string;
anzahl?: number;
werte?: DongleWert[];
abweichungen?: number;
/** Wie viele der gezeigten Werte noch zum Gerät unterwegs sind. */
offen?: number;
fehler: string | null;
}
export interface DongleAngabe {
aktiv: boolean;
stand: DongleStand | null;
}
/** Nutzlast von sensor.audi_dashboard_app_version. */
export interface AppVersionAngabe {
app: string | null;
buendel: Buendelangabe | null;
integration_update: IntegrationUpdateAngabe | null;
zugaenge?: ZugangAngabe[];
dongle?: DongleAngabe;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
+7
View File
@@ -26,6 +26,7 @@ import {
type Fahrt,
type Fahrzeugstatus,
type IntegrationUpdateAngabe,
type DongleAngabe,
type ZugangAngabe,
type Profil,
type Tankvorgang,
@@ -71,6 +72,9 @@ export interface DatenWert {
Integration (aktualisierung.py), oder null vor der ersten Prüfung. */
integrationUpdate: IntegrationUpdateAngabe | null
zugaenge: ZugangAngabe[]
/** Konfiguration des Dongles und eine ggf. vorgemerkte Aenderung
(flespi.py). null, solange kein flespi-Token hinterlegt ist. */
dongle: DongleAngabe | null
einstellungen: Einstellungen | null
fahrzeug: Fahrzeug | null
@@ -118,6 +122,7 @@ export function DatenAnbieter({
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
const [zugaenge, setzeZugaenge] = useState<ZugangAngabe[]>([])
const [dongle, setzeDongle] = useState<DongleAngabe | null>(null)
const [integrationUpdate, setzeIntegrationUpdate] = useState<IntegrationUpdateAngabe | null>(
null,
)
@@ -162,6 +167,7 @@ export function DatenAnbieter({
setzeOtaBuendel(versionsangabe?.buendel ?? null)
setzeIntegrationUpdate(versionsangabe?.integration_update ?? null)
setzeZugaenge(versionsangabe?.zugaenge ?? [])
setzeDongle(versionsangabe?.dongle ?? null)
return true
} catch (fehler) {
// Ein voruebergehender Ausfall ist kein Ladefehler, solange schon
@@ -294,6 +300,7 @@ export function DatenAnbieter({
otaBuendel,
integrationUpdate,
zugaenge,
dongle,
einstellungen,
fahrzeug,
fahrten,
+67 -5
View File
@@ -15,7 +15,7 @@ import { Datumsfeld } from "./Datumsfeld"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
import { datum, datumZeit, isoTag } from "../format"
import { datum, datumZeit, isoTag, standAlter } from "../format"
import { useTheme } from "../theme"
import { BildMitMenue, Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { BILDPLAETZE, SEITE_WINTER, markenlogoUrl } from "./bilder"
@@ -43,6 +43,7 @@ export function Einstellungen({
integrationUpdate,
serverVersion,
zugaenge,
dongle,
verbindung,
api,
profilSpeichern,
@@ -231,6 +232,28 @@ export function Einstellungen({
const [tokenLaeuft, setzeTokenLaeuft] = useState<string | null>(null)
const [tokenFehler, setzeTokenFehler] = useState<string | null>(null)
// Wie tokenLaeuft daneben: der Abruf geht ueber zwei Netze (wir -> flespi ->
// Geraet) und darf nicht wie ein toter Knopf aussehen. Das Ergebnis selbst
// kommt ueber die veroeffentlichte Entitaet zurueck (flespi.py), hier steht
// nur, was den Aufruf gar nicht erst erreicht hat.
const [dongleLaeuft, setzeDongleLaeuft] = useState(false)
const [dongleFehler, setzeDongleFehler] = useState<string | null>(null)
const dongleAbrufen = async () => {
setzeDongleLaeuft(true)
setzeDongleFehler(null)
try {
await api.dongleLesen()
await neuLaden()
} catch (fehler) {
setzeDongleFehler(
fehler instanceof Error ? fehler.message : "Der Abruf ist gescheitert.",
)
} finally {
setzeDongleLaeuft(false)
}
}
const tokenSchreiben = async (dienst: string, wert: string) => {
setzeTokenLaeuft(dienst)
setzeTokenFehler(null)
@@ -375,22 +398,61 @@ export function Einstellungen({
<Tile>
<span className="ads-eyebrow">Fahrt beenden</span>
<Slider
min={0}
min={1}
max={60}
schritt={5}
schritt={1}
wert={werte.pausenzeitMin}
onChange={(min) => aendern({ pausenzeitMin: min })}
anzeige={werte.pausenzeitMin === 0 ? "sofort" : werte.pausenzeitMin + " Min."}
anzeige={werte.pausenzeitMin + " Min."}
aria-label="Minuten bis zum automatischen Fahrtende"
/>
{/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden
Text unter einer Einstellung ist der Footer der Gruppe. */}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die
Fahrt automatisch beendet werden soll.
Fahrt automatisch beendet werden soll. Derselbe Wert stellt den Schlaf-Timeout
des Dongles: so lange bleibt er nach dem Abstellen wach und wartet darauf, dass
es gleich weitergeht. Danach schläft er das spart Strom, kostet beim Fortsetzen
aber ein paar Sekunden fürs Aufwachen.
</span>
</Tile>
{dongle?.aktiv && (
<Tile>
<span className="ads-eyebrow">Dongle</span>
{dongle.stand?.fehler && <p className="dm-fehler">{dongle.stand.fehler}</p>}
{(dongle.stand?.werte ?? []).length > 0 && (
<Werteliste
kinder={(dongle.stand?.werte ?? []).map((w) => (
<Wertzeile
key={w.schluessel}
label={`${w.titel} ${w.nummer}`}
wert={`${w.geraet ?? ""}${w.einheit ? " " + w.einheit : ""}`}
zusatz={
w.offen != null
? `${w.offen} ist unterwegs zum Gerät`
: w.abweichung
? `wir rechnen mit ${w.unser}`
: undefined
}
/>
))}
/>
)}
<ActionButton disabled={dongleLaeuft} onClick={() => void dongleAbrufen()}>
{dongleLaeuft ? "Liest …" : "Jetzt lesen"}
</ActionButton>
{dongleFehler && <p className="dm-fehler">{dongleFehler}</p>}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
{dongle.stand?.gelesen_am
? `Gelesen ${standAlter(dongle.stand.gelesen_am)}. `
: "Noch nicht gelesen. "}
Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich
das nächste Mal meldet, steht die Änderung als unterwegs" daneben.
</span>
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Darstellung</span>
<Seg
@@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResu
from homeassistant.core import callback
from homeassistant.helpers import selector
from .const import CONF_FLESPI_TOKEN, CONF_GITEA_TOKEN, DOMAIN
from .const import CONF_FLESPI_GERAET, CONF_FLESPI_TOKEN, CONF_GITEA_TOKEN, DOMAIN
class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -82,6 +82,11 @@ class AudiDashboardOptionsFlow(OptionsFlow):
vol.Optional(CONF_FLESPI_TOKEN, default=""): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
# Leer lassen, solange der Token genau ein Gerät sieht - dann
# ermittelt flespi.geraet_finden() die Nummer selbst. Sieht er
# mehrere, wird bewusst nicht geraten und die Nummer gehört
# hierher.
vol.Optional(CONF_FLESPI_GERAET, default=""): selector.TextSelector(),
}),
self.config_entry.options,
),
@@ -79,6 +79,7 @@ D_FAHRT_AKTUALISIEREN = "fahrt_aktualisieren"
D_FAHRT_LOESCHEN = "fahrt_loeschen"
D_FAHRT_JETZT_BEENDEN = "fahrt_jetzt_beenden"
D_ZUGANG_SETZEN = "zugang_setzen"
D_DONGLE_LESEN = "dongle_lesen"
D_SCREENING_JETZT = "screening_jetzt"
D_BELEG_HOCHLADEN = "beleg_hochladen"
D_TANKVORGANG_MANUELL = "tankvorgang_manuell"
@@ -105,3 +106,6 @@ D_UPDATE_INSTALLIEREN = "update_installieren"
# Umweg über den Options-Flow importieren können.
CONF_GITEA_TOKEN = "gitea_token"
CONF_FLESPI_TOKEN = "flespi_token"
# Die Geräte-Nummer bei flespi. Leer lassen, solange der Token genau ein
# Gerät sieht - dann wird sie einmal selbst ermittelt (flespi.geraet_finden).
CONF_FLESPI_GERAET = "flespi_geraet"
@@ -33,6 +33,7 @@ from . import (
bilder,
csv_import,
fahrterkennung,
flespi,
historienimport,
identitaet,
reifen,
@@ -53,6 +54,7 @@ from .const import (
D_ENTITAETEN_SCHREIBEN,
D_FAHRT_AKTUALISIEREN,
D_FAHRT_JETZT_BEENDEN,
D_DONGLE_LESEN,
D_ZUGANG_SETZEN,
D_FAHRT_LOESCHEN,
D_FAHRT_MANUELL_ANLEGEN,
@@ -136,6 +138,7 @@ SCHEMATA: dict[str, vol.Schema] = {
}),
D_FAHRT_LOESCHEN: vol.Schema({vol.Required("trip_id"): cv.string}),
D_FAHRT_JETZT_BEENDEN: vol.Schema({}),
D_DONGLE_LESEN: vol.Schema({}),
D_ZUGANG_SETZEN: vol.Schema({
vol.Required("dienst"): cv.string,
# Leerer Text loescht - ein eigener Loesch-Dienst waere ein zweiter Weg
@@ -238,6 +241,30 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
await k.ablage.profil_schreiben(_objekt(aufruf.data["profil_json"]))
await identitaet.fin_ableiten_und_uebernehmen(k.hass, k.zuordnung, k.ablage)
await k.profil_veroeffentlichen()
# Die Reglerstellung "Fahrt beenden" ist zugleich der Sollwert für den
# Schlaf-Timeout des Dongles (103). Vorgemerkt statt geschrieben:
# schreiben geht nur bei wachem Gerät, siehe flespi.py. Muss NACH
# profil_veroeffentlichen() stehen - erst dort liest der Koordinator
# die neue Reglerstellung mit.
await flespi.wunsch_uebernehmen(k)
async def dongle_lesen(_aufruf: ServiceCall) -> None:
"""Liest die Konfiguration des Dongles - auf ausdrücklichen Knopfdruck.
Nötig ist der Knopf nicht: flespi antwortet auch bei schlafendem Gerät
aus seinem eigenen Speicher (siehe flespi.py). Er ist dafür da,
nachzusehen, ob eine geschriebene Änderung inzwischen am Gerät
angekommen ist.
Ein Fehler wird als Anzeigestand hinterlegt statt geworfen: er gehört
in die Kachel, aus der der Knopf gedrückt wurde, nicht ins Protokoll."""
try:
await flespi.jetzt_lesen(k)
except flespi.FlespiFehler as fehler:
_LOGGER.warning("Geräte-Konfiguration nicht gelesen: %s", fehler)
await k.flespi_stand_setzen(
{"fehler": str(fehler), "gelesen_am": flespi.jetzt_iso()}
)
async def entitaeten_schreiben(aufruf: ServiceCall) -> None:
"""Speichert die im Setup-Menü gewählte Sensor-Zuordnung, wendet sie
@@ -362,6 +389,7 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
D_ZUGANG_SETZEN: _als_dienst(
lambda a: zugaenge.setzen(k, a.data["dienst"], a.data.get("token", ""))
),
D_DONGLE_LESEN: dongle_lesen,
D_FAHRT_LOESCHEN: _als_dienst(
lambda a: fahrterkennung.loeschen(k, a.data["trip_id"])
),
@@ -485,18 +485,47 @@ async def _spaetes_aus(
ende = await _geraetezeit(k, ereigniszeit, jetzt)
echt = ende - datetime.timedelta(seconds=ZUENDUNG_NACHLAUF_S)
juengste = None
# Zwei verschiedene Fragen, deshalb zwei Suchen ueber denselben Bestand:
#
# juengstes_ende beantwortet "gehoert das Aus zur zuletzt geschlossenen
# Fahrt?" - das ist der Verwerfen-Zweig unten.
#
# kandidat beantwortet "gibt es eine von Hand beendete Fahrt, deren Ende
# dieses Aus nachtraegt?". Bewusst NICHT auf die juengste Fahrt beschraenkt:
# trifft das gepufferte Aus erst ein, nachdem schon eine weitere Fahrt
# aufgezeichnet wurde, waere die vorlaeufige sonst fuer immer zu kurz. Es
# gewinnt die vorlaeufige Fahrt, deren Ende dem Aus am naechsten VOR ihm
# liegt - jede spaetere kann nicht gemeint sein.
fahrten = await k.ablage.fahrten_lesen()
juengstes_ende = None
for fahrt in await k.ablage.fahrten_lesen():
kandidat = None
kandidat_ende = None
for fahrt in fahrten:
roh = fahrt.get("ts_end")
if not roh:
continue
ts = zeit_normalisiert(roh)
if juengstes_ende is None or ts > juengstes_ende:
juengste, juengstes_ende = fahrt, ts
juengstes_ende = ts
if fahrt.get("ende_vorlaeufig") and ts < echt:
if kandidat_ende is None or ts > kandidat_ende:
kandidat, kandidat_ende = fahrt, ts
if juengste is not None and await _vorlaeufiges_ende_korrigieren(
k, juengste, juengstes_ende, echt
# Eine Fahrt darf nicht ueber eine andere hinweg verlaengert werden. Faengt
# zwischen dem vorlaeufigen Ende und dem Aus eine Fahrt an, gehoert das Aus
# zu ihr oder zu etwas Spaeterem - jedenfalls nicht mehr zur vorlaeufigen,
# und zwei sich ueberlappende Fahrten waeren schlimmer als eine zu kurze.
if kandidat is not None and _fahrt_dazwischen(fahrten, kandidat, kandidat_ende, echt):
_LOGGER.info(
"Zuendungs-Aus (Geraetezeit %s) koennte die vorlaeufige Fahrt %s "
"verlaengern, aber dazwischen liegt bereits eine andere Fahrt - "
"nicht korrigiert",
echt.isoformat(), kandidat["trip_id"],
)
kandidat = None
if kandidat is not None and await _vorlaeufiges_ende_korrigieren(
k, kandidat, kandidat_ende, echt
):
return
@@ -517,6 +546,25 @@ async def _spaetes_aus(
)
def _fahrt_dazwischen(
fahrten: list[dict],
kandidat: dict,
kandidat_ende: datetime.datetime,
echt: datetime.datetime,
) -> bool:
"""Faengt zwischen dem vorlaeufigen Ende und dem Aus eine andere Fahrt an?"""
for fahrt in fahrten:
if fahrt.get("trip_id") == kandidat.get("trip_id"):
continue
roh = fahrt.get("ts_start")
if not roh:
continue
beginn = zeit_normalisiert(roh)
if kandidat_ende < beginn <= echt:
return True
return False
async def _vorlaeufiges_ende_korrigieren(
k: Koordinator,
fahrt: dict,
@@ -532,10 +580,17 @@ async def _vorlaeufiges_ende_korrigieren(
verworfen.
Drei Bedingungen, jede aus einem eigenen Grund: nur eine als vorlaeufig
gekennzeichnete Fahrt (eine regulaer geschlossene ist bereits richtig),
nur ein SPAETERES Ende (frueher waere kein Nachtrag, sondern eine
Verkuerzung), und nur innerhalb von KORREKTUR_FENSTER_S (sonst gehoert das
Aus wahrscheinlich zu einer ganz anderen Fahrt).
gekennzeichnete Fahrt (eine regulaer geschlossene ist bereits richtig - ihr
Ende IST dieses Signal, ein zweites waere nur die gepufferte Kopie), nur ein
SPAETERES Ende (frueher waere kein Nachtrag, sondern eine Verkuerzung), und
nur innerhalb von KORREKTUR_FENSTER_S.
Zum Fenster: gemessen wird zwischen dem vorlaeufigen Ende und der
GERAETEZEIT des Aus - nicht zwischen Knopfdruck und Eintreffen. Ein
Datensatz, der eine Woche in der Tiefgarage liegt, traegt trotzdem seinen
eigenen Zeitstempel von damals (siehe verlauf.geraetezeit); das Fenster
begrenzt also, wie lange nach dem letzten Lebenszeichen noch gefahren
worden sein kann, nicht wie lange das Geraet puffern darf.
ts_end wandert bewusst NICHT in edited_fields: das Feld ist keine
Handeingabe, und dort eingetragen wuerde es genau diese Korrektur beim
+525
View File
@@ -0,0 +1,525 @@
"""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.
"offen": offen.get(schluessel),
"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")
if (
vorher.get(SCHLAF_SCHLUESSEL) == minuten
and offen_vorher.get(SCHLAF_SCHLUESSEL) is None
):
_LOGGER.debug("Sleep Timeout steht bereits auf %s Min. - nichts zu tun", minuten)
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:
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)
await k.flespi_stand_setzen(
{"fehler": str(fehler), "gelesen_am": jetzt_iso()}
)
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)
@@ -1 +1 @@
{"version":"2026.9.3.8","sha256":"f1d22bf45afb23685ed3c0a660b4ef5908e0194f19a1461af6ae928d0163ecdc","bytes":268607,"gebaut":"2026-09-03T13:06:29Z"}
{"version":"2026.9.3.10","sha256":"f1a9a5c2ff1ef7fbd29c679e1b1a374d77c57da4f49f864b3758219483bbb013","bytes":269156,"gebaut":"2026-09-03T17:59:17Z"}
@@ -1834,9 +1834,9 @@ function terminText(t) {
Updates vom Backend, siehe datenLaden()) selbständig hoch und schaltet
dabei die Einheit um - Sek. bis 60, dann Min. bis 60, dann Std. bis 24,
dann Tage. */
function standAlterText() {
if (LETZTES_UPDATE_TS == null) return "unbekannt";
const sekunden = Math.max(0, Math.floor((Date.now() - LETZTES_UPDATE_TS) / 1000));
function alterText(ts) {
if (ts == null || !Number.isFinite(ts)) return "unbekannt";
const sekunden = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (sekunden < 60) return `vor ${sekunden} Sek.`;
const minuten = Math.floor(sekunden / 60);
if (minuten < 60) return `vor ${minuten} Min.`;
@@ -1844,6 +1844,9 @@ function standAlterText() {
if (stunden < 24) return `vor ${stunden} Std.`;
return `vor ${Math.floor(stunden / 24)} Tage`;
}
function standAlterText() {
return alterText(LETZTES_UPDATE_TS);
}
function standAlterTicken() {
if (!ROOT || !CAR) return;
const sy = $("#sync span"); if (sy) sy.textContent = standAlterText();
@@ -3100,16 +3103,47 @@ function vEinst() {
</div>` : ""}
<div class="tile"><span class="label">Fahrt beenden</span>
<div class="schieber" style="margin-top:14px">
<input type="range" min="0" max="60" step="5" value="${CONFIG.pausenzeit}"
style="--fuell:${(CONFIG.pausenzeit / 60) * 100}%" data-pausenzeit
<input type="range" min="1" max="60" step="1" value="${CONFIG.pausenzeit}"
style="--fuell:${((CONFIG.pausenzeit - 1) / 59) * 100}%" data-pausenzeit
aria-label="Minuten bis zum automatischen Fahrtende">
<output>${CONFIG.pausenzeit === 0 ? "sofort" : CONFIG.pausenzeit + " Min."}</output>
<output>${CONFIG.pausenzeit} Min.</output>
</div>
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die
Fahrt automatisch beendet werden soll.</span>
Fahrt automatisch beendet werden soll. Derselbe Wert stellt den Schlaf-Timeout
des Dongles: so lange bleibt er nach dem Abstellen wach und wartet darauf,
dass es gleich weitergeht. Danach schläft er das spart Strom, kostet beim
Fortsetzen aber ein paar Sekunden fürs Aufwachen.</span>
</div>
${DONGLE && DONGLE.aktiv ? `<div class="tile"><span class="label">Dongle</span>
${DONGLE.stand && DONGLE.stand.fehler
? `<span class="label" style="margin-top:8px;color:var(--bad);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">${esc(DONGLE.stand.fehler)}</span>`
: ""}
${DONGLE.stand && (DONGLE.stand.werte || []).length ? `<dl class="rows">
${DONGLE.stand.werte.map((w) => `<div class="row">
<dt>${esc(w.titel)} <small style="color:var(--fg3)">${esc(w.nummer)}</small></dt>
<dd${w.abweichung ? ` style="color:var(--warn)"` : ""}>${
w.geraet === null || w.geraet === undefined ? "" : esc(String(w.geraet))
}${w.einheit ? " " + esc(w.einheit) : ""}${
w.offen !== null && w.offen !== undefined
? ` <small>→ ${esc(String(w.offen))}, unterwegs</small>`
: w.abweichung ? ` <small>(wir: ${esc(String(w.unser))})</small>` : ""
}</dd>
</div>`).join("")}
</dl>` : ""}
<button class="aktion" data-dongle ${DONGLE_LAEUFT ? "disabled" : ""}>${
DONGLE_LAEUFT ? `<span class="lade-spinner"></span>Liest …` : "Jetzt lesen"
}</button>
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
${DONGLE.stand && DONGLE.stand.gelesen_am
? `Gelesen ${esc(alterText(Date.parse(DONGLE.stand.gelesen_am)))}. `
: "Noch nicht gelesen. "}Lesen und Schreiben gehen nur bei laufender Zündung
Der Regler oben schreibt den Schlaf-Timeout sofort hierher; bis das Gerät sich
das nächste Mal meldet, steht die Änderung als unterwegs" daneben.</span>
</div>` : ""}
<div class="tile"><span class="label">Darstellung</span>
<div class="seg">
<button data-mode="nacht" class="${HOST.dataset.theme === "nacht" ? "on" : ""}">Nacht</button>
@@ -4158,6 +4192,13 @@ let INTEGRATION_UPDATE = null;
/* Nur "gesetzt/nicht gesetzt" je Dienst - der Token selbst kommt nie ueber
die Leitung, siehe zugaenge.py im Backend. */
let ZUGAENGE = [];
/* Was am Dongle steht, was noch aussteht - siehe flespi.py im Backend.
null, solange kein flespi-Token hinterlegt ist. */
let DONGLE = null;
// true, waehrend "Jetzt lesen" laeuft. Wie INTEGRATION_UPDATE_LAEUFT und aus
// demselben Grund: der Abruf geht ueber zwei Netze (wir -> flespi -> Geraet)
// und darf nicht wie ein toter Knopf aussehen.
let DONGLE_LAEUFT = false;
// "pruefen" | "installieren" | "neustart" | null - während einer dieser drei
// Aktionen läuft, siehe vEinst()'s Integration-Update-Kachel. Eigens statt
// über serviceRufen() (das feuert-und-vergisst), weil diese drei Aktionen
@@ -6001,6 +6042,18 @@ function ereignisseVerdrahten() {
// div ohne data-go, aber die Reihenfolge haelt das auch dann, wenn die
// Kachel spaeter einmal anders gebaut wird.
if (e.target.closest("[data-fahrtbeenden]")) { serviceRufen("fahrt_jetzt_beenden", {}); return; }
if (e.target.closest("[data-dongle]")) {
// Nicht ueber serviceRufen(): das feuert und vergisst, hier soll der Knopf
// aber zeigen, dass gerade zwei Netze befragt werden - und danach wieder
// freigeben. Das Ergebnis (auch ein Fehler) kommt ueber die veroeffentlichte
// Entitaet zurueck, siehe flespi.py; deshalb hier nur der Ladezustand.
if (DONGLE_LAEUFT) return;
DONGLE_LAEUFT = true; render();
HASS.callService(DOMAIN, "dongle_lesen", {})
.catch((f) => console.error("dongle_lesen", f))
.finally(() => { DONGLE_LAEUFT = false; render(); });
return;
}
const tokSpeichern = e.target.closest("[data-tokenspeichern]");
if (tokSpeichern) {
const dienst = tokSpeichern.dataset.tokenspeichern;
@@ -6391,6 +6444,9 @@ async function datenLaden(erzwingen) {
ZUGAENGE = versionState.attributes.daten
? versionState.attributes.daten.zugaenge ?? []
: [];
DONGLE = versionState.attributes.daten
? versionState.attributes.daten.dongle ?? null
: null;
}
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
@@ -50,6 +50,7 @@ from homeassistant.helpers.storage import Store
from . import (
batterie,
fahrterkennung,
flespi,
identitaet,
reifen,
screening,
@@ -145,6 +146,20 @@ class Koordinator:
# liest diesen Wert bei jedem Takt erneut mit, damit er nicht
# zwischen zwei Prüfungen verschwindet.
self.letzte_update_pruefung: dict | None = None
# Die Reglerstellung "Fahrt beenden" in Minuten, aus dem Profil
# mitgelesen. Sie ist zugleich der Sollwert fuer den Schlaf-Timeout
# des Dongles (103) - siehe flespi.py. None = noch nicht gelesen.
self.pausenzeit_min: int | None = None
# Der zuletzt gelesene Stand der Geraete-Konfiguration. Ueberlebt einen
# Neustart, damit die Anzeige nicht auf "unbekannt" zurueckfaellt,
# obwohl sie erst eine Stunde alt ist - und damit wunsch_uebernehmen()
# ohne Netzabfrage weiss, ob ueberhaupt etwas zu tun ist. Was noch zum
# Geraet unterwegs ist, steht darin als "offen" (siehe flespi.py);
# eine eigene Warteschlange gibt es bewusst nicht.
self.flespi_stand: dict | None = None
# Zwei Wege koennen gleichzeitig schreiben wollen (Profil gespeichert,
# Knopf gedrueckt) - der zweite haette nichts mehr zu tun.
self.flespi_sperre = asyncio.Lock()
self._takte: list[CALLBACK_TYPE] = []
self._beobachter: list[CALLBACK_TYPE] = []
@@ -198,6 +213,10 @@ class Koordinator:
# Fahrt warten. Reicht das Ortsbudget eines Laufs nicht, plant das
# Screening selbst einen zweiten ein (screening._nachfassen_planen).
await screening.durchfuehren(self)
# Weicht die Reglerstellung vom zuletzt gelesenen Geraetewert ab,
# wird die Aenderung hier vorgemerkt - auch wenn sie waehrend eines
# Ausfalls gesetzt wurde oder ein frueherer Auftrag nie zum Zug kam.
await flespi.wunsch_uebernehmen(self)
async def beenden(self) -> None:
if self.screening_nachlauf is not None:
@@ -385,6 +404,7 @@ class Koordinator:
)
geladen = None
self.geparkt_seit_ts = geladen
self.flespi_stand = gespeichert.get("flespi_stand")
async def _laufzeit_sichern(self) -> None:
await self._store.async_save({
@@ -394,8 +414,14 @@ class Koordinator:
"geparkt_seit_ts": (
self.geparkt_seit_ts.isoformat() if self.geparkt_seit_ts else None
),
"flespi_stand": self.flespi_stand,
})
async def flespi_stand_setzen(self, stand: dict | None) -> None:
self.flespi_stand = stand
await self._laufzeit_sichern()
await self.app_version_veroeffentlichen()
async def fahrt_start_setzen(self, ts: datetime.datetime | None) -> None:
self.fahrt_start_ts = ts
await self._laufzeit_sichern()
@@ -501,6 +527,15 @@ class Koordinator:
if profil is None:
return
self.setzen(E_PROFIL, "aktuell", profil)
# Die Reglerstellung mitlesen: sie ist der Sollwert für den
# Schlaf-Timeout des Dongles, und flespi.py hat keinen eigenen
# Zugriff auf das Profil.
try:
self.pausenzeit_min = int(
(profil.get("einstellungen") or {})["fahrten_pausenzeit_min"]
)
except (KeyError, TypeError, ValueError):
self.pausenzeit_min = None
# Die drei Reifen-Kontrollwerte hängen am selben Profil - zusammen
# veröffentlichen, damit sie nicht auseinanderlaufen können.
# Die beiden km-Stände bleiben Zahlen (die Entitäten tragen die
@@ -595,6 +630,12 @@ class Koordinator:
# der Token selbst verlaesst die Integration nie, siehe
# zugaenge.py.
"zugaenge": zugaenge.stand(self),
# Was am Dongle steht, was noch aussteht, und wie alt die
# Angabe ist - siehe flespi.py.
"dongle": {
"aktiv": flespi.zugang(self) is not None,
"stand": self.flespi_stand,
},
},
)
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.3.8",
"version": "2026.9.3.10",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
@@ -345,3 +345,6 @@ zugang_setzen:
required: false
selector:
text:
dongle_lesen:
fields: {}
+5 -1
View File
@@ -61,7 +61,11 @@ DIENSTE: dict[str, dict] = {
"flespi": {
"schluessel": CONF_FLESPI_TOKEN,
"titel": "flespi",
"zweck": "Liest die Konfiguration des Dongles (noch nicht in Gebrauch).",
"zweck": (
"Liest die Konfiguration des Dongles und schreibt den Schlaf-Timeout. "
"Der Token braucht dafür Lese- und Schreibrecht auf die "
"Geräte-Konfiguration."
),
"erforderlich": False,
},
}
+25 -4
View File
@@ -220,9 +220,10 @@ class Nachziehen(Basis):
)
self.assertEqual(k.ablage.aktualisiert, [])
async def test_nur_die_juengste_fahrt_wird_korrigiert(self):
"""Eine aeltere vorlaeufige Fahrt darf ein spaetes Aus nicht an sich
ziehen - sonst wanderte ihr Ende ueber die naechste Fahrt hinweg."""
async def test_keine_verlaengerung_ueber_eine_andere_fahrt_hinweg(self):
"""Faengt zwischen dem vorlaeufigen Ende und dem Aus eine andere Fahrt
an, gehoert das Aus zu ihr - zwei sich ueberlappende Fahrten waeren
schlimmer als eine zu kurze."""
alt_start = T0 - datetime.timedelta(hours=3)
k = FakeKoordinator(
start_ts=None,
@@ -236,9 +237,29 @@ class Nachziehen(Basis):
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
)
self.assertEqual(
k.ablage.aktualisiert, [], "die juengste Fahrt ist regulaer geschlossen"
k.ablage.aktualisiert, [], "dazwischen liegt der Beginn von t-neu"
)
async def test_aeltere_vorlaeufige_fahrt_wird_gefunden(self):
"""Die Suche ist NICHT auf die juengste Fahrt beschraenkt: eine aeltere
regulaer geschlossene Fahrt darf die Korrektur nicht verdecken."""
frueher = T0 - datetime.timedelta(hours=5)
k = FakeKoordinator(
start_ts=None,
fahrten=[
fahrt("t-frueh", frueher, frueher + datetime.timedelta(minutes=20), False),
fahrt("t-hand", T0, LEBENSZEICHEN, True),
],
)
echt = LEBENSZEICHEN + datetime.timedelta(minutes=8)
await self._spaetes_aus(
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
)
self.assertEqual(len(k.ablage.aktualisiert), 1)
trip_id, aenderungen = k.ablage.aktualisiert[0]
self.assertEqual(trip_id, "t-hand")
self.assertEqual(aenderungen["ts_end"], echt.isoformat())
if __name__ == "__main__":
unittest.main(verbosity=2)