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