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
+76 -2
View File
@@ -226,6 +226,74 @@ async def test_install_serves_image_once_and_waits_for_new_version(
assert init_integration.runtime_data.ota_in_progress is False
async def test_install_serves_exactly_the_verified_bytes(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
init_integration,
mock_client: MagicMock,
lamp_info: dict[str, Any],
firmware_dir: Path,
) -> None:
"""F1: the lamp must receive exactly the bytes checked (Wi-Fi guard, type, hash) at install
time, even if the file on disk is replaced by something else afterwards - e.g. a build script
that writes the no-credentials variant over the same filename mid-update.
"""
path = put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
verified_bytes = path.read_bytes()
await rescan(hass)
client = await hass_client_no_auth()
served: dict[str, Any] = {}
async def lamp_downloads(url: str) -> None:
# Swap in a different, otherwise-valid image for the same device type right before the
# lamp "fetches" it. The Wi-Fi guard already passed for `verified_bytes`; this replacement
# has no credentials at all and must never reach the lamp.
path.write_bytes(build_image(version=NEWER, content=CONTENT_WITHOUT_SSID))
served["url"] = url
response = await client.get(urlsplit(url).path)
served["status"] = response.status
served["body"] = await response.read()
lamp_info["fw_version"] = NEWER
lamp_info["uptime"] = 4
mock_client.fw_update.side_effect = lamp_downloads
await install(hass)
await hass.async_block_till_done()
assert served["status"] == 200
assert served["body"] == verified_bytes
assert served["body"] != path.read_bytes() # the swapped-in file, not what was served
async def test_rescan_during_install_does_not_trigger_evaluation(
hass: HomeAssistant,
init_integration,
mock_client: MagicMock,
lamp_info: dict[str, Any],
firmware_dir: Path,
) -> None:
"""M2: a catalog rescan landing while an install is in progress must not evaluate offers
against the (currently flashing) lamp - that would mean extra requests to it and could churn
the offer/issue underneath the install.
"""
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
calls_before_rescan: dict[str, int] = {}
async def lamp_flashes(url: str) -> None:
# a periodic catalog scan happens to land while this install is in progress
calls_before_rescan["count"] = mock_client.get_settings.await_count
await rescan(hass)
lamp_info["fw_version"] = NEWER
lamp_info["uptime"] = 4
mock_client.fw_update.side_effect = lamp_flashes
await install(hass)
await hass.async_block_till_done()
assert mock_client.get_settings.await_count == calls_before_rescan["count"]
@pytest.mark.parametrize(
("lamp_reaction", "key", "reason_part"),
[
@@ -304,7 +372,12 @@ async def test_install_rechecks_wifi_right_before_sending(
mock_client: MagicMock,
firmware_dir: Path,
lamp_settings: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Guards against a regression that would let this install slip past the re-check into the
# wait loop: without this it would hang for the full OTA_TIMEOUT_S (12 min) instead of failing
# fast with "wifi_guard".
monkeypatch.setattr(update_module, "OTA_TIMEOUT_S", 0)
lamp_settings.clear()
lamp_settings.update(SETTINGS_STORED)
put(firmware_dir, "nocreds.img", version=NEWEST, content=CONTENT_WITHOUT_SSID)
@@ -373,8 +446,9 @@ async def test_install_rejects_concurrent_calls(
already True (it is a `cached_property` that HA invalidates whenever `_attr_in_progress` is
set, so it is never stale) - by the time that guard would fire, a second concurrent call is
already blocked by HA itself with translation_key "update_in_progress", not "update_busy".
The real gap I2 describes is *before* that: `verify`, `get_info` and `get_settings` are all
awaited before we ever set the flags, and HA's guard does not protect that window. This test
The real gap I2 describes is *before* that: image verification, `get_info` and `get_settings`
are all awaited before we ever set the flags, and HA's guard does not protect that window. This
test
blocks the first `get_info()` re-check inside that window to reach it deterministically.
"""
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)