Files
audi-app/custom_components/audi_dashboard/sensor.py
T
tobias d8b12da36d pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)
Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine
normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform
und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf
GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert
homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS.

Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der
panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige
Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und
"pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die
Integration beim ersten Start aus ihrer Vorlage an.

Drei alte Schwächen sind dabei mit erledigt:
- Die Nutzlast landet nicht mehr in der Recorder-Datenbank
  (_unrecorded_attributes - das kann nur eine echte Entität).
- Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher);
  fiel sie während eines Ausfalls ins Ende, schließt
  nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt.
- Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu
  gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg.

Namensvertrag geändert, beide Oberflächen mitgezogen:
pyscript.audi_dashboard_x -> sensor.audi_dashboard_x,
pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten
Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das
Panel liegt in der Integration und kann nicht driften.

Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home
Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json.

Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem
Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport,
echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit
automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112
Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:53:56 +02:00

163 lines
6.7 KiB
Python

"""Die Entitäten, über die beide Oberflächen die Daten der App lesen.
Das Frontend liest hass.states["sensor.audi_dashboard_profil"] usw. direkt und
holt sich die Nutzlast aus dem Attribut `daten` - kein Dienstaufruf mit
Rückgabewert, dessen Verhalten aus Sicht des Browsers nicht durchgängig
dokumentiert ist.
WARUM ECHTE ENTITÄTEN UND NICHT NUR EINTRÄGE IN DER ZUSTANDSMASCHINE
--------------------------------------------------------------------
Wegen einer einzigen Zeile: `_unrecorded_attributes`. Die Nutzlast dieser
Entitäten ist groß - das Fahrtenarchiv wächst über die Jahre auf Hunderte
Kilobyte. Home Assistant zeichnet Zustands-Attribute standardmäßig bei jeder
Änderung in der Recorder-Datenbank auf und warnt ab ~16 KB, dass genau das
Datenbankprobleme macht. In der pyscript-Fassung war das eine offene Flanke:
jede Veröffentlichung schrieb den kompletten Bestand erneut in die Datenbank,
alle 60 Sekunden.
Nur eine Entität, die zu einer Integration gehört, kann Attribute von der
Aufzeichnung ausnehmen. Ein roher Eintrag in der Zustandsmaschine (was
pyscripts state.set() macht) kann das nicht. Die Nutzlast geht damit
weiterhin an jede Oberfläche, landet aber nie in der Datenbank.
Die Zustände selbst sind bewusst schlicht ("aktuell"): der Inhalt steckt im
Attribut, der Zustand sagt nur, dass etwas da ist. Bei den drei
Reifen-Entitäten und der App-Version ist der Zustand dagegen der eigentliche
Wert - die sind zur Kontrolle in Entwicklerwerkzeuge -> Zustände und für
eigene Automationen des Nutzers gedacht.
"""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
E_APP_VERSION,
E_BATTERIEVERLAUF,
E_BELEG_ERGEBNIS,
E_FAHRTEN,
E_FAHRZEUGSTATUS,
E_IMPORT_STATUS,
E_PROFIL,
E_REIFEN_AKTIV,
E_REIFEN_SOMMER,
E_REIFEN_WINTER,
E_TANKVORGAENGE,
E_ZUORDNUNG,
SIGNAL_AKTUALISIERT,
)
from .koordinator import Koordinator
@dataclass(frozen=True)
class Beschreibung:
schluessel: str
name: str
icon: str
einheit: str | None = None
kategorie: EntityCategory | None = None
BESCHREIBUNGEN: tuple[Beschreibung, ...] = (
Beschreibung(E_PROFIL, "Audi Dashboard Profil", "mdi:car-info"),
Beschreibung(E_FAHRTEN, "Audi Dashboard Fahrten", "mdi:road-variant"),
Beschreibung(E_TANKVORGAENGE, "Audi Dashboard Tankvorgänge", "mdi:gas-station"),
Beschreibung(E_FAHRZEUGSTATUS, "Audi Dashboard Fahrzeugstatus", "mdi:car-connected"),
Beschreibung(E_BATTERIEVERLAUF, "Audi Dashboard Batterieverlauf", "mdi:car-battery"),
Beschreibung(E_REIFEN_SOMMER, "Audi Dashboard Reifen Sommer km", "mdi:tire", UnitOfLength.KILOMETERS),
Beschreibung(E_REIFEN_WINTER, "Audi Dashboard Reifen Winter km", "mdi:snowflake", UnitOfLength.KILOMETERS),
Beschreibung(E_REIFEN_AKTIV, "Audi Dashboard Reifen aktiver Satz", "mdi:tire"),
Beschreibung(E_ZUORDNUNG, "Audi Dashboard Entitäten", "mdi:link-variant", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_BELEG_ERGEBNIS, "Audi Dashboard Beleg Ergebnis", "mdi:receipt-text", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_IMPORT_STATUS, "Audi Dashboard Import Status", "mdi:database-import", None, EntityCategory.DIAGNOSTIC),
Beschreibung(E_APP_VERSION, "Audi Dashboard App Version", "mdi:tag-outline", None, EntityCategory.DIAGNOSTIC),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
koordinator: Koordinator = entry.runtime_data
async_add_entities(
AudiEntitaet(koordinator, beschreibung) for beschreibung in BESCHREIBUNGEN
)
class AudiEntitaet(SensorEntity):
"""Eine Entität je Datenbestand. Zustand kurz, Inhalt im Attribut `daten`."""
# Der eigentliche Grund für echte Entitäten - siehe Kopfkommentar.
_unrecorded_attributes = frozenset({"daten"})
# Bewusst KEIN has_entity_name: die Entity-IDs (sensor.audi_dashboard_*)
# sind ein Vertrag mit beiden Oberflächen (siehe const.py). Mit
# has_entity_name würde Home Assistant den Gerätenamen davorsetzen und die
# IDs hingen am - vom Nutzer änderbaren - Gerätenamen.
_attr_has_entity_name = False
_attr_should_poll = False
def __init__(self, koordinator: Koordinator, beschreibung: Beschreibung) -> None:
self._koordinator = koordinator
self._beschreibung = beschreibung
self._attr_name = beschreibung.name
self._attr_icon = beschreibung.icon
self._attr_native_unit_of_measurement = beschreibung.einheit
self._attr_entity_category = beschreibung.kategorie
self._attr_unique_id = f"{koordinator.entry.entry_id}_{beschreibung.schluessel}"
# Die gewünschte Objekt-ID ausdrücklich vorgeben, statt sie aus dem
# Namen ableiten zu lassen: aus dem Namen käme zwar dasselbe heraus,
# aber nur solange niemand den Namen anfasst.
self.internal_integration_suggested_object_id = (
f"audi_dashboard_{beschreibung.schluessel}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, koordinator.entry.entry_id)},
name="Audi Dashboard",
manufacturer="Audi Dashboard",
sw_version=koordinator.version,
entry_type=None,
)
async def async_added_to_hass(self) -> None:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_AKTUALISIERT}_{self._beschreibung.schluessel}",
self._neu_zeichnen,
)
)
@callback
def _neu_zeichnen(self) -> None:
self.async_write_ha_state()
def _wert(self) -> tuple[str, object]:
return self._koordinator.werte.get(self._beschreibung.schluessel, ("unbekannt", None))
@property
def available(self) -> bool:
"""Bis zur ersten Veröffentlichung gibt es nichts zu zeigen.
"unavailable" ist hier ehrlicher als ein Platzhalterwert: die
Oberfläche unterscheidet ausdrücklich zwischen "kein Wert" und "Wert
unbekannt" und darf sich darauf verlassen."""
return self._beschreibung.schluessel in self._koordinator.werte
@property
def native_value(self) -> str:
return self._wert()[0]
@property
def extra_state_attributes(self) -> dict[str, object]:
return {"daten": self._wert()[1]}