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:
@@ -3770,6 +3770,93 @@ verified, deployed fixes can vanish in one click.** (`git push` needed one retry
|
||||
already-documented Gitea Credential Manager flakiness - consistent with every other push this project has
|
||||
made.)
|
||||
|
||||
## Y. Full cross-codebase bug audit: six real defects found and fixed (2026.8.27.19)
|
||||
|
||||
Owner asked for a general audit of the app, not tied to a specific report. Ran three parallel reviews
|
||||
(backend Python, panel JS, companion-app) rather than one pass, each grounded in reading the actual current
|
||||
code rather than trusting this file's own history. Six real, concrete defects survived; three items the
|
||||
reviews raised as *possible* concerns were checked and confirmed correct as-is (documented below so they
|
||||
aren't re-flagged). No crash-class bugs (undefined-reference errors, the historical `bvRuhePunkte` class of
|
||||
bug) were found anywhere - confirmed via ESLint `no-undef` over the whole panel file plus manual review.
|
||||
|
||||
**1. (High) companion-app's `Batterie.tsx` showed generator voltage as "Ruhespannung" and skewed the SOH
|
||||
trend.** The panel's `bvPunkte` (the single, already-filtered `<= AGM_RUHE_MAX_V` array used for the
|
||||
headline, trend, and diagram alike - see section X's `.16`/`.17` history) was never mirrored here:
|
||||
companion-app's `messwerte` had no such filter, so the newest day's minimum reading - even a generator
|
||||
reading above 13.0 V - could show as the "Ruhespannung" headline, and the trend average wasn't excluding
|
||||
those days either. Fixed with the identical one-line filter the panel already applies:
|
||||
`verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)` in `Batterie.tsx` - this one array already
|
||||
drives the headline, the trend, and the diagram, so a single filter fixes all three. `BatterieListe.tsx`
|
||||
(the measurement list) is correctly left unfiltered, exactly like the panel's `vBatterieliste()` - it tags
|
||||
generator readings with a pill instead of hiding them, which is a different, already-correct concept.
|
||||
|
||||
**2. (High) Manually-entered trips/refuels could be silently duplicated by a later import.**
|
||||
`historienimport.py`'s own docstring promises every import-created record is checked for overlap against
|
||||
*all* existing ones, "egal ob live erkannt, von Hand angelegt oder aus einem früheren Import" - but the
|
||||
manual-entry paths (`fahrterkennung.manuell_anlegen()`/`aktualisieren()`, `belege.tankvorgang_manuell()`/
|
||||
`tankvorgang_aktualisieren()`) stored whatever timestamp the frontend sent verbatim via a raw
|
||||
`datetime.fromisoformat()`, while `historienimport.als_zeit()` normalizes the same kind of input (naive =
|
||||
local time, per its own comment) to a timezone-aware UTC datetime. `_ueberschneidet()`'s overlap check and
|
||||
the tank-vorgänge dedup window both explicitly skipped any record with a naive timestamp - so every
|
||||
manually-created trip or refuel was invisible to a later import's dedup, and could be re-created as a
|
||||
duplicate. Fixed by moving the normalization into a shared function, `zeit_normalisiert()` in `verlauf.py`
|
||||
(a leaf module with no local imports, chosen specifically to avoid a circular import between
|
||||
`fahrterkennung.py`/`belege.py` and `historienimport.py`) - `historienimport.als_zeit` is now an alias for
|
||||
it. Every write path (`manuell_anlegen`, `aktualisieren`, `tankvorgang_manuell`, `tankvorgang_aktualisieren`)
|
||||
now normalizes through it before storing. `_ueberschneidet()`/`_tankvorgaenge_importieren()`'s
|
||||
`bekannte_zeiten` collection were also changed to run existing stored timestamps through the same function
|
||||
instead of skipping naive ones - this also correctly protects any already-stored legacy record with a naive
|
||||
timestamp from before this fix, not just new writes.
|
||||
|
||||
**3. (Medium) Battery voltage history had no backup coverage.** `sicherung.py`'s `DATEIEN` tuple (used by
|
||||
"Backup jetzt erstellen") listed `fahrzeugprofil.json`/`fahrten.jsonl`/`tankvorgaenge.jsonl`/
|
||||
`entitaeten.json` but not `batteriespannung.jsonl` - even though `batterie.py`'s own docstring frames it as
|
||||
a deliberately multi-year dataset for detecting battery aging. Added it to `DATEIEN`; `_kopieren()` already
|
||||
skips any file that doesn't exist, so this is zero-risk for an install with no battery history yet.
|
||||
|
||||
**4. (Low/Medium) Tank-vorgang import was all-or-nothing per sensor once `TANK_LITER_SENSOR` was mapped.**
|
||||
`_tankvorgaenge_importieren()` used to read *only* the liter sensor's history once one was mapped, silently
|
||||
losing any refuel from before the liter sensor's own recorder history started (e.g. it was mapped
|
||||
mid-project, per section Q) even though the percent sensor's own history covered that period. Rewrote the
|
||||
function to run both passes when a liter sensor is mapped: the percent-based pass now covers the period
|
||||
strictly *before* the liter history's first point, and the liter-based pass (more accurate, since it's
|
||||
already in liters) covers everything from there on - both share the same `bekannte_zeiten`/`neue` state so
|
||||
the same physical refill can't be created twice if the two histories overlap. Unmapped-liter-sensor
|
||||
behavior (pure percent-based import) is unchanged.
|
||||
|
||||
**5. (Moderate) Panel's Statistik page mislabeled a tank-based estimate as vehicle-reported data.** The
|
||||
"Vom Fahrzeug gemeldet, je Zeitraum" tile and its caption claimed `t.verbrauch_l_100km` (per-trip fuel
|
||||
consumption) came from the vehicle's own OBD reporting - it doesn't; section V documented that this field
|
||||
is entirely a backend-computed approximation from `TANK_LITER_SENSOR` liter deltas, and the trip-detail
|
||||
page already correctly labels it "Näherung aus Tankfüllstand". Only the Statistik tile and a stale code
|
||||
comment (`fahrtVerbrauch`'s own header) still claimed OBD provenance. Fixed both to say what the value
|
||||
actually is - a text-only change, the underlying computation was already correct.
|
||||
|
||||
**6. (Low) companion-app's "Art der Fahrten" chart still colored the Arbeitsweg segment red.** The panel
|
||||
fixed this exact chart on 2026-08-13 (section C, the iOS-overlay red-semantics-inversion finding: red is
|
||||
reserved for destructive actions, this segment is a neutral data category) but companion-app's
|
||||
`Statistik.tsx` never received the port - its Arbeitsweg segment stayed `var(--red)` while its sibling
|
||||
Privat segment already used the neutral `var(--fg3)`. Changed to `var(--fg)`, matching the panel's own
|
||||
Arbeitsweg color exactly (not `--fg3`, which the panel reserves for the Privat segment).
|
||||
|
||||
**Checked and confirmed NOT a bug** (raised by one of the three reviews, verified against current code):
|
||||
`AGM_RUHE_MAX_V`, `NEBENWERT_MAX_ABSTAND_S=300`, `_letzte_gueltige_spannung`, the `ablage.py` temperature
|
||||
backfill branch, `historienimport.py`'s `aussentemp` fetch, and `batterie.py`'s freshness check - all
|
||||
already correct per sections T/W/X's history; the route/back-navigation tables (`ZURUECK` vs. the panel's
|
||||
dispatcher) - a full cross-check found every route has exactly one matching entry both ways; and the panel's
|
||||
`ereignisseVerdrahten()` listener wiring - guarded by the existing idempotency lock, no double-registration
|
||||
risk.
|
||||
|
||||
Verified: all five touched backend files (`verlauf.py`, `historienimport.py`, `fahrterkennung.py`,
|
||||
`belege.py`, `sicherung.py`) `py_compile` clean in `audi_ha_test`; panel `node --check` clean; companion-app
|
||||
`tsc --noEmit` clean and full suite green at 146/146. Manifest bumped to `2026.8.27.19` (panel text
|
||||
changed), `npm run ota` rerun (companion-app source changed, `Batterie.tsx`/`Statistik.tsx`), `audi_ha_test`
|
||||
restarted and confirmed clean via log (`Audi Dashboard 2026.8.27.19 eingerichtet`, no traceback; the only
|
||||
`ReferenceError: bvRuhePunkte` lines in the post-restart log predate the setup-complete line and come from a
|
||||
stale browser tab's cached pre-restart script, not a new regression). **Not yet committed/pushed** - per
|
||||
this file's own binding lesson from earlier the same day (section W), that should happen before any
|
||||
self-update/install run is suggested or allowed; ask the owner before running one.
|
||||
|
||||
---
|
||||
|
||||
## Working conventions (observed — keep them)
|
||||
|
||||
@@ -93,7 +93,12 @@ export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
|
||||
|
||||
if (!verlauf) return <div className="dm-laden">Verlauf wird geladen …</div>
|
||||
|
||||
const messwerte = verlauf.filter((t) => t.min != null)
|
||||
// Wie bvPunkte im Panel: Werte über AGM_RUHE_MAX_V sind Generatorspannung,
|
||||
// keine Ruhespannung - ausgeschlossen von Kopfzeile, Trend UND Diagramm
|
||||
// (eine einzige gefilterte Liste, kein separater "alle Messungen"-Bestand).
|
||||
// Ohne diesen Filter hätte ein Tag, an dem das Fahrzeug nie lange genug
|
||||
// stand, seine Generatorspannung fälschlich als "Ruhespannung" gezeigt.
|
||||
const messwerte = verlauf.filter((t) => t.min != null && t.min <= AGM_RUHE_MAX_V)
|
||||
|
||||
if (messwerte.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -133,7 +133,7 @@ export function Statistik() {
|
||||
/>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: a.arbeitKm, color: "var(--red)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
|
||||
{ value: a.arbeitKm, color: "var(--fg)", label: `Arbeitsweg · ${de(a.arbeitKm)} km` },
|
||||
{ value: a.privatKm, color: "var(--fg3)", label: `Privat · ${de(a.privatKm)} km` },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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