Fahrten: Verbrauch-Näherung, 0V-Zustandsanzeige gefiltert, Plausibilitätsgrenze für die Live-Vervollständigung
Verbrauch (l/100km) war bislang totes Schema - kein Codepfad füllte verbrauch_l_100km je. Jetzt aus der Literstand-Differenz (TANK_LITER_SENSOR) über die Distanz genähert, live und beim Import, in beiden Frontends. Die "Zustand"-Kachel zeigte die Batteriespannung ungefiltert direkt vom Sensor, unabhängig von der Plausibilitätsgrenze der Verlaufsaufzeichnung - ein Sensorausreißer (0V) zeigte sich dort weiterhin, obwohl die Messwertliste ihn längst verwarf. Dieselbe Grenze gilt jetzt auch für diesen Anzeigepfad. Reale Fahrtendaten zeigten eine Fahrt mit 22km in 67s (~1180 km/h) - die Live-Vervollständigung (screening.py) hatte anders als der Import keine Plausibilitätsprüfung der Durchschnittsgeschwindigkeit. Jetzt gemeinsam in verlauf.py (UNPLAUSIBLE_KMH/durchschnitt_kmh) für beide Pfade. Dabei einen zweiten echten Bug gefunden: _fahrt_screenen() zog sein Ergebnis nie ins In-Memory-Objekt nach (nur in die Ablage) - eine im selben Durchlauf gerade erst ermittelte Distanz blieb für spätere Schritte (z. B. Verbrauch) bis zum nächsten Screening unsichtbar. Beide Stellen jetzt behoben. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3267,6 +3267,181 @@ measured 736×170px at a 1400px viewport (Y-scale exactly `1`), axis label `getC
|
|||||||
`tsc --noEmit` clean. Manifest bumped to `2026.8.27.5`, `npm run ota` rerun, `audi_ha_test` restart confirmed
|
`tsc --noEmit` clean. Manifest bumped to `2026.8.27.5`, `npm run ota` rerun, `audi_ha_test` restart confirmed
|
||||||
clean via log.
|
clean via log.
|
||||||
|
|
||||||
|
**Fourth follow-up (2026.8.27.6): the real gap behind the `.5` "0V on the real instance" report - a second,
|
||||||
|
unfiltered publish path, not (only) stale/not-yet-updated data.** Owner report: "Mein Audi/Zustand Box"
|
||||||
|
still shows `Batteriespannung 0,0V`, while the Batterie-Messwerte list has no such entry. Traced to a second
|
||||||
|
code path that was never touched by the `SPANNUNG_MIN_V` floor at all: `veroeffentlichung.py`'s
|
||||||
|
`fahrzeugstatus()` publishes `batteriespannung` as the **raw, unfiltered current `BATTERIE_SENSOR` reading**
|
||||||
|
(`_zu_zahl(zustand_oder_none(...))`), completely independent of `batterie.py`'s history-recording path that
|
||||||
|
the floor actually guards. The panel's "Zustand" tile (`audi-dashboard-app.js` ~line 1611) and
|
||||||
|
companion-app's `MeinAudi.tsx`/`Batterie.tsx` "Aktuell gemeldet" line all read this same published field
|
||||||
|
directly - so a live sensor glitch (confirmed live: the test container's `BATTERIE_SENSOR` was genuinely
|
||||||
|
reporting raw state `"0"`) always showed through unfiltered there, in both codebases, regardless of whether
|
||||||
|
the instance had ever been updated. Fixed by applying the identical floor to this publish too: `spannung =
|
||||||
|
None` when the raw reading is below `SPANNUNG_MIN_V`, so "Zustand"/`Aktuell gemeldet` now show "unbekannt"/
|
||||||
|
"—" for the same implausible readings the history path already silently drops - one backend fix covers both
|
||||||
|
frontends, since both read the same published field. Needed a **function-local** import of `SPANNUNG_MIN_V`
|
||||||
|
from `batterie.py` (not a module-level one): `batterie.py` already imports `zustand_oder_none` from
|
||||||
|
`veroeffentlichung.py`, so a top-level import the other direction risks a circular-import failure depending
|
||||||
|
on load order (batterie.py reaches its own `from .veroeffentlichung import zustand_oder_none` line *before*
|
||||||
|
its `SPANNUNG_MIN_V = 10.0` assignment, so an eager reverse import can hit a partially-initialized module).
|
||||||
|
A local import inside `fahrzeugstatus()` sidesteps this entirely - both modules are always fully loaded by
|
||||||
|
the time that function actually runs. Verified live: a real Docker Desktop outage interrupted this fix
|
||||||
|
mid-session (confirmed via `docker ps` failing, unrelated to the code) - resumed once Docker was back.
|
||||||
|
`py_compile` clean, `audi_ha_test` restart confirmed clean via log **with no circular-import traceback**
|
||||||
|
(the actual proof this pattern needed, `py_compile` alone can't catch an import-order bug), and live in the
|
||||||
|
browser: raw sensor confirmed still reporting `"0"` via a direct state query, "Zustand" tile now reads
|
||||||
|
"Batteriespannung unbekannt" instead of "0,0 V". No companion-app source changes this round (backend-only
|
||||||
|
fix), so no OTA rebuild needed. Manifest bumped to `2026.8.27.6`.
|
||||||
|
|
||||||
|
## V. Per-trip Verbrauch (l/100 km): dead schema closed with a liter-based approximation, real-sensor
|
||||||
|
question left open (2026.8.27.7)
|
||||||
|
|
||||||
|
**The trip detail's "Verbrauch" row (`fahrtVerbrauch()`, `audi-dashboard-app.js`) always showed "liegt nicht
|
||||||
|
vor" - traced (owner asked "why is it so? what sensor are you waiting for?") to `t.verbrauch_l_100km` being
|
||||||
|
dead schema exactly like `start_lat`/`route` were before section U: it existed **only** in the frontend, no
|
||||||
|
backend code path (live or import) had ever written it, and no Setup-mappable sensor role existed for it
|
||||||
|
either. The "vom Fahrzeug gemeldet" caption was aspirational leftover, not a description of anything actually
|
||||||
|
wired up.**
|
||||||
|
|
||||||
|
**Owner's decision, to close this properly rather than just deleting the dead row:** two options exist -
|
||||||
|
(a) the FMM003 might expose a genuine short-trip l/100km value directly over OBD (the owner is checking), or
|
||||||
|
(b) compute an approximation from `TANK_LITER_SENSOR`'s liter-delta over the trip ÷ distance. Built (b) now
|
||||||
|
as the working default, with (a) tracked as an explicit follow-up: **if a real short-trip consumption sensor
|
||||||
|
turns up, swap to it directly instead of this approximation** - noted here rather than only in a chat
|
||||||
|
message, per this file's own "state changes, note it" maintenance rule.
|
||||||
|
|
||||||
|
**Implementation**, mirroring the existing `route_aus_verlauf()`/position-screening pattern from section U
|
||||||
|
for the exact same reason (live vs. import parity, no duplicated math): new
|
||||||
|
`verbrauch_aus_literstaenden(start_liter, end_liter, distance_km)` in `verlauf.py` - `None` unless both liter
|
||||||
|
readings and a positive distance are available *and* the liter level actually fell (a level that rose or held
|
||||||
|
steady means a mid-trip refuel or noise, not a meaningful ratio). `verbrauch_l_100km` added to the trip schema
|
||||||
|
(`leere_fahrt()`, was entirely absent before). Live path: new `_verbrauch_screenen()` in `screening.py`,
|
||||||
|
wired into `durchfuehren()` exactly like `_position_screenen()` - runs whenever `TANK_LITER_SENSOR` is mapped,
|
||||||
|
re-attempted on every screening pass until it succeeds (liter data can lag just like odometer/GPS data).
|
||||||
|
Import path: `_fahrten_importieren()` in `historienimport.py` computes it inline from the already-fetched
|
||||||
|
`verlaeufe["tank_liter"]` history (was fetched for tank-refill import already, just never used for this).
|
||||||
|
Both frontends: panel's caption changed from the false "vom Fahrzeug gemeldet" to "Näherung aus
|
||||||
|
Tankfüllstand"; companion-app's `FahrtDetail.tsx` had **no Verbrauch row at all before this** (a pre-existing
|
||||||
|
parity gap, not something this change broke) - added one in the same position as the panel's (after
|
||||||
|
"Durchschnitt"), same honest caption, `verbrauch_l_100km` added to the `Fahrt` type (was typed `unknown`
|
||||||
|
nowhere - simply absent).
|
||||||
|
|
||||||
|
**A second, real bug found and fixed while live-testing this: `_fahrt_screenen()` (the existing odometer
|
||||||
|
screening, unchanged by this feature) never synced its result back onto the in-memory `fahrt` dict, only into
|
||||||
|
storage.** `_vollstaendig()` only ever wrote `distance_km`/`km_quelle`/`status` into a local `aenderungen`
|
||||||
|
dict, which `_fahrt_screenen()` then persisted via `fahrt_aktualisieren()` - but never applied to the `fahrt`
|
||||||
|
object itself, unlike `_position_screenen()`, which already does `fahrt.update(aenderungen)` for exactly this
|
||||||
|
reason. Consequence: when a trip's distance was computed for the first time and the *same* `durchfuehren()`
|
||||||
|
pass then reached the new `_verbrauch_screenen()` step, the `ohne_verbrauch` filter still saw the stale
|
||||||
|
`distance_km: None` from the `fahrten` list fetched at the top of the function - `verbrauch_l_100km` silently
|
||||||
|
stayed unfilled for one whole screening cycle, only catching up on the *next* trigger. Fixed by adding the
|
||||||
|
same `fahrt.update(aenderungen)` to `_fahrt_screenen()`, matching `_position_screenen()`'s already-correct
|
||||||
|
pattern - found by reproducing it directly (a trip's `distance_km` and `verbrauch_l_100km` both stayed empty
|
||||||
|
after the first screening pass, filled in only after a second explicit trigger) rather than by reading the
|
||||||
|
code and guessing.
|
||||||
|
|
||||||
|
Verified: `py_compile` clean on all five touched backend files (incl. this `screening.py` fix), `node --check`
|
||||||
|
clean on the panel JS, companion-app `tsc --noEmit` clean and full suite green at 146/146. Live end-to-end in
|
||||||
|
`audi_ha_test` (`TANK_LITER_SENSOR` was already mapped to the container's real
|
||||||
|
`sensor.testcar_b9_fmm003_testintegratoin_can_fuel_volume`, liters - no Setup change needed): ran a synthetic
|
||||||
|
20.0→19.0 l trip over a simulated 10 km, confirmed `verbrauch_l_100km: 10.0` computed correctly and rendered
|
||||||
|
on the panel's trip detail ("10,0 l/100 km · Näherung aus Tankfüllstand", screenshot taken). companion-app's
|
||||||
|
`FahrtDetail` row was **not** live-rendered this round (no running dev server connected to a backend this
|
||||||
|
session) - verified only via `tsc --noEmit` and the full test suite, same as several other companion-app-only
|
||||||
|
changes this session.
|
||||||
|
|
||||||
|
**Separately, while pulling data for this: found the container's `ZUENDUNG_SENSOR` mapping had changed since
|
||||||
|
last checked** - now `binary_sensor...engine_ignition_or_acc_status` (stable, no chattering observed) instead
|
||||||
|
of the `instant_movement_status` entity used earlier this session, which **was** flapping on/off dozens of
|
||||||
|
times per minute (test-integration noise, unrelated to any real vehicle behavior). The owner had reconfigured
|
||||||
|
the Setup mapping themselves in the meantime. Confirms this container's synthetic test entities remain
|
||||||
|
unrelated to the owner's real-vehicle trip-merging report from the same day (section P/the "no clean
|
||||||
|
ignition-off signal" conversation) - it needs the real instance's own history, not this container's.
|
||||||
|
|
||||||
|
Manifest bumped to `2026.8.27.7`, `npm run ota` rerun (companion-app source changed).
|
||||||
|
|
||||||
|
## W. Correction: `audi_ha_test` is connected to the real vehicle, not synthetic data - and the real
|
||||||
|
trip-merging/GPS-jump report, root-caused with real data (2026.8.27.8)
|
||||||
|
|
||||||
|
**Correction to sections U and V above: the owner confirmed `audi_ha_test` (this session's "test container")
|
||||||
|
receives live telemetry from the real car/real FMM003 - the same sensors are wired into both this container
|
||||||
|
and the real HA instance in parallel.** Both prior sections state the opposite ("synthetic test-integration
|
||||||
|
noise", "unrelated to the owner's real-vehicle... report") - that was wrong, and this project's own
|
||||||
|
convention is to correct rather than silently rewrite history, so it stays here rather than editing U/V after
|
||||||
|
the fact. The erratic on/off chatter observed on `instant_movement_status` earlier the same day is real
|
||||||
|
telemetry from a real accelerometer/movement-based signal, not fabricated test noise - see below for what it
|
||||||
|
actually means. **The owner also confirmed creating synthetic test trips in this container (as done
|
||||||
|
throughout sections R-V) remains fine** - it's a secondary instance fed by the same sensors, not the sole
|
||||||
|
copy of their trip history, so the established "simulate via `hass.callApi` POST states, verify, clean up
|
||||||
|
afterward" testing convention stands unchanged going forward.
|
||||||
|
|
||||||
|
**With that corrected, the real trip-merging/GPS-jump report (originally raised in section P's conversation)
|
||||||
|
was root-caused directly from this container's real recorder history - no separate pull from a "production"
|
||||||
|
instance was needed after all, since this *is* that instance's real data.** Matched the owner's report
|
||||||
|
exactly once UTC/local (`CEST = UTC+2`) is accounted for:
|
||||||
|
|
||||||
|
- `t-da363d681915` (`09:55:31`–`12:05:01` UTC = **11:55–14:05 local**) - the wrongly-merged trip.
|
||||||
|
- `t-8be105166025` (`12:28:05`–`12:29:12` UTC = **14:28–14:29 local**), `distance_km: 22.0` over 67 seconds
|
||||||
|
(~1180 km/h) - the "GPS jumps 22 km" report.
|
||||||
|
|
||||||
|
**Root cause of the merge, confirmed from the raw history (not the "unavailable-instead-of-off" hypothesis
|
||||||
|
originally guessed in section P's conversation):** `instant_movement_status` (mapped as `ZUENDUNG_SENSOR` at
|
||||||
|
the time) reported sustained, unbroken `on` for 45–64-minute stretches with zero transitions - including
|
||||||
|
straight through the ~30-minute real stop the owner described around `10:22` UTC, with no `off` at all during
|
||||||
|
it. Where it did flip `off`, it almost always flipped back `on` within milliseconds to a couple of seconds,
|
||||||
|
so the 15-minute pause-close logic (`fahrterkennung.py`) never got an uncanceled window to actually end the
|
||||||
|
trip - every near-instant `on` cancels the pending close (`k.warte_ende_ab_abbrechen()`, correct behavior in
|
||||||
|
isolation) before the wait can complete. No `unavailable` states appeared anywhere in the relevant window, so
|
||||||
|
the section P dropout-hypothesis genuinely doesn't apply to this specific incident. The entity itself is the
|
||||||
|
problem: a real-time movement/accelerometer heuristic, not a debounced ACC/ignition state - it can both
|
||||||
|
chatter during genuine driving *and* fail to register a genuine stop. **The owner had already fixed this
|
||||||
|
independently by remapping `ZUENDUNG_SENSOR` to `engine_ignition_or_acc_status`** (confirmed stable, no
|
||||||
|
chatter, when checked) - the correct fix; nothing further needed there from this end.
|
||||||
|
|
||||||
|
**The GPS-jump trip (`t-8be105166025`) is a second, related but distinct defect: the live trip-completion
|
||||||
|
path had no plausibility guard on computed average speed at all.** `historienimport.py` already had one
|
||||||
|
(`UNPLAUSIBLE_KMH = 300`, added 2026-08-25/section P) for exactly this failure mode - a real 22 km drive
|
||||||
|
whose *actual* duration was much longer than 67 seconds, but whose recorded trip window got compressed by the
|
||||||
|
chattering ignition entity creating a spuriously short boundary, while `naechster_wert()`'s odometer lookup
|
||||||
|
still (correctly, on its own terms) found the real km delta nearest those wrong timestamps. `screening.py`
|
||||||
|
(the live path) never got the equivalent guard - a gap already flagged as open in section P but not built
|
||||||
|
until now. Fixed by moving the check into a shared home: `UNPLAUSIBLE_KMH` and a new `durchschnitt_kmh()`
|
||||||
|
helper now live in `verlauf.py` (both `screening.py` and `historienimport.py` import them, no more duplicated
|
||||||
|
constant), and `screening.py`'s `_vollstaendig()` - the single choke point both `_fahrt_screenen()` and
|
||||||
|
`_lueckenlos_verketten()` already funnel through - now rejects (not just the distance, both `odo_start` *and*
|
||||||
|
`odo_end`) whenever the implied average speed exceeds the limit, logs a warning, and leaves the trip `offen`
|
||||||
|
to be retried on the next screening pass, exactly matching how `historienimport.py` already handles this
|
||||||
|
class of error.
|
||||||
|
|
||||||
|
**The two already-broken real trip records were deliberately left untouched** - they're the owner's real
|
||||||
|
driving history, not disposable test data (see the correction above), so fixing/deleting them is the owner's
|
||||||
|
call via the app's own edit/delete controls, not something to do unilaterally from this session.
|
||||||
|
|
||||||
|
**Owner then confirmed synthetic trips in this container remain fine** (same sensors feed both this
|
||||||
|
container and the real instance in parallel, this one is not the sole copy of trip history) **- and drew a
|
||||||
|
hard line that stays for the rest of this project: `audi_ha_test`/`:18123` is the only instance ever to be
|
||||||
|
touched; the real instance is never to be worked on, full stop.** Attempted a live end-to-end repro of the
|
||||||
|
exact bug (POSTed a fake +22 km jump over ~8 s, same recipe used successfully for the position/verbrauch
|
||||||
|
features earlier this session) - and hit a new, worth-remembering limitation: **the real FMM003/flespi
|
||||||
|
integration re-asserts its own live odometer reading shortly after a manual `POST /api/states` write**, so
|
||||||
|
the simulated +22 km value never actually landed in the recorder at all (confirmed via a direct history
|
||||||
|
query - only the real, unchanged ~21324 km value ever appears). This container's earlier "first screening
|
||||||
|
pass runs before the recorder commits" races (sections R/S) may have partly been this same effect rather than
|
||||||
|
pure commit latency - a real competing writer, not just replay timing. **Consequence for future sessions:**
|
||||||
|
simulating a *specific* sensor value in this container is unreliable for entities the real FMM003 actively
|
||||||
|
reports (ignition, odometer, GPS, tank) - it can work if the value is posted right before the dependent
|
||||||
|
event fires (as it did for the position/verbrauch tests), but don't assume a posted value will still be there
|
||||||
|
moments later if the real device happens to report in the meantime.
|
||||||
|
|
||||||
|
Fell back to the same no-live-state verification as the first pass: `py_compile` clean on all three touched
|
||||||
|
backend files, `audi_ha_test` restart confirmed clean via log (single `audi_dashboard` loader warning, no
|
||||||
|
duplicate-domain regression), and the plausibility math verified by calling `durchschnitt_kmh()` directly
|
||||||
|
against the real bug's own numbers (no HA state involved): `durchschnitt_kmh(22.0, 67)` → `1182.1` km/h,
|
||||||
|
correctly exceeding `UNPLAUSIBLE_KMH=300` and therefore now rejected; a real plausible drive
|
||||||
|
(`durchschnitt_kmh(22.0, 1200)` → `66.0` km/h) correctly passes through unaffected. No companion-app source
|
||||||
|
changes this round (backend-only). Manifest bumped to `2026.8.27.8`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Working conventions (observed — keep them)
|
## Working conventions (observed — keep them)
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export interface Fahrt {
|
|||||||
start_address?: string | null;
|
start_address?: string | null;
|
||||||
end_address?: string | null;
|
end_address?: string | null;
|
||||||
route?: [number, number][] | null;
|
route?: [number, number][] | null;
|
||||||
|
/** Näherung aus der Literstand-Differenz (TANK_LITER_SENSOR) ÷ Distanz, kein
|
||||||
|
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
|
||||||
|
* verbrauch_aus_literstaenden() im Backend. */
|
||||||
|
verbrauch_l_100km?: number | null;
|
||||||
pausen?: unknown[];
|
pausen?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
|
|||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Wertzeile
|
||||||
|
label="Verbrauch"
|
||||||
|
wert={fahrt.verbrauch_l_100km != null ? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km` : "liegt nicht vor"}
|
||||||
|
zusatz="Näherung aus Tankfüllstand"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle:
|
|||||||
"odo_start": None,
|
"odo_start": None,
|
||||||
"odo_end": None,
|
"odo_end": None,
|
||||||
"avg_speed_kmh": None,
|
"avg_speed_kmh": None,
|
||||||
|
"verbrauch_l_100km": None,
|
||||||
"start_lat": None,
|
"start_lat": None,
|
||||||
"start_lon": None,
|
"start_lon": None,
|
||||||
"end_lat": None,
|
"end_lat": None,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":"2026.8.27.5","sha256":"8d63b052bdcf4cf2eabd5a01f525fc25c4ab8eea30327dc3b8cbd0db2feb05ed","bytes":235510,"gebaut":"2026-08-27T16:30:48Z"}
|
{"version":"2026.8.27.7","sha256":"4631011c09be09efcdf787ba86ad1bd1fb40006f0d247e8b99b29b0a196f2da3","bytes":235579,"gebaut":"2026-08-27T17:32:23Z"}
|
||||||
Binary file not shown.
@@ -3165,7 +3165,7 @@ function vTrip(id) {
|
|||||||
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
|
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
|
||||||
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
|
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
|
||||||
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
|
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
|
||||||
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
|
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>Näherung aus Tankfüllstand</small></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
|
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,16 @@ from typing import TYPE_CHECKING
|
|||||||
from .batterie import SPANNUNG_MIN_V
|
from .batterie import SPANNUNG_MIN_V
|
||||||
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
|
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
|
||||||
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
|
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
|
||||||
from .verlauf import Verlaufspunkt, route_aus_verlauf, verlauf_lesen, wert_bei, zahl
|
from .verlauf import (
|
||||||
|
UNPLAUSIBLE_KMH,
|
||||||
|
Verlaufspunkt,
|
||||||
|
durchschnitt_kmh,
|
||||||
|
route_aus_verlauf,
|
||||||
|
verbrauch_aus_literstaenden,
|
||||||
|
verlauf_lesen,
|
||||||
|
wert_bei,
|
||||||
|
zahl,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .koordinator import Koordinator
|
from .koordinator import Koordinator
|
||||||
@@ -61,14 +70,11 @@ TANK_DUBLETTE_MIN = 90
|
|||||||
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
|
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
|
||||||
MINDESTDAUER_S = 60
|
MINDESTDAUER_S = 60
|
||||||
|
|
||||||
# Kein Auto dieser Art erreicht diesen Schnitt. Eine errechnete
|
# UNPLAUSIBLE_KMH selbst lebt in verlauf.py - gemeinsam mit screening.py, das
|
||||||
# Durchschnittsgeschwindigkeit darüber ist kein Beleg für eine schnelle
|
# dieselbe Prüfung seit 2026.8.27 auch für die Live-Vervollständigung nutzt
|
||||||
# Fahrt, sondern für einen fehlerhaften Kilometerstand-Sprung am Fahrtrand
|
# (siehe dortiges _vollstaendig()). Die Strecke wird dann verworfen statt
|
||||||
# (z. B. ein Ausreißer im Verlauf oder ein knapp daneben liegender
|
# eine physikalisch unmögliche Fahrt anzuzeigen - die Fahrt bleibt "offen",
|
||||||
# wert_bei()-Treffer über eine Lücke hinweg). Die Strecke wird dann verworfen
|
# genau wie bei fehlendem Kilometerstand.
|
||||||
# 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:
|
def als_zeit(wert: object) -> datetime.datetime | None:
|
||||||
@@ -150,6 +156,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
|||||||
km_verlauf = verlaeufe["km"]
|
km_verlauf = verlaeufe["km"]
|
||||||
lat_verlauf = verlaeufe["lat"]
|
lat_verlauf = verlaeufe["lat"]
|
||||||
lon_verlauf = verlaeufe["lon"]
|
lon_verlauf = verlaeufe["lon"]
|
||||||
|
tank_liter_verlauf = verlaeufe["tank_liter"]
|
||||||
|
|
||||||
angelegt = uebersprungen = zu_kurz = 0
|
angelegt = uebersprungen = zu_kurz = 0
|
||||||
neue: list[dict] = []
|
neue: list[dict] = []
|
||||||
@@ -169,19 +176,17 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
|||||||
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
|
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
|
||||||
distanz = round(odo_end - odo_start, 1)
|
distanz = round(odo_end - odo_start, 1)
|
||||||
|
|
||||||
durchschnitt = None
|
durchschnitt = durchschnitt_kmh(distanz, dauer_s)
|
||||||
if distanz is not None and dauer_s > 0:
|
if durchschnitt is not None and durchschnitt > UNPLAUSIBLE_KMH:
|
||||||
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
|
_LOGGER.warning(
|
||||||
if durchschnitt > UNPLAUSIBLE_KMH:
|
"Kilometerstand für Fahrt %s bis %s verworfen: unmögliche %s km/h "
|
||||||
_LOGGER.warning(
|
"im Schnitt (%s km in %s s) - vermutlich ein Ausreißer im Verlauf. "
|
||||||
"Kilometerstand für Fahrt %s bis %s verworfen: unmögliche %s km/h "
|
"Die Fahrt wird trotzdem angelegt, bleibt aber ohne Strecke.",
|
||||||
"im Schnitt (%s km in %s s) - vermutlich ein Ausreißer im Verlauf. "
|
f_start.isoformat(), f_ende.isoformat(), durchschnitt, distanz, dauer_s,
|
||||||
"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
|
||||||
distanz = None
|
durchschnitt = None
|
||||||
odo_start = odo_end = None
|
|
||||||
durchschnitt = None
|
|
||||||
|
|
||||||
fahrt = leere_fahrt(f_start, f_ende, "import")
|
fahrt = leere_fahrt(f_start, f_ende, "import")
|
||||||
fahrt.update({
|
fahrt.update({
|
||||||
@@ -195,6 +200,9 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
|||||||
"end_lat": wert_bei(lat_verlauf, f_ende),
|
"end_lat": wert_bei(lat_verlauf, f_ende),
|
||||||
"end_lon": wert_bei(lon_verlauf, f_ende),
|
"end_lon": wert_bei(lon_verlauf, f_ende),
|
||||||
"route": route_aus_verlauf(lat_verlauf, lon_verlauf, f_start, f_ende),
|
"route": route_aus_verlauf(lat_verlauf, lon_verlauf, f_start, f_ende),
|
||||||
|
"verbrauch_l_100km": verbrauch_aus_literstaenden(
|
||||||
|
wert_bei(tank_liter_verlauf, f_start), wert_bei(tank_liter_verlauf, f_ende), distanz
|
||||||
|
),
|
||||||
"status": "vollständig" if distanz is not None else "offen",
|
"status": "vollständig" if distanz is not None else "offen",
|
||||||
})
|
})
|
||||||
neue.append(fahrt)
|
neue.append(fahrt)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "Audi Dashboard",
|
"name": "Audi Dashboard",
|
||||||
"version": "2026.8.27.5",
|
"version": "2026.8.27.8",
|
||||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": ["@paul"],
|
"codeowners": ["@paul"],
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .verlauf import naechster_wert, route_aus_verlauf, verlauf_lesen
|
from .verlauf import (
|
||||||
|
UNPLAUSIBLE_KMH,
|
||||||
|
durchschnitt_kmh,
|
||||||
|
naechster_wert,
|
||||||
|
route_aus_verlauf,
|
||||||
|
verbrauch_aus_literstaenden,
|
||||||
|
verlauf_lesen,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .koordinator import Koordinator
|
from .koordinator import Koordinator
|
||||||
@@ -47,9 +54,30 @@ def _als_zeit(wert: str | None) -> datetime.datetime | None:
|
|||||||
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
|
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
|
||||||
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
|
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
|
||||||
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
|
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
|
||||||
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch"."""
|
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch".
|
||||||
|
|
||||||
|
Verwirft eine physikalisch unplausible Durchschnittsgeschwindigkeit
|
||||||
|
(UNPLAUSIBLE_KMH) statt sie anzuzeigen, statt der bislang unbehandelten
|
||||||
|
Lücke gegenüber historienimport.py: eine falsch abgegrenzte Fahrt (z. B.
|
||||||
|
durch ein chatterndes Zündungssignal) kann sonst einen Kilometerstand-
|
||||||
|
Sprung wie "22 km in 67 s" als echte Strecke zeigen. Sowohl odo_start als
|
||||||
|
auch odo_end werden dann verworfen (nicht nur die Strecke) - die Fahrt
|
||||||
|
bleibt "offen" und wird beim nächsten Screening erneut versucht, statt
|
||||||
|
einen unmöglichen Wert dauerhaft zu speichern."""
|
||||||
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
|
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
|
||||||
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
|
distanz = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
|
||||||
|
geschwindigkeit = durchschnitt_kmh(distanz, fahrt.get("duration_s"))
|
||||||
|
if geschwindigkeit is not None and geschwindigkeit > UNPLAUSIBLE_KMH:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Fahrt %s: Kilometerstand verworfen - unmögliche %s km/h im "
|
||||||
|
"Schnitt (%s km in %s s), vermutlich ein Ausreißer im "
|
||||||
|
"Verlauf oder eine falsch abgegrenzte Fahrt.",
|
||||||
|
fahrt.get("trip_id"), geschwindigkeit, distanz, fahrt.get("duration_s"),
|
||||||
|
)
|
||||||
|
aenderungen["odo_start"] = None
|
||||||
|
aenderungen["odo_end"] = None
|
||||||
|
return aenderungen
|
||||||
|
aenderungen["distance_km"] = distanz
|
||||||
aenderungen["km_quelle"] = "odometer"
|
aenderungen["km_quelle"] = "odometer"
|
||||||
aenderungen["status"] = "vollständig"
|
aenderungen["status"] = "vollständig"
|
||||||
return aenderungen
|
return aenderungen
|
||||||
@@ -81,6 +109,15 @@ async def durchfuehren(k: Koordinator) -> None:
|
|||||||
for fahrt in ohne_position:
|
for fahrt in ohne_position:
|
||||||
await _position_screenen(k, lat_sensor, lon_sensor, fahrt)
|
await _position_screenen(k, lat_sensor, lon_sensor, fahrt)
|
||||||
|
|
||||||
|
tank_liter_sensor = k.zuordnung.werte.TANK_LITER_SENSOR
|
||||||
|
if tank_liter_sensor:
|
||||||
|
ohne_verbrauch = [
|
||||||
|
f for f in fahrten
|
||||||
|
if f.get("verbrauch_l_100km") is None and f.get("distance_km")
|
||||||
|
]
|
||||||
|
for fahrt in ohne_verbrauch:
|
||||||
|
await _verbrauch_screenen(k, tank_liter_sensor, fahrt)
|
||||||
|
|
||||||
await _lueckenlos_verketten(k)
|
await _lueckenlos_verketten(k)
|
||||||
await k.fahrten_veroeffentlichen()
|
await k.fahrten_veroeffentlichen()
|
||||||
|
|
||||||
@@ -115,7 +152,15 @@ async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
|
|||||||
|
|
||||||
if geaendert:
|
if geaendert:
|
||||||
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
||||||
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
|
aenderungen = _vollstaendig(aenderungen, fahrt)
|
||||||
|
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
|
||||||
|
# Das In-Memory-Objekt sofort mitziehen (nicht nur die Ablage) - sonst
|
||||||
|
# sehen spätere Schritte innerhalb desselben durchfuehren()-Laufs
|
||||||
|
# (z. B. _verbrauch_screenen(), das eine bekannte distance_km
|
||||||
|
# braucht) noch den alten Stand, obwohl die Strecke gerade erst in
|
||||||
|
# diesem Durchlauf ermittelt wurde. _position_screenen() macht das
|
||||||
|
# bereits genauso.
|
||||||
|
fahrt.update(aenderungen)
|
||||||
|
|
||||||
|
|
||||||
async def _position_screenen(
|
async def _position_screenen(
|
||||||
@@ -161,6 +206,29 @@ async def _position_screenen(
|
|||||||
fahrt.update(aenderungen)
|
fahrt.update(aenderungen)
|
||||||
|
|
||||||
|
|
||||||
|
async def _verbrauch_screenen(
|
||||||
|
k: Koordinator, tank_liter_sensor: str, fahrt: dict
|
||||||
|
) -> None:
|
||||||
|
"""Trägt den Verbrauch (l/100 km) nach, sobald die Distanz feststeht und
|
||||||
|
der Tankfüllstand (Liter) das hergibt - siehe verbrauch_aus_literstaenden()
|
||||||
|
für die Näherung selbst und ihre Begrenzung."""
|
||||||
|
start = _als_zeit(fahrt.get("ts_start"))
|
||||||
|
ende = _als_zeit(fahrt.get("ts_end"))
|
||||||
|
if start is None or ende is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
verlauf = await verlauf_lesen(k.hass, tank_liter_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
|
||||||
|
if not verlauf:
|
||||||
|
return
|
||||||
|
|
||||||
|
verbrauch = verbrauch_aus_literstaenden(
|
||||||
|
naechster_wert(start, verlauf), naechster_wert(ende, verlauf), fahrt.get("distance_km")
|
||||||
|
)
|
||||||
|
if verbrauch is not None:
|
||||||
|
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"verbrauch_l_100km": verbrauch})
|
||||||
|
fahrt["verbrauch_l_100km"] = verbrauch
|
||||||
|
|
||||||
|
|
||||||
async def _lueckenlos_verketten(k: Koordinator) -> None:
|
async def _lueckenlos_verketten(k: Koordinator) -> None:
|
||||||
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
||||||
wurde.
|
wurde.
|
||||||
|
|||||||
@@ -85,6 +85,25 @@ def zahl(wert: object) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 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()/naechster_wert()-Treffer über eine Lücke hinweg). Gemeinsam für
|
||||||
|
# den Import (historienimport.py) und die Live-Vervollständigung
|
||||||
|
# (screening.py) - beide dürfen dieselbe Strecke nie unterschiedlich
|
||||||
|
# bewerten.
|
||||||
|
UNPLAUSIBLE_KMH = 300
|
||||||
|
|
||||||
|
|
||||||
|
def durchschnitt_kmh(distanz_km: float | None, dauer_s: float | None) -> float | None:
|
||||||
|
"""km/h aus Strecke und Dauer, oder None wenn eine der beiden fehlt oder
|
||||||
|
die Dauer nicht positiv ist."""
|
||||||
|
if distanz_km is None or not dauer_s or dauer_s <= 0:
|
||||||
|
return None
|
||||||
|
return round(distanz_km / (dauer_s / 3600.0), 1)
|
||||||
|
|
||||||
|
|
||||||
def wert_bei(
|
def wert_bei(
|
||||||
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
|
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
@@ -134,6 +153,26 @@ def route_aus_verlauf(
|
|||||||
return punkte if len(punkte) >= 2 else None
|
return punkte if len(punkte) >= 2 else None
|
||||||
|
|
||||||
|
|
||||||
|
def verbrauch_aus_literstaenden(
|
||||||
|
start_liter: float | None, end_liter: float | None, distance_km: float | None
|
||||||
|
) -> float | None:
|
||||||
|
"""l/100km aus der Differenz zweier Literstände (TANK_LITER_SENSOR) und
|
||||||
|
der gefahrenen Strecke - eine Näherung, kein vom Fahrzeug selbst für
|
||||||
|
diese eine Fahrt gemeldeter Wert (ein solcher OBD-Sensor steht als
|
||||||
|
offener Punkt in AGENTS.md; taucht einer auf, ersetzt er diese Näherung).
|
||||||
|
|
||||||
|
None, wenn einer der Werte fehlt, die Strecke nicht positiv ist, oder der
|
||||||
|
Literstand nicht gefallen ist (z. B. ein Tankvorgang während der Fahrt,
|
||||||
|
oder unveränderter Füllstand) - in beiden Fällen ist der Quotient nicht
|
||||||
|
aussagekräftig."""
|
||||||
|
if start_liter is None or end_liter is None or not distance_km or distance_km <= 0:
|
||||||
|
return None
|
||||||
|
verbraucht = start_liter - end_liter
|
||||||
|
if verbraucht <= 0:
|
||||||
|
return None
|
||||||
|
return round(verbraucht / distance_km * 100, 1)
|
||||||
|
|
||||||
|
|
||||||
def naechster_wert(
|
def naechster_wert(
|
||||||
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
|
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
|
|||||||
@@ -149,11 +149,25 @@ def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
|
|||||||
|
|
||||||
standort = _standort(hass, werte)
|
standort = _standort(hass, werte)
|
||||||
|
|
||||||
|
# Verzögerter Import (statt oben am Dateikopf): batterie.py importiert
|
||||||
|
# seinerseits zustand_oder_none aus diesem Modul - ein Import auf
|
||||||
|
# Modulebene würde bei bestimmter Ladereihenfolge zu einem
|
||||||
|
# Circular-Import-Fehler führen. Zum Zeitpunkt dieses Funktionsaufrufs
|
||||||
|
# sind beide Module immer schon vollständig geladen.
|
||||||
|
from .batterie import SPANNUNG_MIN_V
|
||||||
|
|
||||||
|
spannung = _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR))
|
||||||
|
if spannung is not None and spannung < SPANNUNG_MIN_V:
|
||||||
|
# Dieselbe Plausibilitätsgrenze wie beim Aufzeichnen (batterie.py) -
|
||||||
|
# sonst zeigt die "Zustand"-Kachel einen Wert, der im Messwertverlauf
|
||||||
|
# nie auftaucht, weil er dort schon verworfen wird.
|
||||||
|
spannung = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
|
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
|
||||||
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
|
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
|
||||||
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
|
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
|
||||||
"batteriespannung": _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR)),
|
"batteriespannung": spannung,
|
||||||
"gesichert": gesichert,
|
"gesichert": gesichert,
|
||||||
"sicherheitscheck": sicherheitscheck,
|
"sicherheitscheck": sicherheitscheck,
|
||||||
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
|
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
|
||||||
|
|||||||
Reference in New Issue
Block a user