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
+15 -2
View File
@@ -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
@@ -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(
@@ -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",
"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"],
@@ -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."
}
}
}