diff --git a/AGENTS.md b/AGENTS.md
index 6999a48..672b32f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3770,6 +3770,93 @@ verified, deployed fixes can vanish in one click.** (`git push` needed one retry
already-documented Gitea Credential Manager flakiness - consistent with every other push this project has
made.)
+## Y. Full cross-codebase bug audit: six real defects found and fixed (2026.8.27.19)
+
+Owner asked for a general audit of the app, not tied to a specific report. Ran three parallel reviews
+(backend Python, panel JS, companion-app) rather than one pass, each grounded in reading the actual current
+code rather than trusting this file's own history. Six real, concrete defects survived; three items the
+reviews raised as *possible* concerns were checked and confirmed correct as-is (documented below so they
+aren't re-flagged). No crash-class bugs (undefined-reference errors, the historical `bvRuhePunkte` class of
+bug) were found anywhere - confirmed via ESLint `no-undef` over the whole panel file plus manual review.
+
+**1. (High) companion-app's `Batterie.tsx` showed generator voltage as "Ruhespannung" and skewed the SOH
+trend.** The panel's `bvPunkte` (the single, already-filtered `<= AGM_RUHE_MAX_V` array used for the
+headline, trend, and diagram alike - see section X's `.16`/`.17` history) was never mirrored here:
+companion-app's `messwerte` had no such filter, so the newest day's minimum reading - even a generator
+reading above 13.0 V - could show as the "Ruhespannung" headline, and the trend average wasn't excluding
+those days either. Fixed with the identical one-line filter the panel already applies:
+`verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)` in `Batterie.tsx` - this one array already
+drives the headline, the trend, and the diagram, so a single filter fixes all three. `BatterieListe.tsx`
+(the measurement list) is correctly left unfiltered, exactly like the panel's `vBatterieliste()` - it tags
+generator readings with a pill instead of hiding them, which is a different, already-correct concept.
+
+**2. (High) Manually-entered trips/refuels could be silently duplicated by a later import.**
+`historienimport.py`'s own docstring promises every import-created record is checked for overlap against
+*all* existing ones, "egal ob live erkannt, von Hand angelegt oder aus einem früheren Import" - but the
+manual-entry paths (`fahrterkennung.manuell_anlegen()`/`aktualisieren()`, `belege.tankvorgang_manuell()`/
+`tankvorgang_aktualisieren()`) stored whatever timestamp the frontend sent verbatim via a raw
+`datetime.fromisoformat()`, while `historienimport.als_zeit()` normalizes the same kind of input (naive =
+local time, per its own comment) to a timezone-aware UTC datetime. `_ueberschneidet()`'s overlap check and
+the tank-vorgänge dedup window both explicitly skipped any record with a naive timestamp - so every
+manually-created trip or refuel was invisible to a later import's dedup, and could be re-created as a
+duplicate. Fixed by moving the normalization into a shared function, `zeit_normalisiert()` in `verlauf.py`
+(a leaf module with no local imports, chosen specifically to avoid a circular import between
+`fahrterkennung.py`/`belege.py` and `historienimport.py`) - `historienimport.als_zeit` is now an alias for
+it. Every write path (`manuell_anlegen`, `aktualisieren`, `tankvorgang_manuell`, `tankvorgang_aktualisieren`)
+now normalizes through it before storing. `_ueberschneidet()`/`_tankvorgaenge_importieren()`'s
+`bekannte_zeiten` collection were also changed to run existing stored timestamps through the same function
+instead of skipping naive ones - this also correctly protects any already-stored legacy record with a naive
+timestamp from before this fix, not just new writes.
+
+**3. (Medium) Battery voltage history had no backup coverage.** `sicherung.py`'s `DATEIEN` tuple (used by
+"Backup jetzt erstellen") listed `fahrzeugprofil.json`/`fahrten.jsonl`/`tankvorgaenge.jsonl`/
+`entitaeten.json` but not `batteriespannung.jsonl` - even though `batterie.py`'s own docstring frames it as
+a deliberately multi-year dataset for detecting battery aging. Added it to `DATEIEN`; `_kopieren()` already
+skips any file that doesn't exist, so this is zero-risk for an install with no battery history yet.
+
+**4. (Low/Medium) Tank-vorgang import was all-or-nothing per sensor once `TANK_LITER_SENSOR` was mapped.**
+`_tankvorgaenge_importieren()` used to read *only* the liter sensor's history once one was mapped, silently
+losing any refuel from before the liter sensor's own recorder history started (e.g. it was mapped
+mid-project, per section Q) even though the percent sensor's own history covered that period. Rewrote the
+function to run both passes when a liter sensor is mapped: the percent-based pass now covers the period
+strictly *before* the liter history's first point, and the liter-based pass (more accurate, since it's
+already in liters) covers everything from there on - both share the same `bekannte_zeiten`/`neue` state so
+the same physical refill can't be created twice if the two histories overlap. Unmapped-liter-sensor
+behavior (pure percent-based import) is unchanged.
+
+**5. (Moderate) Panel's Statistik page mislabeled a tank-based estimate as vehicle-reported data.** The
+"Vom Fahrzeug gemeldet, je Zeitraum" tile and its caption claimed `t.verbrauch_l_100km` (per-trip fuel
+consumption) came from the vehicle's own OBD reporting - it doesn't; section V documented that this field
+is entirely a backend-computed approximation from `TANK_LITER_SENSOR` liter deltas, and the trip-detail
+page already correctly labels it "Näherung aus Tankfüllstand". Only the Statistik tile and a stale code
+comment (`fahrtVerbrauch`'s own header) still claimed OBD provenance. Fixed both to say what the value
+actually is - a text-only change, the underlying computation was already correct.
+
+**6. (Low) companion-app's "Art der Fahrten" chart still colored the Arbeitsweg segment red.** The panel
+fixed this exact chart on 2026-08-13 (section C, the iOS-overlay red-semantics-inversion finding: red is
+reserved for destructive actions, this segment is a neutral data category) but companion-app's
+`Statistik.tsx` never received the port - its Arbeitsweg segment stayed `var(--red)` while its sibling
+Privat segment already used the neutral `var(--fg3)`. Changed to `var(--fg)`, matching the panel's own
+Arbeitsweg color exactly (not `--fg3`, which the panel reserves for the Privat segment).
+
+**Checked and confirmed NOT a bug** (raised by one of the three reviews, verified against current code):
+`AGM_RUHE_MAX_V`, `NEBENWERT_MAX_ABSTAND_S=300`, `_letzte_gueltige_spannung`, the `ablage.py` temperature
+backfill branch, `historienimport.py`'s `aussentemp` fetch, and `batterie.py`'s freshness check - all
+already correct per sections T/W/X's history; the route/back-navigation tables (`ZURUECK` vs. the panel's
+dispatcher) - a full cross-check found every route has exactly one matching entry both ways; and the panel's
+`ereignisseVerdrahten()` listener wiring - guarded by the existing idempotency lock, no double-registration
+risk.
+
+Verified: all five touched backend files (`verlauf.py`, `historienimport.py`, `fahrterkennung.py`,
+`belege.py`, `sicherung.py`) `py_compile` clean in `audi_ha_test`; panel `node --check` clean; companion-app
+`tsc --noEmit` clean and full suite green at 146/146. Manifest bumped to `2026.8.27.19` (panel text
+changed), `npm run ota` rerun (companion-app source changed, `Batterie.tsx`/`Statistik.tsx`), `audi_ha_test`
+restarted and confirmed clean via log (`Audi Dashboard 2026.8.27.19 eingerichtet`, no traceback; the only
+`ReferenceError: bvRuhePunkte` lines in the post-restart log predate the setup-complete line and come from a
+stale browser tab's cached pre-restart script, not a new regression). **Not yet committed/pushed** - per
+this file's own binding lesson from earlier the same day (section W), that should happen before any
+self-update/install run is suggested or allowed; ask the owner before running one.
+
---
## Working conventions (observed — keep them)
diff --git a/companion-app/src/screens/Batterie.tsx b/companion-app/src/screens/Batterie.tsx
index c19f2b4..21edb8e 100644
--- a/companion-app/src/screens/Batterie.tsx
+++ b/companion-app/src/screens/Batterie.tsx
@@ -93,7 +93,12 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
if (!verlauf) return
Verlauf wird geladen …
- const messwerte = verlauf.filter((t) => t.min != null)
+ // Wie bvPunkte im Panel: Werte über AGM_RUHE_MAX_V sind Generatorspannung,
+ // keine Ruhespannung - ausgeschlossen von Kopfzeile, Trend UND Diagramm
+ // (eine einzige gefilterte Liste, kein separater "alle Messungen"-Bestand).
+ // Ohne diesen Filter hätte ein Tag, an dem das Fahrzeug nie lange genug
+ // stand, seine Generatorspannung fälschlich als "Ruhespannung" gezeigt.
+ const messwerte = verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)
if (messwerte.length === 0) {
return (
diff --git a/companion-app/src/screens/Statistik.tsx b/companion-app/src/screens/Statistik.tsx
index 7a2bacb..911562f 100644
--- a/companion-app/src/screens/Statistik.tsx
+++ b/companion-app/src/screens/Statistik.tsx
@@ -133,7 +133,7 @@ export function Statistik() {
/>
diff --git a/custom_components/audi_dashboard/belege.py b/custom_components/audi_dashboard/belege.py
index 7f76be3..6909f17 100644
--- a/custom_components/audi_dashboard/belege.py
+++ b/custom_components/audi_dashboard/belege.py
@@ -35,6 +35,7 @@ from typing import TYPE_CHECKING
from . import shell_beleg_parser
from .tankerkennung import leerer_tankvorgang
from .veroeffentlichung import zustand_oder_none
+from .verlauf import zeit_normalisiert
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -199,9 +200,11 @@ async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None:
# deshalb immer Vorrang vor der Berechnung.
distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
+ ts_roh = daten.get("ts")
+ ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC)
receipt_key = daten.get("receipt_key")
tankvorgang = leerer_tankvorgang(
- daten.get("ts") or datetime.datetime.now(datetime.UTC).isoformat(),
+ ts.isoformat(),
"beleg" if receipt_key else "manual",
)
tankvorgang.update({
@@ -241,8 +244,9 @@ async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
liter = daten.get("liter")
kosten = daten.get("kosten")
+ ts_normalisiert = zeit_normalisiert(daten.get("ts"))
aenderungen = {
- "ts": daten.get("ts"),
+ "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),
diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py
index 041067c..901d6a3 100644
--- a/custom_components/audi_dashboard/fahrterkennung.py
+++ b/custom_components/audi_dashboard/fahrterkennung.py
@@ -27,7 +27,7 @@ from typing import TYPE_CHECKING
from . import screening
from .ablage import neue_id
from .veroeffentlichung import zustand_oder_none
-from .verlauf import verlauf_lesen
+from .verlauf import verlauf_lesen, zeit_normalisiert
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -228,8 +228,8 @@ async def manuell_anlegen(k: Koordinator, daten: dict) -> None:
automatischen Zündungs-Erkennung. Alles außer Start- und Endzeitpunkt ist
optional und bleibt leer, wenn es das Screening später selbst ergänzen
soll."""
- start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
- ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
+ start_ts = zeit_normalisiert(daten["ts_start"])
+ ende_ts = zeit_normalisiert(daten["ts_end"])
hand = _handfelder(
daten.get("start_ort"), daten.get("ziel_ort"),
daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"),
@@ -264,8 +264,8 @@ async def aktualisieren(k: Koordinator, daten: dict) -> None:
if daten.get("art"):
aenderungen["art"] = daten["art"]
if daten.get("ts_start") and daten.get("ts_end"):
- start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
- ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
+ start_ts = zeit_normalisiert(daten["ts_start"])
+ ende_ts = zeit_normalisiert(daten["ts_end"])
aenderungen["ts_start"] = start_ts.isoformat()
aenderungen["ts_end"] = ende_ts.isoformat()
aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json
index 3eaf181..fb137aa 100644
--- a/custom_components/audi_dashboard/frontend/app/bundle.json
+++ b/custom_components/audi_dashboard/frontend/app/bundle.json
@@ -1 +1 @@
-{"version":"2026.8.27.17","sha256":"9ccedff2ae407751703713cc91b37bb51124f6fb5b98014ef2ae49659e51de2a","bytes":236026,"gebaut":"2026-08-27T21:03:47Z"}
\ No newline at end of file
+{"version":"2026.8.27.19","sha256":"9045270a7f0e7222ed00f583e0f131d0bdf20a79a69d6b6004ccb4f548206884","bytes":236046,"gebaut":"2026-08-27T21:58:57Z"}
\ No newline at end of file
diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip
index 64ecc4d..f97eb45 100644
Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ
diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js
index 7e8eafb..c43ec10 100644
--- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js
+++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js
@@ -3278,10 +3278,13 @@ function strecke(t) {
return `${esc(a)} → ${esc(b)}`;
}
-/* A1.1: Der Verbrauch einer einzelnen Fahrt kommt vom Fahrzeug (OBD) und wird
- vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */
+/* A1.1: Der Verbrauch einer einzelnen Fahrt ist eine Näherung aus der
+ Literstand-Differenz über die Fahrt (verbrauch_aus_literstaenden() im
+ Backend) - kein vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert
+ (Trip-Detail zeigt das schon korrekt als "Näherung aus Tankfüllstand",
+ siehe oben). */
const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null);
-/* Mittelwert der gemeldeten Fahrtverbräuche, nach Distanz gewichtet. */
+/* Mittelwert der genäherten Fahrtverbräuche, nach Distanz gewichtet. */
function schnittFahrtVerbrauch(list) {
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
const km = sum(mit, (t) => t.distance_km);
@@ -3339,8 +3342,8 @@ function vStat() {
Letzter Tankstop${lv.bis ? dedat(lv.bis) : "–"}
${lv.unplausibel ? `Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.` : ""}
- ${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
- Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.`)}
+ ${quad("Näherung je Fahrt, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
+ Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.`)}
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
`
Tag · ${de(tagKm)} km
diff --git a/custom_components/audi_dashboard/historienimport.py b/custom_components/audi_dashboard/historienimport.py
index 82946f0..f46e7b4 100644
--- a/custom_components/audi_dashboard/historienimport.py
+++ b/custom_components/audi_dashboard/historienimport.py
@@ -54,6 +54,7 @@ from .verlauf import (
wert_bei,
wert_in_naehe,
zahl,
+ zeit_normalisiert,
)
if TYPE_CHECKING:
@@ -78,17 +79,7 @@ MINDESTDAUER_S = 60
# genau wie bei fehlendem Kilometerstand.
-def als_zeit(wert: object) -> datetime.datetime | None:
- """Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
-
- Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
- im Formular schließlich Ortszeit, keine UTC."""
- if not wert:
- return None
- ts = datetime.datetime.fromisoformat(str(wert))
- if ts.tzinfo is None:
- ts = ts.astimezone()
- return ts.astimezone(datetime.UTC)
+als_zeit = zeit_normalisiert # lokaler Name beibehalten, Funktion lebt jetzt in verlauf.py (siehe dort)
def _fahrtfenster(
@@ -132,14 +123,24 @@ def _ueberschneidet(
start: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
) -> bool:
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
- überschneidet. Verhindert Dubletten beim wiederholten Import."""
+ überschneidet. Verhindert Dubletten beim wiederholten Import.
+
+ Läuft über zeit_normalisiert() statt über ein rohes fromisoformat(): eine
+ von Hand angelegte oder bearbeitete Fahrt (fahrterkennung.py) wird seit
+ dieser Funktion ebenfalls über zeit_normalisiert() gespeichert und trägt
+ deshalb einen zeitzonenbewussten Zeitstempel - vorher wurde ein naiver
+ Zeitstempel hier stillschweigend übersprungen (kein Vergleich, keine
+ Erkennung), sodass ein von Hand angelegter Datensatz nie gegen einen
+ späteren Import geschützt war. Ältere, bereits gespeicherte naive
+ Zeitstempel werden hier trotzdem korrekt behandelt: zeit_normalisiert()
+ interpretiert sie als lokale Zeit, genau wie beim Schreiben."""
for f in bestehende:
try:
- f_start = datetime.datetime.fromisoformat(f.get("ts_start")) # type: ignore[arg-type]
- f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) # type: ignore[arg-type]
+ f_start = zeit_normalisiert(f.get("ts_start"))
+ f_ende = zeit_normalisiert(f.get("ts_end"))
except (TypeError, ValueError):
continue
- if f_start.tzinfo is None or f_ende.tzinfo is None:
+ if f_start is None or f_ende is None:
continue
if start < f_ende and f_start < ende:
return True
@@ -215,64 +216,88 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
- """Tankvorgänge aus dem Füllstands- oder Tankvolumenverlauf - dieselbe
+ """Tankvorgänge aus dem Füllstands- und/oder Tankvolumenverlauf - dieselbe
Tiefststand-Logik wie in der Live-Erkennung (tankerkennung.py): jeder
Anstieg über die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein
Tankvorgang, nicht jeder Anstieg gegen den unmittelbar vorherigen Wert.
- Dieselbe Präferenz wie live auch: ist ein Litersensor zugeordnet,
- übernimmt der - genauer, und der Anstieg dient gleich als grobe
- Vorbelegung für die getankte Menge."""
- mit_liter = bool(k.zuordnung.werte.TANK_LITER_SENSOR)
- verlauf = verlaeufe["tank_liter"] if mit_liter else verlaeufe["tank"]
- if not verlauf:
+
+ Ist ein Litersensor zugeordnet, übernimmt der für den Zeitraum, den seine
+ eigene Verlaufshistorie abdeckt - genauer, und der Anstieg dient gleich
+ als grobe Vorbelegung für die getankte Menge. Für den Teil DAVOR (der
+ Litersensor wurde z. B. erst später zugeordnet, oder der recorder hält
+ seine Historie nicht so weit zurück wie die des Prozentsensors) läuft
+ zusätzlich der Prozentsensor - sonst würde ein Import über einen
+ Zeitraum, der vor die Liter-Historie zurückreicht, die dort tatsächlich
+ 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."""
+ prozent_verlauf = verlaeufe["tank"]
+ liter_verlauf = verlaeufe["tank_liter"] if k.zuordnung.werte.TANK_LITER_SENSOR else []
+ if not prozent_verlauf and not liter_verlauf:
return {"angelegt": 0, "uebersprungen": 0}
- schwelle = LITER_SCHWELLE if mit_liter else schwelle_prozent(await k.ablage.profil_lesen())
km_verlauf = verlaeufe["km"]
bestehende = await k.ablage.tankvorgaenge_lesen()
fenster_s = TANK_DUBLETTE_MIN * 60
+ # zeit_normalisiert() statt rohem fromisoformat(): siehe die identische
+ # Begründung in _ueberschneidet() oben - ein von Hand angelegter oder
+ # bearbeiteter Tankvorgang (belege.py) trägt seither ebenfalls einen
+ # zeitzonenbewussten Zeitstempel und wurde vorher hier stillschweigend
+ # aus der Dublettenprüfung ausgeschlossen.
bekannte_zeiten: list[datetime.datetime] = []
for t in bestehende:
try:
- ts = datetime.datetime.fromisoformat(t.get("ts")) # type: ignore[arg-type]
+ ts = zeit_normalisiert(t.get("ts"))
except (TypeError, ValueError):
continue
- if ts.tzinfo is not None:
+ if ts is not None:
bekannte_zeiten.append(ts)
angelegt = uebersprungen = 0
neue: list[dict] = []
- tiefststand: float | None = None
- for ts, wert in verlauf:
- aktuell = zahl(wert)
- if aktuell is None:
- continue
- if tiefststand is None or aktuell <= tiefststand:
+ def _verlauf_abarbeiten(verlauf: list, schwelle: float, mit_liter: bool) -> None:
+ nonlocal angelegt, uebersprungen
+ tiefststand: float | None = None
+ for ts, wert in verlauf:
+ aktuell = zahl(wert)
+ if aktuell is None:
+ continue
+ if tiefststand is None or aktuell <= tiefststand:
+ tiefststand = aktuell
+ continue
+ anstieg = aktuell - tiefststand
+ if anstieg < schwelle:
+ continue
+
+ if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
+ uebersprungen += 1
+ tiefststand = aktuell
+ continue
+
+ odometer_km = wert_bei(km_verlauf, ts)
+ tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
+ 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
+ )
+ neue.append(tankvorgang)
+ bekannte_zeiten.append(ts)
+ angelegt += 1
tiefststand = aktuell
- continue
- anstieg = aktuell - tiefststand
- if anstieg < schwelle:
- continue
- if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
- uebersprungen += 1
- tiefststand = aktuell
- continue
-
- odometer_km = wert_bei(km_verlauf, ts)
- tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
- 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
- )
- neue.append(tankvorgang)
- bekannte_zeiten.append(ts)
- angelegt += 1
- tiefststand = aktuell
+ if liter_verlauf:
+ grenze = liter_verlauf[0][0]
+ aelterer_teil = [p for p in prozent_verlauf if p[0] < grenze]
+ if aelterer_teil:
+ _verlauf_abarbeiten(aelterer_teil, schwelle_prozent(await k.ablage.profil_lesen()), False)
+ _verlauf_abarbeiten(liter_verlauf, LITER_SCHWELLE, True)
+ else:
+ _verlauf_abarbeiten(prozent_verlauf, schwelle_prozent(await k.ablage.profil_lesen()), False)
await k.ablage.tankvorgaenge_ergaenzen(neue)
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json
index a039dc6..e10e850 100644
--- a/custom_components/audi_dashboard/manifest.json
+++ b/custom_components/audi_dashboard/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
- "version": "2026.8.27.18",
+ "version": "2026.8.27.19",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
diff --git a/custom_components/audi_dashboard/sicherung.py b/custom_components/audi_dashboard/sicherung.py
index b026913..562b335 100644
--- a/custom_components/audi_dashboard/sicherung.py
+++ b/custom_components/audi_dashboard/sicherung.py
@@ -6,11 +6,17 @@ wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner
täglicher Termin genügt für alle drei Intervalle: wöchentlich läuft nur
montags, monatlich nur am Monatsersten, täglich jeden Tag.
-Jede Sicherung ist ein eigener Zeitstempel-Unterordner mit Kopien der vier
+Jede Sicherung ist ein eigener Zeitstempel-Unterordner mit Kopien der fünf
Bestände, keine ZIP-Datei - eine Sicherung, in die man ohne Werkzeug
hineinschauen und aus der man eine einzelne Datei zurückkopieren kann, ist im
Ernstfall mehr wert als ein paar gesparte Kilobyte.
+batteriespannung.jsonl gehört ausdrücklich dazu: batterie.py führt sie
+bewusst als mehrjährigen Verlauf, um eine über die Zeit absinkende
+Ruhespannung erkennen zu können - ohne Sicherung wäre dieser Verlauf bei
+einem Festplattenausfall unwiederbringlich weg, anders als Profil/Fahrten/
+Tankvorgänge, die dieselbe Sicherung bereits abdeckte.
+
entitaeten.json gehört ausdrücklich dazu: ohne sie wäre nach einer
Wiederherstellung die komplette Sensor-Zuordnung aus dem Setup-Menü verloren,
und weil sie sonst nirgends abgelegt ist, gäbe es nichts zurückzuholen.
@@ -40,6 +46,7 @@ DATEIEN = (
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
+ "batteriespannung.jsonl",
)
diff --git a/custom_components/audi_dashboard/verlauf.py b/custom_components/audi_dashboard/verlauf.py
index 8296474..bc87e07 100644
--- a/custom_components/audi_dashboard/verlauf.py
+++ b/custom_components/audi_dashboard/verlauf.py
@@ -78,6 +78,29 @@ async def verlauf_lesen(
return []
+def zeit_normalisiert(wert: object) -> datetime.datetime | None:
+ """Akzeptiert ISO-Zeit mit oder ohne Zeitzone und normalisiert auf UTC.
+
+ Ohne Zeitzone gilt die lokale Zeit von Home Assistant - die Oberfläche
+ schickt sowohl bei manueller Eingabe (Fahrt/Tankvorgang von Hand anlegen
+ oder bearbeiten, fahrterkennung.py/belege.py) als auch beim rückwirkenden
+ Import (historienimport.py) Ortszeit, nie UTC. Ohne diese Normalisierung
+ bliebe ein von Hand erfasster Zeitstempel naiv (kein tzinfo) - und
+ historienimport.py's eigene Dublettenprüfung (_ueberschneidet() für
+ Fahrten, bekannte_zeiten für Tankvorgänge) überspringt jeden Datensatz mit
+ naivem Zeitstempel stillschweigend, statt ihn auf Überschneidung zu
+ prüfen. Ein von Hand angelegter oder bearbeiteter Datensatz wäre dadurch
+ nie vor einem doppelten Import geschützt - genau die Lücke, die diese
+ gemeinsame Funktion schließt, indem jeder Schreibpfad dieselbe
+ Normalisierung durchläuft."""
+ if not wert:
+ return None
+ ts = datetime.datetime.fromisoformat(str(wert))
+ if ts.tzinfo is None:
+ ts = ts.astimezone()
+ return ts.astimezone(datetime.UTC)
+
+
def zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]