Fahrterkennung: FMM003-Funklöcher reißen keine Fahrt mehr auseinander

Die Zündungs-Entität stammt vom FMM003-Tracker; verliert er unterwegs
kurz die Verbindung, meldet Home Assistant "unavailable" statt eines
echten Zündungszustands. Die Live-Erkennung wertete das bisher wie
"aus" - eine Funklücke über der Pausenzeit teilte eine durchgehende
Fahrt in zwei. zuendung_geaendert() ignoriert "unavailable"/"unknown"
jetzt vollständig und leitet "fährt gerade" aus dem eigenen
Zwischenstand (fahrt_start_ts) statt aus dem letzten Rohwert ab.

historienimport.py bekommt zusätzlich eine Plausibilitätsprüfung: eine
errechnete Durchschnittsgeschwindigkeit über 300 km/h deutet auf einen
Kilometerstand-Ausreißer an der Fahrtgrenze hin, nicht auf eine echte
Fahrt - die Strecke wird dann verworfen statt eine unmögliche Fahrt
anzuzeigen.

Tankerkennung: optionaler zweiter Sensor für das Tankvolumen in Litern
(TANK_LITER_SENSOR, direkt vom CAN) neben dem bisherigen
Prozent-Füllstand. Ist er zugeordnet, übernimmt er die automatische
Tankerkennung vollständig - genauer als der Umweg über das im
Fahrzeugprofil hinterlegte Tankvolumen, und der erkannte Anstieg
liefert gleich eine grobe Vorbelegung für die getankte Menge statt
eines leeren Feldes. Ohne den Sensor bleibt alles beim Alten. Der
historische Import zieht mit derselben Präferenz nach.

Manifest auf 2026.8.25.2, beide Änderungsrunden live im Testcontainer
verifiziert (py_compile, Neustart, sauberes Setup-Log).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:04:26 +02:00
parent f97ceab3ad
commit 70253e83a5
7 changed files with 295 additions and 37 deletions
+141
View File
@@ -2801,6 +2801,147 @@ in `Einstellungen.tsx` touches companion-app source (the panel-side fixes don't
which ships companion-app only), no OTA bundle rebuild triggered until the owner asks or the next round
bundles it together with something else.
## O. Single-trip duration formatting, and a defensive error boundary for the companion-app (2026.8.24.15/.16)
**Bug report: the single-trip detail view ("Einzelfahrt") showed raw minutes ("75 min") instead of an
hours-and-minutes breakdown.** Traced to `vTrip()` in the panel using `Math.round(t.duration_s / 60)}
min` directly instead of the already-existing `dauerText()` helper, which every *other* duration display
in the panel already goes through (the trip-edit form's live duration field, `fahrtDauerText()`). Fixed by
routing `vTrip()`'s "Dauer" row through `dauerText()` too. Manifest bumped to `2026.8.24.15`.
**Follow-up correction (2026.8.24.16): the owner explicitly rejected `dauerText()`'s own output format.**
`dauerText()` produced `"1 Std. 15 Min."` — technically consistent with the rest of the panel at the time,
but not what was wanted. Explicit ask: `"1 h 10 min"` exactly. This happens to be the *exact* format
companion-app's own `format.ts` `dauer()` helper already produces (`"2 h 14 min"` / `"14 min"`, 2-digit
padded minutes) — so `dauerText()` was changed to match it precisely (`std ? "${std} h
${String(min % 60).padStart(2, "0")} min" : "${min} min"`), rather than inventing a third convention.
Since `dauerText()` is the shared helper, this fixes both the trip detail view and the trip-edit form's
live duration display in one change. Verified live in the test container (real restart to pick up the new
manifest version, not just a copied file — the panel's module URL is versioned from the *in-memory*
version read at integration setup, so a filesystem-only change isn't visible until either a restart or an
integration reload): opened a real 7200 s trip, confirmed the row reads exactly `"Dauer 2 h 00 min"`.
**Separate, unrelated report investigated the same session: "Update prüfen" in the companion-app leads to
a black page; only navigating away and back to Einstellungen shows the result.** Reviewed the whole call
path in detail — `updatePruefenAusloesen()`/`api.updatePruefen()`/`DatenKontext.neuLaden()` — and found
nothing that should throw: every state field the Einstellungen screen reads off `integrationUpdate` is
already optional-chained, `neuLaden()` catches its own errors, `ActionButton`'s default `type="button"`
rules out an accidental form submit/navigation. Could **not** reproduce live this session: reproducing
needs a real Home Assistant long-lived access token to connect the companion-app dev server to the test
backend, and generating one (even for the disposable local test container) was refused by the standing
"never handle auth/secret creation" rule — this needed the owner's own action, not a workaround.
**What was found instead, and fixed regardless of the exact root cause: the app had zero React error
boundaries anywhere.** `main.tsx` rendered `<App />` directly. In React, an uncaught exception during
render anywhere in the tree unmounts the *entire* app — `#wurzel` goes empty, and what's left on screen is
just the page's own background, which in Nacht/dark theme is close to black. That matches the "black page"
report closely: not a stuck loading state (which has its own distinct `.dm-laden` visual and no plausible
trigger tied to a version-check REST call), but a silent full unmount with no error surfaced anywhere —
consistent with "only navigating away and back fixes it" too, since re-entering the screen remounts the
tree from scratch and the triggering state is gone. Added `src/ErrorGrenze.tsx` (a class component — error
boundaries require `componentDidCatch`/`getDerivedStateFromError`, hooks can't do this), wired around
`<App />` in `main.tsx`. On a caught error it now shows a visible "Etwas ist schiefgelaufen" tile with the
error message and a "Neu laden" button, and logs the error (plus component stack) to the console — instead
of a silent blank screen. This does not identify the original root cause (if the report reproduces again,
the console will now show what actually threw, which is the fastest path to the real fix), but it directly
addresses the reported symptom either way: a future crash is visible and recoverable, not indistinguishable
from a hang. `tsc --noEmit` clean, full suite still green at 145/145 (behavior of the happy path is
unchanged; the boundary only activates on an actual render exception, which no existing test triggers).
## P. Trip splitting/data-quality bugs traced to the FMM003 losing signal mid-drive (2026.8.25.1)
**Bug report: "gaps within one trip" after `Daten importieren aus HA`, and separately a trip with
physically impossible data (implausible average speed).** Both traced to the same underlying cause: the
FMM003 (the Teltonika GPS tracker that has been the ignition/GPS/km source since the 2026.8.13 switch away
from the iPhone-WiFi detector, see `SPECIFICATION.md`'s "Teilweise überholt" banner) drops its cellular/GPS
connection intermittently while the car is still being driven — parking garages, tunnels, dead zones. When
that happens the ignition entity (`ZUENDUNG_SENSOR`) goes to Home Assistant's `unavailable` state for a
while, then reports `on` again once the FMM003 reconnects, even though the ignition was never actually off.
**Root cause 1 — live trip detection treated "no signal" as "ignition off":**
`Koordinator._zuendung_geaendert()` (`koordinator.py`) fires on *every* `state_changed` event for the
ignition entity, `unavailable` included, and passed the raw state straight into
`fahrterkennung.zuendung_geaendert()`. That function computed `an_vorher = alt == "on"`; when the entity
resumed reporting `on` after an `unavailable` blip, `alt` was `"unavailable"`, not `"on"`, so
`an_vorher` was `False` — the code read this as "ignition just turned on", i.e. a *new* trip, splitting
one continuous drive into two the moment an FMM003 dropout outlasted the configured pause window
(`fahrten_pausenzeit_min`, 15 min default). Fixed in `fahrterkennung.py`: `zuendung_geaendert()` now
returns immediately (no-op) when the new state isn't literally `"on"` or `"off"` — `unavailable`/`unknown`
carry no information about the ignition and must not be treated as either. The "is a trip currently
running" check was also switched from `alt == "on"` to the coordinator's own `fahrt_start_ts is not None`,
which is the actual ground truth and no longer depends on what the entity's state happened to be during a
dropout.
Historical import (`historienimport.py`'s `_fahrtfenster()`) was **not** affected by this — it reads
`verlauf_lesen()`, which already drops `unavailable`/`unknown` points entirely (`verlauf.py`'s
`_rohverlauf()`), so a dropout never registers as an "off" transition there. That's also why re-running
the import over an already-affected period doesn't repair it: the already-split live trips exist as two
separate stored records, and the import's overlap check (`_ueberschneidet()`) skips anything that overlaps
an existing trip rather than merging. Going forward (with the live fix above) new drives won't split in
the first place; already-split historical entries would need a manual merge (not built — out of scope for
this round, flag if it comes up again).
**Root cause 2 — no plausibility check on imported trip distance:** separately, a physically-impossible
average speed was possible whenever `wert_bei()` matched a stale odometer reading across a data gap
(sensor outage, sparse reporting) at one edge of a trip window. Added `UNPLAUSIBLE_KMH = 300` in
`historienimport.py`: if a trip's computed average speed exceeds it, the distance/odometer fields are
dropped (trip still gets created, `status` falls back to `"offen"` — same "data missing, not zero"
semantics already used everywhere else in this file) and a warning is logged with the trip window and the
rejected numbers, rather than silently showing an impossible figure. `screening.py` (the live-trip
km-completion path) has no equivalent guard yet — not touched this round since it wasn't what was reported,
but worth applying the same idea there if a similar impossible-speed trip shows up on a live-detected trip.
Verified: both files `py_compile` clean, copied into the `audi_ha_test` container, manifest bumped to
`2026.8.25.1`, container restarted, confirmed `"Audi Dashboard 2026.8.25.1 eingerichtet"` in the log with
no setup errors. Live reproduction of an actual FMM003 dropout wasn't attempted (needs a real signal
outage while driving, not reproducible in the test container) — this is a static-analysis-grounded fix,
not one confirmed against a captured dropout event.
## Q. Optional direct-liter fuel sensor, for more accurate refill detection (2026.8.25.2)
**Feature request: the automatic refill detection (`tankerkennung.py`) only ever had the tank's percent
sensor to work with**, converting the "+5 L" threshold into percentage points via
`fahrzeug.tankvolumen_liter` from the vehicle profile. Accurate enough to *detect* a refill, but it can
never tell the app how many liters were actually put in — the auto-created tank record's `liters` field
stayed `None` until a receipt was uploaded or the owner typed it in by hand. The FMM003 (see section P)
reads the tank volume directly from CAN in liters on this instance, alongside the existing percent reading
— a second, independent signal that doesn't need the profile-based conversion and, being already in
liters, hands over a real estimate of the refilled amount for free.
Added `TANK_LITER_SENSOR` as a new, optional role in `einstellungen.py` (same `FELDER` catalog pattern as
every other role — the setup menu picks it up automatically, no frontend work needed, confirmed by the
same reasoning as the pending Sicherheit-Neugestaltung plan). Wired into `koordinator.py` exactly like
`TANK_SENSOR`: its own state-change observer, its own persisted low-point (`tiefststand_liter`, stored and
restored across restarts the same way `tiefststand_pct` already was).
`tankerkennung.py` gained `tankvolumen_geaendert()`, the liter-based twin of the existing
`fuellstand_geaendert()` — same low-point-tracking logic (a refill often arrives in several small reported
steps, so the comparison is against the last-seen low point, not the immediately previous value), but the
threshold is `LITER_SCHWELLE` (5 L) directly, no percentage conversion. **Whichever sensor is assigned,
only one of the two paths ever creates a tank record for the same event**: `fuellstand_geaendert()` now
checks `TANK_LITER_SENSOR` and stays silent if it's assigned, since the liter path is strictly more
accurate and would otherwise double up the same refill into two records. Unassigned, behavior is
unchanged — pure percent-based detection exactly as before, zero-risk to any existing setup. When the
liter path fires, the detected increase is written into the new record's `liters` field (rounded to 0.1 L)
as a rough starting value — the record still lands as `status: "unvollständig"` (price/station still
unknown), same as always; the owner corrects/confirms it via the existing inline edit form (panel) or a
receipt upload (both codebases), no new UI needed for this either.
Extended `historienimport.py`'s `_tankvorgaenge_importieren()` the same way, for the same reason section P
called out for trips: leaving the historical import on the old percent-only path while live detection uses
the more accurate liter path would have reintroduced exactly the kind of live/import inconsistency that
was just fixed there. It now reads `TANK_LITER_SENSOR`'s history too (added to the `verlaeufe` dict in
`importieren()`) and prefers it under the identical condition as the live path.
Verified: all four touched files (`einstellungen.py`, `koordinator.py`, `tankerkennung.py`,
`historienimport.py`) `py_compile` clean in the `audi_ha_test` container; manifest bumped to `2026.8.25.2`,
restarted, confirmed `"Audi Dashboard 2026.8.25.2 eingerichtet"` with no errors. Did not verify the new
setup-menu entry by clicking through it live (a browser-tooling networking issue blocked reaching the test
instance's UI this round) — relying on the FELDER catalog being genuinely code-driven, per the same
confirmed mechanism the pending Sicherheit plan already documented. `screening.py`/companion-app's
`TankDetail.tsx` were deliberately left untouched: the estimate flows through existing display/edit paths
without needing either to change.
---
## Working conventions (observed — keep them)
@@ -60,9 +60,18 @@ class Sensorzuordnung:
# Kilometerstand der EU-Data-Act-Integration ist der richtige.
KM_SENSOR: str = ""
# Tankfüllstand in Prozent - für die automatische Tankerkennung.
# Tankfüllstand in Prozent - für die automatische Tankerkennung und die
# Anzeige in der Übersicht.
TANK_SENSOR: str = ""
# Tankvolumen in Litern, direkt vom CAN (nicht aus dem Prozentwert
# umgerechnet). Optional - ist er gesetzt, übernimmt er die automatische
# Tankerkennung von TANK_SENSOR: ein Anstieg misst sich dann direkt in
# Litern statt über den Umweg Prozentpunkte -> Tankvolumen aus dem
# Fahrzeugprofil, und der erkannte Anstieg selbst dient als grobe
# Vorbelegung für die getankte Menge (siehe tankerkennung.py).
TANK_LITER_SENSOR: str = ""
# Reichweite (Übersicht).
RANGE_SENSOR: str = ""
@@ -144,6 +153,11 @@ FELDER: list[dict] = [
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
{"key": "TANK_LITER_SENSOR", "label": "Tankvolumen (Liter)", "gruppe": "fahrterkennung",
"hinweis": "Optional, vom CAN statt aus dem Prozentwert umgerechnet - macht die automatische "
"Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.",
"domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False,
"stichworte": ["tankvolumen", "liter", "litres", "fuel volume", "kraftstoffvolumen"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
@@ -78,24 +78,39 @@ async def pausenzeit_sekunden(k: Koordinator) -> int:
async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None:
"""Reagiert auf jede Zustandsänderung der Zündungs-Entität."""
"""Reagiert auf jede Zustandsänderung der Zündungs-Entität.
"unavailable"/"unknown" ist kein Zündungszustand, sondern Funkstille des
Trackers - beim FMM003 z. B. in Tiefgaragen oder Funklöchern, auch mitten
in einer laufenden Fahrt. Würde das wie "aus" gewertet, risse jede solche
Lücke eine einzelne Fahrt in zwei auseinander, sobald sie länger als die
Pausenzeit dauert (der Tracker meldet sich zurück, die Zündung steht laut
letztem echten Wert immer noch auf "on", also sieht es wie eine neue
Fahrt aus). Ohne Signal ist deshalb "keine Aussage", nicht "aus": der
Zustand bleibt unangetastet, bis wieder ein echter Wert kommt."""
if neu not in ("on", "off"):
return
# Eine noch wartende Ende-Bestätigung aus einer vorherigen Änderung
# abbrechen - das ist der Mechanismus hinter der Pausenregel.
k.warte_ende_ab_abbrechen()
an_jetzt = neu == "on"
an_vorher = alt == "on"
# Ob eine Fahrt läuft, entscheidet der eigene Zwischenstand
# (fahrt_start_ts) - nicht der vorherige Rohwert der Entität. Der wäre bei
# einer Funklücke "unavailable" statt "on" gewesen, obwohl die Fahrt die
# ganze Zeit lief.
fahrt_laeuft = k.fahrt_start_ts is not None
if an_jetzt and not an_vorher:
if k.fahrt_start_ts is None:
await k.fahrt_start_setzen(datetime.datetime.now(datetime.UTC))
_LOGGER.info("Fahrt gestartet um %s", k.fahrt_start_ts)
if an_jetzt and not fahrt_laeuft:
await k.fahrt_start_setzen(datetime.datetime.now(datetime.UTC))
_LOGGER.info("Fahrt gestartet um %s", k.fahrt_start_ts)
# Die Anzeige "fährt/steht" hängt an derselben Entität - sofort neu
# veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu warten.
await k.fahrzeugstatus_veroeffentlichen()
return
if an_vorher and not an_jetzt and k.fahrt_start_ts is not None:
if not an_jetzt and fahrt_laeuft:
start_ts = k.fahrt_start_ts
abbruch_ts = datetime.datetime.now(datetime.UTC)
wartezeit = await pausenzeit_sekunden(k)
@@ -42,7 +42,7 @@ import logging
from typing import TYPE_CHECKING
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
from .tankerkennung import leerer_tankvorgang, schwelle_prozent
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
from .verlauf import Verlaufspunkt, verlauf_lesen, wert_bei, zahl
if TYPE_CHECKING:
@@ -60,6 +60,15 @@ TANK_DUBLETTE_MIN = 90
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
# Kein Auto dieser Art erreicht diesen Schnitt. Eine errechnete
# Durchschnittsgeschwindigkeit darüber ist kein Beleg für eine schnelle
# Fahrt, sondern für einen fehlerhaften Kilometerstand-Sprung am Fahrtrand
# (z. B. ein Ausreißer im Verlauf oder ein knapp daneben liegender
# wert_bei()-Treffer über eine Lücke hinweg). Die Strecke wird dann verworfen
# statt eine physikalisch unmögliche Fahrt anzuzeigen - die Fahrt bleibt
# "offen", genau wie bei fehlendem Kilometerstand.
UNPLAUSIBLE_KMH = 300
def als_zeit(wert: object) -> datetime.datetime | None:
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
@@ -162,6 +171,16 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
if durchschnitt > UNPLAUSIBLE_KMH:
_LOGGER.warning(
"Kilometerstand für Fahrt %s bis %s verworfen: unmögliche %s km/h "
"im Schnitt (%s km in %s s) - vermutlich ein Ausreißer im Verlauf. "
"Die Fahrt wird trotzdem angelegt, bleibt aber ohne Strecke.",
f_start.isoformat(), f_ende.isoformat(), durchschnitt, distanz, dauer_s,
)
distanz = None
odo_start = odo_end = None
durchschnitt = None
fahrt = leere_fahrt(f_start, f_ende, "import")
fahrt.update({
@@ -185,15 +204,19 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik wie
in der Live-Erkennung: jeder Anstieg über die Schwelle gegen den zuletzt
gesehenen Tiefststand ist ein Tankvorgang, nicht jeder Anstieg gegen den
unmittelbar vorherigen Wert."""
verlauf = verlaeufe["tank"]
"""Tankvorgänge aus dem Füllstands- oder Tankvolumenverlauf - dieselbe
Tiefststand-Logik wie in der Live-Erkennung (tankerkennung.py): jeder
Anstieg über die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein
Tankvorgang, nicht jeder Anstieg gegen den unmittelbar vorherigen Wert.
Dieselbe Präferenz wie live auch: ist ein Litersensor zugeordnet,
übernimmt der - genauer, und der Anstieg dient gleich als grobe
Vorbelegung für die getankte Menge."""
mit_liter = bool(k.zuordnung.werte.TANK_LITER_SENSOR)
verlauf = verlaeufe["tank_liter"] if mit_liter else verlaeufe["tank"]
if not verlauf:
return {"angelegt": 0, "uebersprungen": 0}
schwelle = schwelle_prozent(await k.ablage.profil_lesen())
schwelle = LITER_SCHWELLE if mit_liter else schwelle_prozent(await k.ablage.profil_lesen())
km_verlauf = verlaeufe["km"]
bestehende = await k.ablage.tankvorgaenge_lesen()
fenster_s = TANK_DUBLETTE_MIN * 60
@@ -218,7 +241,8 @@ async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
if tiefststand is None or aktuell <= tiefststand:
tiefststand = aktuell
continue
if aktuell - tiefststand < schwelle:
anstieg = aktuell - tiefststand
if anstieg < schwelle:
continue
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
@@ -229,6 +253,8 @@ async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
odometer_km = wert_bei(km_verlauf, ts)
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
tankvorgang["odometer_km"] = odometer_km
if mit_liter:
tankvorgang["liters"] = round(anstieg, 1)
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
ts.isoformat(), odometer_km, neue + bestehende
)
@@ -339,6 +365,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
"zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis),
"tank_liter": await verlauf_lesen(k.hass, werte.TANK_LITER_SENSOR, von, bis),
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis),
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis),
"lon": await verlauf_lesen(k.hass, werte.STANDORT_LON_SENSOR, von, bis),
@@ -99,6 +99,7 @@ class Koordinator:
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
self.fahrt_start_ts: datetime.datetime | None = None
self.tiefststand_pct: float | None = None
self.tiefststand_liter: float | None = None
# Ergebnis des letzten "Auf Update prüfen"/"Update installieren" -
# nur auf Tastendruck gesetzt (siehe dienste.py), nie im normalen
# Veröffentlichungs-Takt neu berechnet, damit keine automatischen
@@ -198,6 +199,7 @@ class Koordinator:
self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_geaendert)
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
self._beobachten(werte.TANK_LITER_SENSOR, self._tankvolumen_geaendert)
def _beobachten(
self, entity_id: str, rueckruf: Callable[[Event[EventStateChangedData]], Coroutine[Any, Any, None]]
@@ -229,6 +231,10 @@ class Koordinator:
neu, _alt = self._zustaende(ereignis)
await tankerkennung.fuellstand_geaendert(self, neu)
async def _tankvolumen_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, _alt = self._zustaende(ereignis)
await tankerkennung.tankvolumen_geaendert(self, neu)
# ------------------------------------------------- Laufende Fahrt merken
async def _laufzeit_laden(self) -> None:
@@ -243,11 +249,13 @@ class Koordinator:
except ValueError:
self.fahrt_start_ts = None
self.tiefststand_pct = gespeichert.get("tiefststand_pct")
self.tiefststand_liter = gespeichert.get("tiefststand_liter")
async def _laufzeit_sichern(self) -> None:
await self._store.async_save({
"fahrt_start_ts": self.fahrt_start_ts.isoformat() if self.fahrt_start_ts else None,
"tiefststand_pct": self.tiefststand_pct,
"tiefststand_liter": self.tiefststand_liter,
})
async def fahrt_start_setzen(self, ts: datetime.datetime | None) -> None:
@@ -258,6 +266,10 @@ class Koordinator:
self.tiefststand_pct = wert
await self._laufzeit_sichern()
async def tiefststand_liter_setzen(self, wert: float | None) -> None:
self.tiefststand_liter = wert
await self._laufzeit_sichern()
def warte_ende_ab(self, koroutine: Coroutine[Any, Any, None]) -> None:
self.warte_ende_ab_abbrechen()
self._ende_aufgabe = self.entry.async_create_background_task(
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.24.16",
"version": "2026.8.25.2",
"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"],
@@ -1,23 +1,35 @@
"""Automatische Tankerkennung über den Füllstandssensor.
"""Automatische Tankerkennung über den Füllstandssensor - oder, wenn
zugeordnet, direkt über einen Tankvolumen-Sensor in Litern.
Beobachtung: der Füllstand (in Prozent) springt beim Fahren nie nach oben, er
sinkt nur - jeder Anstieg ist also ein Tankvorgang. Schwelle nach Vorgabe: ab
+5 Liter ODER +9 Prozentpunkten Anstieg gilt als nachgetankt. Die
5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus dem Fahrzeugprofil in
Prozentpunkte umgerechnet, damit beide Angaben auf derselben Einheit
verglichen werden können; es gilt jeweils die empfindlichere (kleinere) der
beiden Schwellen.
Beobachtung: der Füllstand springt beim Fahren nie nach oben, er sinkt nur -
jeder Anstieg ist also ein Tankvorgang. Zwei Datenquellen dafür, in
absteigender Genauigkeit:
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die Datenquelle
liefert einen Tankvorgang oft in mehreren kleinen Schritten (40% -> 45% ->
60%), von denen keiner allein die Schwelle überschreiten muss. Deshalb wird
der zuletzt bekannte Tiefststand gespeichert und der Anstieg dagegen gemessen.
Nach Anlage eines Tankvorgangs wird der Tiefststand auf den aktuellen Wert
zurückgesetzt, damit derselbe Vorgang nicht mehrfach Datensätze erzeugt.
- TANK_LITER_SENSOR (optional, z. B. vom CAN): Anstieg direkt in Litern,
Schwelle LITER_SCHWELLE. Braucht keine Umrechnung und liefert den Anstieg
selbst gleich als Vorbelegung für die getankte Menge mit.
- TANK_SENSOR (Prozent, siehe fuellstand_geaendert()): Schwelle nach Vorgabe
ab +5 Liter ODER +9 Prozentpunkten Anstieg, wobei die 5-Liter-Vorgabe über
fahrzeug.tankvolumen_liter aus dem Fahrzeugprofil in Prozentpunkte
umgerechnet wird (beide Angaben so auf derselben Einheit); es gilt jeweils
die empfindlichere (kleinere) der beiden Schwellen.
Ist der Litersensor zugeordnet, übernimmt er die Erkennung vollständig -
beide gleichzeitig auszuwerten würde denselben Tankvorgang zweimal anlegen.
Ohne ihn bleibt es beim Prozentsensor wie bisher.
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich, für beide
Quellen gleichermaßen: die Datenquelle liefert einen Tankvorgang oft in
mehreren kleinen Schritten (40% -> 45% -> 60%), von denen keiner allein die
Schwelle überschreiten muss. Deshalb wird der zuletzt bekannte Tiefststand
gespeichert und der Anstieg dagegen gemessen. Nach Anlage eines Tankvorgangs
wird der Tiefststand auf den aktuellen Wert zurückgesetzt, damit derselbe
Vorgang nicht mehrfach Datensätze erzeugt.
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
(status "unvollständig", ohne Liter/Kosten/Station): erwartet wird ein
automatisch erkannter Tankvorgang mit Zeitstempel, Kilometerstand und
(status "unvollständig", ohne Kosten/Station, Liter nur als grobe Schätzung
vom Litersensor oder ganz ohne bei reiner Prozent-Erkennung): erwartet wird
ein automatisch erkannter Tankvorgang mit Zeitstempel, Kilometerstand und
gefahrener Distanz, den der Nutzer später per Beleg-Nachtrag vervollständigt.
Auch hier gilt die Härtung aus fahrterkennung.py: der Tiefststand lebt nicht
@@ -97,21 +109,58 @@ async def fuellstand_geaendert(k: Koordinator, neu: str | None) -> None:
if anstieg < schwelle_prozent(await k.ablage.profil_lesen()):
return
await _automatisch_anlegen(k, anstieg)
# Ist ein direkter Litersensor zugeordnet, übernimmt der die Erkennung
# (siehe tankvolumen_geaendert) - genauer, weil ohne den Umweg über das im
# Fahrzeugprofil hinterlegte Tankvolumen. Beide gleichzeitig auswerten
# würde denselben Tankvorgang zweimal anlegen.
if not k.zuordnung.werte.TANK_LITER_SENSOR:
await _automatisch_anlegen(k, f"Füllstandsanstieg {round(anstieg, 1)} Prozentpunkte")
await k.tiefststand_setzen(aktuell)
async def _automatisch_anlegen(k: Koordinator, anstieg_pct: float) -> None:
async def tankvolumen_geaendert(k: Koordinator, neu: str | None) -> None:
"""Wie fuellstand_geaendert(), aber direkt in Litern statt in Prozent -
braucht deshalb keine Umrechnung über das Fahrzeugprofil und liefert mit
dem gemessenen Anstieg selbst eine sinnvolle Vorbelegung für die getankte
Menge (siehe leerer_tankvorgang() - "liters" bleibt sonst None, bis der
Nutzer sie von Hand einträgt oder ein Beleg sie liefert)."""
aktuell = _als_zahl(neu)
if aktuell is None:
return
tiefststand = k.tiefststand_liter
if tiefststand is None or aktuell <= tiefststand:
await k.tiefststand_liter_setzen(aktuell)
return
anstieg = aktuell - tiefststand
if anstieg < LITER_SCHWELLE:
return
await _automatisch_anlegen(
k, f"Tankvolumenanstieg {round(anstieg, 1)} l", liter_schaetzung=anstieg
)
await k.tiefststand_liter_setzen(aktuell)
async def _automatisch_anlegen(
k: Koordinator, anlass: str, liter_schaetzung: float | None = None
) -> None:
odometer_km = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR))
tankvorgang = leerer_tankvorgang(
datetime.datetime.now(datetime.UTC).isoformat(), "auto"
)
tankvorgang["odometer_km"] = odometer_km
tankvorgang["distance_km"] = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
if liter_schaetzung is not None:
# Grobe Vorbelegung, keine feststehende Menge - der Status bleibt
# "unvollständig" (siehe leerer_tankvorgang), bis Preis/Tankstelle
# dazukommen. Der Nutzer korrigiert sie bei Bedarf selbst, ein
# nachgetragener Beleg überschreibt sie ohnehin.
tankvorgang["liters"] = round(liter_schaetzung, 1)
await k.ablage.tankvorgang_anhaengen(tankvorgang)
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info(
"Tankvorgang %s automatisch erkannt (Füllstandsanstieg %s Prozentpunkte)",
tankvorgang["tank_id"], round(anstieg_pct, 1),
"Tankvorgang %s automatisch erkannt (%s)", tankvorgang["tank_id"], anlass
)