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