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
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__)

View File

@ -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"]
}

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"),

View File

@ -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,
)

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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,