2af5c469d6
Ortsauflösung serverseitig (geokodierung.py): der Ort steht jetzt in der Fahrt statt im Zwischenspeicher jedes Geräts. Beide Oberflächen fragen nichts mehr ab. Verbrauchskorrektur (verbrauchskorrektur.py): Faktor = Beleg-Liter / Summe der Einzelfahrten, voll zu voll. Statt einer Schranke am Faktor wird der Nenner geprüft - decken die erkannten Fahrten 90-110 % der Tacho-Spanne ab? Ausgelöst beim Schreiben eines Tankvorgangs, nicht erst bei der nächsten Fahrt. Radzähler: summiert die Fahrten des montierten Satzes statt Tacho-Deltas. Der alte Weg hatte Fahrzeugwechsel als gefahrene Strecke verbucht (375.962 km bei einem Tacho von 61.823). Null heißt unbekannt: ein Kilometerstand von 0 oder ein leeres Feld werden zu None normalisiert, statt an sechs Lesestellen als "Tachostand null" zu gelten. Momentanwerte der Sensoren gelten nur für einen Tankvorgang von jetzt. Oberfläche, beide Codebasen: Wertespalte der Fahrtenliste ausgerichtet, Ort -> Ort in "Zuletzt", "Räder" statt "Reifen", "Termin vereinbart" entfernt, Markenlogo 20 % größer, Übersicht-Symbol auf volle Rasterbreite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
482 lines
20 KiB
Python
482 lines
20 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 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
|
|
|
|
|
|
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,
|
|
"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.
|
|
|
|
NUR wenn die Fahrten wirklich vom Trip-Signal ausgeloest werden. Ist
|
|
TRIP_SENSOR nicht zugeordnet, uebernimmt die Zuendung diese Aufgabe
|
|
(verlauf.fahrtsignal()) - und die hat keinen Nachlauf. Dort abzuziehen
|
|
wuerde jede Fahrt um eine Viertelstunde verkuerzen und jede kuerzere ganz
|
|
verschwinden lassen. Der Nachlauf gehoert zum Trip-Element des Geraets,
|
|
nicht zu uns; ohne dieses Element gibt es ihn nicht."""
|
|
if not k.zuordnung.werte.TRIP_SENSOR:
|
|
return signal_ende
|
|
gerechnet = signal_ende - datetime.timedelta(seconds=NACHLAUF_S)
|
|
if gerechnet <= start_ts:
|
|
_LOGGER.info(
|
|
"Trip-Signal lief %s s, also nicht laenger als der Nachlauf (%s s) - "
|
|
"es wurde nicht gefahren",
|
|
int((signal_ende - start_ts).total_seconds()), NACHLAUF_S,
|
|
)
|
|
return start_ts
|
|
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
|
|
abweichung = abs((aus[-1] - 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()
|
|
|
|
an_jetzt = neu == "on"
|
|
# 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.
|
|
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:
|
|
# Ohne Wartezeit: der FMM003 bringt die Pausenregel selbst mit
|
|
# (Trip Odometer -> "Ignition OFF Timeout" = 900 s). Was hier
|
|
# ankommt, ist bereits eine beendete Fahrt - noch einmal 15 Minuten
|
|
# zu warten haette daraus eine halbe Stunde gemacht und zwei
|
|
# aufeinanderfolgende Fahrten verschmolzen, die das Geraet sauber
|
|
# getrennt hatte.
|
|
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 TRIP-SIGNAL endete, nicht der, zu dem das
|
|
# Fahrzeug abgestellt wurde.
|
|
ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende)
|
|
await fahrt_beenden(k, k.fahrt_start_ts, ende)
|
|
|
|
await k.fahrzeugstatus_veroeffentlichen()
|
|
|
|
|
|
|
|
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
|
|
|
|
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
|
|
) -> 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()))
|
|
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)
|