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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user