Files
audi-app/custom_components/audi_dashboard/fahrterkennung.py
T
tobias 4d4890034a Regler stellt den Schlaf-Timeout des Dongles (2026.9.3.10)
Der Regler "Fahrt beenden" ist jetzt eine Zahl fuer zwei Dinge: unsere eigene
Wartezeit bis zum Fahrtende und der Schlaf-Timeout des FMM003 (103). So lange
bleibt der Dongle wach und wartet darauf, dass es gleich weitergeht; danach
schlaeft er. Die 180 s Ignition OFF Delay laufen davor und bleiben unsichtbar -
sie werden nur von der Fahrtdauer abgezogen. Der Regler geht deshalb von 1 bis
60 in Einerschritten; das Geraet kennt kein Timeout 0.

flespi.py liest und schreibt die Geraete-Konfiguration. Drei Annahmen sind an
der echten API gefallen und stehen als Messung im Modulkopf: der Wert liegt in
current (nicht value), die Einstellung heisst sleep_mode mit dem Wert eine Ebene
tiefer unter mode, und ein PUT braucht {"properties": ..., "address": ...}.

Die eigene Auftrags-Warteschlange ist wieder entfallen. Sie war fuer "das Geraet
schlaeft" gebaut - das gilt aber nur fuer das Geraet, nicht fuer die
Schnittstelle: die vollstaendige Konfiguration kam herein, waehrend das Fahrzeug
seit dem Vortag stand. flespi puffert selbst (pending + address=connection), ein
zweites Auftragsbuch waere eine zweite Warteschlange fuer dieselbe Aufgabe.

Gegengelesen wird nach jedem Schreiben; als Erfolg zaehlt current ODER pending -
letzteres ist bei stehendem Fahrzeug der Normalfall.

Live gegen den echten Account belegt: Token sieht genau ein Geraet, 127
Einstellungen bei parkendem Fahrzeug gelesen, Regler 15 geschrieben und als
pending bestaetigt (Nachbarfelder unveraendert), zweiter Neustart ohne erneute
Anfrage.

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

853 lines
36 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)
# 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 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)