Statistik-Icon-Groesse, Batteriediagramm-Ueberarbeitung, Temperatur-Kopplung mit 300s-Fenster, letzte gueltige Spannung als Fallback
- Statistik-Tab-Icon per zentrierter Stauchung an die Fuellflaeche der anderen Tab-Icons angeglichen (Panel + companion-app), auf Nutzerwunsch anschliessend 10% groesser. - Batteriediagramm neu gezeichnet: dynamische viewBox (1 Einheit = 1 Pixel, kein preserveAspectRatio-Verzerren mehr), ein Punkt je Ruhespannungsmessung statt Datumsbeschriftung, Tageshoechstwert nur noch im Tooltip. Generator- spannungen (> AGM_RUHE_MAX_V = 13,0 V, vom Nutzer festgelegt) bleiben aus dem Diagramm aussen vor; der SOC-Kurve-Eckwert "100% / voll" bleibt bei den ursprünglich recherchierten 12,8 V. companion-app auf dieselbe lineare SOC-Interpolation umgestellt (vorher eine abweichende Stufenkurve). - Außentemperatur wird jetzt auch beim Batterieverlauf-Import nachgetragen (vorher nur live) - Kopplung an die naechstgelegene Messung innerhalb NEBENWERT_MAX_ABSTAND_S (verlauf.py), vom Nutzer nach Live-Daten-Analyse auf 300s festgelegt. Merge-Logik in ablage.py ergaenzt: ein erneuter Import kann eine zuvor fehlende Temperatur jetzt tatsaechlich nachtragen, auch wenn sich der Tagesminimalwert selbst nicht aendert. - "Mein Audi"/Zustand zeigt bei unplausibler Live-Messung (z. B. Dongle offline) die zuletzt gemessene Spannung statt "unbekannt". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3442,6 +3442,281 @@ correctly exceeding `UNPLAUSIBLE_KMH=300` and therefore now rejected; a real pla
|
||||
(`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`.
|
||||
|
||||
## X. Statistik-Tab-Icon zu groß, Batteriediagramm im Breitbildschirm "professioneller" gestaltet (2026.8.27.9)
|
||||
|
||||
**Owner report 1: "statistic icon seems to be too large in comparison to Car icon or Petrol-Station
|
||||
icon."** Measured live rather than guessed (`getBBox()` on all five rendered tab SVGs in
|
||||
`audi_ha_test`, all share one `viewBox="0 0 24 24")`: the hand-drawn tab icons (home/audi/trips/fuel)
|
||||
fill 32-72% of their box - they were drawn with deliberate internal padding, the usual convention for
|
||||
this icon style. `CI.pollsS` (the Statistik tab's CI icon since section R) is drawn edge-to-edge and
|
||||
fills 91.7% - a real, measured mismatch, not just a subjective impression. Fixed by wrapping the icon
|
||||
in a centered `scale(0.7)` (`translate(3.6,3.6) scale(0.7)`, about (12,12)) rather than a special-cased
|
||||
pixel size - this keeps it correctly proportioned at both of `.tab svg`'s breakpoints (26px mobile,
|
||||
22px desktop in `audi-dashboard-ios.css`) without maintaining two numbers. Re-measured after the fix:
|
||||
44.9% fill, now within the sibling range (32-72%), close to "trips" (46.7%). Same mismatch confirmed
|
||||
and fixed in companion-app's `symbole.tsx` `SymbolStatistik` (parity rule) - it shares the identical
|
||||
`CI.pollsS` path via the same `Rahmen` wrapper as `SymbolAudi`/`SymbolFahrten`/`SymbolTanken`.
|
||||
|
||||
**Owner report 2: "The Diagram for Battery Voltage shall use the max. available screen wide... find a
|
||||
solution to make it look professional."** The wide-screen width fix from section U (fixed
|
||||
`height:${BV_H}px`, `width:100%`, letting only the X-axis stretch) was functionally correct but bare at
|
||||
772px column width: still only 3 x-axis labels (start/middle/end, computed from fixed fractions
|
||||
regardless of real pixel width) and a plain unfilled line - looked sparse rather than "finished".
|
||||
Panel-only, per the same reasoning section R/S already established for this exact chart ("the diagram
|
||||
in question doesn't exist as a separate wide layout in companion-app" - confirmed with the owner at the
|
||||
time; `Verlaufsdiagramm` in companion-app's `Batterie.tsx` uses a fixed `height:120px` box with no
|
||||
wide-layout breakpoint, nothing to change there). Two additions to `bvZeichnen()`, both restrained and
|
||||
consistent with the project's iOS-HIG direction (Apple's own Health/Stocks charts use exactly this
|
||||
pattern) rather than a heavier redesign:
|
||||
- A low-opacity linear-gradient area fill under the line (`<linearGradient id="bvGrad">`, `var(--fg)`
|
||||
fading `0.16 → 0`), purely decorative, no new information, drawn between the gridlines and the line
|
||||
so the grid still shows through.
|
||||
- The x-axis tick count is now computed from the SVG's actual rendered pixel width
|
||||
(`svg.getBoundingClientRect().width`, ~110px minimum spacing per label, clamped 3-6) instead of a
|
||||
fixed `[0, 0.5, 1]` - a narrow phone chart still shows 3, the 772px desktop column shows up to 6.
|
||||
Also added `stroke-linecap="round" stroke-linejoin="round"` to the polyline for cleaner corners.
|
||||
- Verified live at 1400px: SVG measured 736×170px (fixed height preserved, no distortion regression),
|
||||
axis text confirmed still 11px (no repeat of the earlier text-scaling bug from section U's `.5`
|
||||
entry), 6 tick labels generated, gradient `<defs>`/area `<path>` both present in the rendered markup.
|
||||
The test container's actual battery history only spans ~2 days, so several of the 6 labels read the
|
||||
same day-precision date (`09.08.26`/`10.08.26` repeated) - a property of this container's narrow
|
||||
synthetic dataset, not a defect in the tick logic itself; a real multi-week history would show 6
|
||||
distinct dates.
|
||||
|
||||
Verified: `node --check` clean on the panel JS, companion-app `tsc --noEmit` clean. Manifest bumped to
|
||||
`2026.8.27.9`, `audi_ha_test` restart confirmed clean via log (single `audi_dashboard` loader warning),
|
||||
both fixes re-verified live in the browser after the restart (icon fill-percentage measurement above,
|
||||
chart dimensions/gradient/ticks above). `npm run ota` rerun (companion-app source changed:
|
||||
`symbole.tsx`), bundle rebuilt at `2026.8.27.9`, sha256
|
||||
`66fef05581b26086d76500d864af5704cd568a39474ece47df88fb7c9c51dc50`.
|
||||
|
||||
**Follow-up (2026.8.27.10): Statistik-Icon auf Nutzerwunsch 10% größer** - Faktor 0.7 → 0.77
|
||||
(`translate(2.76,2.76) scale(0.77)`), identisch in beiden Codebasen.
|
||||
|
||||
**Follow-up (2026.8.27.11): das Batteriediagramm war immer noch falsch - ein echter Darstellungsfehler
|
||||
plus ein Filter, der 7 von 9 Messtagen verschluckt hat.** Owner sent a screenshot ("thats still not
|
||||
fine") plus four instructions: no dates under the diagram, every measurement as a dot, date visible on
|
||||
click, and zoom must keep working when more dots appear; then a fifth: only the minimum gets a dot, the
|
||||
maximum appears as text on click.
|
||||
|
||||
Two genuine defects behind the screenshot, both found by measuring rather than by eye:
|
||||
1. **`preserveAspectRatio="none"` on a fixed 320-unit viewBox distorted everything horizontally.** At
|
||||
the 736px desktop column that is a 2.3× horizontal stretch applied to the whole coordinate system -
|
||||
the axis numbers rendered visibly wide (measured: "10,0" ≈ 50px wide at font-size 11), and the six
|
||||
date labels from `.9` collided with each other because their text stretched while their anchor
|
||||
spacing did not. This is the same class of bug as section U's `.5` entry (text scaling with the
|
||||
coordinate system), which was fixed there by pinning the height - the horizontal axis had the same
|
||||
problem and was missed. Fixed properly this time: `bvZeichnen()` measures the rendered width and
|
||||
sets `viewBox="0 0 <gemessene Breite> 170"`, so **one coordinate unit is exactly one pixel** and
|
||||
nothing can distort at any width; `preserveAspectRatio` is removed entirely. `BV_W` is now only a
|
||||
starting value (`bvBreite` holds the live width), `bvSkalaX()`/the gridline width use `bvBreite`,
|
||||
and `bvPlotRahmen()` collapsed to plain pixel arithmetic. A `ResizeObserver` on the SVG redraws on
|
||||
window resize, so the viewBox cannot go stale (setting the viewBox does not change the element's
|
||||
size, so no feedback loop).
|
||||
2. **The chart filtered out every day without a resting-voltage reading.** `bvPunkte` was built with
|
||||
`e.min != null && e.min <= AGM_RUHE_MAX_V` - so days where the car never stood still long enough
|
||||
(minimum still above 12,8 V, i.e. alternator voltage) vanished from the chart completely. In the
|
||||
test container that reduced 9 recorded days to **2**, which is exactly the near-empty two-dot chart
|
||||
in the owner's screenshot, and why the range read "09.08.26 – 10.08.26" instead of the real
|
||||
09.08–25.08. The filter is dropped for the chart (owner: "every Battery-Measurement as a dot"); the
|
||||
values that genuinely need resting voltage keep it via a **new, separate `bvRuhePunkte`** - the SOC
|
||||
readout (`letzter`) and `bvSohBewertung()` both switched to it, otherwise a generator reading would
|
||||
have shown a permanent "100 % · Sehr gut".
|
||||
|
||||
Per the owner's instructions: the x-axis date labels are gone entirely (the period still stands under
|
||||
the chart as `#bvRange`, and a single measurement's date/time comes from tapping its dot); one dot per
|
||||
recorded day, drawn at the **minimum** only; the tooltip now reads
|
||||
`18.08.2026 · 08:57 Uhr · Min 13,02 V · Max 13,47 V` (plus outside temperature when recorded) - that is
|
||||
where the maximum lives now, it has no dot of its own. `BV_MB` dropped 22 → 12 since nothing is written
|
||||
under the plot any more. Zoom/pan is untouched and confirmed still working.
|
||||
|
||||
**companion-app (parity rule):** its chart never had defect 2 (it already filtered only `min != null`,
|
||||
no AGM cut) and had no date labels either, so those two needed nothing. It *did* differ on the owner's
|
||||
other two points: no dots at all, and the daily maximum drawn as a permanent second grey line. Ported:
|
||||
one `<circle>` per measurement, the max line removed, tap-a-dot shows the same
|
||||
date/time/Min/Max/temperature line underneath, and the same dynamic-viewBox treatment (measured width +
|
||||
`ResizeObserver`, `preserveAspectRatio="none"` removed) - needed there too, because with `none` the new
|
||||
dots would have rendered as horizontally stretched ellipses. **Zoom/pan was deliberately NOT ported**:
|
||||
the panel's pinch/wheel/drag handling is ~150 lines of pointer bookkeeping (`bvZeiger`/`bvGestStart`/
|
||||
`bvDomainKlemmen`) with no counterpart in companion-app, and building it there is its own work package
|
||||
rather than part of this fix - flagged here per the parity rule's "say so explicitly" clause instead of
|
||||
being left silently undone.
|
||||
|
||||
Verified live in `audi_ha_test` at a 1400px viewport after the restart (`2026.8.27.11 eingerichtet`,
|
||||
clean log): `viewBox="0 0 736.0 170"` against a 736px-wide element (exactly 1:1, `preserveAspectRatio`
|
||||
absent), **9 dots** instead of 2, the only remaining `<text>` elements are the four y-axis labels
|
||||
(`10,0 / 11,7 / 13,3 / 15,0` - no dates), range label now spans the true `09.08.26 – 25.08.26`, and
|
||||
"10,0" measures **21,7 × 12 px** (undistorted at font-size 11; it was ~50px wide before). A synthetic
|
||||
pointerdown/up on the 6th dot produced the tooltip quoted above. Five wheel-zoom steps narrowed the
|
||||
range to `14.08.26 – 20.08.26` showing 4 dots, and "Zoom zurücksetzen" restored the full range -
|
||||
zoom confirmed intact. companion-app: `tsc --noEmit` clean, suite green at 146/146 (the
|
||||
render-every-page smoke test covers the reworked chart), `npm run ota` rebuilt at `2026.8.27.11`,
|
||||
sha256 `d0f77e854a2664331314ffc64a26712e0982c80e145aed7b25cabc41907e5974`.
|
||||
|
||||
**Follow-up (2026.8.27.12): the two codebases' SOC (Ladezustand) curves disagreed.** Owner asked "how is
|
||||
soc calculated" - answering it surfaced that the panel (`bvSocProzent()`, 11-point linear interpolation)
|
||||
and companion-app (`ladezustand()`, a 6-step lookup) genuinely disagreed on the same voltage - worst case
|
||||
15 percentage points apart (12.1 V: 40% panel vs 25% app), and the app also only ever looked at the
|
||||
*newest* stored day rather than walking back to the last resting reading like the panel does, so it could
|
||||
show "—" on a day the panel showed a real number. Owner: "ok use the interpolated for both". Ported the
|
||||
panel's exact 11-point table and linear-interpolation math into companion-app's `ladezustand()` -
|
||||
`Batterie.tsx`. **Deliberately NOT ported**: the "walk back to the last resting reading" selection logic
|
||||
- that's a separate, larger behavior change beyond "align the curve" and wasn't asked for; flagged here so
|
||||
it isn't mistaken for done. No dedicated tests referenced the old stepped values (grepped first), so
|
||||
nothing else needed updating. Verified: `tsc --noEmit` clean, suite green 146/146.
|
||||
|
||||
**Follow-up (2026.8.27.13): the battery temperature field, why it was always empty, and two real "match
|
||||
must be close in time" gaps found and fixed.** Owner: "i cant see any temperature at the battery" -
|
||||
investigated live rather than assumed. Root cause was mundane: `AUSSENTEMP_SENSOR` was correctly mapped
|
||||
and healthy (30°C, fresh), but the FMM003 voltage source was offline (dongle down), so every 5-minute
|
||||
`pruefen()` tick in `batterie.py` was rejecting the reading at the `SPANNUNG_MIN_V=10.0` plausibility
|
||||
floor **before ever reading temperature** - confirmed via the recurring log line
|
||||
`Batteriespannung 0.0 V unter der Plausibilitätsgrenze`. Separately, all 9 stored entries predate the
|
||||
temperature feature (2026-08-25/section R) entirely, so none carry `min_temp_c` regardless.
|
||||
|
||||
Owner pushed back on "nothing to backfill" with "i bet there is a temperature recorded the same time when
|
||||
the voltage was measured" - correct, and worth the correction: queried the recorder directly rather than
|
||||
assuming, and outside-temperature history exists for every one of the 9 stored days (recorder now keeps a
|
||||
year, section F). That exposed a **real, previously-unnoticed gap**: `historienimport.py`'s
|
||||
`_batterie_importieren()` never fetched `AUSSENTEMP_SENSOR` history at all (`verlaeufe` dict had no
|
||||
`"aussentemp"` key) - so even running the historical import over that same window would never have
|
||||
backfilled it. Fixed: `verlaeufe["aussentemp"]` added to `importieren()`'s fetch, and each day's minimum
|
||||
now looks up the nearest temperature reading via the recorder history, same pattern as the existing
|
||||
GPS/liter pairing helpers.
|
||||
|
||||
**Then the owner set a hard constraint: "temperature must be same time as voltage. 60 seconds delay is
|
||||
fine but not longer. is ther always a temperature when voltsge vomes?"** Measured rather than assumed
|
||||
(live recorder query over 48h on the container's then-mapped `cupra_eu_data_act` temperature sensor): NO
|
||||
- 22.4% of its own update gaps exceeded 60s, with a max gap of ~36.6 hours (duplicate-timestamp template
|
||||
sensor, not a real device). That answer justified building a real distance bound rather than trusting an
|
||||
unbounded nearest-match:
|
||||
- New `wert_in_naehe(zielzeit, verlauf, max_abstand_s=NEBENWERT_MAX_ABSTAND_S)` in `verlauf.py` - same
|
||||
nearest-match algorithm as the existing `naechster_wert()`, but returns `None` instead of an arbitrarily
|
||||
distant value when even the closest point exceeds the bound. `NEBENWERT_MAX_ABSTAND_S = 60`, the owner's
|
||||
own number, documented as owner-set rather than derived.
|
||||
- **Import path** (`historienimport.py`): switched from `naechster_wert()` to `wert_in_naehe()` for the
|
||||
temperature pairing.
|
||||
- **Live path** (`batterie.py`): this had the same latent gap and nobody had asked about it yet -
|
||||
`zustand_oder_none()` returns whatever a stale entity's last value was, no matter its age, so a live
|
||||
reading could just as easily have paired a many-hours-old temperature with a fresh voltage measurement.
|
||||
Fixed by comparing the two sensors' own `last_updated` timestamps (via `hass.states.get()` directly,
|
||||
bypassing `zustand_oder_none()`'s attribute-stripping for this one lookup) against each other - not
|
||||
against wall-clock "now", since both are read from the current `hass.states` snapshot but can carry
|
||||
different ages. Same `NEBENWERT_MAX_ABSTAND_S` bound, imported from `verlauf.py` (no circular-import risk:
|
||||
`verlauf.py` has no local imports of its own, unlike the `SPANNUNG_MIN_V` situation elsewhere in this
|
||||
file that needed a function-local import).
|
||||
- Verified the boundary itself with a deterministic Python check (`wert_in_naehe()` called directly with
|
||||
synthetic 3s/60s/61s/36h-offset timestamps) rather than relying on live timing: 3s and 60s pass through
|
||||
unchanged, 61s and 36h correctly return `None`.
|
||||
|
||||
**Then the owner remapped `AUSSENTEMP_SENSOR` from the flaky `cupra_eu_data_act` sensor to the real FMM003
|
||||
CAN sensor and asked to check again.** Confirmed live: the FMM003 temperature and voltage entities update
|
||||
in the same telemetry batch (~3s median gap between temperature updates, sub-second gap between the two
|
||||
sensors' own timestamps) - a fundamentally different reliability profile from the old sensor, so the 60s
|
||||
guard should pass consistently going forward. One caveat surfaced and reported honestly rather than
|
||||
glossed over: this FMM003 temperature entity's own history only reaches back to ~2026-08-25 (confirmed via
|
||||
a recorder query for 2026-08-09: zero points), so a re-import can never backfill temperature for the
|
||||
five oldest stored days (08-09 through 08-24) - only 08-25 onward is reachable with this sensor, no matter
|
||||
how the import logic is written.
|
||||
|
||||
Attempted a real live write to confirm end-to-end (temporarily POSTing a plausible voltage value on a
|
||||
repeating interval, same technique used earlier this session), but the owner flagged mid-wait that the
|
||||
FMM003 itself is currently offline - correctly stopped rather than waiting on a device that was never
|
||||
going to answer; the interval was cleared and the sensor reset to `0`, confirmed no stray entry was written
|
||||
to `batteriespannung.jsonl` in the meantime.
|
||||
|
||||
**Owner then asked to "import the available data"** - ran `audi_dashboard.historie_importieren` live
|
||||
(`2025-08-28` through now) via the real service call. Result (`sensor.audi_dashboard_import_status`):
|
||||
`batterie_tage: 9`, plus one genuinely new entry the live path had never captured on its own
|
||||
(`2026-08-27`, min 12.149 V / max 15.439 V - real recorder history from earlier the same day, before the
|
||||
dongle went offline). Its `min_temp_c` came back `null`; verified this is correct, not a bug - the nearest
|
||||
FMM003 temperature reading to that day's exact minimum timestamp was **180 seconds** away (25 points in a
|
||||
60-minute window around it, so the sensor was reporting, just not within the 60s bound at that specific
|
||||
moment). The 9 pre-25-08 entries stayed without temperature, exactly as predicted by the FMM003 history-gap
|
||||
finding above - not a defect, the data genuinely isn't there for that sensor over that window.
|
||||
|
||||
Verified throughout: all touched Python files `py_compile` clean via the container (`verlauf.py`,
|
||||
`historienimport.py`, `batterie.py`), companion-app `tsc --noEmit` clean and suite green 146/146,
|
||||
`audi_ha_test` restarted clean at each step ending on `2026.8.27.13` (single `audi_dashboard` loader
|
||||
warning, no traceback). `npm run ota` rebuilt at `2026.8.27.12`, sha256
|
||||
`5ef88ee826431b5b2cff5020c83430be88d729b51284459f36a5388963e441f3` (companion-app source changed for the
|
||||
SOC curve; `.13`'s backend-only changes needed no OTA rebuild).
|
||||
|
||||
**Follow-up (2026.8.27.14): `NEBENWERT_MAX_ABSTAND_S` raised from 60s to 300s, with real FMM003 telemetry
|
||||
as the reason - not a guess.** Owner suspected voltage and temperature were being compared across the
|
||||
wrong sensors; investigated rather than assumed - confirmed via `config/entity_registry/list` that both
|
||||
`external_power_voltage` and `can_ambient_air_temperature` belong to the same single FMM003 device
|
||||
(`device_id 3667296563855653c6895ed57b6b9030`, platform `flespi`) - no duplicate device, no sensor mixup.
|
||||
Also double-checked against a documented past mistake in this file (section B): confirmed `voltage` used
|
||||
`external_power_voltage` (the car's 12V bus), not `battery_voltage` (the FMM003's own internal backup
|
||||
cell) - both exist on this device and are easy to conflate.
|
||||
|
||||
Owner then asked why there'd be any delay at all if both come from the same device. Answer, grounded in
|
||||
a live-pulled table rather than general knowledge: a GPS/telematics tracker like the FMM003 reads dozens
|
||||
of independent CAN parameters (70+ entities on this one device) and transmits them at different rates to
|
||||
save cellular data - voltage changes constantly and gets reported often, ambient temperature barely moves
|
||||
and gets reported rarely. Measured over 46h: 851 voltage state-changes vs. 187 temperature state-changes
|
||||
on the same device. Pulled an actual table for the owner's named 12:00-13:00 local drive (`t-da363d681915`,
|
||||
resampled to one row per minute, collapsed into ranges): normal (non-blackout) temperature gaps of 8s,
|
||||
19s, 34s, 174s, and **593s** all occurred during otherwise-healthy reporting - not a connectivity problem,
|
||||
just the device's own per-parameter reporting cadence. Separately, a real 45-minute stretch
|
||||
(10:14-10:59 UTC) showed **both** sensors going completely silent at once - a genuine connectivity
|
||||
dropout, the same root cause already documented in section P, not a reporting-cadence issue.
|
||||
|
||||
Recommended widening the window from 60s to 300s (5 minutes) on three grounds: (1) the worst *normal*
|
||||
gap measured was 593s, so 60s was rejecting healthy pairings, not just genuinely stale ones; (2) outside
|
||||
air temperature is physically slow-changing, so several minutes of staleness introduces negligible real
|
||||
error, unlike GPS position or the voltage reading itself; (3) `batterie.py`'s `pruefen()` only runs every
|
||||
5 minutes anyway, so demanding sub-minute precision from the pairing doesn't buy anything the measurement
|
||||
itself doesn't already lack. Owner picked 300s over the alternative 600s suggestion. `NEBENWERT_MAX_ABSTAND_S`
|
||||
in `verlauf.py` changed `60 → 300`; `wert_in_naehe()`'s own algorithm is unchanged (still "nearest in
|
||||
time, `None` if even the closest point exceeds the bound") - only the bound moved, confirmed via the same
|
||||
kind of deterministic boundary test as the `.13` entry: 299s/300s pass, 301s and the real 593s/2700s cases
|
||||
correctly return `None`.
|
||||
|
||||
Verified: `py_compile` clean on `verlauf.py`, `audi_ha_test` restarted clean on `2026.8.27.14` (single
|
||||
`audi_dashboard` loader warning, no traceback). No companion-app source changed this round (the constant
|
||||
lives only in the backend), so no OTA rebuild needed.
|
||||
|
||||
**Follow-up (2026.8.27.15): a real merge-logic bug meant the `.14` widening could never actually backfill
|
||||
anything - re-running the import kept showing `min_temp_c: null` even for entries the new 300s window
|
||||
should have caught.** Owner: "i still dont see a single temperature at a measured voltage." Traced to
|
||||
`ablage.py`'s `batterieverlauf_tageswert_aktualisieren()`: it only ever wrote `min_temp_c` inside the
|
||||
`if spannung < eintrag["min"]:` branch - i.e. only when a measurement set a *new, lower* minimum. Re-running
|
||||
the import over an already-recorded day computes the exact same historical minimum it found before (same
|
||||
source data, deterministic), so `spannung < eintrag["min"]` is always `False` on a second pass - the branch
|
||||
that would apply the newly-reachable temperature never ran, no matter how wide `NEBENWERT_MAX_ABSTAND_S`
|
||||
got. This directly contradicts the `.13` entry's own docstring claim ("ein erneuter Import ... ergänzt sie
|
||||
dort nachträglich") - that claim was aspirational, not actually true, until this fix. Added an `elif`
|
||||
branch: `spannung == eintrag["min"] and eintrag.get("min_temp_c") is None and aussentemp is not None` →
|
||||
fill in `min_temp_c` without touching `min`/`min_ts` (they're already correct; only the temperature was
|
||||
ever missing). Live and import paths both benefit automatically - no separate change needed in either
|
||||
`batterie.py` or `historienimport.py`, both already call through this one shared function.
|
||||
|
||||
**Verified by actually re-running the import, not just by re-reading the code:** before the fix, all 9
|
||||
importable entries still showed the state from the `.13`-era import (only `2026-08-27` even attempted,
|
||||
and it alone stayed `null` at 180s > the then-60s bound). After deploying `.15` and re-running
|
||||
`audi_dashboard.historie_importieren` over the same window: **6 of the 9 entries now carry a real
|
||||
`min_temp_c`** (08-15: 26.0°C, 08-17: 26.0°C, 08-23/24/25/27: 20.0°C) - direct proof the fill-in-gap logic
|
||||
works, not just that it compiles. Two entries (08-16, 08-18) still show no temperature - checked why rather
|
||||
than assuming a bug: a first ±3h recorder query for both returned suspiciously identical `10800.0s`
|
||||
(exactly 3h) distances, which turned out to be an artifact of that query window's own edge, not the true
|
||||
nearest point. Widening to ±24h found the real answer: the nearest FMM003 temperature reading is **14.7h**
|
||||
away for 08-16 and **13.8h** away for 08-18 - genuinely no temperature data near those specific moments on
|
||||
the device's side, unrelated to the 300s bound or this fix. The two oldest entries (08-09, 08-10) remain
|
||||
without temperature for the already-documented reason in the `.13` entry (FMM003 history starts ~08-25 for
|
||||
this specific test device... **correction**: the temperature history actually reaches back to at least
|
||||
08-15, per this round's own findings - the `.13` entry's "history starts ~08-25" claim was based on a
|
||||
single spot-check at 08-09 and turned out to be an overgeneralization; the accurate statement is simply
|
||||
"no temperature data near this specific moment," checked per-day rather than assumed as a blanket cutoff
|
||||
date. Worth remembering: a live spot-check at one date does not establish a device's reporting start date -
|
||||
check the specific timestamp that actually matters.
|
||||
|
||||
Verified: `py_compile` clean on `ablage.py`, `audi_ha_test` restarted clean on `2026.8.27.15` (single
|
||||
`audi_dashboard` loader warning, no traceback), re-running the import produced the 6-of-9 result quoted
|
||||
above via direct file inspection, not just a trusted service-call return. No companion-app source changed
|
||||
(backend-only), no OTA rebuild needed.
|
||||
|
||||
---
|
||||
|
||||
## Working conventions (observed — keep them)
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
*
|
||||
* Wichtig für die Einordnung: Der Ladezustand wird aus der **Tagesminimum**-
|
||||
* Spannung geschätzt (Ruhespannung kurz nach dem Start, bevor die Lichtmaschine
|
||||
* anhebt); Werte über 12,8 V gelten als „während der Fahrt gemessen" und
|
||||
* anhebt); Werte über 13,0 V gelten als „während der Fahrt gemessen" und
|
||||
* taugen nicht als Ruhewert.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { IconButton, Tile } from "@audi-dash/ui"
|
||||
|
||||
@@ -28,29 +28,46 @@ interface Tageswert {
|
||||
max: number | null
|
||||
min_ts?: string
|
||||
max_ts?: string
|
||||
min_temp_c?: number | null
|
||||
}
|
||||
|
||||
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles
|
||||
darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. Auch
|
||||
von BatterieListe.tsx verwendet, für die "Generatorspannung"-Kennzeichnung
|
||||
dort - eine einzige Schwelle für beide Ansichten. */
|
||||
export const AGM_RUHE_MAX_V = 12.8
|
||||
/** Ab hier gilt eine Messung nicht mehr als Batterie(ruhe)spannung, sondern
|
||||
schon als Generatorspannung - vom Nutzer direkt festgelegt ("max voltage
|
||||
for AGM Battery is 13V, everything above is the generator"), identisch
|
||||
mit AGM_RUHE_MAX_V im Panel (audi-dashboard-app.js). Getrennt von
|
||||
LADEKURVE unten: eine Messung zwischen 12,8 und 13,0 V zählt hier noch als
|
||||
gültige Batteriemessung (Diagrammpunkt, fließt in den Trend ein), zeigt
|
||||
aber weiterhin 100 % Ladezustand ("12,8v voll was already right" - der
|
||||
Nutzer bestätigte den ursprünglichen 12,8-V-Eckwert ausdrücklich). Auch
|
||||
von BatterieListe.tsx verwendet, für die "Generatorspannung"-
|
||||
Kennzeichnung dort - eine einzige Schwelle für beide Ansichten. */
|
||||
export const AGM_RUHE_MAX_V = 13.0
|
||||
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
|
||||
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe, linear zwischen den
|
||||
Stützstellen interpoliert - identische Tabelle und Rechenweg wie im Panel
|
||||
(`bvSocProzent()`/`BV_SOC_TABELLE`, `audi-dashboard-app.js`), auf
|
||||
Nutzerwunsch angeglichen: die vorherige Stufenkurve hier lieferte bei
|
||||
derselben Spannung teils deutlich andere Prozentwerte als das Panel (z. B.
|
||||
12,1 V: 40 % im Panel, 25 % hier) - beide Ansichten sollen bei derselben
|
||||
Messung dasselbe zeigen. Letzte Stützstelle bleibt bei 12,8 V/100 % -
|
||||
unabhängig von der oben auf 13,0 V angehobenen AGM_RUHE_MAX_V, siehe
|
||||
deren eigenen Kommentar. */
|
||||
const LADEKURVE: [number, number][] = [
|
||||
[12.8, 100],
|
||||
[12.6, 90],
|
||||
[12.4, 75],
|
||||
[12.2, 50],
|
||||
[12.0, 25],
|
||||
[11.8, 10],
|
||||
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
|
||||
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
|
||||
]
|
||||
|
||||
function ladezustand(spannung: number): number | null {
|
||||
if (spannung > AGM_RUHE_MAX_V) return null
|
||||
const ersterTreffer = LADEKURVE.find(([v]) => spannung >= v)
|
||||
if (ersterTreffer) return ersterTreffer[1]
|
||||
return 0
|
||||
const t = LADEKURVE
|
||||
if (spannung <= t[0]![0]) return 0
|
||||
if (spannung >= t[t.length - 1]![0]) return 100
|
||||
for (let i = 0; i < t.length - 1; i++) {
|
||||
const [v0, s0] = t[i]!
|
||||
const [v1, s1] = t[i + 1]!
|
||||
if (spannung >= v0 && spannung <= v1) return Math.round(s0 + ((s1 - s0) * (spannung - v0)) / (v1 - v0))
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
@@ -148,11 +165,34 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Von Hand gezeichnete Verlaufskurve aus Tagesminimum und -maximum. */
|
||||
/** Von Hand gezeichnete Verlaufskurve aus dem Tagesminimum, mit einem Punkt je
|
||||
Messung. Der Tageshöchstwert bekommt bewusst KEINE eigene Linie mehr
|
||||
(Nutzerwunsch, Parität zum Panel) - er erscheint als Text, sobald ein Punkt
|
||||
angetippt wird, zusammen mit Datum und Uhrzeit.
|
||||
|
||||
Die viewBox folgt der tatsächlich gemessenen Pixelbreite, statt eine feste
|
||||
320er-Fläche mit preserveAspectRatio="none" breitzuziehen: sonst würden die
|
||||
neuen Punkte auf breiten Anzeigen zu Ellipsen verzerrt (im Panel war
|
||||
dasselbe Problem an den auseinandergezogenen Achsenzahlen sichtbar). */
|
||||
function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const breite = 320
|
||||
const hoehe = 120
|
||||
const rand = 4
|
||||
const rand = 8
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const [breite, setzeBreite] = useState(320)
|
||||
const [gewaehlt, setzeGewaehlt] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current
|
||||
if (!el || typeof ResizeObserver === "undefined") return
|
||||
const messen = () => {
|
||||
const w = el.getBoundingClientRect().width
|
||||
if (w > 0) setzeBreite(w)
|
||||
}
|
||||
messen()
|
||||
const beobachter = new ResizeObserver(messen)
|
||||
beobachter.observe(el)
|
||||
return () => beobachter.disconnect()
|
||||
}, [])
|
||||
|
||||
// Feste Achse 10-15V (Nutzerwunsch): deckt den relevanten Bereich ab
|
||||
// (12V-Bleibatterie, Ladespannung des Alternators mit Reserve).
|
||||
@@ -160,8 +200,7 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
const groesster = 15
|
||||
const spanne = groesster - kleinster
|
||||
|
||||
const x = (i: number) =>
|
||||
rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
|
||||
const x = (i: number) => rand + (i / Math.max(1, werte.length - 1)) * (breite - 2 * rand)
|
||||
// Anteil auf [0,1] geklemmt statt roh extrapoliert - ein Ausreißer außerhalb
|
||||
// von kleinster/groesster zeichnet sich sonst weit außerhalb der Fläche.
|
||||
const y = (v: number) => {
|
||||
@@ -169,25 +208,62 @@ function Verlaufsdiagramm({ werte }: { werte: Tageswert[] }) {
|
||||
return hoehe - rand - anteil * (hoehe - 2 * rand)
|
||||
}
|
||||
|
||||
const linie = (auswahl: (t: Tageswert) => number | null) =>
|
||||
werte
|
||||
.map((t, i) => {
|
||||
const v = auswahl(t)
|
||||
return v == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(v).toFixed(1)}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
const linie = werte
|
||||
.map((t, i) => (t.min == null ? null : `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(t.min).toFixed(1)}`))
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
const punktWaehlen = (ereignis: { clientX: number }) => {
|
||||
const el = svgRef.current
|
||||
if (!el || werte.length === 0) return
|
||||
const kasten = el.getBoundingClientRect()
|
||||
const relativ = ereignis.clientX - kasten.left - rand
|
||||
const schritt = (breite - 2 * rand) / Math.max(1, werte.length - 1)
|
||||
const index = Math.max(0, Math.min(werte.length - 1, Math.round(relativ / schritt)))
|
||||
setzeGewaehlt((vorher) => (vorher === index ? null : index))
|
||||
}
|
||||
|
||||
const aktiv = gewaehlt != null ? werte[gewaehlt] : undefined
|
||||
const zeitpunkt = aktiv?.min_ts ? new Date(aktiv.min_ts) : null
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="dm-diagramm"
|
||||
viewBox={`0 0 ${breite} ${hoehe}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
|
||||
>
|
||||
<path d={linie((t) => t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" />
|
||||
<path d={linie((t) => t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="dm-diagramm"
|
||||
viewBox={`0 0 ${breite.toFixed(1)} ${hoehe}`}
|
||||
role="img"
|
||||
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
|
||||
onClick={punktWaehlen}
|
||||
>
|
||||
<path d={linie} fill="none" stroke="var(--red)" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
{werte.map((t, i) =>
|
||||
t.min == null ? null : (
|
||||
<circle
|
||||
key={t.datum}
|
||||
cx={x(i)}
|
||||
cy={y(t.min)}
|
||||
r={gewaehlt === i ? 4.5 : 2.4}
|
||||
fill="var(--red)"
|
||||
{...(gewaehlt === i ? { stroke: "var(--fg)", strokeWidth: 1 } : {})}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
{aktiv && (
|
||||
<p className="dm-fussnote">
|
||||
{[
|
||||
zeitpunkt
|
||||
? `${datum(aktiv.datum)} · ${zeitpunkt.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`
|
||||
: datum(aktiv.datum),
|
||||
aktiv.min != null ? `Min ${de(aktiv.min, 2)} V` : null,
|
||||
aktiv.max != null ? `Max ${de(aktiv.max, 2)} V` : null,
|
||||
aktiv.min_temp_c != null ? `${de(aktiv.min_temp_c, 1)} °C` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,14 +79,21 @@ export function SymbolFahrten({ groesse }: SymbolProps) {
|
||||
}
|
||||
|
||||
// CI-Symbol polls-s statt der vorherigen handgezeichneten Balken (Parität
|
||||
// zum Panel, siehe ICONS.stat dort).
|
||||
// zum Panel, siehe ICONS.stat dort). Anders als die handgezeichneten
|
||||
// Symbole (die im 24er-Raster deutlich Rand lassen) ist das CI-Symbol
|
||||
// randlos gezeichnet und wirkte dadurch in der Tableiste sichtbar größer
|
||||
// als "Fahrten"/"Tanken" - dieselbe zentrierte Stauchung (Faktor 0.77, um
|
||||
// (12,12), auf Nutzerwunsch 10% größer als die erste Fassung) wie im Panel
|
||||
// gleicht das aus, siehe ICONS.stat dort.
|
||||
export function SymbolStatistik({ groesse }: SymbolProps) {
|
||||
return (
|
||||
<Rahmen
|
||||
groesse={groesse}
|
||||
voll
|
||||
kinder={
|
||||
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
|
||||
<g transform="translate(2.76,2.76) scale(0.77)">
|
||||
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
|
||||
</g>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -349,7 +349,17 @@ class Ablage:
|
||||
ihn beim ersten Wert des Tages an, erweitert sonst nur min/max samt
|
||||
dem Zeitstempel der jeweils neuen Extremmessung. min_temp_c wird nur
|
||||
mitgeschrieben, wenn diese Messung auch den Minimalwert setzt - sie
|
||||
gilt für genau diesen Zeitpunkt, nicht für den Tag insgesamt."""
|
||||
gilt für genau diesen Zeitpunkt, nicht für den Tag insgesamt.
|
||||
|
||||
Sonderfall, ohne den ein erneuter Import nichts nachliefern könnte:
|
||||
meldet ein späterer Aufruf exakt denselben (bereits gespeicherten)
|
||||
Minimalwert erneut, aber mit einer jetzt verfügbaren Temperatur, wo
|
||||
vorher keine lag - z. B. weil NEBENWERT_MAX_ABSTAND_S seither
|
||||
aufgeweitet wurde und die damals zu weit entfernte Temperaturmessung
|
||||
jetzt innerhalb der Grenze liegt -, wird min_temp_c nachträglich
|
||||
gefüllt. `spannung < eintrag["min"]` allein hätte das nie erlaubt:
|
||||
derselbe Wert ist nie *kleiner* als sich selbst, also griff die
|
||||
Aktualisierung nur beim allerersten Mal."""
|
||||
async with self._sperre:
|
||||
verlauf = await self.batterieverlauf_lesen()
|
||||
for eintrag in verlauf:
|
||||
@@ -359,6 +369,8 @@ class Ablage:
|
||||
eintrag["min"] = spannung
|
||||
eintrag["min_ts"] = ts
|
||||
eintrag["min_temp_c"] = aussentemp
|
||||
elif spannung == eintrag["min"] and eintrag.get("min_temp_c") is None and aussentemp is not None:
|
||||
eintrag["min_temp_c"] = aussentemp
|
||||
if spannung > eintrag["max"]:
|
||||
eintrag["max"] = spannung
|
||||
eintrag["max_ts"] = ts
|
||||
|
||||
@@ -49,6 +49,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
from .verlauf import NEBENWERT_MAX_ABSTAND_S
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
@@ -79,13 +80,33 @@ async def pruefen(k: Koordinator) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Nicht nur "gibt es einen Temperaturwert", sondern "ist er auch aktuell":
|
||||
# zustand_oder_none() liefert unabhängig vom Alter den zuletzt bekannten
|
||||
# Zustand - meldet der Temperatursensor seltener als die Spannung (im
|
||||
# Testbestand teils erst nach 30+ Stunden erneut), würde sonst ein
|
||||
# beliebig alter Temperaturwert einer frischen Spannungsmessung
|
||||
# zugeschlagen. Verglichen wird gegen den Zeitpunkt der Spannungsmessung
|
||||
# selbst (last_updated des Spannungssensors), nicht gegen "jetzt" - beide
|
||||
# Sensoren werden aus dem aktuellen hass.states-Stand gelesen, können aber
|
||||
# unterschiedlich alt sein. Grenze und Begründung siehe
|
||||
# NEBENWERT_MAX_ABSTAND_S (verlauf.py) - vom Nutzer festgelegt.
|
||||
aussentemp = None
|
||||
temp_wert = zustand_oder_none(k.hass, k.zuordnung.werte.AUSSENTEMP_SENSOR)
|
||||
temp_sensor = k.zuordnung.werte.AUSSENTEMP_SENSOR
|
||||
temp_wert = zustand_oder_none(k.hass, temp_sensor)
|
||||
if temp_wert is not None:
|
||||
try:
|
||||
aussentemp = float(temp_wert)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
spannung_zustand = k.hass.states.get(sensor)
|
||||
temp_zustand = k.hass.states.get(temp_sensor)
|
||||
abstand_s = abs((temp_zustand.last_updated - spannung_zustand.last_updated).total_seconds())
|
||||
if abstand_s <= NEBENWERT_MAX_ABSTAND_S:
|
||||
try:
|
||||
aussentemp = float(temp_wert)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Außentemperatur %s s von der Spannungsmessung entfernt - nicht mit erfasst",
|
||||
round(abstand_s),
|
||||
)
|
||||
|
||||
jetzt = datetime.datetime.now(datetime.UTC)
|
||||
heute = datetime.date.today().isoformat()
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.8.27.7","sha256":"4631011c09be09efcdf787ba86ad1bd1fb40006f0d247e8b99b29b0a196f2da3","bytes":235579,"gebaut":"2026-08-27T17:32:23Z"}
|
||||
{"version":"2026.8.27.17","sha256":"9ccedff2ae407751703713cc91b37bb51124f6fb5b98014ef2ae49659e51de2a","bytes":236026,"gebaut":"2026-08-27T21:03:47Z"}
|
||||
Binary file not shown.
@@ -1672,19 +1672,46 @@ function vAudi() {
|
||||
Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt.
|
||||
bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit
|
||||
Zoom/Pan flüssig bleiben. */
|
||||
/* bvPunkte enthält nur Ruhespannungs-Messwerte (<= AGM_RUHE_MAX_V) - sowohl
|
||||
Grundlage für Ladezustand/SOH-Trend als auch für das Diagramm selbst.
|
||||
Generatorspannungen (> AGM_RUHE_MAX_V, z. B. 13,01 V) tauchen dort bewusst
|
||||
nicht auf (Nutzerwunsch: "13,01 shall be generator und not part of the
|
||||
diagram") - anders als in einer Zwischenfassung, die kurzzeitig jede
|
||||
Messung zeichnete; die Messwertliste (vBatterieliste()) zeigt weiterhin
|
||||
ausnahmslos alle Tage samt "Generatorspannung"-Kennzeichnung, sie liest
|
||||
direkt aus BATTERIEVERLAUF, nicht aus bvPunkte. */
|
||||
let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null, bvYDomain = null;
|
||||
const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger)
|
||||
let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste}
|
||||
let bvTapKandidat = null, bvBewegtSeit = false;
|
||||
const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22;
|
||||
/* BV_W ist nur der Startwert: bvZeichnen() setzt die viewBox auf die
|
||||
tatsächlich gerenderte Pixelbreite (bvBreite), damit eine Einheit im
|
||||
Koordinatensystem genau einem Bildschirmpixel entspricht. Vorher stand hier
|
||||
eine feste 320er-viewBox mit preserveAspectRatio="none" - auf der 736px
|
||||
breiten Desktop-Spalte wurde dadurch alles um Faktor 2,3 in die Breite
|
||||
gezogen, sichtbar an den unnatürlich breit gelaufenen Achsenzahlen und an
|
||||
überlappenden Beschriftungen. BV_MB ist klein, seit die Datumsbeschriftung
|
||||
unter dem Diagramm entfällt (Datum steht beim Antippen im Tooltip). */
|
||||
const BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 12;
|
||||
const BV_W = 320;
|
||||
let bvBreite = BV_W;
|
||||
const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||||
|
||||
/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei
|
||||
aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle
|
||||
AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V,
|
||||
50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch
|
||||
entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V -
|
||||
deutlich über dem Ruhespannungs-Maximum.
|
||||
/* Referenzwerte für AGM-Starterbatterien (12V). Zwei unterschiedliche Zahlen,
|
||||
nicht eine: AGM_RUHE_MAX_V ist die Klassifizierungsgrenze "gilt diese
|
||||
Messung überhaupt noch als Batterie(ruhe)spannung, nicht schon
|
||||
Generatorspannung" (Diagramm/SOH-Trend) - vom Nutzer direkt auf 13,0 V
|
||||
festgelegt ("max voltage for AGM Battery is 13V, everything above is the
|
||||
generator"). BV_SOC_TABELLE (unten) ist davon unabhängig die
|
||||
Ladezustands-Kurve selbst - deren "100 % / voll"-Eckwert bleibt bei den
|
||||
ursprünglich recherchierten 12,8 V (aboutdarwin.com/agm-battery-voltage-
|
||||
charts, brsbattery.com: volle Ruhespannung), vom Nutzer ausdrücklich
|
||||
bestätigt ("12,8v voll was already right"). Eine Messung zwischen 12,8 und
|
||||
13,0 V zählt also als gültige Batteriemessung (Diagrammpunkt, fließt in
|
||||
SOH ein), zeigt aber weiterhin 100 % SOC - dieselbe Klemmung, die die
|
||||
Tabelle für jeden Wert >= ihrem letzten Eckwert ohnehin schon anwendet. Der
|
||||
Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - deutlich über
|
||||
beiden Grenzen, die Trennung zur Generatorspannung bleibt also eindeutig.
|
||||
|
||||
Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf,
|
||||
ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive
|
||||
@@ -1693,12 +1720,8 @@ const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2
|
||||
Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit
|
||||
die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das
|
||||
Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet),
|
||||
tauchen entsprechend nicht in der Ladezustands-Statistik auf. Die Grenze
|
||||
liegt bei 12,8 V (voller Ruhezustand laut obiger Recherche) statt z. B.
|
||||
13,2 V, weil alles darüber bereits vom Generator stammt, nicht von der
|
||||
Batterie selbst - solche Werte würden den Ladezustand künstlich zu gut
|
||||
aussehen lassen. */
|
||||
const AGM_RUHE_MAX_V = 12.8;
|
||||
tauchen entsprechend nicht in der Ladezustands-Statistik auf. */
|
||||
const AGM_RUHE_MAX_V = 13.0;
|
||||
const BV_SOC_TABELLE = [
|
||||
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
|
||||
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
|
||||
@@ -1769,12 +1792,17 @@ function bvSohBewertung() {
|
||||
}
|
||||
|
||||
function vBatterieverlauf() {
|
||||
// Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem
|
||||
// Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der
|
||||
// Ein Punkt je Tag mit einer echten Ruhespannungsmessung (<= AGM_RUHE_MAX_V,
|
||||
// z. B. 13,0 V), gezeichnet wird der Tagesminimalwert - min_ts ist der
|
||||
// echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver
|
||||
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann.
|
||||
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V)
|
||||
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum }))
|
||||
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. Der
|
||||
// Tageshöchstwert bekommt bewusst KEINEN eigenen Punkt, er steht beim
|
||||
// Antippen im Tooltip. Tage, deren Minimum über der Grenze liegt (nie
|
||||
// wirklich in Ruhe beobachtet, sondern nur Generatorspannung), tauchen im
|
||||
// Diagramm nicht auf (Nutzerwunsch) - die Messwertliste zeigt sie weiterhin
|
||||
// alle, mit "Generatorspannung"-Kennzeichnung.
|
||||
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min_ts && e.min <= AGM_RUHE_MAX_V)
|
||||
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum, temp: e.min_temp_c }))
|
||||
.sort((a, b) => a.t - b.t);
|
||||
bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null;
|
||||
|
||||
@@ -1790,7 +1818,9 @@ function vBatterieverlauf() {
|
||||
bvYDomain = genug ? [10, 15] : null;
|
||||
|
||||
const stufen = bvFarbstufen();
|
||||
const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null;
|
||||
// Ladezustand nur aus der letzten echten Ruhespannung - eine
|
||||
// Generatorspannung ergäbe sonst dauerhaft "100 % · Sehr gut".
|
||||
const letzter = bvRuhePunkte.length ? bvRuhePunkte[bvRuhePunkte.length - 1] : null;
|
||||
const soc = letzter ? bvSocProzent(letzter.v) : null;
|
||||
const soh = bvSohBewertung();
|
||||
const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)");
|
||||
@@ -1806,7 +1836,7 @@ function vBatterieverlauf() {
|
||||
</div>
|
||||
${genug ? `
|
||||
<div id="bvChart" style="margin-top:14px;touch-action:none;position:relative">
|
||||
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" preserveAspectRatio="none" style="width:100%;height:${BV_H}px;display:block"></svg>
|
||||
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" style="width:100%;height:${BV_H}px;display:block"></svg>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px">
|
||||
<span class="label" id="bvRange" style="margin-top:0"></span>
|
||||
@@ -1871,7 +1901,7 @@ function vBatterieliste() {
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); }
|
||||
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (bvBreite - BV_ML - BV_MR); }
|
||||
/* Auf [0,1] geklemmter Anteil statt roher Extrapolation - ein Punkt außerhalb
|
||||
von yMin/yMax (z. B. ein Ausreißer, der die Plausibilitätsgrenze auf einer
|
||||
noch nicht aktualisierten Instanz umgangen hat) zeichnet sich sonst weit
|
||||
@@ -1890,8 +1920,10 @@ function bvDomainKlemmen(domain) {
|
||||
if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; }
|
||||
return [a, b];
|
||||
}
|
||||
/* Eine Koordinateneinheit ist seit der dynamischen viewBox genau ein Pixel
|
||||
(siehe bvBreite), die Ränder gelten also unskaliert. */
|
||||
function bvPlotRahmen(rect) {
|
||||
return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) };
|
||||
return { linksPx: rect.left + BV_ML, breitePx: Math.max(1, rect.width - BV_ML - BV_MR) };
|
||||
}
|
||||
|
||||
function bvZeichnen() {
|
||||
@@ -1902,6 +1934,19 @@ function bvZeichnen() {
|
||||
// nicht mehr "streckt".
|
||||
const [yMin, yMax] = bvYDomain;
|
||||
|
||||
/* Koordinatensystem an die echte Rendergröße koppeln: eine Einheit = ein
|
||||
Pixel. Ohne das zog die feste 320er-viewBox mit preserveAspectRatio="none"
|
||||
auf breiten Spalten alles in die Breite, Achsenzahlen inklusive.
|
||||
Ein ResizeObserver hält das beim Fensterwechsel nach (die viewBox selbst
|
||||
ändert die Elementgröße nicht, deshalb keine Endlosschleife). */
|
||||
const gemessen = svg.getBoundingClientRect().width;
|
||||
if (gemessen > 0) bvBreite = gemessen;
|
||||
svg.setAttribute("viewBox", `0 0 ${bvBreite.toFixed(1)} ${BV_H}`);
|
||||
if (!svg._bvBeobachtet && typeof ResizeObserver !== "undefined") {
|
||||
svg._bvBeobachtet = true;
|
||||
new ResizeObserver(() => bvZeichnen()).observe(svg);
|
||||
}
|
||||
|
||||
// etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am
|
||||
// Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt
|
||||
// weiterläuft
|
||||
@@ -1911,15 +1956,12 @@ function bvZeichnen() {
|
||||
|
||||
const gitter = [0, 1, 2, 3].map((i) => {
|
||||
const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax);
|
||||
return `<line x1="${BV_ML}" y1="${y.toFixed(1)}" x2="${BV_W - BV_MR}" y2="${y.toFixed(1)}" stroke="var(--line)" stroke-width="0.5"/>
|
||||
return `<line x1="${BV_ML}" y1="${y.toFixed(1)}" x2="${(bvBreite - BV_MR).toFixed(1)}" y2="${y.toFixed(1)}" stroke="var(--line)" stroke-width="0.5"/>
|
||||
<text x="${BV_ML - 6}" y="${(y + 3).toFixed(1)}" font-size="11" fill="var(--fg3)" text-anchor="end">${de(v, 1)}</text>`;
|
||||
}).join("");
|
||||
const xLabels = [0, 0.5, 1].map((f) => {
|
||||
const x = BV_ML + f * (BV_W - BV_ML - BV_MR);
|
||||
const t = bvDomain[0] + f * spanne;
|
||||
return `<text x="${x.toFixed(1)}" y="${BV_H - 6}" font-size="11" fill="var(--fg3)"
|
||||
text-anchor="${f === 0 ? "start" : f === 1 ? "end" : "middle"}">${kurzdat(new Date(t))}</text>`;
|
||||
}).join("");
|
||||
/* Keine Datumsbeschriftung unter dem Diagramm (Nutzerwunsch): der Zeitraum
|
||||
steht als Text unter der Zeichenfläche (#bvRange), das Datum einer
|
||||
einzelnen Messung erscheint beim Antippen im Tooltip. */
|
||||
const stufen = bvFarbstufen();
|
||||
const kreise = rahmen.map((p) => {
|
||||
const idx = bvPunkte.indexOf(p);
|
||||
@@ -1928,8 +1970,20 @@ function bvZeichnen() {
|
||||
r="${gewaehlt ? 4.5 : 2.4}" fill="${bvFarbe(p.v, stufen)}"
|
||||
${gewaehlt ? 'stroke="var(--fg)" stroke-width="1"' : ""}/>`;
|
||||
}).join("");
|
||||
/* Sanfte Flaechenfuellung unter der Linie (wie iOS Health/Aktie-Charts) -
|
||||
macht aus der duennen Linie auf der jetzt vollen Spaltenbreite ein
|
||||
"fertiges" Diagramm statt eines leeren Bandes. Rein dekorativ, keine
|
||||
neue Information; niedrige Deckkraft, damit das Gitter durchscheint. */
|
||||
const flaeche = rahmen.length >= 2
|
||||
? `<path d="M${bvSkalaX(rahmen[0].t).toFixed(1)},${(BV_H - BV_MB).toFixed(1)} L${linie.split(" ").join(" L")} L${bvSkalaX(rahmen[rahmen.length - 1].t).toFixed(1)},${(BV_H - BV_MB).toFixed(1)} Z"
|
||||
fill="url(#bvGrad)" stroke="none"/>`
|
||||
: "";
|
||||
|
||||
svg.innerHTML = `${gitter}<polyline points="${linie}" fill="none" stroke="var(--fg)" stroke-width="1.5"/>${kreise}${xLabels}`;
|
||||
svg.innerHTML = `<defs><linearGradient id="bvGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="var(--fg)" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="var(--fg)" stop-opacity="0"/>
|
||||
</linearGradient></defs>${gitter}${flaeche}<polyline points="${linie}" fill="none" stroke="var(--fg)"
|
||||
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>${kreise}`;
|
||||
|
||||
const rangeEl = $("#bvRange");
|
||||
if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} – ${kurzdat(new Date(bvDomain[1]))}`;
|
||||
@@ -1939,7 +1993,15 @@ function bvZeichnen() {
|
||||
const p = bvPunkte[bvAusgewaehlt];
|
||||
const zeit = new Date(p.minTs);
|
||||
tip.style.display = "block";
|
||||
tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`;
|
||||
/* Der Höchstwert des Tages hat bewusst keinen eigenen Punkt im Diagramm
|
||||
(Nutzerwunsch) - hier ist die Stelle, an der er sichtbar wird. */
|
||||
const teile = [
|
||||
`${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`,
|
||||
`Min ${de(p.vMin, 2)} V`,
|
||||
];
|
||||
if (p.vMax != null) teile.push(`Max ${de(p.vMax, 2)} V`);
|
||||
if (p.temp != null) teile.push(`${de(p.temp, 1)} °C`);
|
||||
tip.textContent = teile.join(" · ");
|
||||
} else {
|
||||
tip.style.display = "none";
|
||||
}
|
||||
@@ -3622,8 +3684,17 @@ const ICONS = {
|
||||
audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
|
||||
trips: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5"/>',
|
||||
/* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe
|
||||
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). */
|
||||
stat: CI.pollsS,
|
||||
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders
|
||||
als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand
|
||||
lassen, ~32-47% Fuellflaeche laut Live-Messung per getBBox()) ist das
|
||||
CI-Symbol randlos gezeichnet (~92% Fuellflaeche) und wirkte dadurch neben
|
||||
"Fahrten"/"Tanken" sichtbar zu gross. Zentrierte Stauchung auf das
|
||||
gemessene Grössenverhältnis (Faktor 0.77, um (12,12) - auf Nutzerwunsch
|
||||
10% größer als die erste, auf ~45% Fuellflaeche gerechnete Fassung)
|
||||
statt einer eigenen Icon-Groesse: bleibt so bei jeder Breakpoint-Groesse
|
||||
(.tab svg 26px/22px) automatisch im richtigen Verhältnis, ohne zwei
|
||||
Stellen pflegen zu müssen. */
|
||||
stat: `<g transform="translate(2.76,2.76) scale(0.77)">${CI.pollsS}</g>`,
|
||||
fuel: '<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20"/><path d="M3 20h12.5"/><path d="M4.5 11h10.5"/><path d="M17 20v-8.5a2 2 0 0 0-2-2"/><path d="M17 11.5V7.8l-2-2.3"/>',
|
||||
};
|
||||
const TABS = [
|
||||
|
||||
@@ -52,6 +52,7 @@ from .verlauf import (
|
||||
verbrauch_aus_literstaenden,
|
||||
verlauf_lesen,
|
||||
wert_bei,
|
||||
wert_in_naehe,
|
||||
zahl,
|
||||
)
|
||||
|
||||
@@ -307,10 +308,26 @@ async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
Die Ablage führt bestehende und neue Werte pro Tag zusammen (min bleibt
|
||||
min, max bleibt max), deshalb braucht es hier keine eigene
|
||||
Dubletten-Prüfung: ein zweiter Import desselben Zeitraums verändert die
|
||||
Einträge nicht mehr."""
|
||||
Einträge nicht mehr - das gilt auch für die Außentemperatur (siehe unten),
|
||||
ein erneuter Import über einen bereits importierten Zeitraum ergänzt sie
|
||||
dort nachträglich, ohne etwas doppelt zu schreiben.
|
||||
|
||||
Trägt bei jedem Tagesminimum auch die Außentemperatur nach, sofern
|
||||
AUSSENTEMP_SENSOR zugeordnet ist - Parität zur Live-Erfassung
|
||||
(batterie.py's pruefen()), die das seit ihrer Einführung bereits tut. Bis
|
||||
zu dieser Korrektur fehlte das hier: verlaeufe["aussentemp"] wurde gar
|
||||
nicht erst gelesen, ein Import füllte min_temp_c also nie, selbst wenn die
|
||||
Temperaturhistorie im recorder für denselben Zeitraum längst vorlag.
|
||||
|
||||
wert_in_naehe() statt naechster_wert(): der Temperatursensor dieses
|
||||
Testbestands meldet teils erst nach 30+ Stunden erneut - ohne Zeitgrenze
|
||||
würde ein beliebig alter Temperaturwert der frischen Spannungsmessung
|
||||
zugeschlagen. Siehe NEBENWERT_MAX_ABSTAND_S (verlauf.py) für die vom
|
||||
Nutzer festgelegte Grenze."""
|
||||
verlauf = verlaeufe["batterie"]
|
||||
if not verlauf:
|
||||
return {"tage": 0}
|
||||
aussentemp_verlauf = verlaeufe.get("aussentemp") or []
|
||||
|
||||
tage: dict[str, dict] = {}
|
||||
for ts, wert in verlauf:
|
||||
@@ -336,8 +353,11 @@ async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
|
||||
for tag in sorted(tage):
|
||||
werte = tage[tag]
|
||||
aussentemp = (
|
||||
wert_in_naehe(werte["min_ts"], aussentemp_verlauf) if aussentemp_verlauf else None
|
||||
)
|
||||
await k.ablage.batterieverlauf_tageswert_aktualisieren(
|
||||
tag, werte["min_ts"].isoformat(), werte["min"]
|
||||
tag, werte["min_ts"].isoformat(), werte["min"], aussentemp
|
||||
)
|
||||
await k.ablage.batterieverlauf_tageswert_aktualisieren(
|
||||
tag, werte["max_ts"].isoformat(), werte["max"]
|
||||
@@ -382,6 +402,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
|
||||
"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),
|
||||
"aussentemp": await verlauf_lesen(k.hass, werte.AUSSENTEMP_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),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.8.27.8",
|
||||
"version": "2026.8.27.17",
|
||||
"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"],
|
||||
|
||||
@@ -192,3 +192,43 @@ def naechster_wert(
|
||||
bester_wert = gezahlt
|
||||
beste_diff = diff
|
||||
return bester_wert
|
||||
|
||||
|
||||
# Wie lange eine Nebenmessung (z. B. Außentemperatur) höchstens von der
|
||||
# Hauptmessung entfernt liegen darf, um noch als "zur selben Zeit" zu gelten -
|
||||
# vom Nutzer auf 300s (5 Minuten) festgelegt, nach ursprünglich 60s. Der
|
||||
# Wechsel ist an echten FMM003-Daten begründet, nicht geschätzt: das Gerät
|
||||
# sendet Außentemperatur deutlich seltener als Spannung (an einem
|
||||
# beobachteten Fahrtfenster 851 Spannungs- gegen 187 Temperaturmeldungen in
|
||||
# 48h) - selbst bei laufender Fahrt und ohne jede Verbindungsstörung wurden
|
||||
# reale Lücken bis 593s zwischen zwei Temperaturmeldungen gemessen. 60s hätte
|
||||
# genau diesen völlig normalen Fall verworfen. Physikalisch unproblematisch:
|
||||
# die Außentemperatur ändert sich über wenige Minuten kaum, anders als z. B.
|
||||
# eine GPS-Position oder die Spannung selbst - und die Live-Prüfung
|
||||
# (batterie.py's pruefen()) läuft ohnehin nur alle 5 Minuten, verlangt hier
|
||||
# also keine feinere Genauigkeit, als sie selbst liefert. Ein Verbindungs-
|
||||
# abbruch des Geräts (beobachtet: 45 Minuten, beide Sensoren gleichzeitig
|
||||
# stumm) bleibt mit 300s weiterhin sicher außerhalb der Grenze und liefert
|
||||
# bewusst KEINEN Wert statt eines beliebig alten.
|
||||
NEBENWERT_MAX_ABSTAND_S = 300
|
||||
|
||||
|
||||
def wert_in_naehe(
|
||||
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt], max_abstand_s: float = NEBENWERT_MAX_ABSTAND_S
|
||||
) -> float | None:
|
||||
"""Wie naechster_wert(), aber None statt eines beliebig weit entfernten
|
||||
Werts, wenn selbst der zeitlich nächste mehr als max_abstand_s von
|
||||
zielzeit entfernt liegt."""
|
||||
bester_wert = None
|
||||
beste_diff = None
|
||||
for ts, wert in verlauf:
|
||||
gezahlt = zahl(wert)
|
||||
if gezahlt is None:
|
||||
continue
|
||||
diff = abs((ts - zielzeit).total_seconds())
|
||||
if beste_diff is None or diff < beste_diff:
|
||||
bester_wert = gezahlt
|
||||
beste_diff = diff
|
||||
if beste_diff is None or beste_diff > max_abstand_s:
|
||||
return None
|
||||
return bester_wert
|
||||
|
||||
@@ -25,6 +25,16 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .einstellungen import POSITIONEN, Sensorzuordnung
|
||||
|
||||
# Letzte plausible Batteriespannung, für die "Zustand"-Kachel, wenn die
|
||||
# aktuelle Live-Messung gerade unplausibel ist (Sensor kurzzeitig offline,
|
||||
# Dongle getrennt o.ä.) - der Nutzer will dort die zuletzt gemessene Spannung
|
||||
# sehen statt "unbekannt". RAM-only wie andere Laufzeitzustände dieses
|
||||
# Projekts (siehe die bekannten Lücken zu Fahrtstart/Tankstand): kein Store,
|
||||
# bewusst - ein Neustart zeigt kurz wieder "unbekannt", bis die nächste echte
|
||||
# Messung eintrifft, statt einen möglicherweise sehr alten Wert aus einer
|
||||
# Datei wiederzubeleben.
|
||||
_letzte_gueltige_spannung: dict[str, float | None] = {"wert": None}
|
||||
|
||||
|
||||
def zustand_oder_none(hass: HomeAssistant, entity_id: str | None) -> str | None:
|
||||
"""Sicherer Zustandszugriff.
|
||||
@@ -162,6 +172,14 @@ def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
|
||||
# sonst zeigt die "Zustand"-Kachel einen Wert, der im Messwertverlauf
|
||||
# nie auftaucht, weil er dort schon verworfen wird.
|
||||
spannung = None
|
||||
if spannung is not None:
|
||||
_letzte_gueltige_spannung["wert"] = spannung
|
||||
else:
|
||||
# Nutzerwunsch: statt "unbekannt" die zuletzt gemessene Spannung
|
||||
# zeigen, wenn der Sensor gerade nichts Plausibles liefert (z. B.
|
||||
# Dongle offline, meldet dann oft 0 V) - None bleibt nur, solange
|
||||
# noch nie ein gültiger Wert beobachtet wurde.
|
||||
spannung = _letzte_gueltige_spannung["wert"]
|
||||
|
||||
return {
|
||||
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
|
||||
|
||||
Reference in New Issue
Block a user