"""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, 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. # 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 wird abgezogen. # # (Bis zum 05.09.2026 gab es einen zweiten Weg ueber das Trip-Signal des # Geraets, dessen Ende an derselben verzoegerten Zuendung hing und deshalb noch # einmal 900 s spaeter kam. Das Trip-Szenario ist am Geraet abgeschaltet und # der Sensor aus der App entfernt - siehe AGENTS.md.) # # 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. # # Die Konfiguration des Geraets (Parameter 400) 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. Die Zuendung wird vom Geraet verzoegert gemeldet; 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. Das Zuendungselement meldet selbst schon verzoegert (ZUENDUNG_NACHLAUF_S); genau diese Spanne wird abgezogen. Bis zum 03.09.2026 geschah das nicht, und 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 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 # 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 _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. Stand die Zuendung 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 # Frueher stand hier ein bedingungsloses warte_ende_ab_abbrechen(). Das # war der Fehler vom 04.09.2026: ein gepuffertes "an" loeschte damit das # wartende Ende eines gepufferten "aus", das in DERSELBEN Sekunde # angekommen war - obwohl zwischen beiden in Geraetezeit fuenf Stunden # lagen. Abgebrochen wird jetzt in _signalwechsel(), wo die Geraetezeit # bekannt ist und die Pausenregel wirklich rechnen kann. # 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: # `neu != alt` heisst: das ist wirklich ein Wechsel. Home Assistant # schreibt denselben Wert auch neu, wenn sich nur ein Attribut geruehrt # hat - am 04.09.2026 gezaehlt: von 22 Zustandszeilen der Zuendung # waren nur 6 echte Wechsel, die uebrigen 16 blosse Neuschreibungen. # Was das unterscheidet, braucht nur Zweig 2 (siehe dort). await _signalwechsel(k, neu == "on", ereigniszeit, neu != alt) async def _signalwechsel( k: Koordinator, an_jetzt: bool, ereigniszeit: datetime.datetime | None, wechsel: bool = True, ) -> 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: k.warte_ende_ab_abbrechen() 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: # Eine blosse Neuschreibung von "aus", waehrend schon eine Bestaetigung # fuer DIESE Fahrt wartet, aendert nichts - und darf die Wartezeit # deshalb nicht von vorn beginnen lassen. # # Ohne diese Pruefung schob jede Neuschreibung das Schliessen um # weitere 15 Minuten hinaus. Am 04.09.2026 ist es nicht passiert - die # Neuschreibungen lagen 19,7 Minuten nach dem echten "aus" und damit # hinter dem Fenster -, aber das Loch war da. # # NUR HIER, nicht am Anfang von zuendung_geaendert(): _spaetes_aus() # weiter unten verlaesst sich ausdruecklich darauf, ein Zuendungs-Aus # auch dann zu sehen, wenn es "unter Umstaenden zusammen mit dem # naechsten on" ankommt. Ein zweites, spaeter gepuffertes Aus bei # bereits stehendem "off" waere ein off -> off - und genau daran haengt # das Nachtragen vorlaeufiger Enden. if ( not wechsel and k.ende_wartet is not None and k.ende_wartet[0] == k.fahrt_start_ts ): _LOGGER.debug( "Zuendung erneut als aus gemeldet, ohne Wechsel - die " "Bestaetigung fuer die Fahrt seit %s laeuft weiter", k.fahrt_start_ts.isoformat(), ) await k.fahrzeugstatus_veroeffentlichen() return # Ein neues "aus" ersetzt ein aelteres, das noch wartet. k.warte_ende_ab_abbrechen() # Wie lange gewartet wird, entscheidet _pausenzeit_s() - die # Wartezeit ist unsere, das Geraet bringt keine mit. 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 den Nachlauf des Geraets abziehen: signal_ende ist der # Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung -, # 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), (k.fahrt_start_ts, ende), ) await k.fahrzeugstatus_veroeffentlichen() return await fahrt_beenden(k, k.fahrt_start_ts, ende) if an_jetzt and fahrt_laeuft: await _zuendung_kehrt_zurueck(k, ereigniszeit, jetzt) if not an_jetzt and not fahrt_laeuft: k.warte_ende_ab_abbrechen() await _spaetes_aus(k, ereigniszeit, jetzt) await k.fahrzeugstatus_veroeffentlichen() async def _zuendung_kehrt_zurueck( k: Koordinator, ereigniszeit: datetime.datetime | None, jetzt: datetime.datetime ) -> None: """Die Zuendung geht an, waehrend bei uns noch eine Fahrt laeuft. ZWEI FAELLE, UND SIE UNTERSCHEIDEN SICH NUR IN DER GERAETEZEIT ------------------------------------------------------------- (a) Der Fahrer war kurz weg - tanken, Brot holen. Dann gehoert das hier noch zur selben Fahrt, und das wartende Ende faellt weg. (b) Die Fahrt war laengst zu Ende, aber ihr "aus" kam verspaetet - und das "an" der NAECHSTEN Fahrt gleich hinterher. Dann sind es zwei Fahrten. Am 04.09.2026 gemessen: beide Ereignisse kamen in DERSELBEN Sekunde bei Home Assistant an (23:36:50), ihre Geraetezeiten lagen aber 5 Stunden 12 auseinander - 18:23:32 und 23:35:43. Nach der Ankunft gerechnet ist das Fall (a), nach der Geraetezeit Fall (b). Gerechnet wurde nach der Ankunft, und deshalb hat eine Fahrt von 18:10 die naechste um 23:30 verschluckt: ein Eintrag ueber 5 Stunden 33, ohne Strecke, und die zweite Fahrt gab es gar nicht. Entschieden wird deshalb in Geraetezeit - der Zeit, zu der das Fahrzeug wirklich stand und wirklich wieder losfuhr. OHNE WARTENDES ENDE passiert weiterhin nichts. Dann gab es kein "aus", auf das sich ein "an" beziehen koennte: entweder ist die Zuendung nie aus gewesen (dann ist das hier eine Wiederholung), oder das "aus" fehlt ganz - und eine Fahrt auf Verdacht zu zerschneiden waere schlimmer, als sie zu lang stehen zu lassen. """ wartet = k.ende_wartet if wartet is None: return start_ts, ende_ts = wartet if k.fahrt_start_ts != start_ts: # Zwischenzeitlich hat jemand anders geschlossen - dann gehoert das # wartende Ende nicht mehr zu dieser Fahrt. k.warte_ende_ab_abbrechen() return beginn = await _geraetezeit(k, ereigniszeit, jetzt) pause_s = await _pausenzeit_s(k) stand_s = (beginn - ende_ts).total_seconds() k.warte_ende_ab_abbrechen() if stand_s <= pause_s: _LOGGER.info( "Zuendung nach %.0f s wieder an - innerhalb der Pause von %s s, " "dieselbe Fahrt", stand_s, pause_s, ) return _LOGGER.info( "Zuendung nach %.0f s wieder an - laenger als die Pause von %s s. " "Fahrt seit %s wird auf %s geschlossen, neue Fahrt ab %s", stand_s, pause_s, start_ts.isoformat(), ende_ts.isoformat(), beginn.isoformat(), ) await fahrt_beenden(k, start_ts, ende_ts) await k.fahrt_start_setzen(beginn) async def _pausenzeit_s(k: Koordinator) -> int: """Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden. So lange darf eine zurueckkehrende Zuendung noch dieselbe Fahrt sein - Tanken, kurze Besorgung. Der Wert kommt aus dem Regler "Fahrt beenden", derselbe, der auch den Schlaf-Timeout des Dongles stellt (flespi.py). Gemessen wird er in GERAETEZEIT, nicht an der Wanduhr - siehe _zuendung_kehrt_zurueck(). """ 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, k.zuordnung.werte.ZUENDUNG_SENSOR, 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 = k.zuordnung.werte.ZUENDUNG_SENSOR 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)