Files
ha-ninlight/tests/test_update.py
T
Michael Sauer ac60428fdb feat: sACN-Einstellungen und -Status als Entitaeten
Die Firmware kann seit dieser Woche sACN/E1.31. Einstellen liess sich das nur
per REST - jetzt steht es in Home Assistant: Schalter fuer den Empfang, Zahlen
fuer Universe und Startslot, Auswahlen fuer das Verhalten nach dem Timeout und
die Slotbelegung, dazu zwei Diagnose-Binaersensoren.

Die sACN-Einstellungen liegen hinter system/settings, nicht in device/state -
der Koordinator liest sie deshalb jetzt mit, eine dritte Anfrage je Zyklus. Der
Schreibweg liest die Einstellungen danach frisch zurueck statt den gesetzten
Wert anzunehmen: die Lampe validiert selbst (die Startslot-Grenze haengt an der
Slotbelegung), und eine Entitaet, die das Gewuenschte statt des Gespeicherten
zeigt, luegt in genau dem Moment, in dem es darauf ankommt.

Aeltere Firmware antwortet ohne den sacn-Block. Die Entitaeten entstehen
trotzdem und melden sich als nicht verfuegbar - so werden sie nach einem Update
der Lampe beim naechsten Poll von selbst lebendig, ohne Neuladen.

Die Obergrenze des Startslots kommt aus dem "slots"-Feld der Lampe, nicht aus
einer festen 512: sie haengt an Board UND Slotbelegung, und eine feste Grenze
wuerde Werte anbieten, die die Lampe dann ablehnt.

Der Multicast-Sensor ist ab Werk deaktiviert wie die uebrigen Diagnosewerte. Er
meldet im Normalbetrieb schlicht "beigetreten" - aber eine falsche
Gruppenadresse hat zwei Abende gekostet, und genau dort waere sie aufgefallen.

test_update.py misst seine Zusage jetzt um den Rescan herum statt nach dem
Install: dort pollt der Koordinator ohnehin wieder und liest seitdem auch die
Einstellungen mit.
2026-09-21 23:35:54 +02:00

514 lines
20 KiB
Python

"""Firmware update entity: offer, Wi-Fi guard, install flow."""
from __future__ import annotations
import asyncio
from copy import deepcopy
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from urllib.parse import urlsplit
from homeassistant.components.update import (
ATTR_IN_PROGRESS,
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
SERVICE_INSTALL,
)
from homeassistant.components.update import (
DOMAIN as UPDATE_DOMAIN,
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_TOKEN, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.core_config import async_process_ha_core_config
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
from custom_components.ninlight import update as update_module
from custom_components.ninlight.api import NinlightBusyError, NinlightConnectionError
from custom_components.ninlight.const import CONF_LAMP_BASE_URL, DOMAIN
from custom_components.ninlight.shared import DATA_SHARED, async_scan_catalog
from custom_components.ninlight.update import depends_on_compiled_wifi
from tests.helpers import entity_id_for
from tests_unit.images import CONTENT_WITH_SSID, CONTENT_WITHOUT_SSID, build_image
from tests_unit.samples import HOST_RGB, MAC_RGB, SETTINGS_COMPILED, SETTINGS_STORED, TOKEN
BASE = "http://192.168.1.50:8123"
INSTALLED = "0.3.8-36-gca9bb"
NEWER = "0.3.8-47-g71a57"
NEWEST = "0.3.9-rc1"
EVEN_NEWER = "0.3.9-rc2" # newer than NEWEST
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
return MockConfigEntry(
domain=DOMAIN,
title="Klyqa GU10 RGB CW/WW 06b8",
unique_id=MAC_RGB,
data={CONF_HOST: HOST_RGB, CONF_TOKEN: TOKEN},
options={CONF_LAMP_BASE_URL: BASE},
)
@pytest.fixture(autouse=True)
def fast_polling(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(update_module, "OTA_POLL_INTERVAL_S", 0)
def put(directory: Path, name: str, **kwargs: Any) -> Path:
path = directory / name
path.write_bytes(build_image(**kwargs))
return path
async def rescan(hass: HomeAssistant) -> None:
await async_scan_catalog(hass, hass.data[DATA_SHARED])
await hass.async_block_till_done()
def wifi_issue(hass: HomeAssistant, entry: MockConfigEntry) -> ir.IssueEntry | None:
return ir.async_get(hass).async_get_issue(DOMAIN, f"wifi_guard_{entry.entry_id}")
async def install(hass: HomeAssistant) -> None:
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id_for(hass, "update", "firmware")},
blocking=True,
)
def test_only_stored_wifi_is_independent() -> None:
assert not depends_on_compiled_wifi(SETTINGS_STORED)
assert depends_on_compiled_wifi(SETTINGS_COMPILED)
assert depends_on_compiled_wifi({"wifi": {"ssid": ""}})
assert depends_on_compiled_wifi({"wifi": {"source": "flash"}})
assert depends_on_compiled_wifi({})
async def test_no_images_no_update(hass: HomeAssistant, init_integration) -> None:
state = hass.states.get(entity_id_for(hass, "update", "firmware"))
assert state.state == STATE_OFF
assert state.attributes[ATTR_INSTALLED_VERSION] == INSTALLED
assert state.attributes[ATTR_LATEST_VERSION] == INSTALLED
async def test_newer_image_with_credentials_is_offered(
hass: HomeAssistant, init_integration, firmware_dir: Path
) -> None:
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
state = hass.states.get(entity_id_for(hass, "update", "firmware"))
assert state.state == STATE_ON
assert state.attributes[ATTR_LATEST_VERSION] == NEWER
assert wifi_issue(hass, init_integration) is None
@pytest.mark.parametrize(
"image",
[
{
"device_type": "gu10_cw",
"version": "0.4.0",
}, # other type that only differs in the suffix
{"device_type": "gu10_rgb", "version": "0.3.8-36-gffff0"}, # same version, other hash
{"device_type": "gu10_rgb", "version": "0.3.8"}, # older
],
)
async def test_unsuitable_images_are_not_offered(
hass: HomeAssistant, init_integration, firmware_dir: Path, image: dict[str, str]
) -> None:
put(firmware_dir, "a.img", content=CONTENT_WITH_SSID, **image)
await rescan(hass)
assert hass.states.get(entity_id_for(hass, "update", "firmware")).state == STATE_OFF
async def test_wifi_guard_blocks_image_without_credentials(
hass: HomeAssistant, init_integration, firmware_dir: Path
) -> None:
nocreds = put(firmware_dir, "nocreds.img", version=NEWEST, content=CONTENT_WITHOUT_SSID)
await rescan(hass)
entity_id = entity_id_for(hass, "update", "firmware")
assert hass.states.get(entity_id).state == STATE_OFF
issue = wifi_issue(hass, init_integration)
assert issue is not None
assert issue.translation_placeholders == {
"name": "Klyqa GU10 RGB CW/WW 06b8",
"version": NEWEST,
"device_type": "gu10_rgb",
"ssid": "ninIOT",
}
put(firmware_dir, "creds.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
assert hass.states.get(entity_id).attributes[ATTR_LATEST_VERSION] == NEWER
assert wifi_issue(hass, init_integration) is not None # the newer, blocked one is still there
nocreds.unlink()
await rescan(hass)
assert hass.states.get(entity_id).attributes[ATTR_LATEST_VERSION] == NEWER
assert wifi_issue(hass, init_integration) is None
async def test_lamp_with_stored_wifi_gets_image_without_credentials(
hass: HomeAssistant, init_integration, firmware_dir: Path, lamp_settings: dict[str, Any]
) -> None:
lamp_settings.clear()
lamp_settings.update(SETTINGS_STORED)
put(firmware_dir, "nocreds.img", version=NEWEST, content=CONTENT_WITHOUT_SSID)
await rescan(hass)
assert (
hass.states.get(entity_id_for(hass, "update", "firmware")).attributes[ATTR_LATEST_VERSION]
== NEWEST
)
assert wifi_issue(hass, init_integration) is None
async def test_lamp_without_ssid_gets_nothing(
hass: HomeAssistant, init_integration, firmware_dir: Path, lamp_info: dict[str, Any]
) -> None:
lamp_info["wifi"]["ssid"] = ""
await init_integration.runtime_data.async_refresh()
put(firmware_dir, "creds.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
assert hass.states.get(entity_id_for(hass, "update", "firmware")).state == STATE_OFF
async def test_unreadable_settings_keep_previous_offer(
hass: HomeAssistant, init_integration, firmware_dir: Path, mock_client: MagicMock
) -> None:
mock_client.get_settings.side_effect = NinlightConnectionError("down")
put(firmware_dir, "creds.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
assert hass.states.get(entity_id_for(hass, "update", "firmware")).state == STATE_OFF
async def test_install_serves_image_once_and_waits_for_new_version(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
init_integration,
mock_client: MagicMock,
lamp_info: dict[str, Any],
firmware_dir: Path,
) -> None:
path = put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
client = await hass_client_no_auth()
served: dict[str, Any] = {}
async def lamp_downloads(url: str) -> None:
served["url"] = url
response = await client.get(urlsplit(url).path)
served["status"] = response.status
served["body"] = await response.read()
served["ota_flag"] = init_integration.runtime_data.ota_in_progress
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["url"].startswith(f"{BASE}/api/ninlight/firmware/")
assert (served["status"], served["body"]) == (200, path.read_bytes())
assert served["ota_flag"] is True
assert (await client.get(urlsplit(served["url"]).path)).status == 404
assert len(hass.data[DATA_SHARED].registry) == 0
state = hass.states.get(entity_id_for(hass, "update", "firmware"))
assert state.attributes[ATTR_INSTALLED_VERSION] == NEWER
assert state.attributes[ATTR_IN_PROGRESS] is False
assert state.state == STATE_OFF
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
before = mock_client.get_settings.await_count
await rescan(hass)
# Measured around the rescan itself, not after the install: once the install is
# done the coordinator polls again anyway, and since the sACN settings became
# entities that poll reads system/settings too. Comparing after the fact would
# count that poll and fail for a reason this test does not care about.
calls_before_rescan["delta"] = mock_client.get_settings.await_count - before
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 calls_before_rescan["delta"] == 0
@pytest.mark.parametrize(
("lamp_reaction", "key", "reason_part"),
[
({"last_ota_error": "hash"}, "update_failed", "hash"),
({"uptime": 3}, "update_failed", INSTALLED), # rebooted, still the old firmware
],
)
async def test_install_reports_lamp_failures(
hass: HomeAssistant,
init_integration,
mock_client: MagicMock,
lamp_info: dict[str, Any],
firmware_dir: Path,
lamp_reaction: dict[str, Any],
key: str,
reason_part: str,
) -> None:
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
async def lamp_fails(url: str) -> None:
lamp_info.update(lamp_reaction)
mock_client.fw_update.side_effect = lamp_fails
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == key
assert reason_part in raised.value.translation_placeholders["reason"]
assert len(hass.data[DATA_SHARED].registry) == 0
assert init_integration.runtime_data.ota_in_progress is False
async def test_install_busy_lamp(
hass: HomeAssistant, init_integration, mock_client: MagicMock, firmware_dir: Path
) -> None:
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
mock_client.fw_update.side_effect = NinlightBusyError(409, "an update is already running")
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == "update_busy"
assert len(hass.data[DATA_SHARED].registry) == 0
async def test_install_times_out(
hass: HomeAssistant, init_integration, firmware_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(update_module, "OTA_TIMEOUT_S", 0)
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == "update_timeout"
async def test_install_needs_http_address(
hass: HomeAssistant, mock_client: MagicMock, firmware_dir: Path
) -> None:
await async_process_ha_core_config(hass, {"internal_url": "https://ha.local:8123"})
entry = MockConfigEntry(
domain=DOMAIN, unique_id=MAC_RGB, data={CONF_HOST: HOST_RGB, CONF_TOKEN: TOKEN}
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == "no_http_url"
mock_client.fw_update.assert_not_awaited()
async def test_install_rechecks_wifi_right_before_sending(
hass: HomeAssistant,
init_integration,
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)
await rescan(hass)
assert hass.states.get(entity_id_for(hass, "update", "firmware")).state == STATE_ON
lamp_settings["wifi"]["source"] = "compiled" # the lamp forgot its stored Wi-Fi meanwhile
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == "wifi_guard"
assert raised.value.translation_placeholders == {"ssid": "ninIOT"}
mock_client.fw_update.assert_not_awaited()
async def test_install_rejects_changed_file(
hass: HomeAssistant, init_integration, mock_client: MagicMock, firmware_dir: Path
) -> None:
path = put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
path.write_bytes(build_image(version=NEWEST, content=CONTENT_WITH_SSID))
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == "image_invalid"
mock_client.fw_update.assert_not_awaited()
async def test_install_rejects_downgrade(
hass: HomeAssistant,
init_integration,
mock_client: MagicMock,
lamp_info: dict[str, Any],
firmware_dir: Path,
) -> None:
"""The lamp may have been reflashed by another route since the offer was computed."""
put(firmware_dir, "a.img", version=NEWER, content=CONTENT_WITH_SSID)
await rescan(hass)
entity_id = entity_id_for(hass, "update", "firmware")
assert hass.states.get(entity_id).attributes[ATTR_LATEST_VERSION] == NEWER
# A newer image also appears on disk, and the lamp is reflashed by another route to a version
# newer than our stale offer - but the follow-up evaluation (triggered by the catalog scan)
# fails at get_settings and returns early, so the stale NEWER offer survives untouched.
put(firmware_dir, "b.img", version=EVEN_NEWER, content=CONTENT_WITH_SSID)
mock_client.get_settings.side_effect = NinlightConnectionError("down")
lamp_info["fw_version"] = NEWEST
await rescan(hass)
assert hass.states.get(entity_id).attributes[ATTR_LATEST_VERSION] == NEWER
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == "no_update"
mock_client.fw_update.assert_not_awaited()
async def test_install_rejects_concurrent_calls(
hass: HomeAssistant,
init_integration,
mock_client: MagicMock,
lamp_info: dict[str, Any],
firmware_dir: Path,
) -> None:
"""A second call arriving in the pre-flag window (before in_progress is set) must be
rejected by our own lock rather than allowed to touch OTA state.
HA's own service-level "already in progress" guard only helps once `_attr_in_progress` is
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: 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)
await rescan(hass)
entity_id = entity_id_for(hass, "update", "firmware")
entered = asyncio.Event()
release = asyncio.Event()
first_call = True
async def blocking_get_info() -> dict[str, Any]:
nonlocal first_call
if first_call:
first_call = False
entered.set()
await release.wait()
return deepcopy(lamp_info)
async def lamp_flashes(url: str) -> None:
lamp_info["fw_version"] = NEWER
lamp_info["uptime"] = 4
mock_client.get_info.side_effect = blocking_get_info
mock_client.fw_update.side_effect = lamp_flashes
task1 = hass.async_create_task(install(hass), name="install #1")
async with asyncio.timeout(5):
await entered.wait()
# Install #1 is blocked inside its pre-flag get_info() re-check: the lock is held, but
# in_progress/ota_in_progress are still untouched.
assert init_integration.runtime_data.ota_in_progress is False
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is False
with pytest.raises(HomeAssistantError) as raised:
await install(hass)
assert raised.value.translation_key == "update_busy"
assert init_integration.runtime_data.ota_in_progress is False
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is False
release.set()
async with asyncio.timeout(5):
await task1
assert init_integration.runtime_data.ota_in_progress is False
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is False
assert hass.states.get(entity_id).attributes[ATTR_INSTALLED_VERSION] == NEWER
async def test_removing_the_entry_clears_the_wifi_issue(
hass: HomeAssistant, init_integration, firmware_dir: Path
) -> None:
put(firmware_dir, "nocreds.img", version=NEWEST, content=CONTENT_WITHOUT_SSID)
await rescan(hass)
assert wifi_issue(hass, init_integration) is not None
assert await hass.config_entries.async_remove(init_integration.entry_id)
await hass.async_block_till_done()
assert wifi_issue(hass, init_integration) is None