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