diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 96e78b80ca357..7efc85d2d3092 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Hardware", "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], - "dependencies": ["usb"], + "dependencies": ["repairs", "usb"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ diff --git a/homeassistant/components/homeassistant_hardware/repair_helpers.py b/homeassistant/components/homeassistant_hardware/repair_helpers.py new file mode 100644 index 0000000000000..b58c45c3c77e2 --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/repair_helpers.py @@ -0,0 +1,74 @@ +"""Repairs for the Home Assistant Hardware integration.""" + +from __future__ import annotations + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +ISSUE_MULTI_PAN_MIGRATION = "multi_pan_migration" + + +@callback +def _multi_pan_issue_id(config_entry: ConfigEntry) -> str: + """Return the issue id for the multi-PAN migration issue of an entry.""" + return f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}" + + +@callback +def async_create_multi_pan_migration_issue( + hass: HomeAssistant, + domain: str, + config_entry: ConfigEntry, +) -> None: + """Create a repair issue to guide migration away from Multi-PAN.""" + ir.async_create_issue( + hass, + domain=domain, + issue_id=_multi_pan_issue_id(config_entry), + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MULTI_PAN_MIGRATION, + translation_placeholders={"hardware_name": config_entry.title}, + data={"entry_id": config_entry.entry_id}, + ) + + +@callback +def async_delete_multi_pan_migration_issue( + hass: HomeAssistant, + domain: str, + config_entry: ConfigEntry, +) -> None: + """Delete the multi-PAN migration repair issue for this entry.""" + ir.async_delete_issue(hass, domain, _multi_pan_issue_id(config_entry)) + + +class MultiPanMigrationRepairFlow(RepairsFlow): + """Reuse the multi-PAN options flow uninstall steps as a repair flow. + + Subclass this together with the hardware-specific + ``MultiPanOptionsFlowHandler`` in each hardware integration's repairs + module. + + The repair flow runs in the repairs flow manager where ``self.handler`` + is the integration domain rather than the hardware config entry id, so + the ``config_entry`` accessor of ``OptionsFlow`` must be overridden. + """ + + _repair_config_entry: ConfigEntry + + @property + def config_entry(self) -> ConfigEntry: + """Return the hardware config entry to migrate.""" + return self._repair_config_entry + + async def _async_step_start_migration(self) -> ConfigFlowResult: + """Jump straight into the uninstall step of the migration flow. + + The repair flow's init data is the issue context, not user form input, + so pass None to render the uninstall confirmation form. + """ + return await self.async_step_uninstall_addon() # type: ignore[attr-defined, no-any-return] diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index b32998f55b0cb..f9b7ab4de8187 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,6 +8,8 @@ import logging from typing import Any, Protocol +from aiohttp import ClientError +from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing import voluptuous as vol import yarl @@ -27,6 +29,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -39,15 +42,18 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store -from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG +from .const import DOMAIN, LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG +from .util import ( + ApplicationType, + WaitingAddonManager, + async_firmware_flashing_context, + async_flash_silabs_firmware, +) _LOGGER = logging.getLogger(__name__) DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager" -DATA_FLASHER_ADDON_MANAGER = "silabs_flasher" -ADDON_STATE_POLL_INTERVAL = 3 -ADDON_INFO_POLL_TIMEOUT = 15 * 60 CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware" CONF_ADDON_DEVICE = "device" @@ -73,53 +79,6 @@ async def get_multiprotocol_addon_manager( return manager -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 asyncio.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.""" @@ -267,18 +226,6 @@ async def async_using_multipan(self, hass: HomeAssistant) -> bool: """ -@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.""" @@ -341,6 +288,19 @@ def _hardware_name(self) -> str: def _zha_name(self) -> str: """Return the ZHA name.""" + @abstractmethod + def _firmware_update_url(self) -> str: + """Return the firmware update manifest URL.""" + + @abstractmethod + def _zigbee_firmware_type(self) -> str: + """Return the zigbee firmware type identifier (e.g. 'yellow_zigbee_ncp').""" + + @property + @abstractmethod + def _flasher_cls(self) -> type: + """Return the hardware-specific flasher class.""" + @property def flow_manager(self) -> OptionsFlowManager: """Return the correct flow manager.""" @@ -688,61 +648,7 @@ async def async_step_uninstall_addon( async def async_step_firmware_revert( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Install the flasher addon, if necessary.""" - - 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 - ) -> ConfigFlowResult: - """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( - flasher_manager.async_install_addon_waiting(), - "SiLabs Flasher addon install", - eager_start=False, - ) - - if not self.install_task.done(): - return self.async_show_progress( - step_id="install_flasher_addon", - progress_action="install_addon", - description_placeholders={"addon_name": flasher_manager.addon_name}, - progress_task=self.install_task, - ) - - 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 - ) -> ConfigFlowResult: - """Perform initial backup and reconfigure ZHA.""" + """Initiate ZHA backup and start multiprotocol addon uninstall.""" # pylint: disable=hass-component-root-import from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 @@ -784,17 +690,6 @@ async def async_step_configure_flasher_addon( _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( @@ -823,62 +718,90 @@ async def async_step_uninstall_multiprotocol_addon( finally: self.stop_task = None - return self.async_show_progress_done(next_step_id="start_flasher_addon") + return self.async_show_progress_done(next_step_id="install_zigbee_firmware") - async def async_step_start_flasher_addon( + async def async_step_install_zigbee_firmware( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Start Silicon Labs Flasher add-on.""" - flasher_manager = get_flasher_addon_manager(self.hass) - - if not self.start_task: + """Flash Zigbee firmware directly onto the radio.""" + if not self.install_task: - 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 - ) + async def _flash_firmware() -> None: + serial_port_settings = await self._async_serial_port_settings() + device = serial_port_settings.device + + # For the duration of firmware flashing, hint to other integrations + # (i.e. ZHA) that the hardware is in use and should not be accessed. + async with async_firmware_flashing_context(self.hass, device, DOMAIN): + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(self._firmware_update_url(), session) + + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw + for fw in manifest.firmwares + if fw.filename.startswith(self._zigbee_firmware_type()) + ) + fw_data = await client.async_fetch_firmware(fw_manifest) + except ( + StopIteration, + TimeoutError, + ClientError, + ManifestMissing, + ValueError, + ) as err: + raise HomeAssistantError( + "Failed to fetch Zigbee firmware" + ) from err + + await async_flash_silabs_firmware( + hass=self.hass, + device=device, + fw_data=fw_data, + flasher_cls=self._flasher_cls, + expected_installed_firmware_type=ApplicationType.EZSP, + ) - self.start_task = self.hass.async_create_task( - start_and_wait_until_done(), eager_start=False + self.install_task = self.hass.async_create_task( + _flash_firmware(), + "Flash Zigbee firmware", + eager_start=False, ) - if not self.start_task.done(): + if not self.install_task.done(): return self.async_show_progress( - step_id="start_flasher_addon", - progress_action="start_flasher_addon", - description_placeholders={"addon_name": flasher_manager.addon_name}, - progress_task=self.start_task, + step_id="install_zigbee_firmware", + progress_action="install_zigbee_firmware", + description_placeholders={ + "hardware_name": self._hardware_name(), + }, + progress_task=self.install_task, ) try: - await self.start_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="flasher_failed") + await self.install_task + except HomeAssistantError as err: + _LOGGER.error("Failed to flash Zigbee firmware: %s", err) + return self.async_show_progress_done(next_step_id="firmware_flash_failed") finally: - self.start_task = None + self.install_task = None return self.async_show_progress_done(next_step_id="flashing_complete") - async def async_step_flasher_failed( + async def async_step_firmware_flash_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Flasher add-on start failed.""" - flasher_manager = get_flasher_addon_manager(self.hass) + """Firmware flashing failed.""" return self.async_abort( - reason="addon_start_failed", - description_placeholders={"addon_name": flasher_manager.addon_name}, + reason="fw_install_failed", + description_placeholders={"firmware_name": "Zigbee"}, ) async def async_step_flashing_complete( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """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: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 3545c080e089a..8c02b5f6cada0 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -102,7 +102,9 @@ }, "progress": { "install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.", - "start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds." + "install_zigbee_firmware": "Please wait while Zigbee-only firmware is installed on your {hardware_name}. This can take several minutes.", + "start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds.", + "uninstall_multiprotocol_addon": "Please wait while the {addon_name} app is uninstalled." }, "step": { "addon_installed_other_device": { diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index fa0e4e104ee2a..06c7d43350722 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -38,13 +38,59 @@ ZIGBEE_FLASHER_ADDON_SLUG, ) from .helpers import async_firmware_update_context -from .silabs_multiprotocol_addon import ( - WaitingAddonManager, - get_multiprotocol_addon_manager, -) _LOGGER = logging.getLogger(__name__) +ADDON_STATE_POLL_INTERVAL = 3 +ADDON_INFO_POLL_TIMEOUT = 15 * 60 + + +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 asyncio.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 ApplicationType(StrEnum): """Application type running on a device.""" @@ -280,6 +326,11 @@ async def guess_hardware_owners( assert otbr_addon_fw_info is not None device_guesses[otbr_path].append(otbr_addon_fw_info) + # Lazy import to avoid circular dependency + from .silabs_multiprotocol_addon import ( # noqa: PLC0415 + get_multiprotocol_addon_manager, + ) + multipan_addon_manager = await get_multiprotocol_addon_manager(hass) try: diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 317f8eacf72d5..b3e12a2f38606 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -9,7 +9,17 @@ from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) -from homeassistant.components.homeassistant_hardware.util import guess_firmware_info +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + async_create_multi_pan_migration_issue, + async_delete_multi_pan_migration_issue, +) +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + multi_pan_addon_using_device, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + guess_firmware_info, +) from homeassistant.components.usb import ( USBDevice, async_register_port_event_callback, @@ -94,6 +104,18 @@ async def async_setup_entry( translation_key="device_disconnected", ) + uses_multi_pan = ApplicationType(entry.data[FIRMWARE]) is ApplicationType.CPC + if not uses_multi_pan: + try: + uses_multi_pan = await multi_pan_addon_using_device(hass, device_path) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err + + if uses_multi_pan: + async_create_multi_pan_migration_issue(hass, DOMAIN, entry) + else: + async_delete_multi_pan_migration_issue(hass, DOMAIN, entry) + # Create and store the firmware update coordinator in runtime_data session = async_get_clientsession(hass) coordinator = FirmwareUpdateCoordinator( diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 654714aa2431a..e9ea2f9810f3d 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -254,6 +254,19 @@ def _hardware_name(self) -> str: """Return the name of the hardware.""" return self._hw_variant.full_name + def _firmware_update_url(self) -> str: + """Return the firmware update manifest URL.""" + return NABU_CASA_FIRMWARE_RELEASES_URL + + def _zigbee_firmware_type(self) -> str: + """Return the zigbee firmware type identifier.""" + return "skyconnect_zigbee_ncp" + + @property + def _flasher_cls(self) -> type: + """Return the hardware-specific flasher class.""" + return Zbt1Flasher # type: ignore[no-any-return] + async def async_step_flashing_complete( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_sky_connect/repairs.py b/homeassistant/components/homeassistant_sky_connect/repairs.py new file mode 100644 index 0000000000000..a731a776e025b --- /dev/null +++ b/homeassistant/components/homeassistant_sky_connect/repairs.py @@ -0,0 +1,46 @@ +"""Repairs for the Home Assistant SkyConnect integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + ISSUE_MULTI_PAN_MIGRATION, + MultiPanMigrationRepairFlow, +) +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.core import HomeAssistant + +from .config_flow import HomeAssistantSkyConnectMultiPanOptionsFlowHandler + + +class SkyConnectMultiPanMigrationRepairFlow( + MultiPanMigrationRepairFlow, HomeAssistantSkyConnectMultiPanOptionsFlowHandler +): + """Multi-PAN migration repair flow for Home Assistant SkyConnect.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize the repair flow.""" + HomeAssistantSkyConnectMultiPanOptionsFlowHandler.__init__(self, config_entry) + self._repair_config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Jump straight into the uninstall step.""" + return await self._async_step_start_migration() + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create a fix flow for a SkyConnect repair issue.""" + if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None: + entry_id = cast(str, data["entry_id"]) + if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: + return SkyConnectMultiPanMigrationRepairFlow(entry) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 43a3d4dcf7bab..7a033aa299241 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -106,6 +106,37 @@ "message": "The device is not plugged in" } }, + "issues": { + "multi_pan_migration": { + "fix_flow": { + "abort": { + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", + "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_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%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]", + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]" + }, + "progress": { + "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" + }, + "step": { + "uninstall_addon": { + "data": { + "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" + }, + "description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support 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 restore a backup.", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + } + }, + "title": "Multiprotocol support is deprecated" + } + }, "options": { "abort": { "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", @@ -130,8 +161,10 @@ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" }, "step": { "addon_installed_other_device": { diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 26a7b90f3fde0..dd6d5bbfa79b5 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -9,8 +9,13 @@ from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + async_create_multi_pan_migration_issue, + async_delete_multi_pan_migration_issue, +) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, + multi_pan_addon_using_device, ) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, @@ -29,6 +34,7 @@ from homeassistant.helpers.hassio import is_hassio from .const import ( + DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, @@ -72,12 +78,21 @@ async def async_setup_entry( firmware = ApplicationType(entry.data[FIRMWARE]) # Auto start the multiprotocol addon if it is in use + try: + multipan_using_device = await multi_pan_addon_using_device(hass, RADIO_DEVICE) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err if firmware is ApplicationType.CPC: try: await check_multi_pan_addon(hass) except HomeAssistantError as err: raise ConfigEntryNotReady from err + if firmware is ApplicationType.CPC or multipan_using_device: + async_create_multi_pan_migration_issue(hass, DOMAIN, entry) + else: + async_delete_multi_pan_migration_issue(hass, DOMAIN, entry) + if firmware is ApplicationType.EZSP: discovery_flow.async_create_flow( hass, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index acbdb1f1a587e..8d5390f3d9700 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -321,6 +321,19 @@ def _hardware_name(self) -> str: """Return the name of the hardware.""" return BOARD_NAME + def _firmware_update_url(self) -> str: + """Return the firmware update manifest URL.""" + return NABU_CASA_FIRMWARE_RELEASES_URL + + def _zigbee_firmware_type(self) -> str: + """Return the zigbee firmware type identifier.""" + return "yellow_zigbee_ncp" + + @property + def _flasher_cls(self) -> type: + """Return the hardware-specific flasher class.""" + return YellowFlasher # type: ignore[no-any-return] + async def async_step_flashing_complete( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_yellow/repairs.py b/homeassistant/components/homeassistant_yellow/repairs.py new file mode 100644 index 0000000000000..5c1c57b4acd5f --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/repairs.py @@ -0,0 +1,44 @@ +"""Repairs for the Home Assistant Yellow integration.""" + +from __future__ import annotations + +from typing import cast + +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + ISSUE_MULTI_PAN_MIGRATION, + MultiPanMigrationRepairFlow, +) +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.core import HomeAssistant + +from .config_flow import HomeAssistantYellowMultiPanOptionsFlowHandler + + +class YellowMultiPanMigrationRepairFlow( + MultiPanMigrationRepairFlow, HomeAssistantYellowMultiPanOptionsFlowHandler +): + """Multi-PAN migration repair flow for Home Assistant Yellow.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the repair flow.""" + HomeAssistantYellowMultiPanOptionsFlowHandler.__init__(self, hass, config_entry) + self._repair_config_entry = config_entry + + async def async_step_main_menu(self, _: None = None) -> ConfigFlowResult: + """Jump straight into the uninstall step.""" + return await self._async_step_start_migration() + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create a fix flow for a Yellow repair issue.""" + if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None: + entry_id = cast(str, data["entry_id"]) + if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: + return YellowMultiPanMigrationRepairFlow(hass, entry) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index aacf51da97d4d..de451b7b12575 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,6 +11,37 @@ } } }, + "issues": { + "multi_pan_migration": { + "fix_flow": { + "abort": { + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", + "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_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%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]", + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]" + }, + "progress": { + "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" + }, + "step": { + "uninstall_addon": { + "data": { + "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" + }, + "description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support 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 restore a backup.", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + } + }, + "title": "Multiprotocol support is deprecated" + } + }, "options": { "abort": { "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", @@ -37,8 +68,10 @@ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]", "install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]", + "install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]" + "start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]", + "uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]" }, "step": { "addon_installed_other_device": { diff --git a/tests/components/homeassistant_hardware/test_repairs.py b/tests/components/homeassistant_hardware/test_repairs.py new file mode 100644 index 0000000000000..fd5a2a74421c6 --- /dev/null +++ b/tests/components/homeassistant_hardware/test_repairs.py @@ -0,0 +1,44 @@ +"""Tests for the homeassistant_hardware repairs helpers.""" + +from __future__ import annotations + +import pytest + +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + ISSUE_MULTI_PAN_MIGRATION, + async_create_multi_pan_migration_issue, + async_delete_multi_pan_migration_issue, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + +TEST_DOMAIN = "test_hardware" + + +@pytest.fixture +def ignore_translations_for_mock_domains() -> str: + """Ignore translation check for the fake test_hardware domain.""" + return TEST_DOMAIN + + +async def test_create_and_delete_multi_pan_migration_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the helpers create and delete the migration issue per entry.""" + entry = MockConfigEntry(domain=TEST_DOMAIN, title="Test HW", data={}) + entry.add_to_hass(hass) + + async_create_multi_pan_migration_issue(hass, TEST_DOMAIN, entry) + issue_id = f"{ISSUE_MULTI_PAN_MIGRATION}_{entry.entry_id}" + issue = issue_registry.async_get_issue(domain=TEST_DOMAIN, issue_id=issue_id) + assert issue is not None + assert issue.translation_key == ISSUE_MULTI_PAN_MIGRATION + assert issue.translation_placeholders == {"hardware_name": "Test HW"} + assert issue.data == {"entry_id": entry.entry_id} + assert issue.is_fixable + assert issue.severity is ir.IssueSeverity.WARNING + + async_delete_multi_pan_migration_issue(hass, TEST_DOMAIN, entry) + assert issue_registry.async_get_issue(domain=TEST_DOMAIN, issue_id=issue_id) is None diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index fbba3d42bbe67..af13d20d64acd 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -8,6 +8,7 @@ from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsOptions +from aiohttp import ClientError import pytest from homeassistant.components.hassio import AddonError, AddonInfo, AddonState, HassIO @@ -98,6 +99,19 @@ def _hardware_name(self) -> str: """Return the name of the hardware.""" return "Test" + def _firmware_update_url(self) -> str: + """Return the firmware update manifest URL.""" + return "https://example.com/firmware" + + def _zigbee_firmware_type(self) -> str: + """Return the zigbee firmware type identifier.""" + return "test_zigbee_ncp" + + @property + def _flasher_cls(self) -> type: + """Return the hardware-specific flasher class.""" + return Mock + @pytest.fixture(autouse=True) def config_flow_handler( @@ -118,6 +132,31 @@ def options_flow_poll_addon_state() -> Generator[None]: yield +@pytest.fixture +def mock_firmware_client() -> Generator[tuple[AsyncMock, AsyncMock]]: + """Fixture to mock FirmwareUpdateClient and async_flash_silabs_firmware.""" + mock_fw_manifest = Mock() + mock_fw_manifest.filename = "test_zigbee_ncp_7.4.4.0.gbl" + mock_fw_client = AsyncMock() + mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest]) + mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware" + + with ( + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient", + return_value=mock_fw_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_firmware_flashing_context" + ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware", + new_callable=AsyncMock, + ) as mock_flash, + ): + yield mock_fw_client, mock_flash + + @pytest.fixture(autouse=True) def hassio_integration(hass: HomeAssistant) -> Generator[None]: """Fixture to mock the `hassio` integration.""" @@ -174,7 +213,7 @@ def get_suggested(schema, key): @patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.ADDON_STATE_POLL_INTERVAL", + "homeassistant.components.homeassistant_hardware.util.ADDON_STATE_POLL_INTERVAL", 0, ) @pytest.mark.usefixtures( @@ -633,12 +672,10 @@ async def test_option_flow_addon_installed_same_device_uninstall( addon_info, addon_store_info, addon_installed, - install_addon, - start_addon, stop_addon, uninstall_addon, - set_addon_options, options_flow_poll_addon_state, + mock_firmware_client: tuple[AsyncMock, AsyncMock], ) -> None: """Test uninstalling the multi pan addon.""" @@ -675,21 +712,10 @@ async def test_option_flow_addon_installed_same_device_uninstall( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "uninstall_addon" - # Make sure the flasher addon is installed - addon_store_info.return_value.installed = False - addon_store_info.return_Value.available = True - result = await hass.config_entries.options.async_configure( result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "install_flasher_addon" - assert result["progress_action"] == "install_addon" - - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "uninstall_multiprotocol_addon" assert result["progress_action"] == "uninstall_multiprotocol_addon" @@ -699,12 +725,10 @@ async def test_option_flow_addon_installed_same_device_uninstall( result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_flasher_addon" - assert result["progress_action"] == "start_flasher_addon" - assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} + assert result["step_id"] == "install_zigbee_firmware" + assert result["progress_action"] == "install_zigbee_firmware" await hass.async_block_till_done() - install_addon.assert_called_once_with("core_silabs_flasher") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -726,11 +750,6 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa addon_info, addon_store_info, addon_installed, - install_addon, - start_addon, - stop_addon, - uninstall_addon, - set_addon_options, ) -> None: """Test uninstalling the multi pan addon.""" @@ -764,66 +783,15 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa @pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) -async def test_option_flow_flasher_already_running_failure( - hass: HomeAssistant, - addon_info, - addon_store_info, - addon_installed, - install_addon, - start_addon, - stop_addon, - uninstall_addon, - set_addon_options, - options_flow_poll_addon_state, -) -> None: - """Test uninstalling the multi pan addon but with the flasher addon running.""" - - addon_info.return_value.options["device"] = "/dev/ttyTEST123" - - # Setup the config entry - config_entry = MockConfigEntry( - data={}, - domain=TEST_DOMAIN, - options={}, - title="Test HW", - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "addon_menu" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - {"next_step_id": "uninstall_addon"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "uninstall_addon" - - # The flasher addon is already installed and running, this is bad - addon_store_info.return_value.installed = True - addon_info.return_value.state = "started" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "addon_already_running" - - -async def test_option_flow_addon_installed_same_device_flasher_already_installed( +async def test_option_flow_firmware_flash_failure( hass: HomeAssistant, addon_info, addon_store_info, addon_installed, - install_addon, - start_addon, - stop_addon, uninstall_addon, - set_addon_options, options_flow_poll_addon_state, ) -> None: - """Test uninstalling the multi pan addon.""" + """Test uninstalling the multi pan addon, case where firmware flash fails.""" addon_info.return_value.options["device"] = "/dev/ttyTEST123" @@ -847,115 +815,60 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["type"] is FlowResultType.FORM assert result["step_id"] == "uninstall_addon" - addon_store_info.return_value.installed = True - addon_store_info.return_value.available = True + mock_fw_manifest = Mock() + mock_fw_manifest.filename = "test_zigbee_ncp_7.4.4.0.gbl" + mock_fw_client = AsyncMock() + mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest]) + mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware" - result = await hass.config_entries.options.async_configure( - result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} - ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "uninstall_multiprotocol_addon" - assert result["progress_action"] == "uninstall_multiprotocol_addon" - - await hass.async_block_till_done() - uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") - - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_flasher_addon" - assert result["progress_action"] == "start_flasher_addon" - assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} - - addon_store_info.return_value.installed = True - addon_store_info.return_value.available = True - await hass.async_block_till_done() - install_addon.assert_not_called() - - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - - -@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) -async def test_option_flow_flasher_install_failure( - hass: HomeAssistant, - addon_info, - addon_store_info, - addon_installed, - install_addon, - start_addon, - stop_addon, - uninstall_addon, - set_addon_options, - options_flow_poll_addon_state, -) -> None: - """Test uninstalling the multi pan addon, case where flasher addon fails.""" - - addon_info.return_value.options["device"] = "/dev/ttyTEST123" - - # Setup the config entry - config_entry = MockConfigEntry( - data={}, - domain=TEST_DOMAIN, - options={}, - title="Test HW", - ) - config_entry.add_to_hass(hass) - - zha_config_entry = MockConfigEntry( - data={ - "device": {"path": "socket://core-silabs-multiprotocol:9999"}, - "radio_type": "ezsp", - }, - domain=ZHA_DOMAIN, - options={}, - title="Test Multiprotocol", - ) - zha_config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient", + return_value=mock_fw_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_firmware_flashing_context" + ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware", + new_callable=AsyncMock, + side_effect=HomeAssistantError("Flash failed"), + ), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True}, + ) - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "addon_menu" + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "uninstall_multiprotocol_addon" + assert result["progress_action"] == "uninstall_multiprotocol_addon" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - {"next_step_id": "uninstall_addon"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "uninstall_addon" + await hass.async_block_till_done() + uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") - addon_store_info.return_value.installed = False - addon_store_info.return_value.available = True - install_addon.side_effect = [AddonError()] - result = await hass.config_entries.options.async_configure( - result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} - ) + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_zigbee_firmware" + assert result["progress_action"] == "install_zigbee_firmware" - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "install_flasher_addon" - assert result["progress_action"] == "install_addon" + await hass.async_block_till_done() - await hass.async_block_till_done() - install_addon.assert_called_once_with("core_silabs_flasher") - - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "addon_install_failed" + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "fw_install_failed" @pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) -async def test_option_flow_flasher_addon_flash_failure( +async def test_option_flow_zigbee_firmware_fetch_failure( hass: HomeAssistant, addon_info, addon_store_info, addon_installed, - install_addon, - start_addon, - stop_addon, uninstall_addon, - set_addon_options, options_flow_poll_addon_state, ) -> None: - """Test where flasher addon fails to flash Zigbee firmware.""" + """Test where fetching Zigbee firmware fails.""" addon_info.return_value.options["device"] = "/dev/ttyTEST123" @@ -979,30 +892,39 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "uninstall_addon" - result = await hass.config_entries.options.async_configure( - result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} - ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "uninstall_multiprotocol_addon" - assert result["progress_action"] == "uninstall_multiprotocol_addon" + mock_fw_client = AsyncMock() + mock_fw_client.async_update_data.side_effect = ClientError("Network error") - start_addon.side_effect = SupervisorError("Boom") + with ( + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient", + return_value=mock_fw_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_firmware_flashing_context" + ), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True}, + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "uninstall_multiprotocol_addon" + assert result["progress_action"] == "uninstall_multiprotocol_addon" - await hass.async_block_till_done() - uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") + await hass.async_block_till_done() + uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_flasher_addon" - assert result["progress_action"] == "start_flasher_addon" - assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_zigbee_firmware" + assert result["progress_action"] == "install_zigbee_firmware" - await hass.async_block_till_done() + await hass.async_block_till_done() - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "addon_start_failed" - assert result["description_placeholders"]["addon_name"] == "Silicon Labs Flasher" + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "fw_install_failed" @pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @@ -1016,11 +938,6 @@ async def test_option_flow_uninstall_migration_initiate_failure( addon_info, addon_store_info, addon_installed, - install_addon, - start_addon, - stop_addon, - uninstall_addon, - set_addon_options, options_flow_poll_addon_state, ) -> None: """Test uninstalling the multi pan addon, case where ZHA migration init fails.""" @@ -1078,14 +995,11 @@ async def test_option_flow_uninstall_migration_finish_failure( addon_info, addon_store_info, addon_installed, - install_addon, - start_addon, - stop_addon, uninstall_addon, - set_addon_options, options_flow_poll_addon_state, + mock_firmware_client: tuple[AsyncMock, AsyncMock], ) -> None: - """Test uninstalling the multi pan addon, case where ZHA migration init fails.""" + """Test uninstalling the multi pan addon, case where ZHA migration finish fails.""" addon_info.return_value.options["device"] = "/dev/ttyTEST123" @@ -1129,9 +1043,8 @@ async def test_option_flow_uninstall_migration_finish_failure( result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_flasher_addon" - assert result["progress_action"] == "start_flasher_addon" - assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} + assert result["step_id"] == "install_zigbee_firmware" + assert result["progress_action"] == "install_zigbee_firmware" await hass.async_block_till_done() diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 70cb55a8aff41..1a947483416a4 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -219,7 +219,7 @@ async def mock_z2m_firmware_info( return_value=None, ), patch( - "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=multipan_addon_manager, ), patch( @@ -290,7 +290,7 @@ async def test_guess_hardware_owners_otbr(hass: HomeAssistant) -> None: return_value=otbr_addon_fw_info, ), patch( - "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=multipan_addon_manager, ), patch( @@ -334,7 +334,7 @@ async def test_guess_hardware_owners_multipan(hass: HomeAssistant) -> None: return_value=None, ), patch( - "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=multipan_addon_manager, ), patch( diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 3ec09b358e798..b5d45c18fe981 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -18,7 +18,6 @@ ) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( CONF_DISABLE_MULTI_PAN, - get_flasher_addon_manager, get_multiprotocol_addon_manager, ) from homeassistant.components.homeassistant_hardware.util import ( @@ -384,15 +383,11 @@ async def test_options_flow_multipan_uninstall( version="1.0.0", ) - mock_flasher_manager = Mock(spec_set=get_flasher_addon_manager(hass)) - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) + mock_fw_manifest = Mock() + mock_fw_manifest.filename = "skyconnect_zigbee_ncp_7.4.4.0.gbl" + mock_fw_client = AsyncMock() + mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest]) + mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware" with ( patch( @@ -400,8 +395,12 @@ async def test_options_flow_multipan_uninstall( return_value=mock_multipan_manager, ), patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_flasher_addon_manager", - return_value=mock_flasher_manager, + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient", + return_value=mock_fw_client, ), patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", @@ -424,11 +423,15 @@ async def test_options_flow_multipan_uninstall( result["flow_id"], user_input={CONF_DISABLE_MULTI_PAN: True} ) - # Finish the flow + # Uninstall multiprotocol addon result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done(wait_background_tasks=True) + + # Flash zigbee firmware result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done(wait_background_tasks=True) + + # Flashing complete result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3809972e80284..001e09e3a5927 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -5,6 +5,9 @@ import pytest +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + ISSUE_MULTI_PAN_MIGRATION, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, @@ -22,6 +25,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -291,3 +295,126 @@ async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None: untouched_bad_entry = hass.config_entries.async_get_entry(bad_entry.entry_id) assert untouched_bad_entry.minor_version == 3 + + +def _multi_pan_sky_connect_entry(firmware: str) -> MockConfigEntry: + """Return a SkyConnect config entry with the given firmware type.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": firmware, + "firmware_version": None, + }, + title="Home Assistant SkyConnect", + version=1, + minor_version=4, + ) + + +async def test_multi_pan_migration_issue_created_for_cpc( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the multi-PAN migration repair issue is created when firmware is CPC.""" + config_entry = _multi_pan_sky_connect_entry(ApplicationType.CPC.value) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant_sky_connect.multi_pan_addon_using_device", + return_value=False, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + ) + assert issue is not None + assert issue.translation_key == ISSUE_MULTI_PAN_MIGRATION + assert issue.translation_placeholders == { + "hardware_name": "Home Assistant SkyConnect" + } + assert issue.data == {"entry_id": config_entry.entry_id} + assert issue.is_fixable + + +async def test_multi_pan_migration_issue_created_for_addon( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the repair issue is created when the multi-PAN addon is running.""" + config_entry = _multi_pan_sky_connect_entry(ApplicationType.SPINEL.value) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant_sky_connect.multi_pan_addon_using_device", + return_value=True, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + ) + assert issue is not None + assert issue.is_fixable + + +async def test_multi_pan_migration_issue_deleted_for_ezsp( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the multi-PAN migration repair issue is removed when not using multi-PAN.""" + config_entry = _multi_pan_sky_connect_entry(ApplicationType.EZSP.value) + config_entry.add_to_hass(hass) + + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MULTI_PAN_MIGRATION, + translation_placeholders={"hardware_name": "Home Assistant SkyConnect"}, + data={"entry_id": config_entry.entry_id}, + ) + + with ( + patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant_sky_connect.multi_pan_addon_using_device", + return_value=False, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + ) + is None + ) diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index db490c97a3c71..1314d855f11bb 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -19,7 +19,6 @@ ) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( CONF_DISABLE_MULTI_PAN, - get_flasher_addon_manager, get_multiprotocol_addon_manager, ) from homeassistant.components.homeassistant_hardware.util import ( @@ -549,15 +548,11 @@ async def test_options_flow_multipan_uninstall(hass: HomeAssistant) -> None: version="1.0.0", ) - mock_flasher_manager = Mock(spec_set=get_flasher_addon_manager(hass)) - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) + mock_fw_manifest = Mock() + mock_fw_manifest.filename = "yellow_zigbee_ncp_7.4.4.0.gbl" + mock_fw_client = AsyncMock() + mock_fw_client.async_update_data.return_value = Mock(firmwares=[mock_fw_manifest]) + mock_fw_client.async_fetch_firmware.return_value = b"fake_firmware" with ( patch( @@ -565,8 +560,12 @@ async def test_options_flow_multipan_uninstall(hass: HomeAssistant) -> None: return_value=mock_multipan_manager, ), patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_flasher_addon_manager", - return_value=mock_flasher_manager, + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.async_flash_silabs_firmware", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.FirmwareUpdateClient", + return_value=mock_fw_client, ), patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", @@ -595,11 +594,15 @@ async def test_options_flow_multipan_uninstall(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_DISABLE_MULTI_PAN: True} ) - # Finish the flow + # Uninstall multiprotocol addon result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done(wait_background_tasks=True) + + # Flash zigbee firmware result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done(wait_background_tasks=True) + + # Flashing complete result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 2ec4602222a7e..08c56b702e562 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -6,6 +6,9 @@ from homeassistant.components import zha from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN +from homeassistant.components.homeassistant_hardware.repair_helpers import ( + ISSUE_MULTI_PAN_MIGRATION, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, @@ -18,6 +21,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration @@ -321,6 +325,156 @@ async def test_setup_entry_addon_info_fails( assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_multi_pan_migration_issue_created_for_cpc( + hass: HomeAssistant, + addon_store_info, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the multi-PAN migration repair issue is created when firmware is CPC.""" + mock_integration(hass, MockModule("hassio")) + await async_setup_component(hass, HASSIO_DOMAIN, {}) + + config_entry = MockConfigEntry( + data={"firmware": ApplicationType.CPC}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + version=1, + minor_version=2, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ), + patch( + "homeassistant.components.homeassistant_yellow.check_multi_pan_addon", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device", + return_value=False, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + ) + assert issue is not None + assert issue.translation_key == ISSUE_MULTI_PAN_MIGRATION + assert issue.translation_placeholders == {"hardware_name": "Home Assistant Yellow"} + assert issue.data == {"entry_id": config_entry.entry_id} + assert issue.is_fixable + + +async def test_multi_pan_migration_issue_created_for_addon( + hass: HomeAssistant, + addon_store_info, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the repair issue is created when the multi-PAN addon is running.""" + mock_integration(hass, MockModule("hassio")) + await async_setup_component(hass, HASSIO_DOMAIN, {}) + + config_entry = MockConfigEntry( + data={"firmware": ApplicationType.SPINEL}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + version=1, + minor_version=2, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ), + patch( + "homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device", + return_value=True, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + ) + assert issue is not None + assert issue.is_fixable + + +async def test_multi_pan_migration_issue_deleted_for_ezsp( + hass: HomeAssistant, + addon_store_info, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the multi-PAN migration repair issue is removed when not using multi-PAN.""" + mock_integration(hass, MockModule("hassio")) + await async_setup_component(hass, HASSIO_DOMAIN, {}) + + config_entry = MockConfigEntry( + data={"firmware": ApplicationType.EZSP}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + version=1, + minor_version=2, + ) + config_entry.add_to_hass(hass) + + # Pre-existing issue from a previous CPC run + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MULTI_PAN_MIGRATION, + translation_placeholders={"hardware_name": "Home Assistant Yellow"}, + data={"entry_id": config_entry.entry_id}, + ) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ), + patch( + "homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device", + return_value=False, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}", + ) + is None + ) + + @pytest.mark.parametrize( ("start_version", "data", "migrated_data"), [ @@ -379,6 +533,10 @@ async def test_migrate_entry( owners=[], ), ), + patch( + "homeassistant.components.homeassistant_yellow.multi_pan_addon_using_device", + return_value=False, + ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done()