diff --git a/AGENTS.md b/AGENTS.md
index 5b1f378..7dd636a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 `` 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
+`` 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)
diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py
index a095e07..1e3fc92 100644
--- a/custom_components/audi_dashboard/einstellungen.py
+++ b/custom_components/audi_dashboard/einstellungen.py
@@ -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,
diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py
index 7ab48ec..294994d 100644
--- a/custom_components/audi_dashboard/fahrterkennung.py
+++ b/custom_components/audi_dashboard/fahrterkennung.py
@@ -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)
diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py
index 7f95788..7003b61 100644
--- a/custom_components/audi_dashboard/historienimport.py
+++ b/custom_components/audi_dashboard/historienimport.py
@@ -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),
diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py
index f38bee6..7076a1b 100644
--- a/custom_components/audi_dashboard/koordinator.py
+++ b/custom_components/audi_dashboard/koordinator.py
@@ -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(
diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json
index 8d56190..ed0802b 100644
--- a/custom_components/audi_dashboard/manifest.json
+++ b/custom_components/audi_dashboard/manifest.json
@@ -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"],
diff --git a/custom_components/audi_dashboard/tankerkennung.py b/custom_components/audi_dashboard/tankerkennung.py
index facf01f..35b14a8 100644
--- a/custom_components/audi_dashboard/tankerkennung.py
+++ b/custom_components/audi_dashboard/tankerkennung.py
@@ -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
)