From 5e274f0a2672188bac435987fae371d3eac50ac2 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 31 Oct 2025 03:28:08 +0100 Subject: [PATCH 1/6] Show dialog if old adapter was unplugged before reset --- homeassistant/components/zha/config_flow.py | 30 ++++++++++++++++++++- homeassistant/components/zha/strings.json | 8 ++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 3b7d4ff19ba64..8865a5906f736 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -473,10 +473,38 @@ async def async_step_maybe_reset_old_radio( temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE] temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] - await temp_radio_mgr.async_reset_adapter() + try: + await temp_radio_mgr.async_reset_adapter() + except HomeAssistantError: + # Old adapter not found or cannot connect, show prompt to plug back in + return await self.async_step_plug_in_old_radio() return await self.async_step_maybe_confirm_ezsp_restore() + async def async_step_plug_in_old_radio( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prompt user to plug in the old radio if connection fails.""" + config_entries = self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False + ) + + # config entry should always exist here, skip if not + if not config_entries: + return await self.async_step_maybe_confirm_ezsp_restore() + + config_entry = config_entries[0] + old_device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + + # user confirmed, try again + if user_input is not None: + return await self.async_step_maybe_reset_old_radio() + + return self.async_show_form( + step_id="plug_in_old_radio", + description_placeholders={"device_path": old_device_path}, + ) + async def async_step_migration_strategy_advanced( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index e9e506c826a97..485f6a4b7fc79 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -111,6 +111,10 @@ "description": "A backup was created earlier and your old adapter is being reset as part of the migration.", "title": "Resetting old adapter" }, + "plug_in_old_radio": { + "description": "Your old adapter at `{device_path}` was not found. Please plug it back in and click Submit to continue.\n\nOnce plugged in, your old adapter will be reset as part of the migration.", + "title": "Old adapter not found" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Upload a file" @@ -1945,6 +1949,10 @@ "description": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::description%]", "title": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::title%]" }, + "plug_in_old_radio": { + "description": "[%key:component::zha::config::step::plug_in_old_radio::description%]", + "title": "[%key:component::zha::config::step::plug_in_old_radio::title%]" + }, "prompt_migrate_or_reconfigure": { "description": "Are you migrating to a new adapter or re-configuring the current adapter?", "menu_option_descriptions": { From 80987328eff71924390ca9f0c6bc0d65cf8628a4 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 31 Oct 2025 03:51:24 +0100 Subject: [PATCH 2/6] Add option to skip reset of old adapter --- homeassistant/components/zha/config_flow.py | 19 ++++++++++++++----- homeassistant/components/zha/strings.json | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 8865a5906f736..8c46867603f2e 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -496,15 +496,24 @@ async def async_step_plug_in_old_radio( config_entry = config_entries[0] old_device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - # user confirmed, try again - if user_input is not None: - return await self.async_step_maybe_reset_old_radio() - - return self.async_show_form( + return self.async_show_menu( step_id="plug_in_old_radio", + menu_options=["retry", "skip_reset"], description_placeholders={"device_path": old_device_path}, ) + async def async_step_retry( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Retry connecting to the old radio.""" + return await self.async_step_maybe_reset_old_radio() + + async def async_step_skip_reset( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Skip resetting the old radio and continue with migration.""" + return await self.async_step_maybe_confirm_ezsp_restore() + async def async_step_migration_strategy_advanced( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 485f6a4b7fc79..54c740ff436ef 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -112,7 +112,15 @@ "title": "Resetting old adapter" }, "plug_in_old_radio": { - "description": "Your old adapter at `{device_path}` was not found. Please plug it back in and click Submit to continue.\n\nOnce plugged in, your old adapter will be reset as part of the migration.", + "description": "Your old adapter at `{device_path}` was not found. You can retry after plugging it back in, or skip resetting the old adapter.\n\nIf you skip resetting the old adapter, please make sure it is permanently unavailable, as plugging it back in later will cause network issues.", + "menu_option_descriptions": { + "retry": "Retry connecting to the old adapter to reset it as part of the migration.", + "skip_reset": "Skip resetting the old adapter and continue with the migration." + }, + "menu_options": { + "retry": "Retry", + "skip_reset": "Skip reset" + }, "title": "Old adapter not found" }, "upload_manual_backup": { @@ -1951,6 +1959,14 @@ }, "plug_in_old_radio": { "description": "[%key:component::zha::config::step::plug_in_old_radio::description%]", + "menu_option_descriptions": { + "retry": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::retry%]", + "skip_reset": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::skip_reset%]" + }, + "menu_options": { + "retry": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::retry%]", + "skip_reset": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::skip_reset%]" + }, "title": "[%key:component::zha::config::step::plug_in_old_radio::title%]" }, "prompt_migrate_or_reconfigure": { From a17d5b5c20f3afeac15fc3daa5e2735f4a5b75ae Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 31 Oct 2025 04:12:49 +0100 Subject: [PATCH 3/6] Show dialog if new adapter was unplugged before restore --- homeassistant/components/zha/config_flow.py | 21 +++++++++++++++++++++ homeassistant/components/zha/strings.json | 8 ++++++++ 2 files changed, 29 insertions(+) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 8c46867603f2e..9494011e3c046 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -514,6 +514,21 @@ async def async_step_skip_reset( """Skip resetting the old radio and continue with migration.""" return await self.async_step_maybe_confirm_ezsp_restore() + async def async_step_plug_in_new_radio( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prompt user to plug in the new radio if connection fails.""" + if user_input is not None: + # User confirmed, retry now + return await self.async_step_maybe_confirm_ezsp_restore() + + assert self._radio_mgr.device_path is not None + + return self.async_show_form( + step_id="plug_in_new_radio", + description_placeholders={"device_path": self._radio_mgr.device_path}, + ) + async def async_step_migration_strategy_advanced( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -670,6 +685,9 @@ async def async_step_maybe_confirm_ezsp_restore( # On confirmation, overwrite destructively try: await self._radio_mgr.restore_backup(overwrite_ieee=True) + except HomeAssistantError: + # User unplugged the new adapter, allow retry + return await self.async_step_plug_in_new_radio() except CannotWriteNetworkSettings as exc: return self.async_abort( reason="cannot_restore_backup", @@ -687,6 +705,9 @@ async def async_step_maybe_confirm_ezsp_restore( except DestructiveWriteNetworkSettings: # Restore cannot happen automatically, we need to ask for permission pass + except HomeAssistantError: + # User unplugged the new adapter, allow retry + return await self.async_step_plug_in_new_radio() except CannotWriteNetworkSettings as exc: return self.async_abort( reason="cannot_restore_backup", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 54c740ff436ef..5c9c472c0f4a4 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -111,6 +111,10 @@ "description": "A backup was created earlier and your old adapter is being reset as part of the migration.", "title": "Resetting old adapter" }, + "plug_in_new_radio": { + "description": "Your new adapter at `{device_path}` was not found.\nPlease plug it in and click Submit to continue.", + "title": "New adapter not found" + }, "plug_in_old_radio": { "description": "Your old adapter at `{device_path}` was not found. You can retry after plugging it back in, or skip resetting the old adapter.\n\nIf you skip resetting the old adapter, please make sure it is permanently unavailable, as plugging it back in later will cause network issues.", "menu_option_descriptions": { @@ -1957,6 +1961,10 @@ "description": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::description%]", "title": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::title%]" }, + "plug_in_new_radio": { + "description": "[%key:component::zha::config::step::plug_in_new_radio::description%]", + "title": "[%key:component::zha::config::step::plug_in_new_radio::title%]" + }, "plug_in_old_radio": { "description": "[%key:component::zha::config::step::plug_in_old_radio::description%]", "menu_option_descriptions": { From 53b78164e4e4e90e9525aca70df73651e170ec3e Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 31 Oct 2025 05:14:11 +0100 Subject: [PATCH 4/6] Add test for `plug_in_new_radio` retry step --- tests/components/zha/test_config_flow.py | 86 ++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index ce65e2c11cca5..e252cb637abb4 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -51,6 +51,7 @@ from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL, @@ -2925,3 +2926,88 @@ async def test_migrate_setup_options_with_ignored_discovery( "setup_strategy_recommended", "setup_strategy_advanced", ] + + +@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee") +async def test_plug_in_new_radio_retry( + allow_overwrite_ieee_mock, + advanced_pick_radio: RadioPicker, + mock_app: AsyncMock, + backup, + hass: HomeAssistant, +) -> None: + """Test plug_in_new_radio step when restore fails due to unplugged adapter.""" + result = await advanced_pick_radio(RadioType.ezsp) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "upload_manual_backup" + + with ( + patch( + "homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup", + return_value=backup, + ), + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + side_effect=[ + HomeAssistantError( + "Failed to connect to Zigbee adapter: [Errno 2] No such file or directory" + ), + DestructiveWriteNetworkSettings("Radio IEEE change is permanent"), + HomeAssistantError( + "Failed to connect to Zigbee adapter: [Errno 2] No such file or directory" + ), + None, + ], + ) as mock_restore_backup, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())}, + ) + + # Prompt user to plug old adapter back in when restore fails + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "plug_in_new_radio" + assert result3["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"} + + # Submit retry attempt with plugged in adapter + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={}, + ) + + # This adapter requires user confirmation for restore + assert result4["type"] is FlowResultType.FORM + assert result4["step_id"] == "maybe_confirm_ezsp_restore" + + # Confirm destructive rewrite, but adapter is unplugged again + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True}, + ) + + # Prompt user to plug old adapter back in again + assert result4["type"] is FlowResultType.FORM + assert result4["step_id"] == "plug_in_new_radio" + assert result4["description_placeholders"] == {"device_path": "/dev/ttyUSB1234"} + + # User confirms they plugged in the adapter + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input={}, + ) + + # Entry created successfully + assert result5["type"] is FlowResultType.CREATE_ENTRY + assert result5["data"][CONF_RADIO_TYPE] == "ezsp" + + # Verify restore was attempted four times: + # first fail + retry for destructive dialog + failed destructive + successful retry + assert mock_restore_backup.call_count == 4 From 35d0104e58ec89e99451d99569828bd2069d56c0 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 31 Oct 2025 05:16:30 +0100 Subject: [PATCH 5/6] Rename steps for old radio retry and skip reset --- homeassistant/components/zha/config_flow.py | 6 +++--- homeassistant/components/zha/strings.json | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 9494011e3c046..a53deb776b733 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -498,17 +498,17 @@ async def async_step_plug_in_old_radio( return self.async_show_menu( step_id="plug_in_old_radio", - menu_options=["retry", "skip_reset"], + menu_options=["retry_old_radio", "skip_reset_old_radio"], description_placeholders={"device_path": old_device_path}, ) - async def async_step_retry( + async def async_step_retry_old_radio( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Retry connecting to the old radio.""" return await self.async_step_maybe_reset_old_radio() - async def async_step_skip_reset( + async def async_step_skip_reset_old_radio( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Skip resetting the old radio and continue with migration.""" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 5c9c472c0f4a4..c8b2b758b874f 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -118,12 +118,12 @@ "plug_in_old_radio": { "description": "Your old adapter at `{device_path}` was not found. You can retry after plugging it back in, or skip resetting the old adapter.\n\nIf you skip resetting the old adapter, please make sure it is permanently unavailable, as plugging it back in later will cause network issues.", "menu_option_descriptions": { - "retry": "Retry connecting to the old adapter to reset it as part of the migration.", - "skip_reset": "Skip resetting the old adapter and continue with the migration." + "retry_old_radio": "Retry connecting to the old adapter to reset it as part of the migration.", + "skip_reset_old_radio": "Skip resetting the old adapter and continue with the migration." }, "menu_options": { - "retry": "Retry", - "skip_reset": "Skip reset" + "retry_old_radio": "Retry", + "skip_reset_old_radio": "Skip reset" }, "title": "Old adapter not found" }, @@ -1968,12 +1968,12 @@ "plug_in_old_radio": { "description": "[%key:component::zha::config::step::plug_in_old_radio::description%]", "menu_option_descriptions": { - "retry": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::retry%]", - "skip_reset": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::skip_reset%]" + "retry_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::retry_old_radio%]", + "skip_reset_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::skip_reset_old_radio%]" }, "menu_options": { - "retry": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::retry%]", - "skip_reset": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::skip_reset%]" + "retry_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::retry_old_radio%]", + "skip_reset_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::skip_reset_old_radio%]" }, "title": "[%key:component::zha::config::step::plug_in_old_radio::title%]" }, From ede212a4b46641618464ac35a81cc44c49f03833 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 31 Oct 2025 05:45:54 +0100 Subject: [PATCH 6/6] Add test for `plug_in_old_radio` retry step --- tests/components/zha/test_config_flow.py | 95 ++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index e252cb637abb4..e57a7e38b7e1a 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -3011,3 +3011,98 @@ async def test_plug_in_new_radio_retry( # Verify restore was attempted four times: # first fail + retry for destructive dialog + failed destructive + successful retry assert mock_restore_backup.call_count == 4 + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_plug_in_old_radio_retry(hass: HomeAssistant, backup, mock_app) -> None: + """Test plug_in_old_radio step when reset fails due to unplugged adapter.""" + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB0", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, + CONF_RADIO_TYPE: "ezsp", + }, + ) + entry.add_to_hass(hass) + + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + mock_temp_radio_mgr = AsyncMock() + mock_temp_radio_mgr.async_reset_adapter = AsyncMock( + side_effect=HomeAssistantError( + "Failed to connect to Zigbee adapter: [Errno 2] No such file or directory" + ) + ) + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", + return_value=[backup], + ), + patch( + "homeassistant.components.zha.config_flow.ZhaRadioManager", + side_effect=[ZhaRadioManager(), mock_temp_radio_mgr, mock_temp_radio_mgr], + ), + ): + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} + ) + + assert result_confirm["step_id"] == "choose_migration_strategy" + + result_recommended = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], + user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, + ) + + # Prompt user to plug old adapter back in when reset fails + assert result_recommended["type"] is FlowResultType.MENU + assert result_recommended["step_id"] == "plug_in_old_radio" + assert ( + result_recommended["description_placeholders"]["device_path"] + == "/dev/ttyUSB0" + ) + assert result_recommended["menu_options"] == [ + "retry_old_radio", + "skip_reset_old_radio", + ] + + # Retry with unplugged adapter + result_retry = await hass.config_entries.flow.async_configure( + result_recommended["flow_id"], + user_input={"next_step_id": "retry_old_radio"}, + ) + + # Prompt user again to plug old adapter back in + assert result_retry["type"] is FlowResultType.MENU + assert result_retry["step_id"] == "plug_in_old_radio" + + # Skip resetting the old adapter + result_skip = await hass.config_entries.flow.async_configure( + result_retry["flow_id"], + user_input={"next_step_id": "skip_reset_old_radio"}, + ) + + # Entry created successfully after skipping reset + assert result_skip["type"] is FlowResultType.ABORT + assert result_skip["reason"] == "reconfigure_successful" + + # Verify reset was attempted twice: initial + retry + assert mock_temp_radio_mgr.async_reset_adapter.call_count == 2