Files
audi-app/custom_components/audi_dashboard/reifen.py
T
tobias 2af5c469d6 Ortsnamen aus dem Backend, Verbrauchsfaktor aus dem Tankbeleg, Radzaehler ueber Fahrten
Ortsauflösung serverseitig (geokodierung.py): der Ort steht jetzt in der Fahrt
statt im Zwischenspeicher jedes Geräts. Beide Oberflächen fragen nichts mehr ab.

Verbrauchskorrektur (verbrauchskorrektur.py): Faktor = Beleg-Liter / Summe der
Einzelfahrten, voll zu voll. Statt einer Schranke am Faktor wird der Nenner
geprüft - decken die erkannten Fahrten 90-110 % der Tacho-Spanne ab? Ausgelöst
beim Schreiben eines Tankvorgangs, nicht erst bei der nächsten Fahrt.

Radzähler: summiert die Fahrten des montierten Satzes statt Tacho-Deltas. Der
alte Weg hatte Fahrzeugwechsel als gefahrene Strecke verbucht (375.962 km bei
einem Tacho von 61.823).

Null heißt unbekannt: ein Kilometerstand von 0 oder ein leeres Feld werden zu
None normalisiert, statt an sechs Lesestellen als "Tachostand null" zu gelten.
Momentanwerte der Sensoren gelten nur für einen Tankvorgang von jetzt.

Oberfläche, beide Codebasen: Wertespalte der Fahrtenliste ausgerichtet, Ort ->
Ort in "Zuletzt", "Räder" statt "Reifen", "Termin vereinbart" entfernt,
Markenlogo 20 % größer, Übersicht-Symbol auf volle Rasterbreite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:28:37 +02:00

284 lines
11 KiB
Python

"""Radzaehler: der eingetragene Stand, und jeder gefahrene Kilometer kommt
dazu.
DIE VORGESCHICHTE, weil sie erklaert, warum es jetzt so einfach aussieht.
Erst war der Zaehler "aktueller Kilometerstand minus fester Startwert je
Satz" - falsch, sobald einmal umgeschaltet wurde, weil dann alle seither
gefahrenen Kilometer mitzaehlten, auch die auf dem anderen Satz. Danach fuehrte
das Profil einen eigenen Zaehler fort und `referenz_odo_km` hielt den zuletzt
verarbeiteten Kilometerstand, gegen den das naechste Delta gemessen wurde.
Auch das war falsch, nur unauffaelliger: gezaehlt wurde jedes POSITIVE Delta.
Ein Sprung nach unten fiel heraus, einer nach oben nicht - und beim Umstecken
des Geraets zwischen zwei Fahrzeugen gibt es beide Richtungen. Am 01.09.2026
standen deshalb **375.962 km** auf den Sommerraedern, bei einem Tacho von
61.823: der Sprung 61.823 -> 209.177 war als gefahrene Strecke verbucht.
WARUM JETZT DIE FAHRTEN UND NICHT DER TACHO. Ein Sprungschutz am Delta waere
moeglich gewesen (und war kurz gebaut), aber die Fahrten pruefen laengst genau
das - Rueckwaertssprunge, unmoegliches Tempo, Mindestdauer -, und sie messen
seit der GNSS-Verfeinerung auf 100 m statt auf ganze Kilometer. Am echten
Bestand nachgemessen: in den sauberen Bereichen trifft die Fahrtsumme die
Tacho-Spanne auf 99 bis 103 %, bei einer Fahrt 6,9 gegen 7 km.
WAS DAS KOSTET, und der Eigentuemer hat es am 01.09.2026 ausdruecklich
akzeptiert: was die Fahrterkennung nicht sieht, landet nie auf den Raedern.
Der Tacho ist die Wahrheit des Fahrzeugs und holt sich alles zurueck, eine
Fahrtsumme nicht.
WIE GERECHNET WIRD, in genau der Form, die der Eigentuemer verlangt hat:
`saetze.<satz>.km` IST der Stand. Von Hand eingetragen wird er direkt - kein
Umweg, kein Sockel, keine Rueckrechnung. Jede Fahrt legt ihre Strecke oben
drauf, sobald sie feststeht.
Damit keine Fahrt zweimal zaehlt, merkt sie sich selbst, was von ihr bereits
gutgeschrieben wurde (`reifen_gezaehlt_km`). Verfeinert das Screening die
Strecke spaeter noch (7,0 -> 6,896 km ist real vorgekommen), wird nur die
Differenz nachgetragen. Fahrten aus der Zeit vor dieser Umstellung tragen
keinen Radsatz und bleiben deshalb unberuecksichtigt - ihre Kilometer stecken
laengst im gespeicherten Stand.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .ablage import neue_id
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
SAETZE = ("sommer", "winter")
# Felder, die bei "Neue Räder anlegen" mit auf den frischen Satz übernommen
# werden - Größe und Solldruck ändern sich bei einem gleich montierten Ersatz
# typischerweise nicht, anders als Marke/Modell/DOT (siehe archivieren()).
_UEBERNOMMENE_FELDER = ("bild", "mass", "druck_vorne", "druck_hinten")
def aktiver_satz(profil: dict | None) -> str:
"""Welcher Satz gerade montiert ist - der Stempel fuer eine neue Fahrt.
Kleingeschrieben, weil das Profil den Wert historisch mal so und mal
grossgeschrieben fuehrt ("Sommer"), die Schluessel in `saetze` aber immer
klein sind.
"""
satz = str((profil or {}).get("reifen", {}).get("aktiv") or "sommer").lower()
return satz if satz in SAETZE else "sommer"
def _aufraeumen(reifen: dict) -> bool:
"""Reste der frueheren Delta-Buchhaltung wegwerfen. Sie werden nicht mehr
gelesen; stehen zu bleiben waere nur eine Falle fuer den naechsten Leser."""
geaendert = False
for veraltet in ("referenz_odo_km", "referenz_odo_zeit"):
if veraltet in reifen:
del reifen[veraltet]
geaendert = True
return geaendert
async def nachtragen(k: Koordinator, profil: dict | None = None) -> None:
"""Schreibt jeder Fahrt ihre Strecke dem montierten Satz gut - genau einmal.
Der Radsatz steht an der Fahrt selbst, nicht am Zeitpunkt. Ein Satzwechsel
braucht deshalb keine Sonderbehandlung: eine Fahrt zaehlt fuer den Satz,
der WAEHREND ihr montiert war, auch wenn danach umgeschaltet wurde.
`reifen_gezaehlt_km` haelt fest, wieviel von dieser Fahrt schon
gutgeschrieben ist. Verfeinert das Screening die Strecke spaeter noch,
wird nur die Differenz nachgetragen statt der ganzen Fahrt ein zweites
Mal. Ohne diesen Merker waere entweder jede Korrektur verloren oder jede
Fahrt doppelt gezaehlt."""
fahrten = await k.ablage.fahrten_lesen()
offen = []
for f in fahrten:
satz = f.get("reifensatz")
strecke = f.get("distance_km")
if satz not in SAETZE or strecke is None:
continue
gezaehlt = float(f.get("reifen_gezaehlt_km") or 0)
delta = round(float(strecke) - gezaehlt, 1)
if delta:
offen.append((f, satz, delta, round(float(strecke), 1)))
if not offen:
return
if profil is None:
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
_aufraeumen(reifen)
for fahrt, satz, delta, strecke in offen:
eintrag = saetze.setdefault(satz, {})
eintrag["km"] = round(float(eintrag.get("km") or 0) + delta, 1)
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"reifen_gezaehlt_km": strecke})
fahrt["reifen_gezaehlt_km"] = strecke
_LOGGER.info(
"Fahrt %s: %s km auf %s gutgeschrieben, neuer Stand %s km",
fahrt["trip_id"], delta, satz, eintrag["km"],
)
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
async def wechseln(k: Koordinator, satz: str | None) -> None:
"""Umschalten auf den anderen Radsatz.
Braucht keine Abrechnung vorher: jede Fahrt traegt ihren eigenen Satz, es
kann also nichts dem falschen gutgeschrieben werden."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Radsatz %s", satz)
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
profil.setdefault("reifen", {})["aktiv"] = satz
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Aktiver Radsatz auf %s umgeschaltet", satz)
async def km_setzen(k: Koordinator, satz: str | None, km: object) -> None:
"""Laufleistung eines Radsatzes von Hand setzen.
Der eingetragene Wert IST ab sofort der Stand; jeder weitere gefahrene
Kilometer kommt oben drauf. Bereits gutgeschriebene Fahrten tauchen nicht
erneut auf - sie tragen ihren Merker."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Radsatz %s", satz)
return
try:
wert = round(float(km), 1) # type: ignore[arg-type]
except (TypeError, ValueError):
_LOGGER.warning("Ungültiger km-Wert %s", km)
return
if wert < 0:
_LOGGER.warning("Negativer km-Wert %s abgelehnt", wert)
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
_aufraeumen(reifen)
reifen.setdefault("saetze", {}).setdefault(satz, {})["km"] = wert
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Laufleistung %s von Hand auf %s km gesetzt", satz, wert)
async def archivieren(k: Koordinator, satz: str | None) -> None:
""""Neue Räder anlegen": legt den aktuellen Stand von `satz` (km, Marke,
Modell, DOT, Kommentar) als Übersichtszeile ins Archiv und setzt den
laufenden Satz auf einen frischen Stand zurück - km auf 0, Marke/Modell/
DOT/Kommentar leer (galten für den alten, jetzt ersetzten Reifen), Größe
und Solldruck unverändert übernommen (siehe _UEBERNOMMENE_FELDER).
Die Fahrten des alten Satzes tragen ihren Merker und werden dem frischen
Satz nicht noch einmal gutgeschrieben."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Radsatz %s", satz)
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
_aufraeumen(reifen)
saetze = reifen.setdefault("saetze", {})
alt = dict(saetze.get(satz, {}))
archiv = reifen.setdefault("archiv", [])
archiv.insert(0, {
"id": neue_id("r"),
"satz": satz,
"ersetzt_am": datetime.date.today().isoformat(),
"km": alt.get("km") or 0,
"marke": alt.get("marke") or "",
"modell": alt.get("modell") or "",
"dot": alt.get("dot") or "",
"mass": alt.get("mass") or "",
"druck_vorne": alt.get("druck_vorne") or "",
"druck_hinten": alt.get("druck_hinten") or "",
"kommentar": alt.get("kommentar") or "",
})
saetze[satz] = {
"km": 0, "marke": "", "modell": "", "dot": "", "kommentar": "",
**{feld: alt.get(feld) or "" for feld in _UEBERNOMMENE_FELDER},
}
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Radsatz %s archiviert (%s km), neuer Satz angelegt", satz, archiv[0]["km"])
async def archiv_aktualisieren(k: Koordinator, id_: str | None, daten: dict) -> None:
"""Bearbeitet einen archivierten Reifensatz-Eintrag (Übersichtszeile auf
der Räder-Seite, aufgeklappt -> "Bearbeiten")."""
if not id_:
_LOGGER.warning("reifen_archiv_aktualisieren ohne id aufgerufen")
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
archiv = profil.setdefault("reifen", {}).setdefault("archiv", [])
eintrag = next((e for e in archiv if e.get("id") == id_), None)
if eintrag is None:
_LOGGER.warning("Archivierter Radsatz '%s' nicht gefunden", id_)
return
erlaubte_felder = (
"ersetzt_am", "km", "marke", "modell", "dot", "mass",
"druck_vorne", "druck_hinten", "kommentar",
)
for feld in erlaubte_felder:
if feld in daten and daten[feld] is not None:
eintrag[feld] = daten[feld]
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Archivierter Radsatz '%s' aktualisiert", id_)
async def archiv_loeschen(k: Koordinator, id_: str | None) -> None:
if not id_:
_LOGGER.warning("reifen_archiv_loeschen ohne id aufgerufen")
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
archiv = reifen.get("archiv", [])
uebrig = [e for e in archiv if e.get("id") != id_]
if len(uebrig) == len(archiv):
_LOGGER.warning("Archivierter Radsatz '%s' nicht gefunden", id_)
return
reifen["archiv"] = uebrig
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Archivierter Radsatz '%s' gelöscht", id_)
def zustaende(profil: dict | None) -> dict[str, object]:
"""Die drei Kontrollwerte für die eigenen Reifen-Entitäten.
Das Frontend liest die Zahlen direkt aus dem veröffentlichten Profil -
diese Entitäten sind nur zur Kontrolle in Entwicklerwerkzeuge -> Zustände
und für eigene Automationen des Nutzers da."""
reifen = (profil or {}).get("reifen", {})
saetze = reifen.get("saetze", {})
return {
"sommer": saetze.get("sommer", {}).get("km"),
"winter": saetze.get("winter", {}).get("km"),
"aktiv": reifen.get("aktiv") or "unbekannt",
}