From 43e12f567242a3ff665017182e12456d088046e0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 3 Jan 2025 16:00:01 -0500 Subject: [PATCH] Account for OTBR addon existing independent of integration --- .../components/homeassistant_hardware/util.py | 45 +++- .../test_config_flow.py | 8 + .../test_config_flow_failures.py | 5 +- .../homeassistant_hardware/test_util.py | 222 +++++++----------- .../homeassistant_sky_connect/test_init.py | 6 +- .../homeassistant_yellow/test_init.py | 6 +- 6 files changed, 137 insertions(+), 155 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 6ac211bfafb..a2f3f5e3b9c 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -147,10 +147,20 @@ async def get_zha_firmware_info( hass: HomeAssistant, config_entry: ConfigEntry ) -> FirmwareInfo | None: """Return firmware information for the ZHA instance.""" + + # We only support EZSP firmware for now + if config_entry.data.get("radio_type", None) != "ezsp": + return None + + device = config_entry.data.get("device", {}).get("path", None) + if device is None: + return None + try: # pylint: disable-next=import-outside-toplevel from homeassistant.components.zha.helpers import get_zha_gateway except ImportError: + # A ZHA config entry exists but ZHA isn't installed, it was probably ignored return None try: @@ -160,14 +170,6 @@ async def get_zha_firmware_info( else: firmware_version = gateway.state.node_info.version - # We only support EZSP firmware for now - if config_entry.data["radio_type"] != "ezsp": - return None - - device = config_entry.data.get("device", {}).get("path", None) - if device is None: - return None - return FirmwareInfo( device=device, firmware_type=ApplicationType.EZSP, @@ -218,13 +220,38 @@ async def guess_hardware_owners( if firmware_info is not None: device_guesses[firmware_info.device].append(firmware_info) - # We assume that the OTBR integration will always be set up, even without the addon for otbr_config_entry in hass.config_entries.async_entries(OTBR_DOMAIN): firmware_info = await get_otbr_firmware_info(hass, otbr_config_entry) if firmware_info is not None: device_guesses[firmware_info.device].append(firmware_info) + # It may be possible for the OTBR addon to be present without the integration + if is_hassio(hass): + otbr_addon_manager = get_otbr_addon_manager(hass) + + try: + otbr_addon_info = await otbr_addon_manager.async_get_addon_info() + except AddonError: + pass + else: + if otbr_addon_info.state != AddonState.NOT_INSTALLED: + otbr_path = otbr_addon_info.options.get("device") + + # Only create a new entry if there are no existing OTBR ones + if otbr_path is not None and not any( + info.source == "otbr" for info in device_guesses[otbr_path] + ): + device_guesses[otbr_path].append( + FirmwareInfo( + device=otbr_path, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)], + ) + ) + if is_hassio(hass): multipan_addon_manager = await get_multiprotocol_addon_manager(hass) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 145087073af..077188fac76 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -189,6 +189,10 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", return_value=mock_otbr_manager, ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", + return_value=mock_otbr_manager, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager", return_value=mock_flasher_manager, @@ -197,6 +201,10 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", return_value=is_hassio, ), + patch( + "homeassistant.components.homeassistant_hardware.util.is_hassio", + return_value=is_hassio, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type", return_value=app_type, diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index f5375fb51dd..1e70ad5b6dd 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +from homeassistant.components.homeassistant_hardware.const import ZHA_DOMAIN from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, @@ -550,8 +551,8 @@ async def test_options_flow_zigbee_to_thread_zha_configured( # Set up ZHA as well zha_config_entry = MockConfigEntry( - domain="zha", - data={"device": {"path": TEST_DEVICE}}, + domain=ZHA_DOMAIN, + data={"device": {"path": TEST_DEVICE}, "radio_type": "ezsp"}, ) zha_config_entry.add_to_hass(hass) diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 3f019a0409c..6c0d6bf4868 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -2,16 +2,11 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, - FlasherApplicationType, - get_zha_device_path, - guess_firmware_type, - probe_silabs_firmware_type, + FirmwareInfo, + guess_firmware_info, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -21,7 +16,21 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( unique_id="some_unique_id", data={ "device": { - "path": "socket://1.2.3.4:5678", + "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, }, @@ -31,153 +40,90 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( ) -def test_get_zha_device_path() -> None: - """Test extracting the ZHA device path from its config entry.""" - assert ( - get_zha_device_path(ZHA_CONFIG_ENTRY) == ZHA_CONFIG_ENTRY.data["device"]["path"] - ) - - -def test_get_zha_device_path_ignored_discovery() -> None: - """Test extracting the ZHA device path from an ignored ZHA discovery.""" - config_entry = MockConfigEntry( - domain="zha", - unique_id="some_unique_id", - data={}, - version=4, - ) - - assert get_zha_device_path(config_entry) is None - - -async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None: +async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" - assert (await guess_firmware_type(hass, "/dev/missing")) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="unknown" + 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_type(hass: HomeAssistant) -> None: - """Test guessing the firmware.""" - path = ZHA_CONFIG_ENTRY.data["device"]["path"] +async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None: + """Test guessing the firmware via OTBR and ZHA.""" - ZHA_CONFIG_ENTRY.add_to_hass(hass) + # One instance of ZHA and two OTBRs + zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1") + zha.add_to_hass(hass) - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.NOT_LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="zha" + 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))], ) - # When ZHA is running, we indicate as such when guessing - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" + # 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)), + ], ) - mock_otbr_addon_manager = AsyncMock() - mock_multipan_addon_manager = AsyncMock() + # 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)), + ], + ) with ( patch( - "homeassistant.components.homeassistant_hardware.util.is_hassio", - return_value=True, + "homeassistant.components.homeassistant_hardware.util.get_zha_firmware_info", + return_value=zha_firmware_info, ), patch( - "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", - return_value=mock_otbr_addon_manager, - ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", - return_value=mock_multipan_addon_manager, + "homeassistant.components.homeassistant_hardware.util.get_otbr_firmware_info", + side_effect=lambda _, config_entry: { + otbr1: otbr_firmware_info1, + otbr2: otbr_firmware_info2, + }[config_entry], ), ): - mock_otbr_addon_manager.async_get_addon_info.side_effect = AddonError() - mock_multipan_addon_manager.async_get_addon_info.side_effect = AddonError() + # 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 - # Hassio errors are ignored and we still go with ZHA - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) + # Second stick is communicating exclusively with the second OTBR + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device2") + ) == otbr_firmware_info2 - mock_otbr_addon_manager.async_get_addon_info.side_effect = None - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": "/some/other/device"}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # We will prefer ZHA, as it is running (and actually pointing to the device) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) - - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) - - # We will still prefer ZHA, as it is the one actually running - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) - - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Finally, ZHA loses out to OTBR - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.SPINEL, source="otbr" - ) - - mock_multipan_addon_manager.async_get_addon_info.side_effect = None - mock_multipan_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Which will lose out to multi-PAN - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.CPC, source="multiprotocol" - ) - - -async def test_probe_silabs_firmware_type() -> None: - """Test probing Silabs firmware type.""" - - with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=RuntimeError, - ): - assert (await probe_silabs_firmware_type("/dev/ttyUSB0")) is None - - with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=lambda self: setattr(self, "app_type", FlasherApplicationType.EZSP), - autospec=True, - ) as mock_probe_app_type: - # The application type constant is converted back and forth transparently - result = await probe_silabs_firmware_type( - "/dev/ttyUSB0", probe_methods=[ApplicationType.EZSP] - ) - assert result is ApplicationType.EZSP - - flasher = mock_probe_app_type.mock_calls[0].args[0] - assert flasher._probe_methods == [FlasherApplicationType.EZSP] + # 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 diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 15eeb205537..37f6ff4cac2 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant @@ -32,8 +32,8 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( - "homeassistant.components.homeassistant_sky_connect.guess_firmware_type", - return_value=FirmwareGuess( + "homeassistant.components.homeassistant_sky_connect.guess_firmware_info", + return_value=FirmwareInfo( is_running=True, firmware_type=ApplicationType.SPINEL, source="otbr", diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 5d534dad1e7..981a8c4bddb 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import zha from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -49,8 +49,8 @@ async def test_setup_entry( return_value=onboarded, ), patch( - "homeassistant.components.homeassistant_yellow.guess_firmware_type", - return_value=FirmwareGuess( # Nothing is setup + "homeassistant.components.homeassistant_yellow.guess_firmware_info", + return_value=FirmwareInfo( # Nothing is setup is_running=False, firmware_type=ApplicationType.EZSP, source="unknown",