diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 390e2ab..9bb8d6d 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -216,9 +216,18 @@ export interface IntegrationUpdateAngabe { verfuegbar: boolean; version: string | null; geprueft_am: string; - /** true unmittelbar nach einer erfolgreichen Installation - dann ist ein - Neustart von Home Assistant nötig, damit die neue Fassung geladen wird. */ + /** true unmittelbar nach einer erfolgreichen Installation. */ installiert: boolean; + /** Ob nach der Installation wirklich ein Home-Assistant-Neustart nötig ist. + + Der Updater entscheidet das am Datei-Vergleich: betrifft das Update nur + `frontend/`, reicht ein Neuladen der Seite - die Oberflächendateien + werden direkt von der Platte ausgeliefert. Nur geänderter Python-Code + braucht einen Neustart, weil Home Assistant seine Module einmal beim + Start importiert. + + Fehlt das Feld (ältere Integration), gilt "Neustart nötig". */ + neustart_noetig?: boolean; fehler: string | null; } diff --git a/companion-app/src/screens/Einstellungen.tsx b/companion-app/src/screens/Einstellungen.tsx index e1b398f..4e86764 100644 --- a/companion-app/src/screens/Einstellungen.tsx +++ b/companion-app/src/screens/Einstellungen.tsx @@ -796,6 +796,21 @@ export function Einstellungen({ Home Assistant startet neu … + ) : integrationUpdate?.installiert && integrationUpdate.neustart_noetig === false ? ( + /* Nur Oberflächendateien geändert - die liegen als statische Dateien + auf der Platte und werden ohne lange Cache-Vorgabe ausgeliefert. + Ein Neuladen genügt, Home Assistant muss nicht neu starten. */ + <> +
+ Version {integrationUpdate.version} installiert - nur Oberflächendateien, kein + Neustart nötig. +
+diff --git a/custom_components/audi_dashboard/aktualisierung.py b/custom_components/audi_dashboard/aktualisierung.py index 246f06a..c4c927a 100644 --- a/custom_components/audi_dashboard/aktualisierung.py +++ b/custom_components/audi_dashboard/aktualisierung.py @@ -42,6 +42,7 @@ from __future__ import annotations import base64 import datetime +import hashlib import json import logging import os @@ -178,6 +179,112 @@ async def version_pruefen(hass: HomeAssistant, token: str, eigene_version: str) } +# Dateien, deren Aenderung KEINEN Neustart braucht. +# +# frontend/ wird direkt von der Platte ausgeliefert (StaticPathConfig mit +# cache_headers=False, siehe __init__.py) - eine ersetzte .js oder .css ist +# sofort wirksam, es braucht nur ein Neuladen im Browser. Das gilt auch fuer +# frontend/app/, das OTA-Buendel der Companion-App. +# +# manifest.json steht dabei, weil sich seine Versionsnummer bei JEDER +# Veroeffentlichung aendert - ohne diese Ausnahme waere jedes Update ein +# Neustart-Update, und die Unterscheidung waere wertlos. Home Assistant haelt +# das Manifest fuer die Laufzeit fest (loader.py: hass.data[DATA_INTEGRATIONS]), +# die von HA selbst angezeigte Versionsnummer bleibt also bis zum naechsten +# Neustart die alte. Das ist kosmetisch; unsere eigene Anzeige liest die +# Version direkt von der Platte (siehe __init__.py). +OHNE_NEUSTART = ("frontend/",) +OHNE_NEUSTART_DATEIEN = ("manifest.json",) + + +def version_von_platte() -> str | None: + """Die Versionsnummer aus der manifest.json neben diesem Modul. + + Home Assistant haelt das Manifest fuer die Laufzeit fest (loader.py: + `cache = hass.data[DATA_INTEGRATIONS]`) - nach einem Update meldet + `async_get_integration()` also weiter die alte Nummer, selbst wenn der + Config-Eintrag neu geladen wird. Fuer ein Update ohne Neustart braucht es + deshalb eine Quelle, die wirklich von der Platte liest. + + `None`, wenn die Datei fehlt oder unlesbar ist - dann bleibt der Aufrufer + bei dem Wert, den er schon hat, statt eine erfundene Nummer zu zeigen.""" + pfad = os.path.join(INTEGRATIONSORDNER, "manifest.json") + try: + with open(pfad, encoding="utf-8") as datei: + return json.load(datei).get("version") or None + except (OSError, ValueError) as fehler: + _LOGGER.warning("manifest.json nicht lesbar (%s)", fehler) + return None + + +def _dateihashes(ordner: str) -> dict[str, str]: + """Alle Dateien unter `ordner` als {relativer Pfad: sha256}. + + __pycache__ bleibt aussen vor: es entsteht beim Laufen und sagt nichts + ueber die ausgelieferte Fassung.""" + aus: dict[str, str] = {} + for wurzel, ordnerliste, dateien in os.walk(ordner): + ordnerliste[:] = [o for o in ordnerliste if o != "__pycache__"] + for name in dateien: + pfad = os.path.join(wurzel, name) + rel = os.path.relpath(pfad, ordner).replace(os.sep, "/") + hasher = hashlib.sha256() + with open(pfad, "rb") as datei: + for block in iter(lambda: datei.read(65536), b""): + hasher.update(block) + aus[rel] = hasher.hexdigest() + return aus + + +def neustart_noetig(alter_ordner: str, neuer_ordner: str) -> bool: + """Ob die neue Fassung einen Home-Assistant-Neustart braucht. + + Bewusst pessimistisch: gemeldet wird `False` nur, wenn JEDE geaenderte, + hinzugekommene oder entfallene Datei unter frontend/ liegt oder die + manifest.json ist. Alles andere - .py, services.yaml, translations/, + vorlage/ - gilt als neustartpflichtig, auch wenn es das im Einzelfall + vielleicht nicht waere. + + Der Grund fuer die Schieflage: sagt diese Funktion faelschlich "kein + Neustart noetig", laeuft neuer Python-Code nie an, und der Fehler wird an + einer ganz anderen Stelle gesucht. Ein ueberfluessiger Neustart kostet + dagegen eine Minute. Im Zweifel also Neustart. + + Laesst sich der alte Ordner nicht lesen, gilt ebenfalls Neustart.""" + try: + alt = _dateihashes(alter_ordner) + neu = _dateihashes(neuer_ordner) + except OSError as fehler: + _LOGGER.warning( + "Konnte alte und neue Fassung nicht vergleichen (%s) - Neustart angenommen.", + fehler, + ) + return True + + geaendert = { + pfad + for pfad in set(alt) | set(neu) + if alt.get(pfad) != neu.get(pfad) + } + ohne_neustart = { + pfad + for pfad in geaendert + if pfad.startswith(OHNE_NEUSTART) or pfad in OHNE_NEUSTART_DATEIEN + } + rest = sorted(geaendert - ohne_neustart) + if rest: + _LOGGER.info( + "Update braucht einen Neustart - geaendert ausserhalb von frontend/: %s", + ", ".join(rest[:8]) + (" …" if len(rest) > 8 else ""), + ) + return True + _LOGGER.info( + "Update betrifft nur die Oberflaeche (%s Datei(en)) - kein Neustart noetig.", + len(geaendert), + ) + return False + + 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 @@ -202,10 +309,10 @@ async def update_installieren(hass: HomeAssistant, token: str) -> dict[str, Any] except Exception as fehler: raise AktualisierungsFehler(f"Gitea nicht erreichbar: {fehler}") from fehler - neue_version = await hass.async_add_executor_job( + ergebnis = await hass.async_add_executor_job( entpacken_pruefen_tauschen, zip_bytes, INTEGRATIONSORDNER, _STAGING_ORDNER, _BACKUP_ORDNER ) - return {"version": neue_version} + return ergebnis def entpacken_pruefen_tauschen( @@ -277,6 +384,10 @@ def entpacken_pruefen_tauschen( if "__pycache__" in ordner: shutil.rmtree(os.path.join(wurzel, "__pycache__"), ignore_errors=True) + # Vergleich VOR dem Tausch - danach gibt es die alte Fassung unter diesem + # Pfad nicht mehr. + neustart = neustart_noetig(integrationsordner, staging_ordner) + if os.path.isdir(backup_ordner): shutil.rmtree(backup_ordner) try: @@ -315,4 +426,4 @@ def entpacken_pruefen_tauschen( f"Tausch fehlgeschlagen, alte Fassung wiederhergestellt: {fehler}" ) from fehler - return neue_version + return {"version": neue_version, "neustart_noetig": neustart} diff --git a/custom_components/audi_dashboard/dienste.py b/custom_components/audi_dashboard/dienste.py index 318790a..9ef482f 100644 --- a/custom_components/audi_dashboard/dienste.py +++ b/custom_components/audi_dashboard/dienste.py @@ -344,23 +344,41 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None: 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.""" + """Laedt die neueste Fassung von Gitea und ersetzt den + Integrationsordner. + + Ob danach ein Neustart noetig ist, entscheidet der Updater am + Datei-Vergleich (aktualisierung.neustart_noetig): betrifft das Update + nur frontend/, reicht ein Neuladen der Seite - die Oberflaechen-Dateien + werden direkt von der Platte ausgeliefert (StaticPathConfig mit + cache_headers=False). Nur geaenderter Python-Code braucht wirklich + einen Neustart, weil Home Assistant seine Module einmal beim Start + importiert und danach mit lebenden Objekten daran haengt. + + 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) + neustart = bool(ergebnis.get("neustart_noetig", True)) _LOGGER.info( - "Update installiert: Version %s - Home Assistant muss neu gestartet werden.", + "Update installiert: Version %s - %s", ergebnis["version"], + "Home Assistant muss neu gestartet werden." + if neustart + else "nur Oberflaechen-Dateien, ein Neuladen der Seite genuegt.", ) + if not neustart: + # Ohne Neustart bleibt Home Assistants zwischengespeichertes + # Manifest stehen - die neue Nummer muss von der Platte kommen. + await k.version_neu_lesen() k.letzte_update_pruefung = { "verfuegbar": False, "version": ergebnis["version"], "geprueft_am": jetzt, "installiert": True, + "neustart_noetig": neustart, "fehler": None, } except aktualisierung.AktualisierungsFehler as fehler: @@ -370,6 +388,7 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None: "version": (k.letzte_update_pruefung or {}).get("version"), "geprueft_am": jetzt, "installiert": False, + "neustart_noetig": True, "fehler": str(fehler), } await k.app_version_veroeffentlichen() diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 203b9c4..45c8b2b 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.5.22","sha256":"a83dcedbf835def7fedebdb55e136ec5cf6cd1e046bbe02b41a21477a4801547","bytes":324556,"gebaut":"2026-09-05T10:46:04Z"} \ No newline at end of file +{"version":"2026.9.5.23","sha256":"3ff0de25e28b5cf00a9015ec3af3e27f0e4d0ae8a13eb9e443dd47100ebc9d1e","bytes":324637,"gebaut":"2026-09-05T11:29:36Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index fec58a8..055f99c 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 4b4109a..2f98398 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -3743,6 +3743,18 @@ function vEinst() { if (laeuft === "installieren") return spinner("Installiere …"); if (laeuft === "neustart") return spinner("Home Assistant startet neu …"); if (u && u.installiert) { + /* Ob ein Neustart noetig ist, entscheidet der Updater am + Datei-Vergleich (aktualisierung.neustart_noetig). Betrifft das + Update nur frontend/, reicht ein Neuladen der Seite: die Dateien + werden direkt von der Platte ausgeliefert, und zwar mit + cache_headers=False - der Browser holt sie beim naechsten Laden + ohnehin frisch. Nur geaenderter Python-Code braucht wirklich einen + Neustart (Abschnitt CZ in AGENTS.md). */ + if (u.neustart_noetig === false) { + return ` + Version ${esc(u.version)} installiert - nur Oberflächendateien, kein Neustart nötig. + `; + } return ` Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart geladen. @@ -6693,6 +6705,8 @@ function ereignisseVerdrahten() { ); }) .finally(() => { INTEGRATION_UPDATE_LAEUFT = null; render(); scrollWiederherstellen(); }); + } else if (aktion === "neuladen") { + location.reload(); } else if (aktion === "neustart") { INTEGRATION_UPDATE_LAEUFT = "neustart"; render(); HASS.callService("homeassistant", "restart", {}).catch((err) => { diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index 754b352..b536d14 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -48,6 +48,7 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.storage import Store from . import ( + aktualisierung, batterie, belege, bilder, @@ -681,6 +682,19 @@ class Koordinator: return None return {**info, "url": BUENDEL_URL} + async def version_neu_lesen(self) -> None: + """Liest die Versionsnummer erneut von der Platte. + + Nach einem Update OHNE Neustart hat sich die manifest.json geaendert, + Home Assistants zwischengespeicherte Fassung aber nicht. Ohne diesen + Schritt zeigten Panel und App nach einem reinen Oberflaechen-Update + weiter die alte Nummer - und der Nutzer haette keinen Anhalt, ob das + Update wirklich angekommen ist.""" + neu = await self.hass.async_add_executor_job(aktualisierung.version_von_platte) + if neu and neu != self.version: + _LOGGER.info("Version nach Update: %s -> %s", self.version, neu) + self.version = neu + async def app_version_veroeffentlichen(self) -> None: buendel = await self.hass.async_add_executor_job(self._buendel_lesen) bildstaende = await bilder.staende( diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index a4e01f7..9a4a1c1 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.5.22", + "version": "2026.9.5.23", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [ diff --git a/tests/aktualisierung/test_aktualisierung.py b/tests/aktualisierung/test_aktualisierung.py index 209c115..1ce93aa 100644 --- a/tests/aktualisierung/test_aktualisierung.py +++ b/tests/aktualisierung/test_aktualisierung.py @@ -69,10 +69,12 @@ class ErfolgreicherTausch(unittest.TestCase): "neue_datei.py": b"# neue Fassung\n", "frontend/app.js": b"// panel\n", }) - version = a.entpacken_pruefen_tauschen( + ergebnis = a.entpacken_pruefen_tauschen( zip_bytes, self.integration, self.staging, self.backup ) - self.assertEqual(version, "2099.1.1.1") + self.assertEqual(ergebnis["version"], "2099.1.1.1") + # Das Testarchiv enthaelt .py-Dateien, also neustartpflichtig. + self.assertTrue(ergebnis["neustart_noetig"]) # Live-Ordner trägt jetzt die neue Fassung. with open(os.path.join(self.integration, "manifest.json"), "rb") as f: @@ -188,6 +190,73 @@ class AbgelehnteFaelle(unittest.TestCase): self._unangetastet() +class NeustartNoetig(unittest.TestCase): + """Die Unterscheidung "braucht das einen Neustart?" ist bewusst schief. + + Sagt sie faelschlich "nein", laeuft neuer Python-Code nie an und der Fehler + wird woanders gesucht. Ein ueberfluessiger Neustart kostet eine Minute. + Deshalb: nur frontend/ und manifest.json duerfen ohne Neustart wechseln. + """ + + def setUp(self): + self.basis = tempfile.mkdtemp() + self.alt = os.path.join(self.basis, "alt") + self.neu = os.path.join(self.basis, "neu") + for ordner in (self.alt, self.neu): + os.makedirs(os.path.join(ordner, "frontend")) + self._schreiben(ordner, "manifest.json", '{"version": "1"}') + self._schreiben(ordner, "__init__.py", "x = 1\n") + self._schreiben(ordner, "frontend/app.js", "alert(1)\n") + + def tearDown(self): + shutil.rmtree(self.basis, ignore_errors=True) + + def _schreiben(self, ordner, rel, inhalt): + pfad = os.path.join(ordner, *rel.split("/")) + os.makedirs(os.path.dirname(pfad), exist_ok=True) + with open(pfad, "w", encoding="utf-8") as datei: + datei.write(inhalt) + + def test_nichts_geaendert_braucht_keinen_neustart(self): + self.assertFalse(a.neustart_noetig(self.alt, self.neu)) + + def test_nur_frontend_braucht_keinen_neustart(self): + self._schreiben(self.neu, "frontend/app.js", "alert(2)\n") + self._schreiben(self.neu, "frontend/neu.css", "body{}\n") + self.assertFalse(a.neustart_noetig(self.alt, self.neu)) + + def test_manifest_allein_braucht_keinen_neustart(self): + # Die Versionsnummer aendert sich bei JEDER Veroeffentlichung - ohne + # diese Ausnahme waere jedes Update ein Neustart-Update. + self._schreiben(self.neu, "manifest.json", '{"version": "2"}') + self._schreiben(self.neu, "frontend/app.js", "alert(2)\n") + self.assertFalse(a.neustart_noetig(self.alt, self.neu)) + + def test_geaenderte_python_datei_braucht_neustart(self): + self._schreiben(self.neu, "__init__.py", "x = 2\n") + self.assertTrue(a.neustart_noetig(self.alt, self.neu)) + + def test_neue_python_datei_braucht_neustart(self): + self._schreiben(self.neu, "neu.py", "y = 1\n") + self.assertTrue(a.neustart_noetig(self.alt, self.neu)) + + def test_entfallene_python_datei_braucht_neustart(self): + os.remove(os.path.join(self.neu, "__init__.py")) + self.assertTrue(a.neustart_noetig(self.alt, self.neu)) + + def test_services_yaml_braucht_neustart(self): + # Nicht weil es zwingend waere, sondern weil die Regel pessimistisch ist. + self._schreiben(self.neu, "services.yaml", "dienst:\n") + self.assertTrue(a.neustart_noetig(self.alt, self.neu)) + + def test_pycache_zaehlt_nicht(self): + self._schreiben(self.neu, "__pycache__/x.cpython-312.pyc", "egal") + self.assertFalse(a.neustart_noetig(self.alt, self.neu)) + + def test_unlesbarer_ordner_gilt_als_neustart(self): + self.assertTrue(a.neustart_noetig(os.path.join(self.basis, "gibtsnicht"), self.neu)) + + class VersionsOrdnung(unittest.TestCase): """Nur eine NACHWEISLICH neuere Fassung ist ein Update.