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:
@@ -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
|
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: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.
|
||||||
|
|||||||
@@ -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"}
|
||||||
Binary file not shown.
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user