Files
audi-app/custom_components/audi_dashboard/veroeffentlichung.py
T
tobias 7276d06a18 Sicherheit-Feinschliff, Nächster-Service-Sortierfehler behoben, Fahrzeugstatus umbenannt
- Türschlösser auf ein Sensor reduziert (Fahrertür genügt, Zentralverriegelung
  schließt alle Türen gemeinsam) - Kofferraum-/Motorhauben-Schloss entfernt.
- "Sicherheit" heißt im Panel jetzt "Fahrzeugstatus", wie in der companion-app.
- naechsterTermin() (Panel): sortierte null als Epoch 1970 und ließ dadurch
  einen datenlosen Termin (Hauptuntersuchung ohne Eintrag/Erstzulassung) immer
  vor einem echten, berechneten Termin (Ölwechsel) gewinnen - Ursache dafür,
  dass Übersicht nach dem Leeren des Servicebuchs nichts mehr zeigte.
- "Licht ausgeschaltet" statt "Kein Licht" (Formulierung zurückgenommen).
- Große Bildschirme: Unterseiten (Standort, Service, ...) zeigten ihren Titel
  in 26px statt der sonst überall genutzten 17px - wirkte neben dem
  durchgängig leichten Fließtext wie Fettschrift, obwohl font-weight nirgends
  wechselt. Jetzt dieselbe Standardgröße wie im Telefon-Layout.

Details und Verifikation in AGENTS.md.

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

180 lines
8.6 KiB
Python

"""Baut die Nutzlasten, die als Entitäts-Attribut `daten` an die Oberfläche
gehen.
Das Frontend liest die Werte über das ganz normale hass.states statt über
einen Dienst mit Rückgabewert: Zustände lesen ist ein seit Jahren stabiler,
einfacher Weg, während das Verhalten response-fähiger Dienste aus Sicht des
Browsers nicht durchgängig dokumentiert ist.
Hier stehen nur die reinen Rechenfunktionen - wer sie wann veröffentlicht,
entscheidet der Koordinator (koordinator.py).
GRÖSSENGRENZE, DIE JETZT KEINE MEHR IST: Zustands-Attribute sind in Home
Assistant nicht für beliebig große Datenmengen gedacht; ab ~16 KB warnt der
Recorder und schreibt sie trotzdem in die Datenbank. Die Fahrten- und
Tankvorgänge-Archive überschreiten das nach ein paar Jahren zwangsläufig. In
der pyscript-Fassung war das eine bekannte, offene Flanke. Diese Integration
markiert das Attribut `daten` als nicht aufzuzeichnen (siehe
_unrecorded_attributes in sensor.py) - die Nutzlast geht weiterhin an jede
Oberfläche, landet aber nie in der Datenbank.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .einstellungen import POSITIONEN, Sensorzuordnung
def zustand_oder_none(hass: HomeAssistant, entity_id: str | None) -> str | None:
"""Sicherer Zustandszugriff.
Eine nicht (mehr) existierende Entität ist hier der Normalfall, solange
Rollen im Setup-Menü unbelegt sind, und bleibt auch danach relevant: fällt
eine Datenquelle aus, soll die Oberfläche das zeigen, nicht an einem
Fehler hängen bleiben."""
if not entity_id:
return None
zustand = hass.states.get(entity_id)
if zustand is None or zustand.state in (None, "", "unknown", "unavailable"):
return None
return zustand.state
def _zu_zahl(wert: object) -> float | None:
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _abs_zahl(wert: object) -> float | None:
zahl = _zu_zahl(wert)
return None if zahl is None else abs(zahl)
def _zu_bool(wert: object) -> bool | None:
"""binary_sensor-Zustand als echtes True/False, None bei fehlender Meldung.
None ist hier ausdrücklich kein "nein": ohne zugeordneten Sensor weiß die
App schlicht nicht, ob gefahren wird - und muss das anzeigen dürfen, statt
"steht" zu behaupten."""
if wert is None:
return None
return str(wert).lower() in ("on", "true", "1", "open", "yes")
def _standort(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
"""Live-GPS-Position - liest STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei
eigene sensor-Entities für Breiten-/Längengrad (flespi liefert Koordinaten
so, nicht als Attribute einer einzelnen device_tracker-Entity).
Fehlt eine der beiden Entity-IDs oder ist der Zustand (noch) nicht
verfügbar, liefert diese Funktion durchgehend None statt eines geratenen
Werts."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
lat_id, lon_id = werte.STANDORT_LAT_SENSOR, werte.STANDORT_LON_SENSOR
if not lat_id or not lon_id:
return leer
lat_zustand = hass.states.get(lat_id)
lon_zustand = hass.states.get(lon_id)
if lat_zustand is None or lon_zustand is None:
return leer
lat = _zu_zahl(zustand_oder_none(hass, lat_id))
lon = _zu_zahl(zustand_oder_none(hass, lon_id))
if lat is None or lon is None:
return leer
return {
"lat": lat,
"lon": lon,
"genauigkeit_m": None,
"zeit": lat_zustand.last_updated.isoformat(),
}
def _sicherheitscheck(hass: HomeAssistant, werte: Sensorzuordnung) -> list[dict]:
"""Die einzeln geprüften Punkte hinter "Sicher abgestellt", fürs Frontend -
gruppiert über das Feld "gruppe" in vier Sammelzeilen (Fahrzeug verriegelt
/ Türen und Klappen geschlossen / Fenster und Dach geschlossen / Licht
ausgeschaltet), die die Oberfläche durch Gruppieren dieser flachen Liste
berechnet, statt hier schon vier separate Listen zu bauen - eine flache
Liste bleibt die einfachere, additive Änderung gegenüber dem, was das
Frontend vorher schon kannte.
"ok" ist None, wenn der Sensor fehlt oder nicht verfügbar ist - genau
daraus leitet sich auch die zusammengefasste Kennzahl unten ab, damit
beide nie auseinanderlaufen können. Alle binary_sensor-device_classes
hier (door/window/opening/lock/light) folgen HAs eigener Konvention "off
= der gute Zustand" (zu/verriegelt/kein Licht) - ein einziges Prüfmuster
für alle Zeilen, kein Sonderfall je Art."""
eintraege: list[dict] = []
for pos, sensor in zip(POSITIONEN, werte.TUER_SENSOREN):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off", "gruppe": "tueren_klappen"})
for pos, sensor in zip(POSITIONEN, werte.FENSTER_SENSOREN):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off", "gruppe": "fenster_dach"})
# "Kofferraum" statt "Heckklappe": alltagssprachlicher Name für dieselbe
# Öffnung beim Avant/Kombi - das Sensorfeld HECKKLAPPE_SENSOR selbst
# bleibt unverändert, um eine bestehende Zuordnung nicht zu brechen.
for label, sensor in (
("Kofferraum", werte.HECKKLAPPE_SENSOR),
("Motorhaube", werte.HAUBE_SENSOR),
):
w = zustand_oder_none(hass, sensor)
eintraege.append({"label": label, "ok": None if w is None else w == "off", "gruppe": "tueren_klappen"})
# Nur die Fahrertür: die Zentralverriegelung schließt alle Türen gemeinsam,
# ihr Zustand gilt fürs ganze Fahrzeug (siehe TUERSCHLOSS_SENSOR).
w = zustand_oder_none(hass, werte.TUERSCHLOSS_SENSOR)
eintraege.append({"label": "Türschloss", "ok": None if w is None else w == "off", "gruppe": "verriegelt"})
w = zustand_oder_none(hass, werte.DACH_SENSOR)
eintraege.append({"label": "Dach", "ok": None if w is None else w == "off", "gruppe": "fenster_dach"})
w = zustand_oder_none(hass, werte.LICHT_SENSOR)
eintraege.append({"label": "Standlicht", "ok": None if w is None else w == "off", "gruppe": "licht"})
return eintraege
def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss."""
sicherheitscheck = _sicherheitscheck(hass, werte)
# "Sicher abgestellt": erst wenn WIRKLICH jeder einzeln geprüfte Punkt
# zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch nur eine
# Meldung, ist der Status unbekannt statt geraten.
if any(e["ok"] is None for e in sicherheitscheck):
gesichert = None
else:
gesichert = all(e["ok"] for e in sicherheitscheck)
standort = _standort(hass, werte)
return {
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
"batteriespannung": _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR)),
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
# auch die Fahrterkennung als maßgebliches Signal für Fahrtbeginn und
# -ende nimmt - damit sagen Anzeige und Erfassung zwangsläufig
# dasselbe. Vorher wurde dieser Zustand im Frontend aus dem Status der
# Fahrten abgeleitet ("offen" = fährt). Das war falsch: "offen" heißt
# unvollständige Daten, nicht "unterwegs" - eine Fahrt ohne
# Kilometerstand blieb dauerhaft "offen" und das Fahrzeug damit
# dauerhaft "fahrend". None bedeutet: kein Zündungssensor zugeordnet.
"zuendung": _zu_bool(zustand_oder_none(hass, werte.ZUENDUNG_SENSOR)),
"standort_lat": standort["lat"],
"standort_lon": standort["lon"],
"standort_genauigkeit_m": standort["genauigkeit_m"],
"standort_zeit": standort["zeit"],
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
# App-eigene Servicebuch-Berechnung) - die Streckensensoren liefern
# negative Restkilometer-Werte, hier deshalb der Betrag.
"oelwechsel_faellig_ts": zustand_oder_none(hass, werte.NAECHSTER_OELWECHSEL_SENSOR),
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.OELWECHSEL_STRECKE_SENSOR)),
"inspektion_faellig_ts": zustand_oder_none(hass, werte.NAECHSTE_INSPEKTION_SENSOR),
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.INSPEKTION_STRECKE_SENSOR)),
}