Flash ZBT-1 and Yellow firmwares from Core instead of using addons (#145019)

* Make `async_flash_firmware` a public helper

* [ZBT-1] Implement flashing for Zigbee and Thread within the config flow

* WIP: Begin fixing unit tests

* WIP: Unit tests, pass 2

* WIP: pass 3

* Fix hardware unit tests

* Have the individual hardware integrations depend on the firmware flasher

* Break out firmware filter into its own helper

* Mirror to Yellow

* Simplify

* Simplify

* Revert "Have the individual hardware integrations depend on the firmware flasher"

This reverts commit 096f4297dc3b0a0529ed6288c8baea92fcfbfb11.

* Move `async_flash_silabs_firmware` into `util`

* Fix existing unit tests

* Unconditionally upgrade Zigbee firmware during installation

* Fix failing error case unit tests

* Fix remaining failing unit tests

* Increase test coverage

* 100% test coverage

* Remove old translation strings

* Add new translation strings

* Do not probe OTBR firmware when completing the flow

* More translation strings

* Probe OTBR firmware info before starting the addon
This commit is contained in:
puddly 2025-06-24 16:21:02 -04:00 committed by GitHub
parent f735331699
commit 9e7c7ec97e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1084 additions and 1179 deletions

View File

@ -7,6 +7,8 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
from ha_silabs_firmware_client import FirmwareUpdateClient
from homeassistant.components.hassio import ( from homeassistant.components.hassio import (
AddonError, AddonError,
AddonInfo, AddonInfo,
@ -22,17 +24,17 @@ from homeassistant.config_entries import (
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from . import silabs_multiprotocol_addon
from .const import OTBR_DOMAIN, ZHA_DOMAIN from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import ( from .util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
OwningAddon, OwningAddon,
OwningIntegration, OwningIntegration,
async_flash_silabs_firmware,
get_otbr_addon_manager, get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
guess_firmware_info, guess_firmware_info,
guess_hardware_owners, guess_hardware_owners,
probe_silabs_firmware_info, probe_silabs_firmware_info,
@ -61,6 +63,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self.addon_install_task: asyncio.Task | None = None self.addon_install_task: asyncio.Task | None = None
self.addon_start_task: asyncio.Task | None = None self.addon_start_task: asyncio.Task | None = None
self.addon_uninstall_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task | None = None
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
@ -77,22 +80,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
return placeholders return placeholders
async def _async_set_addon_config(
self, config: dict, addon_manager: AddonManager
) -> None:
"""Set add-on config."""
try:
await addon_manager.async_set_addon_options(config)
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": addon_manager.addon_name,
},
) from err
async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo: async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo:
"""Return add-on info.""" """Return add-on info."""
try: try:
@ -150,6 +137,54 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
) )
) )
async def _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:
assert self._device is not None
if not self.firmware_install_task:
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
manifest = await client.async_update_data()
fw_meta = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
fw_data = await client.async_fetch_firmware(fw_meta)
self.firmware_install_task = self.hass.async_create_task(
async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_type=None,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
),
f"Flash {firmware_name} firmware",
)
if not self.firmware_install_task.done():
return self.async_show_progress(
step_id=step_id,
progress_action="install_firmware",
description_placeholders={
**self._get_translation_placeholders(),
"firmware_name": firmware_name,
},
progress_task=self.firmware_install_task,
)
return self.async_show_progress_done(next_step_id=next_step_id)
async def async_step_pick_firmware_zigbee( async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -160,68 +195,133 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(), description_placeholders=self._get_translation_placeholders(),
) )
# Allow the stick to be used with ZHA without flashing return await self.async_step_install_zigbee_firmware()
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
):
return await self.async_step_confirm_zigbee()
if not is_hassio(self.hass): async def async_step_install_zigbee_firmware(
return self.async_abort( self, user_input: dict[str, Any] | None = None
reason="not_hassio", ) -> ConfigFlowResult:
description_placeholders=self._get_translation_placeholders(), """Install Zigbee firmware."""
) raise NotImplementedError
# Only flash new firmware if we need to async def async_step_addon_operation_failed(
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass) self, user_input: dict[str, Any] | None = None
addon_info = await self._async_get_addon_info(fw_flasher_manager) ) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_zigbee_flasher_addon()
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_run_zigbee_flasher_addon()
# If the addon is already installed and running, fail
return self.async_abort( return self.async_abort(
reason="addon_already_running", reason=self._failed_addon_reason,
description_placeholders={ description_placeholders={
**self._get_translation_placeholders(), **self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name, "addon_name": self._failed_addon_name,
}, },
) )
async def async_step_install_zigbee_flasher_addon( async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Show progress dialog for installing the Zigbee flasher addon.""" """Confirm Zigbee setup."""
return await self._install_addon( assert self._device is not None
get_zigbee_flasher_addon_manager(self.hass), assert self._hardware_name is not None
"install_zigbee_flasher_addon",
"run_zigbee_flasher_addon", if user_input is None:
return self.async_show_form(
step_id="confirm_zigbee",
description_placeholders=self._get_translation_placeholders(),
)
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
context={"source": "hardware"},
data={
"name": self._hardware_name,
"port": {
"path": self._device,
"baudrate": 115200,
"flow_control": "hardware",
},
"radio_type": "ezsp",
},
) )
async def _install_addon( return self._async_flow_finished()
self,
addon_manager: silabs_multiprotocol_addon.WaitingAddonManager, async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
step_id: str, """Ensure the OTBR addon is set up and not running."""
next_step_id: str,
# We install the OTBR addon no matter what, since it is required to use Thread
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.RUNNING:
# We only fail setup if we have an instance of OTBR running *and* it's
# pointing to different hardware
if addon_info.options["device"] != self._device:
return self.async_abort(
reason="otbr_addon_already_running",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
)
# Otherwise, stop the addon before continuing to flash firmware
await otbr_manager.async_stop_addon()
return None
async def async_step_pick_firmware_thread(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Show progress dialog for installing an addon.""" """Pick Thread firmware."""
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if result := await self._ensure_thread_addon_setup():
return result
return await self.async_step_install_thread_firmware()
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
raise NotImplementedError
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon."""
addon_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(addon_manager) addon_info = await self._async_get_addon_info(addon_manager)
_LOGGER.debug("Flasher addon state: %s", addon_info) _LOGGER.debug("OTBR addon info: %s", addon_info)
if not self.addon_install_task: if not self.addon_install_task:
self.addon_install_task = self.hass.async_create_task( self.addon_install_task = self.hass.async_create_task(
addon_manager.async_install_addon_waiting(), addon_manager.async_install_addon_waiting(),
"Addon install", "OTBR addon install",
) )
if not self.addon_install_task.done(): if not self.addon_install_task.done():
return self.async_show_progress( return self.async_show_progress(
step_id=step_id, step_id="install_otbr_addon",
progress_action="install_addon", progress_action="install_addon",
description_placeholders={ description_placeholders={
**self._get_translation_placeholders(), **self._get_translation_placeholders(),
@ -240,208 +340,50 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
finally: finally:
self.addon_install_task = None self.addon_install_task = None
return self.async_show_progress_done(next_step_id=next_step_id) return self.async_show_progress_done(next_step_id="install_thread_firmware")
async def async_step_addon_operation_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
return self.async_abort(
reason=self._failed_addon_reason,
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": self._failed_addon_name,
},
)
async def async_step_run_zigbee_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure the flasher addon to point to the SkyConnect and run it."""
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(fw_flasher_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 115200,
"bootloader_baudrate": 115200,
"flow_control": True,
}
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, fw_flasher_manager)
if not self.addon_start_task:
async def start_and_wait_until_done() -> None:
await fw_flasher_manager.async_start_addon_waiting()
# Now that the addon is running, wait for it to finish
await fw_flasher_manager.async_wait_until_addon_state(
AddonState.NOT_RUNNING
)
self.addon_start_task = self.hass.async_create_task(
start_and_wait_until_done()
)
if not self.addon_start_task.done():
return self.async_show_progress(
step_id="run_zigbee_flasher_addon",
progress_action="run_zigbee_flasher_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
},
progress_task=self.addon_start_task,
)
try:
await self.addon_start_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
self._failed_addon_name = fw_flasher_manager.addon_name
self._failed_addon_reason = "addon_start_failed"
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_start_task = None
return self.async_show_progress_done(
next_step_id="uninstall_zigbee_flasher_addon"
)
async def async_step_uninstall_zigbee_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Uninstall the flasher addon."""
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
if not self.addon_uninstall_task:
_LOGGER.debug("Uninstalling flasher addon")
self.addon_uninstall_task = self.hass.async_create_task(
fw_flasher_manager.async_uninstall_addon_waiting()
)
if not self.addon_uninstall_task.done():
return self.async_show_progress(
step_id="uninstall_zigbee_flasher_addon",
progress_action="uninstall_zigbee_flasher_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": fw_flasher_manager.addon_name,
},
progress_task=self.addon_uninstall_task,
)
try:
await self.addon_uninstall_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
# The uninstall failing isn't critical so we can just continue
finally:
self.addon_uninstall_task = None
return self.async_show_progress_done(next_step_id="confirm_zigbee")
async def async_step_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
if user_input is not None:
await self.hass.config_entries.flow.async_init(
ZHA_DOMAIN,
context={"source": "hardware"},
data={
"name": self._hardware_name,
"port": {
"path": self._device,
"baudrate": 115200,
"flow_control": "hardware",
},
"radio_type": "ezsp",
},
)
return self._async_flow_finished()
return self.async_show_form(
step_id="confirm_zigbee",
description_placeholders=self._get_translation_placeholders(),
)
async def async_step_pick_firmware_thread(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
# We install the OTBR addon no matter what, since it is required to use Thread
if not is_hassio(self.hass):
return self.async_abort(
reason="not_hassio_thread",
description_placeholders=self._get_translation_placeholders(),
)
otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
if addon_info.state == AddonState.NOT_INSTALLED:
return await self.async_step_install_otbr_addon()
if addon_info.state == AddonState.NOT_RUNNING:
return await self.async_step_start_otbr_addon()
# If the addon is already installed and running, fail
return self.async_abort(
reason="otbr_addon_already_running",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon."""
return await self._install_addon(
get_otbr_addon_manager(self.hass), "install_otbr_addon", "start_otbr_addon"
)
async def async_step_start_otbr_addon( async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon.""" """Configure OTBR to point to the SkyConnect and run the addon."""
otbr_manager = get_otbr_addon_manager(self.hass) otbr_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(otbr_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 460800,
"flow_control": True,
"autoflash_firmware": True,
}
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, otbr_manager)
if not self.addon_start_task: if not self.addon_start_task:
# Before we start the addon, confirm that the correct firmware is running
# and populate `self._probed_firmware_info` with the correct information
if not await self._probe_firmware_info(
probe_methods=(ApplicationType.SPINEL,)
):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
addon_info = await self._async_get_addon_info(otbr_manager)
assert self._device is not None
new_addon_config = {
**addon_info.options,
"device": self._device,
"baudrate": 460800,
"flow_control": True,
"autoflash_firmware": False,
}
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
try:
await otbr_manager.async_set_addon_options(new_addon_config)
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
},
) from err
self.addon_start_task = self.hass.async_create_task( self.addon_start_task = self.hass.async_create_task(
otbr_manager.async_start_addon_waiting() otbr_manager.async_start_addon_waiting()
) )
@ -475,20 +417,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm OTBR setup.""" """Confirm OTBR setup."""
assert self._device is not None assert self._device is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)): if user_input is None:
return self.async_abort( return self.async_show_form(
reason="unsupported_firmware", step_id="confirm_otbr",
description_placeholders=self._get_translation_placeholders(), description_placeholders=self._get_translation_placeholders(),
) )
if user_input is not None: # OTBR discovery is done automatically via hassio
# OTBR discovery is done automatically via hassio return self._async_flow_finished()
return self._async_flow_finished()
return self.async_show_form(
step_id="confirm_otbr",
description_placeholders=self._get_translation_placeholders(),
)
@abstractmethod @abstractmethod
def _async_flow_finished(self) -> ConfigFlowResult: def _async_flow_finished(self) -> ConfigFlowResult:

View File

@ -10,22 +10,6 @@
"pick_firmware_thread": "Thread" "pick_firmware_thread": "Thread"
} }
}, },
"install_zigbee_flasher_addon": {
"title": "Installing flasher",
"description": "Installing the Silicon Labs Flasher add-on."
},
"run_zigbee_flasher_addon": {
"title": "Installing Zigbee firmware",
"description": "Installing Zigbee firmware. This will take about a minute."
},
"uninstall_zigbee_flasher_addon": {
"title": "Removing flasher",
"description": "Removing the Silicon Labs Flasher add-on."
},
"zigbee_flasher_failed": {
"title": "Zigbee installation failed",
"description": "The Zigbee firmware installation process was unsuccessful. Ensure no other software is trying to communicate with the {model} and try again."
},
"confirm_zigbee": { "confirm_zigbee": {
"title": "Zigbee setup complete", "title": "Zigbee setup complete",
"description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration." "description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration."
@ -55,9 +39,7 @@
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
}, },
"progress": { "progress": {
"install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.", "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
"run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.",
"uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed."
} }
} }
}, },
@ -110,16 +92,6 @@
"data": { "data": {
"disable_multi_pan": "Disable multiprotocol support" "disable_multi_pan": "Disable multiprotocol support"
} }
},
"install_flasher_addon": {
"title": "The Silicon Labs Flasher add-on installation has started"
},
"configure_flasher_addon": {
"title": "The Silicon Labs Flasher add-on installation has started"
},
"start_flasher_addon": {
"title": "Installing firmware",
"description": "Zigbee firmware is now being installed. This will take a few minutes."
} }
}, },
"error": { "error": {

View File

@ -2,15 +2,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncIterator, Callable from collections.abc import Callable
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any, cast from typing import Any, cast
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
from yarl import URL from yarl import URL
from homeassistant.components.update import ( from homeassistant.components.update import (
@ -20,18 +17,12 @@ from homeassistant.components.update import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.restore_state import ExtraStoredData from homeassistant.helpers.restore_state import ExtraStoredData
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FirmwareUpdateCoordinator from .coordinator import FirmwareUpdateCoordinator
from .helpers import async_register_firmware_info_callback from .helpers import async_register_firmware_info_callback
from .util import ( from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
ApplicationType,
FirmwareInfo,
guess_firmware_info,
probe_silabs_firmware_info,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -249,19 +240,11 @@ class BaseFirmwareUpdateEntity(
self._attr_update_percentage = round((offset * 100) / total_size) self._attr_update_percentage = round((offset * 100) / total_size)
self.async_write_ha_state() self.async_write_ha_state()
@asynccontextmanager # Switch to an indeterminate progress bar after installation is complete, since
async def _temporarily_stop_hardware_owners( # we probe the firmware after flashing
self, device: str if offset == total_size:
) -> AsyncIterator[None]: self._attr_update_percentage = None
"""Temporarily stop addons and integrations communicating with the device.""" self.async_write_ha_state()
firmware_info = await guess_firmware_info(self.hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(self.hass))
yield
async def async_install( async def async_install(
self, version: str | None, backup: bool, **kwargs: Any self, version: str | None, backup: bool, **kwargs: Any
@ -278,49 +261,18 @@ class BaseFirmwareUpdateEntity(
fw_data = await self.coordinator.client.async_fetch_firmware( fw_data = await self.coordinator.client.async_fetch_firmware(
self._latest_firmware self._latest_firmware
) )
fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data)
device = self._current_device try:
firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
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,
progress_callback=self._update_progress,
)
finally:
self._attr_in_progress = False
self.async_write_ha_state()
flasher = Flasher( self._firmware_info_callback(firmware_info)
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=self.bootloader_reset_type,
)
async with self._temporarily_stop_hardware_owners(device):
try:
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(
fw_image, progress_callback=self._update_progress
)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
# Probe the running application type with indeterminate progress
self._attr_update_percentage = None
self.async_write_ha_state()
firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(self.entity_description.expected_firmware_type,),
)
if firmware_info is None:
raise HomeAssistantError(
"Failed to probe the firmware after flashing"
)
self._firmware_info_callback(firmware_info)
finally:
self._attr_in_progress = False
self.async_write_ha_state()

View File

@ -4,18 +4,20 @@ from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator, Iterable from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
import logging import logging
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher from universal_silabs_flasher.flasher import Flasher
from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton from homeassistant.helpers.singleton import singleton
@ -333,3 +335,52 @@ async def probe_silabs_firmware_type(
return None return None
return fw_info.firmware_type return fw_info.firmware_type
async def async_flash_silabs_firmware(
hass: HomeAssistant,
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
),
bootloader_reset=bootloader_reset_type,
)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
# Flash the firmware, with progress
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info

View File

@ -32,6 +32,7 @@ from .const import (
FIRMWARE, FIRMWARE,
FIRMWARE_VERSION, FIRMWARE_VERSION,
MANUFACTURER, MANUFACTURER,
NABU_CASA_FIRMWARE_RELEASES_URL,
PID, PID,
PRODUCT, PRODUCT,
SERIAL_NUMBER, SERIAL_NUMBER,
@ -45,19 +46,29 @@ _LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
class TranslationPlaceholderProtocol(Protocol): class FirmwareInstallFlowProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow`'s translation placeholders.""" """Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
return {} return {}
async def _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: ...
else: else:
# Multiple inheritance with `Protocol` seems to break # Multiple inheritance with `Protocol` seems to break
TranslationPlaceholderProtocol = object FirmwareInstallFlowProtocol = object
class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProtocol): class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Translation placeholder mixin for Home Assistant SkyConnect.""" """Mixin for Home Assistant SkyConnect firmware methods."""
context: ConfigFlowContext context: ConfigFlowContext
@ -72,9 +83,35 @@ class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProt
return placeholders return placeholders
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="skyconnect_zigbee_ncp",
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
)
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="skyconnect_openthread_rcp",
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
next_step_id="start_otbr_addon",
)
class HomeAssistantSkyConnectConfigFlow( class HomeAssistantSkyConnectConfigFlow(
SkyConnectTranslationMixin, SkyConnectFirmwareMixin,
firmware_config_flow.BaseFirmwareConfigFlow, firmware_config_flow.BaseFirmwareConfigFlow,
domain=DOMAIN, domain=DOMAIN,
): ):
@ -207,7 +244,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
class HomeAssistantSkyConnectOptionsFlowHandler( class HomeAssistantSkyConnectOptionsFlowHandler(
SkyConnectTranslationMixin, firmware_config_flow.BaseFirmwareOptionsFlow SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow
): ):
"""Zigbee and Thread options flow handlers.""" """Zigbee and Thread options flow handlers."""

View File

@ -48,16 +48,6 @@
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
} }
}, },
"install_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
},
"configure_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
},
"start_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
},
"pick_firmware": { "pick_firmware": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
@ -66,18 +56,6 @@
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]" "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
} }
}, },
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": { "confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@ -120,9 +98,7 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
} }
}, },
"config": { "config": {
@ -136,22 +112,6 @@
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]" "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
} }
}, },
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"uninstall_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": { "confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@ -191,9 +151,7 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
} }
}, },
"exceptions": { "exceptions": {

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import logging import logging
from typing import Any, final from typing import TYPE_CHECKING, Any, Protocol, final
import aiohttp import aiohttp
import voluptuous as vol import voluptuous as vol
@ -31,6 +31,7 @@ from homeassistant.components.homeassistant_hardware.util import (
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_HARDWARE, SOURCE_HARDWARE,
ConfigEntry, ConfigEntry,
ConfigEntryBaseFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
) )
@ -41,6 +42,7 @@ from .const import (
DOMAIN, DOMAIN,
FIRMWARE, FIRMWARE,
FIRMWARE_VERSION, FIRMWARE_VERSION,
NABU_CASA_FIRMWARE_RELEASES_URL,
RADIO_DEVICE, RADIO_DEVICE,
ZHA_DOMAIN, ZHA_DOMAIN,
ZHA_HW_DISCOVERY_DATA, ZHA_HW_DISCOVERY_DATA,
@ -57,8 +59,59 @@ STEP_HW_SETTINGS_SCHEMA = vol.Schema(
} }
) )
if TYPE_CHECKING:
class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): class FirmwareInstallFlowProtocol(Protocol):
"""Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
async def _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: ...
else:
# Multiple inheritance with `Protocol` seems to break
FirmwareInstallFlowProtocol = object
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="yellow_zigbee_ncp",
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
)
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self._install_firmware_step(
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
fw_type="yellow_openthread_rcp",
firmware_name="OpenThread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
next_step_id="start_otbr_addon",
)
class HomeAssistantYellowConfigFlow(
YellowFirmwareMixin, BaseFirmwareConfigFlow, domain=DOMAIN
):
"""Handle a config flow for Home Assistant Yellow.""" """Handle a config flow for Home Assistant Yellow."""
VERSION = 1 VERSION = 1
@ -275,7 +328,9 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
class HomeAssistantYellowOptionsFlowHandler( class HomeAssistantYellowOptionsFlowHandler(
BaseHomeAssistantYellowOptionsFlow, BaseFirmwareOptionsFlow YellowFirmwareMixin,
BaseHomeAssistantYellowOptionsFlow,
BaseFirmwareOptionsFlow,
): ):
"""Handle a firmware options flow for Home Assistant Yellow.""" """Handle a firmware options flow for Home Assistant Yellow."""

View File

@ -71,16 +71,6 @@
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
} }
}, },
"install_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
},
"configure_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
},
"start_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
},
"pick_firmware": { "pick_firmware": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
@ -89,18 +79,6 @@
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]" "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
} }
}, },
"install_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
},
"run_zigbee_flasher_addon": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
},
"zigbee_flasher_failed": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
},
"confirm_zigbee": { "confirm_zigbee": {
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
@ -145,9 +123,7 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
} }
}, },
"entity": { "entity": {

View File

@ -4,9 +4,15 @@ import asyncio
from collections.abc import Awaitable, Callable, Generator, Iterator from collections.abc import Awaitable, Callable, Generator, Iterator
import contextlib import contextlib
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock, call, patch from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
from ha_silabs_firmware_client import (
FirmwareManifest,
FirmwareMetadata,
FirmwareUpdateClient,
)
import pytest import pytest
from yarl import URL
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 (
@ -19,12 +25,13 @@ from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
get_otbr_addon_manager, get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
) )
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
@ -37,6 +44,7 @@ from tests.common import (
TEST_DOMAIN = "test_firmware_domain" TEST_DOMAIN = "test_firmware_domain"
TEST_DEVICE = "/dev/SomeDevice123" TEST_DEVICE = "/dev/SomeDevice123"
TEST_HARDWARE_NAME = "Some Hardware Name" TEST_HARDWARE_NAME = "Some Hardware Name"
TEST_RELEASES_URL = URL("http://invalid/releases")
class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN):
@ -62,6 +70,32 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN):
return await self.async_step_confirm() return await self.async_step_confirm()
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self._install_firmware_step(
fw_update_url=TEST_RELEASES_URL,
fw_type="fake_zigbee_ncp",
firmware_name="Zigbee",
expected_installed_firmware_type=ApplicationType.EZSP,
step_id="install_zigbee_firmware",
next_step_id="confirm_zigbee",
)
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self._install_firmware_step(
fw_update_url=TEST_RELEASES_URL,
fw_type="fake_openthread_rcp",
firmware_name="Thread",
expected_installed_firmware_type=ApplicationType.SPINEL,
step_id="install_thread_firmware",
next_step_id="start_otbr_addon",
)
def _async_flow_finished(self) -> ConfigFlowResult: def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry.""" """Create the config entry."""
assert self._device is not None assert self._device is not None
@ -99,6 +133,18 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow):
# Regenerate the translation placeholders # Regenerate the translation placeholders
self._get_translation_placeholders() self._get_translation_placeholders()
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Zigbee firmware."""
return await self.async_step_confirm_zigbee()
async def async_step_install_thread_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install Thread firmware."""
return await self.async_step_start_otbr_addon()
def _async_flow_finished(self) -> ConfigFlowResult: def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry.""" """Create the config entry."""
assert self._probed_firmware_info is not None assert self._probed_firmware_info is not None
@ -146,12 +192,22 @@ def delayed_side_effect() -> Callable[..., Awaitable[None]]:
return side_effect return side_effect
def create_mock_owner() -> Mock:
"""Mock for OwningAddon / OwningIntegration."""
owner = Mock()
owner.is_running = AsyncMock(return_value=True)
owner.temporarily_stop = MagicMock()
owner.temporarily_stop.return_value.__aenter__.return_value = AsyncMock()
return owner
@contextlib.contextmanager @contextlib.contextmanager
def mock_addon_info( def mock_firmware_info(
hass: HomeAssistant, hass: HomeAssistant,
*, *,
is_hassio: bool = True, is_hassio: bool = True,
app_type: ApplicationType | None = ApplicationType.EZSP, probe_app_type: ApplicationType | None = ApplicationType.EZSP,
otbr_addon_info: AddonInfo = AddonInfo( otbr_addon_info: AddonInfo = AddonInfo(
available=True, available=True,
hostname=None, hostname=None,
@ -160,29 +216,9 @@ def mock_addon_info(
update_available=False, update_available=False,
version=None, version=None,
), ),
flasher_addon_info: AddonInfo = AddonInfo( flash_app_type: ApplicationType = ApplicationType.EZSP,
available=True,
hostname=None,
options={},
state=AddonState.NOT_INSTALLED,
update_available=False,
version=None,
),
) -> Iterator[tuple[Mock, Mock]]: ) -> Iterator[tuple[Mock, Mock]]:
"""Mock the main addon states for the config flow.""" """Mock the main addon states for the config flow."""
mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass))
mock_flasher_manager.addon_name = "Silicon Labs Flasher"
mock_flasher_manager.async_start_addon_waiting = AsyncMock(
side_effect=delayed_side_effect()
)
mock_flasher_manager.async_install_addon_waiting = AsyncMock(
side_effect=delayed_side_effect()
)
mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock(
side_effect=delayed_side_effect()
)
mock_flasher_manager.async_get_addon_info.return_value = flasher_addon_info
mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass))
mock_otbr_manager.addon_name = "OpenThread Border Router" mock_otbr_manager.addon_name = "OpenThread Border Router"
mock_otbr_manager.async_install_addon_waiting = AsyncMock( mock_otbr_manager.async_install_addon_waiting = AsyncMock(
@ -196,17 +232,73 @@ def mock_addon_info(
) )
mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info
if app_type is None: mock_update_client = AsyncMock(spec_set=FirmwareUpdateClient)
firmware_info_result = None mock_update_client.async_update_data.return_value = FirmwareManifest(
url=TEST_RELEASES_URL,
html_url=TEST_RELEASES_URL / "html",
created_at=utcnow(),
firmwares=[
FirmwareMetadata(
filename="fake_openthread_rcp_7.4.4.0_variant.gbl",
checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
size=123,
release_notes="Some release notes",
metadata={},
url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl",
),
FirmwareMetadata(
filename="fake_zigbee_ncp_7.4.4.0_variant.gbl",
checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
size=123,
release_notes="Some release notes",
metadata={},
url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl",
),
],
)
if probe_app_type is None:
probed_firmware_info = None
else: else:
firmware_info_result = FirmwareInfo( probed_firmware_info = FirmwareInfo(
device="/dev/ttyUSB0", # Not used device="/dev/ttyUSB0", # Not used
firmware_type=app_type, firmware_type=probe_app_type,
firmware_version=None, firmware_version=None,
owners=[], owners=[],
source="probe", source="probe",
) )
if flash_app_type is None:
flashed_firmware_info = None
else:
flashed_firmware_info = FirmwareInfo(
device=TEST_DEVICE,
firmware_type=flash_app_type,
firmware_version="7.4.4.0",
owners=[create_mock_owner()],
source="probe",
)
async def mock_flash_firmware(
hass: HomeAssistant,
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
await asyncio.sleep(0)
progress_callback(0, 100)
await asyncio.sleep(0)
progress_callback(50, 100)
await asyncio.sleep(0)
progress_callback(100, 100)
if flashed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return flashed_firmware_info
with ( with (
patch( patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager",
@ -216,10 +308,6 @@ def mock_addon_info(
"homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager",
return_value=mock_otbr_manager, return_value=mock_otbr_manager,
), ),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager",
return_value=mock_flasher_manager,
),
patch( patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio",
return_value=is_hassio, return_value=is_hassio,
@ -229,81 +317,85 @@ def mock_addon_info(
return_value=is_hassio, return_value=is_hassio,
), ),
patch( patch(
# We probe once before installation and once after
"homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info",
return_value=firmware_info_result, side_effect=(probed_firmware_info, flashed_firmware_info),
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient",
return_value=mock_update_client,
),
patch(
"homeassistant.components.homeassistant_hardware.util.parse_firmware_image"
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware",
side_effect=mock_flash_firmware,
), ),
): ):
yield mock_otbr_manager, mock_flasher_manager yield mock_otbr_manager
async def consume_progress_flow(
hass: HomeAssistant,
flow_id: str,
valid_step_ids: tuple[str],
) -> ConfigFlowResult:
"""Consume a progress flow until it is done."""
while True:
result = await hass.config_entries.flow.async_configure(flow_id)
flow_id = result["flow_id"]
if result["type"] != FlowResultType.SHOW_PROGRESS:
break
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] in valid_step_ids
await asyncio.sleep(0.1)
return result
async def test_config_flow_zigbee(hass: HomeAssistant) -> None: async def test_config_flow_zigbee(hass: HomeAssistant) -> None:
"""Test the config flow.""" """Test the config flow."""
result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
assert result["type"] is FlowResultType.MENU assert init_result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware" assert init_result["step_id"] == "pick_firmware"
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.SPINEL, probe_app_type=ApplicationType.SPINEL,
) as (mock_otbr_manager, mock_flasher_manager): flash_app_type=ApplicationType.EZSP,
# Pick the menu option: we are now installing the addon ):
result = await hass.config_entries.flow.async_configure( # Pick the menu option: we are flashing the firmware
result["flow_id"], pick_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
) )
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "install_addon"
assert result["step_id"] == "install_zigbee_flasher_addon"
assert result["description_placeholders"]["firmware_type"] == "spinel"
await hass.async_block_till_done(wait_background_tasks=True) assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
assert pick_result["progress_action"] == "install_firmware"
assert pick_result["step_id"] == "install_zigbee_firmware"
# Progress the flow, we are now configuring the addon and running it confirm_result = await consume_progress_flow(
result = await hass.config_entries.flow.async_configure(result["flow_id"]) hass,
assert result["type"] is FlowResultType.SHOW_PROGRESS flow_id=pick_result["flow_id"],
assert result["step_id"] == "run_zigbee_flasher_addon" valid_step_ids=("install_zigbee_firmware",),
assert result["progress_action"] == "run_zigbee_flasher_addon"
assert mock_flasher_manager.async_set_addon_options.mock_calls == [
call(
{
"device": TEST_DEVICE,
"baudrate": 115200,
"bootloader_baudrate": 115200,
"flow_control": True,
}
)
]
await hass.async_block_till_done(wait_background_tasks=True)
# Progress the flow, we are now uninstalling the addon
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "uninstall_zigbee_flasher_addon"
assert result["progress_action"] == "uninstall_zigbee_flasher_addon"
await hass.async_block_till_done(wait_background_tasks=True)
# We are finally done with the addon
assert mock_flasher_manager.async_uninstall_addon_waiting.mock_calls == [call()]
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_zigbee"
with mock_addon_info(
hass,
app_type=ApplicationType.EZSP,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
) )
assert result["type"] is FlowResultType.CREATE_ENTRY
config_entry = result["result"] assert confirm_result["type"] is FlowResultType.FORM
assert confirm_result["step_id"] == "confirm_zigbee"
create_result = await hass.config_entries.flow.async_configure(
confirm_result["flow_id"], user_input={}
)
assert create_result["type"] is FlowResultType.CREATE_ENTRY
config_entry = create_result["result"]
assert config_entry.data == { assert config_entry.data == {
"firmware": "ezsp", "firmware": "ezsp",
"device": TEST_DEVICE, "device": TEST_DEVICE,
@ -328,52 +420,20 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) ->
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware" assert result["step_id"] == "pick_firmware"
with mock_addon_info( with mock_firmware_info(hass, probe_app_type=ApplicationType.SPINEL):
hass,
app_type=ApplicationType.SPINEL,
flasher_addon_info=AddonInfo(
available=True,
hostname=None,
options={
"device": "",
"baudrate": 115200,
"bootloader_baudrate": 115200,
"flow_control": True,
},
state=AddonState.NOT_RUNNING,
update_available=False,
version="1.2.3",
),
) as (mock_otbr_manager, mock_flasher_manager):
# Pick the menu option: we skip installation, instead we directly run it # Pick the menu option: we skip installation, instead we directly run it
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
) )
assert result["type"] is FlowResultType.SHOW_PROGRESS # Confirm
assert result["step_id"] == "run_zigbee_flasher_addon"
assert result["progress_action"] == "run_zigbee_flasher_addon"
assert result["description_placeholders"]["firmware_type"] == "spinel"
assert mock_flasher_manager.async_set_addon_options.mock_calls == [
call(
{
"device": TEST_DEVICE,
"baudrate": 115200,
"bootloader_baudrate": 115200,
"flow_control": True,
}
)
]
# Uninstall the addon
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Done # Done
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
): ):
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
@ -409,28 +469,29 @@ async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None:
async def test_config_flow_thread(hass: HomeAssistant) -> None: async def test_config_flow_thread(hass: HomeAssistant) -> None:
"""Test the config flow.""" """Test the config flow."""
result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
assert result["type"] is FlowResultType.MENU assert init_result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware" assert init_result["step_id"] == "pick_firmware"
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): flash_app_type=ApplicationType.SPINEL,
) as mock_otbr_manager:
# Pick the menu option # Pick the menu option
result = await hass.config_entries.flow.async_configure( pick_result = await hass.config_entries.flow.async_configure(
result["flow_id"], init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD},
) )
assert result["type"] is FlowResultType.SHOW_PROGRESS assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "install_addon" assert pick_result["progress_action"] == "install_addon"
assert result["step_id"] == "install_otbr_addon" assert pick_result["step_id"] == "install_otbr_addon"
assert result["description_placeholders"]["firmware_type"] == "ezsp" assert pick_result["description_placeholders"]["firmware_type"] == "ezsp"
assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME assert pick_result["description_placeholders"]["model"] == TEST_HARDWARE_NAME
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
@ -441,19 +502,37 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None:
"device": "", "device": "",
"baudrate": 460800, "baudrate": 460800,
"flow_control": True, "flow_control": True,
"autoflash_firmware": True, "autoflash_firmware": False,
}, },
state=AddonState.NOT_RUNNING, state=AddonState.NOT_RUNNING,
update_available=False, update_available=False,
version="1.2.3", version="1.2.3",
) )
# Progress the flow, it is now configuring the addon and running it # Progress the flow, it is now installing firmware
result = await hass.config_entries.flow.async_configure(result["flow_id"]) confirm_otbr_result = await consume_progress_flow(
hass,
flow_id=pick_result["flow_id"],
valid_step_ids=(
"pick_firmware_thread",
"install_otbr_addon",
"install_thread_firmware",
"start_otbr_addon",
),
)
assert result["type"] is FlowResultType.SHOW_PROGRESS # Installation will conclude with the config entry being created
assert result["step_id"] == "start_otbr_addon" create_result = await hass.config_entries.flow.async_configure(
assert result["progress_action"] == "start_otbr_addon" confirm_otbr_result["flow_id"], user_input={}
)
assert create_result["type"] is FlowResultType.CREATE_ENTRY
config_entry = create_result["result"]
assert config_entry.data == {
"firmware": "spinel",
"device": TEST_DEVICE,
"hardware": TEST_HARDWARE_NAME,
}
assert mock_otbr_manager.async_set_addon_options.mock_calls == [ assert mock_otbr_manager.async_set_addon_options.mock_calls == [
call( call(
@ -461,44 +540,22 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None:
"device": TEST_DEVICE, "device": TEST_DEVICE,
"baudrate": 460800, "baudrate": 460800,
"flow_control": True, "flow_control": True,
"autoflash_firmware": True, "autoflash_firmware": False,
} }
) )
] ]
await hass.async_block_till_done(wait_background_tasks=True)
# The addon is now running
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_otbr"
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
config_entry = result["result"]
assert config_entry.data == {
"firmware": "spinel",
"device": TEST_DEVICE,
"hardware": TEST_HARDWARE_NAME,
}
async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None: async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None:
"""Test the Thread config flow, addon is already installed.""" """Test the Thread config flow, addon is already installed."""
result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
flash_app_type=ApplicationType.SPINEL,
otbr_addon_info=AddonInfo( otbr_addon_info=AddonInfo(
available=True, available=True,
hostname=None, hostname=None,
@ -507,81 +564,50 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -
update_available=False, update_available=False,
version=None, version=None,
), ),
) as (mock_otbr_manager, mock_flasher_manager): ) as mock_otbr_manager:
# Pick the menu option # Pick the menu option
result = await hass.config_entries.flow.async_configure( pick_result = await hass.config_entries.flow.async_configure(
result["flow_id"], init_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD},
) )
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_otbr_addon"
assert result["progress_action"] == "start_otbr_addon"
# Progress
confirm_otbr_result = await consume_progress_flow(
hass,
flow_id=pick_result["flow_id"],
valid_step_ids=(
"pick_firmware_thread",
"install_thread_firmware",
"start_otbr_addon",
),
)
# We're now waiting to confirm OTBR
assert confirm_otbr_result["type"] is FlowResultType.FORM
assert confirm_otbr_result["step_id"] == "confirm_otbr"
# The addon has been installed
assert mock_otbr_manager.async_set_addon_options.mock_calls == [ assert mock_otbr_manager.async_set_addon_options.mock_calls == [
call( call(
{ {
"device": TEST_DEVICE, "device": TEST_DEVICE,
"baudrate": 460800, "baudrate": 460800,
"flow_control": True, "flow_control": True,
"autoflash_firmware": True, "autoflash_firmware": False, # And firmware flashing is disabled
} }
) )
] ]
await hass.async_block_till_done(wait_background_tasks=True) # Finally, create the config entry
create_result = await hass.config_entries.flow.async_configure(
# The addon is now running confirm_otbr_result["flow_id"], user_input={}
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_otbr"
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert create_result["type"] is FlowResultType.CREATE_ENTRY
assert create_result["result"].data == {
"firmware": "spinel",
async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: "device": TEST_DEVICE,
"""Test when the stick is used with a non-hassio setup.""" "hardware": TEST_HARDWARE_NAME,
result = await hass.config_entries.flow.async_init( }
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
is_hassio=False,
app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_zigbee"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
config_entry = result["result"]
assert config_entry.data == {
"firmware": "ezsp",
"device": TEST_DEVICE,
"hardware": TEST_HARDWARE_NAME,
}
# Ensure a ZHA discovery flow has been created
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
zha_flow = flows[0]
assert zha_flow["handler"] == "zha"
assert zha_flow["context"]["source"] == "hardware"
assert zha_flow["step_id"] == "confirm"
@pytest.mark.usefixtures("addon_store_info") @pytest.mark.usefixtures("addon_store_info")
@ -601,10 +627,11 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_setup(config_entry.entry_id) assert await hass.config_entries.async_setup(config_entry.entry_id)
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): flash_app_type=ApplicationType.SPINEL,
) as mock_otbr_manager:
# First step is confirmation # First step is confirmation
result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.MENU assert result["type"] is FlowResultType.MENU
@ -630,7 +657,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None:
"device": "", "device": "",
"baudrate": 460800, "baudrate": 460800,
"flow_control": True, "flow_control": True,
"autoflash_firmware": True, "autoflash_firmware": False,
}, },
state=AddonState.NOT_RUNNING, state=AddonState.NOT_RUNNING,
update_available=False, update_available=False,
@ -650,7 +677,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None:
"device": TEST_DEVICE, "device": TEST_DEVICE,
"baudrate": 460800, "baudrate": 460800,
"flow_control": True, "flow_control": True,
"autoflash_firmware": True, "autoflash_firmware": False,
} }
) )
] ]
@ -662,10 +689,6 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_otbr" assert result["step_id"] == "confirm_otbr"
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
):
# We are now done # We are now done
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={} result["flow_id"], user_input={}
@ -700,57 +723,23 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None:
assert result["description_placeholders"]["firmware_type"] == "spinel" assert result["description_placeholders"]["firmware_type"] == "spinel"
assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.SPINEL, probe_app_type=ApplicationType.SPINEL,
) as (mock_otbr_manager, mock_flasher_manager): ):
# Pick the menu option: we are now installing the addon # Pick the menu option: we are now installing the addon
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
) )
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "install_addon"
assert result["step_id"] == "install_zigbee_flasher_addon"
await hass.async_block_till_done(wait_background_tasks=True)
# Progress the flow, we are now configuring the addon and running it
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "run_zigbee_flasher_addon"
assert result["progress_action"] == "run_zigbee_flasher_addon"
assert mock_flasher_manager.async_set_addon_options.mock_calls == [
call(
{
"device": TEST_DEVICE,
"baudrate": 115200,
"bootloader_baudrate": 115200,
"flow_control": True,
}
)
]
await hass.async_block_till_done(wait_background_tasks=True)
# Progress the flow, we are now uninstalling the addon
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "uninstall_zigbee_flasher_addon"
assert result["progress_action"] == "uninstall_zigbee_flasher_addon"
await hass.async_block_till_done(wait_background_tasks=True)
# We are finally done with the addon
assert mock_flasher_manager.async_uninstall_addon_waiting.mock_calls == [call()]
result = await hass.config_entries.options.async_configure(result["flow_id"]) result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_zigbee" assert result["step_id"] == "confirm_zigbee"
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
): ):
# We are now done # We are now done
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(

View File

@ -21,8 +21,8 @@ from .test_config_flow import (
TEST_DEVICE, TEST_DEVICE,
TEST_DOMAIN, TEST_DOMAIN,
TEST_HARDWARE_NAME, TEST_HARDWARE_NAME,
delayed_side_effect, consume_progress_flow,
mock_addon_info, mock_firmware_info,
mock_test_firmware_platform, # noqa: F401 mock_test_firmware_platform, # noqa: F401
) )
@ -51,10 +51,10 @@ async def test_config_flow_cannot_probe_firmware(
) -> None: ) -> None:
"""Test failure case when firmware cannot be probed.""" """Test failure case when firmware cannot be probed."""
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=None, probe_app_type=None,
) as (mock_otbr_manager, mock_flasher_manager): ):
# Start the flow # Start the flow
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
@ -69,283 +69,6 @@ async def test_config_flow_cannot_probe_firmware(
assert result["reason"] == "unsupported_firmware" assert result["reason"] == "unsupported_firmware"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
)
async def test_config_flow_zigbee_not_hassio_wrong_firmware(
hass: HomeAssistant,
) -> None:
"""Test when the stick is used with a non-hassio setup but the firmware is bad."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
is_hassio=False,
) as (mock_otbr_manager, mock_flasher_manager):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "not_hassio"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
)
async def test_config_flow_zigbee_flasher_addon_already_running(
hass: HomeAssistant,
) -> None:
"""Test failure case when flasher addon is already running."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
flasher_addon_info=AddonInfo(
available=True,
hostname=None,
options={},
state=AddonState.RUNNING,
update_available=False,
version="1.0.0",
),
) as (mock_otbr_manager, mock_flasher_manager):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
# Cannot get addon info
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "addon_already_running"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
)
async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) -> None:
"""Test failure case when flasher addon cannot be installed."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
flasher_addon_info=AddonInfo(
available=True,
hostname=None,
options={},
state=AddonState.RUNNING,
update_available=False,
version="1.0.0",
),
) as (mock_otbr_manager, mock_flasher_manager):
mock_flasher_manager.async_get_addon_info.side_effect = AddonError()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
# Cannot get addon info
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "addon_info_failed"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
)
async def test_config_flow_zigbee_flasher_addon_install_fails(
hass: HomeAssistant,
) -> None:
"""Test failure case when flasher addon cannot be installed."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
) as (mock_otbr_manager, mock_flasher_manager):
mock_flasher_manager.async_install_addon_waiting = AsyncMock(
side_effect=AddonError()
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
# Cannot install addon
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "addon_install_failed"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
)
async def test_config_flow_zigbee_flasher_addon_set_config_fails(
hass: HomeAssistant,
) -> None:
"""Test failure case when flasher addon cannot be configured."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
) as (mock_otbr_manager, mock_flasher_manager):
mock_flasher_manager.async_install_addon_waiting = AsyncMock(
side_effect=delayed_side_effect()
)
mock_flasher_manager.async_set_addon_options = AsyncMock(
side_effect=AddonError()
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "addon_set_config_failed"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
)
async def test_config_flow_zigbee_flasher_run_fails(hass: HomeAssistant) -> None:
"""Test failure case when flasher addon fails to run."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
) as (mock_otbr_manager, mock_flasher_manager):
mock_flasher_manager.async_start_addon_waiting = AsyncMock(
side_effect=AddonError()
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "addon_start_failed"
async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) -> None:
"""Test failure case when flasher addon uninstall fails."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.SPINEL,
) as (mock_otbr_manager, mock_flasher_manager):
mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock(
side_effect=AddonError()
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
# Uninstall failure isn't critical
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_zigbee"
@pytest.mark.parametrize(
"ignore_translations_for_mock_domains",
["test_firmware_domain"],
)
async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None:
"""Test the config flow failing due to Zigbee firmware not being detected."""
result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "pick_firmware"
with mock_addon_info(
hass,
app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager):
# Pick the menu option: we are now installing the addon
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_zigbee"
with mock_addon_info(
hass,
app_type=None, # Probing fails
) as (mock_otbr_manager, mock_flasher_manager):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unsupported_firmware"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"ignore_translations_for_mock_domains", "ignore_translations_for_mock_domains",
["test_firmware_domain"], ["test_firmware_domain"],
@ -356,11 +79,11 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None:
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
is_hassio=False, is_hassio=False,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={} result["flow_id"], user_input={}
) )
@ -383,10 +106,10 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None:
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): ) as mock_otbr_manager:
mock_otbr_manager.async_get_addon_info.side_effect = AddonError() mock_otbr_manager.async_get_addon_info.side_effect = AddonError()
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={} result["flow_id"], user_input={}
@ -405,24 +128,26 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None:
"ignore_translations_for_mock_domains", "ignore_translations_for_mock_domains",
["test_firmware_domain"], ["test_firmware_domain"],
) )
async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> None: async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) -> None:
"""Test failure case when the Thread addon is already running.""" """Test failure case when the Thread addon is already running."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
otbr_addon_info=AddonInfo( otbr_addon_info=AddonInfo(
available=True, available=True,
hostname=None, hostname=None,
options={}, options={
"device": TEST_DEVICE + "2", # A different device
},
state=AddonState.RUNNING, state=AddonState.RUNNING,
update_available=False, update_available=False,
version="1.0.0", version="1.0.0",
), ),
) as (mock_otbr_manager, mock_flasher_manager): ) as mock_otbr_manager:
mock_otbr_manager.async_install_addon_waiting = AsyncMock( mock_otbr_manager.async_install_addon_waiting = AsyncMock(
side_effect=AddonError() side_effect=AddonError()
) )
@ -450,10 +175,10 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): ) as mock_otbr_manager:
mock_otbr_manager.async_install_addon_waiting = AsyncMock( mock_otbr_manager.async_install_addon_waiting = AsyncMock(
side_effect=AddonError() side_effect=AddonError()
) )
@ -477,29 +202,51 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No
) )
async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None:
"""Test failure case when flasher addon cannot be configured.""" """Test failure case when flasher addon cannot be configured."""
result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): ) as mock_otbr_manager:
async def install_addon() -> None:
mock_otbr_manager.async_get_addon_info.return_value = AddonInfo(
available=True,
hostname=None,
options={"device": TEST_DEVICE},
state=AddonState.NOT_RUNNING,
update_available=False,
version="1.0.0",
)
mock_otbr_manager.async_install_addon_waiting = AsyncMock(
side_effect=install_addon
)
mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError())
result = await hass.config_entries.flow.async_configure( confirm_result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={} init_result["flow_id"], user_input={}
) )
result = await hass.config_entries.flow.async_configure(
result["flow_id"], pick_thread_result = await hass.config_entries.flow.async_configure(
confirm_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD},
) )
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"]) pick_thread_progress_result = await consume_progress_flow(
assert result["type"] == FlowResultType.ABORT hass,
assert result["reason"] == "addon_set_config_failed" flow_id=pick_thread_result["flow_id"],
valid_step_ids=(
"pick_firmware_thread",
"install_thread_firmware",
"start_otbr_addon",
),
)
assert pick_thread_progress_result["type"] == FlowResultType.ABORT
assert pick_thread_progress_result["reason"] == "addon_set_config_failed"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -508,63 +255,45 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) ->
) )
async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None:
"""Test failure case when flasher addon fails to run.""" """Test failure case when flasher addon fails to run."""
result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): otbr_addon_info=AddonInfo(
available=True,
hostname=None,
options={"device": TEST_DEVICE},
state=AddonState.NOT_RUNNING,
update_available=False,
version="1.0.0",
),
) as mock_otbr_manager:
mock_otbr_manager.async_start_addon_waiting = AsyncMock( mock_otbr_manager.async_start_addon_waiting = AsyncMock(
side_effect=AddonError() side_effect=AddonError()
) )
result = await hass.config_entries.flow.async_configure( confirm_result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={} init_result["flow_id"], user_input={}
) )
result = await hass.config_entries.flow.async_configure( pick_thread_result = await hass.config_entries.flow.async_configure(
result["flow_id"], confirm_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD},
) )
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"]) pick_thread_progress_result = await consume_progress_flow(
assert result["type"] == FlowResultType.ABORT hass,
assert result["reason"] == "addon_start_failed" flow_id=pick_thread_result["flow_id"],
valid_step_ids=(
"pick_firmware_thread",
async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) -> None: "install_thread_firmware",
"""Test failure case when flasher addon uninstall fails.""" "start_otbr_addon",
result = await hass.config_entries.flow.async_init( ),
TEST_DOMAIN, context={"source": "hardware"}
)
with mock_addon_info(
hass,
app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager):
mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock(
side_effect=AddonError()
) )
result = await hass.config_entries.flow.async_configure( assert pick_thread_progress_result["type"] == FlowResultType.ABORT
result["flow_id"], user_input={} assert pick_thread_progress_result["reason"] == "addon_start_failed"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
# Uninstall failure isn't critical
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_otbr"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -573,40 +302,43 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) -
) )
async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None:
"""Test the config flow failing due to OpenThread firmware not being detected.""" """Test the config flow failing due to OpenThread firmware not being detected."""
result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
TEST_DOMAIN, context={"source": "hardware"} TEST_DOMAIN, context={"source": "hardware"}
) )
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.EZSP, probe_app_type=ApplicationType.EZSP,
) as (mock_otbr_manager, mock_flasher_manager): flash_app_type=None,
result = await hass.config_entries.flow.async_configure( otbr_addon_info=AddonInfo(
result["flow_id"], user_input={} available=True,
hostname=None,
options={"device": TEST_DEVICE},
state=AddonState.RUNNING,
update_available=False,
version="1.0.0",
),
):
confirm_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"], user_input={}
) )
result = await hass.config_entries.flow.async_configure( pick_thread_result = await hass.config_entries.flow.async_configure(
result["flow_id"], confirm_result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD},
) )
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"]) pick_thread_progress_result = await consume_progress_flow(
await hass.async_block_till_done(wait_background_tasks=True) hass,
flow_id=pick_thread_result["flow_id"],
result = await hass.config_entries.flow.async_configure(result["flow_id"]) valid_step_ids=(
assert result["type"] is FlowResultType.FORM "pick_firmware_thread",
assert result["step_id"] == "confirm_otbr" "install_thread_firmware",
"start_otbr_addon",
with mock_addon_info( ),
hass,
app_type=None, # Probing fails
) as (mock_otbr_manager, mock_flasher_manager):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
) )
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unsupported_firmware" assert pick_thread_progress_result["type"] is FlowResultType.ABORT
assert pick_thread_progress_result["reason"] == "unsupported_firmware"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -683,9 +415,9 @@ async def test_options_flow_thread_to_zigbee_otbr_configured(
# Confirm options flow # Confirm options flow
result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_init(config_entry.entry_id)
with mock_addon_info( with mock_firmware_info(
hass, hass,
app_type=ApplicationType.SPINEL, probe_app_type=ApplicationType.SPINEL,
otbr_addon_info=AddonInfo( otbr_addon_info=AddonInfo(
available=True, available=True,
hostname=None, hostname=None,
@ -694,7 +426,7 @@ async def test_options_flow_thread_to_zigbee_otbr_configured(
update_available=False, update_available=False,
version="1.0.0", version="1.0.0",
), ),
) as (mock_otbr_manager, mock_flasher_manager): ):
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE},

View File

@ -3,10 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Callable
import dataclasses import dataclasses
import logging import logging
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import Mock, patch
import aiohttp import aiohttp
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
@ -355,10 +355,14 @@ async def test_update_entity_installation(
"https://example.org/release_notes" "https://example.org/release_notes"
) )
mock_firmware = Mock() async def mock_flash_firmware(
mock_flasher = AsyncMock() hass: HomeAssistant,
device: str,
async def mock_flash_firmware(fw_image, progress_callback): fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_type: str | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
await asyncio.sleep(0) await asyncio.sleep(0)
progress_callback(0, 100) progress_callback(0, 100)
await asyncio.sleep(0) await asyncio.sleep(0)
@ -366,31 +370,20 @@ async def test_update_entity_installation(
await asyncio.sleep(0) await asyncio.sleep(0)
progress_callback(100, 100) progress_callback(100, 100)
mock_flasher.flash_firmware = mock_flash_firmware return FirmwareInfo(
device=TEST_DEVICE,
firmware_type=ApplicationType.EZSP,
firmware_version="7.4.4.0 build 0",
owners=[],
source="probe",
)
# When we install it, the other integration is reloaded # When we install it, the other integration is reloaded
with ( with (
patch( patch(
"homeassistant.components.homeassistant_hardware.update.parse_firmware_image", "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware",
return_value=mock_firmware, side_effect=mock_flash_firmware,
), ),
patch(
"homeassistant.components.homeassistant_hardware.update.Flasher",
return_value=mock_flasher,
),
patch(
"homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info",
return_value=FirmwareInfo(
device=TEST_DEVICE,
firmware_type=ApplicationType.EZSP,
firmware_version="7.4.4.0 build 0",
owners=[],
source="probe",
),
),
patch.object(
owning_config_entry, "async_unload", wraps=owning_config_entry.async_unload
) as owning_config_entry_unload,
): ):
state_changes: list[Event[EventStateChangedData]] = async_capture_events( state_changes: list[Event[EventStateChangedData]] = async_capture_events(
hass, EVENT_STATE_CHANGED hass, EVENT_STATE_CHANGED
@ -423,9 +416,6 @@ async def test_update_entity_installation(
assert state_changes[6].data["new_state"].attributes["update_percentage"] is None assert state_changes[6].data["new_state"].attributes["update_percentage"] is None
assert state_changes[6].data["new_state"].attributes["in_progress"] is False assert state_changes[6].data["new_state"].attributes["in_progress"] is False
# The owning integration was unloaded and is again running
assert len(owning_config_entry_unload.mock_calls) == 1
# After the firmware update, the entity has the new version and the correct state # After the firmware update, the entity has the new version and the correct state
state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID)
assert state_after_install is not None assert state_after_install is not None
@ -456,19 +446,10 @@ async def test_update_entity_installation_failure(
assert state_before_install.attributes["installed_version"] == "7.3.1.0" assert state_before_install.attributes["installed_version"] == "7.3.1.0"
assert state_before_install.attributes["latest_version"] == "7.4.4.0" assert state_before_install.attributes["latest_version"] == "7.4.4.0"
mock_flasher = AsyncMock()
mock_flasher.flash_firmware.side_effect = RuntimeError(
"Something broke during flashing!"
)
with ( with (
patch( patch(
"homeassistant.components.homeassistant_hardware.update.parse_firmware_image", "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware",
return_value=Mock(), side_effect=HomeAssistantError("Failed to flash firmware"),
),
patch(
"homeassistant.components.homeassistant_hardware.update.Flasher",
return_value=mock_flasher,
), ),
pytest.raises(HomeAssistantError, match="Failed to flash firmware"), pytest.raises(HomeAssistantError, match="Failed to flash firmware"),
): ):
@ -511,16 +492,10 @@ async def test_update_entity_installation_probe_failure(
with ( with (
patch( patch(
"homeassistant.components.homeassistant_hardware.update.parse_firmware_image", "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware",
return_value=Mock(), side_effect=HomeAssistantError(
), "Failed to probe the firmware after flashing"
patch( ),
"homeassistant.components.homeassistant_hardware.update.Flasher",
return_value=AsyncMock(),
),
patch(
"homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info",
return_value=None,
), ),
pytest.raises( pytest.raises(
HomeAssistantError, match="Failed to probe the firmware after flashing" HomeAssistantError, match="Failed to probe the firmware after flashing"

View File

@ -1,10 +1,13 @@
"""Test hardware utilities.""" """Test hardware utilities."""
from unittest.mock import AsyncMock, MagicMock, patch import asyncio
from collections.abc import Callable
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
import pytest import pytest
from universal_silabs_flasher.common import Version as FlasherVersion from universal_silabs_flasher.common import Version as FlasherVersion
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.firmware import GBLImage
from homeassistant.components.hassio import ( from homeassistant.components.hassio import (
AddonError, AddonError,
@ -20,6 +23,7 @@ from homeassistant.components.homeassistant_hardware.util import (
FirmwareInfo, FirmwareInfo,
OwningAddon, OwningAddon,
OwningIntegration, OwningIntegration,
async_flash_silabs_firmware,
get_otbr_addon_firmware_info, get_otbr_addon_firmware_info,
guess_firmware_info, guess_firmware_info,
probe_silabs_firmware_info, probe_silabs_firmware_info,
@ -27,8 +31,11 @@ from homeassistant.components.homeassistant_hardware.util import (
) )
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .test_config_flow import create_mock_owner
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
ZHA_CONFIG_ENTRY = MockConfigEntry( ZHA_CONFIG_ENTRY = MockConfigEntry(
@ -526,3 +533,201 @@ async def test_probe_silabs_firmware_type(
): ):
result = await probe_silabs_firmware_type("/dev/ttyUSB0") result = await probe_silabs_firmware_type("/dev/ttyUSB0")
assert result == expected assert result == expected
async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None:
"""Test async_flash_silabs_firmware."""
owner1 = create_mock_owner()
owner2 = create_mock_owner()
progress_callback = Mock()
async def mock_flash_firmware(
fw_image: GBLImage, progress_callback: Callable[[int, int], None]
) -> None:
"""Mock flash firmware function."""
await asyncio.sleep(0)
progress_callback(0, 100)
await asyncio.sleep(0)
progress_callback(50, 100)
await asyncio.sleep(0)
progress_callback(100, 100)
await asyncio.sleep(0)
mock_flasher = Mock()
mock_flasher.enter_bootloader = AsyncMock()
mock_flasher.flash_firmware = AsyncMock(side_effect=mock_flash_firmware)
expected_firmware_info = FirmwareInfo(
device="/dev/ttyUSB0",
firmware_type=ApplicationType.SPINEL,
firmware_version=None,
source="probe",
owners=[],
)
with (
patch(
"homeassistant.components.homeassistant_hardware.util.guess_firmware_info",
return_value=FirmwareInfo(
device="/dev/ttyUSB0",
firmware_type=ApplicationType.EZSP,
firmware_version=None,
source="unknown",
owners=[owner1, owner2],
),
),
patch(
"homeassistant.components.homeassistant_hardware.util.Flasher",
return_value=mock_flasher,
),
patch(
"homeassistant.components.homeassistant_hardware.util.parse_firmware_image"
),
patch(
"homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info",
return_value=expected_firmware_info,
),
):
after_flash_info = await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_type=None,
progress_callback=progress_callback,
)
assert progress_callback.mock_calls == [call(0, 100), call(50, 100), call(100, 100)]
assert after_flash_info == expected_firmware_info
# Both owning integrations/addons are stopped and restarted
assert owner1.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
# pylint: disable-next=unnecessary-dunder-call
call().__aexit__(ANY, None, None, None),
]
assert owner2.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
# pylint: disable-next=unnecessary-dunder-call
call().__aexit__(ANY, None, None, None),
]
async def test_async_flash_silabs_firmware_flash_failure(hass: HomeAssistant) -> None:
"""Test async_flash_silabs_firmware flash failure."""
owner1 = create_mock_owner()
owner2 = create_mock_owner()
mock_flasher = Mock()
mock_flasher.enter_bootloader = AsyncMock()
mock_flasher.flash_firmware = AsyncMock(side_effect=RuntimeError("Failure!"))
with (
patch(
"homeassistant.components.homeassistant_hardware.util.guess_firmware_info",
return_value=FirmwareInfo(
device="/dev/ttyUSB0",
firmware_type=ApplicationType.EZSP,
firmware_version=None,
source="unknown",
owners=[owner1, owner2],
),
),
patch(
"homeassistant.components.homeassistant_hardware.util.Flasher",
return_value=mock_flasher,
),
patch(
"homeassistant.components.homeassistant_hardware.util.parse_firmware_image"
),
pytest.raises(HomeAssistantError, match="Failed to flash firmware") as exc,
):
await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_type=None,
)
# Both owning integrations/addons are stopped and restarted
assert owner1.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
# pylint: disable-next=unnecessary-dunder-call
call().__aexit__(ANY, HomeAssistantError, exc.value, ANY),
]
assert owner2.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
# pylint: disable-next=unnecessary-dunder-call
call().__aexit__(ANY, HomeAssistantError, exc.value, ANY),
]
async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> None:
"""Test async_flash_silabs_firmware probe failure."""
owner1 = create_mock_owner()
owner2 = create_mock_owner()
mock_flasher = Mock()
mock_flasher.enter_bootloader = AsyncMock()
mock_flasher.flash_firmware = AsyncMock()
with (
patch(
"homeassistant.components.homeassistant_hardware.util.guess_firmware_info",
return_value=FirmwareInfo(
device="/dev/ttyUSB0",
firmware_type=ApplicationType.EZSP,
firmware_version=None,
source="unknown",
owners=[owner1, owner2],
),
),
patch(
"homeassistant.components.homeassistant_hardware.util.Flasher",
return_value=mock_flasher,
),
patch(
"homeassistant.components.homeassistant_hardware.util.parse_firmware_image"
),
patch(
"homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info",
return_value=None,
),
pytest.raises(
HomeAssistantError, match="Failed to probe the firmware after flashing"
),
):
await async_flash_silabs_firmware(
hass=hass,
device="/dev/ttyUSB0",
fw_data=b"firmware contents",
expected_installed_firmware_type=ApplicationType.SPINEL,
bootloader_reset_type=None,
)
# Both owning integrations/addons are stopped and restarted
assert owner1.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
# pylint: disable-next=unnecessary-dunder-call
call().__aexit__(ANY, None, None, None),
]
assert owner2.temporarily_stop.mock_calls == [
call(hass),
# pylint: disable-next=unnecessary-dunder-call
call().__aenter__(ANY),
# pylint: disable-next=unnecessary-dunder-call
call().__aexit__(ANY, None, None, None),
]

View File

@ -6,6 +6,7 @@ import pytest
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 (
STEP_PICK_FIRMWARE_THREAD,
STEP_PICK_FIRMWARE_ZIGBEE, STEP_PICK_FIRMWARE_ZIGBEE,
) )
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
@ -18,6 +19,7 @@ from homeassistant.components.homeassistant_hardware.util import (
FirmwareInfo, FirmwareInfo,
) )
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo
@ -28,14 +30,31 @@ from tests.common import MockConfigEntry
@pytest.mark.parametrize( @pytest.mark.parametrize(
("usb_data", "model"), ("step", "usb_data", "model", "fw_type", "fw_version"),
[ [
(USB_DATA_SKY, "Home Assistant SkyConnect"), (
(USB_DATA_ZBT1, "Home Assistant Connect ZBT-1"), STEP_PICK_FIRMWARE_ZIGBEE,
USB_DATA_SKY,
"Home Assistant SkyConnect",
ApplicationType.EZSP,
"7.4.4.0 build 0",
),
(
STEP_PICK_FIRMWARE_THREAD,
USB_DATA_ZBT1,
"Home Assistant Connect ZBT-1",
ApplicationType.SPINEL,
"2.4.4.0",
),
], ],
) )
async def test_config_flow( async def test_config_flow(
usb_data: UsbServiceInfo, model: str, hass: HomeAssistant step: str,
usb_data: UsbServiceInfo,
model: str,
fw_type: ApplicationType,
fw_version: str,
hass: HomeAssistant,
) -> None: ) -> None:
"""Test the config flow for SkyConnect.""" """Test the config flow for SkyConnect."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -46,21 +65,36 @@ async def test_config_flow(
assert result["step_id"] == "pick_firmware" assert result["step_id"] == "pick_firmware"
assert result["description_placeholders"]["model"] == model assert result["description_placeholders"]["model"] == model
async def mock_async_step_pick_firmware_zigbee(self, data): async def mock_install_firmware_step(
return await self.async_step_confirm_zigbee(user_input={}) self,
fw_update_url: str,
fw_type: str,
firmware_name: str,
expected_installed_firmware_type: ApplicationType,
step_id: str,
next_step_id: str,
) -> ConfigFlowResult:
if next_step_id == "start_otbr_addon":
next_step_id = "confirm_otbr"
return await getattr(self, f"async_step_{next_step_id}")(user_input={})
with ( with (
patch( patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow.async_step_pick_firmware_zigbee", "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup",
return_value=None,
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step",
autospec=True, autospec=True,
side_effect=mock_async_step_pick_firmware_zigbee, side_effect=mock_install_firmware_step,
), ),
patch( patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info",
return_value=FirmwareInfo( return_value=FirmwareInfo(
device=usb_data.device, device=usb_data.device,
firmware_type=ApplicationType.EZSP, firmware_type=fw_type,
firmware_version="7.4.4.0 build 0", firmware_version=fw_version,
owners=[], owners=[],
source="probe", source="probe",
), ),
@ -68,15 +102,15 @@ async def test_config_flow(
): ):
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, user_input={"next_step_id": step},
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
config_entry = result["result"] config_entry = result["result"]
assert config_entry.data == { assert config_entry.data == {
"firmware": "ezsp", "firmware": fw_type.value,
"firmware_version": "7.4.4.0 build 0", "firmware_version": fw_version,
"device": usb_data.device, "device": usb_data.device,
"manufacturer": usb_data.manufacturer, "manufacturer": usb_data.manufacturer,
"pid": usb_data.pid, "pid": usb_data.pid,
@ -86,13 +120,17 @@ async def test_config_flow(
"vid": usb_data.vid, "vid": usb_data.vid,
} }
# Ensure a ZHA discovery flow has been created
flows = hass.config_entries.flow.async_progress() flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
zha_flow = flows[0] if step == STEP_PICK_FIRMWARE_ZIGBEE:
assert zha_flow["handler"] == "zha" # Ensure a ZHA discovery flow has been created
assert zha_flow["context"]["source"] == "hardware" assert len(flows) == 1
assert zha_flow["step_id"] == "confirm" zha_flow = flows[0]
assert zha_flow["handler"] == "zha"
assert zha_flow["context"]["source"] == "hardware"
assert zha_flow["step_id"] == "confirm"
else:
assert len(flows) == 0
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -11,6 +11,7 @@ from homeassistant.components.hassio import (
AddonState, 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_ZIGBEE, STEP_PICK_FIRMWARE_ZIGBEE,
) )
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
@ -23,6 +24,7 @@ from homeassistant.components.homeassistant_hardware.util import (
FirmwareInfo, FirmwareInfo,
) )
from homeassistant.components.homeassistant_yellow.const import DOMAIN, RADIO_DEVICE from homeassistant.components.homeassistant_yellow.const import DOMAIN, RADIO_DEVICE
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -305,7 +307,16 @@ async def test_option_flow_led_settings_fail_2(
assert result["reason"] == "write_hw_settings_error" assert result["reason"] == "write_hw_settings_error"
async def test_firmware_options_flow(hass: HomeAssistant) -> None: @pytest.mark.parametrize(
("step", "fw_type", "fw_version"),
[
(STEP_PICK_FIRMWARE_ZIGBEE, ApplicationType.EZSP, "7.4.4.0 build 0"),
(STEP_PICK_FIRMWARE_THREAD, ApplicationType.SPINEL, "2.4.4.0"),
],
)
async def test_firmware_options_flow(
step: str, fw_type: ApplicationType, fw_version: str, hass: HomeAssistant
) -> None:
"""Test the firmware options flow for Yellow.""" """Test the firmware options flow for Yellow."""
mock_integration(hass, MockModule("hassio")) mock_integration(hass, MockModule("hassio"))
await async_setup_component(hass, HASSIO_DOMAIN, {}) await async_setup_component(hass, HASSIO_DOMAIN, {})
@ -339,18 +350,36 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None:
async def mock_async_step_pick_firmware_zigbee(self, data): async def mock_async_step_pick_firmware_zigbee(self, data):
return await self.async_step_confirm_zigbee(user_input={}) return await self.async_step_confirm_zigbee(user_input={})
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:
if next_step_id == "start_otbr_addon":
next_step_id = "confirm_otbr"
return await getattr(self, f"async_step_{next_step_id}")(user_input={})
with ( with (
patch( patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup",
return_value=None,
),
patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._install_firmware_step",
autospec=True, autospec=True,
side_effect=mock_async_step_pick_firmware_zigbee, side_effect=mock_install_firmware_step,
), ),
patch( patch(
"homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info",
return_value=FirmwareInfo( return_value=FirmwareInfo(
device=RADIO_DEVICE, device=RADIO_DEVICE,
firmware_type=ApplicationType.EZSP, firmware_type=fw_type,
firmware_version="7.4.4.0 build 0", firmware_version=fw_version,
owners=[], owners=[],
source="probe", source="probe",
), ),
@ -358,15 +387,15 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None:
): ):
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, user_input={"next_step_id": step},
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"] is True assert result["result"] is True
assert config_entry.data == { assert config_entry.data == {
"firmware": "ezsp", "firmware": fw_type.value,
"firmware_version": "7.4.4.0 build 0", "firmware_version": fw_version,
} }