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 )