Files
audi-app/custom_components/audi_dashboard/koordinator.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

316 lines
13 KiB
Python

"""Der Koordinator: hält den Zustand der App und verdrahtet alles miteinander.
Was hier zusammenkommt, lag in der pyscript-Fassung über die Dateien verteilt,
weil es dort keinen Ort dafür gab: die Dekoratoren @service, @state_trigger und
@time_trigger banden ihre Registrierung direkt an die Funktion daneben. Eine
Integration hat einen Lebenszyklus (Einrichten, Entladen, Neu-Laden), und
genau deshalb braucht es eine Stelle, die weiß, was sie angemeldet hat und es
wieder abmelden kann.
DREI DINGE, DIE DIESER AUFBAU MÖGLICH MACHT UND DIE ES VORHER NICHT GAB
-----------------------------------------------------------------------
1. Zustandsbeobachter lassen sich zur Laufzeit neu binden. Früher backte
@state_trigger die Entity-ID einmalig beim Modul-Laden ein - ein im
Setup-Menü geänderter Zündungs-, Kilometer- oder Tanksensor wirkte erst
nach einem HA-Neustart, und die Oberfläche musste einen eigenen
Neustart-Knopf dafür anbieten. trigger_neu_binden() ersetzt beides.
2. Der Zwischenstand einer laufenden Fahrt überlebt einen Neustart. Er liegt
in einem Store statt nur im Arbeitsspeicher; dasselbe gilt für den
Tank-Tiefststand. Beides war als bekannte Schwäche vermerkt.
3. Die Nutzlast landet nicht mehr in der Recorder-Datenbank (siehe sensor.py).
Der Weg von hier zur Oberfläche: `setzen()` legt Zustand und Nutzlast ab und
schickt ein Signal; die Entität in sensor.py hört darauf und schreibt sich
neu. Die Entitäten holen sich ihren Wert beim Anlegen selbst aus `werte` -
deshalb darf schon veröffentlicht werden, bevor die Plattform überhaupt
eingerichtet ist.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
import os
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_change,
async_track_time_interval,
)
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.storage import Store
from . import batterie, fahrterkennung, reifen, screening, sicherung, tankerkennung
from .ablage import Ablage
from .const import (
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 .veroeffentlichung import fahrzeugstatus
from .zuordnung import Zuordnung
_LOGGER = logging.getLogger(__name__)
# Wie oft der Live-Fahrzeugstatus neu aus den Quellsensoren gelesen wird.
TAKT_STATUS = datetime.timedelta(seconds=20)
# Profil/Fahrten/Tankvorgänge ändern sich normalerweise nur durch Aktionen,
# die ohnehin sofort selbst veröffentlichen. Dieser Takt ist nur das
# Sicherheitsnetz für Änderungen direkt im Dateisystem.
TAKT_DATEN = datetime.timedelta(seconds=60)
TAKT_BATTERIE = datetime.timedelta(minutes=5)
STORE_VERSION = 1
class Koordinator:
"""Zustand, Zeittakte und Zustandsbeobachter der Integration."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, version: str) -> None:
self.hass = hass
self.entry = entry
self.version = version
self.ablage = Ablage(hass, hass.config.path("audi_dashboard"))
self.zuordnung = Zuordnung(self.ablage)
# Was die Entitäten anzeigen: Schlüssel -> (Zustand, Nutzlast).
self.werte: dict[str, tuple[str, dict | list | None]] = {}
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
self.fahrt_start_ts: datetime.datetime | None = None
self.tiefststand_pct: float | None = None
self._takte: list[CALLBACK_TYPE] = []
self._beobachter: list[CALLBACK_TYPE] = []
self._ende_aufgabe: asyncio.Task | None = None
# ------------------------------------------------------------ Lebenszyklus
async def starten(self) -> None:
await self.ablage.ordner_sicherstellen()
if await self.ablage.vorlage_anlegen(
os.path.join(os.path.dirname(__file__), "vorlage", "fahrzeugprofil.json")
):
_LOGGER.info(
"Erste Einrichtung: Fahrzeugprofil aus der Vorlage angelegt. "
"Die Fahrzeugdaten werden in der App unter Einstellungen -> "
"Fahrzeug einrichten eingetragen."
)
await self.zuordnung.anwenden()
await self._laufzeit_laden()
await self.alles_veroeffentlichen()
# Der Reifenzähler schreibt beim Start einmal fort: Kilometer, die
# während eines HA-Ausfalls gefahren wurden, gehören noch dem damals
# aktiven Satz (siehe reifen.py).
await reifen.fortschreiben(self, reifen.aktueller_km(self))
self._takte_anmelden()
await self.trigger_neu_binden()
# Erst wenn Home Assistant vollständig oben ist: bis dahin haben die
# Datenquellen ihre Zustände oft noch nicht gemeldet, und eine noch
# nicht gemeldete Zündung ließe sich nicht von "aus" unterscheiden
# (siehe fahrterkennung.nach_neustart_fortsetzen).
self._takte.append(
async_at_started(self.hass, self._nach_start)
)
async def _nach_start(self, _hass: HomeAssistant) -> None:
await fahrterkennung.nach_neustart_fortsetzen(self)
async def beenden(self) -> None:
for abmelden in (*self._takte, *self._beobachter):
abmelden()
self._takte.clear()
self._beobachter.clear()
self.warte_ende_ab_abbrechen()
# ------------------------------------------------------------- Zeittakte
def _takte_anmelden(self) -> None:
self._takte.append(
async_track_time_interval(self.hass, self._takt_status, TAKT_STATUS)
)
self._takte.append(
async_track_time_interval(self.hass, self._takt_daten, TAKT_DATEN)
)
self._takte.append(
async_track_time_interval(self.hass, self._takt_batterie, TAKT_BATTERIE)
)
# Ein einzelner täglicher Termin genügt für alle Sicherungs-Intervalle
# - welche heute fällig ist, entscheidet sicherung.automatisch().
self._takte.append(
async_track_time_change(self.hass, self._takt_sicherung, hour=4, minute=0, second=0)
)
async def _takt_status(self, _jetzt: datetime.datetime) -> None:
await self.fahrzeugstatus_veroeffentlichen()
async def _takt_daten(self, _jetzt: datetime.datetime) -> None:
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
async def _takt_batterie(self, _jetzt: datetime.datetime) -> None:
await batterie.pruefen(self)
async def _takt_sicherung(self, _jetzt: datetime.datetime) -> None:
await sicherung.automatisch(self)
# -------------------------------------------------- Zustandsbeobachter
async def trigger_neu_binden(self) -> None:
"""Meldet die Beobachter für Zündung, Kilometerstand und Tankfüllstand
neu an - nach jeder Änderung der Sensor-Zuordnung.
Ohne zugeordnete Entität wird bewusst gar kein Beobachter angemeldet,
statt einen gegen eine nicht existierende Entity-ID zu registrieren."""
for abmelden in self._beobachter:
abmelden()
self._beobachter.clear()
werte = self.zuordnung.werte
self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_geaendert)
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
def _beobachten(
self, entity_id: str, rueckruf: Callable[[Event[EventStateChangedData]], Coroutine[Any, Any, None]]
) -> None:
if not entity_id:
return
self._beobachter.append(
async_track_state_change_event(self.hass, [entity_id], rueckruf)
)
@staticmethod
def _zustaende(ereignis: Event[EventStateChangedData]) -> tuple[str | None, str | None]:
alt = ereignis.data["old_state"]
neu = ereignis.data["new_state"]
return (neu.state if neu else None, alt.state if alt else None)
async def _zuendung_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, alt = self._zustaende(ereignis)
await fahrterkennung.zuendung_geaendert(self, neu, alt)
async def _kilometerstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, alt = self._zustaende(ereignis)
if neu is None or alt is None:
return
await reifen.fortschreiben(self, reifen.aktueller_km(self))
await screening.durchfuehren(self)
async def _tankfuellstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
neu, _alt = self._zustaende(ereignis)
await tankerkennung.fuellstand_geaendert(self, neu)
# ------------------------------------------------- Laufende Fahrt merken
async def _laufzeit_laden(self) -> None:
gespeichert = await self._store.async_load() or {}
roh = gespeichert.get("fahrt_start_ts")
if roh:
try:
self.fahrt_start_ts = datetime.datetime.fromisoformat(roh)
_LOGGER.info(
"Laufende Fahrt seit %s aus dem letzten Lauf übernommen", roh
)
except ValueError:
self.fahrt_start_ts = None
self.tiefststand_pct = gespeichert.get("tiefststand_pct")
async def _laufzeit_sichern(self) -> None:
await self._store.async_save({
"fahrt_start_ts": self.fahrt_start_ts.isoformat() if self.fahrt_start_ts else None,
"tiefststand_pct": self.tiefststand_pct,
})
async def fahrt_start_setzen(self, ts: datetime.datetime | None) -> None:
self.fahrt_start_ts = ts
await self._laufzeit_sichern()
async def tiefststand_setzen(self, wert: float | None) -> None:
self.tiefststand_pct = wert
await self._laufzeit_sichern()
def warte_ende_ab(self, koroutine: Coroutine[Any, Any, None]) -> None:
self.warte_ende_ab_abbrechen()
self._ende_aufgabe = self.entry.async_create_background_task(
self.hass, koroutine, "audi_dashboard_fahrtende"
)
def warte_ende_ab_abbrechen(self) -> None:
if self._ende_aufgabe is not None and not self._ende_aufgabe.done():
self._ende_aufgabe.cancel()
self._ende_aufgabe = None
# ---------------------------------------------------------- Veröffentlichen
def setzen(
self, schluessel: str, zustand: str | float | None, daten: dict | list | None = None
) -> None:
self.werte[schluessel] = (zustand, daten)
async_dispatcher_send(self.hass, f"{SIGNAL_AKTUALISIERT}_{schluessel}")
async def profil_veroeffentlichen(self, profil: dict | None = None) -> None:
if profil is None:
profil = await self.ablage.profil_lesen()
if profil is None:
return
self.setzen(E_PROFIL, "aktuell", profil)
# Die drei Reifen-Kontrollwerte hängen am selben Profil - zusammen
# veröffentlichen, damit sie nicht auseinanderlaufen können.
# Die beiden km-Stände bleiben Zahlen (die Entitäten tragen die
# Einheit km); None heißt "noch nie gezählt" und wird von Home
# Assistant als "unknown" angezeigt - richtiger als eine erfundene 0.
stand = reifen.zustaende(profil)
self.setzen(E_REIFEN_SOMMER, stand["sommer"])
self.setzen(E_REIFEN_WINTER, stand["winter"])
self.setzen(E_REIFEN_AKTIV, str(stand["aktiv"]))
async def fahrten_veroeffentlichen(self) -> None:
self.setzen(E_FAHRTEN, "aktuell", await self.ablage.fahrten_lesen())
async def tankvorgaenge_veroeffentlichen(self) -> None:
self.setzen(E_TANKVORGAENGE, "aktuell", await self.ablage.tankvorgaenge_lesen())
async def batterieverlauf_veroeffentlichen(self) -> None:
self.setzen(E_BATTERIEVERLAUF, "aktuell", await self.ablage.batterieverlauf_lesen())
async def zuordnung_veroeffentlichen(self) -> None:
self.setzen(E_ZUORDNUNG, "aktuell", self.zuordnung.aktueller_stand())
async def fahrzeugstatus_veroeffentlichen(self) -> None:
self.setzen(E_FAHRZEUGSTATUS, "aktuell", fahrzeugstatus(self.hass, self.zuordnung.werte))
async def beleg_ergebnis_veroeffentlichen(self, daten: dict) -> None:
self.setzen(E_BELEG_ERGEBNIS, "aktuell", daten)
async def import_status_veroeffentlichen(self, zustand: str, daten: dict) -> None:
self.setzen(E_IMPORT_STATUS, zustand, daten)
async def alles_veroeffentlichen(self) -> None:
self.setzen(E_APP_VERSION, self.version, {"app": self.version})
await self.profil_veroeffentlichen()
await self.fahrten_veroeffentlichen()
await self.tankvorgaenge_veroeffentlichen()
await self.fahrzeugstatus_veroeffentlichen()
await self.batterieverlauf_veroeffentlichen()
await self.zuordnung_veroeffentlichen()