TANK_DISTANZ_SENSOR + Datensatz sichern/laden mit echtem CSV-Import (Panel)

- Neuer optionaler Sensor TANK_DISTANZ_SENSOR ersetzt die eigene
  Kilometerstand-Subtraktion fuer die Tankvorgang-Strecke, wo zugeordnet
  (Live-Erkennung, manuelle Erfassung, historischer Import)
- "Fahrzeugprofil" und "Daten ausgeben" zu einer Kachel zusammengelegt:
  "Datensatz sichern" (4 Exporte wie bisher) / "Datensatz laden" (neu:
  echter CSV-Import fuer Fahrten/Tankvorgaenge/Wartungsplan)
- Import gleicht per ID ab (Fahrten/Tankvorgaenge) bzw. Datum+Art
  (Wartungsplan, hat keine eigene ID) und aktualisiert nur die in der CSV
  enthaltenen Spalten - alles andere am Datensatz bleibt unangetastet
- Nebenbei gefunden und behoben: belege.tankvorgang_aktualisieren()
  ueberschrieb bisher immer alle Felder, auch mit None, wenn irgendeins
  geaendert wurde - fuer den CSV-Import gefaehrlich, jetzt nur noch
  tatsaechlich uebergebene Felder
- Ebenfalls gefunden und behoben: Panel las das Import-Ergebnis per
  HASS.states direkt nach dem Dienstaufruf - ein Wettlauf mit dem
  state_changed-Push. Nutzt jetzt denselben HASS.callWS(get_states)-Weg
  wie der bestehende historie_importieren-Ablauf.

companion-app-Portierung von "Datensatz sichern/laden" steht noch aus.

Version 2026.8.28.3, live im Testcontainer verifiziert (alle drei
CSV-Datensatztypen: anlegen + aktualisieren per ID/Datum+Art getestet,
Testdaten danach geloescht).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:19:06 +02:00
parent edefdd66d8
commit bfb7dec1e9
11 changed files with 521 additions and 76 deletions
+86 -9
View File
@@ -4004,21 +4004,98 @@ Archiv button and "Montiert" share one Y-center exactly, with a visible backgrou
of the calculation" - not yet wired in, would mirror the `TANK_LITER_SENSOR` precedent (section Q): new
optional sensor role, preferred over the calculation when mapped, calculation stays as the fallback when
unmapped. Owner to confirm before this is built.
- **Item 9**: combine the "Fahrzeugprofil" tile (`data-profil="export"`/`"import"`, JSON) and "Daten ausgeben"
tile (`data-csv="fahrten"`/`"tanken"`/`"service"`, CSV export only, no import) into one area with two
buttons - "Datensatz sichern" opening a popup with 4 individual export actions (Fahrzeugprofil,
Fahrten/Tankvorgänge/Wartungsplan as CSV), "Datensatz laden" the same 4 in reverse. The pre-existing,
separate "Backup" tile (`data-backup="jetzt"`/`"export"`/`"import"`, a combined profil+fahrten+tank JSON
bundle plus the scheduled-backup mechanism) was NOT named by the owner and stays untouched. Three of the
four export actions and one of the four import actions already exist and just need moving into the new
popup; CSV **import** for Fahrten/Tankvorgänge/Wartungsplan does not exist anywhere yet and needs new
backend parsing (inverse of the existing CSV export format: `;`-delimited, de-DE number format).
- **Item 9: done on the panel, see section AC below - companion-app port still pending** (parity rule).
- **Item 11**: Setup-Menü sensor role fields - replace the current description text under each field's
headline (e.g. "on = Fahrt läuft", "Optional, vom CAN...") with the live sensor's current output value and
unit inline next to the headline (`[on/off]`, `[%]`, `[l]`, `[km]`...), and show the matched entity's ID
suffix (the part after `fmm003_...`) as "the expected sensor" - not yet investigated in the code
(`entitaeten.py`'s `FELDER` catalog / the Setup popup's row-rendering in the panel).
## AC. Item 8 built (TANK_DISTANZ_SENSOR); Item 9 built on the panel: "Datensatz sichern/laden" (2026.8.28.3)
**Item 8.** Owner: "you can use the sensor instead of our current logic. I guess it also helps for fuel
refill detection the best way." New optional `TANK_DISTANZ_SENSOR` (`einstellungen.py`, `FELDER` catalog,
group `fahrterkennung`) - the FMM003's own `calculated_distance_covered_after_the_latest_refueling`. New
shared helper `tankerkennung.distanz_seit_tankung(k, odometer_km)`: reads the sensor if mapped, else falls
back to the existing `ablage.distanz_seit_letzter_tankung()` - used at all three places a refuel's
`distance_km` gets set (`tankerkennung._automatisch_anlegen()`, `belege.tankvorgang_manuell()`'s
auto-suggestion, and `historienimport._tankvorgaenge_importieren()`, the last via `wert_bei()` on the
sensor's own fetched history). **The "helps with refill detection" half of the idea was investigated, not
built**: tried to read the sensor's recorder history in the test container to see whether it resets
synchronously with a real refuel (which would make it useful as a detection signal in its own right, not
just a value source) - the frontend's `history/period` REST call came back empty because it defaults to
significant-changes-only, unlike this project's own `verlauf.py` (`significant_changes_only=False`,
deliberately, for exactly this reason) - inconclusive, not investigated further. Confirmed live: entity
`sensor.testcar_b9_fmm003_testintegratoin_calculated_distance_covered_after_the_latest_refueling` exists in
`audi_ha_test` (reads `407` km); `py_compile` clean on all four touched backend files.
**Item 9, panel only - companion-app not yet ported (parity rule, flagged, not silently skipped).** Merged
the "Fahrzeugprofil" tile and "Daten ausgeben" tile into one "Fahrzeugprofil" tile with two buttons -
"Datensatz sichern" (a popup with 4 instant, clientside export actions: Fahrzeugprofil as JSON,
Fahrten/Tankvorgänge/Wartungsplan as CSV - unchanged logic, just moved into the popup) and "Datensatz laden"
(the same 4 as file pickers). The separate "Backup" tile (combined profil+fahrten+tank JSON bundle plus the
scheduled-backup mechanism) was never named by the owner and is untouched.
**The real new work is CSV import**, which didn't exist for any dataset before. Design question resolved via
`AskUserQuestion` before writing any code: re-importing an edited CSV **matches by ID and updates only the
CSV's own columns**, never a full-dataset replace - a full replace would have silently deleted every field
not in the CSV's few display columns (GPS, route, `edited_fields`, receipt links, ...) on every existing
record, even ones the owner never touched. New `csv_import.py` (own module, own moduledocstring with the
full reasoning): a `_de_zahl()` parser (`"1.234,5"` -> `1234.5`), a `_zeilen()` CSV reader (`;`-delimited,
BOM-tolerant), and one importer function per dataset, dispatched via `importieren(k, art, inhalt)`. New
service `csv_importieren(art, inhalt)` (`dienste.py`/`const.py`/`services.yaml`) - **synchronous**, not
backgrounded like `historie_importieren` (a CSV file is small, the whole point is that the frontend gets an
immediate, precise result). Result published through the existing `sensor.audi_dashboard_import_status`
entity (`k.import_status_veroeffentlichen("csv_fertig", {...})`) rather than inventing a second status
entity for what is, in HIG terms, the same kind of event.
**Fahrten/Tankvorgänge have a real ID already (`trip_id`/`tank_id`) - now exported as a new first CSV
column.** Matched rows go through the existing, already-safe `fahrterkennung.aktualisieren()` (its
`_handfelder()` already drops `None` values, an untouched field was never at risk there) and
`belege.tankvorgang_aktualisieren()`. **The second one was not already safe** - found while designing this:
it unconditionally overwrote every one of its ~9 fields with `daten.get(x)`, `None` included, whenever any
one of them changed (a gap flagged but deliberately left alone during the earlier bug audit in this same
session, since nothing had needed the fix yet). CSV import needed it fixed for real, so it now only touches
keys actually present in `daten` (`"key" in daten`, not `daten.get(key)`) - verified this doesn't change the
existing manual-edit-form caller's behavior, since that caller already sends every key on every save
regardless of whether the value is empty.
**Wartungsplan has no ID at all** - entries live as a plain list inside the vehicle profile
(`profil["service"]["buch"]`), addressed elsewhere in the app only by list index (`vSbuch()`/`Servicebuch()`
in both frontends already work this way). Retrofitting a real ID was judged bigger than this feature
warranted (touches delete/edit routing in both codebases). CSV import for this dataset instead matches on
**Datum+Art** as a natural key - documented in `csv_import.py`'s own docstring as a known, accepted
limitation: editing either of those two columns for an existing row makes it unmatchable and creates a
duplicate instead of updating.
Verified end-to-end, live, in the browser against `audi_ha_test` (not just compiled/typechecked - this
file's own section S lesson) - created and then updated one record of each of the three CSV-backed types via
synthetic `File`/`DataTransfer` dispatch through the real popup/file-input flow, confirmed via the actual
published entity data each time, then deleted the test records afterward:
- Fahrten: created (`1 neu angelegt`), then updated the same `trip_id` (`1 aktualisiert`, `distance_km`
22→60) while `odo_end`/`start_lat`/`route`/`source` all stayed byte-identical - the core guarantee, proven,
not assumed.
- Tankvorgänge: created with de-DE-formatted `"45,00"`/`"1,75"`/`"78,75"` (parsed correctly to `45`/`1.75`/
`78.75`), then updated `station_name` by `tank_id` while `odometer_km` stayed untouched - the exact case
`belege.tankvorgang_aktualisieren()`'s fix was for.
- Wartungsplan: created, then updated by Datum+Art match (`km`/`Werkstatt`/`Kosten` changed, `buchLength`
stayed 1 - no duplicate).
**One real bug found and fixed during this live testing, not by code review**: the first version read the
result via `HASS.states[E.importStatus]` immediately inside the service call's `.then()` - a race, since a
`state_changed` push arrives over its own websocket message and isn't guaranteed to have landed in the
frontend's local state cache by the time the service-call promise resolves. Reproduced live: the popup said
"Keine gültigen Zeilen gefunden" even though the backend had genuinely created the record (confirmed via the
entity's own fresh state). Fixed by switching to the existing `importStatusLesen()` helper (already used by
`importAusloesen()` for `historie_importieren`, for the identical reason) - it fetches fresh via
`HASS.callWS({type:"get_states"})`, bypassing the local cache race entirely. Re-tested after the fix:
correct result every time.
Verified: `node --check` clean on the panel; companion-app untouched, `tsc --noEmit` still clean. Manifest
bumped `2026.8.28.1``.2` (initial popup) → `.3` (the race-condition fix - a second restart was needed
specifically to see this fix, since the panel JS is cache-busted by manifest version, not by content hash).
`audi_ha_test` restarted at each step, confirmed clean via log every time.
---
## Working conventions (observed — keep them)