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
+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.