"""Test hardware utilities.""" import asyncio from collections.abc import Callable, Sequence from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest from universal_silabs_flasher.common import Version as FlasherVersion from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.firmware import GBLImage from homeassistant.components.hassio import ( AddonError, AddonInfo, AddonManager, AddonState, ) from homeassistant.components.homeassistant_hardware import DOMAIN from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_provider, ) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, OwningAddon, OwningIntegration, async_firmware_flashing_context, async_flash_silabs_firmware, get_otbr_addon_firmware_info, get_z2m_addon_firmware_info, get_z2m_addon_manager, guess_firmware_info, guess_hardware_owners, probe_silabs_firmware_info, probe_silabs_firmware_type, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from .test_config_flow import create_mock_owner from tests.common import MockConfigEntry ZHA_CONFIG_ENTRY = MockConfigEntry( domain="zha", unique_id="some_unique_id", data={ "device": { "path": "/dev/ttyUSB1", "baudrate": 115200, "flow_control": None, }, "radio_type": "ezsp", }, version=4, ) ZHA_CONFIG_ENTRY2 = MockConfigEntry( domain="zha", unique_id="some_other_unique_id", data={ "device": { "path": "/dev/ttyUSB2", "baudrate": 115200, "flow_control": None, }, "radio_type": "ezsp", }, version=4, ) TEST_Z2M_ADDON_SLUG_1 = "486e6e9b_zigbee2mqtt" TEST_Z2M_ADDON_SLUG_2 = "45df7312_zigbee2mqtt_edge" TEST_Z2M_ADDON_SLUG_3 = "b0107004_zigbee2mqtt" async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" await async_setup_component(hass, DOMAIN, {}) assert (await guess_firmware_info(hass, "/dev/missing")) == FirmwareInfo( device="/dev/missing", firmware_type=ApplicationType.EZSP, firmware_version=None, source="unknown", owners=[], ) async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None: """Test guessing the firmware via OTBR and ZHA.""" await async_setup_component(hass, DOMAIN, {}) # One instance of ZHA and two OTBRs zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1") zha.add_to_hass(hass) otbr1 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_2") otbr1.add_to_hass(hass) otbr2 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_3") otbr2.add_to_hass(hass) # First ZHA is running with the stick zha_firmware_info = FirmwareInfo( device="/dev/serial/by-id/device1", firmware_type=ApplicationType.EZSP, firmware_version=None, source="zha", owners=[AsyncMock(is_running=AsyncMock(return_value=True))], ) # First OTBR: neither the addon or the integration are loaded otbr_firmware_info1 = FirmwareInfo( device="/dev/serial/by-id/device1", firmware_type=ApplicationType.SPINEL, firmware_version=None, source="otbr", owners=[ AsyncMock(is_running=AsyncMock(return_value=False)), AsyncMock(is_running=AsyncMock(return_value=False)), ], ) # Second OTBR: fully running but is with an unrelated device otbr_firmware_info2 = FirmwareInfo( device="/dev/serial/by-id/device2", # An unrelated device firmware_type=ApplicationType.SPINEL, firmware_version=None, source="otbr", owners=[ AsyncMock(is_running=AsyncMock(return_value=True)), AsyncMock(is_running=AsyncMock(return_value=True)), ], ) mock_zha_hardware_info = MagicMock(spec=["get_firmware_info"]) mock_zha_hardware_info.get_firmware_info = MagicMock(return_value=zha_firmware_info) async_register_firmware_info_provider(hass, "zha", mock_zha_hardware_info) async def mock_otbr_async_get_firmware_info( hass: HomeAssistant, config_entry: ConfigEntry ) -> FirmwareInfo | None: return { otbr1.entry_id: otbr_firmware_info1, otbr2.entry_id: otbr_firmware_info2, }.get(config_entry.entry_id) mock_otbr_hardware_info = MagicMock(spec=["async_get_firmware_info"]) mock_otbr_hardware_info.async_get_firmware_info = AsyncMock( side_effect=mock_otbr_async_get_firmware_info ) async_register_firmware_info_provider(hass, "otbr", mock_otbr_hardware_info) # ZHA wins for the first stick, since it's actually running assert ( await guess_firmware_info(hass, "/dev/serial/by-id/device1") ) == zha_firmware_info # Second stick is communicating exclusively with the second OTBR assert ( await guess_firmware_info(hass, "/dev/serial/by-id/device2") ) == otbr_firmware_info2 # If we stop ZHA, OTBR will take priority zha_firmware_info.owners[0].is_running.return_value = False otbr_firmware_info1.owners[0].is_running.return_value = True assert ( await guess_firmware_info(hass, "/dev/serial/by-id/device1") ) == otbr_firmware_info1 async def test_guess_hardware_owners_z2m( hass: HomeAssistant, ) -> None: """Test fetching adapter info for a complex Z2M scenario.""" await async_setup_component(hass, DOMAIN, {}) multipan_addon_manager = AsyncMock(spec_set=AddonManager) multipan_addon_manager.async_get_addon_info.side_effect = AddonError() async def mock_z2m_firmware_info( hass: HomeAssistant, z2m_addon_manager: AddonManager ) -> FirmwareInfo | None: slug = z2m_addon_manager.addon_slug if slug == TEST_Z2M_ADDON_SLUG_1: return FirmwareInfo( device="/dev/ttyUSB1", firmware_type=ApplicationType.EZSP, firmware_version=None, source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_1})", owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_1)], ) if slug == TEST_Z2M_ADDON_SLUG_2: return FirmwareInfo( device="/dev/ttyUSB2", firmware_type=ApplicationType.EZSP, firmware_version=None, source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_2})", owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_2)], ) return None with ( patch( "homeassistant.components.homeassistant_hardware.util.is_hassio", return_value=True, ), patch( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", return_value=AsyncMock(spec_set=AddonManager), ), patch( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_firmware_info", return_value=None, ), patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=multipan_addon_manager, ), patch( "homeassistant.components.homeassistant_hardware.util.get_apps_list", return_value=[ {"slug": TEST_Z2M_ADDON_SLUG_1, "state": "started"}, {"slug": TEST_Z2M_ADDON_SLUG_2, "state": "stopped"}, {"slug": TEST_Z2M_ADDON_SLUG_3, "state": "stopped"}, {"slug": "unrelated_addon", "state": "started"}, ], ), patch( "homeassistant.components.homeassistant_hardware.util.get_z2m_addon_manager", side_effect=lambda hass, slug: Mock(addon_slug=slug), ), patch( "homeassistant.components.homeassistant_hardware.util.get_z2m_addon_firmware_info", side_effect=mock_z2m_firmware_info, ), ): assert (await guess_hardware_owners(hass, "/dev/ttyUSB1")) == [ FirmwareInfo( device="/dev/ttyUSB1", firmware_type=ApplicationType.EZSP, firmware_version=None, source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_1})", owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_1)], ) ] assert (await guess_hardware_owners(hass, "/dev/ttyUSB2")) == [ FirmwareInfo( device="/dev/ttyUSB2", firmware_type=ApplicationType.EZSP, firmware_version=None, source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_2})", owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_2)], ) ] assert (await guess_hardware_owners(hass, "/dev/ttyUSB3")) == [] async def test_guess_hardware_owners_otbr(hass: HomeAssistant) -> None: """Test OTBR addon detection in guess_hardware_owners.""" await async_setup_component(hass, DOMAIN, {}) otbr_addon_fw_info = FirmwareInfo( device="/dev/ttyUSB1", firmware_type=ApplicationType.SPINEL, firmware_version=None, source="otbr", owners=[OwningAddon(slug="core_openthread_border_router")], ) multipan_addon_manager = AsyncMock(spec_set=AddonManager) multipan_addon_manager.async_get_addon_info.side_effect = AddonError() with ( patch( "homeassistant.components.homeassistant_hardware.util.is_hassio", return_value=True, ), patch( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", return_value=AsyncMock(spec_set=AddonManager), ), patch( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_firmware_info", return_value=otbr_addon_fw_info, ), patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=multipan_addon_manager, ), patch( "homeassistant.components.homeassistant_hardware.util.get_apps_list", return_value=[], ), ): assert (await guess_hardware_owners(hass, "/dev/ttyUSB1")) == [ otbr_addon_fw_info, ] async def test_guess_hardware_owners_multipan(hass: HomeAssistant) -> None: """Test multiprotocol addon detection in guess_hardware_owners.""" await async_setup_component(hass, DOMAIN, {}) multipan_addon_manager = Mock(spec=AddonManager) multipan_addon_manager.addon_slug = "core_silabs_multiprotocol" multipan_addon_manager.async_get_addon_info = AsyncMock( return_value=AddonInfo( available=True, hostname="core_silabs_multiprotocol", options={"device": "/dev/ttyUSB1"}, state=AddonState.RUNNING, update_available=False, version="1.0.0", ) ) with ( patch( "homeassistant.components.homeassistant_hardware.util.is_hassio", return_value=True, ), patch( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", return_value=AsyncMock(spec_set=AddonManager), ), patch( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_firmware_info", return_value=None, ), patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=multipan_addon_manager, ), patch( "homeassistant.components.homeassistant_hardware.util.get_apps_list", return_value=[], ), ): assert (await guess_hardware_owners(hass, "/dev/ttyUSB1")) == [ FirmwareInfo( device="/dev/ttyUSB1", firmware_type=ApplicationType.CPC, firmware_version=None, source="multiprotocol", owners=[OwningAddon(slug="core_silabs_multiprotocol")], ), ] async def test_owning_addon(hass: HomeAssistant) -> None: """Test `OwningAddon`.""" owning_addon = OwningAddon(slug="some-addon-slug") # Explicitly running with patch( "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" ) as mock_manager: mock_manager.return_value.async_get_addon_info = AsyncMock( return_value=AddonInfo( available=True, hostname="core_some_addon_slug", options={}, state=AddonState.RUNNING, update_available=False, version="1.0.0", ) ) assert (await owning_addon.is_running(hass)) is True # Explicitly not running with patch( "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" ) as mock_manager: mock_manager.return_value.async_get_addon_info = AsyncMock( return_value=AddonInfo( available=True, hostname="core_some_addon_slug", options={}, state=AddonState.NOT_RUNNING, update_available=False, version="1.0.0", ) ) assert (await owning_addon.is_running(hass)) is False # Failed to get status with patch( "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" ) as mock_manager: mock_manager.return_value.async_get_addon_info = AsyncMock( side_effect=AddonError() ) assert (await owning_addon.is_running(hass)) is False async def test_owning_addon_temporarily_stop_info_error(hass: HomeAssistant) -> None: """Test `OwningAddon` temporarily stopping with an info error.""" owning_addon = OwningAddon(slug="some-addon-slug") mock_manager = AsyncMock() mock_manager.async_get_addon_info.side_effect = AddonError() with patch( "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", return_value=mock_manager, ): async with owning_addon.temporarily_stop(hass): pass # We never restart it assert len(mock_manager.async_get_addon_info.mock_calls) == 1 assert len(mock_manager.async_stop_addon.mock_calls) == 0 assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 async def test_owning_addon_temporarily_stop_not_running(hass: HomeAssistant) -> None: """Test `OwningAddon` temporarily stopping when the addon is not running.""" owning_addon = OwningAddon(slug="some-addon-slug") mock_manager = AsyncMock() mock_manager.async_get_addon_info.return_value = AddonInfo( available=True, hostname="core_some_addon_slug", options={}, state=AddonState.NOT_RUNNING, update_available=False, version="1.0.0", ) with patch( "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", return_value=mock_manager, ): async with owning_addon.temporarily_stop(hass): pass # We never restart it assert len(mock_manager.async_get_addon_info.mock_calls) == 1 assert len(mock_manager.async_stop_addon.mock_calls) == 0 assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 async def test_owning_addon_temporarily_stop(hass: HomeAssistant) -> None: """Test `OwningAddon` temporarily stopping when the addon is running.""" owning_addon = OwningAddon(slug="some-addon-slug") mock_manager = AsyncMock() mock_manager.async_get_addon_info.return_value = AddonInfo( available=True, hostname="core_some_addon_slug", options={}, state=AddonState.RUNNING, update_available=False, version="1.0.0", ) mock_manager.async_stop_addon = AsyncMock() mock_manager.async_wait_until_addon_state = AsyncMock() mock_manager.async_start_addon_waiting = AsyncMock() # The error is propagated but it doesn't affect restarting the addon with ( patch( "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", return_value=mock_manager, ), pytest.raises(RuntimeError), ): async with owning_addon.temporarily_stop(hass): raise RuntimeError("Some error") # We restart it assert len(mock_manager.async_get_addon_info.mock_calls) == 1 assert len(mock_manager.async_stop_addon.mock_calls) == 1 assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 1 assert len(mock_manager.async_start_addon_waiting.mock_calls) == 1 async def test_owning_integration(hass: HomeAssistant) -> None: """Test `OwningIntegration`.""" config_entry = MockConfigEntry(domain="mock_domain", unique_id="some_unique_id") config_entry.add_to_hass(hass) owning_integration = OwningIntegration(config_entry_id=config_entry.entry_id) # Explicitly running config_entry.mock_state(hass, ConfigEntryState.LOADED) assert (await owning_integration.is_running(hass)) is True # Explicitly not running config_entry.mock_state(hass, ConfigEntryState.NOT_LOADED) assert (await owning_integration.is_running(hass)) is False # Missing config entry owning_integration2 = OwningIntegration(config_entry_id="some_nonexistenct_id") assert (await owning_integration2.is_running(hass)) is False async def test_owning_integration_temporarily_stop_missing_entry( hass: HomeAssistant, ) -> None: """Test temporarily stopping the integration when the config entry doesn't exist.""" missing_integration = OwningIntegration(config_entry_id="missing_entry_id") with ( patch.object(hass.config_entries, "async_unload") as mock_unload, patch.object(hass.config_entries, "async_setup") as mock_setup, ): async with missing_integration.temporarily_stop(hass): pass # Because there's no matching entry, no unload or setup calls are made assert len(mock_unload.mock_calls) == 0 assert len(mock_setup.mock_calls) == 0 async def test_owning_integration_temporarily_stop_not_loaded( hass: HomeAssistant, ) -> None: """Test temporarily stopping the integration when the config entry is not loaded.""" entry = MockConfigEntry(domain="test_domain") entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.NOT_LOADED) integration = OwningIntegration(config_entry_id=entry.entry_id) with ( patch.object(hass.config_entries, "async_unload") as mock_unload, patch.object(hass.config_entries, "async_setup") as mock_setup, ): async with integration.temporarily_stop(hass): pass # Since the entry was not loaded, we never unload or re-setup assert len(mock_unload.mock_calls) == 0 assert len(mock_setup.mock_calls) == 0 async def test_owning_integration_temporarily_stop_loaded(hass: HomeAssistant) -> None: """Test temporarily stopping the integration when the config entry is loaded.""" entry = MockConfigEntry(domain="test_domain") entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) integration = OwningIntegration(config_entry_id=entry.entry_id) with ( patch.object(hass.config_entries, "async_unload") as mock_unload, patch.object(hass.config_entries, "async_setup") as mock_setup, pytest.raises(RuntimeError), ): async with integration.temporarily_stop(hass): raise RuntimeError("Some error during the temporary stop") # We expect one unload followed by one setup call mock_unload.assert_called_once_with(entry.entry_id) mock_setup.assert_called_once_with(entry.entry_id) async def test_firmware_info(hass: HomeAssistant) -> None: """Test `FirmwareInfo`.""" owner1 = AsyncMock() owner2 = AsyncMock() firmware_info = FirmwareInfo( device="/dev/ttyUSB1", firmware_type=ApplicationType.EZSP, firmware_version="1.0.0", source="zha", owners=[owner1, owner2], ) # Both running owner1.is_running.return_value = True owner2.is_running.return_value = True assert (await firmware_info.is_running(hass)) is True # Only one running owner1.is_running.return_value = True owner2.is_running.return_value = False assert (await firmware_info.is_running(hass)) is False # No owners firmware_info2 = FirmwareInfo( device="/dev/ttyUSB1", firmware_type=ApplicationType.EZSP, firmware_version="1.0.0", source="zha", owners=[], ) assert (await firmware_info2.is_running(hass)) is False async def test_get_otbr_addon_firmware_info_failure(hass: HomeAssistant) -> None: """Test getting OTBR addon firmware info failure due to bad API call.""" otbr_addon_manager = AsyncMock(spec_set=AddonManager) otbr_addon_manager.async_get_addon_info.side_effect = AddonError() assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None async def test_get_otbr_addon_firmware_info_failure_bad_options( hass: HomeAssistant, ) -> None: """Test getting OTBR addon firmware info failure due to bad addon options.""" otbr_addon_manager = AsyncMock(spec_set=AddonManager) otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( available=True, hostname="core_some_addon_slug", options={}, # `device` is missing state=AddonState.RUNNING, update_available=False, version="1.0.0", ) assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None async def test_get_z2m_addon_manager( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: """Test getting the Z2M addon manager.""" manager = get_z2m_addon_manager(hass, "abc123_zigbee2mqtt") assert manager.addon_slug == "abc123_zigbee2mqtt" async def test_get_z2m_addon_firmware_info(hass: HomeAssistant) -> None: """Test getting Z2M addon firmware info.""" z2m_addon_manager = Mock(spec=AddonManager) z2m_addon_manager.addon_slug = "abc123_zigbee2mqtt" z2m_addon_manager.async_get_addon_info = AsyncMock( return_value=AddonInfo( available=True, hostname="core_abc123_zigbee2mqtt", options={"serial": {"port": "/dev/ttyUSB0"}}, state=AddonState.RUNNING, update_available=False, version="1.0.0", ) ) assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) == FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.EZSP, firmware_version=None, source="zigbee2mqtt (abc123_zigbee2mqtt)", owners=[OwningAddon(slug="abc123_zigbee2mqtt")], ) async def test_get_z2m_addon_firmware_info_not_installed(hass: HomeAssistant) -> None: """Test getting Z2M addon firmware info, addon not installed.""" z2m_addon_manager = Mock(spec=AddonManager) z2m_addon_manager.addon_slug = "abc123_zigbee2mqtt" z2m_addon_manager.async_get_addon_info.return_value = AddonInfo( available=True, hostname=None, options={}, state=AddonState.NOT_INSTALLED, update_available=False, version=None, ) assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None async def test_get_z2m_addon_firmware_info_failure(hass: HomeAssistant) -> None: """Test getting Z2M addon firmware info failure due to bad API call.""" z2m_addon_manager = AsyncMock(spec_set=AddonManager) z2m_addon_manager.async_get_addon_info.side_effect = AddonError() assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None async def test_get_z2m_addon_firmware_info_failure_bad_options( hass: HomeAssistant, ) -> None: """Test getting Z2M addon firmware info failure due to bad addon options.""" z2m_addon_manager = AsyncMock(spec_set=AddonManager) z2m_addon_manager.async_get_addon_info.return_value = AddonInfo( available=True, hostname="core_some_addon_slug", options={}, # `serial` is missing state=AddonState.RUNNING, update_available=False, version="1.0.0", ) assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None async def test_get_z2m_addon_firmware_info_failure_serial_not_dict( hass: HomeAssistant, ) -> None: """Test getting Z2M addon firmware info failure when serial is not a dict.""" z2m_addon_manager = AsyncMock(spec_set=AddonManager) z2m_addon_manager.async_get_addon_info.return_value = AddonInfo( available=True, hostname="core_some_addon_slug", options={"serial": "/dev/ttyUSB0"}, # `serial` is a string, not a dict state=AddonState.RUNNING, update_available=False, version="1.0.0", ) assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None @pytest.mark.parametrize( ("app_type", "firmware_version", "expected_fw_info"), [ ( FlasherApplicationType.EZSP, FlasherVersion("1.0.0"), FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.EZSP, firmware_version="1.0.0", source="probe", owners=[], ), ), ( FlasherApplicationType.EZSP, None, FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.EZSP, firmware_version=None, source="probe", owners=[], ), ), ( FlasherApplicationType.SPINEL, FlasherVersion("2.0.0"), FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.SPINEL, firmware_version="2.0.0", source="probe", owners=[], ), ), (None, None, None), ], ) async def test_probe_silabs_firmware_info( app_type: FlasherApplicationType | None, firmware_version: FlasherVersion | None, expected_fw_info: FirmwareInfo | None, ) -> None: """Test getting the firmware info.""" def probe_app_type(only: Sequence[FlasherApplicationType] | None = None) -> None: mock_flasher.app_type = app_type mock_flasher.app_version = firmware_version mock_flasher_cls = MagicMock() mock_flasher = mock_flasher_cls.return_value mock_flasher.app_type = None mock_flasher.app_version = None mock_flasher.probe_app_type = AsyncMock(side_effect=probe_app_type) result = await probe_silabs_firmware_info( "/dev/ttyUSB0", flasher_cls=mock_flasher_cls, ) assert result == expected_fw_info @pytest.mark.parametrize( ("probe_result", "expected"), [ (FlasherApplicationType.EZSP, ApplicationType.EZSP), (RuntimeError("some error"), None), (None, None), ], ) async def test_probe_silabs_firmware_type( probe_result: FirmwareInfo | None, expected: ApplicationType | None ) -> None: """Test getting the firmware type from the probe result.""" def probe_app_type(only: Sequence[FlasherApplicationType] | None = None) -> None: if isinstance(probe_result, BaseException): raise probe_result mock_flasher.app_type = probe_result mock_flasher = Mock() mock_flasher.probe_app_type = AsyncMock(side_effect=probe_app_type) mock_flasher.app_type = None with patch( "homeassistant.components.homeassistant_hardware.util.Flasher", autospec=True, return_value=mock_flasher, ): result = await probe_silabs_firmware_type( "/dev/ttyUSB0", application_probe_methods=[ (ApplicationType.EZSP, 460800), (ApplicationType.SPINEL, 460800), ], ) assert result == expected async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: """Test async_flash_silabs_firmware.""" await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() progress_callback = Mock() async def mock_flash_firmware( fw_image: GBLImage, progress_callback: Callable[[int, int], None] ) -> None: """Mock flash firmware function.""" await asyncio.sleep(0) progress_callback(0, 100) await asyncio.sleep(0) progress_callback(50, 100) await asyncio.sleep(0) progress_callback(100, 100) await asyncio.sleep(0) mock_flasher_cls = MagicMock() mock_flasher = mock_flasher_cls.return_value mock_flasher.enter_bootloader = AsyncMock() mock_flasher.flash_firmware = AsyncMock(side_effect=mock_flash_firmware) expected_firmware_info = FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.SPINEL, firmware_version=None, source="probe", owners=[], ) with ( patch( "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", return_value=FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.EZSP, firmware_version=None, source="unknown", owners=[owner1, owner2], ), ), patch( "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" ), patch( "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", return_value=expected_firmware_info, ), ): async with async_firmware_flashing_context( hass, "/dev/ttyUSB0", "homeassistant_hardware" ): after_flash_info = await async_flash_silabs_firmware( hass=hass, device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, flasher_cls=mock_flasher_cls, progress_callback=progress_callback, ) assert mock_flasher_cls.call_count == 1 mock_flasher_cls.assert_called_once_with(device="/dev/ttyUSB0") assert progress_callback.mock_calls == [call(0, 100), call(50, 100), call(100, 100)] assert after_flash_info == expected_firmware_info # Both owning integrations/addons are stopped and restarted assert owner1.temporarily_stop.mock_calls == [ call(hass), # pylint: disable-next=unnecessary-dunder-call call().__aenter__(ANY), call().__aexit__(ANY, None, None, None), ] assert owner2.temporarily_stop.mock_calls == [ call(hass), # pylint: disable-next=unnecessary-dunder-call call().__aenter__(ANY), call().__aexit__(ANY, None, None, None), ] @pytest.mark.parametrize( ("side_effect", "expected_error_msg"), [ ( RuntimeError("Failure!"), "Failed to flash firmware", ), ( PermissionError("The serial port is locked by another application"), "Failed to flash firmware: Device is used by another application", ), ], ) async def test_async_flash_silabs_firmware_flash_failure( hass: HomeAssistant, side_effect: Exception, expected_error_msg: str ) -> None: """Test async_flash_silabs_firmware flash failure.""" await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() mock_flasher_cls = MagicMock() mock_flasher = mock_flasher_cls.return_value mock_flasher.enter_bootloader = AsyncMock() mock_flasher.flash_firmware = AsyncMock(side_effect=side_effect) with ( patch( "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", return_value=FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.EZSP, firmware_version=None, source="unknown", owners=[owner1, owner2], ), ), patch( "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" ), pytest.raises(HomeAssistantError, match=expected_error_msg) as exc, ): async with async_firmware_flashing_context( hass, "/dev/ttyUSB0", "homeassistant_hardware" ): await async_flash_silabs_firmware( hass=hass, device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, flasher_cls=mock_flasher_cls, ) # Both owning integrations/addons are stopped and restarted assert owner1.temporarily_stop.mock_calls == [ call(hass), # pylint: disable-next=unnecessary-dunder-call call().__aenter__(ANY), call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), ] assert owner2.temporarily_stop.mock_calls == [ call(hass), # pylint: disable-next=unnecessary-dunder-call call().__aenter__(ANY), call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), ] async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> None: """Test async_flash_silabs_firmware probe failure.""" await async_setup_component(hass, DOMAIN, {}) owner1 = create_mock_owner() owner2 = create_mock_owner() mock_flasher_cls = MagicMock() mock_flasher = mock_flasher_cls.return_value mock_flasher.enter_bootloader = AsyncMock() mock_flasher.flash_firmware = AsyncMock() with ( patch( "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", return_value=FirmwareInfo( device="/dev/ttyUSB0", firmware_type=ApplicationType.EZSP, firmware_version=None, source="unknown", owners=[owner1, owner2], ), ), patch( "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" ), patch( "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", return_value=None, ), pytest.raises( HomeAssistantError, match="Failed to probe the firmware after flashing" ) as exc, ): async with async_firmware_flashing_context( hass, "/dev/ttyUSB0", "homeassistant_hardware" ): await async_flash_silabs_firmware( hass=hass, device="/dev/ttyUSB0", fw_data=b"firmware contents", expected_installed_firmware_type=ApplicationType.SPINEL, flasher_cls=mock_flasher_cls, ) # Both owning integrations/addons are stopped and restarted assert owner1.temporarily_stop.mock_calls == [ call(hass), # pylint: disable-next=unnecessary-dunder-call call().__aenter__(ANY), call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), ] assert owner2.temporarily_stop.mock_calls == [ call(hass), # pylint: disable-next=unnecessary-dunder-call call().__aenter__(ANY), call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), ]