d8b12da36d
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>
163 lines
6.7 KiB
Python
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]}
|