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)
+35 -19
View File
@@ -33,6 +33,7 @@ import os
from typing import TYPE_CHECKING
from . import shell_beleg_parser
from . import tankerkennung
from .tankerkennung import leerer_tankvorgang
from .veroeffentlichung import zustand_oder_none
from .verlauf import zeit_normalisiert
@@ -195,10 +196,10 @@ async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None:
odometer_km = km if km is not None else _km_aus_sensor(k)
distanz = daten.get("distanz")
if distanz is None:
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
# odometer_km selbst frei überschreibbar - ein übergebener Wert hat
# deshalb immer Vorrang vor der Berechnung.
distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
# Vorschlag aus TANK_DISTANZ_SENSOR oder, ohne ihn, der Differenz zum
# letzten Tankvorgang - aber wie odometer_km selbst frei
# überschreibbar, ein übergebener Wert hat deshalb immer Vorrang.
distanz = await tankerkennung.distanz_seit_tankung(k, odometer_km)
ts_roh = daten.get("ts")
ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC)
@@ -236,26 +237,41 @@ def _km_aus_sensor(k: Koordinator) -> float | None:
async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
ursprünglich per Beleg oder manuell angelegt)."""
ursprünglich per Beleg oder manuell angelegt).
Nur tatsächlich in `daten` enthaltene Schlüssel werden geändert - fehlt
ein Feld ganz (statt nur leer/None zu sein), bleibt der bisherige Wert
unangetastet. Das bestehende Bearbeiten-Formular (Einzelbeleg-Seite)
schickt ohnehin immer alle Felder mit, verhält sich also unverändert;
ein schlankerer Aufrufer wie der CSV-Import (csv_import.py) kann so
gezielt nur einzelne Spalten aktualisieren, ohne den Rest des
Datensatzes (odometer_km, discount, fuel_type, receipt_key, ...)
stillschweigend auf None zu setzen."""
tank_id = daten.get("tank_id")
if not tank_id:
_LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen")
return
liter = daten.get("liter")
kosten = daten.get("kosten")
ts_normalisiert = zeit_normalisiert(daten.get("ts"))
aenderungen = {
"ts": ts_normalisiert.isoformat() if ts_normalisiert else daten.get("ts"),
"liters": liter,
"fuel_total_eur": kosten,
"price_per_l": preis_je_liter(kosten, liter),
"odometer_km": daten.get("km"),
"discount": daten.get("ersparnis"),
"station_name": daten.get("station"),
"distance_km": daten.get("distanz"),
"fuel_type": daten.get("kraftstoff"),
}
aenderungen: dict = {}
if "ts" in daten:
ts_normalisiert = zeit_normalisiert(daten.get("ts"))
aenderungen["ts"] = ts_normalisiert.isoformat() if ts_normalisiert else daten.get("ts")
if "liter" in daten or "kosten" in daten:
liter = daten.get("liter")
kosten = daten.get("kosten")
aenderungen["liters"] = liter
aenderungen["fuel_total_eur"] = kosten
aenderungen["price_per_l"] = preis_je_liter(kosten, liter)
if "km" in daten:
aenderungen["odometer_km"] = daten.get("km")
if "ersparnis" in daten:
aenderungen["discount"] = daten.get("ersparnis")
if "station" in daten:
aenderungen["station_name"] = daten.get("station")
if "distanz" in daten:
aenderungen["distance_km"] = daten.get("distanz")
if "kraftstoff" in daten:
aenderungen["fuel_type"] = daten.get("kraftstoff")
if daten.get("receipt_key"):
aenderungen["receipt_key"] = daten["receipt_key"]
aenderungen["receipt_file"] = daten.get("receipt_file")
@@ -93,6 +93,7 @@ D_REIFEN_ARCHIV_LOESCHEN = "reifen_archiv_loeschen"
D_BACKUP_JETZT = "backup_jetzt"
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
D_HISTORIE_IMPORTIEREN = "historie_importieren"
D_CSV_IMPORTIEREN = "csv_importieren"
D_UPDATE_PRUEFEN = "update_pruefen"
D_UPDATE_INSTALLIEREN = "update_installieren"
@@ -0,0 +1,179 @@
"""CSV-Import für Fahrten, Tankvorgänge und Wartungsplan (Einstellungen ->
"Datensatz laden") - das Gegenstück zum bestehenden, rein clientseitigen
CSV-Export (beide Frontends: der "Datensatz sichern"-Popup baut die Datei
direkt im Browser aus den schon geladenen Daten, ohne Dienstaufruf).
Format identisch zum Export: Semikolon als Trennzeichen, deutsches
Zahlenformat ("," als Dezimaltrennzeichen, optional "." als
Tausendertrennzeichen), UTF-8 mit BOM (wird beim Lesen übersprungen).
ABGLEICH STATT VOLLSTÄNDIGEM ERSATZ (Nutzerentscheidung): eine Zeile mit
bekannter ID aktualisiert NUR die in der CSV enthaltenen Spalten am
bestehenden Datensatz - alles andere (GPS, Route, edited_fields,
Beleg-Verknüpfung, ...) bleibt unangetastet, siehe
`belege.tankvorgang_aktualisieren()`s eigene Begründung. Eine Zeile ohne
oder mit unbekannter ID legt einen neuen Datensatz an - so lässt sich auch
eine von Hand in der Tabelle ergänzte Zeile importieren.
Wartungsplan hat, anders als Fahrten/Tankvorgänge, keine eigene ID - die
Einträge leben als einfache Liste im Fahrzeugprofil und werden bisher nur
über ihren Listenindex adressiert (siehe vSbuch()/Servicebuch() in beiden
Frontends). Der Abgleich läuft dort stattdessen über Datum+Art als
Ersatzschlüssel - eine Zeile, die eines von beidem ändert, wird deshalb
nicht wiedererkannt und legt einen neuen Eintrag an, statt den alten zu
aktualisieren. Für die kleinen, von Hand gepflegten Wartungsplan-Listen
dieses Projekts ist das ein bewusst akzeptierter Kompromiss gegenüber einer
eigens für diesen Zweck eingeführten ID.
"""
from __future__ import annotations
import csv
import io
import logging
from typing import TYPE_CHECKING
from . import belege, fahrterkennung
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
def _de_zahl(text: str | None) -> float | None:
""""1.234,5" -> 1234.5. None/leer -> None (Spalte nicht gesetzt, nicht
"auf 0 setzen")."""
text = (text or "").strip()
if not text:
return None
try:
return float(text.replace(".", "").replace(",", "."))
except ValueError:
return None
def _zeilen(inhalt: str) -> list[dict[str, str]]:
"""CSV-Text (inkl. Kopfzeile, optional mit BOM) als Liste von
Feldname->Text-Wörterbüchern, in der Reihenfolge der Kopfzeile. Ganz
leere Zeilen (z. B. eine Leerzeile am Dateiende) werden übersprungen."""
bereinigt = inhalt.lstrip("")
zeilen = list(csv.reader(io.StringIO(bereinigt), delimiter=";"))
if not zeilen:
return []
kopf = zeilen[0]
return [dict(zip(kopf, zeile)) for zeile in zeilen[1:] if any(f.strip() for f in zeile)]
async def fahrten_importieren(k: Koordinator, inhalt: str) -> dict:
bestehende = {f["trip_id"] for f in await k.ablage.fahrten_lesen()}
aktualisiert = angelegt = uebersprungen = 0
for zeile in _zeilen(inhalt):
trip_id = (zeile.get("ID") or "").strip()
start = zeile.get("Start") or None
ende = zeile.get("Ende") or None
art = zeile.get("Art") or None
km = _de_zahl(zeile.get("km"))
if trip_id and trip_id in bestehende:
await fahrterkennung.aktualisieren(k, {
"trip_id": trip_id, "ts_start": start, "ts_end": ende,
"art": art, "distanz": km,
})
aktualisiert += 1
elif start and ende:
await fahrterkennung.manuell_anlegen(k, {
"ts_start": start, "ts_end": ende, "art": art, "distanz": km,
})
angelegt += 1
else:
_LOGGER.warning("CSV-Zeile (Fahrten) ohne Start/Ende übersprungen: %s", zeile)
uebersprungen += 1
return {"aktualisiert": aktualisiert, "angelegt": angelegt, "uebersprungen": uebersprungen}
async def tankvorgaenge_importieren(k: Koordinator, inhalt: str) -> dict:
bestehende = {t["tank_id"] for t in await k.ablage.tankvorgaenge_lesen()}
aktualisiert = angelegt = uebersprungen = 0
for zeile in _zeilen(inhalt):
tank_id = (zeile.get("ID") or "").strip()
ts = zeile.get("Zeitpunkt") or None
station = zeile.get("Station") or None
liter = _de_zahl(zeile.get("Liter"))
kosten = _de_zahl(zeile.get("Kosten €"))
if tank_id and tank_id in bestehende:
daten: dict = {"tank_id": tank_id}
if ts is not None:
daten["ts"] = ts
if liter is not None or kosten is not None:
daten["liter"] = liter
daten["kosten"] = kosten
if station is not None:
daten["station"] = station
await belege.tankvorgang_aktualisieren(k, daten)
aktualisiert += 1
elif ts and liter is not None and kosten is not None:
await belege.tankvorgang_manuell(k, {
"ts": ts, "liter": liter, "kosten": kosten, "station": station,
})
angelegt += 1
else:
_LOGGER.warning(
"CSV-Zeile (Tankvorgänge) ohne Zeitpunkt/Liter/Kosten übersprungen: %s", zeile
)
uebersprungen += 1
return {"aktualisiert": aktualisiert, "angelegt": angelegt, "uebersprungen": uebersprungen}
async def wartungsplan_importieren(k: Koordinator, inhalt: str) -> dict:
profil = await k.ablage.profil_lesen()
if profil is None:
return {"aktualisiert": 0, "angelegt": 0, "uebersprungen": 0}
buch = profil.setdefault("service", {}).setdefault("buch", [])
aktualisiert = angelegt = uebersprungen = 0
for zeile in _zeilen(inhalt):
datum = zeile.get("Datum") or None
art = zeile.get("Art") or None
if not datum or not art:
_LOGGER.warning("CSV-Zeile (Wartungsplan) ohne Datum/Art übersprungen: %s", zeile)
uebersprungen += 1
continue
km = _de_zahl(zeile.get("km"))
werkstatt = zeile.get("Werkstatt") or None
kosten = _de_zahl(zeile.get("Kosten €"))
treffer = next(
(e for e in buch if e.get("datum") == datum and e.get("art") == art), None
)
if treffer is not None:
if km is not None:
treffer["km"] = km
if werkstatt is not None:
treffer["werkstatt"] = werkstatt
if kosten is not None:
treffer["kosten"] = kosten
aktualisiert += 1
else:
buch.append({
"datum": datum, "km": km or 0, "art": art,
"werkstatt": werkstatt or "", "kosten": kosten, "arbeiten": "",
})
angelegt += 1
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen()
return {"aktualisiert": aktualisiert, "angelegt": angelegt, "uebersprungen": uebersprungen}
IMPORTIERER = {
"fahrten": fahrten_importieren,
"tanken": tankvorgaenge_importieren,
"service": wartungsplan_importieren,
}
async def importieren(k: Koordinator, art: str, inhalt: str) -> dict:
funktion = IMPORTIERER.get(art)
if funktion is None:
_LOGGER.warning("csv_importieren mit unbekannter Art aufgerufen: %s", art)
return {"aktualisiert": 0, "angelegt": 0, "uebersprungen": 0}
ergebnis = await funktion(k, inhalt)
_LOGGER.info("CSV-Import (%s) abgeschlossen - %s", art, ergebnis)
return ergebnis
@@ -31,6 +31,7 @@ from . import (
batterie,
belege,
bilder,
csv_import,
fahrterkennung,
historienimport,
reifen,
@@ -46,6 +47,7 @@ from .const import (
D_BELEG_HOCHLADEN,
D_BILD_HOCHLADEN,
D_BILD_LOESCHEN,
D_CSV_IMPORTIEREN,
D_ENTITAETEN_SCHREIBEN,
D_FAHRT_AKTUALISIEREN,
D_FAHRT_LOESCHEN,
@@ -165,6 +167,10 @@ SCHEMATA: dict[str, vol.Schema] = {
vol.Required("start"): cv.string,
vol.Required("ende"): cv.string,
}),
D_CSV_IMPORTIEREN: vol.Schema({
vol.Required("art"): vol.In(["fahrten", "tanken", "service"]),
vol.Required("inhalt"): cv.string,
}),
D_UPDATE_PRUEFEN: vol.Schema({}),
D_UPDATE_INSTALLIEREN: vol.Schema({}),
}
@@ -243,6 +249,15 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
"audi_dashboard_historienimport",
)
async def csv_importieren(aufruf: ServiceCall) -> None:
"""Liest eine per "Datensatz laden" hochgeladene CSV-Datei ein (siehe
csv_import.py). Läuft synchron statt im Hintergrund wie
historie_importieren - eine einzelne CSV-Datei ist klein, der Aufruf
kehrt erst zurück, wenn das Ergebnis auch wirklich in
sensor.audi_dashboard_import_status steht, kein Abfragen nötig."""
ergebnis = await csv_import.importieren(k, aufruf.data["art"], aufruf.data["inhalt"])
await k.import_status_veroeffentlichen("csv_fertig", {"art": aufruf.data["art"], **ergebnis})
async def update_pruefen(_aufruf: ServiceCall) -> None:
"""Fragt bei Gitea nach, ob eine neuere Fassung vorliegt - ohne
etwas herunterzuladen. Läuft nur auf Tastendruck, siehe
@@ -311,6 +326,7 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
D_PROFIL_SCHREIBEN: profil_schreiben,
D_ENTITAETEN_SCHREIBEN: entitaeten_schreiben,
D_HISTORIE_IMPORTIEREN: historie_importieren,
D_CSV_IMPORTIEREN: csv_importieren,
D_SCREENING_JETZT: _als_dienst(lambda _a: screening.durchfuehren(k)),
D_FAHRT_MANUELL_ANLEGEN: _als_dienst(
lambda a: fahrterkennung.manuell_anlegen(k, dict(a.data))
@@ -72,6 +72,16 @@ class Sensorzuordnung:
# Vorbelegung für die getankte Menge (siehe tankerkennung.py).
TANK_LITER_SENSOR: str = ""
# Vom Fahrzeug selbst berechnete Strecke seit der letzten Tankung
# (FMM003: "calculated distance covered after the latest refueling").
# Optional - ist er gesetzt, übernimmt sein aktueller Wert das Feld
# distance_km eines neu erkannten Tankvorgangs anstelle der eigenen
# Kilometerstand-Subtraktion (ablage.distanz_seit_letzter_tankung()) -
# direkt vom Fahrzeug gemeldet statt selbst gerechnet, und robust gegen
# Lücken im eigenen Bestand. Ohne ihn bleibt die bisherige Berechnung
# unverändert der Rückfall.
TANK_DISTANZ_SENSOR: str = ""
# Reichweite (Übersicht).
RANGE_SENSOR: str = ""
@@ -163,6 +173,11 @@ FELDER: list[dict] = [
"Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.",
"domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False,
"stichworte": ["tankvolumen", "liter", "litres", "fuel volume", "kraftstoffvolumen"]},
{"key": "TANK_DISTANZ_SENSOR", "label": "Strecke seit letzter Tankung", "gruppe": "fahrterkennung",
"hinweis": "Optional, vom Fahrzeug selbst berechnet - ersetzt die eigene Kilometerstand-Rechnung "
"für die Strecke eines Tankvorgangs.",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
"stichworte": ["strecke tankung", "distance refuel", "distance since refueling", "refuelling"]},
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
@@ -2622,11 +2622,12 @@ function vEinst() {
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur
diese Datei getauscht die App selbst bleibt unverändert.</span>
<button class="aktion" data-profil="export">Profil sichern</button>
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
Profil laden<input type="file" accept="application/json" data-profil="import" hidden></label>
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen eine
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
unangetastet.</span>
<button class="aktion" data-datensatz="sichern">Datensatz sichern</button>
<button class="aktion" data-datensatz="laden">Datensatz laden</button>
</div>
<div class="tile"><span class="label">Backup</span>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
@@ -2646,14 +2647,6 @@ function vEinst() {
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
</div>
<div class="tile"><span class="label">Daten ausgeben</span>
<span style="display:block;margin-top:10px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in
Tabellenprogrammen aufgehen.</span>
<button class="aktion" data-csv="fahrten">Fahrten als CSV</button>
<button class="aktion" data-csv="tanken">Tankvorgänge als CSV</button>
<button class="aktion" data-csv="service">Wartungsplan als CSV</button>
</div>
<div class="tile"><span class="label">Version</span>
<dl class="rows">
<div class="row" style="border-bottom:0"><dt>Installiert</dt><dd>${esc(APP_VERSION || "unbekannt")}<small>Home-Assistant-Integration</small></dd></div>
@@ -3904,6 +3897,132 @@ function vImportPopup() {
</div>`;
}
/* ------------------------------------------------- Datensatz-Popup
"Fahrzeugprofil"-Kachel in den Einstellungen: "Datensatz sichern" öffnet
vier sofortige, rein clientseitige Exporte (Fahrzeugprofil als JSON,
Fahrten/Tankvorgänge/Wartungsplan als CSV - identisch zum bisherigen
Verhalten der jetzt zusammengelegten "Fahrzeugprofil"/"Daten
ausgeben"-Kacheln). "Datensatz laden" spiegelt dieselben vier Dateien als
Datei-Auswahl - Fahrzeugprofil läuft wie zuvor über den bestehenden
profil_schreiben-Dienst, die drei CSV-Datensätze über den neuen
csv_importieren-Dienst (csv_import.py): eine Zeile mit bekannter ID
aktualisiert nur ihre eigenen Spalten, alles andere am Datensatz bleibt
unangetastet - siehe dessen Moduldocstring für die Begründung. */
let datensatzPopup = null; // { modus: "sichern" | "laden" } | null
let datensatzLaeuft = null; // welcher Datensatz-Import gerade läuft, oder null
let datensatzErgebnis = null; // { art, ... } des zuletzt gelaufenen Imports, oder null
let datensatzFehler = null;
const DATENSATZ_ARTEN = [
["fahrzeugprofil", "Fahrzeugprofil"],
["fahrten", "Fahrten"],
["tanken", "Tankvorgänge"],
["service", "Wartungsplan"],
];
function datensatzExportieren(art) {
if (art === "fahrzeugprofil") {
const a = document.createElement("a");
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
} else if (art === "fahrten") {
csvDatei("fahrten.csv", ["ID", "Start", "Ende", "km", "Art", "Status"],
TRIPS.map((t) => [t.trip_id, t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
} else if (art === "tanken") {
csvDatei("tankvorgaenge.csv", ["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
FILLS.map((f) => [f.tank_id, f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
} else if (art === "service") {
csvDatei("wartungsplan.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
}
}
function datensatzErgebnisText(e) {
if (e.art === "fahrzeugprofil") return "Fahrzeugprofil geladen.";
const teile = [];
if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`);
if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`);
if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`);
return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden.";
}
function datensatzImportieren(art, datei) {
const leser = new FileReader();
leser.onerror = () => {
datensatzLaeuft = null;
datensatzFehler = "Die Datei ließ sich nicht lesen.";
render();
};
leser.onload = () => {
if (art === "fahrzeugprofil") {
try {
const p = JSON.parse(leser.result);
PROFIL_ROH = p; CONFIG = profilZuConfig(p);
CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) });
bild = 0;
datensatzLaeuft = null; datensatzErgebnis = { art }; datensatzFehler = null;
} catch (err) {
datensatzLaeuft = null;
datensatzFehler = "Die Datei ließ sich nicht lesen: " + err.message;
}
render();
return;
}
HASS.callService(DOMAIN, "csv_importieren", { art, inhalt: leser.result })
.then(async () => {
// importStatusLesen() fragt den Zustand frisch per get_states ab,
// statt aus dem lokal zwischengespeicherten HASS.states zu lesen -
// dessen state_changed-Update kommt über eine eigene
// Websocket-Nachricht rein und muss dem Rückgabewert dieses
// Dienstaufrufs nicht unbedingt schon vorausgegangen sein (dieselbe
// Begründung wie bei importAusloesen() oben, nur ohne dessen
// Warteschleife - der CSV-Import läuft synchron im Backend, bis
// hierhin ist der neue Stand schon veröffentlicht).
const s = await importStatusLesen();
datensatzLaeuft = null;
datensatzErgebnis = { art, ...((s && s.attributes && s.attributes.daten) || {}) };
datensatzFehler = null;
render();
datenAktualisieren();
})
.catch((err) => {
datensatzLaeuft = null;
datensatzFehler = "Der Import ist fehlgeschlagen: " + ((err && err.message) || "unbekannter Fehler");
render();
});
};
datensatzLaeuft = art;
datensatzErgebnis = null; datensatzFehler = null;
render();
leser.readAsText(datei);
}
function vDatensatzPopup() {
if (!datensatzPopup) return "";
const sichern = datensatzPopup.modus === "sichern";
return `<div class="beleg-catcher" data-datensatzzu></div>
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="${sichern ? "Datensatz sichern" : "Datensatz laden"}">
<span class="label">${sichern ? "Datensatz sichern" : "Datensatz laden"}</span>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
${sichern
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
: "Eine zuvor gesicherte, ggf. bearbeitete Datei wieder einspielen - eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet."}
</span>
${DATENSATZ_ARTEN.map(([art, label]) => sichern
? `<button class="aktion" style="margin-top:12px" data-datensatzsichern="${art}">${label}${art === "fahrzeugprofil" ? " sichern" : " als CSV"}</button>`
: `<label class="aktion" style="display:block;text-align:center;cursor:pointer;margin-top:12px">
${datensatzLaeuft === art ? "Lädt …" : label + (art === "fahrzeugprofil" ? " laden" : " (CSV) laden")}
<input type="file" accept="${art === "fahrzeugprofil" ? "application/json" : "text/csv"}"
data-datensatzladen="${art}" hidden ${datensatzLaeuft ? "disabled" : ""}></label>`
).join("")}
${datensatzErgebnis ? `<div style="margin-top:14px;font-size:13.5px;line-height:1.6;color:var(--fg)">${esc(datensatzErgebnisText(datensatzErgebnis))}</div>` : ""}
${datensatzFehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(datensatzFehler)}</span>` : ""}
<button class="aktion primaer" style="margin-top:16px" data-datensatzzu>Fertig</button>
</div>`;
}
function vBelegPopup() {
if (!belegPopup) return "";
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
@@ -4090,7 +4209,7 @@ function render() {
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup();
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup() + vDatensatzPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
@@ -4824,6 +4943,18 @@ function ereignisseVerdrahten() {
importPopup = null; render(); return;
}
if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
const dsOeffnen = e.target.closest("[data-datensatz]");
if (dsOeffnen) {
datensatzPopup = { modus: dsOeffnen.dataset.datensatz };
datensatzLaeuft = null; datensatzErgebnis = null; datensatzFehler = null;
render(); return;
}
if (e.target.closest("[data-datensatzzu]")) {
if (datensatzLaeuft) return;
datensatzPopup = null; render(); return;
}
const dsExport = e.target.closest("[data-datensatzsichern]");
if (dsExport) { datensatzExportieren(dsExport.dataset.datensatzsichern); return; }
const del = e.target.closest("[data-loeschen]");
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
const vseldel = e.target.closest("[data-vselbstdel]");
@@ -4833,13 +4964,6 @@ function ereignisseVerdrahten() {
if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; }
const mode = e.target.closest("[data-mode]");
if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; }
const pr = e.target.closest("[data-profil]");
if (pr && pr.dataset.profil === "export") {
const a = document.createElement("a");
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
return;
}
const bk = e.target.closest("[data-backup]");
if (bk && bk.dataset.backup === "jetzt") { serviceRufen("backup_jetzt", {}); return; }
if (bk && bk.dataset.backup === "export") {
@@ -4895,17 +5019,6 @@ function ereignisseVerdrahten() {
}
return;
}
const cs = e.target.closest("[data-csv]");
if (cs) {
const w = cs.dataset.csv;
if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"],
TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
else csvDatei("servicebuch.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
return;
}
const satz = e.target.closest("[data-satz]");
if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; }
const rfArch = e.target.closest("[data-reifenarchivieren]");
@@ -5007,18 +5120,9 @@ function ereignisseVerdrahten() {
});
ROOT.addEventListener("change", (e) => {
if (e.target.dataset.profil === "import") {
if (e.target.dataset.datensatzladen) {
const datei = e.target.files[0]; if (!datei) return;
const leser = new FileReader();
leser.onload = () => {
try {
const p = JSON.parse(leser.result);
PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) });
bild = 0; render();
} catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); }
};
leser.readAsText(datei);
datensatzImportieren(e.target.dataset.datensatzladen, datei);
return;
}
if (e.target.dataset.backup === "import") {
@@ -231,9 +231,15 @@ async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
vorhandenen älteren Tankvorgänge stillschweigend verlieren, obwohl der
Prozentsensor sie längst hätte erkennen können. Beide Läufe teilen sich
`bekannte_zeiten`/`neue`, damit derselbe physische Tankvorgang nicht
doppelt entsteht, falls sich die beiden Verläufe zeitlich überschneiden."""
doppelt entsteht, falls sich die beiden Verläufe zeitlich überschneiden.
distance_km bevorzugt ebenso TANK_DISTANZ_SENSOR (nächstliegender Wert
vor dem erkannten Tankvorgang) vor der eigenen Kilometerstand-Subtraktion
(_distanz_zum_vorherigen()) - dieselbe Präferenz wie im Live-Pfad
(tankerkennung.distanz_seit_tankung())."""
prozent_verlauf = verlaeufe["tank"]
liter_verlauf = verlaeufe["tank_liter"] if k.zuordnung.werte.TANK_LITER_SENSOR else []
distanz_verlauf = verlaeufe["tank_distanz"] if k.zuordnung.werte.TANK_DISTANZ_SENSOR else []
if not prozent_verlauf and not liter_verlauf:
return {"angelegt": 0, "uebersprungen": 0}
@@ -282,8 +288,10 @@ async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
tankvorgang["odometer_km"] = odometer_km
if mit_liter:
tankvorgang["liters"] = round(anstieg, 1)
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
ts.isoformat(), odometer_km, neue + bestehende
distanz_sensor = wert_bei(distanz_verlauf, ts) if distanz_verlauf else None
tankvorgang["distance_km"] = (
distanz_sensor if distanz_sensor is not None
else _distanz_zum_vorherigen(ts.isoformat(), odometer_km, neue + bestehende)
)
neue.append(tankvorgang)
bekannte_zeiten.append(ts)
@@ -426,6 +434,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis),
"tank_liter": await verlauf_lesen(k.hass, werte.TANK_LITER_SENSOR, von, bis),
"tank_distanz": await verlauf_lesen(k.hass, werte.TANK_DISTANZ_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),
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.28.1",
"version": "2026.8.28.3",
"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"],
@@ -313,3 +313,20 @@ historie_importieren:
example: "2026-08-23T00:00:00"
selector:
text:
csv_importieren:
fields:
art:
required: true
example: "fahrten"
selector:
select:
options:
- "fahrten"
- "tanken"
- "service"
inhalt:
required: true
selector:
text:
multiline: true
@@ -143,6 +143,17 @@ async def tankvolumen_geaendert(k: Koordinator, neu: str | None) -> None:
await k.tiefststand_liter_setzen(aktuell)
async def distanz_seit_tankung(k: Koordinator, odometer_km: float | None) -> float | None:
"""Bevorzugt TANK_DISTANZ_SENSOR (vom Fahrzeug selbst berechnet), fällt
ohne ihn auf die eigene Kilometerstand-Subtraktion zurück - dieselbe
"Sensor bevorzugt, Berechnung als Rückfall"-Präferenz wie bei
TANK_LITER_SENSOR gegenüber TANK_SENSOR oben."""
sensor = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.TANK_DISTANZ_SENSOR))
if sensor is not None:
return sensor
return await k.ablage.distanz_seit_letzter_tankung(odometer_km)
async def _automatisch_anlegen(
k: Koordinator, anlass: str, liter_schaetzung: float | None = None
) -> None:
@@ -151,7 +162,7 @@ async def _automatisch_anlegen(
datetime.datetime.now(datetime.UTC).isoformat(), "auto"
)
tankvorgang["odometer_km"] = odometer_km
tankvorgang["distance_km"] = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
tankvorgang["distance_km"] = await distanz_seit_tankung(k, odometer_km)
if liter_schaetzung is not None:
# Grobe Vorbelegung, keine feststehende Menge - der Status bleibt
# "unvollständig" (siehe leerer_tankvorgang), bis Preis/Tankstelle