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>
316 lines
13 KiB
Python
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()
|
|
|
|
|