2af5c469d6
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>
284 lines
11 KiB
Python
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",
|
|
}
|