Vollaudit: sechs echte Fehler in Backend, Panel und companion-app behoben

- companion-app Batterie.tsx: Generatorspannung zeigte sich faelschlich als
  Ruhespannung/verzerrte den SOH-Trend, Panel-Filter (AGM_RUHE_MAX_V) fehlte
- Manuell angelegte Fahrten/Tankvorgaenge trugen naive Zeitstempel und wurden
  von der Import-Dublettenpruefung stillschweigend uebersprungen; neue
  gemeinsame zeit_normalisiert() in verlauf.py schliesst die Luecke
- Batteriespannungsverlauf fehlte im Backup (sicherung.py)
- Tankvorgangs-Import verlor stillschweigend aeltere Tankvorgaenge vor Beginn
  der Litersensor-Historie; laeuft jetzt zweigleisig (Prozent + Liter)
- Panel-Statistikseite behauptete faelschlich, der Verbrauch je Fahrt komme
  vom Fahrzeug (OBD) - ist eine Naeherung aus dem Tankfuellstand
- companion-app Statistik.tsx: Arbeitsweg-Segment war noch rot statt neutral

Version 2026.8.27.19, OTA-Buendel neu gebaut, im Testcontainer verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 00:05:58 +02:00
parent 8ba3519090
commit 90fff7b305
12 changed files with 223 additions and 69 deletions
+87
View File
@@ -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)
+6 -1
View File
@@ -93,7 +93,12 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
if (!verlauf) return <div className="dm-laden">Verlauf wird geladen </div>
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 (
+1 -1
View File
@@ -133,7 +133,7 @@ export function Statistik() {
/>
<ProgressBar
segments={[
{ value: a.arbeitKm, color: "var(--red)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
{ value: a.arbeitKm, color: "var(--fg)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
{ value: a.privatKm, color: "var(--fg3)", label: `Privat · ${de(a.privatKm)} km` },
]}
/>
+6 -2
View File
@@ -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),
@@ -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())
@@ -1 +1 @@
{"version":"2026.8.27.17","sha256":"9ccedff2ae407751703713cc91b37bb51124f6fb5b98014ef2ae49659e51de2a","bytes":236026,"gebaut":"2026-08-27T21:03:47Z"}
{"version":"2026.8.27.19","sha256":"9045270a7f0e7222ed00f583e0f131d0bdf20a79a69d6b6004ccb4f548206884","bytes":236046,"gebaut":"2026-08-27T21:58:57Z"}
@@ -3278,10 +3278,13 @@ function strecke(t) {
return `${esc(a)}&nbsp;→ ${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() {
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : ""}</dd></div>
</dl>
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
<span class="label" style="margin-top:16px">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.</span>`)}
${quad("Näherung je Fahrt, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
<span class="label" style="margin-top:16px">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.</span>`)}
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "", "% bei Tag", de(tagKm) + " km",
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
<div class="legend"><span><i style="background:var(--fg)"></i>Tag · ${de(tagKm)} km</span>
@@ -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}
@@ -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"],
@@ -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",
)
@@ -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]