Vollaudit: sechs echte Fehler in Backend, Panel und companion-app behoben
- companion-app Batterie.tsx: Generatorspannung zeigte sich faelschlich als Ruhespannung/verzerrte den SOH-Trend, Panel-Filter (AGM_RUHE_MAX_V) fehlte - Manuell angelegte Fahrten/Tankvorgaenge trugen naive Zeitstempel und wurden von der Import-Dublettenpruefung stillschweigend uebersprungen; neue gemeinsame zeit_normalisiert() in verlauf.py schliesst die Luecke - Batteriespannungsverlauf fehlte im Backup (sicherung.py) - Tankvorgangs-Import verlor stillschweigend aeltere Tankvorgaenge vor Beginn der Litersensor-Historie; laeuft jetzt zweigleisig (Prozent + Liter) - Panel-Statistikseite behauptete faelschlich, der Verbrauch je Fahrt komme vom Fahrzeug (OBD) - ist eine Naeherung aus dem Tankfuellstand - companion-app Statistik.tsx: Arbeitsweg-Segment war noch rot statt neutral Version 2026.8.27.19, OTA-Buendel neu gebaut, im Testcontainer verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ from typing import TYPE_CHECKING
|
||||
from . import shell_beleg_parser
|
||||
from .tankerkennung import leerer_tankvorgang
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
from .verlauf import zeit_normalisiert
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
@@ -199,9 +200,11 @@ async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None:
|
||||
# deshalb immer Vorrang vor der Berechnung.
|
||||
distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
|
||||
|
||||
ts_roh = daten.get("ts")
|
||||
ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC)
|
||||
receipt_key = daten.get("receipt_key")
|
||||
tankvorgang = leerer_tankvorgang(
|
||||
daten.get("ts") or datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
ts.isoformat(),
|
||||
"beleg" if receipt_key else "manual",
|
||||
)
|
||||
tankvorgang.update({
|
||||
@@ -241,8 +244,9 @@ async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
|
||||
|
||||
liter = daten.get("liter")
|
||||
kosten = daten.get("kosten")
|
||||
ts_normalisiert = zeit_normalisiert(daten.get("ts"))
|
||||
aenderungen = {
|
||||
"ts": daten.get("ts"),
|
||||
"ts": ts_normalisiert.isoformat() if ts_normalisiert else daten.get("ts"),
|
||||
"liters": liter,
|
||||
"fuel_total_eur": kosten,
|
||||
"price_per_l": preis_je_liter(kosten, liter),
|
||||
|
||||
@@ -27,7 +27,7 @@ from typing import TYPE_CHECKING
|
||||
from . import screening
|
||||
from .ablage import neue_id
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
from .verlauf import verlauf_lesen
|
||||
from .verlauf import verlauf_lesen, zeit_normalisiert
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
@@ -228,8 +228,8 @@ async def manuell_anlegen(k: Koordinator, daten: dict) -> None:
|
||||
automatischen Zündungs-Erkennung. Alles außer Start- und Endzeitpunkt ist
|
||||
optional und bleibt leer, wenn es das Screening später selbst ergänzen
|
||||
soll."""
|
||||
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
|
||||
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
|
||||
start_ts = zeit_normalisiert(daten["ts_start"])
|
||||
ende_ts = zeit_normalisiert(daten["ts_end"])
|
||||
hand = _handfelder(
|
||||
daten.get("start_ort"), daten.get("ziel_ort"),
|
||||
daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"),
|
||||
@@ -264,8 +264,8 @@ async def aktualisieren(k: Koordinator, daten: dict) -> None:
|
||||
if daten.get("art"):
|
||||
aenderungen["art"] = daten["art"]
|
||||
if daten.get("ts_start") and daten.get("ts_end"):
|
||||
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
|
||||
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
|
||||
start_ts = zeit_normalisiert(daten["ts_start"])
|
||||
ende_ts = zeit_normalisiert(daten["ts_end"])
|
||||
aenderungen["ts_start"] = start_ts.isoformat()
|
||||
aenderungen["ts_end"] = ende_ts.isoformat()
|
||||
aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.8.27.17","sha256":"9ccedff2ae407751703713cc91b37bb51124f6fb5b98014ef2ae49659e51de2a","bytes":236026,"gebaut":"2026-08-27T21:03:47Z"}
|
||||
{"version":"2026.8.27.19","sha256":"9045270a7f0e7222ed00f583e0f131d0bdf20a79a69d6b6004ccb4f548206884","bytes":236046,"gebaut":"2026-08-27T21:58:57Z"}
|
||||
Binary file not shown.
@@ -3278,10 +3278,13 @@ function strecke(t) {
|
||||
return `${esc(a)} → ${esc(b)}`;
|
||||
}
|
||||
|
||||
/* A1.1: Der Verbrauch einer einzelnen Fahrt kommt vom Fahrzeug (OBD) und wird
|
||||
vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */
|
||||
/* A1.1: Der Verbrauch einer einzelnen Fahrt ist eine Näherung aus der
|
||||
Literstand-Differenz über die Fahrt (verbrauch_aus_literstaenden() im
|
||||
Backend) - kein vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert
|
||||
(Trip-Detail zeigt das schon korrekt als "Näherung aus Tankfüllstand",
|
||||
siehe oben). */
|
||||
const fahrtVerbrauch = (t) => (t && t.verbrauch_l_100km != null ? Number(t.verbrauch_l_100km) : null);
|
||||
/* Mittelwert der gemeldeten Fahrtverbräuche, nach Distanz gewichtet. */
|
||||
/* Mittelwert der genäherten Fahrtverbräuche, nach Distanz gewichtet. */
|
||||
function schnittFahrtVerbrauch(list) {
|
||||
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
|
||||
const km = sum(mit, (t) => t.distance_km);
|
||||
@@ -3339,8 +3342,8 @@ function vStat() {
|
||||
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : "–"}</dd></div>
|
||||
</dl>
|
||||
${lv.unplausibel ? `<span class="label" style="margin-top:16px;color:var(--warn)">Aus Litern und Strecke ergibt sich derzeit kein plausibler Wert. Sobald Tankvorgänge und Kilometerstände vollständig vorliegen, erscheint er hier.</span>` : ""}
|
||||
${quad("Vom Fahrzeug gemeldet, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
|
||||
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.</span>`)}
|
||||
${quad("Näherung je Fahrt, je Zeitraum", [[schnittFahrtVerbrauch(fJahr), "Jahr"], [schnittFahrtVerbrauch(fMonat), "Monat"], [schnittFahrtVerbrauch(fWoche), "Woche"], [schnittFahrtVerbrauch(fTag), "Tag"]])}
|
||||
<span class="label" style="margin-top:16px">Der Langzeitwert rechnet alle je getankten Liter gegen die Strecke bis zum letzten Tankstop. Die Werte je Zeitraum sind eine Näherung aus der Tankfüllstand-Differenz der einzelnen Fahrten, keine Meldung des Fahrzeugs.</span>`)}
|
||||
${statTile("s4", "Tag und Nacht", kmJahr > 0 ? de(Math.round((tagKm / kmJahr) * 100)) : "–", "% bei Tag", de(tagKm) + " km",
|
||||
`<div class="bar"><i style="flex:${tagKm};background:var(--fg)"></i><i style="flex:${nachtKm};background:var(--fg3)"></i></div>
|
||||
<div class="legend"><span><i style="background:var(--fg)"></i>Tag · ${de(tagKm)} km</span>
|
||||
|
||||
@@ -54,6 +54,7 @@ from .verlauf import (
|
||||
wert_bei,
|
||||
wert_in_naehe,
|
||||
zahl,
|
||||
zeit_normalisiert,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -78,17 +79,7 @@ MINDESTDAUER_S = 60
|
||||
# genau wie bei fehlendem Kilometerstand.
|
||||
|
||||
|
||||
def als_zeit(wert: object) -> datetime.datetime | None:
|
||||
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
|
||||
|
||||
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
|
||||
im Formular schließlich Ortszeit, keine UTC."""
|
||||
if not wert:
|
||||
return None
|
||||
ts = datetime.datetime.fromisoformat(str(wert))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.astimezone()
|
||||
return ts.astimezone(datetime.UTC)
|
||||
als_zeit = zeit_normalisiert # lokaler Name beibehalten, Funktion lebt jetzt in verlauf.py (siehe dort)
|
||||
|
||||
|
||||
def _fahrtfenster(
|
||||
@@ -132,14 +123,24 @@ def _ueberschneidet(
|
||||
start: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
|
||||
) -> bool:
|
||||
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
|
||||
überschneidet. Verhindert Dubletten beim wiederholten Import."""
|
||||
überschneidet. Verhindert Dubletten beim wiederholten Import.
|
||||
|
||||
Läuft über zeit_normalisiert() statt über ein rohes fromisoformat(): eine
|
||||
von Hand angelegte oder bearbeitete Fahrt (fahrterkennung.py) wird seit
|
||||
dieser Funktion ebenfalls über zeit_normalisiert() gespeichert und trägt
|
||||
deshalb einen zeitzonenbewussten Zeitstempel - vorher wurde ein naiver
|
||||
Zeitstempel hier stillschweigend übersprungen (kein Vergleich, keine
|
||||
Erkennung), sodass ein von Hand angelegter Datensatz nie gegen einen
|
||||
späteren Import geschützt war. Ältere, bereits gespeicherte naive
|
||||
Zeitstempel werden hier trotzdem korrekt behandelt: zeit_normalisiert()
|
||||
interpretiert sie als lokale Zeit, genau wie beim Schreiben."""
|
||||
for f in bestehende:
|
||||
try:
|
||||
f_start = datetime.datetime.fromisoformat(f.get("ts_start")) # type: ignore[arg-type]
|
||||
f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) # type: ignore[arg-type]
|
||||
f_start = zeit_normalisiert(f.get("ts_start"))
|
||||
f_ende = zeit_normalisiert(f.get("ts_end"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if f_start.tzinfo is None or f_ende.tzinfo is None:
|
||||
if f_start is None or f_ende is None:
|
||||
continue
|
||||
if start < f_ende and f_start < ende:
|
||||
return True
|
||||
@@ -215,64 +216,88 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
|
||||
|
||||
async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
"""Tankvorgänge aus dem Füllstands- oder Tankvolumenverlauf - dieselbe
|
||||
"""Tankvorgänge aus dem Füllstands- und/oder Tankvolumenverlauf - dieselbe
|
||||
Tiefststand-Logik wie in der Live-Erkennung (tankerkennung.py): jeder
|
||||
Anstieg über die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein
|
||||
Tankvorgang, nicht jeder Anstieg gegen den unmittelbar vorherigen Wert.
|
||||
Dieselbe Präferenz wie live auch: ist ein Litersensor zugeordnet,
|
||||
übernimmt der - genauer, und der Anstieg dient gleich als grobe
|
||||
Vorbelegung für die getankte Menge."""
|
||||
mit_liter = bool(k.zuordnung.werte.TANK_LITER_SENSOR)
|
||||
verlauf = verlaeufe["tank_liter"] if mit_liter else verlaeufe["tank"]
|
||||
if not verlauf:
|
||||
|
||||
Ist ein Litersensor zugeordnet, übernimmt der für den Zeitraum, den seine
|
||||
eigene Verlaufshistorie abdeckt - genauer, und der Anstieg dient gleich
|
||||
als grobe Vorbelegung für die getankte Menge. Für den Teil DAVOR (der
|
||||
Litersensor wurde z. B. erst später zugeordnet, oder der recorder hält
|
||||
seine Historie nicht so weit zurück wie die des Prozentsensors) läuft
|
||||
zusätzlich der Prozentsensor - sonst würde ein Import über einen
|
||||
Zeitraum, der vor die Liter-Historie zurückreicht, die dort tatsächlich
|
||||
vorhandenen älteren Tankvorgänge stillschweigend verlieren, obwohl der
|
||||
Prozentsensor sie längst hätte erkennen können. Beide Läufe teilen sich
|
||||
`bekannte_zeiten`/`neue`, damit derselbe physische Tankvorgang nicht
|
||||
doppelt entsteht, falls sich die beiden Verläufe zeitlich überschneiden."""
|
||||
prozent_verlauf = verlaeufe["tank"]
|
||||
liter_verlauf = verlaeufe["tank_liter"] if k.zuordnung.werte.TANK_LITER_SENSOR else []
|
||||
if not prozent_verlauf and not liter_verlauf:
|
||||
return {"angelegt": 0, "uebersprungen": 0}
|
||||
|
||||
schwelle = LITER_SCHWELLE if mit_liter else schwelle_prozent(await k.ablage.profil_lesen())
|
||||
km_verlauf = verlaeufe["km"]
|
||||
bestehende = await k.ablage.tankvorgaenge_lesen()
|
||||
fenster_s = TANK_DUBLETTE_MIN * 60
|
||||
|
||||
# zeit_normalisiert() statt rohem fromisoformat(): siehe die identische
|
||||
# Begründung in _ueberschneidet() oben - ein von Hand angelegter oder
|
||||
# bearbeiteter Tankvorgang (belege.py) trägt seither ebenfalls einen
|
||||
# zeitzonenbewussten Zeitstempel und wurde vorher hier stillschweigend
|
||||
# aus der Dublettenprüfung ausgeschlossen.
|
||||
bekannte_zeiten: list[datetime.datetime] = []
|
||||
for t in bestehende:
|
||||
try:
|
||||
ts = datetime.datetime.fromisoformat(t.get("ts")) # type: ignore[arg-type]
|
||||
ts = zeit_normalisiert(t.get("ts"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ts.tzinfo is not None:
|
||||
if ts is not None:
|
||||
bekannte_zeiten.append(ts)
|
||||
|
||||
angelegt = uebersprungen = 0
|
||||
neue: list[dict] = []
|
||||
tiefststand: float | None = None
|
||||
|
||||
for ts, wert in verlauf:
|
||||
aktuell = zahl(wert)
|
||||
if aktuell is None:
|
||||
continue
|
||||
if tiefststand is None or aktuell <= tiefststand:
|
||||
def _verlauf_abarbeiten(verlauf: list, schwelle: float, mit_liter: bool) -> None:
|
||||
nonlocal angelegt, uebersprungen
|
||||
tiefststand: float | None = None
|
||||
for ts, wert in verlauf:
|
||||
aktuell = zahl(wert)
|
||||
if aktuell is None:
|
||||
continue
|
||||
if tiefststand is None or aktuell <= tiefststand:
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
anstieg = aktuell - tiefststand
|
||||
if anstieg < schwelle:
|
||||
continue
|
||||
|
||||
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
|
||||
uebersprungen += 1
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
|
||||
odometer_km = wert_bei(km_verlauf, ts)
|
||||
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
|
||||
tankvorgang["odometer_km"] = odometer_km
|
||||
if mit_liter:
|
||||
tankvorgang["liters"] = round(anstieg, 1)
|
||||
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
|
||||
ts.isoformat(), odometer_km, neue + bestehende
|
||||
)
|
||||
neue.append(tankvorgang)
|
||||
bekannte_zeiten.append(ts)
|
||||
angelegt += 1
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
anstieg = aktuell - tiefststand
|
||||
if anstieg < schwelle:
|
||||
continue
|
||||
|
||||
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
|
||||
uebersprungen += 1
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
|
||||
odometer_km = wert_bei(km_verlauf, ts)
|
||||
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
|
||||
tankvorgang["odometer_km"] = odometer_km
|
||||
if mit_liter:
|
||||
tankvorgang["liters"] = round(anstieg, 1)
|
||||
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
|
||||
ts.isoformat(), odometer_km, neue + bestehende
|
||||
)
|
||||
neue.append(tankvorgang)
|
||||
bekannte_zeiten.append(ts)
|
||||
angelegt += 1
|
||||
tiefststand = aktuell
|
||||
if liter_verlauf:
|
||||
grenze = liter_verlauf[0][0]
|
||||
aelterer_teil = [p for p in prozent_verlauf if p[0] < grenze]
|
||||
if aelterer_teil:
|
||||
_verlauf_abarbeiten(aelterer_teil, schwelle_prozent(await k.ablage.profil_lesen()), False)
|
||||
_verlauf_abarbeiten(liter_verlauf, LITER_SCHWELLE, True)
|
||||
else:
|
||||
_verlauf_abarbeiten(prozent_verlauf, schwelle_prozent(await k.ablage.profil_lesen()), False)
|
||||
|
||||
await k.ablage.tankvorgaenge_ergaenzen(neue)
|
||||
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.8.27.18",
|
||||
"version": "2026.8.27.19",
|
||||
"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"],
|
||||
|
||||
@@ -6,11 +6,17 @@ wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner
|
||||
täglicher Termin genügt für alle drei Intervalle: wöchentlich läuft nur
|
||||
montags, monatlich nur am Monatsersten, täglich jeden Tag.
|
||||
|
||||
Jede Sicherung ist ein eigener Zeitstempel-Unterordner mit Kopien der vier
|
||||
Jede Sicherung ist ein eigener Zeitstempel-Unterordner mit Kopien der fünf
|
||||
Bestände, keine ZIP-Datei - eine Sicherung, in die man ohne Werkzeug
|
||||
hineinschauen und aus der man eine einzelne Datei zurückkopieren kann, ist im
|
||||
Ernstfall mehr wert als ein paar gesparte Kilobyte.
|
||||
|
||||
batteriespannung.jsonl gehört ausdrücklich dazu: batterie.py führt sie
|
||||
bewusst als mehrjährigen Verlauf, um eine über die Zeit absinkende
|
||||
Ruhespannung erkennen zu können - ohne Sicherung wäre dieser Verlauf bei
|
||||
einem Festplattenausfall unwiederbringlich weg, anders als Profil/Fahrten/
|
||||
Tankvorgänge, die dieselbe Sicherung bereits abdeckte.
|
||||
|
||||
entitaeten.json gehört ausdrücklich dazu: ohne sie wäre nach einer
|
||||
Wiederherstellung die komplette Sensor-Zuordnung aus dem Setup-Menü verloren,
|
||||
und weil sie sonst nirgends abgelegt ist, gäbe es nichts zurückzuholen.
|
||||
@@ -40,6 +46,7 @@ DATEIEN = (
|
||||
"fahrten.jsonl",
|
||||
"tankvorgaenge.jsonl",
|
||||
"entitaeten.json",
|
||||
"batteriespannung.jsonl",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,29 @@ async def verlauf_lesen(
|
||||
return []
|
||||
|
||||
|
||||
def zeit_normalisiert(wert: object) -> datetime.datetime | None:
|
||||
"""Akzeptiert ISO-Zeit mit oder ohne Zeitzone und normalisiert auf UTC.
|
||||
|
||||
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - die Oberfläche
|
||||
schickt sowohl bei manueller Eingabe (Fahrt/Tankvorgang von Hand anlegen
|
||||
oder bearbeiten, fahrterkennung.py/belege.py) als auch beim rückwirkenden
|
||||
Import (historienimport.py) Ortszeit, nie UTC. Ohne diese Normalisierung
|
||||
bliebe ein von Hand erfasster Zeitstempel naiv (kein tzinfo) - und
|
||||
historienimport.py's eigene Dublettenprüfung (_ueberschneidet() für
|
||||
Fahrten, bekannte_zeiten für Tankvorgänge) überspringt jeden Datensatz mit
|
||||
naivem Zeitstempel stillschweigend, statt ihn auf Überschneidung zu
|
||||
prüfen. Ein von Hand angelegter oder bearbeiteter Datensatz wäre dadurch
|
||||
nie vor einem doppelten Import geschützt - genau die Lücke, die diese
|
||||
gemeinsame Funktion schließt, indem jeder Schreibpfad dieselbe
|
||||
Normalisierung durchläuft."""
|
||||
if not wert:
|
||||
return None
|
||||
ts = datetime.datetime.fromisoformat(str(wert))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.astimezone()
|
||||
return ts.astimezone(datetime.UTC)
|
||||
|
||||
|
||||
def zahl(wert: object) -> float | None:
|
||||
try:
|
||||
return float(wert) # type: ignore[arg-type]
|
||||
|
||||
Reference in New Issue
Block a user