diff --git a/AGENTS.md b/AGENTS.md index b7f63c8..1872893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/custom_components/audi_dashboard/belege.py b/custom_components/audi_dashboard/belege.py index 6909f17..081f124 100644 --- a/custom_components/audi_dashboard/belege.py +++ b/custom_components/audi_dashboard/belege.py @@ -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") diff --git a/custom_components/audi_dashboard/const.py b/custom_components/audi_dashboard/const.py index 797fade..3d5d536 100644 --- a/custom_components/audi_dashboard/const.py +++ b/custom_components/audi_dashboard/const.py @@ -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" diff --git a/custom_components/audi_dashboard/csv_import.py b/custom_components/audi_dashboard/csv_import.py new file mode 100644 index 0000000..8a15923 --- /dev/null +++ b/custom_components/audi_dashboard/csv_import.py @@ -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 diff --git a/custom_components/audi_dashboard/dienste.py b/custom_components/audi_dashboard/dienste.py index 480f0e3..851e529 100644 --- a/custom_components/audi_dashboard/dienste.py +++ b/custom_components/audi_dashboard/dienste.py @@ -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)) diff --git a/custom_components/audi_dashboard/einstellungen.py b/custom_components/audi_dashboard/einstellungen.py index ad7ce88..bf51b92 100644 --- a/custom_components/audi_dashboard/einstellungen.py +++ b/custom_components/audi_dashboard/einstellungen.py @@ -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, diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index f2aef41..d9bd9ca 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -2622,11 +2622,12 @@ function vEinst() { 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. - - + 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. + +
Backup Backup importieren
-
Daten ausgeben - - Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in - Tabellenprogrammen aufgehen. - - - -
Version
Installiert
${esc(APP_VERSION || "unbekannt")}Home-Assistant-Integration
@@ -3904,6 +3897,132 @@ function vImportPopup() {
`; } +/* ------------------------------------------------- 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 `
+ `; +} + 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") { diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py index f46e7b4..fb7b2f0 100644 --- a/custom_components/audi_dashboard/historienimport.py +++ b/custom_components/audi_dashboard/historienimport.py @@ -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), diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 115c350..01d5900 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.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"], diff --git a/custom_components/audi_dashboard/services.yaml b/custom_components/audi_dashboard/services.yaml index 0307f89..e87f696 100644 --- a/custom_components/audi_dashboard/services.yaml +++ b/custom_components/audi_dashboard/services.yaml @@ -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 diff --git a/custom_components/audi_dashboard/tankerkennung.py b/custom_components/audi_dashboard/tankerkennung.py index 35b14a8..8b507a1 100644 --- a/custom_components/audi_dashboard/tankerkennung.py +++ b/custom_components/audi_dashboard/tankerkennung.py @@ -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