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:
2026-09-03 15:00:38 +02:00
parent d30bb0e9f0
commit 5ccbfec92d
29 changed files with 1444 additions and 51 deletions
@@ -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"}
@@ -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"],