d6e27666a3
Der Dongle wacht nach dem Motorstart erst nach 28 s bis 19,5 min auf; was in der Zeit gefahren wird, zeichnet er nicht auf. Die Kilometer holt das Screening jetzt aus dem Endkilometerstand der Vorgaengerin zurueck und haelt in luecke_km fest, wie viel davon betroffen ist. Keine Sperre der GNSS-Verfeinerung, sondern eine Zerlegung: verschlafener Anfang in ganzen Kilometern, aufgezeichneter Rest metergenau. Sonst waere die feine Zahl systematisch zu klein und wuerde bei kleinen Luecken die Korrektur stillschweigend wieder kassieren. Zwei Schutzgitter, beide am Bestand gelernt: LUECKE_MAX_KM = 50 gegen Differenzen, die keine Weckverzoegerung mehr sein koennen, und ein Rueckfall, wenn _vollstaendig() die Werte daraufhin verwerfen wuerde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
775 lines
34 KiB
Python
775 lines
34 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 bisect
|
|
import datetime
|
|
import logging
|
|
|
|
import asyncio
|
|
|
|
from homeassistant.components.recorder import get_instance, history
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
Verlaufspunkt = tuple[datetime.datetime, str]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gerätezeit statt Ankunftszeit
|
|
#
|
|
# Der recorder stempelt jeden Zustand mit `last_updated` - dem Zeitpunkt, zu
|
|
# dem Home Assistant ihn VERARBEITET hat. Die Fahrtgrenzen dagegen stehen seit
|
|
# dem 01.09.2026 in der GERÄTEZEIT (MELDEZEIT_SENSOR, siehe geraetezeit()).
|
|
# Zwei Uhren, und das Gerät puffert: dann fällt alles, was verspätet ankommt,
|
|
# aus einem Fenster heraus, in dem es der Sache nach liegt.
|
|
#
|
|
# Am 03.09.2026 an der Fahrt Eichstätt - Adelschlag gemessen. Das Gerät hat
|
|
# lückenlos aufgezeichnet (48 GPS-Punkte, größte Lücke 30 s, 6,88 km), aber ab
|
|
# Gerätezeit 23:14:13 nichts mehr abgeliefert - erst um 23:24:52 kam der ganze
|
|
# Stau, bis zu 641 s verspätet. Gespeichert wurden davon 26 von 48 Punkten und
|
|
# 3,0 statt 7 km, weil das Fenster nach Ankunftszeit schnitt. Dazu kam der
|
|
# Rückstau in verkehrter Reihenfolge (der aktuelle Datensatz zuerst, dann der
|
|
# Puffer ab dem ältesten), was strecke_aus_zaehler() als Zählerrücksetzung las
|
|
# und 9,8 km statt 2,6 km ergab.
|
|
#
|
|
# Der Riegel sitzt hier, an der einen Stelle, an der alle Verbraucher
|
|
# vorbeikommen: wert_bei(), wert_ab(), zaehlerstrecke(), route_aus_verlauf(),
|
|
# hoechstwert_im_fenster(), naechster_wert(). Damit rechnen Fenster und Werte
|
|
# in derselben Uhr, und die Reihenfolge stimmt wieder.
|
|
#
|
|
# Der Rückstand während der Fahrt lag am 02.09. bei 4 s im Median, am 03.09.
|
|
# abends bei 83 s - das ist kein Ausnahmefall, sondern der Normalbetrieb eines
|
|
# puffernden Geräts.
|
|
NACHREICHFENSTER = datetime.timedelta(hours=6)
|
|
|
|
# Wie dicht die Meldezeit am Wert desselben Datensatzes liegen muss.
|
|
#
|
|
# Die flespi-Integration schreibt die Entitäten eines Datensatzes innerhalb
|
|
# weniger Millisekunden und den Zeitstempel zuletzt (am 03.09.2026 an den
|
|
# Rohdaten nachgesehen). Eine Sekunde ist dafür reichlich - und eng genug, dass
|
|
# ein Zustand einer FREMDEN Integration, der zufällig danebenliegt, nicht die
|
|
# Gerätezeit des Dongles aufgedrückt bekommt: er behält dann seine
|
|
# Ankunftszeit.
|
|
ZUORDNUNG_MAX_S = 1.0
|
|
|
|
|
|
def _marken_aus(zustaende, jetzt: datetime.datetime) -> list[tuple[datetime.datetime, datetime.datetime]]:
|
|
"""(Ankunftszeit, Gerätezeit) je Datensatz, aufsteigend nach Ankunft.
|
|
|
|
Unplausible Zeitstempel fallen hier schon heraus (geraetezeit_plausibel) -
|
|
eine 0 im Meldezeit-Sensor ergäbe sonst 1970 und zöge jeden Wert, der zu
|
|
diesem Datensatz gehört, mit dorthin."""
|
|
marken: list[tuple[datetime.datetime, datetime.datetime]] = []
|
|
for zustand in zustaende or []:
|
|
if zustand.state in ("unknown", "unavailable", None, ""):
|
|
continue
|
|
try:
|
|
gemeldet = datetime.datetime.fromtimestamp(float(zustand.state), datetime.UTC)
|
|
except (TypeError, ValueError, OSError, OverflowError):
|
|
continue
|
|
if not geraetezeit_plausibel(gemeldet, jetzt):
|
|
continue
|
|
marken.append((zustand.last_updated, gemeldet))
|
|
marken.sort(key=lambda m: m[0])
|
|
return marken
|
|
|
|
|
|
def geraetezeit_zuordnen(
|
|
punkte: list[Verlaufspunkt],
|
|
marken: list[tuple[datetime.datetime, datetime.datetime]],
|
|
max_abstand_s: float = ZUORDNUNG_MAX_S,
|
|
) -> tuple[list[Verlaufspunkt], int]:
|
|
"""Stempelt jeden Verlaufspunkt mit der Gerätezeit SEINES Datensatzes um.
|
|
|
|
Gesucht wird die Marke, die am oder direkt nach dem Punkt liegt - denn die
|
|
Integration schreibt den Zeitstempel zuletzt. Erst wenn dort nichts in
|
|
Reichweite ist, zählt die davor. Findet sich gar keine, behält der Punkt
|
|
seine Ankunftszeit; die Zahl solcher Punkte kommt als zweiter Rückgabewert
|
|
zurück, damit der Aufrufer es merken kann.
|
|
|
|
Reine Funktion auf Listen, damit sie ohne Home Assistant prüfbar ist."""
|
|
if not marken:
|
|
return list(punkte), len(punkte)
|
|
ankunft = [m[0] for m in marken]
|
|
neu: list[Verlaufspunkt] = []
|
|
ohne = 0
|
|
for ts, wert in punkte:
|
|
i = bisect.bisect_left(ankunft, ts)
|
|
gemeldet: datetime.datetime | None = None
|
|
if i < len(marken) and (marken[i][0] - ts).total_seconds() <= max_abstand_s:
|
|
gemeldet = marken[i][1]
|
|
elif i > 0 and (ts - marken[i - 1][0]).total_seconds() <= max_abstand_s:
|
|
gemeldet = marken[i - 1][1]
|
|
if gemeldet is None:
|
|
ohne += 1
|
|
neu.append((ts, wert))
|
|
else:
|
|
neu.append((gemeldet, wert))
|
|
neu.sort(key=lambda p: p[0])
|
|
return neu, ohne
|
|
|
|
|
|
def _rohverlauf(
|
|
hass: HomeAssistant,
|
|
entity_id: str,
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
meldezeit_id: str | None = None,
|
|
) -> list[Verlaufspunkt]:
|
|
umstempeln = bool(meldezeit_id) and meldezeit_id != entity_id
|
|
ids = [entity_id, meldezeit_id] if umstempeln else [entity_id]
|
|
# Nachgereichte Datensätze kommen später an, als sie entstanden sind - der
|
|
# Lesebereich muss also nach hinten reichen, sonst fehlen genau die, um die
|
|
# es geht.
|
|
bis = ende + NACHREICHFENSTER if umstempeln else ende
|
|
roh = history.get_significant_states(hass, start, bis, ids, None, True, False) # type: ignore[arg-type]
|
|
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])
|
|
if not umstempeln:
|
|
return ergebnis
|
|
|
|
marken = _marken_aus(roh.get(meldezeit_id), datetime.datetime.now(datetime.UTC))
|
|
ergebnis, ohne = geraetezeit_zuordnen(ergebnis, marken)
|
|
if ohne:
|
|
_LOGGER.debug(
|
|
"%s: %d von %d Werten ohne Meldezeit in Reichweite - sie behalten "
|
|
"ihre Ankunftszeit", entity_id, ohne, len(ergebnis),
|
|
)
|
|
# Was jetzt hinter dem Fenster liegt, gehört auch nicht hinein. Der Rand am
|
|
# Anfang bleibt: der Wert VOR dem Fenster ist der Anker (siehe wert_bei()).
|
|
return [p for p in ergebnis if p[0] <= ende]
|
|
|
|
|
|
async def verlauf_lesen(
|
|
hass: HomeAssistant,
|
|
entity_id: str | None,
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
meldezeit_id: str | None = None,
|
|
) -> 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.
|
|
|
|
Mit `meldezeit_id` tragen die Punkte die **Gerätezeit** statt der
|
|
Ankunftszeit - siehe NACHREICHFENSTER, warum das der Normalfall sein muss.
|
|
Zu setzen ist sie nur für Entitäten DIESES Geräts; für Sensoren einer
|
|
anderen Integration wäre sie falsch, und ZUORDNUNG_MAX_S fängt den Fall
|
|
zusätzlich ab."""
|
|
if not entity_id:
|
|
return []
|
|
try:
|
|
return await get_instance(hass).async_add_executor_job(
|
|
_rohverlauf, hass, entity_id, start, ende, meldezeit_id
|
|
)
|
|
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 als_kilometerstand(wert: object) -> float | None:
|
|
"""Ein eingegebener Kilometerstand, oder None wenn er unbekannt ist.
|
|
|
|
**Null heisst unbekannt.** Der Eigentuemer wollte einen Weg, einen
|
|
Tankvorgang ohne Kilometerstand einzutragen, und eine 0 im Zahlenfeld ist
|
|
dafuer die naheliegende Geste (01.09.2026).
|
|
|
|
Sie wird deshalb hier abgefangen und NICHT gespeichert. Sechs Stellen im
|
|
Projekt lesen `odometer_km` und behandeln ausschliesslich None als
|
|
unbekannt - eine echte 0 waere fuer sie ein Fahrzeug mit Tachostand null:
|
|
die "gefahrene Distanz seit der letzten Tankung" waere der gesamte
|
|
Tachostand, und der Langzeitverbrauch rechnete gegen eine Spanne, die es
|
|
nie gab. Eine Sonderbehandlung an sechs Lesestellen waere sechsmal die
|
|
Gelegenheit, sie zu vergessen; eine Normalisierung an der Eingabe ist
|
|
einmal.
|
|
|
|
Negative Werte fallen aus demselben Grund heraus - ein Tacho zaehlt nicht
|
|
rueckwaerts."""
|
|
if wert is None or wert == "":
|
|
return None
|
|
try:
|
|
zahl = float(wert) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return zahl if zahl > 0 else None
|
|
|
|
|
|
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
|
|
|
|
# Kuerzer als das ist Zuendung ohne Fahrt: Radio, Tuer oeffnen, Diagnose - oder,
|
|
# seit die Pausentoleranz entfallen ist, ein prellender Zuendungseingang. Am
|
|
# 31.08.2026 sind daraus an einem einzigen Tag elf Fahrten mit 0 km entstanden,
|
|
# zwei davon mit null Sekunden Dauer.
|
|
#
|
|
# Die Zahl stand vorher nur in historienimport.py, dessen eigener Kommentar den
|
|
# Widerspruch sogar benannte ("die Live-Erkennung legt sie zwar an"): derselbe
|
|
# Vorgang wurde beim Import verworfen und live gespeichert. Sie lebt deshalb
|
|
# jetzt hier, gemeinsam mit UNPLAUSIBLE_KMH und aus demselben Grund - beide
|
|
# Wege duerfen denselben Vorgang nie unterschiedlich bewerten.
|
|
MINDESTDAUER_S = 60
|
|
|
|
|
|
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)
|
|
|
|
|
|
# Wie lange auf die Meldezeit desselben Datensatzes gewartet wird.
|
|
#
|
|
# Die flespi-Integration setzt die Entitäten eines Datensatzes nacheinander:
|
|
# am 01.09.2026 gemessen stand die Zündung um 07:35:23.633 und die zugehörige
|
|
# Meldezeit 2 ms später um 07:35:23.635. Ohne diese kurze Wartezeit läse man
|
|
# den Zeitstempel des VORIGEN Datensatzes - beim Fahren zehn Sekunden daneben,
|
|
# im Stand aber möglicherweise Stunden, weil das Gerät dort nur alle paar
|
|
# Stunden einen Satz schreibt (Data Acquisition, On Stop: 43200 s).
|
|
MELDEZEIT_FRIST_S = 2.0
|
|
MELDEZEIT_TAKT_S = 0.05
|
|
|
|
# Wie weit die Geräteuhr der von Home Assistant vorauslaufen darf, bevor der
|
|
# Zeitstempel verworfen wird. Etwas Luft für Drift, aber keine Stunden - eine
|
|
# falsch gestellte Geräteuhr darf die Auswertung nicht mitreißen.
|
|
MELDEZEIT_VORLAUF = datetime.timedelta(minutes=5)
|
|
|
|
# Und wie weit sie hoechstens ZURUECKliegen darf.
|
|
#
|
|
# Der Grund ist real: am 02.09.2026 stand auf der Instanz des Eigentuemers
|
|
# "Geparkt seit 20698 Tg. 9 Std. 39 Min." - das ist der 31.12.1969, also die
|
|
# Unix-Epoche. Der Meldezeit-Sensor hatte 0 gemeldet (dasselbe Verhalten wie
|
|
# external_power_voltage bei abgestecktem Dongle, siehe Abschnitt Z), und
|
|
# fromtimestamp(0) ergibt genau diesen Zeitpunkt. Geprueft wurde bis dahin nur
|
|
# die Zukunft, nicht die Vergangenheit.
|
|
#
|
|
# 365 Tage (Vorgabe des Eigentuemers). Das Geraet puffert real nur Stunden bis
|
|
# Tage (On-Stop-Sendeperiode 24 h), die Grenze ist also sehr weit gefasst - und
|
|
# genau deshalb ungefaehrlich: sie kann keine echte gepufferte Meldung
|
|
# verwerfen. Was sie fangen soll, liegt Jahrzehnte daneben: eine 0 ergibt 1970,
|
|
# und eine faelschlich zugeordnete ID-Entitaet (im flespi-Geraet heissen drei
|
|
# davon aehnlich wie der Zeitstempel) ebenfalls - 1304 etwa waere der
|
|
# 01.01.1970, 00:21 Uhr.
|
|
MELDEZEIT_RUECKLAUF = datetime.timedelta(days=365)
|
|
|
|
|
|
def geraetezeit_plausibel(
|
|
gemeldet: datetime.datetime | None, jetzt: datetime.datetime
|
|
) -> bool:
|
|
"""Kann dieser Zeitstempel wirklich vom Geraet stammen?
|
|
|
|
Weder aus der Zukunft (falsch gestellte Uhr) noch aus grauer Vorzeit -
|
|
siehe MELDEZEIT_RUECKLAUF fuer den Fall, der das ausgeloest hat."""
|
|
if gemeldet is None:
|
|
return False
|
|
return (
|
|
gemeldet <= jetzt + MELDEZEIT_VORLAUF
|
|
and gemeldet >= jetzt - MELDEZEIT_RUECKLAUF
|
|
)
|
|
|
|
|
|
async def geraetezeit(
|
|
hass: HomeAssistant,
|
|
sensor: str | None,
|
|
ereigniszeit: datetime.datetime | None,
|
|
standard: datetime.datetime,
|
|
) -> datetime.datetime:
|
|
"""Der Zeitstempel, den das Gerät seinem Datensatz mitgegeben hat - oder
|
|
`standard`, wenn er fehlt oder unglaubwürdig ist.
|
|
|
|
Warum das nötig ist: `standard` ist die Uhrzeit, zu der Home Assistant den
|
|
Zustandswechsel verarbeitet hat. Hat das Gerät gepuffert - Funkloch,
|
|
Tiefgarage, Tiefschlaf -, liegt die Wahrheit beliebig weit davor. Am
|
|
01.09.2026 gemessen: ein Datensatz mit Fahrtende trug die Gerätezeit
|
|
07:29:04 und kam um 07:35:23 an, sechs Minuten später.
|
|
|
|
Der Wert ist eine Unix-Sekunde in UTC, nicht Ortszeit - ebenfalls
|
|
nachgemessen: der Versatz zur UTC-Uhr von Home Assistant lag bei Sekunden,
|
|
nicht bei zwei Stunden. Eine Zeitzonenumrechnung wäre nicht nur unnötig,
|
|
sie baute einen Zwei-Stunden-Fehler ein.
|
|
|
|
Gemeinsam für Fahrterkennung und Tankerkennung: beide legen Datensätze mit
|
|
einem Zeitstempel an und dürfen ihn nie unterschiedlich bestimmen - dieselbe
|
|
Regel wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S."""
|
|
if not sensor:
|
|
return standard
|
|
|
|
if ereigniszeit is not None:
|
|
wartezeit = 0.0
|
|
while wartezeit < MELDEZEIT_FRIST_S:
|
|
zustand = hass.states.get(sensor)
|
|
if zustand is not None and zustand.last_updated >= ereigniszeit:
|
|
break
|
|
await asyncio.sleep(MELDEZEIT_TAKT_S)
|
|
wartezeit += MELDEZEIT_TAKT_S
|
|
|
|
zustand = hass.states.get(sensor)
|
|
try:
|
|
gemeldet = datetime.datetime.fromtimestamp(
|
|
float(zustand.state), datetime.UTC # type: ignore[union-attr]
|
|
)
|
|
except (AttributeError, TypeError, ValueError, OSError, OverflowError):
|
|
return standard
|
|
|
|
if gemeldet > standard + MELDEZEIT_VORLAUF:
|
|
_LOGGER.warning(
|
|
"Meldezeit %s liegt in der Zukunft (jetzt %s) - verworfen",
|
|
gemeldet.isoformat(), standard.isoformat(),
|
|
)
|
|
return standard
|
|
if gemeldet < standard - MELDEZEIT_RUECKLAUF:
|
|
_LOGGER.warning(
|
|
"Meldezeit %s liegt mehr als %s Tage zurueck (jetzt %s) - verworfen. "
|
|
"Der Sensor meldet vermutlich 0, oder im Setup ist statt des "
|
|
"Zeitstempels eine ID-Entitaet zugeordnet",
|
|
gemeldet.isoformat(), MELDEZEIT_RUECKLAUF.days, standard.isoformat(),
|
|
)
|
|
return standard
|
|
return gemeldet
|
|
|
|
|
|
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 wert_ab(
|
|
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
|
|
) -> float | None:
|
|
"""Der erste ab `zeitpunkt` gemeldete Zahlenwert, sonst der letzte davor.
|
|
|
|
Gegenstueck zu wert_bei(). Fuer einen ZAEHLER ist -zuletzt davor- richtig
|
|
(der Kilometerstand aendert sich im Stand nicht), fuer einen gemessenen
|
|
FUELLSTAND nicht: der driftet.
|
|
|
|
Am 01.09.2026 an der Fahrt Eichstaett - Adelschlag nachgemessen. Der
|
|
Tankstand stand am Ende der vorigen Fahrt (13:52) auf 36,4 l, beim ersten
|
|
Datensatz der neuen Fahrt (16:20:49) auf 36,2 l - in der Standzeit dazwischen
|
|
ist niemand gefahren. Diese 0,2 l Setzung wurden der neuen Fahrt zugerechnet:
|
|
0,8 l statt 0,6 l auf 6,9 km, also **11,4 statt 8,7 l/100 km**.
|
|
|
|
Das Fahrtende bleibt bei wert_bei(): dort ist die Fahrt vorbei, und der
|
|
letzte waehrend der Fahrt gemeldete Stand ist der richtige - der naechste
|
|
kaeme erst Stunden spaeter und traege dieselbe Setzung wieder herein."""
|
|
danach = None
|
|
for ts, wert in verlauf:
|
|
gezahlt = zahl(wert)
|
|
if gezahlt is None:
|
|
continue
|
|
if ts >= zeitpunkt:
|
|
danach = gezahlt
|
|
break
|
|
if danach is not None:
|
|
return danach
|
|
return wert_bei(verlauf, zeitpunkt)
|
|
|
|
|
|
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
|
|
|
|
|
|
# Wie weit die GNSS-Strecke vom Kilometerstand des Fahrzeugs abweichen darf.
|
|
#
|
|
# Beide Enden des Kilometerstands sind auf ganze Kilometer gerundet (am
|
|
# echten Fahrzeug nachgemessen: 147 Schritte von exakt 1,0 km, kein einziger
|
|
# feinerer). Die wahre Strecke liegt damit irgendwo in einem Fenster von
|
|
# +/- 1 km um die Differenz. Was darin liegt, ist mit dem Fahrzeugwert
|
|
# vereinbar; was darueber hinausgeht, ist es nicht - dann stimmt etwas nicht,
|
|
# und der Fahrzeugwert behaelt recht.
|
|
GNSS_TOLERANZ_KM = 1.0
|
|
|
|
|
|
def zaehlerstrecke(
|
|
punkte: list[Verlaufspunkt],
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
) -> float | None:
|
|
"""Die im Fenster [start, ende] gefahrene Strecke aus einem laufenden
|
|
Kilometerzaehler.
|
|
|
|
Der Anker ist der letzte Datensatz AM ODER VOR `start`, nicht der erste
|
|
danach: der Zaehlerstand zu Fahrtbeginn ist der zuletzt gemeldete - dieselbe
|
|
Ueberlegung wie bei wert_bei(). Ohne ihn fehlt die Strecke zwischen dem
|
|
Beginn und dem ersten Datensatz danach.
|
|
|
|
WARUM DIESE FUNKTION EXISTIERT: Livepfad und Rueckblick haben dieselbe
|
|
Fahrt unterschiedlich gemessen. Das Screening las den Verlauf ueber
|
|
verlauf_lesen(), das den Stand ZU BEGINN des Fensters mitliefert; der
|
|
Rueckblick schnitt sein Fenster streng heraus und verlor genau diesen
|
|
Punkt. Am 01.09.2026 an der echten Fahrt nachgemessen: 6,896 km gegen
|
|
6,816 km fuer denselben Zeitraum. Das ist die Doppelbewertung, die dieses
|
|
Projekt bei UNPLAUSIBLE_KMH und MINDESTDAUER_S schon zweimal ausgeraeumt
|
|
hat - jetzt schneiden beide Wege durch dieselbe Funktion."""
|
|
davor = [p for p in punkte if p[0] <= start]
|
|
im_fenster = [p for p in punkte if start < p[0] <= ende]
|
|
return strecke_aus_zaehler((davor[-1:] if davor else []) + im_fenster)
|
|
|
|
|
|
def strecke_waehlen(
|
|
grob: float | None, fein: float | None
|
|
) -> tuple[float | None, bool]:
|
|
"""Welche der beiden Streckenangaben gilt - und wurde die feine genommen?
|
|
|
|
`grob` ist die Differenz der Fahrzeug-Kilometerstände: der Anker. Er ist
|
|
der Tacho, er driftet nicht, und er ist die Zahl im Cockpit - aber er löst
|
|
nur ganze Kilometer auf. `fein` ist die Strecke aus dem GNSS-Zähler des
|
|
Geräts: metergenau, dafür anfällig für fehlenden Empfang und Drift.
|
|
|
|
Die feine gilt, solange sie innerhalb der Rundungsunschärfe des Ankers
|
|
liegt (GNSS_TOLERANZ_KM) - oder wenn es gar keinen Anker gibt, denn dann
|
|
ist irgendeine Messung besser als keine. Liegt sie darüber, hat der Anker
|
|
recht: dann fehlte Empfang oder der Zähler ist gedriftet.
|
|
|
|
Gemeinsam für Livepfad (screening.py) und Rückblick (historienimport.py) -
|
|
dieselbe Regel wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S: beide Wege
|
|
dürfen dieselbe Fahrt nie unterschiedlich bewerten."""
|
|
if fein is None:
|
|
return grob, False
|
|
if grob is None or abs(fein - grob) <= GNSS_TOLERANZ_KM:
|
|
return fein, True
|
|
return grob, False
|
|
|
|
|
|
def aufzeichnungsluecke_km(
|
|
vorheriges_ende: float | None, eigener_start: float | None
|
|
) -> float | None:
|
|
"""Die Kilometer am Anfang einer Fahrt, die das Geraet nicht mitbekommen hat.
|
|
|
|
Zwischen zwei aufeinanderfolgenden Fahrten steht das Fahrzeug, der
|
|
Kilometerstand aendert sich also nicht - dieselbe Annahme, auf der
|
|
_lueckenlos_verketten() in screening.py seit jeher beruht. Steht der
|
|
Startkilometerstand einer Fahrt trotzdem HOEHER als der Endstand ihrer
|
|
Vorgaengerin, sind diese Kilometer gefahren worden, ohne dass ein einziger
|
|
Datensatz entstanden ist.
|
|
|
|
WARUM ES DAS GIBT: der FMM003 kommt aus dem Deep Sleep nur ueber seinen
|
|
Beschleunigungssensor hoch. Die eingestellte Zuendungsquelle ist die
|
|
Motordrehzahl, die liegt am CAN - und CAN ist im Schlaf abgeschaltet
|
|
(Teltonika: "In deep sleep mode, OBD, CAN and Eventual I/O activity is
|
|
disabled"). Die Bordspannung taugt an diesem Fahrzeug nicht als Ersatz:
|
|
das Rekuperationsmanagement des RS 4 laedt in Schueben, und von 2089
|
|
Messwerten bei laufendem Motor liegen 87,6 % im Spannungsbereich des
|
|
stehenden Wagens (gemessen 06.09.2026). Gemessene Weckzeiten reichen von
|
|
39 s bis 19,5 min; am 05.09.2026 fehlten 9 Minuten und 6 Kilometer.
|
|
|
|
Der Eigentuemer hat alle geraeteseitigen Auswege ausgeschlossen
|
|
(Dauerbetrieb und GPS-Schlaf wegen des Ruhestroms, Periodic Wakeup ebenso,
|
|
die Empfindlichkeit des Beschleunigungssensors ist am FMM003 nicht
|
|
einstellbar). Die Luecke bleibt also - sie soll aber wenigstens die
|
|
Streckenlaenge nicht kosten und sichtbar sein.
|
|
|
|
Reine Funktion, damit sie ohne Home Assistant pruefbar ist."""
|
|
if vorheriges_ende is None or eigener_start is None:
|
|
return None
|
|
fehlt = round(eigener_start - vorheriges_ende, 1)
|
|
return fehlt if fehlt > 0 else None
|
|
|
|
|
|
def ist_gefahren(distanz: float | None) -> bool:
|
|
"""Hat sich das Fahrzeug bewegt?
|
|
|
|
Auf eine Nachkommastelle gerundet: ab 0,1 km ist es eine Fahrt, darunter
|
|
nicht (Ansage des Eigentümers, 01.09.2026). Unter 50 Metern zeigt die
|
|
Oberfläche ohnehin \"0,0 km\" an, und der Eintrag sähe aus wie die
|
|
Nullfahrten, die aus dem Bestand geräumt wurden.
|
|
|
|
Eine UNBEKANNTE Strecke gilt als gefahren: liegt weder Kilometerstand noch
|
|
GNSS-Zähler vor, wissen wir es nicht - und eine Fahrt zu verwerfen, die man
|
|
nicht gemessen hat, wäre schlechter als eine ohne Streckenangabe zu
|
|
behalten. Nur die gemessene Null ist ein Nein."""
|
|
return distanz is None or round(distanz, 1) != 0
|
|
|
|
|
|
# Ab wann ein Rueckwaertsschritt eine Ruecksetzung ist und kein Rauschen.
|
|
#
|
|
# Eine echte Ruecksetzung faellt auf null: am 01.09.2026 sprang der Zaehler
|
|
# mitten in der Fahrt von 0,415 auf 0, als das Geraet seinen konfigurierten
|
|
# Startwert uebernahm. Ein Rueckschritt von 6,891 auf 6,848 km ist etwas ganz
|
|
# anderes - er entsteht, wenn zwei Datensaetze in derselben Millisekunde
|
|
# ankommen und ihre Reihenfolge dadurch nicht mehr eindeutig ist (am
|
|
# 03.09.2026 in der Nachlieferung real vorgekommen). Ihn als Ruecksetzung zu
|
|
# lesen hiesse, den ganzen Zaehlerstand noch einmal als Strecke zu buchen:
|
|
# aus 87 Metern wurden so 13,7 km.
|
|
#
|
|
# Die beiden Faelle liegen weit auseinander (Verhaeltnis 0,00 gegen 0,99), die
|
|
# Grenze in der Mitte ist deshalb unkritisch. Ein kleinerer Rueckschritt zaehlt
|
|
# gar nicht - weder vorwaerts noch als Ruecksetzung.
|
|
RUECKSETZ_ANTEIL = 0.5
|
|
|
|
def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None:
|
|
"""Gefahrene Strecke aus einem laufenden Kilometerzaehler des Geraets.
|
|
|
|
Summiert die Zuwaechse statt einfach Ende minus Anfang zu rechnen, weil der
|
|
Zaehler zurueckspringen kann. Ein Ruecksprung ist kein Rueckwaertsfahren,
|
|
sondern eine Ruecksetzung - der neue Wert ist dann selbst der Zuwachs seit
|
|
der Ruecksetzung, alles davor wurde bereits gezaehlt.
|
|
|
|
Am 01.09.2026 an zwei echten Fahrten gemessen:
|
|
|
|
* Fahrt 1, mit Ruecksetzung mitten in der Fahrt (das Geraet uebernahm
|
|
seinen konfigurierten Startwert 0, als der CAN-Kilometerstand zum ersten
|
|
Mal hochzaehlte): 0,415 km vor der Ruecksetzung, 6,543 km danach - macht
|
|
6,958 km. Google Maps sagt 7,1 km fuer dieselbe Strecke. Ohne die
|
|
Ruecksprungbehandlung waeren es 6,543 km gewesen, also 0,4 km zu wenig.
|
|
* Fahrt 2, ohne Ruecksetzung: 6,816 km. Dort degeneriert die Summe zu
|
|
Ende minus Anfang, wie es sein soll.
|
|
|
|
Bewusst NICHT ueber die Teilstrecken je Datensatz (segment_mileage), die
|
|
dasselbe Geraet mitliefert: die verlieren einzelne Datensaetze. In Fahrt 2
|
|
waren 4 von 50 Teilstrecken null, obwohl gefahren wurde, und die Summe lag
|
|
dadurch 0,2 km zu niedrig. Der Zaehler zaehlt ueber solche Aussetzer hinweg.
|
|
|
|
None, wenn weniger als zwei Zahlenwerte vorliegen."""
|
|
werte = [zahl(wert) for _ts, wert in punkte]
|
|
werte = [w for w in werte if w is not None]
|
|
if len(werte) < 2:
|
|
return None
|
|
strecke = 0.0
|
|
for vorher, jetzt in zip(werte, werte[1:]):
|
|
if jetzt >= vorher:
|
|
strecke += jetzt - vorher
|
|
elif jetzt < vorher * RUECKSETZ_ANTEIL:
|
|
strecke += jetzt
|
|
return round(strecke, 3)
|
|
|
|
|
|
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
|