Files
audi-app/custom_components/audi_dashboard/verlauf.py
T
tobias 90fff7b305 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>
2026-08-28 00:05:58 +02:00

258 lines
11 KiB
Python

"""Lesezugriff auf den aufgezeichneten Zustandsverlauf (recorder).
Über Home Assistants eigene recorder-API
(`homeassistant.components.recorder.history.get_significant_states`), nicht
über direkten SQL-Zugriff auf home-assistant_v2.db: das Datenbankschema des
recorders ist HA-intern und ändert sich zwischen Versionen, die Funktion
dagegen ist die von HA selbst benutzte und stabile Schnittstelle.
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
Fahrstrecke und Tankvorgänge zusammensetzen.
WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG WEGFÄLLT: das Fahrtabschluss-
Screening las den Verlauf früher über die HTTP-REST-API (/api/history/period)
und brauchte dafür ein langlebiges Zugriffstoken in
audi_dashboard/ha_token.txt - ein eigener Installationsschritt, der bei jeder
Neuinstallation vergessen werden konnte und dessen Fehlen sich nur als
Warnung im Protokoll zeigte (Fahrten blieben dann stumm ohne Strecke). Als
echte Integration liest die App den Verlauf direkt; Token und
Installationsschritt entfallen ersatzlos.
Die recorder-Abfrage läuft über den Executor: ein Datenbankzugriff hat im
Event-Loop nichts verloren.
"""
from __future__ import annotations
import datetime
import logging
from homeassistant.components.recorder import get_instance, history
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
Verlaufspunkt = tuple[datetime.datetime, str]
def _rohverlauf(
hass: HomeAssistant, entity_id: str, start: datetime.datetime, ende: datetime.datetime
) -> list[Verlaufspunkt]:
roh = history.get_significant_states(
hass, start, ende, [entity_id], None, True, False
)
ergebnis: list[Verlaufspunkt] = []
for zustand in roh.get(entity_id) or []:
# "unknown"/"unavailable" bedeuten "keine Meldung", nicht "Wert 0" -
# würden sie durchgereicht, ergäbe ein Ausfall der Datenquelle eine
# Fahrt mit absurder Kilometerdifferenz.
if zustand.state in ("unknown", "unavailable", None, ""):
continue
ergebnis.append((zustand.last_updated, zustand.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
async def verlauf_lesen(
hass: HomeAssistant,
entity_id: str | None,
start: datetime.datetime,
ende: datetime.datetime,
) -> list[Verlaufspunkt]:
"""Zustandsverlauf einer Entität als aufsteigende Liste von
(Zeitpunkt, Rohwert). Leere Liste, wenn die Entität nicht zugeordnet ist,
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist."""
if not entity_id:
return []
try:
return await get_instance(hass).async_add_executor_job(
_rohverlauf, hass, entity_id, start, ende
)
except Exception: # noqa: BLE001 - der Verlauf ist Beiwerk, nie der Kern
# Ohne Verlauf bleibt eine Fahrt ohne Strecke bzw. der Import leer -
# beides ist verkraftbar. Den aufrufenden Ablauf deswegen abzubrechen
# wäre es nicht.
_LOGGER.warning("Verlauf von %s nicht lesbar", entity_id, exc_info=True)
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]
except (TypeError, ValueError):
return None
# Kein Auto dieser Art erreicht diesen Schnitt. Eine errechnete
# Durchschnittsgeschwindigkeit darüber ist kein Beleg für eine schnelle
# Fahrt, sondern für einen fehlerhaften Kilometerstand-Sprung am Fahrtrand
# (z. B. ein Ausreißer im Verlauf oder ein knapp daneben liegender
# wert_bei()/naechster_wert()-Treffer über eine Lücke hinweg). Gemeinsam für
# den Import (historienimport.py) und die Live-Vervollständigung
# (screening.py) - beide dürfen dieselbe Strecke nie unterschiedlich
# bewerten.
UNPLAUSIBLE_KMH = 300
def durchschnitt_kmh(distanz_km: float | None, dauer_s: float | None) -> float | None:
"""km/h aus Strecke und Dauer, oder None wenn eine der beiden fehlt oder
die Dauer nicht positiv ist."""
if distanz_km is None or not dauer_s or dauer_s <= 0:
return None
return round(distanz_km / (dauer_s / 3600.0), 1)
def wert_bei(
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
) -> float | None:
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
danach, sonst None.
"Zuletzt davor" ist die richtige Wahl für einen Zählerstand: der
Kilometerstand bei Fahrtbeginn ist der, der zuletzt gemeldet wurde, nicht
der nächste (der schon Strecke enthält)."""
davor = None
for ts, wert in verlauf:
gezahlt = zahl(wert)
if gezahlt is None:
continue
if ts <= zeitpunkt:
davor = gezahlt
else:
return davor if davor is not None else gezahlt
return davor
def route_aus_verlauf(
lat_verlauf: list[Verlaufspunkt],
lon_verlauf: list[Verlaufspunkt],
start: datetime.datetime,
ende: datetime.datetime,
) -> list[list[float]] | None:
"""Baut eine chronologische Liste von [lat, lon]-Punkten für die
Fahrt-Detailkarte, aus zwei getrennt geführten Sensor-Verläufen (die
FMM003 liefert Breiten-/Längengrad als zwei unabhängige Sensoren, siehe
STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR). Jeder Breitengrad-Punkt im
Fenster [start, ende] bekommt den zeitlich nächstgelegenen Längengrad-Wert
zugeordnet - beide Sensoren aktualisieren unabhängig voneinander, exakt
gleiche Zeitstempel sind nicht garantiert. None bei weniger als zwei
Punkten (keine sinnvolle Linie)."""
punkte: list[list[float]] = []
for ts, wert in lat_verlauf:
if not (start <= ts <= ende):
continue
lat = zahl(wert)
if lat is None:
continue
lon = naechster_wert(ts, lon_verlauf)
if lon is None:
continue
punkte.append([lat, lon])
return punkte if len(punkte) >= 2 else None
def verbrauch_aus_literstaenden(
start_liter: float | None, end_liter: float | None, distance_km: float | None
) -> float | None:
"""l/100km aus der Differenz zweier Literstände (TANK_LITER_SENSOR) und
der gefahrenen Strecke - eine Näherung, kein vom Fahrzeug selbst für
diese eine Fahrt gemeldeter Wert (ein solcher OBD-Sensor steht als
offener Punkt in AGENTS.md; taucht einer auf, ersetzt er diese Näherung).
None, wenn einer der Werte fehlt, die Strecke nicht positiv ist, oder der
Literstand nicht gefallen ist (z. B. ein Tankvorgang während der Fahrt,
oder unveränderter Füllstand) - in beiden Fällen ist der Quotient nicht
aussagekräftig."""
if start_liter is None or end_liter is None or not distance_km or distance_km <= 0:
return None
verbraucht = start_liter - end_liter
if verbraucht <= 0:
return None
return round(verbraucht / distance_km * 100, 1)
def naechster_wert(
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
) -> float | None:
"""Der Wert, dessen Zeitstempel am nächsten an `zielzeit` liegt.
Anders als wert_bei() ist hier bewusst egal, ob der Wert davor oder danach
liegt: der Kilometerstand kommt laut Datenquelle nicht sicher mit
Fahrtende, sondern teils erst mit Beginn der nächsten Fahrt."""
bester_wert = None
beste_diff = None
for ts, wert in verlauf:
gezahlt = zahl(wert)
if gezahlt is None:
continue
diff = abs((ts - zielzeit).total_seconds())
if beste_diff is None or diff < beste_diff:
bester_wert = gezahlt
beste_diff = diff
return bester_wert
# Wie lange eine Nebenmessung (z. B. Außentemperatur) höchstens von der
# Hauptmessung entfernt liegen darf, um noch als "zur selben Zeit" zu gelten -
# vom Nutzer auf 300s (5 Minuten) festgelegt, nach ursprünglich 60s. Der
# Wechsel ist an echten FMM003-Daten begründet, nicht geschätzt: das Gerät
# sendet Außentemperatur deutlich seltener als Spannung (an einem
# beobachteten Fahrtfenster 851 Spannungs- gegen 187 Temperaturmeldungen in
# 48h) - selbst bei laufender Fahrt und ohne jede Verbindungsstörung wurden
# reale Lücken bis 593s zwischen zwei Temperaturmeldungen gemessen. 60s hätte
# genau diesen völlig normalen Fall verworfen. Physikalisch unproblematisch:
# die Außentemperatur ändert sich über wenige Minuten kaum, anders als z. B.
# eine GPS-Position oder die Spannung selbst - und die Live-Prüfung
# (batterie.py's pruefen()) läuft ohnehin nur alle 5 Minuten, verlangt hier
# also keine feinere Genauigkeit, als sie selbst liefert. Ein Verbindungs-
# abbruch des Geräts (beobachtet: 45 Minuten, beide Sensoren gleichzeitig
# stumm) bleibt mit 300s weiterhin sicher außerhalb der Grenze und liefert
# bewusst KEINEN Wert statt eines beliebig alten.
NEBENWERT_MAX_ABSTAND_S = 300
def wert_in_naehe(
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt], max_abstand_s: float = NEBENWERT_MAX_ABSTAND_S
) -> float | None:
"""Wie naechster_wert(), aber None statt eines beliebig weit entfernten
Werts, wenn selbst der zeitlich nächste mehr als max_abstand_s von
zielzeit entfernt liegt."""
bester_wert = None
beste_diff = None
for ts, wert in verlauf:
gezahlt = zahl(wert)
if gezahlt is None:
continue
diff = abs((ts - zielzeit).total_seconds())
if beste_diff is None or diff < beste_diff:
bester_wert = gezahlt
beste_diff = diff
if beste_diff is None or beste_diff > max_abstand_s:
return None
return bester_wert