diff --git a/AGENTS.md b/AGENTS.md index 205cb6a..5df9aca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9207,3 +9207,75 @@ klemmen den gelesenen Wert jetzt auf 1–60. Versionssprung ohne `npm run ota` lässt das Bündel zurück — und seit Befund 3 sagt die App dann wenigstens die Wahrheit darüber. Die Reihenfolge ist: erhöhen, **dann** Bündel bauen, dann ausliefern. + +### Zweite Runde des Audits: der Knopf hat ein erfundenes Ende geschrieben (2026.9.3.14) + +**Der schwerste Befund des Tages, und er ist an echten Daten aufgefallen.** Der +Eigentümer hat „Fahrt beenden" gedrückt; die Fahrt bekam als Ende **18:43:31** +statt des letzten Lebenszeichens **17:59:33** — 74 Minuten statt 30. Die +Zeitstempel im Protokoll erklären es: + +| | | +|---|---| +| 18:43:29–31 | Home Assistant startet neu (Auslieferung von `.11`) | +| 18:43:31.826 | `_letztes_lebenszeichen()` nimmt `jetzt` | +| 18:46:18 | erst jetzt die Meldung — der Verlaufsabruf lief **2:47** und kam **leer** zurück | + +Der Knopf traf genau in den Neustart. Der recorder war noch nicht bereit, +`verlauf_lesen()` fing den Fehler ab (so gebaut, „der Verlauf ist Beiwerk, nie +der Kern") und lieferte eine leere Liste — **und die ist von „nichts +aufgezeichnet" nicht zu unterscheiden**. Der Rückfall auf `jetzt` machte daraus +ein erfundenes Fahrtende. Nachgemessen: der recorder hatte die Punkte sehr wohl, +fünf Zeilen im Fahrtfenster, letzte um 17:59:33, Abfrage in 0,0 s. + +`_letztes_lebenszeichen()` hat jetzt **drei Quellen in fester Reihenfolge**: + +1. die Aufzeichnung (genauester Wert), +2. **`hass.states`** — `last_reported`/`last_updated` der Entität. Weiss dasselbe + ohne Datenbank und ist sofort da; genau die Quelle, die im Fehlerfall gefehlt + hat. Eine Meldung VOR dem Fahrtbeginn zählt nicht, sie kann nicht das Ende + sein, +3. `jetzt` — aber nicht mehr stillschweigend, sondern mit Warnung. + +**Vier Regressionstests** (`tests/fahrterkennung/test_fahrtende_knopf.py`, jetzt +13); drei davon gegen den alten Rückfall nachweislich rot, alle vier gegen den +neuen grün. + +**Die betroffene Fahrt bleibt, wie sie ist:** `t-98425e4f140e`, 03.09. 17:29 bis +18:43, `ende_vorlaeufig: True`. Sie gehört dem Eigentümer; korrigiert wird sie +über „Werte bearbeiten" (richtiges Ende: 17:59:33) oder gelöscht. + +### Datenbestand: zwei Funde, einer davon eine latente Ausnahme + +Geprüft wurden 17 Fahrten, 16 Tankvorgänge, 16 Batterietage auf Nullstrecken, +negative Werte, doppelte IDs, Überlappungen, naive Zeitstempel, Sortierung und +Wertebereiche. + +* **Sauber:** keine 0-km-Fahrt, keine negative Strecke, keine doppelte ID, keine + Überlappung, beide Dateien aufsteigend, alle Batteriewerte innerhalb 10–15,5 V. +* **`odometer_km` als Zeichenkette** in drei Tankvorgängen (`"50250"`, + `source: beleg`, 2024/2025). Der Eingang ist längst zu — jede Schreibstelle + normalisiert über `verlauf.als_kilometerstand()`, und der Belegweg rührt + `odometer_km` grundsätzlich nicht an (§7.7 Regel 2). Im Bestand liegen sie + aber weiter, und `ablage.distanz_seit_letzter_tankung()` rechnete + `float - str` — ein TypeError, sobald so ein Datensatz der jüngste wäre. + Läuft jetzt ebenfalls durch `als_kilometerstand()` (funktionslokal importiert: + `verlauf.py` zieht das recorder-Modul mit, und `ablage.py` ist sonst reine + Datei-E/A). `verbrauchskorrektur._datum_passt()` war mit seinem `float(...)` + schon abgesichert. +* Vier naive Zeitstempel bei Tankvorgängen sind Altbestand und seit Abschnitt Y + behandelt: die Dublettenprüfung normalisiert auch gespeicherte Werte. + +### Und die Lücke, die seit Wochen offenstand, ist zu + +Acht Dienste hatten keinen Klarnamen in `translations/de.json` und erschienen in +den Entwicklerwerkzeugen als roher Schlüssel: `fahrt_jetzt_beenden`, +`zugang_setzen`, `dongle_lesen`, `csv_importieren`, `batterieverlauf_loeschen` +und die drei `reifen_archiv*`. Jetzt **28 Dienste, 28 Klarnamen**, in beide +Richtungen geprüft. + +**Ein Merkposten für mich selbst, aus einem Nebenschauplatz:** eine Warteschleife +mit `docker logs` **ohne** `--tail` lief in diesem Container zweieinhalb Stunden +im Kreis — das Protokoll hat 1,3 Millionen Zeilen, ein Durchlauf dauert länger +als die Pause zwischen zwei Runden. `docker logs --tail 60` beantwortet „ist der +Start durch" in Millisekunden. diff --git a/custom_components/audi_dashboard/ablage.py b/custom_components/audi_dashboard/ablage.py index d9511e1..90c5f6c 100644 --- a/custom_components/audi_dashboard/ablage.py +++ b/custom_components/audi_dashboard/ablage.py @@ -325,9 +325,22 @@ class Ablage: if not alle: return None letzter = max(alle, key=lambda t: t.get("ts") or "") - if letzter.get("odometer_km") is None: + # Durch als_kilometerstand(), nicht direkt: im Bestand liegen + # Tankvorgaenge mit odometer_km als ZEICHENKETTE ("50250") aus einer + # frueheren Fassung, und float - str waere hier ein TypeError. Der + # Eingang ist inzwischen zu (jede Schreibstelle normalisiert), der + # Altbestand aber bleibt - und dieselbe Funktion faengt gleich die 0 + # und negative Werte mit ab. + # + # Funktionslokal importiert: verlauf.py zieht das recorder-Modul mit, + # und diese Datei ist sonst reine Datei-E/A. Kein Zyklus - verlauf.py + # hat selbst keine lokalen Importe. + from .verlauf import als_kilometerstand + + vorher = als_kilometerstand(letzter.get("odometer_km")) + if vorher is None: return None - return round(aktueller_km - letzter["odometer_km"], 1) + return round(aktueller_km - vorher, 1) # ------------------------------------------------------ Batteriespannung diff --git a/custom_components/audi_dashboard/fahrterkennung.py b/custom_components/audi_dashboard/fahrterkennung.py index e0f0c1d..1f35bbc 100644 --- a/custom_components/audi_dashboard/fahrterkennung.py +++ b/custom_components/audi_dashboard/fahrterkennung.py @@ -702,16 +702,50 @@ async def _nachtraeglich_schliessen(k: Koordinator, sensor: str) -> None: async def _letztes_lebenszeichen( k: Koordinator, sensor: str, seit: datetime.datetime ) -> datetime.datetime: - """Der zuletzt aufgezeichnete Zeitpunkt der Zündungs-Entität seit `seit`. + """Der Zeitpunkt, zu dem die Fahrtsignal-Entität zuletzt etwas gemeldet hat. Das ist der ehrlichste verfügbare Endzeitpunkt: bis dahin wissen wir, dass das Fahrzeug beobachtet wurde. "Jetzt" zu nehmen wäre falsch, wenn Home - Assistant tagelang unten war - daraus würde eine Fahrt über Tage. Gibt es - gar keine Aufzeichnung (Sensor nicht zugeordnet, recorder zu kurz), bleibt - nur "jetzt"; das Screening trägt die Kilometerstände ohnehin nach.""" + Assistant tagelang unten war - daraus würde eine Fahrt über Tage. + + DREI QUELLEN, IN DIESER REIHENFOLGE - und die zweite gibt es, weil die + erste am 03.09.2026 eine falsche Fahrt erzeugt hat: + + 1. Die Aufzeichnung. Der genaueste Wert, wenn der recorder antwortet. + 2. Der Zustandsspeicher. `verlauf_lesen()` liefert eine leere Liste sowohl + für "nichts aufgezeichnet" als auch für "nicht lesbar" - und der zweite + Fall trat real ein, als der Knopf genau in einen Neustart hinein + gedrückt wurde: der recorder war noch nicht bereit, die Abfrage lief + 2:47 und kam leer zurück, und die Fahrt bekam "jetzt" als Ende - 74 + Minuten statt 30. `hass.states` weiss dasselbe ohne Datenbank und ist + sofort da. + 3. "Jetzt", aber nicht mehr stillschweigend: wenn beide Quellen nichts + hergeben, ist das eine Meldung wert. Das Screening trägt die + Kilometerstände ohnehin nach. + """ jetzt = datetime.datetime.now(datetime.UTC) punkte = await verlauf_lesen(k.hass, sensor, seit, jetzt) - return punkte[-1][0] if punkte else jetzt + if punkte: + return punkte[-1][0] + + zustand = k.hass.states.get(sensor) if sensor else None + if zustand is not None: + # last_reported zählt jede Meldung, last_updated nur einen Wechsel - + # für "wann haben wir zuletzt etwas gehört" ist das erste genauer. + gemeldet = getattr(zustand, "last_reported", None) or zustand.last_updated + if gemeldet is not None and gemeldet >= seit: + _LOGGER.info( + "Verlauf von %s nicht verfügbar - Ende aus dem Zustandsspeicher: %s", + sensor, gemeldet.isoformat(), + ) + return gemeldet + + _LOGGER.warning( + "Kein Lebenszeichen von %s seit %s - die Fahrt endet auf \"jetzt\", " + "was zu lang sein kann", + sensor, seit.isoformat(), + ) + return jetzt async def fahrt_beenden( diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 039ef04..6f885b0 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.3.13","sha256":"9b8881ea19de328d9b239f93edadc14927e33478565b66679ead690362b8ccf3","bytes":269175,"gebaut":"2026-09-03T19:02:24Z"} \ No newline at end of file +{"version":"2026.9.3.14","sha256":"7fd6807d24b4634087e73b05e262f87c1b8c1f807081e4d5c75157d4e038aed0","bytes":269179,"gebaut":"2026-09-03T19:14:41Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index ed7df88..c0a150e 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 35e9748..6b1540e 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.3.13", + "version": "2026.9.3.14", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"], diff --git a/custom_components/audi_dashboard/translations/de.json b/custom_components/audi_dashboard/translations/de.json index 4506780..48bf6af 100644 --- a/custom_components/audi_dashboard/translations/de.json +++ b/custom_components/audi_dashboard/translations/de.json @@ -38,147 +38,318 @@ "name": "Profil schreiben", "description": "Überschreibt das komplette Fahrzeugprofil.", "fields": { - "profil_json": { "name": "Profil", "description": "Das vollständige Fahrzeugprofil als JSON." } + "profil_json": { + "name": "Profil", + "description": "Das vollständige Fahrzeugprofil als JSON." + } } }, "entitaeten_schreiben": { "name": "Sensor-Zuordnung schreiben", "description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.", "fields": { - "zuordnung_json": { "name": "Zuordnung", "description": "Feldname zu Entity-ID als JSON." } + "zuordnung_json": { + "name": "Zuordnung", + "description": "Feldname zu Entity-ID als JSON." + } } }, "fahrt_manuell_anlegen": { "name": "Fahrt anlegen", "description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.", "fields": { - "ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." }, - "ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." }, - "art": { "name": "Art", "description": "privat oder arbeitsweg." }, - "start_ort": { "name": "Startort", "description": "Freitext." }, - "ziel_ort": { "name": "Zielort", "description": "Freitext." }, - "odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." }, - "odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." }, - "distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." } + "ts_start": { + "name": "Beginn", + "description": "Zeitpunkt des Fahrtbeginns (ISO)." + }, + "ts_end": { + "name": "Ende", + "description": "Zeitpunkt des Fahrtendes (ISO)." + }, + "art": { + "name": "Art", + "description": "privat oder arbeitsweg." + }, + "start_ort": { + "name": "Startort", + "description": "Freitext." + }, + "ziel_ort": { + "name": "Zielort", + "description": "Freitext." + }, + "odo_start": { + "name": "Kilometerstand bei Beginn", + "description": "Leer lassen, wenn das Screening ihn ergänzen soll." + }, + "odo_end": { + "name": "Kilometerstand bei Ende", + "description": "Leer lassen, wenn das Screening ihn ergänzen soll." + }, + "distanz": { + "name": "Strecke", + "description": "Nur nötig, wenn keine Kilometerstände vorliegen." + } } }, "fahrt_aktualisieren": { "name": "Fahrt bearbeiten", "description": "Ändert eine bestehende Fahrt.", "fields": { - "trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }, - "ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." }, - "ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." }, - "art": { "name": "Art", "description": "privat oder arbeitsweg." }, - "start_ort": { "name": "Startort", "description": "Freitext." }, - "ziel_ort": { "name": "Zielort", "description": "Freitext." }, - "odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." }, - "odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." }, - "distanz": { "name": "Strecke", "description": "Von Hand gesetzt." } + "trip_id": { + "name": "Fahrt", + "description": "trip_id der Fahrt." + }, + "ts_start": { + "name": "Beginn", + "description": "Zeitpunkt des Fahrtbeginns (ISO)." + }, + "ts_end": { + "name": "Ende", + "description": "Zeitpunkt des Fahrtendes (ISO)." + }, + "art": { + "name": "Art", + "description": "privat oder arbeitsweg." + }, + "start_ort": { + "name": "Startort", + "description": "Freitext." + }, + "ziel_ort": { + "name": "Zielort", + "description": "Freitext." + }, + "odo_start": { + "name": "Kilometerstand bei Beginn", + "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." + }, + "odo_end": { + "name": "Kilometerstand bei Ende", + "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." + }, + "distanz": { + "name": "Strecke", + "description": "Von Hand gesetzt." + } } }, "fahrt_loeschen": { "name": "Fahrt löschen", "description": "Löscht eine Fahrt unwiderruflich.", "fields": { - "trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." } + "trip_id": { + "name": "Fahrt", + "description": "trip_id der Fahrt." + } } }, "beleg_hochladen": { "name": "Beleg hochladen", "description": "Liest einen Tankbeleg (PDF) und trägt ihn in einen bestehenden Tankvorgang nach oder gibt die Werte an die Oberfläche zurück.", "fields": { - "pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." }, - "dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." }, - "tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." } + "pdf_base64": { + "name": "PDF", + "description": "Der Beleg als Base64." + }, + "dateiname": { + "name": "Dateiname", + "description": "Unter diesem Namen wird der Beleg abgelegt." + }, + "tank_id": { + "name": "Tankvorgang", + "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." + } } }, "tankvorgang_manuell": { "name": "Tankvorgang anlegen", "description": "Erfasst einen Tankvorgang von Hand.", "fields": { - "ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." }, - "liter": { "name": "Liter", "description": "Getankte Menge." }, - "kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." }, - "km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." }, - "ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." }, - "station": { "name": "Station", "description": "Name der Tankstelle." }, - "distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." }, - "kraftstoff": { "name": "Kraftstoff", "description": "Sorte." }, - "receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." }, - "receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." } + "ts": { + "name": "Zeitpunkt", + "description": "ISO-Zeitstempel; leer = jetzt." + }, + "liter": { + "name": "Liter", + "description": "Getankte Menge." + }, + "kosten": { + "name": "Betrag", + "description": "Der real bezahlte, bereits rabattierte Betrag." + }, + "km": { + "name": "Kilometerstand", + "description": "Leer = aus dem zugeordneten Sensor." + }, + "ersparnis": { + "name": "Ersparnis", + "description": "Rabatt in Euro." + }, + "station": { + "name": "Station", + "description": "Name der Tankstelle." + }, + "distanz": { + "name": "Gefahrene Strecke", + "description": "Leer = aus der Differenz zum letzten Tankvorgang." + }, + "kraftstoff": { + "name": "Kraftstoff", + "description": "Sorte." + }, + "receipt_key": { + "name": "Beleg-Schlüssel", + "description": "Aus einem zuvor gelesenen Beleg." + }, + "receipt_file": { + "name": "Belegdatei", + "description": "Pfad zur abgelegten PDF." + } } }, "tankvorgang_aktualisieren": { "name": "Tankvorgang bearbeiten", "description": "Ändert einen bestehenden Tankvorgang.", "fields": { - "tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }, - "ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." }, - "liter": { "name": "Liter", "description": "Getankte Menge." }, - "kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." }, - "km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." }, - "ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." }, - "station": { "name": "Station", "description": "Name der Tankstelle." }, - "distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." }, - "kraftstoff": { "name": "Kraftstoff", "description": "Sorte." }, - "receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." }, - "receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." } + "tank_id": { + "name": "Tankvorgang", + "description": "tank_id des Vorgangs." + }, + "ts": { + "name": "Zeitpunkt", + "description": "ISO-Zeitstempel." + }, + "liter": { + "name": "Liter", + "description": "Getankte Menge." + }, + "kosten": { + "name": "Betrag", + "description": "Der real bezahlte, bereits rabattierte Betrag." + }, + "km": { + "name": "Kilometerstand", + "description": "Kilometerstand beim Tanken." + }, + "ersparnis": { + "name": "Ersparnis", + "description": "Rabatt in Euro." + }, + "station": { + "name": "Station", + "description": "Name der Tankstelle." + }, + "distanz": { + "name": "Gefahrene Strecke", + "description": "Seit dem letzten Tankvorgang." + }, + "kraftstoff": { + "name": "Kraftstoff", + "description": "Sorte." + }, + "receipt_key": { + "name": "Beleg-Schlüssel", + "description": "Aus einem zuvor gelesenen Beleg." + }, + "receipt_file": { + "name": "Belegdatei", + "description": "Pfad zur abgelegten PDF." + } } }, "tankvorgang_loeschen": { "name": "Tankvorgang löschen", "description": "Löscht einen Tankvorgang unwiderruflich.", "fields": { - "tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." } + "tank_id": { + "name": "Tankvorgang", + "description": "tank_id des Vorgangs." + } } }, "bild_hochladen": { "name": "Fahrzeugbild hochladen", "description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.", "fields": { - "dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }, - "daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." } + "dateiname": { + "name": "Dateiname", + "description": "Einer der von der App erwarteten Namen." + }, + "daten_base64": { + "name": "Bild", + "description": "Die Bilddaten als Base64." + } } }, "bild_loeschen": { "name": "Fahrzeugbild löschen", "description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.", "fields": { - "dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." } + "dateiname": { + "name": "Dateiname", + "description": "Einer der von der App erwarteten Namen." + } } }, "reifen_wechseln": { "name": "Reifensatz wechseln", "description": "Schaltet den aktiven Reifensatz um.", "fields": { - "satz": { "name": "Satz", "description": "sommer oder winter." } + "satz": { + "name": "Satz", + "description": "sommer oder winter." + } } }, "reifen_km_setzen": { "name": "Reifen-Kilometerstand setzen", "description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.", "fields": { - "satz": { "name": "Satz", "description": "sommer oder winter." }, - "km": { "name": "Kilometer", "description": "Der neue Zählerstand." } + "satz": { + "name": "Satz", + "description": "sommer oder winter." + }, + "km": { + "name": "Kilometer", + "description": "Der neue Zählerstand." + } } }, "backup_wiederherstellen": { "name": "Sicherung wiederherstellen", "description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.", "fields": { - "profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." }, - "fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." }, - "tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." }, - "entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." } + "profil_json": { + "name": "Profil", + "description": "Fahrzeugprofil als JSON." + }, + "fahrten_json": { + "name": "Fahrten", + "description": "Fahrten als JSON-Liste." + }, + "tankvorgaenge_json": { + "name": "Tankvorgänge", + "description": "Tankvorgänge als JSON-Liste." + }, + "entitaeten_json": { + "name": "Sensor-Zuordnung", + "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." + } } }, "historie_importieren": { "name": "Daten aus dem Verlauf importieren", "description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.", "fields": { - "start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." }, - "ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." } + "start": { + "name": "Von", + "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." + }, + "ende": { + "name": "Bis", + "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." + } } }, "update_pruefen": { @@ -188,6 +359,38 @@ "update_installieren": { "name": "Update installieren", "description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden." + }, + "fahrt_jetzt_beenden": { + "name": "Fahrt jetzt beenden", + "description": "Schließt die laufende Fahrt auf das letzte Lebenszeichen des Fahrzeugs. Das Ende gilt als vorläufig und wird nachgezogen, wenn das Zündungs-Aus des Geräts später eintrifft." + }, + "zugang_setzen": { + "name": "Zugang setzen", + "description": "Trägt den Token eines Dienstes ein oder löscht ihn (leerer Text löscht). Der Wert wird nie wieder ausgegeben." + }, + "dongle_lesen": { + "name": "Dongle-Konfiguration lesen", + "description": "Liest die Einstellungen des FMM003 über flespi und schreibt dabei eine noch offene Änderung mit." + }, + "csv_importieren": { + "name": "CSV importieren", + "description": "Liest eine bearbeitete CSV zurück ein. Vorhandene Datensätze werden über ihre ID abgeglichen, nur die Spalten der Datei werden angefasst." + }, + "batterieverlauf_loeschen": { + "name": "Batterie-Messwert löschen", + "description": "Entfernt den Tageseintrag des angegebenen Datums aus dem Spannungsverlauf." + }, + "reifen_archivieren": { + "name": "Räder archivieren", + "description": "Legt den aktuellen Radsatz ins Archiv und setzt den laufenden Eintrag zurück - für neue Räder." + }, + "reifen_archiv_aktualisieren": { + "name": "Archivierte Räder bearbeiten", + "description": "Ändert einen Eintrag im Räder-Archiv." + }, + "reifen_archiv_loeschen": { + "name": "Archivierte Räder löschen", + "description": "Entfernt einen Eintrag aus dem Räder-Archiv." } } } diff --git a/tests/fahrterkennung/test_fahrtende_knopf.py b/tests/fahrterkennung/test_fahrtende_knopf.py index 8a3994c..c538d74 100644 --- a/tests/fahrterkennung/test_fahrtende_knopf.py +++ b/tests/fahrterkennung/test_fahrtende_knopf.py @@ -63,6 +63,27 @@ class FakeAblage: return False +class FakeZustand: + """Ein Eintrag in hass.states - nur das, was hier gelesen wird.""" + + def __init__(self, gemeldet): + self.last_reported = gemeldet + self.last_updated = gemeldet + + +class FakeStates: + def __init__(self, zustand): + self._zustand = zustand + + def get(self, entity_id): + return self._zustand + + +class FakeHass: + def __init__(self, zustand=None): + self.states = FakeStates(zustand) + + class FakeKoordinator: def __init__(self, start_ts=T0, fahrten=None): self.fahrt_start_ts = start_ts @@ -261,5 +282,56 @@ class Nachziehen(Basis): self.assertEqual(aenderungen["ts_end"], echt.isoformat()) +class Lebenszeichen(unittest.IsolatedAsyncioTestCase): + """Woher das Fahrtende kommt, wenn der Knopf gedrueckt wird. + + Am 03.09.2026 hat genau das eine falsche Fahrt erzeugt: der Knopf traf + einen Neustart, der recorder war noch nicht bereit, verlauf_lesen() kam + nach 2:47 leer zurueck - und "leer" ist von "nichts aufgezeichnet" nicht zu + unterscheiden. Das Ende wurde "jetzt": 74 Minuten statt 30. + """ + + def setUp(self): + self._echt = f.verlauf_lesen + + def tearDown(self): + f.verlauf_lesen = self._echt + + @staticmethod + def _k(punkte, zustand): + async def lesen(hass, sensor, start, ende): + return punkte + + f.verlauf_lesen = lesen + k = FakeKoordinator() + k.hass = FakeHass(zustand) + return k + + async def test_aufzeichnung_gewinnt(self): + punkt = T0 + datetime.timedelta(minutes=30) + k = self._k([(punkt, "on")], FakeZustand(T0 + datetime.timedelta(hours=2))) + self.assertEqual(await f._letztes_lebenszeichen(k, "binary_sensor.x", T0), punkt) + + async def test_ohne_aufzeichnung_zaehlt_der_zustandsspeicher(self): + """Der eigentliche Fix: nicht "jetzt", sondern wann die Entitaet + zuletzt gemeldet hat - das weiss hass.states ohne Datenbank.""" + gemeldet = T0 + datetime.timedelta(minutes=30) + k = self._k([], FakeZustand(gemeldet)) + self.assertEqual(await f._letztes_lebenszeichen(k, "binary_sensor.x", T0), gemeldet) + + async def test_meldung_vor_dem_fahrtbeginn_zaehlt_nicht(self): + """Sie kann nicht das Ende sein - die Fahrt begann ja spaeter.""" + k = self._k([], FakeZustand(T0 - datetime.timedelta(hours=1))) + with self.assertLogs(f._LOGGER, level="WARNING"): + ende = await f._letztes_lebenszeichen(k, "binary_sensor.x", T0) + self.assertGreater(ende, T0) + + async def test_ohne_jede_quelle_wird_gewarnt(self): + """"Jetzt" bleibt der letzte Ausweg - aber nicht mehr stillschweigend.""" + k = self._k([], None) + with self.assertLogs(f._LOGGER, level="WARNING"): + await f._letztes_lebenszeichen(k, "binary_sensor.x", T0) + + if __name__ == "__main__": unittest.main(verbosity=2)