ZHA radio migration: reset the old adapter (#79663)

This commit is contained in:
puddly 2022-10-06 14:02:24 -04:00 committed by GitHub
parent 0a59d37e62
commit 2dab9073fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 219 additions and 68 deletions

View File

@ -1,6 +1,7 @@
"""Config flow for ZHA.""" """Config flow for ZHA."""
from __future__ import annotations from __future__ import annotations
import asyncio
import collections import collections
import contextlib import contextlib
import copy import copy
@ -65,8 +66,16 @@ FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup"
CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup" CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup"
OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee" OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee"
OPTIONS_INTENT_MIGRATE = "intent_migrate"
OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
UPLOADED_BACKUP_FILE = "uploaded_backup_file" UPLOADED_BACKUP_FILE = "uploaded_backup_file"
DEFAULT_ZHA_ZEROCONF_PORT = 6638
ESPHOME_API_PORT = 6053
CONNECT_DELAY_S = 1.0
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -159,6 +168,7 @@ class BaseZhaFlow(FlowHandler):
yield app yield app
finally: finally:
await app.disconnect() await app.disconnect()
await asyncio.sleep(CONNECT_DELAY_S)
async def _restore_backup( async def _restore_backup(
self, backup: zigpy.backups.NetworkBackup, **kwargs: Any self, backup: zigpy.backups.NetworkBackup, **kwargs: Any
@ -628,14 +638,21 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
# Hostname is format: livingroom.local. # Hostname is format: livingroom.local.
local_name = discovery_info.hostname[:-1] 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")] node_name = local_name[: -len(".local")]
host = discovery_info.host device_path = f"socket://{discovery_info.host}:{port}"
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}"
if current_entry := await self.async_set_unique_id(node_name): if current_entry := await self.async_set_unique_id(node_name):
self._abort_if_unique_id_configured( self._abort_if_unique_id_configured(
@ -651,13 +668,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
self._title = device_path self._title = device_path
self._device_path = 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() return await self.async_step_confirm()
async def async_step_hardware( async def async_step_hardware(
@ -720,10 +730,54 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, config_entries.OptionsFlow):
# ZHA is not running # ZHA is not running
pass 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") 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): async def _async_create_radio_entity(self):
"""Re-implementation of the base flow's final step to update the config.""" """Re-implementation of the base flow's final step to update the config."""
device_settings = self._device_settings.copy() device_settings = self._device_settings.copy()

View File

@ -76,6 +76,22 @@
"title": "Reconfigure ZHA", "title": "Reconfigure ZHA",
"description": "ZHA will be stopped. Do you wish to continue?" "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": { "choose_serial_port": {
"title": "[%key:component::zha::config::step::choose_serial_port::title%]", "title": "[%key:component::zha::config::step::choose_serial_port::title%]",
"data": { "data": {

View File

@ -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.", "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" "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": { "upload_manual_backup": {
"data": { "data": {
"uploaded_backup_file": "Upload a file" "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.", "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" "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?", "description": "ZHA will be stopped. Do you wish to continue?",
"title": "Reconfigure ZHA" "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": { "manual_pick_radio_type": {
"data": { "data": {
"radio_type": "Radio Type" "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.", "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" "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": { "upload_manual_backup": {
"data": { "data": {
"uploaded_backup_file": "Upload a file" "uploaded_backup_file": "Upload a file"

View File

@ -46,6 +46,13 @@ def disable_platform_only():
yield 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) @pytest.fixture(autouse=True)
def mock_app(): def mock_app():
"""Mock zigpy app interface.""" """Mock zigpy app interface."""
@ -230,10 +237,10 @@ async def test_efr32_via_zeroconf(hass):
await hass.async_block_till_done() await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY 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"] == { assert result3["data"] == {
CONF_DEVICE: { CONF_DEVICE: {
CONF_DEVICE_PATH: "socket://192.168.1.200:6638", CONF_DEVICE_PATH: "socket://192.168.1.200:1234",
CONF_BAUDRATE: 115200, CONF_BAUDRATE: 115200,
CONF_FLOWCONTROL: "software", CONF_FLOWCONTROL: "software",
}, },
@ -1476,21 +1483,28 @@ async def test_options_flow_defaults(async_setup_entry, async_unload_effect, has
# Unload it ourselves # Unload it ourselves
entry.state = config_entries.ConfigEntryState.NOT_LOADED 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 # Current path is the default
assert result1["step_id"] == "choose_serial_port" assert result2["step_id"] == "choose_serial_port"
assert "/dev/ttyUSB0" in result1["data_schema"]({})[CONF_DEVICE_PATH] assert "/dev/ttyUSB0" in result2["data_schema"]({})[CONF_DEVICE_PATH]
# Autoprobing fails, we have to manually choose the radio type # 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={} flow["flow_id"], user_input={}
) )
# Current radio type is the default # Current radio type is the default
assert result2["step_id"] == "manual_pick_radio_type" assert result3["step_id"] == "manual_pick_radio_type"
assert result2["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description assert result3["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description
# Continue on to port settings # Continue on to port settings
result3 = await hass.config_entries.options.async_configure( result4 = await hass.config_entries.options.async_configure(
flow["flow_id"], flow["flow_id"],
user_input={ user_input={
CONF_RADIO_TYPE: RadioType.znp.description, 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 # The defaults match our current settings
assert result3["step_id"] == "manual_port_config" assert result4["step_id"] == "manual_port_config"
assert result3["data_schema"]({}) == entry.data[CONF_DEVICE] assert result4["data_schema"]({}) == entry.data[CONF_DEVICE]
with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)):
# Change the serial port path # Change the serial port path
result4 = await hass.config_entries.options.async_configure( result5 = await hass.config_entries.options.async_configure(
flow["flow_id"], flow["flow_id"],
user_input={ user_input={
# Change everything # 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 # 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() 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"], result1["flow_id"],
user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result5["type"] == FlowResultType.CREATE_ENTRY assert result6["type"] == FlowResultType.CREATE_ENTRY
assert result5["data"] == {} assert result6["data"] == {}
# The updated entry contains correct settings # The updated entry contains correct settings
assert entry.data == { assert entry.data == {
@ -1581,33 +1595,39 @@ async def test_options_flow_defaults_socket(hass):
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
) )
# Radio path must be manually entered assert result1["step_id"] == "prompt_migrate_or_reconfigure"
assert result1["step_id"] == "choose_serial_port"
assert result1["data_schema"]({})[CONF_DEVICE_PATH] == config_flow.CONF_MANUAL_PATH
result2 = await hass.config_entries.options.async_configure( 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 # Radio path must be manually entered
assert result2["step_id"] == "manual_pick_radio_type" assert result2["step_id"] == "choose_serial_port"
assert result2["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description 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( result3 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
) )
# The defaults match our current settings # Current radio type is the default
assert result3["step_id"] == "manual_port_config" assert result3["step_id"] == "manual_pick_radio_type"
assert result3["data_schema"]({}) == entry.data[CONF_DEVICE] assert result3["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description
with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): # Continue on to port settings
result4 = await hass.config_entries.options.async_configure( result4 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
) )
assert result4["step_id"] == "choose_formation_strategy" # The defaults match our current settings
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)):
result5 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={}
)
assert result5["step_id"] == "choose_formation_strategy"
@patch("homeassistant.components.zha.async_setup_entry", return_value=True) @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 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 # 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() async_setup_entry.reset_mock()
# Abort the flow # 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() await hass.async_block_till_done()
# ZHA was set up once more # ZHA was set up once more
async_setup_entry.assert_called_once_with(hass, entry) 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"