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:
@@ -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 0–60 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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: {}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user