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:
@@ -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
|
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
|
optional sensor role, preferred over the calculation when mapped, calculation stays as the fallback when
|
||||||
unmapped. Owner to confirm before this is built.
|
unmapped. Owner to confirm before this is built.
|
||||||
- **Item 9**: combine the "Fahrzeugprofil" tile (`data-profil="export"`/`"import"`, JSON) and "Daten ausgeben"
|
- **Item 9: done on the panel, see section AC below - companion-app port still pending** (parity rule).
|
||||||
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 11**: Setup-Menü sensor role fields - replace the current description text under each field's
|
- **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
|
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
|
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
|
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).
|
(`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)
|
## Working conventions (observed — keep them)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import os
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from . import shell_beleg_parser
|
from . import shell_beleg_parser
|
||||||
|
from . import tankerkennung
|
||||||
from .tankerkennung import leerer_tankvorgang
|
from .tankerkennung import leerer_tankvorgang
|
||||||
from .veroeffentlichung import zustand_oder_none
|
from .veroeffentlichung import zustand_oder_none
|
||||||
from .verlauf import zeit_normalisiert
|
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)
|
odometer_km = km if km is not None else _km_aus_sensor(k)
|
||||||
distanz = daten.get("distanz")
|
distanz = daten.get("distanz")
|
||||||
if distanz is None:
|
if distanz is None:
|
||||||
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
|
# Vorschlag aus TANK_DISTANZ_SENSOR oder, ohne ihn, der Differenz zum
|
||||||
# odometer_km selbst frei überschreibbar - ein übergebener Wert hat
|
# letzten Tankvorgang - aber wie odometer_km selbst frei
|
||||||
# deshalb immer Vorrang vor der Berechnung.
|
# überschreibbar, ein übergebener Wert hat deshalb immer Vorrang.
|
||||||
distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
|
distanz = await tankerkennung.distanz_seit_tankung(k, odometer_km)
|
||||||
|
|
||||||
ts_roh = daten.get("ts")
|
ts_roh = daten.get("ts")
|
||||||
ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC)
|
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:
|
async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
|
||||||
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
|
"""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")
|
tank_id = daten.get("tank_id")
|
||||||
if not tank_id:
|
if not tank_id:
|
||||||
_LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen")
|
_LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen")
|
||||||
return
|
return
|
||||||
|
|
||||||
liter = daten.get("liter")
|
aenderungen: dict = {}
|
||||||
kosten = daten.get("kosten")
|
if "ts" in daten:
|
||||||
ts_normalisiert = zeit_normalisiert(daten.get("ts"))
|
ts_normalisiert = zeit_normalisiert(daten.get("ts"))
|
||||||
aenderungen = {
|
aenderungen["ts"] = ts_normalisiert.isoformat() if ts_normalisiert else daten.get("ts")
|
||||||
"ts": ts_normalisiert.isoformat() if ts_normalisiert else daten.get("ts"),
|
if "liter" in daten or "kosten" in daten:
|
||||||
"liters": liter,
|
liter = daten.get("liter")
|
||||||
"fuel_total_eur": kosten,
|
kosten = daten.get("kosten")
|
||||||
"price_per_l": preis_je_liter(kosten, liter),
|
aenderungen["liters"] = liter
|
||||||
"odometer_km": daten.get("km"),
|
aenderungen["fuel_total_eur"] = kosten
|
||||||
"discount": daten.get("ersparnis"),
|
aenderungen["price_per_l"] = preis_je_liter(kosten, liter)
|
||||||
"station_name": daten.get("station"),
|
if "km" in daten:
|
||||||
"distance_km": daten.get("distanz"),
|
aenderungen["odometer_km"] = daten.get("km")
|
||||||
"fuel_type": daten.get("kraftstoff"),
|
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"):
|
if daten.get("receipt_key"):
|
||||||
aenderungen["receipt_key"] = daten["receipt_key"]
|
aenderungen["receipt_key"] = daten["receipt_key"]
|
||||||
aenderungen["receipt_file"] = daten.get("receipt_file")
|
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_JETZT = "backup_jetzt"
|
||||||
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
|
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
|
||||||
D_HISTORIE_IMPORTIEREN = "historie_importieren"
|
D_HISTORIE_IMPORTIEREN = "historie_importieren"
|
||||||
|
D_CSV_IMPORTIEREN = "csv_importieren"
|
||||||
D_UPDATE_PRUEFEN = "update_pruefen"
|
D_UPDATE_PRUEFEN = "update_pruefen"
|
||||||
D_UPDATE_INSTALLIEREN = "update_installieren"
|
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,
|
batterie,
|
||||||
belege,
|
belege,
|
||||||
bilder,
|
bilder,
|
||||||
|
csv_import,
|
||||||
fahrterkennung,
|
fahrterkennung,
|
||||||
historienimport,
|
historienimport,
|
||||||
reifen,
|
reifen,
|
||||||
@@ -46,6 +47,7 @@ from .const import (
|
|||||||
D_BELEG_HOCHLADEN,
|
D_BELEG_HOCHLADEN,
|
||||||
D_BILD_HOCHLADEN,
|
D_BILD_HOCHLADEN,
|
||||||
D_BILD_LOESCHEN,
|
D_BILD_LOESCHEN,
|
||||||
|
D_CSV_IMPORTIEREN,
|
||||||
D_ENTITAETEN_SCHREIBEN,
|
D_ENTITAETEN_SCHREIBEN,
|
||||||
D_FAHRT_AKTUALISIEREN,
|
D_FAHRT_AKTUALISIEREN,
|
||||||
D_FAHRT_LOESCHEN,
|
D_FAHRT_LOESCHEN,
|
||||||
@@ -165,6 +167,10 @@ SCHEMATA: dict[str, vol.Schema] = {
|
|||||||
vol.Required("start"): cv.string,
|
vol.Required("start"): cv.string,
|
||||||
vol.Required("ende"): 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_PRUEFEN: vol.Schema({}),
|
||||||
D_UPDATE_INSTALLIEREN: vol.Schema({}),
|
D_UPDATE_INSTALLIEREN: vol.Schema({}),
|
||||||
}
|
}
|
||||||
@@ -243,6 +249,15 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
|||||||
"audi_dashboard_historienimport",
|
"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:
|
async def update_pruefen(_aufruf: ServiceCall) -> None:
|
||||||
"""Fragt bei Gitea nach, ob eine neuere Fassung vorliegt - ohne
|
"""Fragt bei Gitea nach, ob eine neuere Fassung vorliegt - ohne
|
||||||
etwas herunterzuladen. Läuft nur auf Tastendruck, siehe
|
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_PROFIL_SCHREIBEN: profil_schreiben,
|
||||||
D_ENTITAETEN_SCHREIBEN: entitaeten_schreiben,
|
D_ENTITAETEN_SCHREIBEN: entitaeten_schreiben,
|
||||||
D_HISTORIE_IMPORTIEREN: historie_importieren,
|
D_HISTORIE_IMPORTIEREN: historie_importieren,
|
||||||
|
D_CSV_IMPORTIEREN: csv_importieren,
|
||||||
D_SCREENING_JETZT: _als_dienst(lambda _a: screening.durchfuehren(k)),
|
D_SCREENING_JETZT: _als_dienst(lambda _a: screening.durchfuehren(k)),
|
||||||
D_FAHRT_MANUELL_ANLEGEN: _als_dienst(
|
D_FAHRT_MANUELL_ANLEGEN: _als_dienst(
|
||||||
lambda a: fahrterkennung.manuell_anlegen(k, dict(a.data))
|
lambda a: fahrterkennung.manuell_anlegen(k, dict(a.data))
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ class Sensorzuordnung:
|
|||||||
# Vorbelegung für die getankte Menge (siehe tankerkennung.py).
|
# Vorbelegung für die getankte Menge (siehe tankerkennung.py).
|
||||||
TANK_LITER_SENSOR: str = ""
|
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).
|
# Reichweite (Übersicht).
|
||||||
RANGE_SENSOR: str = ""
|
RANGE_SENSOR: str = ""
|
||||||
|
|
||||||
@@ -163,6 +173,11 @@ FELDER: list[dict] = [
|
|||||||
"Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.",
|
"Tankerkennung genauer und liefert eine Vorbelegung für die getankte Menge.",
|
||||||
"domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False,
|
"domains": ["sensor"], "device_classes": ["volume"], "units": ["L", "l"], "liste": False, "pflicht": False,
|
||||||
"stichworte": ["tankvolumen", "liter", "litres", "fuel volume", "kraftstoffvolumen"]},
|
"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",
|
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
|
||||||
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
|
"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,
|
"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;
|
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
||||||
text-transform:none;font-size:12.5px;line-height:1.6">
|
text-transform:none;font-size:12.5px;line-height:1.6">
|
||||||
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
|
||||||
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur
|
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
|
||||||
diese Datei getauscht — die App selbst bleibt unverändert.</span>
|
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen — eine
|
||||||
<button class="aktion" data-profil="export">Profil sichern</button>
|
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
|
||||||
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
|
unangetastet.</span>
|
||||||
Profil laden<input type="file" accept="application/json" data-profil="import" hidden></label>
|
<button class="aktion" data-datensatz="sichern">Datensatz sichern</button>
|
||||||
|
<button class="aktion" data-datensatz="laden">Datensatz laden</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile"><span class="label">Backup</span>
|
<div class="tile"><span class="label">Backup</span>
|
||||||
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
|
<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">
|
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
|
||||||
Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
|
Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
|
||||||
</div>
|
</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>
|
<div class="tile"><span class="label">Version</span>
|
||||||
<dl class="rows">
|
<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>
|
<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>`;
|
</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() {
|
function vBelegPopup() {
|
||||||
if (!belegPopup) return "";
|
if (!belegPopup) return "";
|
||||||
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
|
// 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,
|
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
|
||||||
// auf 0 zurueck).
|
// auf 0 zurueck).
|
||||||
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
|
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) {
|
if (setupOffen) {
|
||||||
const koerper = ROOT.querySelector(".setup-koerper");
|
const koerper = ROOT.querySelector(".setup-koerper");
|
||||||
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
|
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
|
||||||
@@ -4824,6 +4943,18 @@ function ereignisseVerdrahten() {
|
|||||||
importPopup = null; render(); return;
|
importPopup = null; render(); return;
|
||||||
}
|
}
|
||||||
if (e.target.closest("[data-import-start]")) { importAusloesen(); 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]");
|
const del = e.target.closest("[data-loeschen]");
|
||||||
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
|
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
|
||||||
const vseldel = e.target.closest("[data-vselbstdel]");
|
const vseldel = e.target.closest("[data-vselbstdel]");
|
||||||
@@ -4833,13 +4964,6 @@ function ereignisseVerdrahten() {
|
|||||||
if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; }
|
if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; }
|
||||||
const mode = e.target.closest("[data-mode]");
|
const mode = e.target.closest("[data-mode]");
|
||||||
if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; }
|
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]");
|
const bk = e.target.closest("[data-backup]");
|
||||||
if (bk && bk.dataset.backup === "jetzt") { serviceRufen("backup_jetzt", {}); return; }
|
if (bk && bk.dataset.backup === "jetzt") { serviceRufen("backup_jetzt", {}); return; }
|
||||||
if (bk && bk.dataset.backup === "export") {
|
if (bk && bk.dataset.backup === "export") {
|
||||||
@@ -4895,17 +5019,6 @@ function ereignisseVerdrahten() {
|
|||||||
}
|
}
|
||||||
return;
|
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]");
|
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; }
|
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]");
|
const rfArch = e.target.closest("[data-reifenarchivieren]");
|
||||||
@@ -5007,18 +5120,9 @@ function ereignisseVerdrahten() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ROOT.addEventListener("change", (e) => {
|
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 datei = e.target.files[0]; if (!datei) return;
|
||||||
const leser = new FileReader();
|
datensatzImportieren(e.target.dataset.datensatzladen, datei);
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.target.dataset.backup === "import") {
|
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
|
vorhandenen älteren Tankvorgänge stillschweigend verlieren, obwohl der
|
||||||
Prozentsensor sie längst hätte erkennen können. Beide Läufe teilen sich
|
Prozentsensor sie längst hätte erkennen können. Beide Läufe teilen sich
|
||||||
`bekannte_zeiten`/`neue`, damit derselbe physische Tankvorgang nicht
|
`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"]
|
prozent_verlauf = verlaeufe["tank"]
|
||||||
liter_verlauf = verlaeufe["tank_liter"] if k.zuordnung.werte.TANK_LITER_SENSOR else []
|
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:
|
if not prozent_verlauf and not liter_verlauf:
|
||||||
return {"angelegt": 0, "uebersprungen": 0}
|
return {"angelegt": 0, "uebersprungen": 0}
|
||||||
|
|
||||||
@@ -282,8 +288,10 @@ async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
|||||||
tankvorgang["odometer_km"] = odometer_km
|
tankvorgang["odometer_km"] = odometer_km
|
||||||
if mit_liter:
|
if mit_liter:
|
||||||
tankvorgang["liters"] = round(anstieg, 1)
|
tankvorgang["liters"] = round(anstieg, 1)
|
||||||
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
|
distanz_sensor = wert_bei(distanz_verlauf, ts) if distanz_verlauf else None
|
||||||
ts.isoformat(), odometer_km, neue + bestehende
|
tankvorgang["distance_km"] = (
|
||||||
|
distanz_sensor if distanz_sensor is not None
|
||||||
|
else _distanz_zum_vorherigen(ts.isoformat(), odometer_km, neue + bestehende)
|
||||||
)
|
)
|
||||||
neue.append(tankvorgang)
|
neue.append(tankvorgang)
|
||||||
bekannte_zeiten.append(ts)
|
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),
|
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
|
||||||
"tank": await verlauf_lesen(k.hass, werte.TANK_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_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),
|
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis),
|
||||||
"aussentemp": await verlauf_lesen(k.hass, werte.AUSSENTEMP_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),
|
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "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",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": ["@paul"],
|
"codeowners": ["@paul"],
|
||||||
|
|||||||
@@ -313,3 +313,20 @@ historie_importieren:
|
|||||||
example: "2026-08-23T00:00:00"
|
example: "2026-08-23T00:00:00"
|
||||||
selector:
|
selector:
|
||||||
text:
|
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)
|
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(
|
async def _automatisch_anlegen(
|
||||||
k: Koordinator, anlass: str, liter_schaetzung: float | None = None
|
k: Koordinator, anlass: str, liter_schaetzung: float | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -151,7 +162,7 @@ async def _automatisch_anlegen(
|
|||||||
datetime.datetime.now(datetime.UTC).isoformat(), "auto"
|
datetime.datetime.now(datetime.UTC).isoformat(), "auto"
|
||||||
)
|
)
|
||||||
tankvorgang["odometer_km"] = odometer_km
|
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:
|
if liter_schaetzung is not None:
|
||||||
# Grobe Vorbelegung, keine feststehende Menge - der Status bleibt
|
# Grobe Vorbelegung, keine feststehende Menge - der Status bleibt
|
||||||
# "unvollständig" (siehe leerer_tankvorgang), bis Preis/Tankstelle
|
# "unvollständig" (siehe leerer_tankvorgang), bis Preis/Tankstelle
|
||||||
|
|||||||
Reference in New Issue
Block a user