diff --git a/AGENTS.md b/AGENTS.md index 0472845..ebcbbf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (``, `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 ``/area `` 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 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 `` 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 `` 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) diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx index 124e000..c19f2b4 100644 --- a/companion-app/src/screens/Batterie.tsx +++ b/companion-app/src/screens/Batterie.tsx @@ -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(null) + const [breite, setzeBreite] = useState(320) + const [gewaehlt, setzeGewaehlt] = useState(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 ( - - t.max)} fill="none" stroke="var(--fg3)" strokeWidth="1" /> - t.min)} fill="none" stroke="var(--red)" strokeWidth="1.5" /> - + <> + + + {werte.map((t, i) => + t.min == null ? null : ( + + ), + )} + + {aktiv && ( +

+ {[ + 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(" · ")} +

+ )} + ) } diff --git a/companion-app/src/symbole.tsx b/companion-app/src/symbole.tsx index eb8c2de..c73b0d4 100644 --- a/companion-app/src/symbole.tsx +++ b/companion-app/src/symbole.tsx @@ -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 ( + + + } /> ) diff --git a/custom_components/audi_dashboard/ablage.py b/custom_components/audi_dashboard/ablage.py index 09e8682..d9511e1 100644 --- a/custom_components/audi_dashboard/ablage.py +++ b/custom_components/audi_dashboard/ablage.py @@ -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 diff --git a/custom_components/audi_dashboard/batterie.py b/custom_components/audi_dashboard/batterie.py index 487ec9a..e332991 100644 --- a/custom_components/audi_dashboard/batterie.py +++ b/custom_components/audi_dashboard/batterie.py @@ -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() diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 0d874b9..3eaf181 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.8.27.7","sha256":"4631011c09be09efcdf787ba86ad1bd1fb40006f0d247e8b99b29b0a196f2da3","bytes":235579,"gebaut":"2026-08-27T17:32:23Z"} \ No newline at end of file +{"version":"2026.8.27.17","sha256":"9ccedff2ae407751703713cc91b37bb51124f6fb5b98014ef2ae49659e51de2a","bytes":236026,"gebaut":"2026-08-27T21:03:47Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 90ab108..64ecc4d 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 60a3822..655f9b4 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -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() { ${genug ? `
- +
@@ -1871,7 +1901,7 @@ function vBatterieliste() { }).join("")}
`; } -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 ` + return ` ${de(v, 1)}`; }).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 `${kurzdat(new Date(t))}`; - }).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 + ? `` + : ""; - svg.innerHTML = `${gitter}${kreise}${xLabels}`; + svg.innerHTML = ` + + + ${gitter}${flaeche}${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: '', trips: '', /* 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: `${CI.pollsS}`, fuel: '', }; const TABS = [ diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py index 99aa774..82946f0 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -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), } diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index e9f2e5b..7be617a 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.8.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"], diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py index 214fce0..8296474 100644 --- a/custom_components/audi_dashboard/verlauf.py +++ b/custom_components/audi_dashboard/verlauf.py @@ -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 diff --git a/custom_components/audi_dashboard/veroeffentlichung.py b/custom_components/audi_dashboard/veroeffentlichung.py index 8988281..2380204 100644 --- a/custom_components/audi_dashboard/veroeffentlichung.py +++ b/custom_components/audi_dashboard/veroeffentlichung.py @@ -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)),