Files
audi-app/custom_components/audi_dashboard/fahrterkennung.py
T
tobias bcfd54489b Das Ende einer verschlafenen Fahrt richtig bestimmen
Drei Fehler, die zusammen eine 5-Minuten-Fahrt als 15 Stunden mit 0,1 km/h
im Bestand stehen liessen:

- nach_neustart_fortsetzen() schloss eine laufende Fahrt bei ALLEM ausser
  "on" - auch bei Funkstille. Jetzt nur noch bei ausdruecklichem "off".
- _letztes_lebenszeichen() las nur die Zuendungsentitaet, die nur bei einem
  Wechsel schreibt. Zusaetzliche Quelle ist die Meldezeit-Entitaet, deren
  Werte die Geraetezeit jedes Datensatzes sind.
- Strecke und Dauer decken bei einer Luecke verschiedene Zeitraeume ab: der
  Durchschnitt entfaellt, und die Plausibilitaetspruefung rechnet ohne die
  verschlafenen Kilometer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:08:52 +02:00

1043 lines
44 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,
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,
# Kilometer am Anfang der Fahrt, die das Geraet verschlafen hat -
# None heisst "keine Luecke bekannt". Siehe
# verlauf.aufzeichnungsluecke_km().
"luecke_km": None,
# True, wenn schon das Ende der Vorgaengerin nur vorlaeufig war - dann
# ist die Zuordnung der Luecke zu DIESER Fahrt nicht gesichert.
"luecke_unsicher": False,
"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
zustand = zustand_oder_none(k.hass, sensor)
if zustand != "off":
# "on" heisst: faehrt noch, der Beobachter uebernimmt wie sonst auch.
#
# Alles ANDERE - None, "unavailable", "unknown" - heisst Funkstille,
# nicht "aus". Genau diese Regel steht in zuendung_geaendert() seit
# jeher, hier fehlte sie: stand die Entitaet beim Hochfahren noch nicht
# bereit (die flespi-Anbindung baut ihre MQTT-Verbindung erst auf),
# las diese Funktion "nicht on" und schloss eine laufende Fahrt - mit
# einem Ende aus _letztes_lebenszeichen(), im schlimmsten Fall "jetzt".
#
# Eine wirklich beendete Fahrt geht dadurch nicht verloren: kommt das
# gepufferte "aus" spaeter an, schliesst der Beobachter sie, und
# _spaetes_aus() faengt sogar den Fall ab, dass sie da schon zu ist.
return
# 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)
kandidaten: list[datetime.datetime] = []
punkte = await verlauf_lesen(
k.hass, sensor, seit, jetzt, k.zuordnung.werte.MELDEZEIT_SENSOR
)
if punkte:
kandidaten.append(punkte[-1][0])
# Die Zuendungsentitaet schreibt nur bei einem WECHSEL eine Zeile. Bleibt
# die Zuendung stundenlang an, ist ihr letzter Verlaufspunkt der Beginn der
# Fahrt - und ein Ende daraus waere die Fahrtdauer null.
#
# Am 06.09.2026 gemessen: die Fahrt vom Vorabend hatte seit 23:13:08 genau
# drei Zeilen, alle aus derselben Sekunde, obwohl das Geraet danach noch
# zwoelf Minuten lang Daten schickte.
#
# Die Meldezeit-Entitaet dagegen bekommt zu JEDEM Datensatz einen neuen
# Wert - und dieser Wert IST die Geraetezeit. Sie ist damit die ehrlichste
# Antwort auf "bis wann haben wir das Fahrzeug beobachtet".
for zeitpunkt in await _meldezeiten(k, seit, jetzt):
kandidaten.append(zeitpunkt)
if kandidaten:
return max(kandidaten)
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 _meldezeiten(
k: Koordinator, seit: datetime.datetime, bis: datetime.datetime
) -> list[datetime.datetime]:
"""Die Geraetezeiten aller Datensaetze im Fenster, aufsteigend.
Die Werte der Meldezeit-Entitaet SIND Unix-Sekunden in UTC (siehe
verlauf.geraetezeit()) - hier wird deshalb nicht umgestempelt, sondern der
Wert selbst gelesen. Werte ausserhalb des Fensters fallen heraus: ein
gepufferter Datensatz kann eine Geraetezeit vor dem Fahrtbeginn tragen."""
sensor = k.zuordnung.werte.MELDEZEIT_SENSOR
if not sensor:
return []
zeiten: list[datetime.datetime] = []
for _ankunft, roh in await verlauf_lesen(k.hass, sensor, seit, bis):
try:
wert = datetime.datetime.fromtimestamp(float(roh), datetime.UTC)
except (TypeError, ValueError, OSError, OverflowError):
continue
if seit <= wert <= bis:
zeiten.append(wert)
zeiten.sort()
return zeiten
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)