172db54e7a
- Statistik-Tab-Icon per zentrierter Stauchung an die Fuellflaeche der anderen Tab-Icons angeglichen (Panel + companion-app), auf Nutzerwunsch anschliessend 10% groesser. - Batteriediagramm neu gezeichnet: dynamische viewBox (1 Einheit = 1 Pixel, kein preserveAspectRatio-Verzerren mehr), ein Punkt je Ruhespannungsmessung statt Datumsbeschriftung, Tageshoechstwert nur noch im Tooltip. Generator- spannungen (> AGM_RUHE_MAX_V = 13,0 V, vom Nutzer festgelegt) bleiben aus dem Diagramm aussen vor; der SOC-Kurve-Eckwert "100% / voll" bleibt bei den ursprünglich recherchierten 12,8 V. companion-app auf dieselbe lineare SOC-Interpolation umgestellt (vorher eine abweichende Stufenkurve). - Außentemperatur wird jetzt auch beim Batterieverlauf-Import nachgetragen (vorher nur live) - Kopplung an die naechstgelegene Messung innerhalb NEBENWERT_MAX_ABSTAND_S (verlauf.py), vom Nutzer nach Live-Daten-Analyse auf 300s festgelegt. Merge-Logik in ablage.py ergaenzt: ein erneuter Import kann eine zuvor fehlende Temperatur jetzt tatsaechlich nachtragen, auch wenn sich der Tagesminimalwert selbst nicht aendert. - "Mein Audi"/Zustand zeigt bei unplausibler Live-Messung (z. B. Dongle offline) die zuletzt gemessene Spannung statt "unbekannt". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
10 KiB
Python
212 lines
10 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
|
|
|
|
# Letzte plausible Batteriespannung, für die "Zustand"-Kachel, wenn die
|
|
# aktuelle Live-Messung gerade unplausibel ist (Sensor kurzzeitig offline,
|
|
# Dongle getrennt o.ä.) - der Nutzer will dort die zuletzt gemessene Spannung
|
|
# sehen statt "unbekannt". RAM-only wie andere Laufzeitzustände dieses
|
|
# Projekts (siehe die bekannten Lücken zu Fahrtstart/Tankstand): kein Store,
|
|
# bewusst - ein Neustart zeigt kurz wieder "unbekannt", bis die nächste echte
|
|
# Messung eintrifft, statt einen möglicherweise sehr alten Wert aus einer
|
|
# Datei wiederzubeleben.
|
|
_letzte_gueltige_spannung: dict[str, float | None] = {"wert": None}
|
|
|
|
|
|
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)
|
|
|
|
# Verzögerter Import (statt oben am Dateikopf): batterie.py importiert
|
|
# seinerseits zustand_oder_none aus diesem Modul - ein Import auf
|
|
# Modulebene würde bei bestimmter Ladereihenfolge zu einem
|
|
# Circular-Import-Fehler führen. Zum Zeitpunkt dieses Funktionsaufrufs
|
|
# sind beide Module immer schon vollständig geladen.
|
|
from .batterie import SPANNUNG_MIN_V
|
|
|
|
spannung = _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR))
|
|
if spannung is not None and spannung < SPANNUNG_MIN_V:
|
|
# Dieselbe Plausibilitätsgrenze wie beim Aufzeichnen (batterie.py) -
|
|
# sonst zeigt die "Zustand"-Kachel einen Wert, der im Messwertverlauf
|
|
# nie auftaucht, weil er dort schon verworfen wird.
|
|
spannung = None
|
|
if spannung is not None:
|
|
_letzte_gueltige_spannung["wert"] = spannung
|
|
else:
|
|
# Nutzerwunsch: statt "unbekannt" die zuletzt gemessene Spannung
|
|
# zeigen, wenn der Sensor gerade nichts Plausibles liefert (z. B.
|
|
# Dongle offline, meldet dann oft 0 V) - None bleibt nur, solange
|
|
# noch nie ein gültiger Wert beobachtet wurde.
|
|
spannung = _letzte_gueltige_spannung["wert"]
|
|
|
|
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": spannung,
|
|
"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)),
|
|
}
|