Update ohne Neustart, wenn nur Oberflaechendateien wechseln
Bisher verlangte jedes Update einen Home-Assistant-Neustart. Noetig ist er aber nur fuer Python-Code: HA importiert die Module einmal beim Start und haengt danach mit lebenden Objekten daran. frontend/ dagegen wird direkt von der Platte ausgeliefert (StaticPathConfig, cache_headers=False) - eine ersetzte .js ist sofort wirksam, es braucht nur ein Neuladen im Browser. aktualisierung.neustart_noetig() vergleicht die alte gegen die neue Fassung ueber sha256 je Datei und meldet "kein Neustart" nur, wenn JEDE Abweichung unter frontend/ liegt oder die manifest.json ist. Alles andere - .py, services.yaml, translations/, vorlage/ - gilt als neustartpflichtig, auch wo es das im Einzelfall nicht waere. Die Schieflage ist Absicht: ein faelschlich ausgelassener Neustart laesst neuen Python-Code nie anlaufen, und der Fehler wird woanders gesucht. manifest.json ist ausgenommen, weil sich seine Versionsnummer bei jeder Veroeffentlichung aendert - sonst waere die Unterscheidung wertlos. Weil HA das Manifest fuer die Laufzeit festhaelt (loader.py: hass.data[ DATA_INTEGRATIONS]), liest version_von_platte() die Nummer direkt von der Platte; koordinator.version_neu_lesen() zieht sie nach einem Update ohne Neustart nach, damit Panel und App die neue Fassung auch anzeigen. Panel und App zeigen im Neustart-freien Fall "Seite neu laden" statt "Installation abschliessen - Jetzt neu starten". Neun neue Testfaelle fuer die Unterscheidung, 23 Tests in der Aktualisierungs-Suite gruen. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user