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
@@ -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}