puddly d888c70ff0
Fix entity names for HA hardware firmware update entities (#142029)
* Fix entity names for HA hardware firmware update entities

* Fix unit tests
2025-04-02 08:29:23 +00:00

645 lines
22 KiB
Python

"""Test Home Assistant Hardware firmware update entity."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
import dataclasses
import logging
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
import pytest
from yarl import URL
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
from homeassistant.components.homeassistant_hardware.helpers import (
async_notify_firmware_info,
async_register_firmware_info_provider,
)
from homeassistant.components.homeassistant_hardware.update import (
BaseFirmwareUpdateEntity,
FirmwareUpdateEntityDescription,
FirmwareUpdateExtraStoredData,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
OwningIntegration,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
from homeassistant.const import EVENT_STATE_CHANGED, EntityCategory
from homeassistant.core import (
Event,
EventStateChangedData,
HomeAssistant,
HomeAssistantError,
State,
callback,
)
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import (
MockConfigEntry,
MockModule,
MockPlatform,
async_capture_events,
mock_config_flow,
mock_integration,
mock_platform,
mock_restore_cache_with_extra_data,
)
TEST_DOMAIN = "test"
TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345"
TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware"
TEST_UPDATE_ENTITY_ID = "update.mock_name_firmware"
TEST_MANIFEST = FirmwareManifest(
url=URL("https://example.org/firmware"),
html_url=URL("https://example.org/release_notes"),
created_at=dt_util.utcnow(),
firmwares=(
FirmwareMetadata(
filename="skyconnect_zigbee_ncp_test.gbl",
checksum="aaa",
size=123,
release_notes="Some release notes go here",
metadata={
"baudrate": 115200,
"ezsp_version": "7.4.4.0",
"fw_type": "zigbee_ncp",
"fw_variant": None,
"metadata_version": 2,
"sdk_version": "4.4.4",
},
url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"),
),
),
)
TEST_FIRMWARE_ENTITY_DESCRIPTIONS: dict[
ApplicationType | None, FirmwareUpdateEntityDescription
] = {
ApplicationType.EZSP: FirmwareUpdateEntityDescription(
key="firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
version_parser=lambda fw: fw.split(" ", 1)[0],
fw_type="skyconnect_zigbee_ncp",
version_key="ezsp_version",
expected_firmware_type=ApplicationType.EZSP,
firmware_name="EmberZNet",
),
ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
key="firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0],
fw_type="skyconnect_openthread_rcp",
version_key="ot_rcp_version",
expected_firmware_type=ApplicationType.SPINEL,
firmware_name="OpenThread RCP",
),
None: FirmwareUpdateEntityDescription(
key="firmware",
display_precision=0,
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.CONFIG,
version_parser=lambda fw: fw,
fw_type=None,
version_key=None,
expected_firmware_type=None,
firmware_name=None,
),
}
def _mock_async_create_update_entity(
hass: HomeAssistant,
config_entry: ConfigEntry,
session: aiohttp.ClientSession,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> MockFirmwareUpdateEntity:
"""Create an update entity that handles firmware type changes."""
firmware_type = config_entry.data["firmware"]
entity_description = TEST_FIRMWARE_ENTITY_DESCRIPTIONS[
ApplicationType(firmware_type) if firmware_type is not None else None
]
entity = MockFirmwareUpdateEntity(
device=config_entry.data["device"],
config_entry=config_entry,
update_coordinator=FirmwareUpdateCoordinator(
hass,
session,
TEST_FIRMWARE_RELEASES_URL,
),
entity_description=entity_description,
)
def firmware_type_changed(
old_type: ApplicationType | None, new_type: ApplicationType | None
) -> None:
"""Replace the current entity when the firmware type changes."""
er.async_get(hass).async_remove(entity.entity_id)
async_add_entities(
[
_mock_async_create_update_entity(
hass, config_entry, session, async_add_entities
)
]
)
entity.async_on_remove(
entity.add_firmware_type_changed_callback(firmware_type_changed)
)
return entity
async def mock_async_setup_entry(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setups(config_entry, ["update"])
return True
async def mock_async_setup_update_entities(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the firmware update config entry."""
session = async_get_clientsession(hass)
entity = _mock_async_create_update_entity(
hass, config_entry, session, async_add_entities
)
async_add_entities([entity])
class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Mock SkyConnect firmware update entity."""
bootloader_reset_type = None
def __init__(
self,
device: str,
config_entry: ConfigEntry,
update_coordinator: FirmwareUpdateCoordinator,
entity_description: FirmwareUpdateEntityDescription,
) -> None:
"""Initialize the mock SkyConnect firmware update entity."""
super().__init__(device, config_entry, update_coordinator, entity_description)
self._attr_unique_id = self.entity_description.key
self._attr_device_info = DeviceInfo(
identifiers={(TEST_DOMAIN, "yellow")},
name="Mock Name",
model="Mock Model",
manufacturer="Mock Manufacturer",
)
# Use the cached firmware info if it exists
if self._config_entry.data["firmware"] is not None:
self._current_firmware_info = FirmwareInfo(
device=device,
firmware_type=ApplicationType(self._config_entry.data["firmware"]),
firmware_version=self._config_entry.data["firmware_version"],
owners=[],
source=TEST_DOMAIN,
)
@callback
def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
"""Handle updated firmware info being pushed by an integration."""
super()._firmware_info_callback(firmware_info)
self.hass.config_entries.async_update_entry(
self._config_entry,
data={
**self._config_entry.data,
"firmware": firmware_info.firmware_type,
"firmware_version": firmware_info.firmware_version,
},
)
@pytest.fixture(name="update_config_entry")
async def mock_update_config_entry(
hass: HomeAssistant,
) -> AsyncGenerator[ConfigEntry]:
"""Set up a mock Home Assistant Hardware firmware update entity."""
await async_setup_component(hass, "homeassistant", {})
await async_setup_component(hass, "homeassistant_hardware", {})
mock_integration(
hass,
MockModule(
TEST_DOMAIN,
async_setup_entry=mock_async_setup_entry,
),
built_in=False,
)
mock_platform(hass, "test.config_flow")
mock_platform(
hass,
"test.update",
MockPlatform(async_setup_entry=mock_async_setup_update_entities),
)
# Set up a mock integration using the hardware update entity
config_entry = MockConfigEntry(
domain=TEST_DOMAIN,
data={
"device": TEST_DEVICE,
"firmware": "ezsp",
"firmware_version": "7.3.1.0 build 0",
},
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient",
autospec=True,
) as mock_update_client,
mock_config_flow(TEST_DOMAIN, ConfigFlow),
):
mock_update_client.return_value.async_update_data.return_value = TEST_MANIFEST
yield config_entry
async def test_update_entity_installation(
hass: HomeAssistant, update_config_entry: ConfigEntry
) -> None:
"""Test the Hardware firmware update entity installation."""
assert await hass.config_entries.async_setup(update_config_entry.entry_id)
await hass.async_block_till_done()
# Set up another integration communicating with the device
owning_config_entry = MockConfigEntry(
domain="another_integration",
data={
"device": {
"path": TEST_DEVICE,
"flow_control": "hardware",
"baudrate": 115200,
},
"radio_type": "ezsp",
},
version=4,
)
owning_config_entry.add_to_hass(hass)
owning_config_entry.mock_state(hass, ConfigEntryState.LOADED)
# The integration provides firmware info
mock_hw_module = Mock()
mock_hw_module.get_firmware_info = lambda hass, config_entry: FirmwareInfo(
device=TEST_DEVICE,
firmware_type=ApplicationType.EZSP,
firmware_version="7.3.1.0 build 0",
owners=[OwningIntegration(config_entry_id=config_entry.entry_id)],
source="another_integration",
)
async_register_firmware_info_provider(hass, "another_integration", mock_hw_module)
# Pretend the other integration loaded and notified hardware of the running firmware
await async_notify_firmware_info(
hass,
"another_integration",
mock_hw_module.get_firmware_info(hass, owning_config_entry),
)
state_before_update = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_before_update is not None
assert state_before_update.state == "unknown"
assert state_before_update.attributes["title"] == "EmberZNet"
assert state_before_update.attributes["installed_version"] == "7.3.1.0"
assert state_before_update.attributes["latest_version"] is None
# When we check for an update, one will be shown
await hass.services.async_call(
"homeassistant",
"update_entity",
{"entity_id": TEST_UPDATE_ENTITY_ID},
blocking=True,
)
state_after_update = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_after_update is not None
assert state_after_update.state == "on"
assert state_after_update.attributes["title"] == "EmberZNet"
assert state_after_update.attributes["installed_version"] == "7.3.1.0"
assert state_after_update.attributes["latest_version"] == "7.4.4.0"
assert state_after_update.attributes["release_summary"] == (
"Some release notes go here"
)
assert state_after_update.attributes["release_url"] == (
"https://example.org/release_notes"
)
mock_firmware = Mock()
mock_flasher = AsyncMock()
async def mock_flash_firmware(fw_image, progress_callback):
await asyncio.sleep(0)
progress_callback(0, 100)
await asyncio.sleep(0)
progress_callback(50, 100)
await asyncio.sleep(0)
progress_callback(100, 100)
mock_flasher.flash_firmware = mock_flash_firmware
# When we install it, the other integration is reloaded
with (
patch(
"homeassistant.components.homeassistant_hardware.update.parse_firmware_image",
return_value=mock_firmware,
),
patch(
"homeassistant.components.homeassistant_hardware.update.Flasher",
return_value=mock_flasher,
),
patch(
"homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info",
return_value=FirmwareInfo(
device=TEST_DEVICE,
firmware_type=ApplicationType.EZSP,
firmware_version="7.4.4.0 build 0",
owners=[],
source="probe",
),
),
patch.object(
owning_config_entry, "async_unload", wraps=owning_config_entry.async_unload
) as owning_config_entry_unload,
):
state_changes: list[Event[EventStateChangedData]] = async_capture_events(
hass, EVENT_STATE_CHANGED
)
await hass.services.async_call(
"update",
"install",
{"entity_id": TEST_UPDATE_ENTITY_ID},
blocking=True,
)
# Progress events are emitted during the installation
assert len(state_changes) == 7
# Indeterminate progress first
assert state_changes[0].data["new_state"].attributes["in_progress"] is True
assert state_changes[0].data["new_state"].attributes["update_percentage"] is None
# Then the update starts
assert state_changes[1].data["new_state"].attributes["update_percentage"] == 0
assert state_changes[2].data["new_state"].attributes["update_percentage"] == 50
assert state_changes[3].data["new_state"].attributes["update_percentage"] == 100
# Once it is done, we probe the firmware
assert state_changes[4].data["new_state"].attributes["in_progress"] is True
assert state_changes[4].data["new_state"].attributes["update_percentage"] is None
# Finally, the update finishes
assert state_changes[5].data["new_state"].attributes["update_percentage"] is None
assert state_changes[6].data["new_state"].attributes["update_percentage"] is None
assert state_changes[6].data["new_state"].attributes["in_progress"] is False
# The owning integration was unloaded and is again running
assert len(owning_config_entry_unload.mock_calls) == 1
# After the firmware update, the entity has the new version and the correct state
state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_after_install is not None
assert state_after_install.state == "off"
assert state_after_install.attributes["title"] == "EmberZNet"
assert state_after_install.attributes["installed_version"] == "7.4.4.0"
assert state_after_install.attributes["latest_version"] == "7.4.4.0"
async def test_update_entity_installation_failure(
hass: HomeAssistant, update_config_entry: ConfigEntry
) -> None:
"""Test installation failing during flashing."""
assert await hass.config_entries.async_setup(update_config_entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant",
"update_entity",
{"entity_id": TEST_UPDATE_ENTITY_ID},
blocking=True,
)
state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_before_install is not None
assert state_before_install.state == "on"
assert state_before_install.attributes["title"] == "EmberZNet"
assert state_before_install.attributes["installed_version"] == "7.3.1.0"
assert state_before_install.attributes["latest_version"] == "7.4.4.0"
mock_flasher = AsyncMock()
mock_flasher.flash_firmware.side_effect = RuntimeError(
"Something broke during flashing!"
)
with (
patch(
"homeassistant.components.homeassistant_hardware.update.parse_firmware_image",
return_value=Mock(),
),
patch(
"homeassistant.components.homeassistant_hardware.update.Flasher",
return_value=mock_flasher,
),
pytest.raises(HomeAssistantError, match="Failed to flash firmware"),
):
await hass.services.async_call(
"update",
"install",
{"entity_id": TEST_UPDATE_ENTITY_ID},
blocking=True,
)
# After the firmware update fails, we can still try again
state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_after_install is not None
assert state_after_install.state == "on"
assert state_after_install.attributes["title"] == "EmberZNet"
assert state_after_install.attributes["installed_version"] == "7.3.1.0"
assert state_after_install.attributes["latest_version"] == "7.4.4.0"
async def test_update_entity_installation_probe_failure(
hass: HomeAssistant, update_config_entry: ConfigEntry
) -> None:
"""Test installation failing during post-flashing probing."""
assert await hass.config_entries.async_setup(update_config_entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant",
"update_entity",
{"entity_id": TEST_UPDATE_ENTITY_ID},
blocking=True,
)
state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_before_install is not None
assert state_before_install.state == "on"
assert state_before_install.attributes["title"] == "EmberZNet"
assert state_before_install.attributes["installed_version"] == "7.3.1.0"
assert state_before_install.attributes["latest_version"] == "7.4.4.0"
with (
patch(
"homeassistant.components.homeassistant_hardware.update.parse_firmware_image",
return_value=Mock(),
),
patch(
"homeassistant.components.homeassistant_hardware.update.Flasher",
return_value=AsyncMock(),
),
patch(
"homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info",
return_value=None,
),
pytest.raises(
HomeAssistantError, match="Failed to probe the firmware after flashing"
),
):
await hass.services.async_call(
"update",
"install",
{"entity_id": TEST_UPDATE_ENTITY_ID},
blocking=True,
)
# After the firmware update fails, we can still try again
state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_after_install is not None
assert state_after_install.state == "on"
assert state_after_install.attributes["title"] == "EmberZNet"
assert state_after_install.attributes["installed_version"] == "7.3.1.0"
assert state_after_install.attributes["latest_version"] == "7.4.4.0"
async def test_update_entity_state_restoration(
hass: HomeAssistant, update_config_entry: ConfigEntry
) -> None:
"""Test the Hardware firmware update entity state restoration."""
mock_restore_cache_with_extra_data(
hass,
[
(
State(TEST_UPDATE_ENTITY_ID, "on"),
FirmwareUpdateExtraStoredData(
firmware_manifest=TEST_MANIFEST
).as_dict(),
)
],
)
assert await hass.config_entries.async_setup(update_config_entry.entry_id)
await hass.async_block_till_done()
# The state is correctly restored
state = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state is not None
assert state.state == "on"
assert state.attributes["title"] == "EmberZNet"
assert state.attributes["installed_version"] == "7.3.1.0"
assert state.attributes["latest_version"] == "7.4.4.0"
assert state.attributes["release_summary"] == ("Some release notes go here")
assert state.attributes["release_url"] == ("https://example.org/release_notes")
async def test_update_entity_firmware_missing_from_manifest(
hass: HomeAssistant, update_config_entry: ConfigEntry
) -> None:
"""Test the Hardware firmware update entity handles missing firmware."""
mock_restore_cache_with_extra_data(
hass,
[
(
State(TEST_UPDATE_ENTITY_ID, "on"),
# Ensure the manifest does not contain our expected firmware type
FirmwareUpdateExtraStoredData(
firmware_manifest=dataclasses.replace(TEST_MANIFEST, firmwares=())
).as_dict(),
)
],
)
assert await hass.config_entries.async_setup(update_config_entry.entry_id)
await hass.async_block_till_done()
# The state is restored, accounting for the missing firmware
state = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state is not None
assert state.state == "unknown"
assert state.attributes["title"] == "EmberZNet"
assert state.attributes["installed_version"] == "7.3.1.0"
assert state.attributes["latest_version"] is None
assert state.attributes["release_summary"] is None
assert state.attributes["release_url"] is None
async def test_update_entity_graceful_firmware_type_callback_errors(
hass: HomeAssistant,
update_config_entry: ConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test firmware update entity handling of firmware type callback errors."""
session = async_get_clientsession(hass)
update_entity = MockFirmwareUpdateEntity(
device=TEST_DEVICE,
config_entry=update_config_entry,
update_coordinator=FirmwareUpdateCoordinator(
hass,
session,
TEST_FIRMWARE_RELEASES_URL,
),
entity_description=TEST_FIRMWARE_ENTITY_DESCRIPTIONS[ApplicationType.EZSP],
)
update_entity.hass = hass
await update_entity.async_added_to_hass()
callback = Mock(side_effect=RuntimeError("Callback failed"))
unregister_callback = update_entity.add_firmware_type_changed_callback(callback)
with caplog.at_level(logging.WARNING):
await async_notify_firmware_info(
hass,
"some_integration",
FirmwareInfo(
device=TEST_DEVICE,
firmware_type=ApplicationType.SPINEL,
firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57",
owners=[],
source="probe",
),
)
unregister_callback()
assert "Failed to call firmware type changed callback" in caplog.text