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>
This commit is contained in:
2026-09-02 11:28:37 +02:00
parent 78a8017e95
commit 2af5c469d6
27 changed files with 1575 additions and 352 deletions
+128 -62
View File
@@ -1,21 +1,42 @@
"""Reifenzähler: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
Zeitpunkt der Fahrt aktiv war.
"""Radzaehler: der eingetragene Stand, und jeder gefahrene Kilometer kommt
dazu.
Früher war der Zähler = aktueller Kilometerstand minus fester Startwert je
Satz. Das war falsch, sobald einmal zwischen Sommer und Winter umgeschaltet
wurde: die Differenz zum Startwert zählt dann alle seither gefahrenen
Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt führt
reifen.saetze.<satz>.km selbst den fortlaufenden Gesamtstand, und
reifen.referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest,
gegen den die nächste Änderung als Delta gemessen wird.
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.
Keine Abhängigkeit von total_increasing oder utility_meter: die Fortschreibung
braucht nur den zuletzt bekannten Absolutwert des Kilometerstand-Sensors. Ein
zeitweise "unavailable" Quellsensor verliert dadurch nichts - referenz_odo_km
bleibt einfach stehen, bis wieder ein gültiger Wert kommt, und das nächste
Delta wird dann gegen diesen alten Stand berechnet. Auch Kilometer, die
während eines HA-Ausfalls gefahren wurden, werden so noch dem damals aktiven
Satz zugerechnet.
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
@@ -25,7 +46,6 @@ import logging
from typing import TYPE_CHECKING
from .ablage import neue_id
from .veroeffentlichung import zustand_oder_none
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -40,77 +60,120 @@ SAETZE = ("sommer", "winter")
_UEBERNOMMENE_FELDER = ("bild", "mass", "druck_vorne", "druck_hinten")
def aktueller_km(k: Koordinator) -> float | None:
try:
return float(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
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"
async def fortschreiben(k: Koordinator, neuer_km: float | None, neuer_aktiv: str | None = None) -> None:
"""Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit
referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser
Reihenfolge, damit beim Wechseln selbst keine Kilometer dem neuen Satz
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
profil = await k.ablage.profil_lesen()
if profil is None:
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", {})
aktiv = reifen.get("aktiv", "sommer")
referenz = reifen.get("referenz_odo_km")
_aufraeumen(reifen)
if referenz is not None and neuer_km is not None:
delta = round(neuer_km - referenz, 1)
if delta > 0:
satz = saetze.setdefault(aktiv, {})
satz["km"] = round((satz.get("km") or 0) + delta, 1)
if neuer_km is not None:
reifen["referenz_odo_km"] = neuer_km
if neuer_aktiv is not None:
reifen["aktiv"] = neuer_aktiv
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 Reifensatz '%s'", satz)
_LOGGER.warning("Ungültiger Radsatz %s", satz)
return
await fortschreiben(k, aktueller_km(k), neuer_aktiv=satz)
_LOGGER.info("Aktiver Reifensatz auf '%s' umgeschaltet", satz)
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:
"""Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes.
"""Laufleistung eines Radsatzes von Hand setzen.
Überschreibt nur den gespeicherten Zähler selbst - referenz_odo_km (der
zuletzt verarbeitete Fahrzeug-Kilometerstand) bleibt unverändert, damit
die nächste Fortschreibung weiterhin korrekt von hier aus weiterzählt,
statt auf den neuen Wert erneut ein Delta seit dem alten Stand
aufzuschlagen."""
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 Reifensatz '%s'", satz)
_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)
_LOGGER.warning("Ungültiger km-Wert %s", km)
return
if wert < 0:
_LOGGER.warning("Negativer km-Wert '%s' abgelehnt", wert)
_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("km-Stand '%s' manuell auf %s gesetzt", satz, wert)
_LOGGER.info("Laufleistung %s von Hand auf %s km gesetzt", satz, wert)
async def archivieren(k: Koordinator, satz: str | None) -> None:
@@ -118,15 +181,19 @@ async def archivieren(k: Koordinator, satz: str | None) -> None:
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)."""
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 Reifensatz '%s'", satz)
_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, {}))
@@ -152,12 +219,11 @@ async def archivieren(k: Koordinator, satz: str | None) -> None:
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Reifensatz '%s' archiviert (%s km), neuer Satz angelegt", satz, archiv[0]["km"])
_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 Reifen-Seite, aufgeklappt -> "Bearbeiten")."""
der Räder-Seite, aufgeklappt -> "Bearbeiten")."""
if not id_:
_LOGGER.warning("reifen_archiv_aktualisieren ohne id aufgerufen")
return
@@ -167,7 +233,7 @@ async def archiv_aktualisieren(k: Koordinator, id_: str | None, daten: dict) ->
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 Reifensatz '%s' nicht gefunden", id_)
_LOGGER.warning("Archivierter Radsatz '%s' nicht gefunden", id_)
return
erlaubte_felder = (
@@ -180,7 +246,7 @@ async def archiv_aktualisieren(k: Koordinator, id_: str | None, daten: dict) ->
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Archivierter Reifensatz '%s' aktualisiert", id_)
_LOGGER.info("Archivierter Radsatz '%s' aktualisiert", id_)
async def archiv_loeschen(k: Koordinator, id_: str | None) -> None:
@@ -194,12 +260,12 @@ async def archiv_loeschen(k: Koordinator, id_: str | None) -> None:
archiv = reifen.get("archiv", [])
uebrig = [e for e in archiv if e.get("id") != id_]
if len(uebrig) == len(archiv):
_LOGGER.warning("Archivierter Reifensatz '%s' nicht gefunden", id_)
_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 Reifensatz '%s' gelöscht", id_)
_LOGGER.info("Archivierter Radsatz '%s' gelöscht", id_)
def zustaende(profil: dict | None) -> dict[str, object]: