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
+181 -1
View File
@@ -1,6 +1,10 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-09-03** (Das OTA-Buendel kann auf dem Mac gar nicht entstehen - ausgeliefert war eine
**Last updated: 2026-09-03** („Fahrt beenden“ auf Knopfdruck mit vorlaeufigem Ende, Schieberegler in
5-Minuten-Schritten und im Tagmodus sichtbar, `2026.9.3.7`, Abschnitt BX.
Davor: Die Fahrt endet wieder bei uns statt im Dongle - Zuendung als Ausloeser,
eigene Wartezeit, Nachlauf des Geraets abgezogen, Schieberegler, `2026.9.3.5`, Abschnitt BW.
Davor: Das OTA-Buendel kann auf dem Mac gar nicht entstehen - ausgeliefert war eine
Oberflaeche ohne den Versionshinweis unter richtiger Nummer, repariert mit `2026.9.3.4`,
Abschnitt BV. Davor: Zwei Signalwechsel in derselben Sekunde kosteten eine Fahrt - Sperre
plus Regressionstest, Bildformat-Pruefung, `2026.9.3.3`, Abschnitt BU. Davor: Rueckblick loest Orte
@@ -8762,3 +8766,179 @@ braucht es nicht: die Zip-Einträge schreibt die PowerShell-Fassung ohnehin von
ihrem Kopf), `zlib.deflateRawSync` reicht. Dazu ein Wächter, der in der **Git-Historie** den letzten
Commit an `bundle.zip` gegen den letzten an `companion-app/src` und `design-system/src` hält — über
Git und nicht über Dateidaten, weil ein frischer Klon alle Zeitstempel plattmacht.
---
## BW. Die Fahrt endet wieder bei uns, nicht im Dongle (2026.9.3.5)
### Der Befund: zwei Uhren, dieselbe Sekunde
Der FMM003 beendet den Trip erst nach seinem `Ignition OFF Timeout` (`11804`) von 900 s — und sein
Schlaf-Timeout (`103`) stand auf denselben 900 s. Beide starten beim Zuendungs-Aus. Am 02.09.2026
ist das zweimal beobachtet worden, mit **entgegengesetztem Ausgang**:
| | Zuendung aus | Trip-Ende erwartet | tatsaechlich |
|---|---|---|---|
| Fahrt A | 16:54:21 | 17:09:21 | **nichts** — naechster Datensatz erst 18:42:04 |
| Fahrt B | 21:43:15 | 21:58:15 | 21:58:16 (+901,2 s) |
Ein Wettlauf, gemessen, nicht vermutet. Die Doku entkraeftet dabei die naheliegende Erklaerung: im
Deep Sleep **sendet** das Geraet sehr wohl, High-priority-Datensaetze sogar sofort ("device can save
periodical or eventual records and send data to server based on the selected AVL packet priority").
Die Prioritaet regelt die Zustellung, nicht die Entstehung — und im Deep Sleep sind `Speed`,
`Trip Odometer` und `Total Odometer` aus den Datensaetzen ausgeschlossen, also genau die Groessen, aus
denen das Trip-Szenario lebt.
Der Zielkonflikt ist unaufloesbar, solange das Geraet die Uhr fuehrt: der Eigentuemer will, dass der
Dongle frueh einschlaeft (Strom), und dass das Ende ankommt. Beides zugleich geht nicht.
### Die Loesung: die Wartezeit laeuft, wo nichts schlaeft
Ausloeser ist jetzt die **Zuendung** (`50000:2` High, `50001:5` On Change seit 01.09.2026 — sie wird
im Moment des Wechsels gesendet), die Wartezeit laeuft in Home Assistant. Der Dongle darf danach
frueher schlafen als vorher, nicht spaeter.
**`ZUENDUNG_NACHLAUF_S = 180`** (Geraeteparameter `400`, "Ignition OFF Delay"). Abgezogen wird in
**beiden** Wegen: ueber die Zuendung 180 s, ueber das Trip-Signal 1080 s — denn das Trip-Ende haengt
selbst an der bereits verzoegerten Zuendung (Fahrt B oben belegt es auf die Sekunde). Der Wert stand
bis 31.08. auf 0 und seit **01.09. 09:38** auf 180; **Fahrten aus dieser Zeit enden 180 s zu spaet**,
korrigiert wird ab jetzt. `_nachlauf_gegenpruefen()` musste mit — ohne Anpassung haette sie ab sofort
bei jeder Fahrt 180 s Abweichung gemeldet.
**Die Wartezeit laeuft ausserhalb der Fahrtsperre.** Haette die Hintergrundaufgabe die Sperre, stuende
ein zurueckkehrendes "on" eine Viertelstunde davor, statt die Fahrt fortzusetzen — das Gegenteil des
Zwecks. Das Schliessen selbst ist mit `asyncio.shield()` gegen den Abbruch abgeschirmt: ein Wechsel
genau im Schreibmoment duerfte keine halbe Zeile in `fahrten.jsonl` hinterlassen.
**Die Wartezeit entscheidet nur, OB geschlossen wird, nicht WANN die Fahrt endete.** Das Ende steht
beim Zuendungs-Aus fest.
**Keine doppelte Wartezeit:** `_pausenzeit_s()` liefert 0, solange `TRIP_SENSOR` zugeordnet ist —
dann wartet das Geraet bereits. Genau daran war die Pausenregel am 31.08.2026 entfallen.
**Spaetes Aus** wird ueber die **Geraetezeit** entschieden, nicht ueber die Reihenfolge des
Eintreffens: faellt es in die schon geschlossene Fahrt, wird es verworfen und protokolliert; passt es
zu keiner, gibt es eine Warnung — dann ist ein Fahrtbeginn entgangen.
### Was die Daten ueber die Drehzahlquelle sagen
Aus 21 Geraetekonfigurationen rekonstruiert: `101` ist eine Bitmaske (2 = ACC, 4 = Power Voltage,
8 = RPM). Aussetzer der Zuendung gibt es — aber sie sind **kurz (12 s) und liegen am Fahrtanfang**,
in den ersten 25 Sekunden; fuer einen Ausfall ueber Minuten waehrend der Fahrt gibt es keinen Beleg.
Die Stichprobe der aktuellen Konfiguration ist zwei Fahrten gross, das gehoert dazugesagt. Dazu
kommt **Start-Stopp**: der RS4 stellt an der Ampel den Motor ab, aus einer Drehzahlquelle ist das
nicht vom Abstellen zu unterscheiden. Die abbrechbare Wartezeit verschluckt beides — ein Argument
fuer einen grosszuegigen Standardwert, nicht gegen den Ansatz.
### Der Schieberegler
`Slider` / `.ads-schieber` im Design-System, `.schieber` im Panel — es gab bisher **nirgends** einen.
Ein echtes `input[type=range]` (Tastatur und Vorlesefunktion ohne Zutun), selbst gemalt, weil er
sonst im Panel wie Chrome und in der App wie WebKit aussaehe. Der gefuellte Teil kommt aus
`--fuell` / `--ads-fuell`, weil ein Range-Feld seinen Wert nicht ans Stylesheet weitergibt und
`accent-color` entfaellt, sobald man die Spur selbst gestaltet. Farben wie beim Balken: `--fg` auf
`--ios-fill` — Markenrot bleibt den Stellen vorbehalten, die Aufmerksamkeit verlangen.
Im Panel zieht der `input`-Zweig nur Fuellung und Anzeige nach und ruft **kein** `render()`;
gespeichert wird im `change`-Zweig beim Loslassen. Ein Re-Render mitten in der Geste erzeugt den
Regler neu und der Daumen springt aus der Hand — dieselbe Trennung wie beim Suchfeld im Setup.
Erklaerender Text als **Fusszeile der Gruppe**, nicht als Info-Knopf: das ist Apples eigenes Muster
fuer Einstellungen.
### Verifiziert
Backend **19/19** (5 alte, 14 neue in `tests/fahrterkennung/test_zuendungspause.py`), companion-app
`tsc` sauber und **179/179**, Panel als Modul geparst, `audi_ha_test` auf `2026.9.3.5` sauber
gestartet. Live im Panel gemessen: Regler 28 px hoch, Fuellung 25 % bei 15 von 60, Ziehen aktualisiert
Anzeige und Fuellung **ohne** dass das Element ersetzt wird (`isConnected` bleibt true), Loslassen
schreibt — `fahrten_pausenzeit_min = 25` stand danach in `fahrzeugprofil.json`.
**Ehrliche Luecke:** die Kette "Zuendung aus → Wartezeit → Fahrt geschlossen" ist durch die
Regressionstests belegt, nicht durch eine echte Fahrt im Container. Alle drei Koordinator-Eigenschaften,
die sie anfasst (`warte_ende_ab`, `ablage`, `zuordnung`), werden in demselben Modul bereits am echten
Objekt benutzt.
---
## BX. „Fahrt beenden" auf Knopfdruck, mit vorläufigem Ende (2026.9.3.6/.7)
### Wofür der Knopf da ist
Wunsch des Eigentümers: das Fahrzeug steht in einer Tiefgarage ohne Empfang, das Gerät kann sein
Zündungs-Aus nicht melden, und die Fahrt bliebe offen, bis irgendwann gepufferte Datensätze
eintreffen. Der Knopf schliesst sie sofort.
**Geschlossen wird auf das letzte Lebenszeichen, nicht auf „jetzt".** Das Fahrzeug stand still, als
es still stand, nicht als jemand gedrückt hat — und im Anwendungsfall liegen die beiden weit
auseinander. `_letztes_lebenszeichen()` gab es dafür schon (`nach_neustart_fortsetzen`).
**Genau deshalb ist dieses Ende vorläufig.** Der Eigentümer hat den wunden Punkt selbst benannt:
fährt das Fahrzeug nach dem Knopfdruck noch aus der Garage heraus, kommt der Rest gepuffert nach,
und dann gehört das Ende **nachgezogen** statt verworfen. Neues Feld `ende_vorlaeufig` an der Fahrt;
`_vorlaeufiges_ende_korrigieren()` zieht `ts_end` und `duration_s` nach und stösst das Screening neu
an.
Drei Bedingungen dafür, jede aus einem eigenen Grund: nur eine als vorläufig gekennzeichnete Fahrt
(eine regulär geschlossene ist bereits richtig), nur ein **späteres** Ende (früher wäre kein
Nachtrag, sondern eine Verkürzung), und nur innerhalb von `KORREKTUR_FENSTER_S = 6 h` — jenseits
davon gehört das Aus wahrscheinlich zu einer ganz anderen Fahrt.
**Die Falle, die dabei fast zugeschnappt wäre:** `ts_end` darf **nicht** in `edited_fields` landen.
Der Schutz für Handeingaben würde sonst genau die Korrektur blockieren, für die das Feld gebaut ist.
Ein Test hält das fest.
### „vorläufig" hängt am Wert, nicht an der Fahrt
Entscheidung des Eigentümers zwischen zwei gerenderten Mustern: die Strecke steht mit Tilde da
(`~ 14,2 km`), darunter `vorläufig` — und der **Verbrauch erscheint gar nicht**, bis er feststeht.
Die Alternative (beide Werte mit Tilde, „vorläufig" links an der Fahrt) ist verworfen.
Solange die Fahrt **läuft**, steht rechts **nichts** — keine Strecke, kein Verbrauch, auch kein
Strich. Ein Strich behauptet „hier fehlt ein Wert"; richtig ist „hier gibt es ihn noch nicht".
Dafür hat `Blattzeile` in der App ein optionales `aktion` bekommen und `wert` ist optional geworden.
### Woran die Oberflächen erkennen, dass eine Fahrt läuft
Neues Feld `faehrt_seit` im Fahrzeugstatus — der eigene Zwischenstand der Fahrterkennung
(`fahrt_start_ts`), nicht der Rohwert einer Entität. Die Zündung allein taugt nicht: beim
Trip-Signal steht sie nach dem Abstellen noch 900 s auf „an", und bei laufendem Motor im Stand
ebenfalls.
### Der Knopf
`.zeilenknopf` / `.dm-zeilenknopf`, Pillenform im Umriss — vom Eigentümer aus zwei gerenderten
Fassungen gewählt. 36 px sichtbar, 44 px Trefferhöhe über ein Pseudoelement, damit die Zeile nicht
höher wird als ihre Nachbarin. Der Dienst `fahrt_jetzt_beenden` läuft in der App **nicht** über die
Warteschlange: im Funkloch abgesetzt und Stunden später nachgeholt schlösse er eine Fahrt, die
längst regulär zu Ende ist — dieselbe Begründung wie bei `historieImportieren()`.
### Verifiziert
Backend **27/27** (5 Signalwechsel, 14 Zündungspause, 8 neu in
`tests/fahrterkennung/test_fahrtende_knopf.py`), 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, ohne Rückstand:** Meldezeit frisch gesetzt, Zündung an → Zeile zeigt
„Aktuelle Fahrt / läuft", Knopf 36 px in einer 66 px hohen Zeile, **keine Wertespalte und kein
Chevron**. Knopf gedrückt → `Fahrt seit … von Hand beendet, vorlaeufiges Ende …`, danach
`Zündung war nur 0 s an - keine Fahrt angelegt (Grenze 60 s)`. Fahrten vorher 16, nachher 16. Der
Trick für die Rückstandsfreiheit ist derselbe wie bei der Wartezeit: liegt der Beginn nahe am
letzten Lebenszeichen, verwirft `MINDESTDAUER_S` den Vorgang.
Beim anschliessenden Zurücksetzen der Zündung hat auch der zweite Zweig gefeuert und richtig
gemeldet: `Zuendungs-Aus … ohne laufende Fahrt, und es gehoert zu keiner geschlossenen — ein
Fahrtbeginn ist uns entgangen`. Korrekt, denn die Testfahrt war ja verworfen worden.
**Ehrliche Lücke:** die Korrektur eines vorläufigen Endes durch ein spät eintreffendes Zündungs-Aus
ist durch 8 Regressionstests belegt, nicht durch eine echte Tiefgaragenfahrt.
### Der Regler, im selben Zug
Schrittweite von 1 auf **5 Minuten** — Apples eigene Seite mahnt für weite Wertebereiche einen
Weg zur genauen Eingabe an, und bei 060 in Einerschritten sind das 3,8 px je Minute. 13 Rastpunkte
treffen sich bequem, und minutengenau ist hier ohnehin Scheingenauigkeit.
Der Daumen war im **Tagmodus** kaum zu sehen: weiss auf weisser Kachel, nur ein Schatten. Er hat
jetzt `border: 1px solid var(--line-strong)` — ein Token, zwei Themen: im Tagmodus zeichnet es ihn
ab, im Nachtmodus ist es auf Weiss unsichtbar, wo es nicht gebraucht wird.
+9
View File
@@ -332,6 +332,15 @@ export class DataMetricApi {
/** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag
angenommen hat — nicht, wenn er fertig ist; dafür importStatusLesen(). */
/** Beendet die laufende Fahrt sofort, mit vorlaeufigem Ende.
Wie historieImportieren() bewusst NICHT ueber die Warteschlange: im
Funkloch abgesetzt und Stunden spaeter nachgeholt wuerde sie eine Fahrt
schliessen, die laengst regulaer zu Ende ist. */
fahrtJetztBeenden(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "fahrt_jetzt_beenden", {});
}
historieImportieren(start: string, ende: string): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
start,
+3
View File
@@ -68,6 +68,9 @@ export interface Fahrt {
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
* verbrauch_aus_literstaenden() im Backend. */
verbrauch_l_100km?: number | null;
/** Das Ende steht nur vorlaeufig fest (Knopf "Fahrt beenden", das Geraet
hat sein Zuendungs-Aus noch nicht gemeldet). */
ende_vorlaeufig?: boolean;
pausen?: unknown[];
}
+11
View File
@@ -23,6 +23,9 @@ export interface Einstellungen {
backupIntervall: string
letztesBackup: string | null
ausfuehrung: string
/** Minuten nach dem Zuendungs-Aus, bis die Fahrt automatisch endet.
0 heisst: sofort. Siehe fahrterkennung._pausenzeit_s im Backend. */
pausenzeitMin: number
}
export interface Reifensatz {
@@ -69,6 +72,8 @@ export interface Fahrzeug {
zugeordnet", also unbekannt — nicht „steht". */
zuendung: boolean | null
geparktSeit: string | null
/** Beginn der laufenden Fahrt, sonst null - siehe faehrt_seit im Backend. */
faehrtSeit: string | null
standortLat: number | null
standortLon: number | null
standortZeit: string | null
@@ -142,6 +147,10 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
backupIntervall: text(einst, "backup_intervall", "aus"),
letztesBackup: text(einst, "letztes_backup") || null,
ausfuehrung: text(fahrzeug, "ausfuehrung"),
// Der Wert hat die Zeit ueberlebt, in der die Einstellung nicht angeboten
// wurde (31.08. bis 03.09.2026) - deshalb steht er in vielen Profilen
// schon, und der Standard greift nur bei neuen.
pausenzeitMin: zahl(einst, "fahrten_pausenzeit_min") ?? 15,
}
}
@@ -173,6 +182,7 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
sicherheitscheck: status.sicherheitscheck ?? [],
zuendung: status.zuendung ?? null,
geparktSeit: (status.geparkt_seit as string | null) ?? null,
faehrtSeit: (status.faehrt_seit as string | null) ?? null,
standortLat: status.standort_lat ?? null,
standortLon: status.standort_lon ?? null,
standortZeit: status.standort_zeit ?? null,
@@ -240,6 +250,7 @@ export function zusammenfuehren(
e["backup_intervall"] = einstellungen.backupIntervall
e["nacht_von"] = einstellungen.nachtVon
e["nacht_bis"] = einstellungen.nachtBis
e["fahrten_pausenzeit_min"] = einstellungen.pausenzeitMin
e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb }
e["oelwechsel_intervall"] = {
modus: fahrzeug.oel.modus,
+1
View File
@@ -28,6 +28,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
sicherheitscheck: [],
zuendung: null,
geparktSeit: null,
faehrtSeit: null,
standortLat: null,
standortLon: null,
standortZeit: null,
+20 -1
View File
@@ -6,7 +6,7 @@
import { useEffect, useRef, useState } from "react"
import { ActionButton, Feld, Seg, Sheet, Switch, Tile } from "@audi-dash/ui"
import { ActionButton, Feld, Seg, Sheet, Slider, Switch, Tile } from "@audi-dash/ui"
import { ApiFehler, DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import type { Profil } from "../api"
@@ -348,6 +348,25 @@ export function Einstellungen({
</ActionButton>
</Tile>
<Tile>
<span className="ads-eyebrow">Fahrt beenden</span>
<Slider
min={0}
max={60}
schritt={5}
wert={werte.pausenzeitMin}
onChange={(min) => aendern({ pausenzeitMin: min })}
anzeige={werte.pausenzeitMin === 0 ? "sofort" : werte.pausenzeitMin + " Min."}
aria-label="Minuten bis zum automatischen Fahrtende"
/>
{/* Fusszeile statt Info-Knopf: Apples eigenes Muster fuer erklaerenden
Text unter einer Einstellung ist der Footer der Gruppe. */}
<span className="ads-eyebrow" style={{ marginTop: "14px" }}>
Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die
Fahrt automatisch beendet werden soll.
</span>
</Tile>
<Tile>
<span className="ads-eyebrow">Darstellung</span>
<Seg
+3 -3
View File
@@ -107,9 +107,9 @@ function dauerSeitText(ts: number | null): string {
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
standortZustand() im Panel. */
/* 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 (Kachel „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
/* Seit wann das Fahrzeug steht: aus dem Backend, hergeleitet aus dem
+48 -7
View File
@@ -8,6 +8,8 @@
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
import { useState } from "react"
import { useDaten } from "../daten/DatenKontext"
import { naechsterService } from "../daten/service"
import { datum, de, deOderStrich, eur, streckeText } from "../format"
@@ -34,10 +36,21 @@ const SERVICE_SYMBOL: Partial<Record<string, typeof SymbolOelwechsel>> = {
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
const { fahrzeug, einstellungen, fahrten, tankvorgaenge } = useDaten()
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, api, neuLaden } = useDaten()
const [beendenLaeuft, setzeBeendenLaeuft] = useState(false)
if (!fahrzeug || !einstellungen) return null
const fahrtBeenden = async () => {
setzeBeendenLaeuft(true)
try {
await api.fahrtJetztBeenden()
await neuLaden()
} finally {
setzeBeendenLaeuft(false)
}
}
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
/* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname -
siehe die Begruendung in Einstellungen.tsx. Der Vergleich gegen datei
@@ -233,22 +246,50 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
Tankvorgang". Fehlen beide Datensätze, lässt das Panel die Kachel
ganz weg; die Leerzustände hier bleiben als eigene Konvention der
App erhalten (siehe Kapitel 1 des Paritätsaudits). */}
{letzteFahrt || letzteTankung ? (
{fahrzeug.faehrtSeit || letzteFahrt || letzteTankung ? (
<Tile className="dm-teaser">
<span className="ads-eyebrow">Zuletzt</span>
{letzteFahrt && (
{fahrzeug.faehrtSeit ? (
/* Waehrend der Fahrt gibt es weder Strecke noch Verbrauch - also
steht rechts nichts, auch kein Strich. Ein Strich behauptete,
ein Wert fehle; richtig ist, dass es ihn noch nicht gibt. */
<Blattzeile
label="Aktuelle Fahrt"
labelZusatz={<span className="dm-laeuft">läuft</span>}
aktion={
<button
type="button"
className="dm-zeilenknopf"
onClick={() => void fahrtBeenden()}
disabled={beendenLaeuft}
>
{beendenLaeuft ? "Wird beendet …" : "Fahrt beenden"}
</button>
}
letzte={!letzteTankung}
/>
) : (
letzteFahrt && (
<Blattzeile
label="Letzte Fahrt"
labelZusatz={strecke(letzteFahrt) ?? ""}
wert={streckeText(letzteFahrt.distance_km, "—")}
wert={
(letzteFahrt.ende_vorlaeufig ? "~ " : "") +
streckeText(letzteFahrt.distance_km, "—")
}
wertZusatz={
letzteFahrt.verbrauch_l_100km != null
? `${de(letzteFahrt.verbrauch_l_100km, 1)} l/100 km`
: "—"
letzteFahrt.ende_vorlaeufig ? (
<span className="dm-vorlaeufig">vorläufig</span>
) : letzteFahrt.verbrauch_l_100km != null ? (
`${de(letzteFahrt.verbrauch_l_100km, 1)} l/100 km`
) : (
"—"
)
}
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
letzte={!letzteTankung}
/>
)
)}
{letzteTankung && (
<Blattzeile
+9 -1
View File
@@ -200,13 +200,18 @@ export function Blattzeile({
labelZusatz,
wert,
wertZusatz,
aktion,
beiKlick,
letzte,
}: {
label: ReactNode
labelZusatz?: ReactNode
wert: ReactNode
/** Ohne Wert entfaellt die Wertespalte ganz - fuer Zeilen, die (noch)
nichts anzuzeigen haben, statt einen Strich zu setzen. */
wert?: ReactNode
wertZusatz?: ReactNode
/** Steht anstelle der Wertespalte, z. B. ein Knopf. */
aktion?: ReactNode
/** Ohne Klickziel wird die Zeile ein reines <div>, wie im Panel. */
beiKlick?: () => void
/** Unterste Zeile einer Kachel — dort entfällt die Trennlinie. */
@@ -219,10 +224,13 @@ export function Blattzeile({
{label}
{labelZusatz !== undefined && <small>{labelZusatz}</small>}
</span>
{wert !== undefined && (
<span className="dm-blatt__v">
{wert}
{wertZusatz !== undefined && <small>{wertZusatz}</small>}
</span>
)}
{aktion}
{beiKlick && (
<svg className="dm-blatt__chevron" viewBox="0 0 6 10" aria-hidden="true">
<polyline points="1,1 5,5 1,9" />
+27
View File
@@ -1965,3 +1965,30 @@ button.dm-listenzeile {
width: auto;
vertical-align: middle;
}
/* Knopf in einer Listenzeile - heute nur "Fahrt beenden" in der
Zuletzt-Kachel. Zwilling von .zeilenknopf im Panel: 36 px sichtbar,
44 px Trefferhoehe ueber das Pseudoelement, damit die Zeile nicht hoeher
wird als ihre Nachbarin. */
.dm-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;
}
.dm-zeilenknopf:hover { background: var(--shade); }
.dm-zeilenknopf:active { opacity: .55; }
.dm-zeilenknopf:disabled { opacity: .4; cursor: default; }
.dm-zeilenknopf::before { content: ""; position: absolute; inset: -4px 0; }
/* Gruen wie der Sicherheits-Haken: es laeuft gerade etwas. */
.dm-blatt__k small .dm-laeuft, .dm-laeuft { color: var(--ok); }
.dm-vorlaeufig { color: var(--fg3); }
@@ -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,22 +198,27 @@ 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
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"],
@@ -0,0 +1,99 @@
.ads-schieber {
display: flex;
align-items: center;
gap: var(--sp-4);
min-width: 0;
}
/* 28px hoch, obwohl die Spur nur 4px misst: das ist die Trefferfläche des
Daumens. Ein 4px hohes Element wäre auf dem Telefon nicht zu greifen. */
.ads-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;
}
.ads-schieber input[type="range"]:disabled {
opacity: .4;
cursor: default;
}
/* Die beiden Spur-Regeln lassen sich NICHT zusammenfassen: trifft ein Browser
auf einen Selektor, den er nicht kennt, verwirft er die ganze Regel. */
.ads-schieber input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
border-radius: var(--r-pill);
background: linear-gradient(
to right,
var(--fg) var(--ads-fuell, 0%),
var(--ios-fill) var(--ads-fuell, 0%)
);
}
.ads-schieber input[type="range"]::-moz-range-track {
height: 4px;
border-radius: var(--r-pill);
background: linear-gradient(
to right,
var(--fg) var(--ads-fuell, 0%),
var(--ios-fill) var(--ads-fuell, 0%)
);
}
/* margin-top zentriert den Daumen auf der 4px-Spur: (4 - 28) / 2 = -12. */
.ads-schieber input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
width: 28px;
height: 28px;
margin-top: -12px;
/* Der Ring ist der Trick fuer den Tagmodus: --line-strong ist dort
rgba(0,0,0,.22) und zeichnet den weissen Daumen sauber gegen die helle
Kachel ab, im Nachtmodus dagegen rgba(255,255,255,.20) - also auf Weiss
unsichtbar, wo er nicht gebraucht wird. Ein Token, zwei Themen.
Dazu ein kraeftigerer Schatten: der allein trug im Tagmodus nicht. */
border: 1px solid var(--line-strong);
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .22), 0 4px 10px rgba(0, 0, 0, .16);
}
.ads-schieber input[type="range"]::-moz-range-thumb {
box-sizing: border-box;
width: 28px;
height: 28px;
border: 1px solid var(--line-strong);
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .22), 0 4px 10px rgba(0, 0, 0, .16);
}
.ads-schieber input[type="range"]:focus-visible {
outline: none;
}
.ads-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);
}
.ads-schieber input[type="range"]:focus-visible::-moz-range-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. */
.ads-schieber__wert {
flex: 0 0 auto;
min-width: 62px;
text-align: right;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--fg2);
}
@@ -0,0 +1,74 @@
import * as React from "react";
/**
* Schieberegler für einen Zahlenwert - das iOS-Muster für "irgendwo zwischen
* wenig und viel", wo die genaue Ziffer zweitrangig ist.
*
* Bewusst ein echtes `input[type=range]` und kein nachgebauter Regler aus
* divs: das bringt Tastaturbedienung, VoiceOver und die Wischgeste des Systems
* mit, ohne dass wir sie nachbauen (und dabei die Hälfte vergessen). Gemalt
* wird trotzdem selbst, denn sonst sähe der Regler im Panel wie Chrome und in
* der App wie WebKit aus - zwei Codebasen, ein Look.
*
* Der gefüllte Teil der Spur kommt aus der CSS-Variablen `--ads-fuell`, weil
* ein `input[type=range]` seinen Wert nicht an das Stylesheet weiterreicht.
* Sobald man `::-webkit-slider-runnable-track` selbst gestaltet, entfällt auch
* die automatische Füllung von `accent-color` - der Anteil muss also von hier
* kommen.
*
* Farben wie beim Balken (ProgressBar): `--fg` auf `--ios-fill`. Der
* Markenrot-Ton bleibt den Stellen vorbehalten, die wirklich Aufmerksamkeit
* verlangen; eine Einstellung gehört nicht dazu.
*/
export interface SliderProps {
wert: number;
min?: number;
max?: number;
schritt?: number;
onChange: (wert: number) => void;
/** Wird rechts neben dem Regler gezeigt, z. B. "15 Min." */
anzeige?: React.ReactNode;
disabled?: boolean;
id?: string;
"aria-label"?: string;
"aria-describedby"?: string;
className?: string;
}
export function Slider({
wert,
min = 0,
max = 100,
schritt = 1,
onChange,
anzeige,
disabled,
id,
className,
...rest
}: SliderProps) {
const spanne = max - min;
// Geklemmt, damit ein Wert außerhalb der Grenzen die Spur nicht überfüllt -
// gespeicherte Werte können älter sein als die heutigen Grenzen.
const anteil = spanne > 0 ? Math.min(100, Math.max(0, ((wert - min) / spanne) * 100)) : 0;
const classes = ["ads-schieber"];
if (className) classes.push(className);
return (
<div className={classes.join(" ")}>
<input
type="range"
id={id}
min={min}
max={max}
step={schritt}
value={wert}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
style={{ "--ads-fuell": `${anteil}%` } as React.CSSProperties}
{...rest}
/>
{anzeige !== undefined && <output className="ads-schieber__wert">{anzeige}</output>}
</div>
);
}
@@ -0,0 +1,2 @@
export { Slider } from "./Slider";
export type { SliderProps } from "./Slider";
+3
View File
@@ -16,6 +16,9 @@ export type { ActionButtonProps } from "./components/ActionButton";
export { Switch } from "./components/Switch";
export type { SwitchProps } from "./components/Switch";
export { Slider } from "./components/Slider";
export type { SliderProps } from "./components/Slider";
export { Seg } from "./components/Seg";
export type { SegProps, SegOption } from "./components/Seg";
+1
View File
@@ -7,6 +7,7 @@
@import "./components/StatusRow/StatusRow.css";
@import "./components/ActionButton/ActionButton.css";
@import "./components/Switch/Switch.css";
@import "./components/Slider/Slider.css";
@import "./components/Seg/Seg.css";
@import "./components/RowList/RowList.css";
@import "./components/Accordion/LeafRow.css";
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Der Knopf "Fahrt beenden" und das Nachziehen des vorlaeufigen Endes.
Der Fall, fuer den es den Knopf gibt: das Fahrzeug steht in einer Tiefgarage
ohne Empfang. Das Geraet kann sein Zuendungs-Aus nicht melden, die Fahrt bliebe
offen, bis irgendwann gepufferte Datensaetze eintreffen. Der Knopf schliesst
sie auf das letzte Lebenszeichen - mehr weiss in dem Moment niemand.
Genau deshalb ist dieses Ende VORLAEUFIG: faehrt das Fahrzeug danach noch aus
der Garage heraus, kommt der Rest nach, und dann gehoert das Ende nachgezogen
statt verworfen. Geprueft wird beides - dass der Knopf das Richtige schliesst,
und dass die spaete Meldung die Fahrt korrigiert statt eine neue anzulegen.
Aufruf: python3 tests/fahrterkennung/test_fahrtende_knopf.py
(braucht das homeassistant-Paket, weil die Integration es importiert - also
z. B. im Test-Container)
"""
import asyncio
import datetime
import os
import sys
import unittest
_HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(os.path.dirname(_HIER)))
from custom_components.audi_dashboard import fahrterkennung as f # noqa: E402
T0 = datetime.datetime(2026, 9, 3, 8, 0, 0, tzinfo=datetime.UTC)
LEBENSZEICHEN = datetime.datetime(2026, 9, 3, 8, 20, 0, tzinfo=datetime.UTC)
class FakeWerte:
TRIP_SENSOR = ""
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
class FakeZuordnung:
werte = FakeWerte()
class FakeAblage:
def __init__(self, fahrten=None):
self.fahrten = fahrten or []
self.aktualisiert = []
async def profil_lesen(self):
return {"einstellungen": {"fahrten_pausenzeit_min": 15}}
async def fahrten_lesen(self):
return self.fahrten
async def fahrt_anhaengen(self, fahrt):
self.fahrten.append(fahrt)
async def fahrt_aktualisieren(self, trip_id, aenderungen):
self.aktualisiert.append((trip_id, aenderungen))
for fahrt in self.fahrten:
if fahrt.get("trip_id") == trip_id:
fahrt.update(aenderungen)
return True
return False
class FakeKoordinator:
def __init__(self, start_ts=T0, fahrten=None):
self.fahrt_start_ts = start_ts
self.fahrt_sperre = asyncio.Lock()
self.zuordnung = FakeZuordnung()
self.ablage = FakeAblage(fahrten)
self.hass = object()
self.veroeffentlicht = 0
self.abgebrochen = 0
def warte_ende_ab_abbrechen(self):
self.abgebrochen += 1
async def fahrt_start_setzen(self, ts):
self.fahrt_start_ts = ts
async def fahrzeugstatus_veroeffentlichen(self):
pass
async def fahrten_veroeffentlichen(self):
self.veroeffentlicht += 1
def fahrt(trip_id, start, ende, vorlaeufig):
return {
"trip_id": trip_id,
"ts_start": start.isoformat(),
"ts_end": ende.isoformat(),
"duration_s": int((ende - start).total_seconds()),
"ende_vorlaeufig": vorlaeufig,
}
class Basis(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self._echt = (
f._letztes_lebenszeichen,
f._geraetezeit,
f.screening.durchfuehren,
f.reifen.aktiver_satz,
)
async def lebenszeichen(k, sensor, seit):
return LEBENSZEICHEN
async def geraetezeit(k, ereigniszeit, standard):
return ereigniszeit or standard
async def durchfuehren(k):
return None
def aktiver_satz(profil):
return "sommer"
f._letztes_lebenszeichen = lebenszeichen
f._geraetezeit = geraetezeit
f.screening.durchfuehren = durchfuehren
f.reifen.aktiver_satz = aktiver_satz
async def asyncTearDown(self):
(
f._letztes_lebenszeichen,
f._geraetezeit,
f.screening.durchfuehren,
f.reifen.aktiver_satz,
) = self._echt
class Knopf(Basis):
async def test_schliesst_auf_das_letzte_lebenszeichen(self):
"""Nicht auf 'jetzt': das Fahrzeug stand still, als es still stand -
und genau dann liegen die beiden weit auseinander."""
k = FakeKoordinator()
self.assertTrue(await f.jetzt_beenden(k))
self.assertEqual(len(k.ablage.fahrten), 1)
gespeichert = k.ablage.fahrten[0]
self.assertEqual(gespeichert["ts_end"], LEBENSZEICHEN.isoformat())
self.assertTrue(gespeichert["ende_vorlaeufig"])
self.assertIsNone(k.fahrt_start_ts)
async def test_ohne_laufende_fahrt_passiert_nichts(self):
k = FakeKoordinator(start_ts=None)
self.assertFalse(await f.jetzt_beenden(k))
self.assertEqual(k.ablage.fahrten, [])
async def test_bricht_eine_wartende_ende_aufgabe_ab(self):
"""Sonst schloesse die Wartezeit die Fahrt ein zweites Mal."""
k = FakeKoordinator()
await f.jetzt_beenden(k)
self.assertEqual(k.abgebrochen, 1)
class Nachziehen(Basis):
async def _spaetes_aus(self, k, gemeldet):
await f._spaetes_aus(k, gemeldet, gemeldet)
async def test_spaeteres_aus_zieht_das_vorlaeufige_ende_nach(self):
"""Der eigentliche Zweck: nach der Tiefgarage kommt der Rest gepuffert
nach, und die Fahrt endet dort, wo sie wirklich endete."""
k = FakeKoordinator(
start_ts=None,
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, True)],
)
# Gemeldet wird das verzoegerte Zuendungs-Aus; echt ist es
# ZUENDUNG_NACHLAUF_S frueher.
echt = LEBENSZEICHEN + datetime.timedelta(minutes=12)
gemeldet = echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
await self._spaetes_aus(k, gemeldet)
self.assertEqual(len(k.ablage.aktualisiert), 1)
_, aenderungen = k.ablage.aktualisiert[0]
self.assertEqual(aenderungen["ts_end"], echt.isoformat())
self.assertEqual(aenderungen["duration_s"], int((echt - T0).total_seconds()))
self.assertFalse(aenderungen["ende_vorlaeufig"])
self.assertNotIn(
"edited_fields",
aenderungen,
"ts_end darf nicht als Handeingabe gelten - das blockierte die "
"naechste Korrektur",
)
async def test_regulaer_geschlossene_fahrt_wird_nicht_angefasst(self):
k = FakeKoordinator(
start_ts=None,
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, False)],
)
echt = LEBENSZEICHEN + datetime.timedelta(minutes=12)
await self._spaetes_aus(
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
)
self.assertEqual(k.ablage.aktualisiert, [])
async def test_frueheres_aus_verkuerzt_die_fahrt_nicht(self):
k = FakeKoordinator(
start_ts=None,
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, True)],
)
echt = LEBENSZEICHEN - datetime.timedelta(minutes=5)
await self._spaetes_aus(
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
)
self.assertEqual(k.ablage.aktualisiert, [])
async def test_zu_weit_danach_wird_nicht_korrigiert(self):
"""Jenseits des Korrekturfensters gehoert das Aus wahrscheinlich zu
einer ganz anderen Fahrt."""
k = FakeKoordinator(
start_ts=None,
fahrten=[fahrt("t-1", T0, LEBENSZEICHEN, True)],
)
echt = LEBENSZEICHEN + datetime.timedelta(seconds=f.KORREKTUR_FENSTER_S + 60)
with self.assertLogs(f._LOGGER, level="WARNING"):
await self._spaetes_aus(
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
)
self.assertEqual(k.ablage.aktualisiert, [])
async def test_nur_die_juengste_fahrt_wird_korrigiert(self):
"""Eine aeltere vorlaeufige Fahrt darf ein spaetes Aus nicht an sich
ziehen - sonst wanderte ihr Ende ueber die naechste Fahrt hinweg."""
alt_start = T0 - datetime.timedelta(hours=3)
k = FakeKoordinator(
start_ts=None,
fahrten=[
fahrt("t-alt", alt_start, alt_start + datetime.timedelta(minutes=10), True),
fahrt("t-neu", T0, LEBENSZEICHEN, False),
],
)
echt = LEBENSZEICHEN + datetime.timedelta(minutes=5)
await self._spaetes_aus(
k, echt + datetime.timedelta(seconds=f.ZUENDUNG_NACHLAUF_S)
)
self.assertEqual(
k.ablage.aktualisiert, [], "die juengste Fahrt ist regulaer geschlossen"
)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -33,6 +33,15 @@ T0 = datetime.datetime(2026, 9, 2, 16, 32, 18, tzinfo=datetime.UTC)
T_WECHSEL = datetime.datetime(2026, 9, 2, 21, 22, 12, tzinfo=datetime.UTC)
class FakeWerte:
TRIP_SENSOR = "binary_sensor.trip"
ZUENDUNG_SENSOR = "binary_sensor.zuendung"
class FakeZuordnung:
werte = FakeWerte()
class FakeKoordinator:
"""Nur das, was der Signalpfad anfasst."""
@@ -41,6 +50,11 @@ class FakeKoordinator:
self.fahrt_sperre = asyncio.Lock()
self.beendet = []
self.veroeffentlicht = 0
# Dieser Test beschreibt die Welt MIT Trip-Signal: dort bringt das
# Geraet seine Pausentoleranz mit, _pausenzeit_s() gibt 0 zurueck und
# es wird sofort geschlossen. Gegenstand ist hier die Sperre, nicht
# die Wartezeit - die hat test_zuendungspause.py.
self.zuordnung = FakeZuordnung()
def warte_ende_ab_abbrechen(self):
pass
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Die Wartezeit nach dem Zuendungs-Aus, und die beiden Nachlaeufe des Geraets.
Warum es das gibt (03.09.2026): das Trip-Signal des FMM003 endet erst nach
seinem "Ignition OFF Timeout" von 900 s - und der Schlaf-Timeout des Geraets
stand auf denselben 900 s. Beide Uhren starten beim Zuendungs-Aus und fallen in
derselben Sekunde. Am 02.09.2026 ist das zweimal beobachtet worden, mit
entgegengesetztem Ausgang: die Fahrt um 16:54 verlor ihr Trip-Ende (danach
1 h 48 min ohne einen einzigen Datensatz), die um 21:43 bekam es um 21:58:16,
genau 901 s spaeter. Ein Wettlauf, den unsere Seite nicht gewinnen kann.
Deshalb loest jetzt die Zuendung aus, und die Wartezeit laeuft bei uns - auf
einer Maschine, die nicht schlaeft. Geprueft wird genau das: dass eine
zurueckkehrende Zuendung die Fahrt fortsetzt statt sie zu teilen, dass die
Nachlaeufe des Geraets abgezogen werden, und dass ein spaet eintreffendes Aus
keine Phantomfahrt erzeugt.
Aufruf: python3 tests/fahrterkennung/test_zuendungspause.py
(braucht das homeassistant-Paket, weil die Integration es importiert - also
z. B. im Test-Container)
"""
import asyncio
import datetime
import os
import sys
import unittest
_HIER = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(os.path.dirname(_HIER)))
from custom_components.audi_dashboard import fahrterkennung as f # noqa: E402
T0 = datetime.datetime(2026, 9, 2, 16, 32, 18, tzinfo=datetime.UTC)
AUS = datetime.datetime(2026, 9, 2, 16, 54, 21, tzinfo=datetime.UTC)
# So kurz, dass der Test schnell bleibt - die Laenge ist hier nicht der Punkt,
# sondern ob sie abgebrochen wird.
PAUSE = 0.15
class FakeWerte:
def __init__(self, trip=""):
self.TRIP_SENSOR = trip
self.ZUENDUNG_SENSOR = "binary_sensor.zuendung"
class FakeZuordnung:
def __init__(self, trip=""):
self.werte = FakeWerte(trip)
class FakeAblage:
def __init__(self, minuten=15, fahrten=None):
self.profil = {"einstellungen": {"fahrten_pausenzeit_min": minuten}}
self.fahrten = fahrten or []
async def profil_lesen(self):
return self.profil
async def fahrten_lesen(self):
return self.fahrten
class FakeKoordinator:
def __init__(self, start_ts=T0, trip="", minuten=15, fahrten=None):
self.fahrt_start_ts = start_ts
self.fahrt_sperre = asyncio.Lock()
self.zuordnung = FakeZuordnung(trip)
self.ablage = FakeAblage(minuten, fahrten)
self.beendet = []
self._aufgabe = None
def warte_ende_ab(self, koroutine):
self.warte_ende_ab_abbrechen()
self._aufgabe = asyncio.ensure_future(koroutine)
def warte_ende_ab_abbrechen(self):
if self._aufgabe is not None and not self._aufgabe.done():
self._aufgabe.cancel()
self._aufgabe = None
async def fahrt_start_setzen(self, ts):
self.fahrt_start_ts = ts
async def fahrzeugstatus_veroeffentlichen(self):
pass
async def ausklingen(self):
"""Der wartenden Aufgabe Zeit geben, fertig zu werden."""
if self._aufgabe is None:
return
try:
await asyncio.wait_for(asyncio.shield(self._aufgabe), timeout=2)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
class Basis(unittest.IsolatedAsyncioTestCase):
def _pause_kurz(self):
async def pausenzeit(k):
return 0 if k.zuordnung.werte.TRIP_SENSOR else PAUSE
return pausenzeit
async def asyncSetUp(self):
self._echt = (
f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen,
)
async def geraetezeit(k, ereigniszeit, standard):
return ereigniszeit or standard
async def beenden(k, start_ts, ende_ts):
k.beendet.append((start_ts, ende_ts))
await k.fahrt_start_setzen(None)
async def gegenpruefen(k, start_ts, signal_ende, gerechnet):
# Liest den Verlauf und aendert nichts an der Rechnung (siehe dort);
# ohne echtes hass-Objekt ist sie hier nur im Weg.
return None
f._geraetezeit = geraetezeit
f.fahrt_beenden = beenden
f._pausenzeit_s = self._pause_kurz()
f._nachlauf_gegenpruefen = gegenpruefen
async def asyncTearDown(self):
(
f._geraetezeit, f.fahrt_beenden, f._pausenzeit_s, f._nachlauf_gegenpruefen,
) = self._echt
class Wartezeit(Basis):
async def test_aus_schliesst_nicht_sofort(self):
k = FakeKoordinator()
await f.zuendung_geaendert(k, "off", "on", AUS)
self.assertEqual(
k.beendet, [], "waehrend der Wartezeit darf nichts geschrieben sein"
)
self.assertIsNotNone(k.fahrt_start_ts)
await k.ausklingen()
self.assertEqual(len(k.beendet), 1, "nach der Wartezeit muss die Fahrt enden")
async def test_zuendung_kommt_zurueck_und_die_fahrt_laeuft_weiter(self):
"""Der eigentliche Zweck der Wartezeit."""
k = FakeKoordinator()
await f.zuendung_geaendert(k, "off", "on", AUS)
await asyncio.sleep(PAUSE / 3)
await f.zuendung_geaendert(k, "on", "off", AUS + datetime.timedelta(seconds=20))
await asyncio.sleep(PAUSE * 2)
self.assertEqual(k.beendet, [], "eine zurueckgekehrte Zuendung beendet nichts")
self.assertEqual(k.fahrt_start_ts, T0, "es bleibt dieselbe Fahrt")
async def test_start_stopp_zerlegt_die_fahrt_nicht(self):
"""Der RS4 stellt an der Ampel den Motor ab - aus einer Drehzahlquelle
ist das nicht vom Abstellen zu unterscheiden."""
k = FakeKoordinator()
t = AUS
for _ in range(4):
await f.zuendung_geaendert(k, "off", "on", t)
await asyncio.sleep(PAUSE / 5)
t += datetime.timedelta(seconds=45)
await f.zuendung_geaendert(k, "on", "off", t)
t += datetime.timedelta(seconds=200)
self.assertEqual(k.beendet, [])
self.assertEqual(k.fahrt_start_ts, T0)
async def test_ohne_wartezeit_bleibt_es_wie_bisher(self):
"""Mit zugeordnetem Trip-Signal wartet das Geraet - wir nicht."""
k = FakeKoordinator(trip="binary_sensor.trip")
await f.zuendung_geaendert(k, "off", "on", AUS)
self.assertEqual(len(k.beendet), 1, "ohne Pause wird sofort geschlossen")
class Nachlauf(Basis):
async def test_zuendungsweg_zieht_180_s_ab(self):
k = FakeKoordinator()
await f.zuendung_geaendert(k, "off", "on", AUS)
await k.ausklingen()
_, ende = k.beendet[0]
self.assertEqual(ende, AUS - datetime.timedelta(seconds=180))
async def test_tripweg_zieht_beide_nachlaeufe_ab(self):
"""900 s Trip-Nachlauf plus 180 s des Zuendungselements: das Trip-Ende
haengt selbst an der bereits verzoegerten Zuendung."""
k = FakeKoordinator(trip="binary_sensor.trip")
await f.zuendung_geaendert(k, "off", "on", AUS)
_, ende = k.beendet[0]
self.assertEqual(ende, AUS - datetime.timedelta(seconds=1080))
async def test_zu_kurz_wird_auf_den_beginn_geklemmt(self):
k = FakeKoordinator(start_ts=AUS - datetime.timedelta(seconds=60))
await f.zuendung_geaendert(k, "off", "on", AUS)
await k.ausklingen()
start, ende = k.beendet[0]
self.assertEqual(ende, start, "kuerzer als der Nachlauf heisst: nicht gefahren")
class SpaetesAus(Basis):
async def test_aus_zur_geschlossenen_fahrt_wird_verworfen(self):
"""Gepuffert eintreffendes Aus, dessen Geraetezeit in die schon
geschlossene Fahrt faellt - es darf nichts ausloesen."""
ende = AUS - datetime.timedelta(seconds=180)
k = FakeKoordinator(
start_ts=None,
fahrten=[{"ts_start": T0.isoformat(), "ts_end": ende.isoformat()}],
)
await f.zuendung_geaendert(k, "off", "on", AUS)
self.assertEqual(k.beendet, [])
self.assertIsNone(k.fahrt_start_ts, "es darf keine Fahrt entstehen")
async def test_aus_ohne_jede_fahrt_meldet_sich(self):
k = FakeKoordinator(start_ts=None, fahrten=[])
with self.assertLogs(f._LOGGER, level="WARNING") as protokoll:
await f.zuendung_geaendert(k, "off", "on", AUS)
self.assertTrue(
any("entgangen" in z for z in protokoll.output),
"ein Aus ohne passende Fahrt gehoert ins Protokoll",
)
class Einstellung(unittest.IsolatedAsyncioTestCase):
"""_pausenzeit_s selbst - hier ungepatcht."""
async def test_wert_aus_dem_profil(self):
k = FakeKoordinator(minuten=25)
self.assertEqual(await f._pausenzeit_s(k), 25 * 60)
async def test_trip_signal_schlaegt_die_einstellung(self):
k = FakeKoordinator(minuten=25, trip="binary_sensor.trip")
self.assertEqual(await f._pausenzeit_s(k), 0)
async def test_unsinn_faellt_auf_den_standard_zurueck(self):
k = FakeKoordinator()
k.ablage.profil = {"einstellungen": {"fahrten_pausenzeit_min": "viel"}}
self.assertEqual(await f._pausenzeit_s(k), f.STANDARD_PAUSENZEIT_MIN * 60)
async def test_obergrenze_greift(self):
k = FakeKoordinator(minuten=9999)
self.assertEqual(await f._pausenzeit_s(k), f.PAUSENZEIT_MAX_MIN * 60)
async def test_null_ist_erlaubt(self):
"""Wer keine Pause will, bekommt keine - dann endet die Fahrt sofort."""
k = FakeKoordinator(minuten=0)
self.assertEqual(await f._pausenzeit_s(k), 0)
if __name__ == "__main__":
unittest.main(verbosity=2)