Ölwechsel/Inspektion-Anzeige repariert, Selbst-Update der Integration gebaut

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>
This commit is contained in:
2026-08-24 12:01:01 +02:00
parent 5ff33c92d9
commit fb251154c7
27 changed files with 1268 additions and 82 deletions
+4 -4
View File
@@ -104,10 +104,10 @@ async def _statik_anmelden(hass: HomeAssistant) -> None:
scheitern. Das ist unkritisch - der Pfad zeigt auf einen Ordner, dessen
Inhalt sich beim Neu-Laden nicht ändert.
cache_headers=False ist Absicht: die Dateien werden von HACS ersetzt, und
ein Browser, der eine alte Fassung 31 Tage lang aus dem Cache bedient
(die HA-Vorgabe für statische Pfade), wäre genau die stille Veraltung, die
das Versions-Verfahren verhindern soll."""
cache_headers=False ist Absicht: die Dateien werden von install.ps1
ersetzt, und ein Browser, der eine alte Fassung 31 Tage lang aus dem
Cache bedient (die HA-Vorgabe für statische Pfade), wäre genau die
stille Veraltung, die das Versions-Verfahren verhindern soll."""
if hass.data.get(DOMAIN, {}).get("statik"):
return
ordner = os.path.join(os.path.dirname(__file__), STATIK_ORDNER)
@@ -0,0 +1,244 @@
"""Selbst-Update der Integration direkt von Gitea.
Ersetzt install.ps1 als Update-Weg - nicht als Erstinstallations-Weg, die
Integration muss bereits laufen, um sich selbst aktualisieren zu können.
Grund: Windows Smart App Control blockiert die Ausführung von install.ps1
zuverlässig und lässt sich, anders als der SmartScreen davor, nicht per
"trotzdem ausführen" umgehen.
SICHERHEIT: dieses Modul läuft, anders als install.ps1, INNERHALB des
Prozesses, den es aktualisiert - der eigene Ordner wird ersetzt, während der
Dienstaufruf noch läuft. Deshalb strenger als install.ps1s
"leeren-dann-kopieren":
1. Laden und Prüfen passiert komplett in einem Geschwisterordner
(audi_dashboard_update_staging). Der Live-Ordner bleibt unangetastet,
bis feststeht, dass der Download vollständig und plausibel ist.
2. Erst danach ein atomarer Tausch per os.rename, nicht Löschen+Kopieren:
der alte Ordner wird zu audi_dashboard_backup umbenannt statt gelöscht -
reversibel, falls das Update Probleme macht.
3. Schlägt der zweite Rename fehl, wird der erste zurückgenommen, statt
einen halb ersetzten Ordner zurückzulassen.
4. Kein automatischer Reload oder Neustart. Ein Reload aus dem eigenen,
noch laufenden Dienstaufruf heraus anzustoßen wäre riskant - der
Handler könnte mitten in der Ausführung durch den eigenen Reload
unterbrochen werden. Die Oberfläche zeigt stattdessen "Home Assistant
neu starten" an, genau wie install.ps1 es über seine Restliste tut.
5. Nichts außerhalb des eigenen Ordners: configuration.yaml, .storage/,
www/ und der separate Fahrzeugdaten-Ordner werden nirgends geöffnet.
Das Umbenennen des eigenen, gerade ausgeführten Ordners ist auf Linux (Home
Assistant OS/Docker, worauf dieses Projekt läuft) unkritisch: Python hält
nach dem Import keine offenen Dateizugriffe auf die .py-Quelltexte mehr,
und der Verzeichnisname selbst ist nur eine Zeichenkette in os.rename() -
kein Handle, das durch die Umbenennung ungültig würde.
"""
from __future__ import annotations
import base64
import datetime
import json
import logging
import os
import shutil
import zipfile
from io import BytesIO
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
_REPO_BESITZER = "paul"
_REPO_NAME = "audi-app"
_ZWEIG = "main"
_GITEA_BASIS = "https://gitea.nothaft.cloud/api/v1"
_MANIFEST_PFAD_IM_REPO = "custom_components/audi_dashboard/manifest.json"
_INTEGRATIONS_PFAD_IM_REPO = "custom_components/audi_dashboard/"
# Absolute Pfade, einmal zur Modul-Ladezeit bestimmt - __file__ liegt selbst
# im Live-Ordner, dessen Elternordner (custom_components/) ist der Ort für
# Staging- und Backup-Geschwisterordner.
INTEGRATIONSORDNER = os.path.dirname(os.path.abspath(__file__))
_STAGING_ORDNER = os.path.join(
os.path.dirname(INTEGRATIONSORDNER), "audi_dashboard_update_staging"
)
_BACKUP_ORDNER = os.path.join(os.path.dirname(INTEGRATIONSORDNER), "audi_dashboard_backup")
class AktualisierungsFehler(Exception):
"""Jeder Fehlerfall beim Prüfen oder Installieren - Netzwerk, falscher
Token, kaputtes Zip, falsche Domain, fehlgeschlagener Tausch. Eine
eigene Klasse statt roher Exceptions, damit der Dienstaufruf sie gezielt
abfangen und der Oberfläche eine verständliche Meldung zeigen kann,
statt eines unbehandelten Fehlers."""
def _header(token: str) -> dict[str, str]:
return {"Authorization": f"token {token}"}
async def version_pruefen(hass: HomeAssistant, token: str, eigene_version: str) -> dict[str, Any]:
"""Fragt nur die manifest.json von Gitea ab (ein kleiner Request, kein
Repo-Download) und vergleicht die Version gegen die installierte.
Läuft ausschließlich auf Tastendruck aus der Oberfläche - niemals aus
dem Koordinator-Takt heraus, sonst würde jeder normale Veröffentlichungs-
Zyklus einen Netzwerkaufruf zu Gitea auslösen."""
if not token:
raise AktualisierungsFehler(
"Kein Gitea-Token hinterlegt - unter Einstellungen -> Geräte & Dienste -> "
"Audi Dashboard -> Konfigurieren eintragen."
)
url = f"{_GITEA_BASIS}/repos/{_REPO_BESITZER}/{_REPO_NAME}/contents/{_MANIFEST_PFAD_IM_REPO}"
session = async_get_clientsession(hass)
try:
async with session.get(url, headers=_header(token), params={"ref": _ZWEIG}) as antwort:
if antwort.status == 401:
raise AktualisierungsFehler("Token ungültig oder abgelaufen.")
if antwort.status == 404:
raise AktualisierungsFehler("Repository oder Pfad bei Gitea nicht gefunden.")
if antwort.status != 200:
raise AktualisierungsFehler(f"Gitea antwortete mit Status {antwort.status}.")
rumpf = await antwort.json()
except AktualisierungsFehler:
raise
except Exception as fehler: # aiohttp-/Verbindungsfehler aller Art
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
try:
roh = base64.b64decode(rumpf["content"])
remote_version = json.loads(roh)["version"]
except (KeyError, ValueError, TypeError) as fehler:
raise AktualisierungsFehler(f"Antwort von Gitea nicht lesbar: {fehler}") from fehler
return {
"verfuegbar": remote_version != eigene_version,
"version": remote_version,
"geprueft_am": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
async def update_installieren(hass: HomeAssistant, token: str) -> dict[str, Any]:
"""Lädt das komplette Repo-Archiv und delegiert Entpacken/Prüfen/Tauschen
an eine blockierende Funktion im Executor - Datei- und Zip-Operationen
gehören nicht in den Event-Loop."""
if not token:
raise AktualisierungsFehler(
"Kein Gitea-Token hinterlegt - unter Einstellungen -> Geräte & Dienste -> "
"Audi Dashboard -> Konfigurieren eintragen."
)
url = f"{_GITEA_BASIS}/repos/{_REPO_BESITZER}/{_REPO_NAME}/archive/{_ZWEIG}.zip"
session = async_get_clientsession(hass)
try:
async with session.get(url, headers=_header(token)) as antwort:
if antwort.status == 401:
raise AktualisierungsFehler("Token ungültig oder abgelaufen.")
if antwort.status != 200:
raise AktualisierungsFehler(f"Gitea antwortete mit Status {antwort.status}.")
zip_bytes = await antwort.read()
except AktualisierungsFehler:
raise
except Exception as fehler:
raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler
neue_version = await hass.async_add_executor_job(
entpacken_pruefen_tauschen, zip_bytes, INTEGRATIONSORDNER, _STAGING_ORDNER, _BACKUP_ORDNER
)
return {"version": neue_version}
def entpacken_pruefen_tauschen(
zip_bytes: bytes, integrationsordner: str, staging_ordner: str, backup_ordner: str
) -> str:
"""Blockierend: entpackt nur custom_components/audi_dashboard/ aus dem
Archiv in staging_ordner, prüft das dortige manifest.json, und tauscht
per os.rename mit integrationsordner. Wirft AktualisierungsFehler bei
jedem Problem - und zwar VOR jedem Tausch, siehe Moduldocstring.
Bewusst ohne hass-Abhängigkeit und mit allen Pfaden als Parameter statt
fester Modul-Konstanten, damit sie sich isoliert mit einem selbstgebauten
Zip gegen einen Temp-Ordner testen lässt, ohne den echten
Integrationsordner anzufassen."""
if os.path.isdir(staging_ordner):
shutil.rmtree(staging_ordner)
try:
with zipfile.ZipFile(BytesIO(zip_bytes)) as archiv:
namen = archiv.namelist()
if not namen:
raise AktualisierungsFehler("Leeres Archiv erhalten.")
# Gitea packt alles unter einem Wurzelordner wie "audi-app-main/"
# - das wird hier gesucht statt geraten.
treffer = [
n for n in namen if _INTEGRATIONS_PFAD_IM_REPO in n and not n.endswith("/")
]
if not treffer:
raise AktualisierungsFehler(
f"{_INTEGRATIONS_PFAD_IM_REPO} nicht im heruntergeladenen Archiv gefunden."
)
wurzel_marker = treffer[0].split(_INTEGRATIONS_PFAD_IM_REPO, 1)[0] \
+ _INTEGRATIONS_PFAD_IM_REPO
os.makedirs(staging_ordner, exist_ok=True)
for name in treffer:
if wurzel_marker not in name:
continue
rel = name.split(wurzel_marker, 1)[1]
if not rel:
continue
ziel = os.path.join(staging_ordner, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(ziel), exist_ok=True)
with archiv.open(name) as quelle, open(ziel, "wb") as senke:
shutil.copyfileobj(quelle, senke)
except zipfile.BadZipFile as fehler:
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler(f"Kein gültiges Zip-Archiv: {fehler}") from fehler
manifest_pfad = os.path.join(staging_ordner, "manifest.json")
if not os.path.exists(manifest_pfad):
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler("Entpackter Ordner enthält keine manifest.json.")
with open(manifest_pfad, encoding="utf-8") as datei:
manifest = json.load(datei)
if manifest.get("domain") != "audi_dashboard":
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler(
f"Heruntergeladenes Manifest gehört zu Domain '{manifest.get('domain')}', "
"nicht 'audi_dashboard' - Abbruch, nichts wurde angefasst."
)
neue_version = manifest.get("version")
if not neue_version:
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler("Heruntergeladenes Manifest hat kein version-Feld.")
# __pycache__ aus dem Staging-Ordner entfernen - dieselbe Sorgfalt wie
# install.ps1s Aufräumschritt nach dem Kopieren.
for wurzel, ordner, _dateien in os.walk(staging_ordner):
if "__pycache__" in ordner:
shutil.rmtree(os.path.join(wurzel, "__pycache__"), ignore_errors=True)
if os.path.isdir(backup_ordner):
shutil.rmtree(backup_ordner)
try:
os.rename(integrationsordner, backup_ordner)
except OSError as fehler:
shutil.rmtree(staging_ordner, ignore_errors=True)
raise AktualisierungsFehler(
f"Konnte den bestehenden Ordner nicht sichern: {fehler}"
) from fehler
try:
os.rename(staging_ordner, integrationsordner)
except OSError as fehler:
# Rollback: die alte Fassung zurückbenennen, statt HA ohne
# Integrationsordner dastehen zu lassen.
os.rename(backup_ordner, integrationsordner)
raise AktualisierungsFehler(
f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}"
) from fehler
return neue_version
+4 -3
View File
@@ -14,9 +14,10 @@ bewusst nicht: jede Fotogröße muss hochladbar sein.
WARUM DIESE DATEIEN NICHT IM INTEGRATIONSORDNER LIEGEN: sie sind Nutzerdaten,
kein Auslieferbestandteil. Lägen sie unter custom_components/audi_dashboard/,
würde das nächste HACS-Update sie mitlöschen. /config/www/bilder/ gehört
dagegen dem Nutzer und wird von keinem Update angefasst; ausgeliefert wird es
weiterhin unter /local/bilder/.
würde der nächste install.ps1-Lauf sie mitlöschen (er leert den Zielordner
vor dem Kopieren, siehe install.ps1). /config/www/bilder/ gehört dagegen dem
Nutzer und wird von keinem Update angefasst; ausgeliefert wird es weiterhin
unter /local/bilder/.
"""
from __future__ import annotations
@@ -1,25 +1,34 @@
"""Einrichtung über die Oberfläche (Einstellungen -> Geräte & Dienste ->
Integration hinzufügen -> Audi Dashboard).
Bewusst ohne Eingabefelder. Es gäbe genau eine Sache zu fragen - welche
Entität welche Rolle im Fahrzeug spielt -, und die gehört nicht hierher: die
Zuordnung wird im Setup-Menü der App selbst vorgenommen, wo neben jedem Feld
steht, wofür es gebraucht wird und was passiert, wenn es leer bleibt. Sie
hier abzufragen hieße, dieselbe Auswahl an zwei Stellen zu pflegen - und die
schlechtere von beiden zuerst zu zeigen.
Bewusst ohne Eingabefelder im Ersteinrichtungs-Schritt. Es gäbe genau eine
Sache zu fragen - welche Entität welche Rolle im Fahrzeug spielt -, und die
gehört nicht hierher: die Zuordnung wird im Setup-Menü der App selbst
vorgenommen, wo neben jedem Feld steht, wofür es gebraucht wird und was
passiert, wenn es leer bleibt. Sie hier abzufragen hieße, dieselbe Auswahl an
zwei Stellen zu pflegen - und die schlechtere von beiden zuerst zu zeigen.
Eine einzige Instanz, erzwungen über single_config_entry in manifest.json:
die App gehört zu genau einem Fahrzeug, und alle Bestände liegen unter einem
festen Pfad.
Der OptionsFlow unten ist die einzige Ausnahme: ein Gitea-Zugriffstoken für
das Selbst-Update (siehe aktualisierung.py) - ein echtes Geheimnis, das
deshalb in HAs eigenem, verschlüsseltem Config-Entry-Speicher landet
(entry.options), nicht in configuration.yaml.
"""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
import voluptuous as vol
from .const import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import callback
from homeassistant.helpers import selector
from .const import CONF_GITEA_TOKEN, DOMAIN
class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -35,3 +44,35 @@ class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="Audi Dashboard", data={})
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
return AudiDashboardOptionsFlow()
class AudiDashboardOptionsFlow(OptionsFlow):
"""Einstellungen -> Geräte & Dienste -> Audi Dashboard -> Konfigurieren.
Ein Feld: der Gitea-Token fürs Selbst-Update. Leer lassen deaktiviert
die Update-Prüfung wieder (aktualisierung.py verlangt einen nicht-leeren
Token, sonst gibt es eine verständliche Fehlermeldung statt eines
unbehandelten Fehlers)."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({
vol.Optional(CONF_GITEA_TOKEN, default=""): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
}),
self.config_entry.options,
),
)
+14 -6
View File
@@ -32,22 +32,22 @@ PANEL_KOMPONENTE = "audi-dashboard-panel"
# Statischer Pfad, unter dem die Oberflächen-Dateien aus frontend/
# ausgeliefert werden. Ersetzt /local/ (also /config/www/): die Dateien
# gehören jetzt zum Installationspaket der Integration und werden von HACS
# mitgeliefert, statt separat nach www/ kopiert zu werden.
# gehören jetzt zum Installationspaket der Integration und werden von
# install.ps1 mitgeliefert, statt separat nach www/ kopiert zu werden.
STATIK_URL = "/audi_dashboard_static"
STATIK_ORDNER = "frontend"
# Das OTA-Bündel der iOS-App: dieselbe Oberfläche, als Zip zum Nachladen.
# Liegt bewusst mit im Integrationsordner - dann liefert HACS es bei jedem
# Update mit aus, und Bündel und Integration können gar nicht auseinander-
# laufen (siehe companion-app/scripts/ota-paket.ps1).
# Liegt bewusst mit im Integrationsordner - dann liefert install.ps1 es bei
# jeder Auslieferung mit aus, und Bündel und Integration können gar nicht
# auseinanderlaufen (siehe companion-app/scripts/ota-paket.ps1).
BUENDEL_ORDNER = "frontend/app"
BUENDEL_URL = f"{STATIK_URL}/app/bundle.zip"
BUENDEL_INFO = "bundle.json"
# Vom Nutzer hochgeladene Fahrzeugfotos. Die bleiben in /config/www/bilder,
# also unter /local/bilder/: sie sind NUTZERDATEN, kein Auslieferbestandteil.
# Lägen sie im Integrationsordner, würde das nächste HACS-Update sie
# Lägen sie im Integrationsordner, würde der nächste install.ps1-Lauf sie
# mitlöschen - genau das darf nicht passieren.
BILDER_ORDNER = "www/bilder"
@@ -89,3 +89,11 @@ D_REIFEN_KM_SETZEN = "reifen_km_setzen"
D_BACKUP_JETZT = "backup_jetzt"
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
D_HISTORIE_IMPORTIEREN = "historie_importieren"
D_UPDATE_PRUEFEN = "update_pruefen"
D_UPDATE_INSTALLIEREN = "update_installieren"
# Schlüssel für den Gitea-Zugriffstoken in entry.options (siehe
# config_flow.py's AudiDashboardOptionsFlow und aktualisierung.py). Hier statt
# in config_flow.py definiert, damit aktualisierung.py/dienste.py ihn ohne
# Umweg über den Options-Flow importieren können.
CONF_GITEA_TOKEN = "gitea_token"
+74 -1
View File
@@ -15,7 +15,9 @@ nicht die Schema-Prüfung.
from __future__ import annotations
import datetime
import json
import logging
from collections.abc import Callable, Coroutine
from typing import Any
@@ -24,9 +26,19 @@ import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from . import belege, bilder, fahrterkennung, historienimport, reifen, screening, sicherung
from . import (
aktualisierung,
belege,
bilder,
fahrterkennung,
historienimport,
reifen,
screening,
sicherung,
)
from .const import (
BILDER_ORDNER,
CONF_GITEA_TOKEN,
D_BACKUP_JETZT,
D_BACKUP_WIEDERHERSTELLEN,
D_BELEG_HOCHLADEN,
@@ -45,10 +57,14 @@ from .const import (
D_TANKVORGANG_AKTUALISIEREN,
D_TANKVORGANG_LOESCHEN,
D_TANKVORGANG_MANUELL,
D_UPDATE_INSTALLIEREN,
D_UPDATE_PRUEFEN,
DOMAIN,
)
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
# Ein JSON-Feld darf als Zeichenkette oder schon ausgepackt ankommen: das
# Panel schickt JSON.stringify(...), ein Aufruf aus einer Automation heraus
# reicht dagegen naheliegenderweise die Struktur selbst durch.
@@ -126,6 +142,8 @@ SCHEMATA: dict[str, vol.Schema] = {
vol.Required("start"): cv.string,
vol.Required("ende"): cv.string,
}),
D_UPDATE_PRUEFEN: vol.Schema({}),
D_UPDATE_INSTALLIEREN: vol.Schema({}),
}
Fabrik = Callable[[ServiceCall], Coroutine[Any, Any, None]]
@@ -202,6 +220,59 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
"audi_dashboard_historienimport",
)
async def update_pruefen(_aufruf: ServiceCall) -> None:
"""Fragt bei Gitea nach, ob eine neuere Fassung vorliegt - ohne
etwas herunterzuladen. Läuft nur auf Tastendruck, siehe
aktualisierung.py's Moduldocstring."""
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
try:
ergebnis = await aktualisierung.version_pruefen(hass, token, k.version)
k.letzte_update_pruefung = {
"verfuegbar": ergebnis["verfuegbar"],
"version": ergebnis["version"],
"geprueft_am": ergebnis["geprueft_am"],
"installiert": False,
"fehler": None,
}
except aktualisierung.AktualisierungsFehler as fehler:
_LOGGER.warning("Update-Prüfung fehlgeschlagen: %s", fehler)
k.letzte_update_pruefung = {
"verfuegbar": False,
"version": None,
"geprueft_am": jetzt,
"installiert": False,
"fehler": str(fehler),
}
await k.app_version_veroeffentlichen()
async def update_installieren(_aufruf: ServiceCall) -> None:
"""Lädt die neueste Fassung von Gitea und ersetzt den
Integrationsordner. Home Assistant muss danach von Hand neu
gestartet werden - kein automatischer Reload aus dem eigenen,
noch laufenden Aufruf heraus, siehe aktualisierung.py."""
token = k.entry.options.get(CONF_GITEA_TOKEN, "")
jetzt = datetime.datetime.now(datetime.timezone.utc).isoformat()
try:
ergebnis = await aktualisierung.update_installieren(hass, token)
k.letzte_update_pruefung = {
"verfuegbar": False,
"version": ergebnis["version"],
"geprueft_am": jetzt,
"installiert": True,
"fehler": None,
}
except aktualisierung.AktualisierungsFehler as fehler:
_LOGGER.warning("Update-Installation fehlgeschlagen: %s", fehler)
k.letzte_update_pruefung = {
"verfuegbar": (k.letzte_update_pruefung or {}).get("verfuegbar", False),
"version": (k.letzte_update_pruefung or {}).get("version"),
"geprueft_am": jetzt,
"installiert": False,
"fehler": str(fehler),
}
await k.app_version_veroeffentlichen()
bilder_ordner = hass.config.path(BILDER_ORDNER)
behandler: dict[str, Callable[[ServiceCall], Coroutine[Any, Any, None]]] = {
@@ -245,6 +316,8 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
D_BACKUP_WIEDERHERSTELLEN: _als_dienst(
lambda a: sicherung.wiederherstellen(k, dict(a.data))
),
D_UPDATE_PRUEFEN: update_pruefen,
D_UPDATE_INSTALLIEREN: update_installieren,
}
for name, funktion in behandler.items():
@@ -1 +1 @@
{"version":"2026.8.24.1","sha256":"967ab36fc866187cf5fdc03d95a3ec21cb6e42003d8c012f8f67b99c67373324","bytes":230770,"gebaut":"2026-08-24T07:16:44Z"}
{"version":"2026.8.24.4","sha256":"cbce1baa785a3577fc65168b9f8b2094ecd844aadc2ac36f99d6be8ccb4cb8c1","bytes":231567,"gebaut":"2026-08-24T09:39:59Z"}
@@ -1262,6 +1262,42 @@ function servicePrognose(stichwort, intervallKm, intervallMonate) {
return { km: zielKm, restKm, datum };
}
/* Fahrleistung pro Tag aus dem tatsächlichen Fahrtenlog (TRIPS), unabhängig
vom Servicebuch - Grundlage für meldungsPrognose() unten, die auch ganz
ohne Servicebucheintrag funktionieren soll. Nur abgeschlossene Fahrten mit
bekannter Distanz zählen; die Rate ist der Durchschnitt seit der ältesten
erfassten Fahrt, kein kurzes Zeitfenster - damit bleibt sie robust gegen
einzelne fahrtenarme Tage. null ohne belastbare Datenlage. */
function kmProTagAusFahrten() {
const gueltig = TRIPS.filter((t) => t.status !== "offen" && t.distance_km != null);
if (!gueltig.length) return null;
const km = gueltig.reduce((s, t) => s + t.distance_km, 0);
const aeltesteZeit = Math.min(...gueltig.map((t) => new Date(t.ts_start).getTime()));
const tage = (heute().getTime() - aeltesteZeit) / dTag;
return tage >= 1 ? km / tage : null;
}
/* Prognose rein aus der Fahrzeugmeldung, für den Fall ganz ohne
Servicebucheintrag (siehe Aufrufer in vService()) - ein echter
Servicebucheintrag bleibt vorrangig, näher am tatsächlichen letzten
Service. Restkilometer aus der Meldung, hochgerechnet mit der echten
Fahrleistung aus dem Fahrtenlog statt einer Servicebuch-Rate; gedeckelt
auf das vom Fahrzeug selbst gemeldete Datum als Zeitgrenze - fährt der
Nutzer zu wenig, um die km-Grenze rechtzeitig zu erreichen, ist die
gemeldete Zeitgrenze maßgeblich, nicht die hochgerechnete km-Grenze. */
function meldungsPrognose(art) {
const fm = fahrzeugMeldungRoh(art);
const zeitDatum = fm.ts && gueltigesDatum(new Date(fm.ts)) ? new Date(fm.ts) : null;
if (fm.km == null) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
const restKm = Math.abs(fm.km);
if (restKm <= 0) return { datum: heute(), durchZeitlimit: false };
const rate = kmProTagAusFahrten();
if (!rate) return zeitDatum ? { datum: zeitDatum, durchZeitlimit: true } : null;
const kmDatum = new Date(heute().getTime() + (restKm / rate) * dTag);
const durchZeitlimit = !!(zeitDatum && zeitDatum < kmDatum);
return { datum: durchZeitlimit ? zeitDatum : kmDatum, durchZeitlimit };
}
function oelwechselPrognose() {
const oel = CAR.oel;
return servicePrognose("öl",
@@ -1776,21 +1812,35 @@ function vService() {
const artIcon = ART_ICON[t.art];
const iconMarkup = artIcon ? ciSVG(artIcon, ART_ICON_GROESSE[t.art]) : "";
const kopf = `<div class="tg-name">${iconMarkup}<span>${t.art}</span></div>`;
// Fahrzeugmeldung zuerst prüfen: sie steht für sich, auch ganz ohne
// Servicebucheintrag - siehe fahrzeugMeldungRoh()'s Kommentar. Ein
// frisch eingerichtetes Fahrzeug mit zugeordnetem Sensor und
// "Herstellervorgabe", aber ohne einen einzigen Servicebucheintrag,
// zeigte vorher trotz korrekt gemeldeter Fälligkeit nur "kein Eintrag
// im Servicebuch" - iv war null und der frühe Return griff, bevor fm
// überhaupt gelesen wurde.
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
const hatMeldung = fm.km != null || fmDatum;
const iv = intervalle(t);
if (!iv) return `<div class="termingruppe">
if (!iv && !hatMeldung) return `<div class="termingruppe">
<div class="tg-kopf">${kopf}</div>
<div class="tg-wert leer">kein Eintrag im Servicebuch</div>
</div>`;
const anzeige = iv.hersteller || iv.eigen;
const fm = fahrzeugMeldungRoh(t.art);
const fmDatum = fm.ts ? dedat(new Date(fm.ts)) : null;
const anzeige = iv ? (iv.hersteller || iv.eigen) : null;
// Forecast-Zeile aus dem kundeneigenen Intervall in "Einrichten" mit
// eigenständiger Reset-nach-Service-Logik (servicePrognose()): beim
// Ölwechsel mit Restkilometern, bei der Inspektion auf Wunsch nur mit
// dem Datum. Hauptuntersuchung bleibt bewusst ohne.
// dem Datum. Hauptuntersuchung bleibt bewusst ohne. Beide liefern null
// ohne Servicebucheintrag (servicePrognose() braucht letzterEintrag) -
// dann bleibt nur die Fahrzeugmeldung als Wert übrig, siehe wert unten.
const eigenOel = t.art === "Ölwechsel" ? oelwechselPrognose() : null;
const eigenInsp = t.art === "Inspektion" ? inspektionPrognose() : null;
const hatMeldung = fm.km != null || fmDatum;
// Ganz ohne Servicebucheintrag (eigenOel/eigenInsp beide null) übernimmt
// die Fahrzeugmeldung selbst die Rolle der Prognosequelle, hochgerechnet
// mit der echten Fahrleistung aus dem Fahrtenlog statt einer
// Servicebuch-Rate - siehe meldungsPrognose().
const meldungProg = hatMeldung && !eigenOel && !eigenInsp ? meldungsPrognose(t.art) : null;
// Bei Herstellervorgabe rechnet die eigene Prognose ohnehin schon mit dem
// Herstellerintervall - die Restkilometerzahl daneben waere nur eine
// zweite, verwirrende km-Angabe neben dem Fahrzeug-Wert oben. Nur beim
@@ -1799,7 +1849,8 @@ function vService() {
? (CAR.oel.modus === "hersteller"
? `voraussichtlich am ${dedat(eigenOel.datum)}`
: `${de(eigenOel.restKm)} km, voraussichtlich am ${dedat(eigenOel.datum)}`)
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}` : "";
: eigenInsp ? `voraussichtlich am ${dedat(eigenInsp.datum)}`
: meldungProg ? `voraussichtlich am ${dedat(meldungProg.datum)}` : "";
const wert = hatMeldung
? `${fm.km != null ? de(fm.km) + " km" : ""}${fm.km != null && fmDatum ? " / " : ""}${fmDatum || ""}`
: (eigenOel ? de(eigenOel.restKm) + " km · " + dedat(eigenOel.datum) : (anzeige.km !== null ? de(anzeige.km) + " km · " : "") + dedat(anzeige.datum));
@@ -1808,7 +1859,7 @@ function vService() {
// Intervall gar nicht. Deshalb zaehlt sie unabhaengig von iv.hersteller
// als Herstellervorgabe; nur der App-eigene Eigen-Intervall-Fallback
// (anzeige.eigen/eigenOel bei modus "eigen") ist es nicht.
const herstellervorgabe = hatMeldung || iv.hersteller;
const herstellervorgabe = hatMeldung || (iv && iv.hersteller);
return `<div class="termingruppe">
<div class="tg-kopf">
${kopf}
@@ -2331,11 +2382,37 @@ function vEinst() {
<div class="row"><dt>Fahrzeugdaten</dt><dd>FMM003<small>Zündung · Batterie</small></dd></div>
<div class="row" style="border-bottom:0"><dt>Position</dt><dd>FMM003<small>GPS-Tracker</small></dd></div>
</dl>
<span class="label" style="margin-top:14px">Updates laufen über HACS</span>
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
HACS meldet selbst, wenn eine neuere Fassung bereitliegt, und installiert sie.
Fahrzeugprofil, Fahrten, Tankvorgänge und eigene Fotos bleiben dabei unangetastet
die liegen außerhalb des Integrationsordners.</span>
</div>
<div class="tile"><span class="label">Integration-Update</span>
${(() => {
const u = INTEGRATION_UPDATE;
// Ersetzt install.ps1 als laufenden Update-Weg (Windows Smart App
// Control blockiert dessen Ausführung zuverlässig) - install.ps1
// bleibt nur noch für die Erstinstallation nötig, bevor die
// Integration überhaupt läuft und sich selbst aktualisieren kann.
if (u && u.installiert) {
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Version ${esc(u.version)} installiert. Home Assistant neu starten, damit die neue
Fassung geladen wird - Einstellungen -> System -> Neu starten.</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Erneut prüfen</button>`;
}
if (u && u.verfuegbar) {
return `<dl class="rows"><div class="row" style="border-bottom:0"><dt>Verfügbare Version</dt><dd>${esc(u.version)}</dd></div></dl>
<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant
muss danach neu gestartet werden. Fahrzeugprofil, Fahrten, Tankvorgänge und eigene
Fotos bleiben dabei unangetastet - die liegen außerhalb des Integrationsordners.</span>
<button class="aktion" style="margin-top:14px" data-update="installieren">Update installieren</button>`;
}
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13.5px;line-height:1.6">
${u ? `Integration ist aktuell${u.geprueft_am ? ` (geprüft ${dedat(new Date(u.geprueft_am))})` : ""}.` : "Noch nicht geprüft."}
Ein Gitea-Zugriffstoken muss zuvor unter Einstellungen -> Geräte & Dienste -> Audi
Dashboard -> Konfigurieren eingetragen sein.</span>
<button class="aktion" style="margin-top:14px" data-update="pruefen">Auf Update prüfen</button>`;
})()}
${INTEGRATION_UPDATE && INTEGRATION_UPDATE.fehler
? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(INTEGRATION_UPDATE.fehler)}</span>`
: ""}
</div>`;
}
@@ -3022,18 +3099,25 @@ let entwurfIndex = null;
const SERVICE_ARTEN = ["Ölwechsel", "Inspektion", "Hauptuntersuchung", "Weitere"];
/* Welche Fassung der Integration gerade installiert ist (Einstellungen ->
Version). Kommt aus sensor.audi_dashboard_app_version, also aus der
manifest.json der Integration - der Zahl, die auch HACS anzeigt.
Version). Kommt aus sensor.audi_dashboard_app_version, also direkt aus der
manifest.json der Integration.
Hier stand bis 2026-08-23 eine eigene Update-Funktion: "Update suchen"
klonte ein Git-Repository in einen Zwischenordner und kopierte pyscript/
und www/ von dort über den laufenden Code. Das war nötig, solange die App
keine Installationsform hatte, die Home Assistant selbst kennt. Als
HACS-Integration gibt es die: HACS prüft, lädt, installiert und kann
zurückrollen, mit Versionsanzeige und Änderungsprotokoll. Eine zweite,
selbstgebaute Update-Mechanik daneben wäre mehr Angriffsfläche als
Nutzen - der eigene Updater ist deshalb ersatzlos entfallen. */
keine Installationsform hatte, die Home Assistant selbst kennt - jetzt
gibt es eine: install.ps1 kopiert genau einen Ordner, das Panel darin
inklusive, und ist damit der einzige Installations- wie Update-Weg (HACS
scheidet für dieses private Repository dauerhaft aus, siehe AGENTS.md
Abschnitt H). Eine zweite, selbstgebaute Update-Mechanik daneben wäre
mehr Angriffsfläche als Nutzen - der eigene Updater ist deshalb
ersatzlos entfallen. */
let APP_VERSION = null;
// Ergebnis der letzten "Auf Update prüfen"/"Update installieren"
// (custom_components/audi_dashboard/aktualisierung.py) - null, solange nie
// geprüft wurde. Ersetzt install.ps1 als Update-Weg, das Windows Smart App
// Control zuverlässig blockiert; siehe vVersion()/Einstellungen-Ansicht.
let INTEGRATION_UPDATE = null;
/* Auswahl für "Modell" unter Einrichten - setzt nur CONFIG.fahrzeugtitel
(Zeile "Modell" in der Identität, Kopfzeilen, Kalender-Einträge, und den
@@ -4392,6 +4476,9 @@ function ereignisseVerdrahten() {
a.download = `backup_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); a.remove();
return;
}
const upd = e.target.closest("[data-update]");
if (upd && upd.dataset.update === "pruefen") { serviceRufen("update_pruefen", {}); return; }
if (upd && upd.dataset.update === "installieren") { serviceRufen("update_installieren", {}); return; }
const cs = e.target.closest("[data-csv]");
if (cs) {
const w = cs.dataset.csv;
@@ -4755,6 +4842,11 @@ async function datenLaden(erzwingen) {
const entitaetenState = HASS.states[E.entitaeten] || NACHGELADEN.entitaeten;
const versionState = HASS.states[E.appVersion];
if (versionState && versionState.state) APP_VERSION = versionState.state;
if (versionState && versionState.attributes) {
INTEGRATION_UPDATE = versionState.attributes.daten
? versionState.attributes.daten.integration_update ?? null
: null;
}
if (entitaetenState && entitaetenState.attributes) ENTITAETEN = entitaetenState.attributes.daten;
if (belegState && belegState !== letzterBelegState) {
@@ -99,6 +99,13 @@ class Koordinator:
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] = []
@@ -329,13 +336,25 @@ class Koordinator:
return None
return {**info, "url": BUENDEL_URL}
async def alles_veroeffentlichen(self) -> None:
async def app_version_veroeffentlichen(self) -> None:
buendel = await self.hass.async_add_executor_job(self._buendel_lesen)
# Die App liest beides aus derselben Entität: welchen Stand diese
# Installation ausliefert, und ob ein nachladbares Bündel danebenliegt.
# Zusammen an einer Stelle, damit die App nicht zwei Quellen abgleichen
# muss, die auseinanderlaufen können.
self.setzen(E_APP_VERSION, self.version, {"app": self.version, "buendel": buendel})
# 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()
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.24.1",
"version": "2026.8.24.4",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
@@ -10,6 +10,8 @@
jetzt_aktualisieren:
screening_jetzt:
backup_jetzt:
update_pruefen:
update_installieren:
profil_schreiben:
fields:
@@ -10,6 +10,17 @@
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"options": {
"step": {
"init": {
"title": "Selbst-Update",
"description": "Zugriffstoken für gitea.nothaft.cloud, damit die Integration ihre eigene neueste Fassung laden und sich selbst ersetzen kann - ersetzt install.ps1, das Windows Smart App Control zuverlässig blockiert. Ein Token mit reinem Lesezugriff auf das Repository genügt. Leer lassen deaktiviert die Update-Prüfung.",
"data": {
"gitea_token": "Gitea-Zugriffstoken"
}
}
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
@@ -169,6 +180,14 @@
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
},
"update_pruefen": {
"name": "Auf Update prüfen",
"description": "Fragt bei Gitea die neueste manifest.json ab und vergleicht die Version - lädt dabei noch nichts herunter."
},
"update_installieren": {
"name": "Update installieren",
"description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden."
}
}
}
@@ -10,6 +10,17 @@
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
}
},
"options": {
"step": {
"init": {
"title": "Selbst-Update",
"description": "Zugriffstoken für gitea.nothaft.cloud, damit die Integration ihre eigene neueste Fassung laden und sich selbst ersetzen kann - ersetzt install.ps1, das Windows Smart App Control zuverlässig blockiert. Ein Token mit reinem Lesezugriff auf das Repository genügt. Leer lassen deaktiviert die Update-Prüfung.",
"data": {
"gitea_token": "Gitea-Zugriffstoken"
}
}
}
},
"services": {
"jetzt_aktualisieren": {
"name": "Jetzt aktualisieren",
@@ -169,6 +180,14 @@
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
}
},
"update_pruefen": {
"name": "Auf Update prüfen",
"description": "Fragt bei Gitea die neueste manifest.json ab und vergleicht die Version - lädt dabei noch nichts herunter."
},
"update_installieren": {
"name": "Update installieren",
"description": "Lädt die neueste Fassung von Gitea und ersetzt den Integrationsordner. Home Assistant muss danach neu gestartet werden."
}
}
}