Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.config_entries import (
ConfigEntry,
Expand Down Expand Up @@ -67,6 +68,11 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):

context: ConfigFlowContext

# `rts_dtr` targets older adapters, `baudrate` works for newer ones. The reason we
# try them in this order is that on older adapters `baudrate` entered the ESP32-S3
# bootloader instead of the MG24 bootloader.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]

async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry
Expand Down Expand Up @@ -156,7 +157,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity."""

bootloader_reset_type = None
bootloader_reset_methods = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]

def __init__(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
FirmwareInfo,
OwningAddon,
OwningIntegration,
ResetTarget,
async_flash_silabs_firmware,
get_otbr_addon_manager,
guess_firmware_info,
Expand Down Expand Up @@ -79,6 +80,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware."""

ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override

_picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED

Expand Down Expand Up @@ -274,7 +277,7 @@ async def _install_firmware(
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_type=None,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.0.32",
"universal-silabs-flasher==0.0.34",
"ha-silabs-firmware-client==0.2.0"
]
}
11 changes: 8 additions & 3 deletions homeassistant/components/homeassistant_hardware/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@

from .coordinator import FirmwareUpdateCoordinator
from .helpers import async_register_firmware_info_callback
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
from .util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
async_flash_silabs_firmware,
)

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -81,7 +86,7 @@ class BaseFirmwareUpdateEntity(

# Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription
bootloader_reset_type: str | None = None
bootloader_reset_methods: list[ResetTarget] = []

_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
Expand Down Expand Up @@ -268,7 +273,7 @@ async def async_install(
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_type=self.bootloader_reset_type,
bootloader_reset_methods=self.bootloader_reset_methods,
progress_callback=self._update_progress,
)
finally:
Expand Down
25 changes: 21 additions & 4 deletions homeassistant/components/homeassistant_hardware/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
import logging

from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.const import (
ApplicationType as FlasherApplicationType,
ResetTarget as FlasherResetTarget,
)
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher

Expand Down Expand Up @@ -59,6 +62,18 @@ def as_flasher_application_type(self) -> FlasherApplicationType:
return FlasherApplicationType(self.value)


class ResetTarget(StrEnum):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we use the library ResetTarget enum directly?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address this as a follow up if needed.

"""Methods to reset a device into bootloader mode."""

RTS_DTR = "rts_dtr"
BAUDRATE = "baudrate"
YELLOW = "yellow"

def as_flasher_reset_target(self) -> FlasherResetTarget:
"""Convert the reset target enum into one compatible with USF."""
return FlasherResetTarget(self.value)


@singleton(OTBR_ADDON_MANAGER_DATA)
@callback
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
Expand Down Expand Up @@ -342,7 +357,7 @@ async def async_flash_silabs_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
Expand All @@ -359,7 +374,9 @@ async def async_flash_silabs_firmware(
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=bootloader_reset_type,
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)

async with AsyncExitStack() as stack:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""

bootloader_reset_type = None
# The ZBT-1 does not have a hardware bootloader trigger
bootloader_reset_methods = []

def __init__(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
probe_silabs_firmware_info,
)
from homeassistant.config_entries import (
Expand Down Expand Up @@ -83,6 +84,8 @@ async def _install_firmware_step(
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""

BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]

async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/homeassistant_yellow/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry
Expand Down Expand Up @@ -173,7 +174,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""

bootloader_reset_type = "yellow" # Triggers a GPIO reset
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset

def __init__(
self,
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 56 additions & 21 deletions tests/components/homeassistant_connect_zbt2/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Test the Home Assistant Connect ZBT-2 config flow."""

from collections.abc import Generator
from unittest.mock import AsyncMock, call, patch
from unittest.mock import AsyncMock, Mock, call, patch

import pytest

Expand Down Expand Up @@ -243,33 +243,61 @@ async def test_options_flow(
assert description_placeholders["firmware_type"] == "spinel"
assert description_placeholders["model"] == model

async def mock_install_firmware_step(
self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult:
self._probed_firmware_info = FirmwareInfo(
device=usb_data.device,
firmware_type=expected_installed_firmware_type,
firmware_version="7.4.4.0 build 0",
owners=[],
source="probe",
)
return await getattr(self, f"async_step_{next_step_id}")()
mock_update_client = AsyncMock()
mock_manifest = Mock()
mock_firmware = Mock()
mock_firmware.filename = "zbt2_zigbee_ncp_7.4.4.0.gbl"
mock_firmware.metadata = {
"ezsp_version": "7.4.4.0",
"fw_type": "zbt2_zigbee_ncp",
"metadata_version": 2,
}
mock_manifest.firmwares = [mock_firmware]
mock_update_client.async_update_data.return_value = mock_manifest
mock_update_client.async_fetch_firmware.return_value = b"firmware_data"

with (
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners",
return_value=[],
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow._install_firmware_step",
autospec=True,
side_effect=mock_install_firmware_step,
"homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient",
return_value=mock_update_client,
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware",
return_value=FirmwareInfo(
device=usb_data.device,
firmware_type=ApplicationType.EZSP,
firmware_version="7.4.4.0 build 0",
owners=[],
source="probe",
),
) as flash_mock,
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info",
side_effect=[
# First call: probe before installation (returns current SPINEL firmware)
FirmwareInfo(
device=usb_data.device,
firmware_type=ApplicationType.SPINEL,
firmware_version="2.4.4.0",
owners=[],
source="probe",
),
# Second call: probe after installation (returns new EZSP firmware)
FirmwareInfo(
device=usb_data.device,
firmware_type=ApplicationType.EZSP,
firmware_version="7.4.4.0 build 0",
owners=[],
source="probe",
),
],
),
patch(
"homeassistant.components.homeassistant_hardware.util.parse_firmware_image"
),
):
pick_result = await hass.config_entries.options.async_configure(
Expand Down Expand Up @@ -298,6 +326,13 @@ async def mock_install_firmware_step(
"vid": usb_data.vid,
}

# Verify async_flash_silabs_firmware was called with ZBT-2's reset methods
assert flash_mock.call_count == 1
assert flash_mock.mock_calls[0].kwargs["bootloader_reset_methods"] == [
"rts_dtr",
"baudrate",
]


async def test_duplicate_discovery(hass: HomeAssistant) -> None:
"""Test config flow unique_id deduplication."""
Expand Down
5 changes: 3 additions & 2 deletions tests/components/homeassistant_hardware/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Test the Home Assistant hardware firmware config flow."""

import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Sequence
import contextlib
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
Expand All @@ -25,6 +25,7 @@
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.config_entries import (
SOURCE_IGNORE,
Expand Down Expand Up @@ -299,7 +300,7 @@ async def mock_flash_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
await asyncio.sleep(0)
Expand Down
7 changes: 4 additions & 3 deletions tests/components/homeassistant_hardware/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import asyncio
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator, Callable, Sequence
import dataclasses
import logging
from unittest.mock import Mock, patch
Expand All @@ -29,6 +29,7 @@
ApplicationType,
FirmwareInfo,
OwningIntegration,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
Expand Down Expand Up @@ -197,7 +198,7 @@ async def mock_async_setup_update_entities(
class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Mock SkyConnect firmware update entity."""

bootloader_reset_type = None
bootloader_reset_methods = []

def __init__(
self,
Expand Down Expand Up @@ -361,7 +362,7 @@ async def mock_flash_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
bootloader_reset_methods: Sequence[ResetTarget] = (),
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
await asyncio.sleep(0)
Expand Down
Loading
Loading