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:
@@ -1,6 +1,10 @@
|
|||||||
# AGENTS.md — Project state, review findings, open items, and working rules
|
# 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`,
|
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
|
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
|
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
|
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
|
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.
|
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.
|
||||||
|
|||||||
@@ -332,6 +332,15 @@ export class DataMetricApi {
|
|||||||
|
|
||||||
/** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag
|
/** Startet den Import. Kehrt zurück, sobald das Backend den Auftrag
|
||||||
angenommen hat — nicht, wenn er fertig ist; dafür importStatusLesen(). */
|
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> {
|
historieImportieren(start: string, ende: string): Promise<unknown> {
|
||||||
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
|
return this.rest.dienstAufrufen(DIENST_DOMAIN, "historie_importieren", {
|
||||||
start,
|
start,
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export interface Fahrt {
|
|||||||
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
|
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
|
||||||
* verbrauch_aus_literstaenden() im Backend. */
|
* verbrauch_aus_literstaenden() im Backend. */
|
||||||
verbrauch_l_100km?: number | null;
|
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[];
|
pausen?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export interface Einstellungen {
|
|||||||
backupIntervall: string
|
backupIntervall: string
|
||||||
letztesBackup: string | null
|
letztesBackup: string | null
|
||||||
ausfuehrung: string
|
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 {
|
export interface Reifensatz {
|
||||||
@@ -69,6 +72,8 @@ export interface Fahrzeug {
|
|||||||
zugeordnet", also unbekannt — nicht „steht". */
|
zugeordnet", also unbekannt — nicht „steht". */
|
||||||
zuendung: boolean | null
|
zuendung: boolean | null
|
||||||
geparktSeit: string | null
|
geparktSeit: string | null
|
||||||
|
/** Beginn der laufenden Fahrt, sonst null - siehe faehrt_seit im Backend. */
|
||||||
|
faehrtSeit: string | null
|
||||||
standortLat: number | null
|
standortLat: number | null
|
||||||
standortLon: number | null
|
standortLon: number | null
|
||||||
standortZeit: string | null
|
standortZeit: string | null
|
||||||
@@ -142,6 +147,10 @@ export function profilZuEinstellungen(profil: Profil): Einstellungen {
|
|||||||
backupIntervall: text(einst, "backup_intervall", "aus"),
|
backupIntervall: text(einst, "backup_intervall", "aus"),
|
||||||
letztesBackup: text(einst, "letztes_backup") || null,
|
letztesBackup: text(einst, "letztes_backup") || null,
|
||||||
ausfuehrung: text(fahrzeug, "ausfuehrung"),
|
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 ?? [],
|
sicherheitscheck: status.sicherheitscheck ?? [],
|
||||||
zuendung: status.zuendung ?? null,
|
zuendung: status.zuendung ?? null,
|
||||||
geparktSeit: (status.geparkt_seit as string | null) ?? null,
|
geparktSeit: (status.geparkt_seit as string | null) ?? null,
|
||||||
|
faehrtSeit: (status.faehrt_seit as string | null) ?? null,
|
||||||
standortLat: status.standort_lat ?? null,
|
standortLat: status.standort_lat ?? null,
|
||||||
standortLon: status.standort_lon ?? null,
|
standortLon: status.standort_lon ?? null,
|
||||||
standortZeit: status.standort_zeit ?? null,
|
standortZeit: status.standort_zeit ?? null,
|
||||||
@@ -240,6 +250,7 @@ export function zusammenfuehren(
|
|||||||
e["backup_intervall"] = einstellungen.backupIntervall
|
e["backup_intervall"] = einstellungen.backupIntervall
|
||||||
e["nacht_von"] = einstellungen.nachtVon
|
e["nacht_von"] = einstellungen.nachtVon
|
||||||
e["nacht_bis"] = einstellungen.nachtBis
|
e["nacht_bis"] = einstellungen.nachtBis
|
||||||
|
e["fahrten_pausenzeit_min"] = einstellungen.pausenzeitMin
|
||||||
e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb }
|
e["smartdeal"] = { aktiv: fahrzeug.smartdeal.aktiv, laeuft_ab: fahrzeug.smartdeal.laeuftAb }
|
||||||
e["oelwechsel_intervall"] = {
|
e["oelwechsel_intervall"] = {
|
||||||
modus: fahrzeug.oel.modus,
|
modus: fahrzeug.oel.modus,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
|
|||||||
sicherheitscheck: [],
|
sicherheitscheck: [],
|
||||||
zuendung: null,
|
zuendung: null,
|
||||||
geparktSeit: null,
|
geparktSeit: null,
|
||||||
|
faehrtSeit: null,
|
||||||
standortLat: null,
|
standortLat: null,
|
||||||
standortLon: null,
|
standortLon: null,
|
||||||
standortZeit: null,
|
standortZeit: null,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
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 { ApiFehler, DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
|
||||||
import type { Profil } from "../api"
|
import type { Profil } from "../api"
|
||||||
@@ -348,6 +348,25 @@ export function Einstellungen({
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Tile>
|
</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>
|
<Tile>
|
||||||
<span className="ads-eyebrow">Darstellung</span>
|
<span className="ads-eyebrow">Darstellung</span>
|
||||||
<Seg
|
<Seg
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ function dauerSeitText(ts: number | null): string {
|
|||||||
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
|
/* Fahrzustand aus der Zündung + letzter beendeter Fahrt, deckungsgleich mit
|
||||||
standortZustand() im Panel. */
|
standortZustand() im Panel. */
|
||||||
/* Ab wann aus „steht gerade" ein „Parkplatz" wird. Hing bis 2026-08-31 an
|
/* Ab wann aus „steht gerade" ein „Parkplatz" wird. Hing bis 2026-08-31 an
|
||||||
der Einstellung fahrten_pausenzeit_min - die ist mit der Pausenregel
|
der Einstellung fahrten_pausenzeit_min. Die gibt es seit dem 03.09.2026
|
||||||
entfallen (der FMM003 bringt sie selbst mit), der Schwellwert hier hat
|
wieder (Kachel „Fahrt beenden“), aber der Schwellwert hier hat nach wie vor
|
||||||
damit aber nichts zu tun und bleibt als eigene Zahl stehen. */
|
nichts damit zu tun und bleibt als eigene Zahl stehen. */
|
||||||
const PARKPLATZ_AB_MIN = 15
|
const PARKPLATZ_AB_MIN = 15
|
||||||
|
|
||||||
/* Seit wann das Fahrzeug steht: aus dem Backend, hergeleitet aus dem
|
/* Seit wann das Fahrzeug steht: aus dem Backend, hergeleitet aus dem
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
|
import { Fig, ProgressBar, StatusRow, Tile } from "@audi-dash/ui"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
import { useDaten } from "../daten/DatenKontext"
|
import { useDaten } from "../daten/DatenKontext"
|
||||||
import { naechsterService } from "../daten/service"
|
import { naechsterService } from "../daten/service"
|
||||||
import { datum, de, deOderStrich, eur, streckeText } from "../format"
|
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 }) {
|
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
|
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"
|
const winterraeder = fahrzeug.reifen.aktiv === "Winter"
|
||||||
/* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname -
|
/* Der gespeicherte Wert ist der NAME der Ansicht, nicht der Dateiname -
|
||||||
siehe die Begruendung in Einstellungen.tsx. Der Vergleich gegen datei
|
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
|
Tankvorgang". Fehlen beide Datensätze, lässt das Panel die Kachel
|
||||||
ganz weg; die Leerzustände hier bleiben als eigene Konvention der
|
ganz weg; die Leerzustände hier bleiben als eigene Konvention der
|
||||||
App erhalten (siehe Kapitel 1 des Paritätsaudits). */}
|
App erhalten (siehe Kapitel 1 des Paritätsaudits). */}
|
||||||
{letzteFahrt || letzteTankung ? (
|
{fahrzeug.faehrtSeit || letzteFahrt || letzteTankung ? (
|
||||||
<Tile className="dm-teaser">
|
<Tile className="dm-teaser">
|
||||||
<span className="ads-eyebrow">Zuletzt</span>
|
<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
|
<Blattzeile
|
||||||
label="Letzte Fahrt"
|
label="Letzte Fahrt"
|
||||||
labelZusatz={strecke(letzteFahrt) ?? ""}
|
labelZusatz={strecke(letzteFahrt) ?? ""}
|
||||||
wert={streckeText(letzteFahrt.distance_km, "—")}
|
wert={
|
||||||
|
(letzteFahrt.ende_vorlaeufig ? "~ " : "") +
|
||||||
|
streckeText(letzteFahrt.distance_km, "—")
|
||||||
|
}
|
||||||
wertZusatz={
|
wertZusatz={
|
||||||
letzteFahrt.verbrauch_l_100km != null
|
letzteFahrt.ende_vorlaeufig ? (
|
||||||
? `${de(letzteFahrt.verbrauch_l_100km, 1)} l/100 km`
|
<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)}
|
beiKlick={() => geheZu("trip", letzteFahrt.trip_id)}
|
||||||
letzte={!letzteTankung}
|
letzte={!letzteTankung}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{letzteTankung && (
|
{letzteTankung && (
|
||||||
<Blattzeile
|
<Blattzeile
|
||||||
|
|||||||
@@ -200,13 +200,18 @@ export function Blattzeile({
|
|||||||
labelZusatz,
|
labelZusatz,
|
||||||
wert,
|
wert,
|
||||||
wertZusatz,
|
wertZusatz,
|
||||||
|
aktion,
|
||||||
beiKlick,
|
beiKlick,
|
||||||
letzte,
|
letzte,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
labelZusatz?: 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
|
wertZusatz?: ReactNode
|
||||||
|
/** Steht anstelle der Wertespalte, z. B. ein Knopf. */
|
||||||
|
aktion?: ReactNode
|
||||||
/** Ohne Klickziel wird die Zeile ein reines <div>, wie im Panel. */
|
/** Ohne Klickziel wird die Zeile ein reines <div>, wie im Panel. */
|
||||||
beiKlick?: () => void
|
beiKlick?: () => void
|
||||||
/** Unterste Zeile einer Kachel — dort entfällt die Trennlinie. */
|
/** Unterste Zeile einer Kachel — dort entfällt die Trennlinie. */
|
||||||
@@ -219,10 +224,13 @@ export function Blattzeile({
|
|||||||
{label}
|
{label}
|
||||||
{labelZusatz !== undefined && <small>{labelZusatz}</small>}
|
{labelZusatz !== undefined && <small>{labelZusatz}</small>}
|
||||||
</span>
|
</span>
|
||||||
|
{wert !== undefined && (
|
||||||
<span className="dm-blatt__v">
|
<span className="dm-blatt__v">
|
||||||
{wert}
|
{wert}
|
||||||
{wertZusatz !== undefined && <small>{wertZusatz}</small>}
|
{wertZusatz !== undefined && <small>{wertZusatz}</small>}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{aktion}
|
||||||
{beiKlick && (
|
{beiKlick && (
|
||||||
<svg className="dm-blatt__chevron" viewBox="0 0 6 10" aria-hidden="true">
|
<svg className="dm-blatt__chevron" viewBox="0 0 6 10" aria-hidden="true">
|
||||||
<polyline points="1,1 5,5 1,9" />
|
<polyline points="1,1 5,5 1,9" />
|
||||||
|
|||||||
@@ -1965,3 +1965,30 @@ button.dm-listenzeile {
|
|||||||
width: auto;
|
width: auto;
|
||||||
vertical-align: middle;
|
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_MANUELL_ANLEGEN = "fahrt_manuell_anlegen"
|
||||||
D_FAHRT_AKTUALISIEREN = "fahrt_aktualisieren"
|
D_FAHRT_AKTUALISIEREN = "fahrt_aktualisieren"
|
||||||
D_FAHRT_LOESCHEN = "fahrt_loeschen"
|
D_FAHRT_LOESCHEN = "fahrt_loeschen"
|
||||||
|
D_FAHRT_JETZT_BEENDEN = "fahrt_jetzt_beenden"
|
||||||
D_SCREENING_JETZT = "screening_jetzt"
|
D_SCREENING_JETZT = "screening_jetzt"
|
||||||
D_BELEG_HOCHLADEN = "beleg_hochladen"
|
D_BELEG_HOCHLADEN = "beleg_hochladen"
|
||||||
D_TANKVORGANG_MANUELL = "tankvorgang_manuell"
|
D_TANKVORGANG_MANUELL = "tankvorgang_manuell"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from .const import (
|
|||||||
D_CSV_IMPORTIEREN,
|
D_CSV_IMPORTIEREN,
|
||||||
D_ENTITAETEN_SCHREIBEN,
|
D_ENTITAETEN_SCHREIBEN,
|
||||||
D_FAHRT_AKTUALISIEREN,
|
D_FAHRT_AKTUALISIEREN,
|
||||||
|
D_FAHRT_JETZT_BEENDEN,
|
||||||
D_FAHRT_LOESCHEN,
|
D_FAHRT_LOESCHEN,
|
||||||
D_FAHRT_MANUELL_ANLEGEN,
|
D_FAHRT_MANUELL_ANLEGEN,
|
||||||
D_HISTORIE_IMPORTIEREN,
|
D_HISTORIE_IMPORTIEREN,
|
||||||
@@ -132,6 +133,7 @@ SCHEMATA: dict[str, vol.Schema] = {
|
|||||||
**_FAHRT_FELDER,
|
**_FAHRT_FELDER,
|
||||||
}),
|
}),
|
||||||
D_FAHRT_LOESCHEN: vol.Schema({vol.Required("trip_id"): cv.string}),
|
D_FAHRT_LOESCHEN: vol.Schema({vol.Required("trip_id"): cv.string}),
|
||||||
|
D_FAHRT_JETZT_BEENDEN: vol.Schema({}),
|
||||||
D_BELEG_HOCHLADEN: vol.Schema({
|
D_BELEG_HOCHLADEN: vol.Schema({
|
||||||
vol.Required("pdf_base64"): cv.string,
|
vol.Required("pdf_base64"): cv.string,
|
||||||
vol.Optional("dateiname"): _TEXT,
|
vol.Optional("dateiname"): _TEXT,
|
||||||
@@ -348,6 +350,7 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
|||||||
D_FAHRT_AKTUALISIEREN: _als_dienst(
|
D_FAHRT_AKTUALISIEREN: _als_dienst(
|
||||||
lambda a: fahrterkennung.aktualisieren(k, dict(a.data))
|
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(
|
D_FAHRT_LOESCHEN: _als_dienst(
|
||||||
lambda a: fahrterkennung.loeschen(k, a.data["trip_id"])
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -72,6 +73,50 @@ NACHLAUF_S = 900
|
|||||||
# Datensaetze schreibt - beim Fahren alle 10 s, im Stand deutlich seltener.
|
# Datensaetze schreibt - beim Fahren alle 10 s, im Stand deutlich seltener.
|
||||||
NACHLAUF_TOLERANZ_S = 120
|
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(
|
def leere_fahrt(
|
||||||
start_ts: datetime.datetime,
|
start_ts: datetime.datetime,
|
||||||
@@ -123,6 +168,12 @@ def leere_fahrt(
|
|||||||
"route": None,
|
"route": None,
|
||||||
"pausen": [],
|
"pausen": [],
|
||||||
"source": quelle,
|
"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",
|
"status": "offen",
|
||||||
"edited_fields": [],
|
"edited_fields": [],
|
||||||
}
|
}
|
||||||
@@ -147,22 +198,27 @@ async def _echtes_ende(
|
|||||||
anschliessend. Ohne diese Klemme entstuende aus dreissig Sekunden Zuendung
|
anschliessend. Ohne diese Klemme entstuende aus dreissig Sekunden Zuendung
|
||||||
eine Viertelstunde Fahrt.
|
eine Viertelstunde Fahrt.
|
||||||
|
|
||||||
NUR wenn die Fahrten wirklich vom Trip-Signal ausgeloest werden. Ist
|
ZWEI Nachlaeufe, nicht einer. Das Zuendungselement meldet selbst schon
|
||||||
TRIP_SENSOR nicht zugeordnet, uebernimmt die Zuendung diese Aufgabe
|
verzoegert (ZUENDUNG_NACHLAUF_S), und das Trip-Ende haengt an dieser bereits
|
||||||
(verlauf.fahrtsignal()) - und die hat keinen Nachlauf. Dort abzuziehen
|
verzoegerten Zuendung - wer ueber das Trip-Signal kommt, muss deshalb beide
|
||||||
wuerde jede Fahrt um eine Viertelstunde verkuerzen und jede kuerzere ganz
|
Spannen abziehen, wer ueber die Zuendung kommt, nur die eine. Bis zum
|
||||||
verschwinden lassen. Der Nachlauf gehoert zum Trip-Element des Geraets,
|
03.09.2026 wurde nur NACHLAUF_S abgezogen; seit der Ignition OFF Delay am
|
||||||
nicht zu uns; ohne dieses Element gibt es ihn nicht."""
|
01.09.2026 auf 180 s steht, endeten die Fahrten dadurch 180 s zu spaet."""
|
||||||
if not k.zuordnung.werte.TRIP_SENSOR:
|
nachlauf = ZUENDUNG_NACHLAUF_S
|
||||||
|
ueber_trip = bool(k.zuordnung.werte.TRIP_SENSOR)
|
||||||
|
if ueber_trip:
|
||||||
|
nachlauf += NACHLAUF_S
|
||||||
|
if not nachlauf:
|
||||||
return signal_ende
|
return signal_ende
|
||||||
gerechnet = signal_ende - datetime.timedelta(seconds=NACHLAUF_S)
|
gerechnet = signal_ende - datetime.timedelta(seconds=nachlauf)
|
||||||
if gerechnet <= start_ts:
|
if gerechnet <= start_ts:
|
||||||
_LOGGER.info(
|
_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",
|
"es wurde nicht gefahren",
|
||||||
int((signal_ende - start_ts).total_seconds()), NACHLAUF_S,
|
int((signal_ende - start_ts).total_seconds()), nachlauf,
|
||||||
)
|
)
|
||||||
return start_ts
|
return start_ts
|
||||||
|
if ueber_trip:
|
||||||
await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet)
|
await _nachlauf_gegenpruefen(k, start_ts, signal_ende, gerechnet)
|
||||||
return gerechnet
|
return gerechnet
|
||||||
|
|
||||||
@@ -192,7 +248,12 @@ async def _nachlauf_gegenpruefen(
|
|||||||
aus = [ts for ts, wert in punkte if wert == "off"]
|
aus = [ts for ts, wert in punkte if wert == "off"]
|
||||||
if not aus:
|
if not aus:
|
||||||
return
|
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:
|
if abweichung > NACHLAUF_TOLERANZ_S:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Fahrtende gerechnet auf %s (Trip-Ende minus %s s), die Zuendung ging "
|
"Fahrtende gerechnet auf %s (Trip-Ende minus %s s), die Zuendung ging "
|
||||||
@@ -305,12 +366,11 @@ async def _signalwechsel(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not an_jetzt and fahrt_laeuft:
|
if not an_jetzt and fahrt_laeuft:
|
||||||
# Ohne Wartezeit: der FMM003 bringt die Pausenregel selbst mit
|
# Ob hier noch gewartet wird, entscheidet _pausenzeit_s(): liegt ein
|
||||||
# (Trip Odometer -> "Ignition OFF Timeout" = 900 s). Was hier
|
# Trip-Signal an, bringt das Geraet seine Pausentoleranz selbst mit,
|
||||||
# ankommt, ist bereits eine beendete Fahrt - noch einmal 15 Minuten
|
# und eine zweite obendrauf haette zwei getrennte Fahrten verschmolzen.
|
||||||
# zu warten haette daraus eine halbe Stunde gemacht und zwei
|
# Loest die Zuendung aus, gibt es diese Toleranz nicht - dann ist die
|
||||||
# aufeinanderfolgende Fahrten verschmolzen, die das Geraet sauber
|
# Wartezeit unsere.
|
||||||
# getrennt hatte.
|
|
||||||
signal_ende = await _geraetezeit(k, ereigniszeit, jetzt)
|
signal_ende = await _geraetezeit(k, ereigniszeit, jetzt)
|
||||||
# Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die
|
# Ein Ende vor dem Beginn kann es nicht geben. Passiert, wenn die
|
||||||
# Meldezeit aus einem älteren Datensatz stammt als der Fahrtbeginn -
|
# 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.isoformat(), k.fahrt_start_ts.isoformat(),
|
||||||
)
|
)
|
||||||
signal_ende = jetzt
|
signal_ende = jetzt
|
||||||
# Erst jetzt den Nachlauf des Geraets abziehen: signal_ende ist der
|
# Erst jetzt die Nachlaeufe des Geraets abziehen: signal_ende ist der
|
||||||
# Zeitpunkt, zu dem das TRIP-SIGNAL endete, nicht der, zu dem das
|
# Zeitpunkt, zu dem das SIGNAL endete - die verzoegerte Zuendung oder
|
||||||
# Fahrzeug abgestellt wurde.
|
# das noch spaetere Trip-Ende -, nicht der, zu dem das Fahrzeug
|
||||||
|
# abgestellt wurde.
|
||||||
ende = await _echtes_ende(k, k.fahrt_start_ts, signal_ende)
|
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)
|
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()
|
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:
|
async def nach_neustart_fortsetzen(k: Koordinator) -> None:
|
||||||
"""Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging.
|
"""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(
|
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:
|
) -> None:
|
||||||
dauer_s = int((ende_ts - start_ts).total_seconds())
|
dauer_s = int((ende_ts - start_ts).total_seconds())
|
||||||
if dauer_s < MINDESTDAUER_S:
|
if dauer_s < MINDESTDAUER_S:
|
||||||
@@ -403,6 +680,7 @@ async def fahrt_beenden(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
fahrt = leere_fahrt(start_ts, ende_ts, "ha", reifen.aktiver_satz(await k.ablage.profil_lesen()))
|
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.ablage.fahrt_anhaengen(fahrt)
|
||||||
await k.fahrt_start_setzen(None)
|
await k.fahrt_start_setzen(None)
|
||||||
await k.fahrten_veroeffentlichen()
|
await k.fahrten_veroeffentlichen()
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":"2026.9.3.4","sha256":"54a1d06cd129ee5c8c28869a7191a40adbfb383b5090bd2feaed2ec9c8c1ffe9","bytes":267146,"gebaut":"2026-09-03T08:56:06Z"}
|
{"version":"2026.9.3.7","sha256":"82f5fdbf1ab4281a51f0c2d31f27b7609b4bc88835c19641d071f9db4970bb00","bytes":268073,"gebaut":"2026-09-03T12:57:42Z"}
|
||||||
Binary file not shown.
@@ -200,6 +200,11 @@ function profilZuConfig(p) {
|
|||||||
backupIntervall: p.einstellungen.backup_intervall || "aus",
|
backupIntervall: p.einstellungen.backup_intervall || "aus",
|
||||||
letztesBackup: p.einstellungen.letztes_backup || null,
|
letztesBackup: p.einstellungen.letztes_backup || null,
|
||||||
ausfuehrung: p.fahrzeug.ausfuehrung || "",
|
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 || [],
|
sicherheitscheck: status.sicherheitscheck || [],
|
||||||
zuendung: status.zuendung ?? null,
|
zuendung: status.zuendung ?? null,
|
||||||
geparktSeit: status.geparkt_seit ?? null,
|
geparktSeit: status.geparkt_seit ?? null,
|
||||||
|
faehrtSeit: status.faehrt_seit ?? null,
|
||||||
standortLat: status.standort_lat,
|
standortLat: status.standort_lat,
|
||||||
standortLon: status.standort_lon,
|
standortLon: status.standort_lon,
|
||||||
standortGenauigkeitM: status.standort_genauigkeit_m,
|
standortGenauigkeitM: status.standort_genauigkeit_m,
|
||||||
@@ -284,6 +290,7 @@ function configCarZuProfil(vorherigesProfil) {
|
|||||||
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
|
p.fahrzeug.ausfuehrung = CONFIG.ausfuehrung;
|
||||||
p.einstellungen.uebersichtsbild = CONFIG.startbild;
|
p.einstellungen.uebersichtsbild = CONFIG.startbild;
|
||||||
p.einstellungen.backup_intervall = CONFIG.backupIntervall;
|
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.smartdeal = { aktiv: CAR.smartdeal.aktiv, laeuft_ab: CAR.smartdeal.laeuftAb };
|
||||||
p.einstellungen.oelwechsel_intervall = {
|
p.einstellungen.oelwechsel_intervall = {
|
||||||
modus: CAR.oel.modus, km: CAR.oel.km, monate: CAR.oel.monate,
|
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
|
/* Ab wann aus "steht gerade" ein "Parkplatz" wird. Hing bis 2026-08-31 an
|
||||||
der Einstellung fahrten_pausenzeit_min - die ist mit der Pausenregel
|
der Einstellung fahrten_pausenzeit_min. Die gibt es seit dem 03.09.2026
|
||||||
entfallen (der FMM003 bringt sie selbst mit), der Schwellwert hier hat
|
wieder ("Fahrt beenden"), aber der Schwellwert hier hat nach wie vor nichts
|
||||||
damit aber nichts zu tun und bleibt als eigene Zahl stehen. */
|
damit zu tun und bleibt als eigene Zahl stehen. */
|
||||||
const PARKPLATZ_AB_MIN = 15;
|
const PARKPLATZ_AB_MIN = 15;
|
||||||
function dauerSeitText(ts) {
|
function dauerSeitText(ts) {
|
||||||
if (ts == null) return "unbekannt";
|
if (ts == null) return "unbekannt";
|
||||||
@@ -1608,9 +1615,15 @@ function teaser() {
|
|||||||
: null;
|
: null;
|
||||||
const t = juengster(TRIPS, "ts_start"), f = juengster(FILLS, "ts");
|
const t = juengster(TRIPS, "ts_start"), f = juengster(FILLS, "ts");
|
||||||
return `<div class="tile teaser"><span class="label">Zuletzt</span>
|
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="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>` : ""}
|
<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}">
|
${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>
|
<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>
|
<button class="aktion ${einrichtenOffen ? "primaer" : ""}" style="margin-top:16px" data-einrichten-auf>${einrichtenOffen ? "Fertig" : "Einrichten"}</button>
|
||||||
</div>
|
</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="tile"><span class="label">Darstellung</span>
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
<button data-mode="nacht" class="${HOST.dataset.theme === "nacht" ? "on" : ""}">Nacht</button>
|
<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
|
// Einzelbeleg: zurück auf die Tankstelle (manipuliert MAP direkt, kein
|
||||||
// render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren).
|
// render() - das würde die Karte neu aufbauen und den Ausschnitt verlieren).
|
||||||
if (e.target.closest("#belegZentrieren")) { belegAufTankstelleZentrieren(); return; }
|
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]");
|
const g = e.target.closest("[data-go]");
|
||||||
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
|
if (g) { const [n, i] = g.dataset.go.split(":"); go(n, i); }
|
||||||
});
|
});
|
||||||
|
|
||||||
ROOT.addEventListener("input", (e) => {
|
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;
|
const sucheFeld = e.target.dataset.setupSuche;
|
||||||
if (sucheFeld) {
|
if (sucheFeld) {
|
||||||
// Nur die Kandidatenliste patchen, nicht render() - ein voller
|
// 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.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.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.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; }
|
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 { background: var(--fg); border-color: var(--fg); }
|
||||||
.switch input:checked + .track::before { transform: translateX(19px); background: var(--canvas); }
|
.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,
|
/* C3: gemischt geschrieben, 15 px, 48 px hoch. .primaer ist gefüllt,
|
||||||
.aktion allein bleibt die Umriss-Sekundäraktion. */
|
.aktion allein bleibt die Umriss-Sekundäraktion. */
|
||||||
.aktion {
|
.aktion {
|
||||||
|
|||||||
@@ -544,7 +544,9 @@ class Koordinator:
|
|||||||
self.setzen(E_ZUORDNUNG, "aktuell", self.zuordnung.aktueller_stand())
|
self.setzen(E_ZUORDNUNG, "aktuell", self.zuordnung.aktueller_stand())
|
||||||
|
|
||||||
async def fahrzeugstatus_veroeffentlichen(self) -> None:
|
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:
|
async def beleg_ergebnis_veroeffentlichen(self, daten: dict) -> None:
|
||||||
self.setzen(E_BELEG_ERGEBNIS, "aktuell", daten)
|
self.setzen(E_BELEG_ERGEBNIS, "aktuell", daten)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "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",
|
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||||
"codeowners": ["@paul"],
|
"codeowners": ["@paul"],
|
||||||
|
|||||||
@@ -330,3 +330,6 @@ csv_importieren:
|
|||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
multiline: true
|
multiline: true
|
||||||
|
|
||||||
|
fahrt_jetzt_beenden:
|
||||||
|
fields: {}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ def fahrzeugstatus(
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
werte: Sensorzuordnung,
|
werte: Sensorzuordnung,
|
||||||
geparkt_seit: datetime.datetime | None = None,
|
geparkt_seit: datetime.datetime | None = None,
|
||||||
|
faehrt_seit: datetime.datetime | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
|
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
|
||||||
frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss."""
|
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
|
# nach einer Antwort aus, war aber eine über die Fahrterkennung und
|
||||||
# nicht über das Parken (siehe koordinator._zuendung_angezeigt).
|
# nicht über das Parken (siehe koordinator._zuendung_angezeigt).
|
||||||
"geparkt_seit": geparkt_seit.isoformat() if geparkt_seit else None,
|
"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_lat": standort["lat"],
|
||||||
"standort_lon": standort["lon"],
|
"standort_lon": standort["lon"],
|
||||||
"standort_genauigkeit_m": standort["genauigkeit_m"],
|
"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";
|
||||||
@@ -16,6 +16,9 @@ export type { ActionButtonProps } from "./components/ActionButton";
|
|||||||
export { Switch } from "./components/Switch";
|
export { Switch } from "./components/Switch";
|
||||||
export type { SwitchProps } 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 { Seg } from "./components/Seg";
|
||||||
export type { SegProps, SegOption } from "./components/Seg";
|
export type { SegProps, SegOption } from "./components/Seg";
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@import "./components/StatusRow/StatusRow.css";
|
@import "./components/StatusRow/StatusRow.css";
|
||||||
@import "./components/ActionButton/ActionButton.css";
|
@import "./components/ActionButton/ActionButton.css";
|
||||||
@import "./components/Switch/Switch.css";
|
@import "./components/Switch/Switch.css";
|
||||||
|
@import "./components/Slider/Slider.css";
|
||||||
@import "./components/Seg/Seg.css";
|
@import "./components/Seg/Seg.css";
|
||||||
@import "./components/RowList/RowList.css";
|
@import "./components/RowList/RowList.css";
|
||||||
@import "./components/Accordion/LeafRow.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)
|
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:
|
class FakeKoordinator:
|
||||||
"""Nur das, was der Signalpfad anfasst."""
|
"""Nur das, was der Signalpfad anfasst."""
|
||||||
|
|
||||||
@@ -41,6 +50,11 @@ class FakeKoordinator:
|
|||||||
self.fahrt_sperre = asyncio.Lock()
|
self.fahrt_sperre = asyncio.Lock()
|
||||||
self.beendet = []
|
self.beendet = []
|
||||||
self.veroeffentlicht = 0
|
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):
|
def warte_ende_ab_abbrechen(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user