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
This commit is contained in:
puddly 2025-01-03 10:57:39 -05:00 committed by GitHub
parent a53554dad3
commit 90265e2afd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 103 additions and 92 deletions

View File

@ -7,17 +7,12 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components.hassio import ( from homeassistant.components.hassio import (
AddonError, AddonError,
AddonInfo, AddonInfo,
AddonManager, AddonManager,
AddonState, AddonState,
) )
from homeassistant.components.zha.repairs.wrong_silabs_firmware import (
probe_silabs_firmware_type,
)
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigEntryBaseFlow, ConfigEntryBaseFlow,
@ -32,9 +27,11 @@ from homeassistant.helpers.hassio import is_hassio
from . import silabs_multiprotocol_addon from . import silabs_multiprotocol_addon
from .const import ZHA_DOMAIN from .const import ZHA_DOMAIN
from .util import ( from .util import (
ApplicationType,
get_otbr_addon_manager, get_otbr_addon_manager,
get_zha_device_path, get_zha_device_path,
get_zigbee_flasher_addon_manager, get_zigbee_flasher_addon_manager,
probe_silabs_firmware_type,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@ -1,8 +1,9 @@
{ {
"domain": "homeassistant_hardware", "domain": "homeassistant_hardware",
"name": "Home Assistant Hardware", "name": "Home Assistant Hardware",
"after_dependencies": ["hassio", "zha"], "after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system" "integration_type": "system",
"requirements": ["universal-silabs-flasher==0.0.25"]
} }

View File

@ -3,11 +3,14 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum
import logging import logging
from typing import cast 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.components.hassio import AddonError, AddonState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@ -32,6 +35,26 @@ from .silabs_multiprotocol_addon import (
_LOGGER = logging.getLogger(__name__) _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: def get_zha_device_path(config_entry: ConfigEntry) -> str | None:
"""Get the device path from a ZHA config entry.""" """Get the device path from a ZHA config entry."""
return cast(str | None, config_entry.data.get("device", {}).get("path", None)) 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 assert guesses
return guesses[-1] 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)

View File

@ -5,13 +5,12 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components import usb from homeassistant.components import usb
from homeassistant.components.homeassistant_hardware import ( from homeassistant.components.homeassistant_hardware import (
firmware_config_flow, firmware_config_flow,
silabs_multiprotocol_addon, silabs_multiprotocol_addon,
) )
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigEntryBaseFlow, ConfigEntryBaseFlow,

View File

@ -8,7 +8,6 @@ import logging
from typing import Any, final from typing import Any, final
import aiohttp import aiohttp
from universal_silabs_flasher.const import ApplicationType
import voluptuous as vol import voluptuous as vol
from homeassistant.components.hassio import ( from homeassistant.components.hassio import (
@ -25,6 +24,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
OptionsFlowHandler as MultiprotocolOptionsFlowHandler, OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
SerialPortSettings as MultiprotocolSerialPortSettings, SerialPortSettings as MultiprotocolSerialPortSettings,
) )
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_HARDWARE, SOURCE_HARDWARE,
ConfigEntry, ConfigEntry,

View File

@ -4,7 +4,7 @@
"after_dependencies": ["hassio", "onboarding", "usb"], "after_dependencies": ["hassio", "onboarding", "usb"],
"codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"],
"config_flow": true, "config_flow": true,
"dependencies": ["file_upload"], "dependencies": ["file_upload", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/zha", "documentation": "https://www.home-assistant.io/integrations/zha",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": [ "loggers": [
@ -21,7 +21,7 @@
"zha", "zha",
"universal_silabs_flasher" "universal_silabs_flasher"
], ],
"requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.44"], "requirements": ["zha==0.0.44"],
"usb": [ "usb": [
{ {
"vid": "10C4", "vid": "10C4",

View File

@ -5,9 +5,10 @@ from __future__ import annotations
import enum import enum
import logging import logging
from universal_silabs_flasher.const import ApplicationType from homeassistant.components.homeassistant_hardware.util import (
from universal_silabs_flasher.flasher import Flasher ApplicationType,
probe_silabs_firmware_type,
)
from homeassistant.components.homeassistant_sky_connect import ( from homeassistant.components.homeassistant_sky_connect import (
hardware as skyconnect_hardware, hardware as skyconnect_hardware,
) )
@ -74,23 +75,6 @@ def _detect_radio_hardware(hass: HomeAssistant, device: str) -> HardwareType:
return HardwareType.OTHER 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: 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.""" """Create a repair issue if the wrong type of SiLabs firmware is detected."""
# Only consider actual serial ports # Only consider actual serial ports

View File

@ -2942,7 +2942,7 @@ unifi_ap==0.0.2
# homeassistant.components.unifiled # homeassistant.components.unifiled
unifiled==0.11 unifiled==0.11
# homeassistant.components.zha # homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.0.25 universal-silabs-flasher==0.0.25
# homeassistant.components.upb # homeassistant.components.upb

View File

@ -2358,9 +2358,6 @@ ultraheat-api==0.5.7
# homeassistant.components.unifiprotect # homeassistant.components.unifiprotect
unifi-discovery==1.2.0 unifi-discovery==1.2.0
# homeassistant.components.zha
universal-silabs-flasher==0.0.25
# homeassistant.components.upb # homeassistant.components.upb
upb-lib==0.5.9 upb-lib==0.5.9

View File

@ -168,6 +168,7 @@ IGNORE_VIOLATIONS = {
("zha", "homeassistant_sky_connect"), ("zha", "homeassistant_sky_connect"),
("zha", "homeassistant_yellow"), ("zha", "homeassistant_yellow"),
("homeassistant_sky_connect", "zha"), ("homeassistant_sky_connect", "zha"),
("homeassistant_hardware", "zha"),
# This should become a helper method that integrations can submit data to # This should become a helper method that integrations can submit data to
("websocket_api", "lovelace"), ("websocket_api", "lovelace"),
("websocket_api", "shopping_list"), ("websocket_api", "shopping_list"),

View File

@ -7,7 +7,6 @@ from typing import Any
from unittest.mock import AsyncMock, Mock, call, patch from unittest.mock import AsyncMock, Mock, call, patch
import pytest import pytest
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.hassio import AddonInfo, AddonState
from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
@ -17,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import
BaseFirmwareOptionsFlow, BaseFirmwareOptionsFlow,
) )
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
get_otbr_addon_manager, get_otbr_addon_manager,
get_zigbee_flasher_addon_manager, get_zigbee_flasher_addon_manager,
) )

View File

@ -3,13 +3,13 @@
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.components.hassio import AddonError, AddonInfo, AddonState
from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_THREAD,
STEP_PICK_FIRMWARE_ZIGBEE, STEP_PICK_FIRMWARE_ZIGBEE,
) )
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType

View File

@ -2,13 +2,14 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from universal_silabs_flasher.const import ApplicationType
from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.components.hassio import AddonError, AddonInfo, AddonState
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareGuess, FirmwareGuess,
FlasherApplicationType,
get_zha_device_path, get_zha_device_path,
guess_firmware_type, guess_firmware_type,
probe_silabs_firmware_type,
) )
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant 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( assert (await guess_firmware_type(hass, path)) == FirmwareGuess(
is_running=True, firmware_type=ApplicationType.CPC, source="multiprotocol" 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]

View File

@ -2,9 +2,10 @@
from unittest.mock import patch from unittest.mock import patch
from universal_silabs_flasher.const import ApplicationType from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
from homeassistant.components.homeassistant_hardware.util import FirmwareGuess FirmwareGuess,
)
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant

View File

@ -1,17 +1,14 @@
"""Test ZHA repairs.""" """Test ZHA repairs."""
from collections.abc import Callable
from http import HTTPStatus from http import HTTPStatus
import logging
from unittest.mock import Mock, call, patch from unittest.mock import Mock, call, patch
import pytest import pytest
from universal_silabs_flasher.const import ApplicationType
from universal_silabs_flasher.flasher import Flasher
from zigpy.application import ControllerApplication from zigpy.application import ControllerApplication
import zigpy.backups import zigpy.backups
from zigpy.exceptions import NetworkSettingsInconsistent 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 from homeassistant.components.homeassistant_sky_connect.const import ( # pylint: disable=hass-component-root-import
DOMAIN as SKYCONNECT_DOMAIN, DOMAIN as SKYCONNECT_DOMAIN,
) )
@ -25,7 +22,6 @@ from homeassistant.components.zha.repairs.wrong_silabs_firmware import (
ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED,
HardwareType, HardwareType,
_detect_radio_hardware, _detect_radio_hardware,
probe_silabs_firmware_type,
warn_on_wrong_silabs_firmware, warn_on_wrong_silabs_firmware,
) )
from homeassistant.config_entries import ConfigEntryState 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" 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: def test_detect_radio_hardware(hass: HomeAssistant) -> None:
"""Test logic to detect radio hardware.""" """Test logic to detect radio hardware."""
skyconnect_config_entry = MockConfigEntry( skyconnect_config_entry = MockConfigEntry(
@ -143,9 +130,8 @@ async def test_multipan_firmware_repair(
# ZHA fails to set up # ZHA fails to set up
with ( with (
patch( patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type",
side_effect=set_flasher_app_type(ApplicationType.CPC), return_value=ApplicationType.CPC,
autospec=True,
), ),
patch( patch(
"homeassistant.components.zha.Gateway.async_initialize", "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 # ZHA fails to set up
with ( with (
patch( patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type",
side_effect=set_flasher_app_type(None), return_value=None,
autospec=True,
), ),
patch( patch(
"homeassistant.components.zha.Gateway.async_initialize", "homeassistant.components.zha.Gateway.async_initialize",
@ -231,9 +216,8 @@ async def test_multipan_firmware_retry_on_probe_ezsp(
# ZHA fails to set up # ZHA fails to set up
with ( with (
patch( patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type",
side_effect=set_flasher_app_type(ApplicationType.EZSP), return_value=ApplicationType.EZSP,
autospec=True,
), ),
patch( patch(
"homeassistant.components.zha.Gateway.async_initialize", "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.""" """Test that no warning is issued when the device is a socket."""
with patch( with patch(
"homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type",
autospec=True,
) as mock_probe: ) as mock_probe:
await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678") await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678")
mock_probe.assert_not_called() 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( async def test_inconsistent_settings_keep_new(
hass: HomeAssistant, hass: HomeAssistant,
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,