Fahrt beenden auf Knopfdruck, Schieberegler, Zuendungspause (2026.9.3.5-.7)
Vier Themen aus einer Sitzung. 1. Die Fahrt endet wieder bei uns statt im Dongle (Abschnitt BW). Der Ignition-OFF-Timeout des FMM003 (900 s) und sein Schlaf-Timeout (ebenfalls 900 s) starten beide beim Zuendungs-Aus und fallen in derselben Sekunde - am 02.09. zweimal beobachtet, einmal ging das Trip-Ende verloren, einmal kam es eine Sekunde vor dem Schlaf an. Ausloeser ist jetzt die Zuendung, die Wartezeit laeuft in Home Assistant. ZUENDUNG_NACHLAUF_S = 180 wird in beiden Wegen abgezogen (ueber das Trip-Signal 1080 s, weil dessen Ende selbst an der verzoegerten Zuendung haengt). 2. Schieberegler, neu in beiden Codebasen. Schrittweite 5 Minuten; der Daumen war im Tagmodus weiss auf weiss und traegt jetzt einen Ring aus --line-strong - ein Token, das genau dort sichtbar ist, wo es gebraucht wird. 3. Knopf "Fahrt beenden" in der Zuletzt-Kachel (Abschnitt BX). Schliesst auf das letzte Lebenszeichen, nicht auf "jetzt", und kennzeichnet das Ende als vorlaeufig. Ein spaet eintreffendes Zuendungs-Aus zieht es nach - innerhalb von sechs Stunden, nur nach vorn, und nur bei einer vorlaeufigen Fahrt. ts_end wandert bewusst NICHT in edited_fields, sonst blockierte der Schutz fuer Handeingaben genau diese Korrektur. 4. Das OTA-Buendel kann auf dem Mac gar nicht entstehen (Abschnitt BV): npm run ota ist eine Windows-PowerShell-Datei, ios-signieren.sh baut ein frisches dist/ und fasst das Buendel nie an. Ausgeliefert war deshalb eine Oberflaeche ohne den Versionshinweis unter richtiger Nummer. Verifiziert: Backend 27/27 (5 Signalwechsel, 14 Zuendungspause, 8 Fahrtende), companion-app tsc sauber und 179/179, Panel als Modul geparst, audi_ha_test auf 2026.9.3.7 sauber gestartet. Live am laufenden Panel und ohne Rueckstand belegt: Wartezeit samt Abbruch, die 180-s-Rechnung, der Regler im Tagmodus und der Knopf von der laufenden Fahrt bis zum verworfenen Kurzvorgang - Fahrten vorher 16, nachher 16. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,7 @@ D_ENTITAETEN_SCHREIBEN = "entitaeten_schreiben"
|
||||
D_FAHRT_MANUELL_ANLEGEN = "fahrt_manuell_anlegen"
|
||||
D_FAHRT_AKTUALISIEREN = "fahrt_aktualisieren"
|
||||
D_FAHRT_LOESCHEN = "fahrt_loeschen"
|
||||
D_FAHRT_JETZT_BEENDEN = "fahrt_jetzt_beenden"
|
||||
D_SCREENING_JETZT = "screening_jetzt"
|
||||
D_BELEG_HOCHLADEN = "beleg_hochladen"
|
||||
D_TANKVORGANG_MANUELL = "tankvorgang_manuell"
|
||||
|
||||
@@ -51,6 +51,7 @@ from .const import (
|
||||
D_CSV_IMPORTIEREN,
|
||||
D_ENTITAETEN_SCHREIBEN,
|
||||
D_FAHRT_AKTUALISIEREN,
|
||||
D_FAHRT_JETZT_BEENDEN,
|
||||
D_FAHRT_LOESCHEN,
|
||||
D_FAHRT_MANUELL_ANLEGEN,
|
||||
D_HISTORIE_IMPORTIEREN,
|
||||
@@ -132,6 +133,7 @@ SCHEMATA: dict[str, vol.Schema] = {
|
||||
**_FAHRT_FELDER,
|
||||
}),
|
||||
D_FAHRT_LOESCHEN: vol.Schema({vol.Required("trip_id"): cv.string}),
|
||||
D_FAHRT_JETZT_BEENDEN: vol.Schema({}),
|
||||
D_BELEG_HOCHLADEN: vol.Schema({
|
||||
vol.Required("pdf_base64"): cv.string,
|
||||
vol.Optional("dateiname"): _TEXT,
|
||||
@@ -348,6 +350,7 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
||||
D_FAHRT_AKTUALISIEREN: _als_dienst(
|
||||
lambda a: fahrterkennung.aktualisieren(k, dict(a.data))
|
||||
),
|
||||
D_FAHRT_JETZT_BEENDEN: _als_dienst(lambda _a: fahrterkennung.jetzt_beenden(k)),
|
||||
D_FAHRT_LOESCHEN: _als_dienst(
|
||||
lambda a: fahrterkennung.loeschen(k, a.data["trip_id"])
|
||||
),
|
||||
|
||||
@@ -19,6 +19,7 @@ hier behoben: der Zwischenstand liegt in einem Store (siehe koordinator.py),
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -72,6 +73,50 @@ NACHLAUF_S = 900
|
||||
# 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,
|
||||
@@ -123,6 +168,12 @@ def leere_fahrt(
|
||||
"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": [],
|
||||
}
|
||||
@@ -147,23 +198,28 @@ async def _echtes_ende(
|
||||
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:
|
||||
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_S)
|
||||
gerechnet = signal_ende - datetime.timedelta(seconds=nachlauf)
|
||||
if gerechnet <= start_ts:
|
||||
_LOGGER.info(
|
||||
"Trip-Signal lief %s s, also nicht laenger als der Nachlauf (%s s) - "
|
||||
"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,
|
||||
int((signal_ende - start_ts).total_seconds()), nachlauf,
|
||||
)
|
||||
return start_ts
|
||||
await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet)
|
||||
if ueber_trip:
|
||||
await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet)
|
||||
return gerechnet
|
||||
|
||||
|
||||
@@ -192,7 +248,12 @@ async def _nachlauf_gegenpruefen(
|
||||
aus = [ts for ts, wert in punkte if wert == "off"]
|
||||
if not aus:
|
||||
return
|
||||
abweichung = abs((aus[-1] - gerechnet).total_seconds())
|
||||
# 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 "
|
||||
@@ -305,12 +366,11 @@ async def _signalwechsel(
|
||||
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.
|
||||
# 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 -
|
||||
@@ -321,15 +381,229 @@ async def _signalwechsel(
|
||||
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.
|
||||
# Erst jetzt die Nachlaeufe des Geraets abziehen: signal_ende ist der
|
||||
# Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung oder
|
||||
# das noch spaetere Trip-Ende -, nicht der, zu dem das Fahrzeug
|
||||
# abgestellt wurde.
|
||||
ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende)
|
||||
pause_s = await _pausenzeit_s(k)
|
||||
if pause_s:
|
||||
_LOGGER.info(
|
||||
"Zuendung aus, Fahrtende waere %s - warte %s s, ob sie zurueckkommt",
|
||||
ende.isoformat(), pause_s,
|
||||
)
|
||||
k.warte_ende_ab(_nach_pause_beenden(k, k.fahrt_start_ts, ende, pause_s))
|
||||
await k.fahrzeugstatus_veroeffentlichen()
|
||||
return
|
||||
await fahrt_beenden(k, k.fahrt_start_ts, ende)
|
||||
|
||||
if not an_jetzt and not fahrt_laeuft:
|
||||
await _spaetes_aus(k, ereigniszeit, jetzt)
|
||||
|
||||
await k.fahrzeugstatus_veroeffentlichen()
|
||||
|
||||
|
||||
async def _pausenzeit_s(k: Koordinator) -> int:
|
||||
"""Unsere eigene Wartezeit nach dem Zuendungs-Aus, in Sekunden.
|
||||
|
||||
Null, solange ein Trip-Signal zugeordnet ist: dann wartet das Geraet
|
||||
bereits (Ignition OFF Timeout), und beides zusammen haette aus einer
|
||||
Viertelstunde eine halbe gemacht. Genau daran ist die Wartezeit am
|
||||
31.08.2026 entfallen - sie kommt hier nur fuer den Weg ueber die Zuendung
|
||||
zurueck.
|
||||
"""
|
||||
if k.zuordnung.werte.TRIP_SENSOR:
|
||||
return 0
|
||||
try:
|
||||
profil = await k.ablage.profil_lesen()
|
||||
wert = (profil.get("einstellungen") or {}).get("fahrten_pausenzeit_min")
|
||||
minuten = int(wert)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
minuten = STANDARD_PAUSENZEIT_MIN
|
||||
return max(0, min(minuten, PAUSENZEIT_MAX_MIN)) * 60
|
||||
|
||||
|
||||
async def _nach_pause_beenden(
|
||||
k: Koordinator,
|
||||
start_ts: datetime.datetime,
|
||||
ende_ts: datetime.datetime,
|
||||
pause_s: int,
|
||||
) -> None:
|
||||
"""Schliesst die Fahrt, wenn die Zuendung die Wartezeit ueber aus bleibt.
|
||||
|
||||
Laeuft als Hintergrundaufgabe und bewusst AUSSERHALB von k.fahrt_sperre:
|
||||
der naechste Wechsel bricht sie ueber warte_ende_ab_abbrechen() ab, und das
|
||||
ginge nicht, wenn sie die Sperre die ganze Wartezeit ueber hielte - das
|
||||
"on" stuende dann eine Viertelstunde davor, statt die Fahrt fortzusetzen.
|
||||
|
||||
Das Schliessen selbst ist gegen den Abbruch abgeschirmt. Ohne shield()
|
||||
koennte ein Wechsel, der genau in diesem Moment eintrifft, mitten im
|
||||
Schreiben abbrechen - und eine halb geschriebene Zeile in fahrten.jsonl
|
||||
waere schlimmer als eine Fahrt zu viel.
|
||||
|
||||
Das Ende steht schon fest (Zuendungszeitpunkt minus Nachlauf) und wird
|
||||
nicht nachgerechnet: die Wartezeit entscheidet nur, OB geschlossen wird,
|
||||
nicht WANN die Fahrt zu Ende war.
|
||||
"""
|
||||
await asyncio.sleep(pause_s)
|
||||
await asyncio.shield(_jetzt_schliessen(k, start_ts, ende_ts))
|
||||
|
||||
|
||||
async def _jetzt_schliessen(
|
||||
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime
|
||||
) -> None:
|
||||
async with k.fahrt_sperre:
|
||||
if k.fahrt_start_ts != start_ts:
|
||||
_LOGGER.info(
|
||||
"Wartezeit abgelaufen, aber die Fahrt von %s laeuft nicht mehr - "
|
||||
"jemand war schneller",
|
||||
start_ts.isoformat(),
|
||||
)
|
||||
return
|
||||
await fahrt_beenden(k, start_ts, ende_ts)
|
||||
|
||||
|
||||
async def _spaetes_aus(
|
||||
k: Koordinator,
|
||||
ereigniszeit: datetime.datetime | None,
|
||||
jetzt: datetime.datetime,
|
||||
) -> None:
|
||||
"""Ein Zuendungs-Aus, waehrend keine Fahrt laeuft.
|
||||
|
||||
Kommt regelmaessig vor, sobald das Geraet gepuffert hat: die Fahrt ist
|
||||
laengst geschlossen - die Wartezeit ist abgelaufen, oder
|
||||
nach_neustart_fortsetzen() hat aufgeraeumt - und das zugehoerige Aus trifft
|
||||
erst danach ein, unter Umstaenden zusammen mit dem naechsten "on".
|
||||
|
||||
Entschieden wird ueber die GERAETEZEIT und nicht ueber die Reihenfolge des
|
||||
Eintreffens; dann ist es gleichgueltig, ob das Signal vor, mit oder nach dem
|
||||
naechsten "on" ankommt. Ohne laufende Fahrt gibt es ohnehin nichts zu
|
||||
schliessen - der Unterschied liegt nur im Protokoll, und der ist es wert:
|
||||
ein Aus, das NICHT zur letzten Fahrt gehoert, heisst, dass uns ein
|
||||
Fahrtbeginn entgangen ist.
|
||||
"""
|
||||
ende = await _geraetezeit(k, ereigniszeit, jetzt)
|
||||
echt = ende - datetime.timedelta(seconds=ZUENDUNG_NACHLAUF_S)
|
||||
|
||||
juengste = None
|
||||
juengstes_ende = None
|
||||
for fahrt in await k.ablage.fahrten_lesen():
|
||||
roh = fahrt.get("ts_end")
|
||||
if not roh:
|
||||
continue
|
||||
ts = zeit_normalisiert(roh)
|
||||
if juengstes_ende is None or ts > juengstes_ende:
|
||||
juengste, juengstes_ende = fahrt, ts
|
||||
|
||||
if juengste is not None and await _vorlaeufiges_ende_korrigieren(
|
||||
k, juengste, juengstes_ende, echt
|
||||
):
|
||||
return
|
||||
|
||||
if juengstes_ende is not None and echt <= juengstes_ende + datetime.timedelta(
|
||||
seconds=NACHLAUF_TOLERANZ_S
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Zuendungs-Aus (Geraetezeit %s) gehoert zur bereits geschlossenen "
|
||||
"Fahrt mit Ende %s - verworfen",
|
||||
echt.isoformat(), juengstes_ende.isoformat(),
|
||||
)
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Zuendungs-Aus (Geraetezeit %s) ohne laufende Fahrt, und es gehoert zu "
|
||||
"keiner geschlossenen (letztes Ende %s) - ein Fahrtbeginn ist uns "
|
||||
"entgangen",
|
||||
echt.isoformat(), juengstes_ende.isoformat() if juengstes_ende else "keines",
|
||||
)
|
||||
|
||||
|
||||
async def _vorlaeufiges_ende_korrigieren(
|
||||
k: Koordinator,
|
||||
fahrt: dict,
|
||||
ende_bisher: datetime.datetime,
|
||||
echt: datetime.datetime,
|
||||
) -> bool:
|
||||
"""Zieht das Ende einer von Hand beendeten Fahrt nach, sobald das Geraet
|
||||
sein Zuendungs-Aus nachliefert. Gibt zurueck, ob korrigiert wurde.
|
||||
|
||||
Der Knopf "Fahrt beenden" schliesst auf das letzte Lebenszeichen - mehr
|
||||
weiss in dem Moment niemand. Faehrt das Fahrzeug danach noch, kommt der
|
||||
Rest gepuffert nach, und genau dann gehoert das Ende nachgezogen statt
|
||||
verworfen.
|
||||
|
||||
Drei Bedingungen, jede aus einem eigenen Grund: nur eine als vorlaeufig
|
||||
gekennzeichnete Fahrt (eine regulaer geschlossene ist bereits richtig),
|
||||
nur ein SPAETERES Ende (frueher waere kein Nachtrag, sondern eine
|
||||
Verkuerzung), und nur innerhalb von KORREKTUR_FENSTER_S (sonst gehoert das
|
||||
Aus wahrscheinlich zu einer ganz anderen Fahrt).
|
||||
|
||||
ts_end wandert bewusst NICHT in edited_fields: das Feld ist keine
|
||||
Handeingabe, und dort eingetragen wuerde es genau diese Korrektur beim
|
||||
naechsten Mal blockieren.
|
||||
"""
|
||||
if not fahrt.get("ende_vorlaeufig"):
|
||||
return False
|
||||
if echt <= ende_bisher:
|
||||
return False
|
||||
if (echt - ende_bisher).total_seconds() > KORREKTUR_FENSTER_S:
|
||||
_LOGGER.warning(
|
||||
"Zuendungs-Aus (Geraetezeit %s) liegt %.0f h nach dem vorlaeufigen "
|
||||
"Ende %s - zu weit auseinander, nicht korrigiert",
|
||||
echt.isoformat(),
|
||||
(echt - ende_bisher).total_seconds() / 3600,
|
||||
ende_bisher.isoformat(),
|
||||
)
|
||||
return False
|
||||
|
||||
start_ts = zeit_normalisiert(fahrt["ts_start"])
|
||||
await k.ablage.fahrt_aktualisieren(
|
||||
fahrt["trip_id"],
|
||||
{
|
||||
"ts_end": echt.isoformat(),
|
||||
"duration_s": int((echt - start_ts).total_seconds()),
|
||||
"ende_vorlaeufig": False,
|
||||
},
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Fahrt %s: vorlaeufiges Ende %s auf %s nachgezogen (+%.0f s)",
|
||||
fahrt["trip_id"], ende_bisher.isoformat(), echt.isoformat(),
|
||||
(echt - ende_bisher).total_seconds(),
|
||||
)
|
||||
await k.fahrten_veroeffentlichen()
|
||||
await screening.durchfuehren(k)
|
||||
return True
|
||||
|
||||
|
||||
async def jetzt_beenden(k: Koordinator) -> bool:
|
||||
"""Beendet die laufende Fahrt auf Knopfdruck. Gibt zurueck, ob es eine gab.
|
||||
|
||||
Geschlossen wird auf das LETZTE LEBENSZEICHEN, nicht auf "jetzt": das
|
||||
Fahrzeug stand still, als es still stand, nicht als jemand gedrueckt hat -
|
||||
und in dem Fall, fuer den der Knopf gedacht ist (Tiefgarage ohne Empfang),
|
||||
liegen die beiden weit auseinander.
|
||||
|
||||
Das Ende gilt als vorlaeufig. Trifft das Zuendungs-Aus des Geraets spaeter
|
||||
gepuffert ein, zieht _vorlaeufiges_ende_korrigieren() es nach.
|
||||
|
||||
Dieselbe Sperre wie im Beobachter, und die Pruefung auf eine laufende Fahrt
|
||||
steht INNERHALB: zwischen Knopfdruck und Sperre kann sie regulaer geendet
|
||||
haben.
|
||||
"""
|
||||
k.warte_ende_ab_abbrechen()
|
||||
async with k.fahrt_sperre:
|
||||
if k.fahrt_start_ts is None:
|
||||
_LOGGER.info("\"Fahrt beenden\" gedrueckt, aber es laeuft keine Fahrt")
|
||||
return False
|
||||
start_ts = k.fahrt_start_ts
|
||||
ende = await _letztes_lebenszeichen(k, fahrtsignal(k.zuordnung.werte), start_ts)
|
||||
_LOGGER.info(
|
||||
"Fahrt seit %s von Hand beendet, vorlaeufiges Ende %s",
|
||||
start_ts.isoformat(), ende.isoformat(),
|
||||
)
|
||||
await fahrt_beenden(k, start_ts, ende, vorlaeufig=True)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
async def nach_neustart_fortsetzen(k: Koordinator) -> None:
|
||||
"""Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging.
|
||||
@@ -386,7 +660,10 @@ async def _letztes_lebenszeichen(
|
||||
|
||||
|
||||
async def fahrt_beenden(
|
||||
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime
|
||||
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:
|
||||
@@ -403,6 +680,7 @@ async def fahrt_beenden(
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.9.3.4","sha256":"54a1d06cd129ee5c8c28869a7191a40adbfb383b5090bd2feaed2ec9c8c1ffe9","bytes":267146,"gebaut":"2026-09-03T08:56:06Z"}
|
||||
{"version":"2026.9.3.7","sha256":"82f5fdbf1ab4281a51f0c2d31f27b7609b4bc88835c19641d071f9db4970bb00","bytes":268073,"gebaut":"2026-09-03T12:57:42Z"}
|
||||
Binary file not shown.
@@ -200,6 +200,11 @@ function profilZuConfig(p) {
|
||||
backupIntervall: p.einstellungen.backup_intervall || "aus",
|
||||
letztesBackup: p.einstellungen.letztes_backup || null,
|
||||
ausfuehrung: p.fahrzeug.ausfuehrung || "",
|
||||
// Der Wert hat die Zeit ueberlebt, in der die Einstellung nicht
|
||||
// angeboten wurde (31.08. bis 03.09.2026); der Standard greift nur bei
|
||||
// neuen Profilen. Siehe fahrterkennung._pausenzeit_s im Backend.
|
||||
pausenzeit: Number.isFinite(p.einstellungen.fahrten_pausenzeit_min)
|
||||
? p.einstellungen.fahrten_pausenzeit_min : 15,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,6 +232,7 @@ function profilZuCar(p, status) {
|
||||
sicherheitscheck: status.sicherheitscheck || [],
|
||||
zuendung: status.zuendung ?? null,
|
||||
geparktSeit: status.geparkt_seit ?? null,
|
||||
faehrtSeit: status.faehrt_seit ?? null,
|
||||
standortLat: status.standort_lat,
|
||||
standortLon: status.standort_lon,
|
||||
standortGenauigkeitM: status.standort_genauigkeit_m,
|
||||
@@ -284,6 +290,7 @@ function configCarZuProfil(vorherigesProfil) {
|
||||
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
|
||||
p.einstellungen.uebersichtsbild = CONFIG.startbild;
|
||||
p.einstellungen.backup_intervall = CONFIG.backupIntervall;
|
||||
p.einstellungen.fahrten_pausenzeit_min = CONFIG.pausenzeit;
|
||||
p.einstellungen.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb };
|
||||
p.einstellungen.oelwechsel_intervall = {
|
||||
modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate,
|
||||
@@ -959,9 +966,9 @@ function standortZustand() {
|
||||
};
|
||||
}
|
||||
/* Ab wann aus "steht gerade" ein "Parkplatz" wird. Hing bis 2026-08-31 an
|
||||
der Einstellung fahrten_pausenzeit_min - die ist mit der Pausenregel
|
||||
entfallen (der FMM003 bringt sie selbst mit), der Schwellwert hier hat
|
||||
damit aber nichts zu tun und bleibt als eigene Zahl stehen. */
|
||||
der Einstellung fahrten_pausenzeit_min. Die gibt es seit dem 03.09.2026
|
||||
wieder ("Fahrt beenden"), aber der Schwellwert hier hat nach wie vor nichts
|
||||
damit zu tun und bleibt als eigene Zahl stehen. */
|
||||
const PARKPLATZ_AB_MIN = 15;
|
||||
function dauerSeitText(ts) {
|
||||
if (ts == null) return "unbekannt";
|
||||
@@ -1608,9 +1615,15 @@ function teaser() {
|
||||
: null;
|
||||
const t = juengster(TRIPS, "ts_start"), f = juengster(FILLS, "ts");
|
||||
return `<div class="tile teaser"><span class="label">Zuletzt</span>
|
||||
${t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
|
||||
${CAR.faehrtSeit ? `<div class="leaf" style="padding-left:0">
|
||||
${/* Kein Knopf und keine Wertespalte: solange die Fahrt laeuft, gibt es
|
||||
weder Strecke noch Verbrauch. Ein Strich behauptete, ein Wert fehle -
|
||||
richtig ist, dass es ihn noch nicht gibt. */""}
|
||||
<span class="k">Aktuelle Fahrt<small class="laeuft">läuft</small></span>
|
||||
<button class="zeilenknopf" data-fahrtbeenden>Fahrt beenden</button></div>`
|
||||
: t ? `<button class="leaf" style="padding-left:0" data-go="trip:${t.trip_id}">
|
||||
<span class="k">Letzte Fahrt<small>${strecke(t)}</small></span>
|
||||
<span class="v">${streckeText(t.distance_km, "—")}<small>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "—"}</small></span>
|
||||
<span class="v">${t.ende_vorlaeufig ? "~ " : ""}${streckeText(t.distance_km, "—")}<small${t.ende_vorlaeufig ? " class=\"vorlaeufig\"" : ""}>${t.ende_vorlaeufig ? "vorläufig" : fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "—"}</small></span>
|
||||
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>` : ""}
|
||||
${f ? `<button class="leaf" style="padding-left:0;border-bottom:0" data-go="fill:${f.tank_id}">
|
||||
<span class="k">Letzter Tankvorgang<small>${f.ts ? dedat(new Date(f.ts)) : ""}</small></span>
|
||||
@@ -3063,6 +3076,18 @@ function vEinst() {
|
||||
` : ""}
|
||||
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
|
||||
</div>
|
||||
<div class="tile"><span class="label">Fahrt beenden</span>
|
||||
<div class="schieber" style="margin-top:14px">
|
||||
<input type="range" min="0" max="60" step="5" value="${CONFIG.pausenzeit}"
|
||||
style="--fuell:${(CONFIG.pausenzeit / 60) * 100}%" data-pausenzeit
|
||||
aria-label="Minuten bis zum automatischen Fahrtende">
|
||||
<output>${CONFIG.pausenzeit === 0 ? "sofort" : CONFIG.pausenzeit + " Min."}</output>
|
||||
</div>
|
||||
<span class="label" style="margin-top:14px;color:var(--fg2);letter-spacing:0;
|
||||
text-transform:none;font-size:12.5px;line-height:1.6">
|
||||
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die
|
||||
Fahrt automatisch beendet werden soll.</span>
|
||||
</div>
|
||||
<div class="tile"><span class="label">Darstellung</span>
|
||||
<div class="seg">
|
||||
<button data-mode="nacht" class="${HOST.dataset.theme === "nacht" ? "on" : ""}">Nacht</button>
|
||||
@@ -5946,11 +5971,29 @@ function ereignisseVerdrahten() {
|
||||
// Einzelbeleg: zurück auf die Tankstelle (manipuliert MAP direkt, kein
|
||||
// render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren).
|
||||
if (e.target.closest("#belegZentrieren")) { belegAufTankstelleZentrieren(); return; }
|
||||
// Vor [data-go]: der Knopf sitzt in der Zuletzt-Kachel, deren Zeile sonst
|
||||
// in die Einzelfahrt navigiert. Die Zeile ist waehrend der Fahrt zwar ein
|
||||
// div ohne data-go, aber die Reihenfolge haelt das auch dann, wenn die
|
||||
// Kachel spaeter einmal anders gebaut wird.
|
||||
if (e.target.closest("[data-fahrtbeenden]")) { serviceRufen("fahrt_jetzt_beenden", {}); return; }
|
||||
const g = e.target.closest("[data-go]");
|
||||
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
|
||||
});
|
||||
|
||||
ROOT.addEventListener("input", (e) => {
|
||||
if (e.target.dataset.pausenzeit !== undefined) {
|
||||
// Beim Ziehen nur die Anzeige und die Fuellung nachziehen, NICHT
|
||||
// render() und nicht speichern: ein Re-Render mitten in der Geste
|
||||
// erzeugt den Regler neu und der Daumen springt aus der Hand.
|
||||
// Gespeichert wird beim Loslassen (change-Zweig weiter unten) -
|
||||
// dieselbe Trennung wie beim Suchfeld direkt darunter.
|
||||
const minuten = parseInt(e.target.value, 10);
|
||||
CONFIG.pausenzeit = minuten;
|
||||
e.target.style.setProperty("--fuell", (minuten / 60) * 100 + "%");
|
||||
const feld = e.target.parentElement.querySelector("output");
|
||||
if (feld) feld.textContent = minuten === 0 ? "sofort" : minuten + " Min.";
|
||||
return;
|
||||
}
|
||||
const sucheFeld = e.target.dataset.setupSuche;
|
||||
if (sucheFeld) {
|
||||
// Nur die Kandidatenliste patchen, nicht render() - ein voller
|
||||
@@ -6150,6 +6193,7 @@ function ereignisseVerdrahten() {
|
||||
if (e.target.dataset.oelm !== undefined) { CAR.oel.monate = parseInt(e.target.value, 10); profilSpeichern(); return; }
|
||||
if (e.target.dataset.setupNurpassend !== undefined) { setupNurPassend = e.target.checked; render(); return; }
|
||||
if (e.target.dataset.startbild !== undefined) { CONFIG.startbild = e.target.value; profilSpeichern(); render(); return; }
|
||||
if (e.target.dataset.pausenzeit !== undefined) { CONFIG.pausenzeit = parseInt(e.target.value, 10); profilSpeichern(); return; }
|
||||
if (e.target.dataset.svsel !== undefined) { svAuswahl = parseInt(e.target.value, 10); render(); return; }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -798,6 +798,75 @@ img.carfix.klein { aspect-ratio: auto; height: auto; }
|
||||
.switch input:checked + .track { background: var(--fg); border-color: var(--fg); }
|
||||
.switch input:checked + .track::before { transform: translateX(19px); background: var(--canvas); }
|
||||
|
||||
/* Schieberegler - Zwilling von .ads-schieber im Design-System.
|
||||
|
||||
Ein echtes input[type=range], damit Tastatur und Vorlesefunktion ohne
|
||||
Zutun stimmen; gemalt wird selbst, sonst saehe er im Panel wie Chrome und
|
||||
in der App wie WebKit aus. Der gefuellte Teil kommt aus --fuell, weil ein
|
||||
Range-Feld seinen Wert nicht ans Stylesheet weitergibt und accent-color
|
||||
entfaellt, sobald man die Spur selbst gestaltet (gesetzt beim Rendern und
|
||||
im input-Zweig der Ereignisse).
|
||||
|
||||
Die beiden Spur-Regeln lassen sich NICHT zusammenfassen: einen unbekannten
|
||||
Selektor verwirft der Browser samt der ganzen Regel. */
|
||||
.schieber { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||||
/* 28 px hoch bei 4 px Spur: das ist die Trefferflaeche des Daumens. */
|
||||
.schieber input[type=range] {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
flex: 1 1 auto; min-width: 0; height: 28px;
|
||||
margin: 0; padding: 0; background: transparent; cursor: pointer;
|
||||
}
|
||||
.schieber input[type=range]::-webkit-slider-runnable-track {
|
||||
height: 4px; border-radius: var(--r-pill);
|
||||
background: linear-gradient(to right, var(--fg) var(--fuell, 0%), var(--ios-fill) var(--fuell, 0%));
|
||||
}
|
||||
.schieber input[type=range]::-moz-range-track {
|
||||
height: 4px; border-radius: var(--r-pill);
|
||||
background: linear-gradient(to right, var(--fg) var(--fuell, 0%), var(--ios-fill) var(--fuell, 0%));
|
||||
}
|
||||
/* margin-top zentriert den Daumen auf der Spur: (4 - 28) / 2 = -12. */
|
||||
.schieber input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none; box-sizing: border-box;
|
||||
width: 28px; height: 28px; margin-top: -12px; border-radius: 50%;
|
||||
/* Der Ring ist der Trick fuer den Tagmodus: --line-strong ist dort
|
||||
rgba(0,0,0,.22) und zeichnet den weissen Daumen gegen die helle Kachel
|
||||
ab, im Nachtmodus rgba(255,255,255,.20) - auf Weiss also unsichtbar, wo
|
||||
er nicht gebraucht wird. Ein Token, zwei Themen. */
|
||||
border: 1px solid var(--line-strong);
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.22), 0 4px 10px rgba(0,0,0,.16);
|
||||
}
|
||||
.schieber input[type=range]::-moz-range-thumb {
|
||||
box-sizing: border-box; width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.22), 0 4px 10px rgba(0,0,0,.16);
|
||||
}
|
||||
.schieber input[type=range]:focus-visible { outline: none; }
|
||||
.schieber input[type=range]:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 3px var(--line-strong), 0 3px 8px rgba(0,0,0,.16);
|
||||
}
|
||||
/* Feste Mindestbreite und Ziffern gleicher Laufweite: sonst ruckelt der
|
||||
Regler seitlich, sobald der Wert von 9 auf 10 springt. */
|
||||
/* Knopf in einer Listenzeile - heute nur "Fahrt beenden" in der
|
||||
Zuletzt-Kachel. 36 px sichtbar, 44 px Trefferhoehe ueber das
|
||||
Pseudoelement, damit die Zeile nicht hoeher wird als ihre Nachbarin. */
|
||||
.zeilenknopf {
|
||||
position: relative; flex: 0 0 auto;
|
||||
font-family: inherit; font-size: 13px; letter-spacing: .01em;
|
||||
color: var(--fg); background: none; border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill); padding: 8px 15px; min-height: 36px; cursor: pointer;
|
||||
}
|
||||
.zeilenknopf:hover { background: var(--shade); }
|
||||
.zeilenknopf:active { opacity: .55; }
|
||||
.zeilenknopf::before { content: ""; position: absolute; inset: -4px 0; }
|
||||
/* Gruen wie der Sicherheits-Haken: es laeuft gerade etwas. */
|
||||
.leaf .k small.laeuft { color: var(--ok); }
|
||||
.leaf .v small.vorlaeufig { color: var(--fg3); }
|
||||
|
||||
.schieber output {
|
||||
flex: 0 0 auto; min-width: 62px; text-align: right;
|
||||
font-size: 13px; font-variant-numeric: tabular-nums; color: var(--fg2);
|
||||
}
|
||||
|
||||
/* C3: gemischt geschrieben, 15 px, 48 px hoch. .primaer ist gefüllt,
|
||||
.aktion allein bleibt die Umriss-Sekundäraktion. */
|
||||
.aktion {
|
||||
|
||||
@@ -544,7 +544,9 @@ class Koordinator:
|
||||
self.setzen(E_ZUORDNUNG, "aktuell", self.zuordnung.aktueller_stand())
|
||||
|
||||
async def fahrzeugstatus_veroeffentlichen(self) -> None:
|
||||
self.setzen(E_FAHRZEUGSTATUS, "aktuell", fahrzeugstatus(self.hass, self.zuordnung.werte, self.geparkt_seit_ts))
|
||||
self.setzen(E_FAHRZEUGSTATUS, "aktuell", fahrzeugstatus(
|
||||
self.hass, self.zuordnung.werte, self.geparkt_seit_ts, self.fahrt_start_ts
|
||||
))
|
||||
|
||||
async def beleg_ergebnis_veroeffentlichen(self, daten: dict) -> None:
|
||||
self.setzen(E_BELEG_ERGEBNIS, "aktuell", daten)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.9.3.4",
|
||||
"version": "2026.9.3.7",
|
||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||
"codeowners": ["@paul"],
|
||||
|
||||
@@ -330,3 +330,6 @@ csv_importieren:
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
|
||||
fahrt_jetzt_beenden:
|
||||
fields: {}
|
||||
|
||||
@@ -194,6 +194,7 @@ def fahrzeugstatus(
|
||||
hass: HomeAssistant,
|
||||
werte: Sensorzuordnung,
|
||||
geparkt_seit: datetime.datetime | None = None,
|
||||
faehrt_seit: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
|
||||
frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss."""
|
||||
@@ -253,6 +254,13 @@ def fahrzeugstatus(
|
||||
# nach einer Antwort aus, war aber eine über die Fahrterkennung und
|
||||
# nicht über das Parken (siehe koordinator._zuendung_angezeigt).
|
||||
"geparkt_seit": geparkt_seit.isoformat() if geparkt_seit else None,
|
||||
# Seit wann die laufende Fahrt laeuft - der eigene Zwischenstand der
|
||||
# Fahrterkennung, nicht der Rohwert einer Entitaet. Nur daran koennen
|
||||
# die Oberflaechen den Knopf "Fahrt beenden" zeigen: die Zuendung
|
||||
# allein sagt es nicht, sie steht beim Trip-Signal noch 900 s nach dem
|
||||
# Abstellen auf "an" und bei laufendem Motor im Stand ohne Fahrt
|
||||
# ebenfalls. None heisst: es laeuft keine Fahrt.
|
||||
"faehrt_seit": faehrt_seit.isoformat() if faehrt_seit else None,
|
||||
"standort_lat": standort["lat"],
|
||||
"standort_lon": standort["lon"],
|
||||
"standort_genauigkeit_m": standort["genauigkeit_m"],
|
||||
|
||||
Reference in New Issue
Block a user