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 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 13:30:25 +02:00
parent 4dad3d21a8
commit 81808dbc10
10 changed files with 265 additions and 14 deletions
+11 -2
View File
@@ -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;
}
@@ -796,6 +796,21 @@ export function Einstellungen({
Home Assistant startet neu
</span>
</div>
) : 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. */
<>
<p className="dm-erklaerung">
Version {integrationUpdate.version} installiert - nur Oberflächendateien, kein
Neustart nötig.
</p>
<div className="dm-knopfreihe">
<ActionButton variant="primary" onClick={() => window.location.reload()}>
App neu laden
</ActionButton>
</div>
</>
) : integrationUpdate?.installiert ? (
<>
<p className="dm-erklaerung">
@@ -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}
+24 -5
View File
@@ -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()
@@ -1 +1 @@
{"version":"2026.9.5.22","sha256":"a83dcedbf835def7fedebdb55e136ec5cf6cd1e046bbe02b41a21477a4801547","bytes":324556,"gebaut":"2026-09-05T10:46:04Z"}
{"version":"2026.9.5.23","sha256":"3ff0de25e28b5cf00a9015ec3af3e27f0e4d0ae8a13eb9e443dd47100ebc9d1e","bytes":324637,"gebaut":"2026-09-05T11:29:36Z"}
@@ -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 `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
Version ${esc(u.version)} installiert - nur Oberflächendateien, kein Neustart nötig.</span>
<button class="aktion primaer" style="margin-top:14px" data-update="neuladen">Seite neu laden</button>`;
}
return `<span style="display:block;margin-top:8px;color:var(--fg2);font-size:13px;line-height:1.6">
Version ${esc(u.version)} installiert - die neue Fassung wird erst nach einem Neustart
geladen.</span>
@@ -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) => {
@@ -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(
@@ -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": [
+71 -2
View File
@@ -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.