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
|
already-documented Gitea Credential Manager flakiness - consistent with every other push this project has
|
||||||
made.)
|
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)
|
## 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>
|
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) {
|
if (messwerte.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function Statistik() {
|
|||||||
/>
|
/>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
segments={[
|
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` },
|
{ 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 . import shell_beleg_parser
|
||||||
from .tankerkennung import leerer_tankvorgang
|
from .tankerkennung import leerer_tankvorgang
|
||||||
from .veroeffentlichung import zustand_oder_none
|
from .veroeffentlichung import zustand_oder_none
|
||||||
|
from .verlauf import zeit_normalisiert
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .koordinator import Koordinator
|
from .koordinator import Koordinator
|
||||||
@@ -199,9 +200,11 @@ async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None:
|
|||||||
# deshalb immer Vorrang vor der Berechnung.
|
# deshalb immer Vorrang vor der Berechnung.
|
||||||
distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
|
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")
|
receipt_key = daten.get("receipt_key")
|
||||||
tankvorgang = leerer_tankvorgang(
|
tankvorgang = leerer_tankvorgang(
|
||||||
daten.get("ts") or datetime.datetime.now(datetime.UTC).isoformat(),
|
ts.isoformat(),
|
||||||
"beleg" if receipt_key else "manual",
|
"beleg" if receipt_key else "manual",
|
||||||
)
|
)
|
||||||
tankvorgang.update({
|
tankvorgang.update({
|
||||||
@@ -241,8 +244,9 @@ async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
|
|||||||
|
|
||||||
liter = daten.get("liter")
|
liter = daten.get("liter")
|
||||||
kosten = daten.get("kosten")
|
kosten = daten.get("kosten")
|
||||||
|
ts_normalisiert = zeit_normalisiert(daten.get("ts"))
|
||||||
aenderungen = {
|
aenderungen = {
|
||||||
"ts": daten.get("ts"),
|
"ts": ts_normalisiert.isoformat() if ts_normalisiert else daten.get("ts"),
|
||||||
"liters": liter,
|
"liters": liter,
|
||||||
"fuel_total_eur": kosten,
|
"fuel_total_eur": kosten,
|
||||||
"price_per_l": preis_je_liter(kosten, liter),
|
"price_per_l": preis_je_liter(kosten, liter),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from typing import TYPE_CHECKING
|
|||||||
from . import screening
|
from . import screening
|
||||||
from .ablage import neue_id
|
from .ablage import neue_id
|
||||||
from .veroeffentlichung import zustand_oder_none
|
from .veroeffentlichung import zustand_oder_none
|
||||||
from .verlauf import verlauf_lesen
|
from .verlauf import verlauf_lesen, zeit_normalisiert
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .koordinator import Koordinator
|
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
|
automatischen Zündungs-Erkennung. Alles außer Start- und Endzeitpunkt ist
|
||||||
optional und bleibt leer, wenn es das Screening später selbst ergänzen
|
optional und bleibt leer, wenn es das Screening später selbst ergänzen
|
||||||
soll."""
|
soll."""
|
||||||
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
|
start_ts = zeit_normalisiert(daten["ts_start"])
|
||||||
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
|
ende_ts = zeit_normalisiert(daten["ts_end"])
|
||||||
hand = _handfelder(
|
hand = _handfelder(
|
||||||
daten.get("start_ort"), daten.get("ziel_ort"),
|
daten.get("start_ort"), daten.get("ziel_ort"),
|
||||||
daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"),
|
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"):
|
if daten.get("art"):
|
||||||
aenderungen["art"] = daten["art"]
|
aenderungen["art"] = daten["art"]
|
||||||
if daten.get("ts_start") and daten.get("ts_end"):
|
if daten.get("ts_start") and daten.get("ts_end"):
|
||||||
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
|
start_ts = zeit_normalisiert(daten["ts_start"])
|
||||||
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
|
ende_ts = zeit_normalisiert(daten["ts_end"])
|
||||||
aenderungen["ts_start"] = start_ts.isoformat()
|
aenderungen["ts_start"] = start_ts.isoformat()
|
||||||
aenderungen["ts_end"] = ende_ts.isoformat()
|
aenderungen["ts_end"] = ende_ts.isoformat()
|
||||||
aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
|
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)}`;
|
return `${esc(a)} → ${esc(b)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A1.1: Der Verbrauch einer einzelnen Fahrt kommt vom Fahrzeug (OBD) und wird
|
/* A1.1: Der Verbrauch einer einzelnen Fahrt ist eine Näherung aus der
|
||||||
vom Backend mitgeliefert - er wird hier nie selbst gerechnet. */
|
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);
|
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) {
|
function schnittFahrtVerbrauch(list) {
|
||||||
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
|
const mit = list.filter((t) => fahrtVerbrauch(t) != null && t.distance_km > 0);
|
||||||
const km = sum(mit, (t) => t.distance_km);
|
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>
|
<div class="row" style="border-bottom:0"><dt>Letzter Tankstop</dt><dd>${lv.bis ? dedat(lv.bis) : "–"}</dd></div>
|
||||||
</dl>
|
</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>` : ""}
|
${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"]])}
|
${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 stammen aus den Verbrauchsmeldungen der einzelnen Fahrten.</span>`)}
|
<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",
|
${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="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>
|
<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_bei,
|
||||||
wert_in_naehe,
|
wert_in_naehe,
|
||||||
zahl,
|
zahl,
|
||||||
|
zeit_normalisiert,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -78,17 +79,7 @@ MINDESTDAUER_S = 60
|
|||||||
# genau wie bei fehlendem Kilometerstand.
|
# genau wie bei fehlendem Kilometerstand.
|
||||||
|
|
||||||
|
|
||||||
def als_zeit(wert: object) -> datetime.datetime | None:
|
als_zeit = zeit_normalisiert # lokaler Name beibehalten, Funktion lebt jetzt in verlauf.py (siehe dort)
|
||||||
"""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)
|
|
||||||
|
|
||||||
|
|
||||||
def _fahrtfenster(
|
def _fahrtfenster(
|
||||||
@@ -132,14 +123,24 @@ def _ueberschneidet(
|
|||||||
start: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
|
start: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
|
"""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:
|
for f in bestehende:
|
||||||
try:
|
try:
|
||||||
f_start = datetime.datetime.fromisoformat(f.get("ts_start")) # type: ignore[arg-type]
|
f_start = zeit_normalisiert(f.get("ts_start"))
|
||||||
f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) # type: ignore[arg-type]
|
f_ende = zeit_normalisiert(f.get("ts_end"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
if f_start.tzinfo is None or f_ende.tzinfo is None:
|
if f_start is None or f_ende is None:
|
||||||
continue
|
continue
|
||||||
if start < f_ende and f_start < ende:
|
if start < f_ende and f_start < ende:
|
||||||
return True
|
return True
|
||||||
@@ -215,64 +216,88 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def _tankvorgaenge_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
|
Tiefststand-Logik wie in der Live-Erkennung (tankerkennung.py): jeder
|
||||||
Anstieg über die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein
|
Anstieg über die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein
|
||||||
Tankvorgang, nicht jeder Anstieg gegen den unmittelbar vorherigen Wert.
|
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
|
Ist ein Litersensor zugeordnet, übernimmt der für den Zeitraum, den seine
|
||||||
Vorbelegung für die getankte Menge."""
|
eigene Verlaufshistorie abdeckt - genauer, und der Anstieg dient gleich
|
||||||
mit_liter = bool(k.zuordnung.werte.TANK_LITER_SENSOR)
|
als grobe Vorbelegung für die getankte Menge. Für den Teil DAVOR (der
|
||||||
verlauf = verlaeufe["tank_liter"] if mit_liter else verlaeufe["tank"]
|
Litersensor wurde z. B. erst später zugeordnet, oder der recorder hält
|
||||||
if not verlauf:
|
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}
|
return {"angelegt": 0, "uebersprungen": 0}
|
||||||
|
|
||||||
schwelle = LITER_SCHWELLE if mit_liter else schwelle_prozent(await k.ablage.profil_lesen())
|
|
||||||
km_verlauf = verlaeufe["km"]
|
km_verlauf = verlaeufe["km"]
|
||||||
bestehende = await k.ablage.tankvorgaenge_lesen()
|
bestehende = await k.ablage.tankvorgaenge_lesen()
|
||||||
fenster_s = TANK_DUBLETTE_MIN * 60
|
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] = []
|
bekannte_zeiten: list[datetime.datetime] = []
|
||||||
for t in bestehende:
|
for t in bestehende:
|
||||||
try:
|
try:
|
||||||
ts = datetime.datetime.fromisoformat(t.get("ts")) # type: ignore[arg-type]
|
ts = zeit_normalisiert(t.get("ts"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
if ts.tzinfo is not None:
|
if ts is not None:
|
||||||
bekannte_zeiten.append(ts)
|
bekannte_zeiten.append(ts)
|
||||||
|
|
||||||
angelegt = uebersprungen = 0
|
angelegt = uebersprungen = 0
|
||||||
neue: list[dict] = []
|
neue: list[dict] = []
|
||||||
tiefststand: float | None = None
|
|
||||||
|
|
||||||
for ts, wert in verlauf:
|
def _verlauf_abarbeiten(verlauf: list, schwelle: float, mit_liter: bool) -> None:
|
||||||
aktuell = zahl(wert)
|
nonlocal angelegt, uebersprungen
|
||||||
if aktuell is None:
|
tiefststand: float | None = None
|
||||||
continue
|
for ts, wert in verlauf:
|
||||||
if tiefststand is None or aktuell <= tiefststand:
|
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
|
tiefststand = aktuell
|
||||||
continue
|
|
||||||
anstieg = aktuell - tiefststand
|
|
||||||
if anstieg < schwelle:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
|
if liter_verlauf:
|
||||||
uebersprungen += 1
|
grenze = liter_verlauf[0][0]
|
||||||
tiefststand = aktuell
|
aelterer_teil = [p for p in prozent_verlauf if p[0] < grenze]
|
||||||
continue
|
if aelterer_teil:
|
||||||
|
_verlauf_abarbeiten(aelterer_teil, schwelle_prozent(await k.ablage.profil_lesen()), False)
|
||||||
odometer_km = wert_bei(km_verlauf, ts)
|
_verlauf_abarbeiten(liter_verlauf, LITER_SCHWELLE, True)
|
||||||
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
|
else:
|
||||||
tankvorgang["odometer_km"] = odometer_km
|
_verlauf_abarbeiten(prozent_verlauf, schwelle_prozent(await k.ablage.profil_lesen()), False)
|
||||||
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
|
|
||||||
|
|
||||||
await k.ablage.tankvorgaenge_ergaenzen(neue)
|
await k.ablage.tankvorgaenge_ergaenzen(neue)
|
||||||
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
|
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "audi_dashboard",
|
"domain": "audi_dashboard",
|
||||||
"name": "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",
|
"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"],
|
||||||
|
|||||||
@@ -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
|
täglicher Termin genügt für alle drei Intervalle: wöchentlich läuft nur
|
||||||
montags, monatlich nur am Monatsersten, täglich jeden Tag.
|
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
|
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
|
hineinschauen und aus der man eine einzelne Datei zurückkopieren kann, ist im
|
||||||
Ernstfall mehr wert als ein paar gesparte Kilobyte.
|
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
|
entitaeten.json gehört ausdrücklich dazu: ohne sie wäre nach einer
|
||||||
Wiederherstellung die komplette Sensor-Zuordnung aus dem Setup-Menü verloren,
|
Wiederherstellung die komplette Sensor-Zuordnung aus dem Setup-Menü verloren,
|
||||||
und weil sie sonst nirgends abgelegt ist, gäbe es nichts zurückzuholen.
|
und weil sie sonst nirgends abgelegt ist, gäbe es nichts zurückzuholen.
|
||||||
@@ -40,6 +46,7 @@ DATEIEN = (
|
|||||||
"fahrten.jsonl",
|
"fahrten.jsonl",
|
||||||
"tankvorgaenge.jsonl",
|
"tankvorgaenge.jsonl",
|
||||||
"entitaeten.json",
|
"entitaeten.json",
|
||||||
|
"batteriespannung.jsonl",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,29 @@ async def verlauf_lesen(
|
|||||||
return []
|
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:
|
def zahl(wert: object) -> float | None:
|
||||||
try:
|
try:
|
||||||
return float(wert) # type: ignore[arg-type]
|
return float(wert) # type: ignore[arg-type]
|
||||||
|
|||||||
Reference in New Issue
Block a user