Ö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:
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user