Files
audi-app/custom_components/audi_dashboard/csv_import.py
T
tobias bfb7dec1e9 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>
2026-08-28 10:19:06 +02:00

180 lines
7.2 KiB
Python

"""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