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>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""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]}
|
||||
Reference in New Issue
Block a user