Files
audi-app/custom_components/audi_dashboard/fahrterkennung.py
T
tobias 5ccbfec92d Fahrt beenden auf Knopfdruck, Schieberegler, Zuendungspause (2026.9.3.5-.7)
Vier Themen aus einer Sitzung.

1. Die Fahrt endet wieder bei uns statt im Dongle (Abschnitt BW). Der
   Ignition-OFF-Timeout des FMM003 (900 s) und sein Schlaf-Timeout (ebenfalls
   900 s) starten beide beim Zuendungs-Aus und fallen in derselben Sekunde -
   am 02.09. zweimal beobachtet, einmal ging das Trip-Ende verloren, einmal kam
   es eine Sekunde vor dem Schlaf an. Ausloeser ist jetzt die Zuendung, die
   Wartezeit laeuft in Home Assistant. ZUENDUNG_NACHLAUF_S = 180 wird in beiden
   Wegen abgezogen (ueber das Trip-Signal 1080 s, weil dessen Ende selbst an der
   verzoegerten Zuendung haengt).

2. Schieberegler, neu in beiden Codebasen. Schrittweite 5 Minuten; der Daumen
   war im Tagmodus weiss auf weiss und traegt jetzt einen Ring aus
   --line-strong - ein Token, das genau dort sichtbar ist, wo es gebraucht wird.

3. Knopf "Fahrt beenden" in der Zuletzt-Kachel (Abschnitt BX). Schliesst auf
   das letzte Lebenszeichen, nicht auf "jetzt", und kennzeichnet das Ende als
   vorlaeufig. Ein spaet eintreffendes Zuendungs-Aus zieht es nach - innerhalb
   von sechs Stunden, nur nach vorn, und nur bei einer vorlaeufigen Fahrt.
   ts_end wandert bewusst NICHT in edited_fields, sonst blockierte der Schutz
   fuer Handeingaben genau diese Korrektur.

4. Das OTA-Buendel kann auf dem Mac gar nicht entstehen (Abschnitt BV):
   npm run ota ist eine Windows-PowerShell-Datei, ios-signieren.sh baut ein
   frisches dist/ und fasst das Buendel nie an. Ausgeliefert war deshalb eine
   Oberflaeche ohne den Versionshinweis unter richtiger Nummer.

Verifiziert: Backend 27/27 (5 Signalwechsel, 14 Zuendungspause, 8 Fahrtende),
companion-app tsc sauber und 179/179, Panel als Modul geparst, audi_ha_test auf
2026.9.3.7 sauber gestartet. Live am laufenden Panel und ohne Rueckstand
belegt: Wartezeit samt Abbruch, die 180-s-Rechnung, der Regler im Tagmodus und
der Knopf von der laufenden Fahrt bis zum verworfenen Kurzvorgang - Fahrten
vorher 16, nachher 16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:00:38 +02:00

798 lines
34 KiB
Python

"""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,
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 _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)
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)
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)
juengste = None
juengstes_ende = None
for fahrt in await k.ablage.fahrten_lesen():
roh = fahrt.get("ts_end")
if not roh:
continue
ts = zeit_normalisiert(roh)
if juengstes_ende is None or ts > juengstes_ende:
juengste, juengstes_ende = fahrt, ts
if juengste is not None and await _vorlaeufiges_ende_korrigieren(
k, juengste, juengstes_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",
)
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),
nur ein SPAETERES Ende (frueher waere kein Nachtrag, sondern eine
Verkuerzung), und nur innerhalb von KORREKTUR_FENSTER_S (sonst gehoert das
Aus wahrscheinlich zu einer ganz anderen Fahrt).
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 zuletzt aufgezeichnete Zeitpunkt der Zündungs-Entität seit `seit`.
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. Gibt es
gar keine Aufzeichnung (Sensor nicht zugeordnet, recorder zu kurz), bleibt
nur "jetzt"; das Screening trägt die Kilometerstände ohnehin nach."""
jetzt = datetime.datetime.now(datetime.UTC)
punkte = await verlauf_lesen(k.hass, sensor, seit, jetzt)
return punkte[-1][0] if punkte else 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)