"""Fahrterkennung über den Zündungs-/ACC-Status (SPECIFICATION.md §7.1). Start: die Zündungs-Entität wechselt auf "on". Ende: sie wechselt auf "off" und kommt binnen der Pausenzeit nicht zurück. Die Pausenregel läuft über eine abbrechbare Aufgabe: jede neue Änderung der Zündung bricht eine noch wartende Ende-Bestätigung ab. Kommt die Zündung während der Wartezeit zurück, wird die Fahrt dadurch nicht beendet - genau die Pausenregel aus §7.1. (In der pyscript-Fassung leisteten task.unique() und task.sleep() dasselbe.) WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG BESSER IST: dort lebte der Beginn einer laufenden Fahrt nur im Arbeitsspeicher. Ein HA-Neustart mitten in einer Fahrt oder während der Wartezeit verlor diesen Zwischenstand - die Fahrt wurde dann gar nicht aufgezeichnet. Das war als bekannte Schwäche vermerkt und ist hier behoben: der Zwischenstand liegt in einem Store (siehe koordinator.py), überlebt einen Neustart und wird beim Hochfahren wieder aufgenommen. """ from __future__ import annotations import asyncio import datetime import logging from typing import TYPE_CHECKING from . import reifen, screening from .ablage import neue_id from .veroeffentlichung import zustand_oder_none from .verlauf import ( MINDESTDAUER_S, fahrtsignal, geraetezeit, geraetezeit_plausibel, verlauf_lesen, zeit_normalisiert, ) if TYPE_CHECKING: from .koordinator import Koordinator _LOGGER = logging.getLogger(__name__) # Die frühere Pausenregel (STANDARD_PAUSENZEIT_MIN = 15) ist am 2026-08-31 # entfallen. Der FMM003 wendet sie selbst an - in seiner Konfiguration unter # "Trip Odometer" steht "Ignition OFF Timeout" auf 900 Sekunden. Eine # zweite Wartezeit hier oben drauf hätte daraus 30 Minuten gemacht. # # Zurückholen, falls die Erkennung je wieder ohne das Gerät auskommen muss: # eine abbrechbare Wartezeit vor fahrt_beenden() legen (k.warte_ende_ab / # k.warte_ende_ab_abbrechen im Koordinator sind dafür noch da) und die # Einstellung fahrten_pausenzeit_min in beiden Oberflächen wieder anbieten. # Der Nachlauf des Geraets zwischen Zuendung aus und Trip-Ende. # # Das Trip-Signal des FMM003 endet nicht mit der Zuendung, sondern erst nach # seinem "Ignition OFF Timeout" (Trip \ Odometer). Es ist damit als Ausloeser # ideal - es prellt nicht und bringt die Pausentoleranz mit -, liefert aber ein # Fahrtende, das systematisch um genau diese Spanne zu spaet liegt. # # Der Wert ist die Konfiguration des Geraets, nicht unsere Wahl: er steht dort # auf 900 s. Aendert er sich, gehoert diese Zahl mitgeaendert - sie laesst sich # nicht aus den Daten ableiten (siehe _nachlauf_gegenpruefen unten, die genau # das versucht und meldet, wenn es nicht mehr zusammenpasst). # # Bewusst KEINE Einstellung in der Oberflaeche: die Zahl gehoert zum Geraet, # nicht zum Fahrzeug oder zum Nutzer - dieselbe Linie wie bei UNPLAUSIBLE_KMH # und MIN_STRECKE_VERBRAUCH_KM in verlauf.py. NACHLAUF_S = 900 # Ab welcher Abweichung zwischen gerechnetem und beobachtetem Ende gewarnt # wird. Grosszuegig, weil die Zuendung nur so genau bekannt ist, wie das Geraet # Datensaetze schreibt - beim Fahren alle 10 s, im Stand deutlich seltener. NACHLAUF_TOLERANZ_S = 120 # Der Nachlauf des Zuendungselements selbst (FMM003: "Ignition OFF Delay", # Parameter 400). # # Das Geraet meldet "Zuendung aus" nicht beim Abfall der Drehzahl, sondern erst # diese Spanne spaeter - eine Entprellung gegen Start-Stopp und gegen kurze # Aussetzer des CAN-Signals. Beide sind nachgewiesen: in den beiden Fahrten vom # 02.09.2026 kippte die Zuendung je einmal in den ersten 25 Sekunden fuer unter # eine Sekunde weg und kam sofort zurueck. # # Der gemeldete Zeitpunkt liegt damit systematisch zu spaet, und zwar in BEIDEN # Wegen: auch das Trip-Ende des Geraets haengt an der verzoegerten Zuendung. # Nachgemessen am 02.09.2026: Zuendung aus 21:43:15, Trip aus 21:58:16 - genau # NACHLAUF_S spaeter. Wer das Trip-Signal als Ausloeser nimmt, muss deshalb # beide Spannen abziehen. # # Der Wert stand bis zum 31.08.2026 auf 0 und seit dem 01.09.2026 09:38 auf # 180 s (aus den Geraetekonfigurationen rekonstruiert). Fahrten aus der Zeit # dazwischen enden 180 s zu spaet - korrigiert wird ab jetzt, rueckwirkend nur # von Hand. # # Wie NACHLAUF_S die Konfiguration des Geraets und bewusst keine Einstellung in # der Oberflaeche. ZUENDUNG_NACHLAUF_S = 180 # Wie lange nach einem vorlaeufigen Ende ein spaeter eintreffendes Zuendungs-Aus # dieses Ende noch korrigieren darf. # # Der Fall, fuer den es das gibt: das Fahrzeug steht in einer Tiefgarage ohne # Empfang, der Nutzer beendet die Fahrt von Hand, und die gepufferten # Datensaetze treffen erst Stunden spaeter ein. Der Abstand zwischen dem # vorlaeufigen Ende (dem letzten Lebenszeichen vor dem Funkloch) und dem # echten Zuendungs-Aus ist hoechstens so gross wie die Funkluecke plus die # Restfahrt. Sechs Stunden decken das reichlich ab und begrenzen zugleich den # Schaden, falls ein Aus doch zu einer ganz anderen Fahrt gehoert. KORREKTUR_FENSTER_S = 6 * 3600 # Unsere eigene Wartezeit nach dem Zuendungs-Aus, wenn die Zuendung selbst der # Ausloeser ist. Anders als die beiden Nachlaufzeiten oben gehoert sie UNS und # steht deshalb in der Oberflaeche: sie beantwortet die Frage, ab wann eine # Unterbrechung keine Pause mehr ist, sondern das Ende der Fahrt. Das haengt am # Fahrverhalten, nicht am Geraet. STANDARD_PAUSENZEIT_MIN = 15 PAUSENZEIT_MAX_MIN = 60 def leere_fahrt( start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: str, reifensatz: str | None = None, ) -> dict: """Das vollständige Fahrt-Schema mit allem, was noch nicht bekannt ist, auf None. Bewusst alle Felder ausgeschrieben: die Oberfläche darf sich auf ihre Existenz verlassen, und ein fehlendes Feld wäre schwerer zu finden als ein leeres.""" return { "trip_id": neue_id("t"), "ts_start": start_ts.isoformat(), "ts_end": ende_ts.isoformat(), "duration_s": int((ende_ts - start_ts).total_seconds()), "distance_km": None, "km_quelle": None, "odo_start": None, "odo_end": None, "avg_speed_kmh": None, "vmax_kmh": None, # Die reine Messung, unangetastet - damit die Eichung an den # Betankungen wiederholbar bleibt (verbrauchskorrektur.py). "verbrauch_roh_l_100km": None, "verbrauch_faktor": None, # Was angezeigt wird: roh mal Faktor, oder roh allein. "verbrauch_l_100km": None, "start_lat": None, "start_lon": None, "end_lat": None, "end_lon": None, "start_address": None, "end_address": None, # Nur der Ortsname, fuer die Fahrtenliste. Zwei volle Anschriften # passen dort nicht in eine Zeile. Er wird beim Aufloesen SEPARAT # gemerkt, nicht nachtraeglich aus der Anschrift geschnitten - # Begruendung in geokodierung.anschrift_bauen(). "start_stadt": None, "end_stadt": None, "art": "privat", # Welcher Radsatz montiert war. Der Radzaehler summiert danach - siehe # reifen.py. None heisst "zaehlt fuer keinen Satz": so bleiben Fahrten # aus der Zeit vor der Umstellung dem Sockel ueberlassen, statt dort # ein zweites Mal aufzutauchen. "reifensatz": reifensatz, # Wieviel von dieser Fahrt dem Radsatz bereits gutgeschrieben ist. "reifen_gezaehlt_km": None, "route": None, "pausen": [], "source": quelle, # Das Ende steht nur vorlaeufig fest - gesetzt vom Knopf "Fahrt # beenden", solange das Geraet sein Zuendungs-Aus noch nicht gemeldet # hat. Die Oberflaechen zeigen die Strecke dann mit Tilde und dem # Zusatz "vorlaeufig"; _spaetes_aus() zieht das Ende nach, sobald der # gepufferte Datensatz eintrifft. "ende_vorlaeufig": False, "status": "offen", "edited_fields": [], } async def _letzter_datensatz( k: Koordinator, start_ts: datetime.datetime, bis: datetime.datetime ) -> datetime.datetime | None: """Der letzte Datensatz, den das Geraet im Fenster [start_ts, bis] geschrieben hat - in seiner eigenen Zeit. Der Meldezeit-Sensor traegt als WERT die Geraetezeit; hier ist also nichts umzustempeln, der Wert ist die Antwort. Gelesen wird bis jetzt, weil ein Datensatz aus dem Fenster erst Minuten spaeter ankommen kann (siehe verlauf.NACHREICHFENSTER).""" sensor = k.zuordnung.werte.MELDEZEIT_SENSOR if not sensor: return None jetzt = datetime.datetime.now(datetime.UTC) reihe = await verlauf_lesen(k.hass, sensor, start_ts, max(jetzt, bis)) letzter: datetime.datetime | None = None for _ts, wert in reihe: try: gemeldet = datetime.datetime.fromtimestamp(float(wert), datetime.UTC) except (TypeError, ValueError, OSError, OverflowError): continue if not geraetezeit_plausibel(gemeldet, jetzt): continue if start_ts < gemeldet <= bis and (letzter is None or gemeldet > letzter): letzter = gemeldet return letzter async def _echtes_ende( k: Koordinator, start_ts: datetime.datetime, signal_ende: datetime.datetime, ) -> datetime.datetime: """Das Fahrtende ohne den Nachlauf des Geraets. Das Trip-Signal endet NACHLAUF_S nach der Zuendung; abgezogen ergibt das den Zeitpunkt, zu dem wirklich abgestellt wurde. Die Rechnung stammt vollstaendig aus der Logik des Geraets und ueberlebt deshalb auch ein Funkloch: der Datensatz kommt spaeter, traegt aber seine eigene Zeit (siehe geraetezeit()). War die ganze Fahrt kuerzer als der Nachlauf, bleibt nichts uebrig - dann war die Zuendung nur kurz an, ohne dass gefahren wurde. Das Ende wird auf den Beginn geklemmt, und MINDESTDAUER_S in fahrt_beenden() verwirft den Vorgang anschliessend. Ohne diese Klemme entstuende aus dreissig Sekunden Zuendung eine Viertelstunde Fahrt. ZWEI Nachlaeufe, nicht einer. Das Zuendungselement meldet selbst schon verzoegert (ZUENDUNG_NACHLAUF_S), und das Trip-Ende haengt an dieser bereits verzoegerten Zuendung - wer ueber das Trip-Signal kommt, muss deshalb beide Spannen abziehen, wer ueber die Zuendung kommt, nur die eine. Bis zum 03.09.2026 wurde nur NACHLAUF_S abgezogen; seit der Ignition OFF Delay am 01.09.2026 auf 180 s steht, endeten die Fahrten dadurch 180 s zu spaet.""" nachlauf = ZUENDUNG_NACHLAUF_S ueber_trip = bool(k.zuordnung.werte.TRIP_SENSOR) if ueber_trip: nachlauf += NACHLAUF_S if not nachlauf: return signal_ende gerechnet = signal_ende - datetime.timedelta(seconds=nachlauf) if gerechnet <= start_ts: _LOGGER.info( "Signal lief %s s, also nicht laenger als der Nachlauf (%s s) - " "es wurde nicht gefahren", int((signal_ende - start_ts).total_seconds()), nachlauf, ) return start_ts if ueber_trip: await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet) # Nie hinter dem letzten Lebenszeichen des Geraets. # # Der Nachlauf ist eine Rechnung, kein Messwert: er unterstellt, dass die # gemeldete Zuendung wirklich eine verzoegerte Zuendung war. Am 03.09.2026 # war sie das nicht - der Dongle war um 23:17:48 stromlos (Stecker # gezogen), meldete deshalb ZUENDUNG_NACHLAUF_S spaeter mangels Drehzahl # -aus- und konnte den Datensatz erst um 23:24:27 absetzen. Abzueglich der # 180 s ergab das ein Fahrtende um 23:21:57 - vier Minuten nach dem # letzten Datensatz, den es ueberhaupt gibt. Das Auto stand da laengst. # # Was wir wissen koennen, endet beim letzten Datensatz. Alles danach waere # erfunden, und eine erfundene Zahl ist schlechter als eine knappe. letzter = await _letzter_datensatz(k, start_ts, gerechnet) if letzter is not None and letzter < gerechnet: _LOGGER.info( "Fahrtende auf %s geklemmt - danach hat das Geraet nichts mehr " "gemeldet (gerechnet waere %s gewesen)", letzter.isoformat(), gerechnet.isoformat(), ) return letzter return gerechnet async def _nachlauf_gegenpruefen( k: Koordinator, start_ts: datetime.datetime, signal_ende: datetime.datetime, gerechnet: datetime.datetime, ) -> None: """Vergleicht das gerechnete Ende mit dem beobachteten Zuendungswechsel und meldet, wenn beide nicht zusammenpassen. Aendert nichts - die Rechnung bleibt massgeblich. Warum nur eine Meldung und keine Korrektur: wie genau der Zeitpunkt "Zuendung aus" bekannt ist, haengt an der I/O-Einstellung des Geraets. Steht der Operand des Zuendungselements auf "Monitoring", erzeugt ein Wechsel keinen eigenen Datensatz, sondern faehrt nur im naechsten mit - dann ist der beobachtete Zeitpunkt zu spaet, oft genau der des Trip-Endes. Auf "On Change" waere er exakt. Solange das nicht feststeht, waere ein Umschalten auf den beobachteten Wert eine Verschlechterung; eine Abweichung zu protokollieren ist es nie.""" sensor = k.zuordnung.werte.ZUENDUNG_SENSOR if not sensor or sensor == fahrtsignal(k.zuordnung.werte): return punkte = await verlauf_lesen( k.hass, sensor, start_ts, signal_ende, k.zuordnung.werte.MELDEZEIT_SENSOR ) aus = [ts for ts, wert in punkte if wert == "off"] if not aus: return # Der beobachtete Wechsel traegt den Nachlauf des Zuendungselements noch in # sich, das gerechnete Ende nicht mehr - erst ohne ihn meinen beide Zahlen # denselben Zeitpunkt. Ohne diese Zeile meldete die Pruefung seit dem # 01.09.2026 bei JEDER Fahrt eine Abweichung von 180 s. beobachtet = aus[-1] - datetime.timedelta(seconds=ZUENDUNG_NACHLAUF_S) abweichung = abs((beobachtet - gerechnet).total_seconds()) if abweichung > NACHLAUF_TOLERANZ_S: _LOGGER.warning( "Fahrtende gerechnet auf %s (Trip-Ende minus %s s), die Zuendung ging " "laut Aufzeichnung aber um %s aus - %.0f s Abweichung. Entweder stimmt " "NACHLAUF_S nicht mehr mit dem Geraet ueberein, oder das " "Zuendungselement schreibt keine eigenen Datensaetze.", gerechnet.isoformat(), NACHLAUF_S, aus[-1].isoformat(), abweichung, ) async def _geraetezeit( k: Koordinator, ereigniszeit: datetime.datetime | None, standard: datetime.datetime, ) -> datetime.datetime: """Die Meldezeit des Geräts - siehe verlauf.geraetezeit().""" return await geraetezeit( k.hass, k.zuordnung.werte.MELDEZEIT_SENSOR, ereigniszeit, standard ) async def zuendung_geaendert( k: Koordinator, neu: str | None, alt: str | None, ereigniszeit: datetime.datetime | None = None, ) -> None: """Reagiert auf jede Zustandsänderung der Zündungs-Entität. "unavailable"/"unknown" ist kein Zündungszustand, sondern Funkstille des Trackers - beim FMM003 z. B. in Tiefgaragen oder Funklöchern, auch mitten in einer laufenden Fahrt. Würde das wie "aus" gewertet, risse jede solche Lücke eine einzelne Fahrt in zwei auseinander, sobald sie länger als die Pausenzeit dauert (der Tracker meldet sich zurück, die Zündung steht laut letztem echten Wert immer noch auf "on", also sieht es wie eine neue Fahrt aus). Ohne Signal ist deshalb "keine Aussage", nicht "aus": der Zustand bleibt unangetastet, bis wieder ein echter Wert kommt.""" if neu not in ("on", "off"): return # Kein Vorzustand heißt: die Entität taucht gerade erst auf - beim # Hochfahren von Home Assistant oder nach einem Neuladen der Integration. # Das ist kein Wechsel der Zündung, sondern eine Registrierung. # # Ohne diese Prüfung las der Beobachter das als "Zündung ging gerade an" # und legte eine Fahrt an, deren Beginn schlicht der Zeitpunkt des # Neustarts war. Steht das Trip-Signal ohnehin auf "an", entstand so bei # JEDEM Neustart eine Phantomfahrt - daher der Eintrag vom 30.08.2026, # 19:43 bis 08:37 des Folgetags, 12,9 Stunden, 0 km. # # Eine wirklich laufende Fahrt geht dabei nicht verloren: deren Beginn # liegt im Store und wird von nach_neustart_fortsetzen() aufgenommen. # _kilometerstand_geaendert() im Koordinator prüft aus demselben Grund # schon immer auf einen Vorzustand. if alt is None: return # Eine noch wartende Ende-Bestätigung aus einer vorherigen Änderung # abbrechen - das ist der Mechanismus hinter der Pausenregel. k.warte_ende_ab_abbrechen() # Von hier an nur einer zur Zeit. Home Assistant startet für jeden # Zustandswechsel eine eigene Aufgabe, und dieser Rumpf wartet mehrfach: # _geraetezeit() bis zu zwei Sekunden auf die Meldezeit, danach liest # _echtes_ende() den Verlauf, danach schreibt fahrt_beenden() die Fahrt # und stösst das Screening an. # # Ohne die Sperre überholt der nächste Wechsel den laufenden. Genau das ist # am 02.09.2026 passiert und hat eine ganze Fahrt gekostet: das Gerät hatte # gepuffert, deshalb kamen "Fahrt zu Ende" (Gerätezeit 21:20:55) und "Fahrt # begonnen" in DERSELBEN Sekunde bei Home Assistant an, 21:22:12. Das "off" # ging in den Beende-Zweig und wartete; das "on" traf mitten hinein, sah # fahrt_start_ts noch gesetzt - also "es läuft eine Fahrt" - und passte # damit in keinen der beiden Zweige unten. Es fiel wortlos durch. Danach # räumte der Beende-Vorgang das Feld ab, und das reguläre "off" um 21:58:16 # fand keine laufende Fahrt mehr. 36 Minuten Fahrt, live nie gesehen; nur # der Rückblick hat sie später gefunden. # # Die Sperre statt eines früheren Zurücksetzens von fahrt_start_ts: fällt # der Beende-Vorgang unterwegs aus, bleibt der Zwischenstand stehen wie # bisher und der nächste Auslöser schliesst die Fahrt nach. Ein früheres # Löschen hätte sie in dem Fall verloren. async with k.fahrt_sperre: await _signalwechsel(k, neu == "on", ereigniszeit) async def _signalwechsel( k: Koordinator, an_jetzt: bool, ereigniszeit: datetime.datetime | None ) -> None: # Ob eine Fahrt läuft, entscheidet der eigene Zwischenstand # (fahrt_start_ts) - nicht der vorherige Rohwert der Entität. Der wäre bei # einer Funklücke "unavailable" statt "on" gewesen, obwohl die Fahrt die # ganze Zeit lief. Gelesen wird er INNERHALB der Sperre: ein Wechsel, der # auf seinen Vorgänger gewartet hat, muss dessen Ergebnis sehen. fahrt_laeuft = k.fahrt_start_ts is not None jetzt = datetime.datetime.now(datetime.UTC) if an_jetzt and not fahrt_laeuft: beginn = await _geraetezeit(k, ereigniszeit, jetzt) await k.fahrt_start_setzen(beginn) _LOGGER.info( "Fahrt gestartet um %s (%s)", k.fahrt_start_ts, "Gerätezeit" if beginn != jetzt else "Ankunft in HA", ) # Die Anzeige "fährt/steht" hängt an derselben Entität - sofort neu # veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu warten. await k.fahrzeugstatus_veroeffentlichen() return if not an_jetzt and fahrt_laeuft: # Ob hier noch gewartet wird, entscheidet _pausenzeit_s(): liegt ein # Trip-Signal an, bringt das Geraet seine Pausentoleranz selbst mit, # und eine zweite obendrauf haette zwei getrennte Fahrten verschmolzen. # Loest die Zuendung aus, gibt es diese Toleranz nicht - dann ist die # Wartezeit unsere. signal_ende = await _geraetezeit(k, ereigniszeit, jetzt) # Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die # Meldezeit aus einem älteren Datensatz stammt als der Fahrtbeginn - # dann ist die Ankunftszeit die ehrlichere Angabe. if signal_ende < k.fahrt_start_ts: _LOGGER.warning( "Meldezeit %s liegt vor dem Fahrtbeginn %s - nehme die Ankunft", signal_ende.isoformat(), k.fahrt_start_ts.isoformat(), ) signal_ende = jetzt # Erst jetzt die Nachlaeufe des Geraets abziehen: signal_ende ist der # Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung oder # das noch spaetere Trip-Ende -, nicht der, zu dem das Fahrzeug # abgestellt wurde. ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende) pause_s = await _pausenzeit_s(k) if pause_s: _LOGGER.info( "Zuendung aus, Fahrtende waere %s - warte %s s, ob sie zurueckkommt", ende.isoformat(), pause_s, ) k.warte_ende_ab(_nach_pause_beenden(k, k.fahrt_start_ts, ende, pause_s)) await k.fahrzeugstatus_veroeffentlichen() return await fahrt_beenden(k, k.fahrt_start_ts, ende) if not an_jetzt and not fahrt_laeuft: await _spaetes_aus(k, ereigniszeit, jetzt) await k.fahrzeugstatus_veroeffentlichen() async def _pausenzeit_s(k: Koordinator) -> int: """Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden. Null, solange ein Trip-Signal zugeordnet ist: dann wartet das Geraet bereits (Ignition OFF Timeout), und beides zusammen haette aus einer Viertelstunde eine halbe gemacht. Genau daran ist die Wartezeit am 31.08.2026 entfallen - sie kommt hier nur fuer den Weg ueber die Zuendung zurueck. """ if k.zuordnung.werte.TRIP_SENSOR: return 0 try: profil = await k.ablage.profil_lesen() wert = (profil.get("einstellungen") or {}).get("fahrten_pausenzeit_min") minuten = int(wert) except (AttributeError, TypeError, ValueError): minuten = STANDARD_PAUSENZEIT_MIN return max(0, min(minuten, PAUSENZEIT_MAX_MIN)) * 60 async def _nach_pause_beenden( k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime, pause_s: int, ) -> None: """Schliesst die Fahrt, wenn die Zuendung die Wartezeit ueber aus bleibt. Laeuft als Hintergrundaufgabe und bewusst AUSSERHALB von k.fahrt_sperre: der naechste Wechsel bricht sie ueber warte_ende_ab_abbrechen() ab, und das ginge nicht, wenn sie die Sperre die ganze Wartezeit ueber hielte - das "on" stuende dann eine Viertelstunde davor, statt die Fahrt fortzusetzen. Das Schliessen selbst ist gegen den Abbruch abgeschirmt. Ohne shield() koennte ein Wechsel, der genau in diesem Moment eintrifft, mitten im Schreiben abbrechen - und eine halb geschriebene Zeile in fahrten.jsonl waere schlimmer als eine Fahrt zu viel. Das Ende steht schon fest (Zuendungszeitpunkt minus Nachlauf) und wird nicht nachgerechnet: die Wartezeit entscheidet nur, OB geschlossen wird, nicht WANN die Fahrt zu Ende war. """ await asyncio.sleep(pause_s) await asyncio.shield(_jetzt_schliessen(k, start_ts, ende_ts)) async def _jetzt_schliessen( k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime ) -> None: async with k.fahrt_sperre: if k.fahrt_start_ts != start_ts: _LOGGER.info( "Wartezeit abgelaufen, aber die Fahrt von %s laeuft nicht mehr - " "jemand war schneller", start_ts.isoformat(), ) return await fahrt_beenden(k, start_ts, ende_ts) async def _spaetes_aus( k: Koordinator, ereigniszeit: datetime.datetime | None, jetzt: datetime.datetime, ) -> None: """Ein Zuendungs-Aus, waehrend keine Fahrt laeuft. Kommt regelmaessig vor, sobald das Geraet gepuffert hat: die Fahrt ist laengst geschlossen - die Wartezeit ist abgelaufen, oder nach_neustart_fortsetzen() hat aufgeraeumt - und das zugehoerige Aus trifft erst danach ein, unter Umstaenden zusammen mit dem naechsten "on". Entschieden wird ueber die GERAETEZEIT und nicht ueber die Reihenfolge des Eintreffens; dann ist es gleichgueltig, ob das Signal vor, mit oder nach dem naechsten "on" ankommt. Ohne laufende Fahrt gibt es ohnehin nichts zu schliessen - der Unterschied liegt nur im Protokoll, und der ist es wert: ein Aus, das NICHT zur letzten Fahrt gehoert, heisst, dass uns ein Fahrtbeginn entgangen ist. """ ende = await _geraetezeit(k, ereigniszeit, jetzt) echt = ende - datetime.timedelta(seconds=ZUENDUNG_NACHLAUF_S) # Zwei verschiedene Fragen, deshalb zwei Suchen ueber denselben Bestand: # # juengstes_ende beantwortet "gehoert das Aus zur zuletzt geschlossenen # Fahrt?" - das ist der Verwerfen-Zweig unten. # # kandidat beantwortet "gibt es eine von Hand beendete Fahrt, deren Ende # dieses Aus nachtraegt?". Bewusst NICHT auf die juengste Fahrt beschraenkt: # trifft das gepufferte Aus erst ein, nachdem schon eine weitere Fahrt # aufgezeichnet wurde, waere die vorlaeufige sonst fuer immer zu kurz. Es # gewinnt die vorlaeufige Fahrt, deren Ende dem Aus am naechsten VOR ihm # liegt - jede spaetere kann nicht gemeint sein. fahrten = await k.ablage.fahrten_lesen() juengstes_ende = None kandidat = None kandidat_ende = None for fahrt in fahrten: roh = fahrt.get("ts_end") if not roh: continue ts = zeit_normalisiert(roh) if juengstes_ende is None or ts > juengstes_ende: juengstes_ende = ts if fahrt.get("ende_vorlaeufig") and ts < echt: if kandidat_ende is None or ts > kandidat_ende: kandidat, kandidat_ende = fahrt, ts # Eine Fahrt darf nicht ueber eine andere hinweg verlaengert werden. Faengt # zwischen dem vorlaeufigen Ende und dem Aus eine Fahrt an, gehoert das Aus # zu ihr oder zu etwas Spaeterem - jedenfalls nicht mehr zur vorlaeufigen, # und zwei sich ueberlappende Fahrten waeren schlimmer als eine zu kurze. if kandidat is not None and _fahrt_dazwischen(fahrten, kandidat, kandidat_ende, echt): _LOGGER.info( "Zuendungs-Aus (Geraetezeit %s) koennte die vorlaeufige Fahrt %s " "verlaengern, aber dazwischen liegt bereits eine andere Fahrt - " "nicht korrigiert", echt.isoformat(), kandidat["trip_id"], ) kandidat = None if kandidat is not None and await _vorlaeufiges_ende_korrigieren( k, kandidat, kandidat_ende, echt ): return if juengstes_ende is not None and echt <= juengstes_ende + datetime.timedelta( seconds=NACHLAUF_TOLERANZ_S ): _LOGGER.info( "Zuendungs-Aus (Geraetezeit %s) gehoert zur bereits geschlossenen " "Fahrt mit Ende %s - verworfen", echt.isoformat(), juengstes_ende.isoformat(), ) return _LOGGER.warning( "Zuendungs-Aus (Geraetezeit %s) ohne laufende Fahrt, und es gehoert zu " "keiner geschlossenen (letztes Ende %s) - ein Fahrtbeginn ist uns " "entgangen", echt.isoformat(), juengstes_ende.isoformat() if juengstes_ende else "keines", ) def _fahrt_dazwischen( fahrten: list[dict], kandidat: dict, kandidat_ende: datetime.datetime, echt: datetime.datetime, ) -> bool: """Faengt zwischen dem vorlaeufigen Ende und dem Aus eine andere Fahrt an?""" for fahrt in fahrten: if fahrt.get("trip_id") == kandidat.get("trip_id"): continue roh = fahrt.get("ts_start") if not roh: continue beginn = zeit_normalisiert(roh) if kandidat_ende < beginn <= echt: return True return False async def _vorlaeufiges_ende_korrigieren( k: Koordinator, fahrt: dict, ende_bisher: datetime.datetime, echt: datetime.datetime, ) -> bool: """Zieht das Ende einer von Hand beendeten Fahrt nach, sobald das Geraet sein Zuendungs-Aus nachliefert. Gibt zurueck, ob korrigiert wurde. Der Knopf "Fahrt beenden" schliesst auf das letzte Lebenszeichen - mehr weiss in dem Moment niemand. Faehrt das Fahrzeug danach noch, kommt der Rest gepuffert nach, und genau dann gehoert das Ende nachgezogen statt verworfen. Drei Bedingungen, jede aus einem eigenen Grund: nur eine als vorlaeufig gekennzeichnete Fahrt (eine regulaer geschlossene ist bereits richtig - ihr Ende IST dieses Signal, ein zweites waere nur die gepufferte Kopie), nur ein SPAETERES Ende (frueher waere kein Nachtrag, sondern eine Verkuerzung), und nur innerhalb von KORREKTUR_FENSTER_S. Zum Fenster: gemessen wird zwischen dem vorlaeufigen Ende und der GERAETEZEIT des Aus - nicht zwischen Knopfdruck und Eintreffen. Ein Datensatz, der eine Woche in der Tiefgarage liegt, traegt trotzdem seinen eigenen Zeitstempel von damals (siehe verlauf.geraetezeit); das Fenster begrenzt also, wie lange nach dem letzten Lebenszeichen noch gefahren worden sein kann, nicht wie lange das Geraet puffern darf. ts_end wandert bewusst NICHT in edited_fields: das Feld ist keine Handeingabe, und dort eingetragen wuerde es genau diese Korrektur beim naechsten Mal blockieren. """ if not fahrt.get("ende_vorlaeufig"): return False if echt <= ende_bisher: return False if (echt - ende_bisher).total_seconds() > KORREKTUR_FENSTER_S: _LOGGER.warning( "Zuendungs-Aus (Geraetezeit %s) liegt %.0f h nach dem vorlaeufigen " "Ende %s - zu weit auseinander, nicht korrigiert", echt.isoformat(), (echt - ende_bisher).total_seconds() / 3600, ende_bisher.isoformat(), ) return False start_ts = zeit_normalisiert(fahrt["ts_start"]) await k.ablage.fahrt_aktualisieren( fahrt["trip_id"], { "ts_end": echt.isoformat(), "duration_s": int((echt - start_ts).total_seconds()), "ende_vorlaeufig": False, }, ) _LOGGER.info( "Fahrt %s: vorlaeufiges Ende %s auf %s nachgezogen (+%.0f s)", fahrt["trip_id"], ende_bisher.isoformat(), echt.isoformat(), (echt - ende_bisher).total_seconds(), ) await k.fahrten_veroeffentlichen() await screening.durchfuehren(k) return True async def jetzt_beenden(k: Koordinator) -> bool: """Beendet die laufende Fahrt auf Knopfdruck. Gibt zurueck, ob es eine gab. Geschlossen wird auf das LETZTE LEBENSZEICHEN, nicht auf "jetzt": das Fahrzeug stand still, als es still stand, nicht als jemand gedrueckt hat - und in dem Fall, fuer den der Knopf gedacht ist (Tiefgarage ohne Empfang), liegen die beiden weit auseinander. Das Ende gilt als vorlaeufig. Trifft das Zuendungs-Aus des Geraets spaeter gepuffert ein, zieht _vorlaeufiges_ende_korrigieren() es nach. Dieselbe Sperre wie im Beobachter, und die Pruefung auf eine laufende Fahrt steht INNERHALB: zwischen Knopfdruck und Sperre kann sie regulaer geendet haben. """ k.warte_ende_ab_abbrechen() async with k.fahrt_sperre: if k.fahrt_start_ts is None: _LOGGER.info("\"Fahrt beenden\" gedrueckt, aber es laeuft keine Fahrt") return False start_ts = k.fahrt_start_ts ende = await _letztes_lebenszeichen(k, fahrtsignal(k.zuordnung.werte), start_ts) _LOGGER.info( "Fahrt seit %s von Hand beendet, vorlaeufiges Ende %s", start_ts.isoformat(), ende.isoformat(), ) await fahrt_beenden(k, start_ts, ende, vorlaeufig=True) return True async def nach_neustart_fortsetzen(k: Koordinator) -> None: """Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging. Der Zwischenstand einer laufenden Fahrt überlebt jetzt einen Neustart (Store im Koordinator). Damit entsteht aber ein neuer Fall, den es vorher nicht gab: ging die Zündung aus, WÄHREND Home Assistant unten war, hat niemand die Ende-Bestätigung gesehen - der Beobachter bekommt nach dem Hochfahren keinen Wechsel mehr, weil der längst passiert ist. Ohne diese Prüfung bliebe die Fahrt für immer offen, und die nächste echte Fahrt würde an ihren Beginn angehängt. Läuft bewusst erst, wenn Home Assistant vollständig gestartet ist: mitten im Einrichten hat die Datenquelle ihren Zustand oft noch nicht gemeldet, und ein "noch nicht da" ließe sich nicht von "aus" unterscheiden.""" if k.fahrt_start_ts is None: return sensor = fahrtsignal(k.zuordnung.werte) if zustand_oder_none(k.hass, sensor) == "on": return # fährt noch - der Beobachter übernimmt wie sonst auch # Dieselbe Sperre wie im Beobachter: der erste Wechsel des Fahrtsignals # kann eintreffen, während dieser Nachlauf noch schreibt. async with k.fahrt_sperre: await _nachtraeglich_schliessen(k, sensor) async def _nachtraeglich_schliessen(k: Koordinator, sensor: str) -> None: if k.fahrt_start_ts is None: return # der Beobachter war schneller ende_ts = await _letztes_lebenszeichen(k, sensor, k.fahrt_start_ts) _LOGGER.info( "Fahrt seit %s wurde während eines Ausfalls beendet - wird jetzt mit " "Ende %s abgeschlossen", k.fahrt_start_ts.isoformat(), ende_ts.isoformat(), ) await fahrt_beenden(k, k.fahrt_start_ts, ende_ts) async def _letztes_lebenszeichen( k: Koordinator, sensor: str, seit: datetime.datetime ) -> datetime.datetime: """Der Zeitpunkt, zu dem die Fahrtsignal-Entität zuletzt etwas gemeldet hat. Das ist der ehrlichste verfügbare Endzeitpunkt: bis dahin wissen wir, dass das Fahrzeug beobachtet wurde. "Jetzt" zu nehmen wäre falsch, wenn Home Assistant tagelang unten war - daraus würde eine Fahrt über Tage. DREI QUELLEN, IN DIESER REIHENFOLGE - und die zweite gibt es, weil die erste am 03.09.2026 eine falsche Fahrt erzeugt hat: 1. Die Aufzeichnung. Der genaueste Wert, wenn der recorder antwortet. 2. Der Zustandsspeicher. `verlauf_lesen()` liefert eine leere Liste sowohl für "nichts aufgezeichnet" als auch für "nicht lesbar" - und der zweite Fall trat real ein, als der Knopf genau in einen Neustart hinein gedrückt wurde: der recorder war noch nicht bereit, die Abfrage lief 2:47 und kam leer zurück, und die Fahrt bekam "jetzt" als Ende - 74 Minuten statt 30. `hass.states` weiss dasselbe ohne Datenbank und ist sofort da. 3. "Jetzt", aber nicht mehr stillschweigend: wenn beide Quellen nichts hergeben, ist das eine Meldung wert. Das Screening trägt die Kilometerstände ohnehin nach. """ jetzt = datetime.datetime.now(datetime.UTC) punkte = await verlauf_lesen( k.hass, sensor, seit, jetzt, k.zuordnung.werte.MELDEZEIT_SENSOR ) if punkte: return punkte[-1][0] zustand = k.hass.states.get(sensor) if sensor else None if zustand is not None: # last_reported zählt jede Meldung, last_updated nur einen Wechsel - # für "wann haben wir zuletzt etwas gehört" ist das erste genauer. gemeldet = getattr(zustand, "last_reported", None) or zustand.last_updated if gemeldet is not None and gemeldet >= seit: _LOGGER.info( "Verlauf von %s nicht verfügbar - Ende aus dem Zustandsspeicher: %s", sensor, gemeldet.isoformat(), ) return gemeldet _LOGGER.warning( "Kein Lebenszeichen von %s seit %s - die Fahrt endet auf \"jetzt\", " "was zu lang sein kann", sensor, seit.isoformat(), ) return jetzt async def fahrt_beenden( k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime, vorlaeufig: bool = False, ) -> None: dauer_s = int((ende_ts - start_ts).total_seconds()) if dauer_s < MINDESTDAUER_S: # Zündung an, aber nicht gefahren - oder ein prellender # Zündungseingang. Der Import verwirft solche Fenster seit jeher; # hier taten wir es nicht, und der Bestand füllte sich mit # Nulleinträgen (siehe MINDESTDAUER_S in verlauf.py). Der laufende # Start wird trotzdem zurückgesetzt, sonst hinge er für immer. await k.fahrt_start_setzen(None) _LOGGER.info( "Zündung war nur %s s an - keine Fahrt angelegt (Grenze %s s)", dauer_s, MINDESTDAUER_S, ) return fahrt = leere_fahrt(start_ts, ende_ts, "ha", reifen.aktiver_satz(await k.ablage.profil_lesen())) fahrt["ende_vorlaeufig"] = vorlaeufig await k.ablage.fahrt_anhaengen(fahrt) await k.fahrt_start_setzen(None) await k.fahrten_veroeffentlichen() _LOGGER.info( "Fahrt %s beendet, Status 'offen', Screening angestoßen", fahrt["trip_id"] ) await screening.durchfuehren(k) # ------------------------------------------------------- Aus der Oberfläche HANDFELDER = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"} def _handfelder( start_ort: str | None, ziel_ort: str | None, odo_start: float | None, odo_end: float | None, distanz: float | None, ) -> dict: """Die von Hand befüllbaren Felder auf ihre Datensatz-Namen abgebildet, ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel zusätzlich nach edited_fields - das schützt sie vor dem automatischen Kilometerstand-Screening, das sonst später darüberschreiben würde.""" werte = { "start_address": start_ort or None, "end_address": ziel_ort or None, "odo_start": odo_start, "odo_end": odo_end, "distance_km": distanz, } felder = {k: v for k, v in werte.items() if v is not None} # Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen - # dieselbe Rechnung wie im Screening. if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder: felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1) return felder async def manuell_anlegen(k: Koordinator, daten: dict) -> None: """Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der 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 = 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"), ) fahrt = leere_fahrt( start_ts, ende_ts, "manual", reifen.aktiver_satz(await k.ablage.profil_lesen()) ) fahrt["art"] = daten.get("art") or "privat" fahrt.update(hand) fahrt["edited_fields"] = sorted(hand) if fahrt["distance_km"] is not None: fahrt["km_quelle"] = "manuell" fahrt["status"] = "vollständig" await k.ablage.fahrt_anhaengen(fahrt) await k.fahrten_veroeffentlichen() await screening.durchfuehren(k) _LOGGER.info("Fahrt %s von Hand angelegt", fahrt["trip_id"]) async def aktualisieren(k: Koordinator, daten: dict) -> None: """Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie automatisch erkannt oder von Hand angelegt wurde.""" trip_id = daten.get("trip_id") if not trip_id: _LOGGER.warning("fahrt_aktualisieren ohne trip_id aufgerufen") return aenderungen = _handfelder( daten.get("start_ort"), daten.get("ziel_ort"), daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"), ) if daten.get("art"): aenderungen["art"] = daten["art"] if daten.get("ts_start") and daten.get("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()) if aenderungen.get("distance_km") is not None: aenderungen["km_quelle"] = "manuell" aenderungen["status"] = "vollständig" # Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit, # damit ein späteres Screening sie nicht wieder überschreibt. passend = [f for f in await k.ablage.fahrten_lesen() if f.get("trip_id") == trip_id] bisher = set(passend[0].get("edited_fields", [])) if passend else set() aenderungen["edited_fields"] = sorted(bisher | (HANDFELDER & set(aenderungen))) if await k.ablage.fahrt_bearbeiten(trip_id, aenderungen): await k.fahrten_veroeffentlichen() _LOGGER.info("Fahrt %s aktualisiert", trip_id) else: _LOGGER.warning("Fahrt %s nicht gefunden", trip_id) async def loeschen(k: Koordinator, trip_id: str | None) -> None: if not trip_id: _LOGGER.warning("fahrt_loeschen ohne trip_id aufgerufen") return if await k.ablage.fahrt_loeschen(trip_id): await k.fahrten_veroeffentlichen() _LOGGER.info("Fahrt %s gelöscht", trip_id) else: _LOGGER.warning("Fahrt %s nicht gefunden", trip_id)