fb251154c7
Zwei getrennte Themen in einem Commit, beide in derselben Sitzung entstanden:
1. Ölwechsel/Inspektion wurden komplett ausgeblendet ("kein Eintrag im
Servicebuch"), sobald kein Servicebucheintrag vorlag - selbst wenn der
zugeordnete Sensor eine gültige Fälligkeit meldete. In beiden Frontends
prüfte die Anzeige nur den Servicebuch-Zweig, bevor sie die
Fahrzeugmeldung überhaupt las. Jetzt steht die Fahrzeugmeldung für sich;
fehlt zusätzlich ein Servicebucheintrag, übernimmt eine neue,
fahrtenlog-basierte Prognose (kmProTagAusFahrten()/meldungsPrognose())
die Hochrechnung statt der Servicebuch-Rate - deckelt auf die vom
Fahrzeug selbst gemeldete Zeitgrenze, falls zu wenig gefahren wird.
2. install.ps1 als Update-Weg wird von Windows Smart App Control blockiert,
ohne Umgehungsmöglichkeit. Die Integration lädt sich jetzt auf
Tastendruck selbst von Gitea (aktualisierung.py), verifiziert das
Manifest vor jedem Tausch und tauscht per os.rename mit automatischem
Rollback bei Fehlern - install.ps1 bleibt nur noch für die
Erstinstallation nötig. Zugangstoken über einen neuen OptionsFlow in
entry.options, nie in configuration.yaml.
Nebenbei: mehrere seit der HACS-Ausschluss-Entscheidung liegen gebliebene
falsche HACS-Referenzen in Code-Kommentaren und einem UI-Text korrigiert.
Details, Sicherheitsbegründung und Verifikationsstand in AGENTS.md,
Abschnitte I und J.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
366 lines
15 KiB
Python
366 lines
15 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 json
|
|
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 (
|
|
BUENDEL_INFO,
|
|
BUENDEL_ORDNER,
|
|
BUENDEL_URL,
|
|
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
|
|
# Ergebnis des letzten "Auf Update prüfen"/"Update installieren" -
|
|
# nur auf Tastendruck gesetzt (siehe dienste.py), nie im normalen
|
|
# Veröffentlichungs-Takt neu berechnet, damit keine automatischen
|
|
# Gitea-Abfragen im Hintergrund laufen. app_version_veroeffentlichen()
|
|
# liest diesen Wert bei jedem Takt erneut mit, damit er nicht
|
|
# zwischen zwei Prüfungen verschwindet.
|
|
self.letzte_update_pruefung: dict | 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)
|
|
|
|
def _buendel_lesen(self) -> dict | None:
|
|
"""Angaben zum mitgelieferten OTA-Bündel, oder None.
|
|
|
|
None ist ein völlig normaler Zustand: wer nur das Panel benutzt,
|
|
braucht kein App-Bündel, und eine Installation ohne bundle.json ist
|
|
deshalb nicht kaputt, sondern schlicht ohne diese Möglichkeit."""
|
|
pfad = os.path.join(
|
|
os.path.dirname(__file__), BUENDEL_ORDNER.replace("/", os.sep), BUENDEL_INFO
|
|
)
|
|
if not os.path.exists(pfad):
|
|
return None
|
|
try:
|
|
with open(pfad, encoding="utf-8") as datei:
|
|
info = json.load(datei)
|
|
except (ValueError, OSError) as fehler:
|
|
_LOGGER.warning("%s ist nicht lesbar (%s)", pfad, fehler)
|
|
return None
|
|
if not isinstance(info, dict) or not info.get("version") or not info.get("sha256"):
|
|
_LOGGER.warning("%s hat nicht die erwartete Form - Bündel wird nicht gemeldet", pfad)
|
|
return None
|
|
return {**info, "url": BUENDEL_URL}
|
|
|
|
async def app_version_veroeffentlichen(self) -> None:
|
|
buendel = await self.hass.async_add_executor_job(self._buendel_lesen)
|
|
# Die App liest alle drei aus derselben Entität: welchen Stand diese
|
|
# Installation ausliefert, ob ein nachladbares OTA-Bündel danebenliegt,
|
|
# und ob eine neuere Fassung bei Gitea bereitliegt. Zusammen an einer
|
|
# Stelle, damit die App nicht mehrere Quellen abgleichen muss, die
|
|
# auseinanderlaufen können.
|
|
self.setzen(
|
|
E_APP_VERSION,
|
|
self.version,
|
|
{
|
|
"app": self.version,
|
|
"buendel": buendel,
|
|
"integration_update": self.letzte_update_pruefung,
|
|
},
|
|
)
|
|
|
|
async def alles_veroeffentlichen(self) -> None:
|
|
await self.app_version_veroeffentlichen()
|
|
await self.profil_veroeffentlichen()
|
|
await self.fahrten_veroeffentlichen()
|
|
await self.tankvorgaenge_veroeffentlichen()
|
|
await self.fahrzeugstatus_veroeffentlichen()
|
|
await self.batterieverlauf_veroeffentlichen()
|
|
await self.zuordnung_veroeffentlichen()
|
|
|
|
|