"""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..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", }