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]