From 90265e2afdaad73146782fb5ac61622b38c1902b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:57:39 -0500 Subject: [PATCH] Move SiLabs firmware probing helper from ZHA into `homeassistant_hardware` (#131586) * Move firmware probing helper out of ZHA and into hardware * Add a unit test --- .../firmware_config_flow.py | 7 +-- .../homeassistant_hardware/manifest.json | 5 +- .../components/homeassistant_hardware/util.py | 49 ++++++++++++++++- .../homeassistant_sky_connect/config_flow.py | 3 +- .../homeassistant_yellow/config_flow.py | 2 +- homeassistant/components/zha/manifest.json | 4 +- .../zha/repairs/wrong_silabs_firmware.py | 24 ++------ requirements_all.txt | 2 +- requirements_test_all.txt | 3 - script/hassfest/dependencies.py | 1 + .../test_config_flow.py | 2 +- .../test_config_flow_failures.py | 2 +- .../homeassistant_hardware/test_util.py | 29 +++++++++- .../homeassistant_sky_connect/test_init.py | 7 ++- tests/components/zha/test_repairs.py | 55 +++---------------- 15 files changed, 103 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index a91fb00c142..fac3d2d9735 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,17 +7,12 @@ import asyncio import logging from typing import Any -from universal_silabs_flasher.const import ApplicationType - from homeassistant.components.hassio import ( AddonError, AddonInfo, AddonManager, AddonState, ) -from homeassistant.components.zha.repairs.wrong_silabs_firmware import ( - probe_silabs_firmware_type, -) from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -32,9 +27,11 @@ from homeassistant.helpers.hassio import is_hassio from . import silabs_multiprotocol_addon from .const import ZHA_DOMAIN from .util import ( + ApplicationType, get_otbr_addon_manager, get_zha_device_path, get_zigbee_flasher_addon_manager, + probe_silabs_firmware_type, ) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index f692094bc67..2efa12ccfda 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -1,8 +1,9 @@ { "domain": "homeassistant_hardware", "name": "Home Assistant Hardware", - "after_dependencies": ["hassio", "zha"], + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", - "integration_type": "system" + "integration_type": "system", + "requirements": ["universal-silabs-flasher==0.0.25"] } diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 0c06ff05e5c..3fd5bc60037 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -3,11 +3,14 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Iterable from dataclasses import dataclass +from enum import StrEnum import logging from typing import cast -from universal_silabs_flasher.const import ApplicationType +from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType +from universal_silabs_flasher.flasher import Flasher from homeassistant.components.hassio import AddonError, AddonState from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -32,6 +35,26 @@ from .silabs_multiprotocol_addon import ( _LOGGER = logging.getLogger(__name__) +class ApplicationType(StrEnum): + """Application type running on a device.""" + + GECKO_BOOTLOADER = "bootloader" + CPC = "cpc" + EZSP = "ezsp" + SPINEL = "spinel" + + @classmethod + def from_flasher_application_type( + cls, app_type: FlasherApplicationType + ) -> ApplicationType: + """Convert a USF application type enum.""" + return cls(app_type.value) + + def as_flasher_application_type(self) -> FlasherApplicationType: + """Convert the application type enum into one compatible with USF.""" + return FlasherApplicationType(self.value) + + def get_zha_device_path(config_entry: ConfigEntry) -> str | None: """Get the device path from a ZHA config entry.""" return cast(str | None, config_entry.data.get("device", {}).get("path", None)) @@ -137,3 +160,27 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware assert guesses return guesses[-1] + + +async def probe_silabs_firmware_type( + device: str, *, probe_methods: Iterable[ApplicationType] | None = None +) -> ApplicationType | None: + """Probe the running firmware on a Silabs device.""" + flasher = Flasher( + device=device, + **( + {"probe_methods": [m.as_flasher_application_type() for m in probe_methods]} + if probe_methods + else {} + ), + ) + + try: + await flasher.probe_app_type() + except Exception: # noqa: BLE001 + _LOGGER.debug("Failed to probe application type", exc_info=True) + + if flasher.app_type is None: + return None + + return ApplicationType.from_flasher_application_type(flasher.app_type) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 5c35732312b..2fbf8bcb6bc 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -5,13 +5,12 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any, Protocol -from universal_silabs_flasher.const import ApplicationType - from homeassistant.components import usb from homeassistant.components.homeassistant_hardware import ( firmware_config_flow, silabs_multiprotocol_addon, ) +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 2c58ecdfc1c..502a20db07c 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -8,7 +8,6 @@ import logging from typing import Any, final import aiohttp -from universal_silabs_flasher.const import ApplicationType import voluptuous as vol from homeassistant.components.hassio import ( @@ -25,6 +24,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon OptionsFlowHandler as MultiprotocolOptionsFlowHandler, SerialPortSettings as MultiprotocolSerialPortSettings, ) +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.config_entries import ( SOURCE_HARDWARE, ConfigEntry, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 45d8f6bb25f..28d5f601671 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["hassio", "onboarding", "usb"], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "config_flow": true, - "dependencies": ["file_upload"], + "dependencies": ["file_upload", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/zha", "iot_class": "local_polling", "loggers": [ @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.44"], + "requirements": ["zha==0.0.44"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py index 4d6d1ae52d8..566158eff56 100644 --- a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py +++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py @@ -5,9 +5,10 @@ from __future__ import annotations import enum import logging -from universal_silabs_flasher.const import ApplicationType -from universal_silabs_flasher.flasher import Flasher - +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + probe_silabs_firmware_type, +) from homeassistant.components.homeassistant_sky_connect import ( hardware as skyconnect_hardware, ) @@ -74,23 +75,6 @@ def _detect_radio_hardware(hass: HomeAssistant, device: str) -> HardwareType: return HardwareType.OTHER -async def probe_silabs_firmware_type( - device: str, *, probe_methods: ApplicationType | None = None -) -> ApplicationType | None: - """Probe the running firmware on a Silabs device.""" - flasher = Flasher( - device=device, - **({"probe_methods": probe_methods} if probe_methods else {}), - ) - - try: - await flasher.probe_app_type() - except Exception: # noqa: BLE001 - _LOGGER.debug("Failed to probe application type", exc_info=True) - - return flasher.app_type - - async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> bool: """Create a repair issue if the wrong type of SiLabs firmware is detected.""" # Only consider actual serial ports diff --git a/requirements_all.txt b/requirements_all.txt index dbaefdc3ebe..f6bbe93da08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2942,7 +2942,7 @@ unifi_ap==0.0.2 # homeassistant.components.unifiled unifiled==0.11 -# homeassistant.components.zha +# homeassistant.components.homeassistant_hardware universal-silabs-flasher==0.0.25 # homeassistant.components.upb diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c43f82df54..cea3c39c145 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2358,9 +2358,6 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.2.0 -# homeassistant.components.zha -universal-silabs-flasher==0.0.25 - # homeassistant.components.upb upb-lib==0.5.9 diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 62644e19c5e..d29571eaa83 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -168,6 +168,7 @@ IGNORE_VIOLATIONS = { ("zha", "homeassistant_sky_connect"), ("zha", "homeassistant_yellow"), ("homeassistant_sky_connect", "zha"), + ("homeassistant_hardware", "zha"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 8b0995a67f3..145087073af 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -7,7 +7,6 @@ from typing import Any from unittest.mock import AsyncMock, Mock, call, patch import pytest -from universal_silabs_flasher.const import ApplicationType from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( @@ -17,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import BaseFirmwareOptionsFlow, ) from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, ) diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 5a6f765c44c..f5375fb51dd 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -3,13 +3,13 @@ from unittest.mock import AsyncMock import pytest -from universal_silabs_flasher.const import ApplicationType from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 4a30a39686f..3f019a0409c 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -2,13 +2,14 @@ from unittest.mock import AsyncMock, patch -from universal_silabs_flasher.const import ApplicationType - 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, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -156,3 +157,27 @@ async def test_guess_firmware_type(hass: HomeAssistant) -> None: 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] diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index e1c13771fdc..15eeb205537 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -2,9 +2,10 @@ from unittest.mock import patch -from universal_silabs_flasher.const import ApplicationType - -from homeassistant.components.homeassistant_hardware.util import FirmwareGuess +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareGuess, +) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index c2925161748..af81ac0d586 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -1,17 +1,14 @@ """Test ZHA repairs.""" -from collections.abc import Callable from http import HTTPStatus -import logging from unittest.mock import Mock, call, patch import pytest -from universal_silabs_flasher.const import ApplicationType -from universal_silabs_flasher.flasher import Flasher from zigpy.application import ControllerApplication import zigpy.backups from zigpy.exceptions import NetworkSettingsInconsistent +from homeassistant.components.homeassistant_hardware.util import ApplicationType from homeassistant.components.homeassistant_sky_connect.const import ( # pylint: disable=hass-component-root-import DOMAIN as SKYCONNECT_DOMAIN, ) @@ -25,7 +22,6 @@ from homeassistant.components.zha.repairs.wrong_silabs_firmware import ( ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, HardwareType, _detect_radio_hardware, - probe_silabs_firmware_type, warn_on_wrong_silabs_firmware, ) from homeassistant.config_entries import ConfigEntryState @@ -41,15 +37,6 @@ SKYCONNECT_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8 CONNECT_ZBT1_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0" -def set_flasher_app_type(app_type: ApplicationType) -> Callable[[Flasher], None]: - """Set the app type on the flasher.""" - - def replacement(self: Flasher) -> None: - self.app_type = app_type - - return replacement - - def test_detect_radio_hardware(hass: HomeAssistant) -> None: """Test logic to detect radio hardware.""" skyconnect_config_entry = MockConfigEntry( @@ -143,9 +130,8 @@ async def test_multipan_firmware_repair( # ZHA fails to set up with ( patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=set_flasher_app_type(ApplicationType.CPC), - autospec=True, + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + return_value=ApplicationType.CPC, ), patch( "homeassistant.components.zha.Gateway.async_initialize", @@ -194,9 +180,8 @@ async def test_multipan_firmware_no_repair_on_probe_failure( # ZHA fails to set up with ( patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=set_flasher_app_type(None), - autospec=True, + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + return_value=None, ), patch( "homeassistant.components.zha.Gateway.async_initialize", @@ -231,9 +216,8 @@ async def test_multipan_firmware_retry_on_probe_ezsp( # ZHA fails to set up with ( patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=set_flasher_app_type(ApplicationType.EZSP), - autospec=True, + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + return_value=ApplicationType.EZSP, ), patch( "homeassistant.components.zha.Gateway.async_initialize", @@ -260,37 +244,12 @@ async def test_no_warn_on_socket(hass: HomeAssistant) -> None: """Test that no warning is issued when the device is a socket.""" with patch( "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", - autospec=True, ) as mock_probe: await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678") mock_probe.assert_not_called() -async def test_probe_failure_exception_handling( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that probe failures are handled gracefully.""" - logger = logging.getLogger( - "homeassistant.components.zha.repairs.wrong_silabs_firmware" - ) - orig_level = logger.level - - with ( - caplog.at_level(logging.DEBUG), - patch( - "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", - side_effect=RuntimeError(), - ) as mock_probe_app_type, - ): - logger.setLevel(logging.DEBUG) - await probe_silabs_firmware_type("/dev/ttyZigbee") - logger.setLevel(orig_level) - - mock_probe_app_type.assert_awaited() - assert "Failed to probe application type" in caplog.text - - async def test_inconsistent_settings_keep_new( hass: HomeAssistant, hass_client: ClientSessionGenerator, -- GitLab