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:
2026-08-27 23:23:12 +02:00
parent 80d73e8afd
commit 172db54e7a
12 changed files with 626 additions and 85 deletions
+275
View File
@@ -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.0825.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)
+109 -33
View File
@@ -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)}`
})
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
ref={svgRef}
className="dm-diagramm"
viewBox={`0 0 ${breite} ${hoehe}`}
preserveAspectRatio="none"
viewBox={`0 0 ${breite.toFixed(1)} ${hoehe}`}
role="img"
aria-label={`Spannungsverlauf über ${werte.length} Tage`}
aria-label={`Spannungsverlauf über ${werte.length} Messungen`}
onClick={punktWaehlen}
>
<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" />
<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>
)}
</>
)
}
+8 -1
View File
@@ -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={
<g transform="translate(2.76,2.76) scale(0.77)">
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
</g>
}
/>
)
+13 -1
View File
@@ -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
+22 -1
View File
@@ -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:
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"}
@@ -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)),