4af38d7e65
Der 15-Minuten-Timer war doppelt: der FMM003 wartet laut Konfiguration (Trip \ Odometer, Ignition OFF Timeout) selbst 900 s, die App noch einmal 15 Minuten. Zusammen eine halbe Stunde. Die Pausenregel ist damit entfallen - in der Live-Erkennung, im Historienimport und in beiden Oberflaechen. Der Schwellwert "ab wann ist es ein Parkplatz" hing an derselben Einstellung, hat damit aber nichts zu tun und steht jetzt als eigene Zahl. Hoechst- und Durchschnittsgeschwindigkeit je Fahrt. Der Durchschnitt folgt aus Strecke und Dauer, vmax aus der neuen Rolle GESCHWINDIGKEIT_SENSOR - laut Konfiguration liest das Geraet die Geschwindigkeit vom OBD/CAN. Es ist der groesste gemeldete Wert, nicht die tatsaechliche Spitze: alle zehn Sekunden ein Datensatz. So ist es auch beschriftet. Der Langzeitverbrauch war um einen ganzen Tankinhalt zu hoch. Gemessen wird die Strecke ZWISCHEN erstem und letztem Tankstop; verbraucht wurde darauf, was ab dem ZWEITEN hineinkam - der erste fuellte die Strecke davor. Bei 13 Tankvorgaengen rund 8 Prozent, im Testfall 16,7 statt 8,3 l/100 km. Der Test hatte die falsche Rechnung festgeschrieben und ist mitkorrigiert. Eine negative Strecke (falscher Kilometerstand an einem Tankvorgang) gilt jetzt als unplausibel statt als Null ohne Erklaerung. Verbrauch je Fahrt bekommt Grenzen: am Sensor nachgemessen loest can_fuel_volume 0,1 l auf, ein Schritt entspricht also 10/Strecke l/100 km Fehler. Unter 3 km sagt die Differenz nichts mehr, ueber 60 l/100 km ist es kein Verbrauch. Statistik: zusaetzlich "Absolut", und die Einheit steht jetzt in eckigen Klammern an der Rubrik statt vier Mal am Zeitraum. Fuenf Spalten passen auf einem Telefon nicht nebeneinander, das Raster bricht um. Radfoto in Reifen war ein reines <Bild> - Ersetzen und Loeschen von dort aus gar nicht erreichbar. Jetzt BildMitMenue wie im Panel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
12 KiB
Python
305 lines
12 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 hoechstwert_im_fenster(
|
|
verlauf: list[Verlaufspunkt],
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
) -> float | None:
|
|
"""Der größte Zahlenwert eines Verlaufs innerhalb von [start, ende].
|
|
|
|
Für die Höchstgeschwindigkeit einer Fahrt. Das ist der größte
|
|
GEMELDETE Wert, nicht die tatsächliche Spitze: der FMM003 schreibt
|
|
während der Fahrt alle zehn Sekunden einen Datensatz (Data
|
|
Acquisition -> Moving -> "Min Period" = 10 s), eine kurze Spitze
|
|
dazwischen sieht niemand. Für eine Fahrtenübersicht ist das genau
|
|
genug; als Beweismittel taugt es nicht, und so ist es auch
|
|
beschriftet.
|
|
|
|
None, wenn im Fenster kein einziger Zahlenwert liegt."""
|
|
werte = [
|
|
float(wert)
|
|
for ts, wert in verlauf
|
|
if start <= ts <= ende and _als_zahl(wert) is not None
|
|
]
|
|
return round(max(werte), 1) if werte else None
|
|
|
|
|
|
def _als_zahl(wert: object) -> float | None:
|
|
"""Ein Verlaufswert als Zahl, oder None - Zustände wie "unavailable"
|
|
kommen hier genauso an wie Messwerte."""
|
|
try:
|
|
return float(wert) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# Unterhalb dieser Strecke sagt die Literdifferenz nichts mehr: der
|
|
# Tanksensor löst 0,1 l auf (an der echten Aufzeichnung nachgemessen,
|
|
# 2026-08-31), ein Quantisierungsschritt entspricht also 10/Strecke
|
|
# l/100 km Fehler - bei 3 km sind das ±3,3, bei 1 km schon ±10.
|
|
MIN_STRECKE_VERBRAUCH_KM = 3.0
|
|
|
|
# Ein Verbrennerwert darüber ist kein Verbrauch, sondern ein Messfehler
|
|
# (Sensorsprung, Tankvorgang kurz vor Fahrtende, Betankung im Stand).
|
|
UNPLAUSIBLE_L_100KM = 60.0
|
|
|
|
|
|
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:
|
|
return None
|
|
if distance_km < MIN_STRECKE_VERBRAUCH_KM:
|
|
return None
|
|
verbraucht = start_liter - end_liter
|
|
if verbraucht <= 0:
|
|
return None
|
|
wert = round(verbraucht / distance_km * 100, 1)
|
|
return wert if wert <= UNPLAUSIBLE_L_100KM else None
|
|
|
|
|
|
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
|