diff --git a/AGENTS.md b/AGENTS.md index de5c7b9..a86eae8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (1–2 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 0–60 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. diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index 9354c14..b094adb 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -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 { + return this.rest.dienstAufrufen(DIENST_DOMAIN, "fahrt_jetzt_beenden", {}); + } + historieImportieren(start: string, ende: string): Promise { return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", { start, diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 8ae3cc9..222868c 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -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[]; } diff --git a/companion-app/src/daten/profilAdapter.ts b/companion-app/src/daten/profilAdapter.ts index 3f94bdd..b9897da 100644 --- a/companion-app/src/daten/profilAdapter.ts +++ b/companion-app/src/daten/profilAdapter.ts @@ -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, diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts index 194d209..25ccc6b 100644 --- a/companion-app/src/daten/service.test.ts +++ b/companion-app/src/daten/service.test.ts @@ -28,6 +28,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug { sicherheitscheck: [], zuendung: null, geparktSeit: null, + faehrtSeit: null, standortLat: null, standortLon: null, standortZeit: null, diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index 7653263..4e7741d 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -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({ + + Fahrt beenden + 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. */} + + Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die + Fahrt automatisch beendet werden soll. + + + Darstellung > = { 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 ? ( Zuletzt - {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. */ läuft} + aktion={ + } - beiKlick={() => geheZu("trip", letzteFahrt.trip_id)} letzte={!letzteTankung} /> + ) : ( + letzteFahrt && ( + vorläufig + ) : letzteFahrt.verbrauch_l_100km != null ? ( + `${de(letzteFahrt.verbrauch_l_100km, 1)} l/100 km` + ) : ( + "—" + ) + } + beiKlick={() => geheZu("trip", letzteFahrt.trip_id)} + letzte={!letzteTankung} + /> + ) )} {letzteTankung && ( , wie im Panel. */ beiKlick?: () => void /** Unterste Zeile einer Kachel — dort entfällt die Trennlinie. */ @@ -219,10 +224,13 @@ export function Blattzeile({ {label} {labelZusatz !== undefined && {labelZusatz}} - - {wert} - {wertZusatz !== undefined && {wertZusatz}} - + {wert !== undefined && ( + + {wert} + {wertZusatz !== undefined && {wertZusatz}} + + )} + {aktion} {beiKlick && (
Zuletzt - ${t ? `
` + : t ? `` : ""} ${f ? ` +
Fahrt beenden +
+ + ${CONFIG.pausenzeit === 0 ? "sofort" : CONFIG.pausenzeit + " Min."} +
+ + Hier können Sie einstellen, wie viele Minuten nach Ausschalten der Zündung die + Fahrt automatisch beendet werden soll. +
Darstellung
@@ -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; } }); } diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard.css b/custom_components/audi_dashboard/frontend/audi-dashboard.css index 8d08978..3889fd8 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard.css +++ b/custom_components/audi_dashboard/frontend/audi-dashboard.css @@ -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 { diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index 000229d..49a43bb 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -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) diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 9026196..8497d0d 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -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"], diff --git a/custom_components/audi_dashboard/services.yaml b/custom_components/audi_dashboard/services.yaml index e87f696..2e074b6 100644 --- a/custom_components/audi_dashboard/services.yaml +++ b/custom_components/audi_dashboard/services.yaml @@ -330,3 +330,6 @@ csv_importieren: selector: text: multiline: true + +fahrt_jetzt_beenden: + fields: {} diff --git a/custom_components/audi_dashboard/veroeffentlichung.py b/custom_components/audi_dashboard/veroeffentlichung.py index b042a08..943760c 100644 --- a/custom_components/audi_dashboard/veroeffentlichung.py +++ b/custom_components/audi_dashboard/veroeffentlichung.py @@ -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"], diff --git a/design-system/src/components/Slider/Slider.css b/design-system/src/components/Slider/Slider.css new file mode 100644 index 0000000..ed70832 --- /dev/null +++ b/design-system/src/components/Slider/Slider.css @@ -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); +} diff --git a/design-system/src/components/Slider/Slider.tsx b/design-system/src/components/Slider/Slider.tsx new file mode 100644 index 0000000..74fea8d --- /dev/null +++ b/design-system/src/components/Slider/Slider.tsx @@ -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 ( +
+ onChange(Number(e.target.value))} + style={{ "--ads-fuell": `${anteil}%` } as React.CSSProperties} + {...rest} + /> + {anzeige !== undefined && {anzeige}} +
+ ); +} diff --git a/design-system/src/components/Slider/index.ts b/design-system/src/components/Slider/index.ts new file mode 100644 index 0000000..5d185a6 --- /dev/null +++ b/design-system/src/components/Slider/index.ts @@ -0,0 +1,2 @@ +export { Slider } from "./Slider"; +export type { SliderProps } from "./Slider"; diff --git a/design-system/src/index.ts b/design-system/src/index.ts index 54e41a6..9b14478 100644 --- a/design-system/src/index.ts +++ b/design-system/src/index.ts @@ -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"; diff --git a/design-system/src/styles.css b/design-system/src/styles.css index d1f10bf..a47b3ab 100644 --- a/design-system/src/styles.css +++ b/design-system/src/styles.css @@ -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"; diff --git a/tests/fahrterkennung/test_fahrtende_knopf.py b/tests/fahrterkennung/test_fahrtende_knopf.py new file mode 100644 index 0000000..8a4452a --- /dev/null +++ b/tests/fahrterkennung/test_fahrtende_knopf.py @@ -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) diff --git a/tests/fahrterkennung/test_signalwechsel.py b/tests/fahrterkennung/test_signalwechsel.py index 5775e92..26fd473 100644 --- a/tests/fahrterkennung/test_signalwechsel.py +++ b/tests/fahrterkennung/test_signalwechsel.py @@ -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 diff --git a/tests/fahrterkennung/test_zuendungspause.py b/tests/fahrterkennung/test_zuendungspause.py new file mode 100644 index 0000000..3c61aa8 --- /dev/null +++ b/tests/fahrterkennung/test_zuendungspause.py @@ -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)