fix(update): Lampe bekommt genau die geprüften Bytes

Die Registry für Einmal-URLs speicherte bisher nur einen Pfad; View und
verify() lasen die Datei getrennt und zu unterschiedlichen Zeitpunkten
erneut ein. Wurde die Datei zwischen Prüfung und Abruf ersetzt (z. B.
gleicher Dateiname in build/ und build-nocreds/), erreichte ein nie
geprüftes Image die Lampe und hebelte damit die WLAN-Sicherung aus.

Jetzt werden die Bytes einmal im Install-Pfad gelesen, Gerätetyp/Version
und WLAN-SSID auf genau diesen Bytes geprüft (FirmwareCatalog.verify_and_read,
firmware.contains_ssid) und exakt diese Bytes in der Registry hinterlegt;
die View serviert nur noch das, was tatsächlich geprüft wurde.

Außerdem: Bewertungen (Katalog-Rescan-Signal) werden nicht mehr angestoßen,
während ein Update läuft - das würde eine gerade flashende Lampe zusätzlich
anfragen und Angebot/Issue unter dem laufenden Update verändern; nach jedem
Update wird stattdessen genau einmal neu bewertet. Kommentar zum Install-Lock
korrigiert (der eigentliche Lücke liegt vor dem Setzen der Flags, nicht in
HAs eigener "bereits läuft"-Prüfung). Testlücke geschlossen: die View liefert
jetzt nachweislich nur die zu ihrem Schlüssel gehörenden Bytes. Timeout in
einem WLAN-Regressionstest verkürzt, damit er bei einem Rückfall nicht
12 Minuten hängt.
This commit is contained in:
Michael Sauer
2026-09-16 17:40:10 +02:00
parent 06dd04e8fe
commit b8f3c94278
4 changed files with 122 additions and 42 deletions
+15 -15
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
import secrets
from typing import Any
@@ -17,24 +16,29 @@ from .const import CONF_LAMP_BASE_URL, DOMAIN, FIRMWARE_URL_PREFIX
class OtaRegistry:
"""Random keys, each bound to exactly one file, valid only while its update runs."""
"""Random keys, each bound to exactly the verified bytes of one image, valid only while its
update runs.
Bytes rather than a path: the file on disk could be replaced between verification and
download (spec §7 guard would otherwise apply to a file the lamp never actually receives).
"""
def __init__(self) -> None:
self._paths: dict[str, Path] = {}
self._images: dict[str, bytes] = {}
def create(self, path: Path) -> str:
def create(self, data: bytes) -> str:
key = secrets.token_urlsafe(32)
self._paths[key] = path
self._images[key] = data
return key
def revoke(self, key: str) -> None:
self._paths.pop(key, None)
self._images.pop(key, None)
def get(self, key: str) -> Path | None:
return self._paths.get(key)
def get(self, key: str) -> bytes | None:
return self._images.get(key)
def __len__(self) -> int:
return len(self._paths)
return len(self._images)
class NinlightFirmwareView(HomeAssistantView):
@@ -49,12 +53,8 @@ class NinlightFirmwareView(HomeAssistantView):
self._registry = registry
async def get(self, request: web.Request, key: str) -> web.Response:
path = self._registry.get(key)
if path is None:
return web.Response(status=404)
try:
body = await self._hass.async_add_executor_job(path.read_bytes)
except OSError:
body = self._registry.get(key)
if body is None:
return web.Response(status=404)
return web.Response(body=body, content_type="application/octet-stream")