mirror of
https://github.com/home-assistant/core.git
synced 2025-07-09 14:27:07 +00:00
Automatic migration from multi-PAN back to Zigbee firmware (#93831)
* Initial implementation of migration back to Zigbee firmware * Fix typo in `BACKUP_RETRIES` constant name * Name potentially long-running tasks * Add an explicit timeout to `_async_wait_until_addon_state` * Guard against the addon not being installed when uninstalling * Do not launch the progress flow unless the addon is being installed * Use a separate translation key for confirmation before disabling multi-PAN * Disable the bellows UART thread within the ZHA config flow radio manager * Enhance config flow progress keys for flasher addon installation * Allow `zha.async_unload_entry` to succeed when ZHA is not loaded * Do not endlessly spawn task when uninstalling addon synchronously * Include `uninstall_addon.data.*` in SkyConnect and Yellow translations * Make `homeassistant_hardware` unit tests pass * Fix SkyConnect unit test USB mock * Fix unit tests in related integrations * Use a separate constant for connection retrying * Unit test ZHA migration from multi-PAN * Test ZHA multi-PAN migration helper changes * Fix flaky SkyConnect unit test being affected by system USB devices * Unit test the synchronous addon uninstall helper * Test failure when flasher addon is already running * Test failure where flasher addon fails to install * Test ZHA migration failures * Rename `get_addon_manager` to `get_multiprotocol_addon_manager` * Remove stray "addon uninstall" comment * Use better variable names for the two addon managers * Remove extraneous `self.install_task = None` * Use the addon manager's `addon_name` instead of constants * Migrate synchronous addon operations into a new class * Remove wrapper functions with `finally` clause * Use a more descriptive error message when the flasher addon is stalled * Fix existing unit tests * Remove `wait_until_done` * Fully replace all addon name constants with those from managers * Fix OTBR breakage * Simplify `is_hassio` mocking * Add missing tests for `check_multi_pan_addon` * Add missing tests for `multi_pan_addon_using_device` * Use `waiting` instead of `sync` in class name and methods
This commit is contained in:
parent
23839a7f10
commit
c8ef3f9393
@ -5,3 +5,4 @@ import logging
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
SILABS_MULTIPROTOCOL_ADDON_SLUG = "core_silabs_multiprotocol"
|
||||
SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher"
|
||||
|
@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
|
||||
@ -33,17 +35,19 @@ from homeassistant.helpers.selector import (
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG
|
||||
from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
|
||||
DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
|
||||
DATA_FLASHER_ADDON_MANAGER = "silabs_flasher"
|
||||
|
||||
ADDON_SETUP_TIMEOUT = 5
|
||||
ADDON_SETUP_TIMEOUT_ROUNDS = 40
|
||||
ADDON_STATE_POLL_INTERVAL = 3
|
||||
ADDON_INFO_POLL_TIMEOUT = 15 * 60
|
||||
|
||||
CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
|
||||
CONF_ADDON_DEVICE = "device"
|
||||
CONF_DISABLE_MULTI_PAN = "disable_multi_pan"
|
||||
CONF_ENABLE_MULTI_PAN = "enable_multi_pan"
|
||||
|
||||
DEFAULT_CHANNEL = 15
|
||||
@ -55,15 +59,64 @@ STORAGE_VERSION_MINOR = 1
|
||||
SAVE_DELAY = 10
|
||||
|
||||
|
||||
@singleton(DATA_ADDON_MANAGER)
|
||||
async def get_addon_manager(hass: HomeAssistant) -> MultiprotocolAddonManager:
|
||||
@singleton(DATA_MULTIPROTOCOL_ADDON_MANAGER)
|
||||
async def get_multiprotocol_addon_manager(
|
||||
hass: HomeAssistant,
|
||||
) -> MultiprotocolAddonManager:
|
||||
"""Get the add-on manager."""
|
||||
manager = MultiprotocolAddonManager(hass)
|
||||
await manager.async_setup()
|
||||
return manager
|
||||
|
||||
|
||||
class MultiprotocolAddonManager(AddonManager):
|
||||
class WaitingAddonManager(AddonManager):
|
||||
"""Addon manager which supports waiting operations for managing an addon."""
|
||||
|
||||
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
|
||||
"""Poll an addon's info until it is in a specific state."""
|
||||
async with async_timeout.timeout(ADDON_INFO_POLL_TIMEOUT):
|
||||
while True:
|
||||
try:
|
||||
info = await self.async_get_addon_info()
|
||||
except AddonError:
|
||||
info = None
|
||||
|
||||
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
|
||||
|
||||
if info is not None and info.state in states:
|
||||
break
|
||||
|
||||
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
|
||||
|
||||
async def async_start_addon_waiting(self) -> None:
|
||||
"""Start an add-on."""
|
||||
await self.async_schedule_start_addon()
|
||||
await self.async_wait_until_addon_state(AddonState.RUNNING)
|
||||
|
||||
async def async_install_addon_waiting(self) -> None:
|
||||
"""Install an add-on."""
|
||||
await self.async_schedule_install_addon()
|
||||
await self.async_wait_until_addon_state(
|
||||
AddonState.RUNNING,
|
||||
AddonState.NOT_RUNNING,
|
||||
)
|
||||
|
||||
async def async_uninstall_addon_waiting(self) -> None:
|
||||
"""Uninstall an add-on."""
|
||||
try:
|
||||
info = await self.async_get_addon_info()
|
||||
except AddonError:
|
||||
info = None
|
||||
|
||||
# Do not try to uninstall an addon if it is already uninstalled
|
||||
if info is not None and info.state == AddonState.NOT_INSTALLED:
|
||||
return
|
||||
|
||||
await self.async_uninstall_addon()
|
||||
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
|
||||
|
||||
|
||||
class MultiprotocolAddonManager(WaitingAddonManager):
|
||||
"""Silicon Labs Multiprotocol add-on manager."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
@ -207,6 +260,18 @@ class MultipanProtocol(Protocol):
|
||||
"""
|
||||
|
||||
|
||||
@singleton(DATA_FLASHER_ADDON_MANAGER)
|
||||
@callback
|
||||
def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
|
||||
"""Get the flasher add-on manager."""
|
||||
return WaitingAddonManager(
|
||||
hass,
|
||||
LOGGER,
|
||||
"Silicon Labs Flasher",
|
||||
SILABS_FLASHER_ADDON_SLUG,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SerialPortSettings:
|
||||
"""Serial port settings."""
|
||||
@ -242,9 +307,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
ZhaMultiPANMigrationHelper,
|
||||
)
|
||||
|
||||
# If we install the add-on we should uninstall it on entry remove.
|
||||
self.install_task: asyncio.Task | None = None
|
||||
self.start_task: asyncio.Task | None = None
|
||||
self.stop_task: asyncio.Task | None = None
|
||||
self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None
|
||||
self.config_entry = config_entry
|
||||
self.original_addon_config: dict[str, Any] | None = None
|
||||
@ -275,37 +340,37 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
"""Return the correct flow manager."""
|
||||
return self.hass.config_entries.options
|
||||
|
||||
async def _async_get_addon_info(self) -> AddonInfo:
|
||||
async def _resume_flow_when_done(self, awaitable: Awaitable) -> None:
|
||||
try:
|
||||
await awaitable
|
||||
finally:
|
||||
self.hass.async_create_task(
|
||||
self.flow_manager.async_configure(flow_id=self.flow_id)
|
||||
)
|
||||
|
||||
async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo:
|
||||
"""Return and cache Silicon Labs Multiprotocol add-on info."""
|
||||
addon_manager: AddonManager = await get_addon_manager(self.hass)
|
||||
try:
|
||||
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow("addon_info_failed") from err
|
||||
raise AbortFlow(
|
||||
"addon_info_failed",
|
||||
description_placeholders={"addon_name": addon_manager.addon_name},
|
||||
) from err
|
||||
|
||||
return addon_info
|
||||
|
||||
async def _async_set_addon_config(self, config: dict) -> None:
|
||||
async def _async_set_addon_config(
|
||||
self, config: dict, addon_manager: AddonManager
|
||||
) -> None:
|
||||
"""Set Silicon Labs Multiprotocol add-on config."""
|
||||
addon_manager: AddonManager = await get_addon_manager(self.hass)
|
||||
try:
|
||||
await addon_manager.async_set_addon_options(config)
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow("addon_set_config_failed") from err
|
||||
|
||||
async def _async_install_addon(self) -> None:
|
||||
"""Install the Silicon Labs Multiprotocol add-on."""
|
||||
addon_manager: AddonManager = await get_addon_manager(self.hass)
|
||||
try:
|
||||
await addon_manager.async_schedule_install_addon()
|
||||
finally:
|
||||
# Continue the flow after show progress when the task is done.
|
||||
self.hass.async_create_task(
|
||||
self.flow_manager.async_configure(flow_id=self.flow_id)
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@ -319,7 +384,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle logic when on Supervisor host."""
|
||||
addon_info = await self._async_get_addon_info()
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(multipan_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_addon_not_installed()
|
||||
@ -347,19 +413,26 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
) -> FlowResult:
|
||||
"""Install Silicon Labs Multiprotocol add-on."""
|
||||
if not self.install_task:
|
||||
self.install_task = self.hass.async_create_task(self._async_install_addon())
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
self.install_task = self.hass.async_create_task(
|
||||
self._resume_flow_when_done(
|
||||
multipan_manager.async_install_addon_waiting()
|
||||
),
|
||||
"SiLabs Multiprotocol addon install",
|
||||
)
|
||||
return self.async_show_progress(
|
||||
step_id="install_addon", progress_action="install_addon"
|
||||
step_id="install_addon",
|
||||
progress_action="install_addon",
|
||||
description_placeholders={"addon_name": multipan_manager.addon_name},
|
||||
)
|
||||
|
||||
try:
|
||||
await self.install_task
|
||||
except AddonError as err:
|
||||
self.install_task = None
|
||||
_LOGGER.error(err)
|
||||
return self.async_show_progress_done(next_step_id="install_failed")
|
||||
|
||||
self.install_task = None
|
||||
finally:
|
||||
self.install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="configure_addon")
|
||||
|
||||
@ -367,7 +440,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Add-on installation failed."""
|
||||
return self.async_abort(reason="addon_install_failed")
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
return self.async_abort(
|
||||
reason="addon_install_failed",
|
||||
description_placeholders={"addon_name": multipan_manager.addon_name},
|
||||
)
|
||||
|
||||
async def async_step_configure_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@ -386,7 +463,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
async_get_channel as async_get_zha_channel,
|
||||
)
|
||||
|
||||
addon_info = await self._async_get_addon_info()
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(multipan_manager)
|
||||
|
||||
addon_config = addon_info.options
|
||||
|
||||
@ -426,14 +504,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
multipan_channel = zha_channel
|
||||
|
||||
# Initialize the shared channel
|
||||
multipan_manager = await get_addon_manager(self.hass)
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
multipan_manager.async_set_channel(multipan_channel)
|
||||
|
||||
if new_addon_config != addon_config:
|
||||
# Copy the add-on config to keep the objects separate.
|
||||
self.original_addon_config = dict(addon_config)
|
||||
_LOGGER.debug("Reconfiguring addon with %s", new_addon_config)
|
||||
await self._async_set_addon_config(new_addon_config)
|
||||
await self._async_set_addon_config(new_addon_config, multipan_manager)
|
||||
|
||||
return await self.async_step_start_addon()
|
||||
|
||||
@ -442,9 +520,16 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
) -> FlowResult:
|
||||
"""Start Silicon Labs Multiprotocol add-on."""
|
||||
if not self.start_task:
|
||||
self.start_task = self.hass.async_create_task(self._async_start_addon())
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
self.start_task = self.hass.async_create_task(
|
||||
self._resume_flow_when_done(
|
||||
multipan_manager.async_start_addon_waiting()
|
||||
)
|
||||
)
|
||||
return self.async_show_progress(
|
||||
step_id="start_addon", progress_action="start_addon"
|
||||
step_id="start_addon",
|
||||
progress_action="start_addon",
|
||||
description_placeholders={"addon_name": multipan_manager.addon_name},
|
||||
)
|
||||
|
||||
try:
|
||||
@ -461,18 +546,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Add-on start failed."""
|
||||
return self.async_abort(reason="addon_start_failed")
|
||||
|
||||
async def _async_start_addon(self) -> None:
|
||||
"""Start Silicon Labs Multiprotocol add-on."""
|
||||
addon_manager: AddonManager = await get_addon_manager(self.hass)
|
||||
try:
|
||||
await addon_manager.async_schedule_start_addon()
|
||||
finally:
|
||||
# Continue the flow after show progress when the task is done.
|
||||
self.hass.async_create_task(
|
||||
self.flow_manager.async_configure(flow_id=self.flow_id)
|
||||
)
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
return self.async_abort(
|
||||
reason="addon_start_failed",
|
||||
description_placeholders={"addon_name": multipan_manager.addon_name},
|
||||
)
|
||||
|
||||
async def async_step_finish_addon_setup(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@ -493,16 +571,25 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
async def async_step_addon_installed_other_device(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Show dialog explaining the addon is in use by another device."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="addon_installed_other_device")
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
async def async_step_addon_installed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle logic when the addon is already installed."""
|
||||
addon_info = await self._async_get_addon_info()
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(multipan_manager)
|
||||
|
||||
serial_device = (await self._async_serial_port_settings()).device
|
||||
if addon_info.options.get(CONF_ADDON_DEVICE) == serial_device:
|
||||
return await self.async_step_show_addon_menu()
|
||||
return await self.async_step_addon_installed_other_device()
|
||||
if addon_info.options.get(CONF_ADDON_DEVICE) != serial_device:
|
||||
return await self.async_step_addon_installed_other_device()
|
||||
return await self.async_step_show_addon_menu()
|
||||
|
||||
async def async_step_show_addon_menu(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@ -520,7 +607,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Reconfigure the addon."""
|
||||
multipan_manager = await get_addon_manager(self.hass)
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
active_platforms = await multipan_manager.async_active_platforms()
|
||||
if set(active_platforms) != {"otbr", "zha"}:
|
||||
return await self.async_step_notify_unknown_multipan_user()
|
||||
@ -540,7 +627,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Change the channel."""
|
||||
multipan_manager = await get_addon_manager(self.hass)
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
if user_input is None:
|
||||
channels = [str(x) for x in range(11, 27)]
|
||||
suggested_channel = DEFAULT_CHANNEL
|
||||
@ -584,23 +671,217 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC):
|
||||
async def async_step_uninstall_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Uninstall the addon (not implemented)."""
|
||||
return await self.async_step_show_revert_guide()
|
||||
"""Uninstall the addon and revert the firmware."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="uninstall_addon",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_DISABLE_MULTI_PAN, default=False): bool}
|
||||
),
|
||||
description_placeholders={"hardware_name": self._hardware_name()},
|
||||
)
|
||||
if not user_input[CONF_DISABLE_MULTI_PAN]:
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
async def async_step_show_revert_guide(
|
||||
return await self.async_step_firmware_revert()
|
||||
|
||||
async def async_step_firmware_revert(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Link to a guide for reverting to Zigbee firmware."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="show_revert_guide")
|
||||
return self.async_create_entry(title="", data={})
|
||||
"""Install the flasher addon, if necessary."""
|
||||
|
||||
async def async_step_addon_installed_other_device(
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_flasher_addon()
|
||||
|
||||
if addon_info.state == AddonState.NOT_RUNNING:
|
||||
return await self.async_step_configure_flasher_addon()
|
||||
|
||||
# If the addon is already installed and running, fail
|
||||
return self.async_abort(
|
||||
reason="addon_already_running",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
)
|
||||
|
||||
async def async_step_install_flasher_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Show dialog explaining the addon is in use by another device."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="addon_installed_other_device")
|
||||
"""Show progress dialog for installing flasher addon."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
||||
|
||||
_LOGGER.debug("Flasher addon state: %s", addon_info)
|
||||
|
||||
if not self.install_task:
|
||||
self.install_task = self.hass.async_create_task(
|
||||
self._resume_flow_when_done(
|
||||
flasher_manager.async_install_addon_waiting()
|
||||
),
|
||||
"SiLabs Flasher addon install",
|
||||
)
|
||||
return self.async_show_progress(
|
||||
step_id="install_flasher_addon",
|
||||
progress_action="install_addon",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
)
|
||||
|
||||
try:
|
||||
await self.install_task
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
return self.async_show_progress_done(next_step_id="install_failed")
|
||||
finally:
|
||||
self.install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="configure_flasher_addon")
|
||||
|
||||
async def async_step_configure_flasher_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Perform initial backup and reconfigure ZHA."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.zha.radio_manager import (
|
||||
ZhaMultiPANMigrationHelper,
|
||||
)
|
||||
|
||||
zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN)
|
||||
new_settings = await self._async_serial_port_settings()
|
||||
|
||||
_LOGGER.debug("Using new ZHA settings: %s", new_settings)
|
||||
|
||||
if zha_entries:
|
||||
zha_migration_mgr = ZhaMultiPANMigrationHelper(self.hass, zha_entries[0])
|
||||
migration_data = {
|
||||
"new_discovery_info": {
|
||||
"name": self._hardware_name(),
|
||||
"port": {
|
||||
"path": new_settings.device,
|
||||
"baudrate": int(new_settings.baudrate),
|
||||
"flow_control": (
|
||||
"hardware" if new_settings.flow_control else None
|
||||
),
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
},
|
||||
"old_discovery_info": {
|
||||
"hw": {
|
||||
"name": self._zha_name(),
|
||||
"port": {"path": get_zigbee_socket()},
|
||||
"radio_type": "ezsp",
|
||||
}
|
||||
},
|
||||
}
|
||||
_LOGGER.debug("Starting ZHA migration with: %s", migration_data)
|
||||
try:
|
||||
if await zha_migration_mgr.async_initiate_migration(migration_data):
|
||||
self._zha_migration_mgr = zha_migration_mgr
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unexpected exception during ZHA migration")
|
||||
raise AbortFlow("zha_migration_failed") from err
|
||||
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
||||
new_addon_config = {
|
||||
**addon_info.options,
|
||||
"device": new_settings.device,
|
||||
"flow_control": new_settings.flow_control,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
|
||||
await self._async_set_addon_config(new_addon_config, flasher_manager)
|
||||
|
||||
return await self.async_step_uninstall_multiprotocol_addon()
|
||||
|
||||
async def async_step_uninstall_multiprotocol_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Uninstall Silicon Labs Multiprotocol add-on."""
|
||||
|
||||
if not self.stop_task:
|
||||
multipan_manager = await get_multiprotocol_addon_manager(self.hass)
|
||||
self.stop_task = self.hass.async_create_task(
|
||||
self._resume_flow_when_done(
|
||||
multipan_manager.async_uninstall_addon_waiting()
|
||||
),
|
||||
"SiLabs Multiprotocol addon uninstall",
|
||||
)
|
||||
return self.async_show_progress(
|
||||
step_id="uninstall_multiprotocol_addon",
|
||||
progress_action="uninstall_multiprotocol_addon",
|
||||
description_placeholders={"addon_name": multipan_manager.addon_name},
|
||||
)
|
||||
|
||||
try:
|
||||
await self.stop_task
|
||||
finally:
|
||||
self.stop_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="start_flasher_addon")
|
||||
|
||||
async def async_step_start_flasher_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Start Silicon Labs Flasher add-on."""
|
||||
|
||||
if not self.start_task:
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
|
||||
async def start_and_wait_until_done() -> None:
|
||||
await flasher_manager.async_start_addon_waiting()
|
||||
# Now that the addon is running, wait for it to finish
|
||||
await flasher_manager.async_wait_until_addon_state(
|
||||
AddonState.NOT_RUNNING
|
||||
)
|
||||
|
||||
self.start_task = self.hass.async_create_task(
|
||||
self._resume_flow_when_done(start_and_wait_until_done())
|
||||
)
|
||||
return self.async_show_progress(
|
||||
step_id="start_flasher_addon",
|
||||
progress_action="start_flasher_addon",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
)
|
||||
|
||||
try:
|
||||
await self.start_task
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
return self.async_show_progress_done(next_step_id="flasher_failed")
|
||||
finally:
|
||||
self.start_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="flashing_complete")
|
||||
|
||||
async def async_step_flasher_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Flasher add-on start failed."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
return self.async_abort(
|
||||
reason="addon_start_failed",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
)
|
||||
|
||||
async def async_step_flashing_complete(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Finish flashing and update the config entry."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
await flasher_manager.async_uninstall_addon_waiting()
|
||||
|
||||
# Finish ZHA migration if needed
|
||||
if self._zha_migration_mgr:
|
||||
try:
|
||||
await self._zha_migration_mgr.async_finish_migration()
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unexpected exception during ZHA migration")
|
||||
raise AbortFlow("zha_migration_failed") from err
|
||||
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
|
||||
@ -613,18 +894,18 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None:
|
||||
if not is_hassio(hass):
|
||||
return
|
||||
|
||||
addon_manager: AddonManager = await get_addon_manager(hass)
|
||||
multipan_manager = await get_multiprotocol_addon_manager(hass)
|
||||
try:
|
||||
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
|
||||
addon_info: AddonInfo = await multipan_manager.async_get_addon_info()
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise HomeAssistantError from err
|
||||
|
||||
# Request the addon to start if it's not started
|
||||
# addon_manager.async_start_addon returns as soon as the start request has been sent
|
||||
# `async_start_addon` returns as soon as the start request has been sent
|
||||
# and does not wait for the addon to be started, so we raise below
|
||||
if addon_info.state == AddonState.NOT_RUNNING:
|
||||
await addon_manager.async_start_addon()
|
||||
await multipan_manager.async_start_addon()
|
||||
|
||||
if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING):
|
||||
_LOGGER.debug("Multi pan addon installed and in state %s", addon_info.state)
|
||||
@ -640,8 +921,8 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) ->
|
||||
if not is_hassio(hass):
|
||||
return False
|
||||
|
||||
addon_manager: AddonManager = await get_addon_manager(hass)
|
||||
addon_info: AddonInfo = await addon_manager.async_get_addon_info()
|
||||
multipan_manager = await get_multiprotocol_addon_manager(hass)
|
||||
addon_info: AddonInfo = await multipan_manager.async_get_addon_info()
|
||||
|
||||
if addon_info.state != AddonState.RUNNING:
|
||||
return False
|
||||
|
@ -39,31 +39,42 @@
|
||||
"reconfigure_addon": {
|
||||
"title": "Reconfigure IEEE 802.15.4 radio multiprotocol support"
|
||||
},
|
||||
"show_revert_guide": {
|
||||
"title": "Multiprotocol support is enabled for this device",
|
||||
"description": "If you want to change to Zigbee only firmware, please complete the following manual steps:\n\n * Remove the Silicon Labs Multiprotocol addon\n\n * Flash the Zigbee only firmware, follow the guide at https://github.com/NabuCasa/silabs-firmware/wiki/Flash-Silicon-Labs-radio-firmware-manually.\n\n * Reconfigure ZHA to migrate settings to the reflashed radio"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "The Silicon Labs Multiprotocol add-on is starting."
|
||||
},
|
||||
"uninstall_addon": {
|
||||
"title": "Remove IEEE 802.15.4 radio multiprotocol support."
|
||||
"title": "Remove IEEE 802.15.4 radio multiprotocol support",
|
||||
"description": "Disabling multiprotocol support will revert your {hardware_name}'s radio back to Zigbee-only firmware and will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restoring a backup.",
|
||||
"data": {
|
||||
"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": {
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"addon_info_failed": "Failed to get Silicon Labs Multiprotocol add-on info.",
|
||||
"addon_install_failed": "Failed to install the Silicon Labs Multiprotocol add-on.",
|
||||
"addon_set_config_failed": "Failed to set Silicon Labs Multiprotocol configuration.",
|
||||
"addon_start_failed": "Failed to start the Silicon Labs Multiprotocol add-on.",
|
||||
"addon_info_failed": "Failed to get {addon_name} add-on info.",
|
||||
"addon_install_failed": "Failed to install the {addon_name} add-on.",
|
||||
"addon_already_running": "Failed to start the {addon_name} add-on because it is already running.",
|
||||
"addon_set_config_failed": "Failed to set {addon_name} configuration.",
|
||||
"addon_start_failed": "Failed to start the {addon_name} add-on.",
|
||||
"not_hassio": "The hardware options can only be configured on HassOS installations.",
|
||||
"zha_migration_failed": "The ZHA migration did not succeed."
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "Please wait while the Silicon Labs Multiprotocol add-on installation finishes. This can take several minutes.",
|
||||
"start_addon": "Please wait while the Silicon Labs Multiprotocol add-on start completes. This may take some seconds."
|
||||
"install_addon": "Please wait while the {addon_name} add-on installation finishes. This can take several minutes.",
|
||||
"start_addon": "Please wait while the {addon_name} add-on start completes. This may take some seconds."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,15 +38,25 @@
|
||||
"reconfigure_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
|
||||
},
|
||||
"show_revert_guide": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
|
||||
},
|
||||
"uninstall_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::description%]",
|
||||
"data": {
|
||||
"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%]"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@ -55,6 +65,7 @@
|
||||
"abort": {
|
||||
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
|
||||
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
|
||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
|
||||
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
|
||||
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
|
||||
|
@ -60,15 +60,25 @@
|
||||
"reconfigure_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
|
||||
},
|
||||
"show_revert_guide": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
|
||||
},
|
||||
"uninstall_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::description%]",
|
||||
"data": {
|
||||
"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%]"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@ -77,6 +87,7 @@
|
||||
"abort": {
|
||||
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
|
||||
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
|
||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
|
||||
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
|
||||
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
|
||||
|
@ -14,7 +14,7 @@ from python_otbr_api.tlv_parser import MeshcopTLVType
|
||||
|
||||
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
||||
MultiprotocolAddonManager,
|
||||
get_addon_manager,
|
||||
get_multiprotocol_addon_manager,
|
||||
is_multiprotocol_url,
|
||||
multi_pan_addon_using_device,
|
||||
)
|
||||
@ -146,8 +146,10 @@ async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None:
|
||||
# The OTBR is not sharing the radio, no restriction
|
||||
return None
|
||||
|
||||
addon_manager: MultiprotocolAddonManager = await get_addon_manager(hass)
|
||||
return addon_manager.async_get_channel()
|
||||
multipan_manager: MultiprotocolAddonManager = await get_multiprotocol_addon_manager(
|
||||
hass
|
||||
)
|
||||
return multipan_manager.async_get_channel()
|
||||
|
||||
|
||||
async def _warn_on_channel_collision(
|
||||
|
@ -166,7 +166,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload ZHA config entry."""
|
||||
zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY)
|
||||
try:
|
||||
zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY)
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
await zha_gateway.shutdown()
|
||||
|
||||
GROUP_PROBE.cleanup()
|
||||
|
@ -96,10 +96,12 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
|
||||
yellow_radio.manufacturer = "Nabu Casa"
|
||||
|
||||
# Present the multi-PAN addon as a setup option, if it's available
|
||||
addon_manager = await silabs_multiprotocol_addon.get_addon_manager(hass)
|
||||
multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(
|
||||
hass
|
||||
)
|
||||
|
||||
try:
|
||||
addon_info = await addon_manager.async_get_addon_info()
|
||||
addon_info = await multipan_manager.async_get_addon_info()
|
||||
except (AddonError, KeyError):
|
||||
addon_info = None
|
||||
|
||||
|
@ -9,6 +9,7 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from bellows.config import CONF_USE_THREAD
|
||||
import voluptuous as vol
|
||||
from zigpy.application import ControllerApplication
|
||||
import zigpy.backups
|
||||
@ -47,7 +48,9 @@ RECOMMENDED_RADIOS = (
|
||||
)
|
||||
|
||||
CONNECT_DELAY_S = 1.0
|
||||
RETRY_DELAY_S = 1.0
|
||||
|
||||
BACKUP_RETRIES = 5
|
||||
MIGRATION_RETRIES = 100
|
||||
|
||||
HARDWARE_DISCOVERY_SCHEMA = vol.Schema(
|
||||
@ -134,6 +137,7 @@ class ZhaRadioManager:
|
||||
app_config[CONF_DATABASE] = database_path
|
||||
app_config[CONF_DEVICE] = self.device_settings
|
||||
app_config[CONF_NWK_BACKUP_ENABLED] = False
|
||||
app_config[CONF_USE_THREAD] = False
|
||||
app_config = self.radio_type.controller.SCHEMA(app_config)
|
||||
|
||||
app = await self.radio_type.controller.new(
|
||||
@ -341,7 +345,24 @@ class ZhaMultiPANMigrationHelper:
|
||||
old_radio_mgr.device_path = config_entry_data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||
old_radio_mgr.device_settings = config_entry_data[CONF_DEVICE]
|
||||
old_radio_mgr.radio_type = RadioType[config_entry_data[CONF_RADIO_TYPE]]
|
||||
backup = await old_radio_mgr.async_load_network_settings(create_backup=True)
|
||||
|
||||
for retry in range(BACKUP_RETRIES):
|
||||
try:
|
||||
backup = await old_radio_mgr.async_load_network_settings(
|
||||
create_backup=True
|
||||
)
|
||||
break
|
||||
except OSError as err:
|
||||
if retry >= BACKUP_RETRIES - 1:
|
||||
raise
|
||||
|
||||
_LOGGER.debug(
|
||||
"Failed to create backup %r, retrying in %s seconds",
|
||||
err,
|
||||
RETRY_DELAY_S,
|
||||
)
|
||||
|
||||
await asyncio.sleep(RETRY_DELAY_S)
|
||||
|
||||
# Then configure the radio manager for the new radio to use the new settings
|
||||
self._radio_mgr.chosen_backup = backup
|
||||
@ -381,10 +402,10 @@ class ZhaMultiPANMigrationHelper:
|
||||
_LOGGER.debug(
|
||||
"Failed to restore backup %r, retrying in %s seconds",
|
||||
err,
|
||||
CONNECT_DELAY_S,
|
||||
RETRY_DELAY_S,
|
||||
)
|
||||
|
||||
await asyncio.sleep(CONNECT_DELAY_S)
|
||||
await asyncio.sleep(RETRY_DELAY_S)
|
||||
|
||||
_LOGGER.debug("Restored backup after %s retries", retry)
|
||||
|
||||
|
@ -147,3 +147,21 @@ def start_addon_fixture():
|
||||
"homeassistant.components.hassio.addon_manager.async_start_addon"
|
||||
) as start_addon:
|
||||
yield start_addon
|
||||
|
||||
|
||||
@pytest.fixture(name="stop_addon")
|
||||
def stop_addon_fixture():
|
||||
"""Mock stop add-on."""
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_manager.async_stop_addon"
|
||||
) as stop_addon:
|
||||
yield stop_addon
|
||||
|
||||
|
||||
@pytest.fixture(name="uninstall_addon")
|
||||
def uninstall_addon_fixture():
|
||||
"""Mock uninstall add-on."""
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_manager.async_uninstall_addon"
|
||||
) as uninstall_addon:
|
||||
yield uninstall_addon
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -9,7 +9,7 @@ import pytest
|
||||
def mock_usb_serial_by_id_fixture() -> Generator[MagicMock, None, None]:
|
||||
"""Mock usb serial by id."""
|
||||
with patch(
|
||||
"homeassistant.components.zwave_js.config_flow.usb.get_serial_by_id"
|
||||
"homeassistant.components.zha.config_flow.usb.get_serial_by_id"
|
||||
) as mock_usb_serial_by_id:
|
||||
mock_usb_serial_by_id.side_effect = lambda x: x
|
||||
yield mock_usb_serial_by_id
|
||||
@ -149,3 +149,21 @@ def start_addon_fixture():
|
||||
"homeassistant.components.hassio.addon_manager.async_start_addon"
|
||||
) as start_addon:
|
||||
yield start_addon
|
||||
|
||||
|
||||
@pytest.fixture(name="stop_addon")
|
||||
def stop_addon_fixture():
|
||||
"""Mock stop add-on."""
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_manager.async_stop_addon"
|
||||
) as stop_addon:
|
||||
yield stop_addon
|
||||
|
||||
|
||||
@pytest.fixture(name="uninstall_addon")
|
||||
def uninstall_addon_fixture():
|
||||
"""Mock uninstall add-on."""
|
||||
with patch(
|
||||
"homeassistant.components.hassio.addon_manager.async_uninstall_addon"
|
||||
) as uninstall_addon:
|
||||
yield uninstall_addon
|
||||
|
@ -1,7 +1,10 @@
|
||||
"""Test the Home Assistant SkyConnect config flow."""
|
||||
from collections.abc import Generator
|
||||
import copy
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import homeassistant_sky_connect, usb
|
||||
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
||||
from homeassistant.components.zha.core.const import (
|
||||
@ -25,6 +28,15 @@ USB_DATA = usb.UsbServiceInfo(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def config_flow_handler(hass: HomeAssistant) -> Generator[None, None, None]:
|
||||
"""Fixture for a test config flow."""
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.WaitingAddonManager.async_wait_until_addon_state"
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
async def test_config_flow(hass: HomeAssistant) -> None:
|
||||
"""Test the config flow."""
|
||||
with patch(
|
||||
|
@ -24,6 +24,13 @@ CONFIG_ENTRY_DATA = {
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_usb_probing() -> Generator[None, None, None]:
|
||||
"""Disallow touching of system USB devices during unit tests."""
|
||||
with patch("homeassistant.components.usb.comports", return_value=[]):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_zha_config_flow_setup() -> Generator[None, None, None]:
|
||||
"""Mock the radio connection and probing of the ZHA config flow."""
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Test the Home Assistant Yellow config flow."""
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@ -11,6 +12,15 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from tests.common import MockConfigEntry, MockModule, mock_integration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def config_flow_handler(hass: HomeAssistant) -> Generator[None, None, None]:
|
||||
"""Fixture for a test config flow."""
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.WaitingAddonManager.async_wait_until_addon_state"
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="get_yellow_settings")
|
||||
def mock_get_yellow_settings():
|
||||
"""Mock getting yellow settings."""
|
||||
|
@ -31,7 +31,9 @@ def disable_platform_only():
|
||||
@pytest.fixture(autouse=True)
|
||||
def reduce_reconnect_timeout():
|
||||
"""Reduces reconnect timeout to speed up tests."""
|
||||
with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001):
|
||||
with patch(
|
||||
"homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001
|
||||
), patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001):
|
||||
yield
|
||||
|
||||
|
||||
@ -83,7 +85,7 @@ def com_port(device="/dev/ttyUSB1234"):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connect_zigpy_app() -> Generator[None, None, None]:
|
||||
def mock_connect_zigpy_app() -> Generator[MagicMock, None, None]:
|
||||
"""Mock the radio connection."""
|
||||
|
||||
mock_connect_app = MagicMock()
|
||||
@ -96,7 +98,7 @@ def mock_connect_zigpy_app() -> Generator[None, None, None]:
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app",
|
||||
return_value=mock_connect_app,
|
||||
):
|
||||
yield
|
||||
yield mock_connect_app
|
||||
|
||||
|
||||
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
|
||||
@ -370,3 +372,52 @@ async def test_migrate_non_matching_port(
|
||||
"radio_type": "ezsp",
|
||||
}
|
||||
assert config_entry.title == "Test"
|
||||
|
||||
|
||||
async def test_migrate_initiate_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_connect_zigpy_app,
|
||||
) -> None:
|
||||
"""Test retries with failure."""
|
||||
# Set up the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data={"device": {"path": "/dev/ttyTEST123"}, "radio_type": "ezsp"},
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Test",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
config_entry.state = config_entries.ConfigEntryState.SETUP_IN_PROGRESS
|
||||
|
||||
migration_data = {
|
||||
"new_discovery_info": {
|
||||
"name": "Test Updated",
|
||||
"port": {
|
||||
"path": "socket://some/virtual_port",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "efr32",
|
||||
},
|
||||
"old_discovery_info": {
|
||||
"hw": {
|
||||
"name": "Test",
|
||||
"port": {
|
||||
"path": "/dev/ttyTEST123",
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "efr32",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mock_load_info = AsyncMock(side_effect=OSError())
|
||||
mock_connect_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info
|
||||
|
||||
migration_helper = radio_manager.ZhaMultiPANMigrationHelper(hass, config_entry)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
await migration_helper.async_initiate_migration(migration_data)
|
||||
|
||||
assert len(mock_load_info.mock_calls) == radio_manager.BACKUP_RETRIES
|
||||
|
Loading…
x
Reference in New Issue
Block a user