Audit, zweite Runde: der Knopf schrieb ein erfundenes Ende (2026.9.3.14)

_letztes_lebenszeichen() fiel auf "jetzt" zurueck, sobald verlauf_lesen() eine
leere Liste lieferte - und die bedeutet sowohl "nichts aufgezeichnet" als auch
"nicht lesbar". Am 03.09. trat der zweite Fall ein: der Knopf traf einen
Neustart, der recorder war noch nicht bereit, die Abfrage lief 2:47 und kam leer
zurueck. Die Fahrt bekam 18:43 statt 17:59 als Ende, 74 Minuten statt 30.

Drei Quellen in fester Reihenfolge statt einer: Aufzeichnung, dann hass.states
(last_reported/last_updated - weiss dasselbe ohne Datenbank), dann "jetzt", und
das nur noch mit Warnung. Vier Regressionstests, drei davon gegen den alten
Stand nachweislich rot.

Dazu aus der Bestandspruefung: odometer_km liegt in drei alten Tankvorgaengen
als Zeichenkette vor, ablage.distanz_seit_letzter_tankung() rechnete damit
float - str. Laeuft jetzt durch als_kilometerstand(). Der Eingang war schon
zu; das ist Altbestand.

Und acht Dienste hatten keinen Klarnamen in translations/de.json - jetzt 28
Dienste, 28 Klarnamen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 21:15:42 +02:00
parent 2736c3d6e0
commit 6ec31b5275
8 changed files with 460 additions and 66 deletions
+72
View File
@@ -9207,3 +9207,75 @@ klemmen den gelesenen Wert jetzt auf 160.
Versionssprung ohne `npm run ota` lässt das Bündel zurück — und seit Befund 3 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, sagt die App dann wenigstens die Wahrheit darüber. Die Reihenfolge ist: erhöhen,
**dann** Bündel bauen, dann ausliefern. **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:2931 | 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 1015,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.
+15 -2
View File
@@ -325,9 +325,22 @@ class Ablage:
if not alle: if not alle:
return None return None
letzter = max(alle, key=lambda t: t.get("ts") or "") 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 None
return round(aktueller_km - letzter["odometer_km"], 1) return round(aktueller_km - vorher, 1)
# ------------------------------------------------------ Batteriespannung # ------------------------------------------------------ Batteriespannung
@@ -702,16 +702,50 @@ async def _nachtraeglich_schliessen(k: Koordinator, sensor: str) -> None:
async def _letztes_lebenszeichen( async def _letztes_lebenszeichen(
k: Koordinator, sensor: str, seit: datetime.datetime k: Koordinator, sensor: str, seit: datetime.datetime
) -> 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 ist der ehrlichste verfügbare Endzeitpunkt: bis dahin wissen wir, dass
das Fahrzeug beobachtet wurde. "Jetzt" zu nehmen wäre falsch, wenn Home 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 Assistant tagelang unten war - daraus würde eine Fahrt über Tage.
gar keine Aufzeichnung (Sensor nicht zugeordnet, recorder zu kurz), bleibt
nur "jetzt"; das Screening trägt die Kilometerstände ohnehin nach.""" 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) jetzt = datetime.datetime.now(datetime.UTC)
punkte = await verlauf_lesen(k.hass, sensor, seit, jetzt) 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( async def fahrt_beenden(
@@ -1 +1 @@
{"version":"2026.9.3.13","sha256":"9b8881ea19de328d9b239f93edadc14927e33478565b66679ead690362b8ccf3","bytes":269175,"gebaut":"2026-09-03T19:02:24Z"} {"version":"2026.9.3.14","sha256":"7fd6807d24b4634087e73b05e262f87c1b8c1f807081e4d5c75157d4e038aed0","bytes":269179,"gebaut":"2026-09-03T19:14:41Z"}
@@ -1,7 +1,7 @@
{ {
"domain": "audi_dashboard", "domain": "audi_dashboard",
"name": "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", "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"],
@@ -38,147 +38,318 @@
"name": "Profil schreiben", "name": "Profil schreiben",
"description": "Überschreibt das komplette Fahrzeugprofil.", "description": "Überschreibt das komplette Fahrzeugprofil.",
"fields": { "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": { "entitaeten_schreiben": {
"name": "Sensor-Zuordnung schreiben", "name": "Sensor-Zuordnung schreiben",
"description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.", "description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.",
"fields": { "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": { "fahrt_manuell_anlegen": {
"name": "Fahrt anlegen", "name": "Fahrt anlegen",
"description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.", "description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.",
"fields": { "fields": {
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." }, "ts_start": {
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." }, "name": "Beginn",
"art": { "name": "Art", "description": "privat oder arbeitsweg." }, "description": "Zeitpunkt des Fahrtbeginns (ISO)."
"start_ort": { "name": "Startort", "description": "Freitext." }, },
"ziel_ort": { "name": "Zielort", "description": "Freitext." }, "ts_end": {
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." }, "name": "Ende",
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." }, "description": "Zeitpunkt des Fahrtendes (ISO)."
"distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." } },
"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": { "fahrt_aktualisieren": {
"name": "Fahrt bearbeiten", "name": "Fahrt bearbeiten",
"description": "Ändert eine bestehende Fahrt.", "description": "Ändert eine bestehende Fahrt.",
"fields": { "fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }, "trip_id": {
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." }, "name": "Fahrt",
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." }, "description": "trip_id der Fahrt."
"art": { "name": "Art", "description": "privat oder arbeitsweg." }, },
"start_ort": { "name": "Startort", "description": "Freitext." }, "ts_start": {
"ziel_ort": { "name": "Zielort", "description": "Freitext." }, "name": "Beginn",
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." }, "description": "Zeitpunkt des Fahrtbeginns (ISO)."
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." }, },
"distanz": { "name": "Strecke", "description": "Von Hand gesetzt." } "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": { "fahrt_loeschen": {
"name": "Fahrt löschen", "name": "Fahrt löschen",
"description": "Löscht eine Fahrt unwiderruflich.", "description": "Löscht eine Fahrt unwiderruflich.",
"fields": { "fields": {
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." } "trip_id": {
"name": "Fahrt",
"description": "trip_id der Fahrt."
}
} }
}, },
"beleg_hochladen": { "beleg_hochladen": {
"name": "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.", "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": { "fields": {
"pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." }, "pdf_base64": {
"dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." }, "name": "PDF",
"tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." } "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": { "tankvorgang_manuell": {
"name": "Tankvorgang anlegen", "name": "Tankvorgang anlegen",
"description": "Erfasst einen Tankvorgang von Hand.", "description": "Erfasst einen Tankvorgang von Hand.",
"fields": { "fields": {
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." }, "ts": {
"liter": { "name": "Liter", "description": "Getankte Menge." }, "name": "Zeitpunkt",
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." }, "description": "ISO-Zeitstempel; leer = jetzt."
"km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." }, },
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." }, "liter": {
"station": { "name": "Station", "description": "Name der Tankstelle." }, "name": "Liter",
"distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." }, "description": "Getankte Menge."
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." }, },
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." }, "kosten": {
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." } "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": { "tankvorgang_aktualisieren": {
"name": "Tankvorgang bearbeiten", "name": "Tankvorgang bearbeiten",
"description": "Ändert einen bestehenden Tankvorgang.", "description": "Ändert einen bestehenden Tankvorgang.",
"fields": { "fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }, "tank_id": {
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." }, "name": "Tankvorgang",
"liter": { "name": "Liter", "description": "Getankte Menge." }, "description": "tank_id des Vorgangs."
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." }, },
"km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." }, "ts": {
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." }, "name": "Zeitpunkt",
"station": { "name": "Station", "description": "Name der Tankstelle." }, "description": "ISO-Zeitstempel."
"distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." }, },
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." }, "liter": {
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." }, "name": "Liter",
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." } "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": { "tankvorgang_loeschen": {
"name": "Tankvorgang löschen", "name": "Tankvorgang löschen",
"description": "Löscht einen Tankvorgang unwiderruflich.", "description": "Löscht einen Tankvorgang unwiderruflich.",
"fields": { "fields": {
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." } "tank_id": {
"name": "Tankvorgang",
"description": "tank_id des Vorgangs."
}
} }
}, },
"bild_hochladen": { "bild_hochladen": {
"name": "Fahrzeugbild hochladen", "name": "Fahrzeugbild hochladen",
"description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.", "description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.",
"fields": { "fields": {
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }, "dateiname": {
"daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." } "name": "Dateiname",
"description": "Einer der von der App erwarteten Namen."
},
"daten_base64": {
"name": "Bild",
"description": "Die Bilddaten als Base64."
}
} }
}, },
"bild_loeschen": { "bild_loeschen": {
"name": "Fahrzeugbild löschen", "name": "Fahrzeugbild löschen",
"description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.", "description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.",
"fields": { "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": { "reifen_wechseln": {
"name": "Reifensatz wechseln", "name": "Reifensatz wechseln",
"description": "Schaltet den aktiven Reifensatz um.", "description": "Schaltet den aktiven Reifensatz um.",
"fields": { "fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." } "satz": {
"name": "Satz",
"description": "sommer oder winter."
}
} }
}, },
"reifen_km_setzen": { "reifen_km_setzen": {
"name": "Reifen-Kilometerstand setzen", "name": "Reifen-Kilometerstand setzen",
"description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.", "description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.",
"fields": { "fields": {
"satz": { "name": "Satz", "description": "sommer oder winter." }, "satz": {
"km": { "name": "Kilometer", "description": "Der neue Zählerstand." } "name": "Satz",
"description": "sommer oder winter."
},
"km": {
"name": "Kilometer",
"description": "Der neue Zählerstand."
}
} }
}, },
"backup_wiederherstellen": { "backup_wiederherstellen": {
"name": "Sicherung wiederherstellen", "name": "Sicherung wiederherstellen",
"description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.", "description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.",
"fields": { "fields": {
"profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." }, "profil_json": {
"fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." }, "name": "Profil",
"tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." }, "description": "Fahrzeugprofil als JSON."
"entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." } },
"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": { "historie_importieren": {
"name": "Daten aus dem Verlauf importieren", "name": "Daten aus dem Verlauf importieren",
"description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.", "description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.",
"fields": { "fields": {
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." }, "start": {
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." } "name": "Von",
"description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)."
},
"ende": {
"name": "Bis",
"description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)."
}
} }
}, },
"update_pruefen": { "update_pruefen": {
@@ -188,6 +359,38 @@
"update_installieren": { "update_installieren": {
"name": "Update installieren", "name": "Update installieren",
"description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden." "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."
} }
} }
} }
@@ -63,6 +63,27 @@ class FakeAblage:
return False 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: class FakeKoordinator:
def __init__(self, start_ts=T0, fahrten=None): def __init__(self, start_ts=T0, fahrten=None):
self.fahrt_start_ts = start_ts self.fahrt_start_ts = start_ts
@@ -261,5 +282,56 @@ class Nachziehen(Basis):
self.assertEqual(aenderungen["ts_end"], echt.isoformat()) 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__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)