Files
audi-app/custom_components/audi_dashboard/reifen.py
T
tobias 17149937a6 Batteriespannungs-Messwertliste, Wisch-Löschen, Reifensatz-Archiv, CI-Icons
Fünf vom Nutzer angeforderte Erweiterungen in einer Runde:

- Batteriespannungs-Kachel bekommt eine Messwertliste (Datum, Uhrzeit,
  Spannung, optional Außentemperatur über den neuen AUSSENTEMP_SENSOR),
  erreichbar über ein neues list-s-Symbol in der Diagramm-Kachel. Werte
  über AGM_RUHE_MAX_V (12,8 V) sind keine Ruhespannung, sondern
  Lichtmaschinenspannung - die Zeile markiert das jetzt mit
  "Generatorspannung" statt es unkommentiert als Messwert auszugeben.
- Jede Zeile der neuen Liste wischbar zum Löschen (neuer Dienst
  batterieverlauf_loeschen), über dieselbe Wisch-Mechanik wie Fahrten/
  Tankvorgänge in beiden Oberflächen.
- Batteriespannungs-Diagramm im Panel war auf großen Bildschirmen (≥860px,
  Spaltenlayout) stark in die Breite gezogen (preserveAspectRatio="none"
  bei fester Höhe) - gedeckelt wie die dort bereits vorhandenen Popups.
- Statistik-Tab-Symbol in beiden Oberflächen durch das Audi-CI-Symbol
  polls-s ersetzt.
- Neue Möglichkeit, einen Reifensatz zu archivieren ("Neue Räder
  anlegen"): der aktuelle Stand (km, Marke, Modell, DOT, Maße, Solldruck,
  Kommentar) wandert in ein Archiv, der laufende Satz beginnt bei 0 km neu.
  Archiv als aufklappbare Übersicht mit voller Bearbeitungsmöglichkeit,
  über drei neue, bewusst nicht über profilSchreiben laufende Dienste
  (reifen_archivieren/-aktualisieren/-loeschen) - aus demselben Grund wie
  reifen_wechseln: kein im Browser gehaltener Stand darf einen
  zwischenzeitlich fortgeschriebenen km-Wert überschreiben.

Live im Testcontainer geprüft (erstmals über :18123 statt :8123 erreichbar)
und dabei zwei echte Layout-Fehler gefunden, die kein Compile-/Testlauf
sehen konnte: die Archiv-Eingabefelder waren durch ein fälschlich
verwendetes .mitEinheit (feste 96px-Breite, eigentlich für Zahl+Einheit
gedacht) abgeschnitten ("Continenta" statt "Continental"), und die
Kilometerzahl in der eingeklappten Archiv-Zeile war klein an das Datum
gequetscht statt wie der Satzname lesbar. Beides behoben, live erneut
bestätigt.

Backend: py_compile clean, services.yaml ergänzt. Panel: node --check
clean. companion-app: tsc/Testsuite (146/146)/Build/OTA-Bündel alle grün.
Manifest 2026.8.25.2 → .8, jeder Neustart im Testcontainer sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 11:44:24 +02:00

218 lines
8.4 KiB
Python

"""Reifenzähler: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
Zeitpunkt der Fahrt aktiv war.
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.
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.
"""
from __future__ import annotations
import datetime
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
_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 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
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:
return
reifen = profil.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
aktiv = reifen.get("aktiv", "sommer")
referenz = reifen.get("referenz_odo_km")
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
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
async def wechseln(k: Koordinator, satz: str | None) -> None:
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
return
await fortschreiben(k, aktueller_km(k), neuer_aktiv=satz)
_LOGGER.info("Aktiver Reifensatz auf '%s' umgeschaltet", satz)
async def km_setzen(k: Koordinator, satz: str | None, km: object) -> None:
"""Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes.
Ü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."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%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", {})
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)
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)."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("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("Reifensatz '%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")."""
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 Reifensatz '%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 Reifensatz '%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 Reifensatz '%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_)
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",
}