diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index ce2080e4a13..85f03b9f1f5 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,6 +1,7 @@ """Config flow for ZHA.""" from __future__ import annotations +import asyncio import collections import contextlib import copy @@ -65,8 +66,16 @@ FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup" CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup" OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee" +OPTIONS_INTENT_MIGRATE = "intent_migrate" +OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure" + UPLOADED_BACKUP_FILE = "uploaded_backup_file" +DEFAULT_ZHA_ZEROCONF_PORT = 6638 +ESPHOME_API_PORT = 6053 + +CONNECT_DELAY_S = 1.0 + _LOGGER = logging.getLogger(__name__) @@ -159,6 +168,7 @@ class BaseZhaFlow(FlowHandler): yield app finally: await app.disconnect() + await asyncio.sleep(CONNECT_DELAY_S) async def _restore_backup( self, backup: zigpy.backups.NetworkBackup, **kwargs: Any @@ -628,14 +638,21 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN # Hostname is format: livingroom.local. local_name = discovery_info.hostname[:-1] - radio_type = discovery_info.properties.get("radio_type") or local_name + port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT + + # Fix incorrect port for older TubesZB devices + if "tube" in local_name and port == ESPHOME_API_PORT: + port = DEFAULT_ZHA_ZEROCONF_PORT + + if "radio_type" in discovery_info.properties: + self._radio_type = RadioType[discovery_info.properties["radio_type"]] + elif "efr32" in local_name: + self._radio_type = RadioType.ezsp + else: + self._radio_type = RadioType.znp + node_name = local_name[: -len(".local")] - host = discovery_info.host - port = discovery_info.port - if local_name.startswith("tube") or "efr32" in local_name: - # This is hard coded to work with legacy devices - port = 6638 - device_path = f"socket://{host}:{port}" + device_path = f"socket://{discovery_info.host}:{port}" if current_entry := await self.async_set_unique_id(node_name): self._abort_if_unique_id_configured( @@ -651,13 +668,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN self._title = device_path self._device_path = device_path - if "efr32" in radio_type: - self._radio_type = RadioType.ezsp - elif "zigate" in radio_type: - self._radio_type = RadioType.zigate - else: - self._radio_type = RadioType.znp - return await self.async_step_confirm() async def async_step_hardware( @@ -720,10 +730,54 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, config_entries.OptionsFlow): # ZHA is not running pass - return await self.async_step_choose_serial_port() + return await self.async_step_prompt_migrate_or_reconfigure() return self.async_show_form(step_id="init") + async def async_step_prompt_migrate_or_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm if we are migrating adapters or just re-configuring.""" + + return self.async_show_menu( + step_id="prompt_migrate_or_reconfigure", + menu_options=[ + OPTIONS_INTENT_RECONFIGURE, + OPTIONS_INTENT_MIGRATE, + ], + ) + + async def async_step_intent_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Virtual step for when the user is reconfiguring the integration.""" + return await self.async_step_choose_serial_port() + + async def async_step_intent_migrate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the user wants to reset their current radio.""" + + if user_input is not None: + # Reset the current adapter + async with self._connect_zigpy_app() as app: + await app.reset_network_info() + + return await self.async_step_instruct_unplug() + + return self.async_show_form(step_id="intent_migrate") + + async def async_step_instruct_unplug( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Instruct the user to unplug the current radio, if possible.""" + + if user_input is not None: + # Now that the old radio is gone, we can scan for serial ports again + return await self.async_step_choose_serial_port() + + return self.async_show_form(step_id="instruct_unplug") + async def _async_create_radio_entity(self): """Re-implementation of the base flow's final step to update the config.""" device_settings = self._device_settings.copy() diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 3901f9f9439..240f3c4ee83 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -76,6 +76,22 @@ "title": "Reconfigure ZHA", "description": "ZHA will be stopped. Do you wish to continue?" }, + "prompt_migrate_or_reconfigure": { + "title": "Migrate or re-configure", + "description": "Are you migrating to a new radio or re-configuring the current radio?", + "menu_options": { + "intent_migrate": "Migrate to a new radio", + "intent_reconfigure": "Re-configure the current radio" + } + }, + "intent_migrate": { + "title": "Migrate to a new radio", + "description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?" + }, + "instruct_unplug": { + "title": "Unplug your old radio", + "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it." + }, "choose_serial_port": { "title": "[%key:component::zha::config::step::choose_serial_port::title%]", "data": { diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index c75fa14628d..bb62ccca64a 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -64,35 +64,12 @@ "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "title": "Overwrite Radio IEEE Address" }, - "pick_radio": { - "data": { - "radio_type": "Radio Type" - }, - "description": "Pick a type of your Zigbee radio", - "title": "Radio Type" - }, - "port_config": { - "data": { - "baudrate": "port speed", - "flow_control": "data flow control", - "path": "Serial device path" - }, - "description": "Enter port specific settings", - "title": "Settings" - }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Upload a file" }, "description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.", "title": "Upload a Manual Backup" - }, - "user": { - "data": { - "path": "Serial Device Path" - }, - "description": "Select serial port for Zigbee radio", - "title": "ZHA" } } }, @@ -212,6 +189,14 @@ "description": "ZHA will be stopped. Do you wish to continue?", "title": "Reconfigure ZHA" }, + "instruct_unplug": { + "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.", + "title": "Unplug your old radio" + }, + "intent_migrate": { + "description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?", + "title": "Migrate to a new radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Radio Type" @@ -235,6 +220,14 @@ "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "title": "Overwrite Radio IEEE Address" }, + "prompt_migrate_or_reconfigure": { + "description": "Are you migrating to a new radio or re-configuring the current radio?", + "menu_options": { + "intent_migrate": "Migrate to a new radio", + "intent_reconfigure": "Re-configure the current radio" + }, + "title": "Migrate or re-configure" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Upload a file" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 5fc4b232634..725f9cc0917 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -46,6 +46,13 @@ def disable_platform_only(): yield +@pytest.fixture(autouse=True) +def reduce_reconnect_timeout(): + """Reduces reconnect timeout to speed up tests.""" + with patch("homeassistant.components.zha.config_flow.CONNECT_DELAY_S", 0.01): + yield + + @pytest.fixture(autouse=True) def mock_app(): """Mock zigpy app interface.""" @@ -230,10 +237,10 @@ async def test_efr32_via_zeroconf(hass): await hass.async_block_till_done() assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "socket://192.168.1.200:6638" + assert result3["title"] == "socket://192.168.1.200:1234" assert result3["data"] == { CONF_DEVICE: { - CONF_DEVICE_PATH: "socket://192.168.1.200:6638", + CONF_DEVICE_PATH: "socket://192.168.1.200:1234", CONF_BAUDRATE: 115200, CONF_FLOWCONTROL: "software", }, @@ -1476,21 +1483,28 @@ async def test_options_flow_defaults(async_setup_entry, async_unload_effect, has # Unload it ourselves entry.state = config_entries.ConfigEntryState.NOT_LOADED + # Reconfigure ZHA + assert result1["step_id"] == "prompt_migrate_or_reconfigure" + result2 = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE}, + ) + # Current path is the default - assert result1["step_id"] == "choose_serial_port" - assert "/dev/ttyUSB0" in result1["data_schema"]({})[CONF_DEVICE_PATH] + assert result2["step_id"] == "choose_serial_port" + assert "/dev/ttyUSB0" in result2["data_schema"]({})[CONF_DEVICE_PATH] # Autoprobing fails, we have to manually choose the radio type - result2 = await hass.config_entries.options.async_configure( + result3 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={} ) # Current radio type is the default - assert result2["step_id"] == "manual_pick_radio_type" - assert result2["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description + assert result3["step_id"] == "manual_pick_radio_type" + assert result3["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description # Continue on to port settings - result3 = await hass.config_entries.options.async_configure( + result4 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={ CONF_RADIO_TYPE: RadioType.znp.description, @@ -1498,12 +1512,12 @@ async def test_options_flow_defaults(async_setup_entry, async_unload_effect, has ) # The defaults match our current settings - assert result3["step_id"] == "manual_port_config" - assert result3["data_schema"]({}) == entry.data[CONF_DEVICE] + assert result4["step_id"] == "manual_port_config" + assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): # Change the serial port path - result4 = await hass.config_entries.options.async_configure( + result5 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={ # Change everything @@ -1514,18 +1528,18 @@ async def test_options_flow_defaults(async_setup_entry, async_unload_effect, has ) # The radio has been detected, we can move on to creating the config entry - assert result4["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_formation_strategy" async_setup_entry.assert_not_called() - result5 = await hass.config_entries.options.async_configure( + result6 = await hass.config_entries.options.async_configure( result1["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result5["type"] == FlowResultType.CREATE_ENTRY - assert result5["data"] == {} + assert result6["type"] == FlowResultType.CREATE_ENTRY + assert result6["data"] == {} # The updated entry contains correct settings assert entry.data == { @@ -1581,33 +1595,39 @@ async def test_options_flow_defaults_socket(hass): flow["flow_id"], user_input={} ) - # Radio path must be manually entered - assert result1["step_id"] == "choose_serial_port" - assert result1["data_schema"]({})[CONF_DEVICE_PATH] == config_flow.CONF_MANUAL_PATH - + assert result1["step_id"] == "prompt_migrate_or_reconfigure" result2 = await hass.config_entries.options.async_configure( - flow["flow_id"], user_input={} + flow["flow_id"], + user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE}, ) - # Current radio type is the default - assert result2["step_id"] == "manual_pick_radio_type" - assert result2["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description + # Radio path must be manually entered + assert result2["step_id"] == "choose_serial_port" + assert result2["data_schema"]({})[CONF_DEVICE_PATH] == config_flow.CONF_MANUAL_PATH - # Continue on to port settings result3 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={} ) + # Current radio type is the default + assert result3["step_id"] == "manual_pick_radio_type" + assert result3["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description + + # Continue on to port settings + result4 = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) + # The defaults match our current settings - assert result3["step_id"] == "manual_port_config" - assert result3["data_schema"]({}) == entry.data[CONF_DEVICE] + assert result4["step_id"] == "manual_port_config" + assert result4["data_schema"]({}) == entry.data[CONF_DEVICE] with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): - result4 = await hass.config_entries.options.async_configure( + result5 = await hass.config_entries.options.async_configure( flow["flow_id"], user_input={} ) - assert result4["step_id"] == "choose_formation_strategy" + assert result5["step_id"] == "choose_formation_strategy" @patch("homeassistant.components.zha.async_setup_entry", return_value=True) @@ -1643,14 +1663,82 @@ async def test_options_flow_restarts_running_zha_if_cancelled(async_setup_entry, entry.state = config_entries.ConfigEntryState.NOT_LOADED + assert result1["step_id"] == "prompt_migrate_or_reconfigure" + result2 = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE}, + ) + # Radio path must be manually entered - assert result1["step_id"] == "choose_serial_port" + assert result2["step_id"] == "choose_serial_port" async_setup_entry.reset_mock() # Abort the flow - hass.config_entries.options.async_abort(result1["flow_id"]) + hass.config_entries.options.async_abort(result2["flow_id"]) await hass.async_block_till_done() # ZHA was set up once more async_setup_entry.assert_called_once_with(hass, entry) + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_options_flow_migration_reset_old_adapter(hass, mock_app): + """Test options flow for migrating from an old radio.""" + + entry = MockConfigEntry( + version=config_flow.ZhaConfigFlowHandler.VERSION, + domain=DOMAIN, + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/serial/by-id/old_radio", + CONF_BAUDRATE: 12345, + CONF_FLOWCONTROL: None, + }, + CONF_RADIO_TYPE: "znp", + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + flow = await hass.config_entries.options.async_init(entry.entry_id) + + # ZHA gets unloaded + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload", return_value=True + ): + result1 = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) + + entry.state = config_entries.ConfigEntryState.NOT_LOADED + + assert result1["step_id"] == "prompt_migrate_or_reconfigure" + result2 = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={"next_step_id": config_flow.OPTIONS_INTENT_MIGRATE}, + ) + + # User must explicitly approve radio reset + assert result2["step_id"] == "intent_migrate" + + mock_app.reset_network_info = AsyncMock() + + result3 = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={}, + ) + + mock_app.reset_network_info.assert_awaited_once() + + # Now we can unplug the old radio + assert result3["step_id"] == "instruct_unplug" + + # And move on to choosing the new radio + result4 = await hass.config_entries.options.async_configure( + flow["flow_id"], + user_input={}, + ) + assert result4["step_id"] == "choose_serial_port"