Compare commits

...

3 Commits

Author SHA1 Message Date
Martin Hjelmare
c1b799856a Fix ZHA tests 2025-10-01 11:36:29 +02:00
Martin Hjelmare
2f4e3b98f3 Fix data_entry_flow recursion 2025-10-01 11:36:29 +02:00
Martin Hjelmare
f2c354eb3d Improve ZHA progress flows 2025-10-01 11:36:29 +02:00
4 changed files with 401 additions and 191 deletions

View File

@@ -25,7 +25,9 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import
)
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
from homeassistant.config_entries import (
SOURCE_HARDWARE,
SOURCE_IGNORE,
SOURCE_USB,
SOURCE_ZEROCONF,
ConfigEntry,
ConfigEntryBaseFlow,
@@ -37,7 +39,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.data_entry_flow import AbortFlow, progress_step
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
@@ -205,7 +207,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
}
@abstractmethod
async def _async_create_radio_entry(self) -> ConfigFlowResult:
async def async_step_create_radio_entry(self) -> ConfigFlowResult:
"""Create a config entry with the current flow state."""
async def async_step_choose_serial_port(
@@ -233,16 +235,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
port = ports[list_of_ports.index(user_selection)]
self._radio_mgr.device_path = port.device
probe_result = await self._radio_mgr.detect_radio_type()
if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED:
return self.async_abort(
reason="wrong_firmware_installed",
description_placeholders={"repair_url": REPAIR_MY_URL},
)
if probe_result == ProbeResult.PROBING_FAILED:
# Did not autodetect anything, proceed to manual selection
return await self.async_step_manual_pick_radio_type()
self._title = (
f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}"
f" - {port.manufacturer}"
@@ -250,7 +242,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
else ""
)
return await self.async_step_verify_radio()
return await self.async_step_detect_radio_type()
# Pre-select the currently configured port
default_port: vol.Undefined | str = vol.UNDEFINED
@@ -272,6 +264,35 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
)
return self.async_show_form(step_id="choose_serial_port", data_schema=schema)
@progress_step()
async def async_step_detect_radio_type(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Detect the radio type."""
# Probe the radio type if we don't have one yet
if self._radio_mgr.radio_type is None:
probe_result = await self._radio_mgr.detect_radio_type()
else:
probe_result = ProbeResult.RADIO_TYPE_DETECTED
if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED:
return self.async_abort(
reason="wrong_firmware_installed",
description_placeholders={"repair_url": REPAIR_MY_URL},
)
if probe_result == ProbeResult.PROBING_FAILED:
# This path probably will not happen now that we have
# more precise USB matching unless there is a problem
# with the device
if self.source in {SOURCE_HARDWARE, SOURCE_USB, SOURCE_ZEROCONF}:
return self.async_abort(reason="usb_probe_failed")
return await self.async_step_manual_pick_radio_type()
if self._radio_mgr.device_settings is None:
return await self.async_step_manual_port_config()
return await self.async_step_verify_radio()
async def async_step_manual_pick_radio_type(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -353,10 +374,10 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
) -> ConfigFlowResult:
"""Add a warning step to dissuade the use of deprecated radios."""
assert self._radio_mgr.radio_type is not None
await self._radio_mgr.async_read_backups_from_database()
# Skip this step if we are using a recommended radio
if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS:
await self._radio_mgr.async_read_backups_from_database()
# ZHA disables the single instance check and will decide at runtime if we
# are migrating or setting up from scratch
if self.hass.config_entries.async_entries(DOMAIN, include_ignore=False):
@@ -410,7 +431,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Advanced setup strategy: let the user choose."""
return await self.async_step_choose_formation_strategy()
return await self.async_step_load_network_settings()
async def async_step_choose_migration_strategy(
self, user_input: dict[str, Any] | None = None
@@ -439,6 +460,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
return await self.async_step_maybe_reset_old_radio()
@progress_step()
async def async_step_maybe_reset_old_radio(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -468,20 +490,27 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
await temp_radio_mgr.async_reset_adapter()
return await self.async_step_maybe_confirm_ezsp_restore()
return await self.async_step_try_ezsp_restore()
async def async_step_migration_strategy_advanced(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Advanced migration strategy: let the user choose."""
return await self.async_step_load_network_settings()
@progress_step()
async def async_step_load_network_settings(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Load the current network settings from the radio."""
await self._radio_mgr.async_load_network_settings()
return await self.async_step_choose_formation_strategy()
async def async_step_choose_formation_strategy(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose how to deal with the current radio's settings."""
await self._radio_mgr.async_load_network_settings()
strategies = []
# Check if we have any automatic backups *and* if the backups differ from
@@ -523,7 +552,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reuse the existing network settings on the stick."""
return await self._async_create_radio_entry()
return await self.async_step_create_radio_entry()
async def async_step_form_initial_network(
self, user_input: dict[str, Any] | None = None
@@ -532,12 +561,13 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
# This step exists only for translations, it does nothing new
return await self.async_step_form_new_network(user_input)
@progress_step()
async def async_step_form_new_network(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Form a brand-new network."""
await self._radio_mgr.async_form_network()
return await self._async_create_radio_entry()
return await self.async_step_create_radio_entry()
def _parse_uploaded_backup(
self, uploaded_file_id: str
@@ -615,7 +645,25 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
),
)
async def async_step_maybe_confirm_ezsp_restore(
@progress_step()
async def async_step_try_ezsp_restore(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Try to restore EZSP radios nondestructively."""
# On first attempt, just try to restore nondestructively
try:
await self._radio_mgr.restore_backup()
except DestructiveWriteNetworkSettings:
# Restore cannot happen automatically, we need to ask for permission
return await self.async_step_confirm_ezsp_restore()
except CannotWriteNetworkSettings as exc:
return self.async_abort(
reason="cannot_restore_backup",
description_placeholders={"error": str(exc)},
)
return await self.async_step_create_radio_entry()
async def async_step_confirm_ezsp_restore(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm restore for EZSP radios that require permanent IEEE writes."""
@@ -630,28 +678,14 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
description_placeholders={"error": str(exc)},
)
return await self._async_create_radio_entry()
return await self.async_step_create_radio_entry()
# On rejection, explain why we can't restore
return self.async_abort(reason="cannot_restore_backup_no_ieee_confirm")
# On first attempt, just try to restore nondestructively
try:
await self._radio_mgr.restore_backup()
except DestructiveWriteNetworkSettings:
# Restore cannot happen automatically, we need to ask for permission
pass
except CannotWriteNetworkSettings as exc:
return self.async_abort(
reason="cannot_restore_backup",
description_placeholders={"error": str(exc)},
)
else:
return await self._async_create_radio_entry()
# If it fails, show the form
return self.async_show_form(
step_id="maybe_confirm_ezsp_restore",
step_id="confirm_ezsp_restore",
data_schema=vol.Schema(
{vol.Required(OVERWRITE_COORDINATOR_IEEE, default=True): bool}
),
@@ -740,27 +774,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
if user_input is not None or (
not onboarding.async_is_onboarded(self.hass) and not zha_config_entries
):
# Probe the radio type if we don't have one yet
if self._radio_mgr.radio_type is None:
probe_result = await self._radio_mgr.detect_radio_type()
else:
probe_result = ProbeResult.RADIO_TYPE_DETECTED
if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED:
return self.async_abort(
reason="wrong_firmware_installed",
description_placeholders={"repair_url": REPAIR_MY_URL},
)
if probe_result == ProbeResult.PROBING_FAILED:
# This path probably will not happen now that we have
# more precise USB matching unless there is a problem
# with the device
return self.async_abort(reason="usb_probe_failed")
if self._radio_mgr.device_settings is None:
return await self.async_step_manual_port_config()
return await self.async_step_verify_radio()
return await self.async_step_detect_radio_type()
return self.async_show_form(
step_id="confirm",
@@ -898,7 +912,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
async def _async_create_radio_entry(self) -> ConfigFlowResult:
async def async_step_create_radio_entry(self) -> ConfigFlowResult:
"""Create a config entry with the current flow state."""
# ZHA is still single instance only, even though we use discovery to allow for
@@ -1003,7 +1017,8 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
return self.async_show_form(step_id="instruct_unplug")
async def _async_create_radio_entry(self):
@progress_step()
async def async_step_create_radio_entry(self) -> ConfigFlowResult:
"""Re-implementation of the base flow's final step to update the config."""
# Avoid creating both `.options` and `.data` by directly writing `data` here

View File

@@ -15,6 +15,18 @@
"confirm_hardware": {
"description": "Do you want to set up {name}?"
},
"create_radio_entry": {
"title": "Configuring Zigbee network"
},
"detect_radio_type": {
"title": "Configuring Zigbee network"
},
"form_new_network": {
"title": "Configuring Zigbee network"
},
"load_network_settings": {
"title": "Configuring Zigbee network"
},
"manual_pick_radio_type": {
"title": "Select a radio type",
"description": "Pick your Zigbee radio type",
@@ -65,8 +77,7 @@
}
},
"maybe_reset_old_radio": {
"title": "Resetting old adapter",
"description": "A backup was created earlier and your old adapter is being reset as part of the migration."
"title": "Configuring Zigbee network"
},
"choose_formation_strategy": {
"title": "Network formation",
@@ -100,12 +111,15 @@
"uploaded_backup_file": "Upload a file"
}
},
"maybe_confirm_ezsp_restore": {
"confirm_ezsp_restore": {
"title": "Overwrite radio IEEE address",
"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.",
"data": {
"overwrite_coordinator_ieee": "Permanently replace the radio IEEE address"
}
},
"try_ezsp_restore": {
"title": "Configuring Zigbee network"
}
},
"error": {
@@ -122,15 +136,38 @@
"cannot_restore_backup": "The adapter you are restoring to does not properly support backup restoration. Please upgrade the firmware.\n\nError: {error}",
"cannot_restore_backup_no_ieee_confirm": "The adapter you are restoring to has outdated firmware and cannot write the adapter IEEE address multiple times. Please upgrade the firmware or confirm permanent overwrite in the previous step.",
"reconfigure_successful": "ZHA has successfully migrated from your old adapter to the new one. Give your Zigbee network a few minutes to stabilize.\n\nIf you no longer need the old adapter, you can now unplug it."
},
"progress": {
"create_radio_entry": "Finalizing configuration",
"detect_radio_type": "Detecting the radio type",
"form_new_network": "Creating a new Zigbee network",
"load_network_settings": "Loading the current network settings from the radio",
"maybe_reset_old_radio": "Resetting your old radio",
"try_ezsp_restore": "Restoring the network settings to your radio"
}
},
"options": {
"flow_title": "[%key:component::zha::config::flow_title%]",
"step": {
"create_radio_entry": {
"title": "[%key:component::zha::config::step::create_radio_entry::title%]"
},
"detect_radio_type": {
"title": "[%key:component::zha::config::step::detect_radio_type::title%]"
},
"form_new_network": {
"title": "[%key:component::zha::config::step::form_new_network::title%]"
},
"init": {
"title": "Reconfigure ZHA",
"description": "A backup will be performed and ZHA will be stopped. Do you wish to continue?"
},
"load_network_settings": {
"title": "[%key:component::zha::config::step::load_network_settings::title%]"
},
"maybe_reset_old_radio": {
"title": "[%key:component::zha::config::step::maybe_reset_old_radio::title%]"
},
"prompt_migrate_or_reconfigure": {
"title": "Migrate or re-configure",
"description": "Are you migrating to a new radio or re-configuring the current radio?",
@@ -143,6 +180,9 @@
"intent_reconfigure": "This will let you change the serial port for your current Zigbee adapter."
}
},
"try_ezsp_restore": {
"title": "[%key:component::zha::config::step::try_ezsp_restore::title%]"
},
"intent_migrate": {
"title": "[%key:component::zha::options::step::prompt_migrate_or_reconfigure::menu_options::intent_migrate%]",
"description": "Before plugging in your new adapter, your old adapter needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?"
@@ -222,11 +262,11 @@
"uploaded_backup_file": "[%key:component::zha::config::step::upload_manual_backup::data::uploaded_backup_file%]"
}
},
"maybe_confirm_ezsp_restore": {
"title": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::title%]",
"description": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::description%]",
"confirm_ezsp_restore": {
"title": "[%key:component::zha::config::step::confirm_ezsp_restore::title%]",
"description": "[%key:component::zha::config::step::confirm_ezsp_restore::description%]",
"data": {
"overwrite_coordinator_ieee": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::data::overwrite_coordinator_ieee%]"
"overwrite_coordinator_ieee": "[%key:component::zha::config::step::confirm_ezsp_restore::data::overwrite_coordinator_ieee%]"
}
}
},
@@ -241,6 +281,14 @@
"wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]",
"cannot_restore_backup": "[%key:component::zha::config::abort::cannot_restore_backup%]",
"cannot_restore_backup_no_ieee_confirm": "[%key:component::zha::config::abort::cannot_restore_backup_no_ieee_confirm%]"
},
"progress": {
"create_radio_entry": "[%key:component::zha::config::progress::create_radio_entry%]",
"detect_radio_type": "[%key:component::zha::config::progress::detect_radio_type%]",
"form_new_network": "[%key:component::zha::config::progress::form_new_network%]",
"load_network_settings": "[%key:component::zha::config::progress::load_network_settings%]",
"maybe_reset_old_radio": "[%key:component::zha::config::progress::maybe_reset_old_radio%]",
"try_ezsp_restore": "[%key:component::zha::config::progress::try_ezsp_restore%]"
}
},
"config_panel": {

View File

@@ -799,14 +799,15 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
without using async_show_progress_done.
If no next step is set, abort the flow.
"""
if self._progress_step_data["next_step_result"] is None:
if (next_step_result := self._progress_step_data["next_step_result"]) is None:
return self.async_abort(
reason=self._progress_step_data["abort_reason"],
description_placeholders=self._progress_step_data[
"abort_description_placeholders"
],
)
return self._progress_step_data["next_step_result"]
self._progress_step_data["next_step_result"] = None
return next_step_result
@callback
def async_external_step(
@@ -1044,7 +1045,7 @@ def progress_step[
# Task is done or this is a subsequent call
try:
self._progress_step_data["next_step_result"] = await progress_task
progress_task_result = await progress_task
except AbortFlow as err:
self._progress_step_data["abort_reason"] = err.reason
self._progress_step_data["abort_description_placeholders"] = (
@@ -1057,6 +1058,9 @@ def progress_step[
# Clean up task reference
self._progress_step_data["tasks"].pop(step_id, None)
if progress_task_result["type"] != FlowResultType.SHOW_PROGRESS_DONE:
self._progress_step_data["next_step_result"] = progress_task_result
return self.async_show_progress_done(
next_step_id="_progress_step_progress_done"
)

View File

@@ -50,7 +50,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_SOURCE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType
from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_MANUFACTURER_URL,
ATTR_UPNP_SERIAL,
@@ -61,9 +61,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
type RadioPicker = Callable[
[RadioType], Coroutine[Any, Any, tuple[ConfigFlowResult, ListPortInfo]]
]
type RadioPicker = Callable[[RadioType], Coroutine[Any, Any, ConfigFlowResult]]
PROBE_FUNCTION_PATH = "zigbee.application.ControllerApplication.probe"
@@ -1529,13 +1527,19 @@ def advanced_pick_radio(
) -> Generator[RadioPicker]:
"""Fixture for the first step of the config flow (where a radio is picked)."""
async def wrapper(radio_type: RadioType) -> tuple[ConfigFlowResult, ListPortInfo]:
async def wrapper(radio_type: RadioType) -> ConfigFlowResult:
port = com_port()
port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}"
with patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type",
mock_detect_radio_type(radio_type=radio_type),
with (
patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type",
mock_detect_radio_type(radio_type=radio_type),
),
patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.async_load_network_settings",
AsyncMock(return_value=None),
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -1545,6 +1549,12 @@ def advanced_pick_radio(
},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "detect_radio_type"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "choose_setup_strategy"
@@ -1553,10 +1563,18 @@ def advanced_pick_radio(
user_input={"next_step_id": config_flow.SETUP_STRATEGY_ADVANCED},
)
assert advanced_strategy_result["type"] == FlowResultType.MENU
assert advanced_strategy_result["step_id"] == "choose_formation_strategy"
assert advanced_strategy_result["type"] is FlowResultType.SHOW_PROGRESS
assert advanced_strategy_result["step_id"] == "load_network_settings"
return advanced_strategy_result
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
advanced_strategy_result["flow_id"]
)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "choose_formation_strategy"
return result
p1 = patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()]))
p2 = patch("homeassistant.components.zha.async_setup_entry")
@@ -1686,7 +1704,7 @@ def test_parse_uploaded_backup(process_mock) -> None:
@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee")
async def test_formation_strategy_restore_manual_backup_non_ezsp(
allow_overwrite_ieee_mock,
allow_overwrite_ieee_mock: MagicMock,
advanced_pick_radio: RadioPicker,
mock_app: AsyncMock,
hass: HomeAssistant,
@@ -1694,50 +1712,54 @@ async def test_formation_strategy_restore_manual_backup_non_ezsp(
"""Test restoring a manual backup on non-EZSP coordinators."""
result = await advanced_pick_radio(RadioType.znp)
result2 = await hass.config_entries.flow.async_configure(
result = 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"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "upload_manual_backup"
with patch(
"homeassistant.components.zha.config_flow.ZhaConfigFlowHandler._parse_uploaded_backup",
return_value=zigpy.backups.NetworkBackup(),
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "try_ezsp_restore"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
mock_app.backups.restore_backup.assert_called_once()
allow_overwrite_ieee_mock.assert_not_called()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["data"][CONF_RADIO_TYPE] == "znp"
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_RADIO_TYPE] == "znp"
@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee")
async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
allow_overwrite_ieee_mock,
allow_overwrite_ieee_mock: MagicMock,
advanced_pick_radio: RadioPicker,
mock_app: AsyncMock,
backup,
backup: zigpy.backups.NetworkBackup,
hass: HomeAssistant,
) -> None:
"""Test restoring a manual backup on EZSP coordinators (overwrite IEEE)."""
result = await advanced_pick_radio(RadioType.ezsp)
result2 = await hass.config_entries.flow.async_configure(
result = 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"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "upload_manual_backup"
with (
patch(
@@ -1752,8 +1774,8 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
],
) as mock_restore_backup,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())},
)
@@ -1762,16 +1784,16 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
mock_restore_backup.reset_mock()
# The radio requires user confirmation for restore
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "maybe_confirm_ezsp_restore"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_ezsp_restore"
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: True},
)
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["data"][CONF_RADIO_TYPE] == "ezsp"
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_RADIO_TYPE] == "ezsp"
assert mock_restore_backup.call_count == 1
assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True
@@ -1779,7 +1801,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp(
@patch("homeassistant.components.zha.radio_manager._allow_overwrite_ezsp_ieee")
async def test_formation_strategy_restore_manual_backup_ezsp(
allow_overwrite_ieee_mock,
allow_overwrite_ieee_mock: MagicMock,
advanced_pick_radio: RadioPicker,
mock_app: AsyncMock,
hass: HomeAssistant,
@@ -1787,14 +1809,13 @@ async def test_formation_strategy_restore_manual_backup_ezsp(
"""Test restoring a manual backup on EZSP coordinators (don't overwrite IEEE)."""
result = await advanced_pick_radio(RadioType.ezsp)
result2 = await hass.config_entries.flow.async_configure(
result = 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"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "upload_manual_backup"
with (
patch(
@@ -1809,8 +1830,8 @@ async def test_formation_strategy_restore_manual_backup_ezsp(
],
) as mock_restore_backup,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={config_flow.UPLOADED_BACKUP_FILE: str(uuid.uuid4())},
)
@@ -1819,17 +1840,17 @@ async def test_formation_strategy_restore_manual_backup_ezsp(
mock_restore_backup.reset_mock()
# The radio requires user confirmation for restore
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "maybe_confirm_ezsp_restore"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_ezsp_restore"
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
# We do not accept
user_input={config_flow.OVERWRITE_COORDINATOR_IEEE: False},
)
assert result4["type"] is FlowResultType.ABORT
assert result4["reason"] == "cannot_restore_backup_no_ieee_confirm"
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_restore_backup_no_ieee_confirm"
assert mock_restore_backup.call_count == 0
@@ -1888,7 +1909,7 @@ def test_format_backup_choice() -> None:
async def test_formation_strategy_restore_automatic_backup_ezsp(
advanced_pick_radio: RadioPicker,
mock_app: AsyncMock,
make_backup,
make_backup: Callable[..., zigpy.backups.NetworkBackup],
hass: HomeAssistant,
) -> None:
"""Test restoring an automatic backup (EZSP radio)."""
@@ -1901,26 +1922,31 @@ async def test_formation_strategy_restore_automatic_backup_ezsp(
backup.is_compatible_with = MagicMock(return_value=False)
result = await advanced_pick_radio(RadioType.ezsp)
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"next_step_id": (config_flow.FORMATION_CHOOSE_AUTOMATIC_BACKUP)},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "choose_automatic_backup"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "choose_automatic_backup"
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
config_flow.CHOOSE_AUTOMATIC_BACKUP: "choice:" + repr(backup),
},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "try_ezsp_restore"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
mock_app.backups.restore_backup.assert_called_once()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["data"][CONF_RADIO_TYPE] == "ezsp"
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_RADIO_TYPE] == "ezsp"
@patch(
@@ -1930,7 +1956,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp(
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
@pytest.mark.parametrize("is_advanced", [True, False])
async def test_formation_strategy_restore_automatic_backup_non_ezsp(
is_advanced,
is_advanced: bool,
advanced_pick_radio: RadioPicker,
mock_app: AsyncMock,
make_backup,
@@ -1951,38 +1977,45 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp(
"homeassistant.config_entries.ConfigFlow.show_advanced_options",
new_callable=PropertyMock(return_value=is_advanced),
):
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
"next_step_id": (config_flow.FORMATION_CHOOSE_AUTOMATIC_BACKUP)
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "choose_automatic_backup"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "choose_automatic_backup"
# We don't prompt for overwriting the IEEE address, since only EZSP needs this
assert config_flow.OVERWRITE_COORDINATOR_IEEE not in result2["data_schema"].schema
data_schema = result["data_schema"]
assert data_schema is not None
assert config_flow.OVERWRITE_COORDINATOR_IEEE not in data_schema.schema
# The backup choices are ordered by date
assert result2["data_schema"].schema["choose_automatic_backup"].container == [
assert data_schema.schema["choose_automatic_backup"].container == [
f"choice:{mock_app.backups.backups[0]!r}",
f"choice:{mock_app.backups.backups[2]!r}",
f"choice:{mock_app.backups.backups[1]!r}",
]
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
config_flow.CHOOSE_AUTOMATIC_BACKUP: f"choice:{backup!r}",
},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "try_ezsp_restore"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
mock_app.backups.restore_backup.assert_called_once_with(backup)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["data"][CONF_RADIO_TYPE] == "znp"
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_RADIO_TYPE] == "znp"
@patch("homeassistant.components.zha.async_setup_entry", return_value=True)
@@ -2047,7 +2080,9 @@ async def test_options_flow_creates_backup(
)
@patch("homeassistant.components.zha.async_setup_entry", return_value=True)
async def test_options_flow_defaults(
async_setup_entry, async_unload_effect, hass: HomeAssistant
async_setup_entry: AsyncMock,
async_unload_effect: Exception | bool,
hass: HomeAssistant,
) -> None:
"""Test options flow defaults match radio defaults."""
@@ -2065,20 +2100,18 @@ async def test_options_flow_defaults(
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
flow = await hass.config_entries.options.async_init(entry.entry_id)
async_setup_entry.reset_mock()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
# ZHA gets unloaded
with patch(
"homeassistant.config_entries.ConfigEntries.async_unload",
side_effect=[async_unload_effect],
) as mock_async_unload:
result1 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={}
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={}
)
mock_async_unload.assert_called_once_with(entry.entry_id)
@@ -2087,41 +2120,76 @@ async def test_options_flow_defaults(
entry.mock_state(hass, ConfigEntryState.NOT_LOADED)
# Reconfigure ZHA
assert result1["step_id"] == "prompt_migrate_or_reconfigure"
result2 = await hass.config_entries.options.async_configure(
flow["flow_id"],
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "prompt_migrate_or_reconfigure"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE},
)
# Current path is the default
assert result2["step_id"] == "choose_serial_port"
assert "/dev/ttyUSB0" in result2["data_schema"]({})[CONF_DEVICE_PATH]
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
data_schema = result["data_schema"]
assert data_schema is not None
assert "/dev/ttyUSB0" in data_schema({})[CONF_DEVICE_PATH]
# Autoprobing fails, we have to manually choose the radio type
result3 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={}
# Abort the current flow, we'll start a new one
hass.config_entries.options.async_abort(result["flow_id"])
result = await hass.config_entries.options.async_init(entry.entry_id)
async_setup_entry.reset_mock()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "prompt_migrate_or_reconfigure"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"path": "Enter Manually"}
)
# Current radio type is the default
assert result3["step_id"] == "manual_pick_radio_type"
assert result3["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_pick_radio_type"
data_schema = result["data_schema"]
assert data_schema is not None
assert data_schema({})[CONF_RADIO_TYPE] == RadioType.znp.description
# Continue on to port settings
result4 = await hass.config_entries.options.async_configure(
flow["flow_id"],
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_RADIO_TYPE: RadioType.znp.description,
},
)
# The defaults match our current settings
assert result4["step_id"] == "manual_port_config"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual_port_config"
assert entry.data[CONF_DEVICE] == {
"path": "/dev/ttyUSB0",
"baudrate": 12345,
"flow_control": None,
}
assert result4["data_schema"]({}) == {
data_schema = result["data_schema"]
assert data_schema is not None
assert data_schema({}) == {
"path": "/dev/ttyUSB0",
"baudrate": 12345,
"flow_control": "none",
@@ -2129,8 +2197,8 @@ async def test_options_flow_defaults(
with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)):
# Change the serial port path
result5 = await hass.config_entries.options.async_configure(
flow["flow_id"],
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
# Change everything
CONF_DEVICE_PATH: "/dev/new_serial_port",
@@ -2140,24 +2208,32 @@ async def test_options_flow_defaults(
)
# The radio has been detected, we can move on to creating the config entry
assert result5["step_id"] == "choose_migration_strategy"
assert result["step_id"] == "choose_migration_strategy"
async_setup_entry.assert_not_called()
result6 = await hass.config_entries.options.async_configure(
result5["flow_id"],
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_ADVANCED},
)
await hass.async_block_till_done()
result7 = await hass.config_entries.options.async_configure(
result6["flow_id"],
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "load_network_settings"
await hass.async_block_till_done()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "choose_formation_strategy"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS},
)
await hass.async_block_till_done()
assert result7["type"] is FlowResultType.CREATE_ENTRY
assert result7["data"] == {}
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
# The updated entry contains correct settings
assert entry.data == {
@@ -2457,12 +2533,13 @@ async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None:
@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()]))
@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=False))
async def test_probe_wrong_firmware_installed(hass: HomeAssistant) -> None:
"""Test auto-probing failing because the wrong firmware is installed."""
with patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type",
return_value=ProbeResult.WRONG_FIRMWARE_INSTALLED,
"homeassistant.components.zha.repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware",
return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -2474,28 +2551,65 @@ async def test_probe_wrong_firmware_installed(hass: HomeAssistant) -> None:
},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "detect_radio_type"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_firmware_installed"
async def test_discovery_wrong_firmware_installed(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("source", "discover_info"),
[
(
"usb",
UsbServiceInfo(
device="/dev/ttyZIGBEE",
pid="AAAA",
vid="AAAA",
serial_number="1234",
description="zigbee radio",
manufacturer="test",
),
),
],
)
@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=False))
async def test_discovery_wrong_firmware_installed(
hass: HomeAssistant,
source: str,
discover_info: BaseServiceInfo,
) -> None:
"""Test auto-probing failing because the wrong firmware is installed."""
with (
patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type",
return_value=ProbeResult.WRONG_FIRMWARE_INSTALLED,
),
patch(
"homeassistant.components.onboarding.async_is_onboarded", return_value=False
"homeassistant.components.zha.repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware",
return_value=True,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: "confirm"},
data={},
context={CONF_SOURCE: source},
data=discover_info,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result_confirm = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result_confirm["type"] is FlowResultType.SHOW_PROGRESS
assert result_confirm["step_id"] == "detect_radio_type"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_firmware_installed"
@@ -2529,7 +2643,9 @@ async def test_migration_ti_cc_to_znp(
@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_migration_resets_old_radio(
hass: HomeAssistant, backup, mock_app
hass: HomeAssistant,
backup: zigpy.backups.NetworkBackup,
mock_app: AsyncMock,
) -> None:
"""Test that the old radio is reset during migration."""
entry = MockConfigEntry(
@@ -2572,19 +2688,39 @@ async def test_migration_resets_old_radio(
DOMAIN, context={"source": SOURCE_USB}, data=discovery_info
)
assert result_init["type"] is FlowResultType.FORM
assert result_init["step_id"] == "confirm"
result_confirm = await hass.config_entries.flow.async_configure(
result_init["flow_id"], user_input={}
)
assert result_confirm["step_id"] == "choose_migration_strategy"
assert result_confirm["type"] is FlowResultType.SHOW_PROGRESS
assert result_confirm["step_id"] == "detect_radio_type"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
result_confirm["flow_id"]
)
assert result["type"] is FlowResultType.MENU
assert result["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},
)
assert result_recommended["type"] is FlowResultType.ABORT
assert result_recommended["reason"] == "reconfigure_successful"
assert result_recommended["type"] is FlowResultType.SHOW_PROGRESS
assert result_recommended["step_id"] == "try_ezsp_restore"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
result_recommended["flow_id"]
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# We reset the old radio
assert mock_temp_radio_mgr.async_reset_adapter.call_count == 1
@@ -2645,7 +2781,6 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ
advanced_strategy_result["flow_id"],
user_input={"next_step_id": config_flow.FORMATION_UPLOAD_MANUAL_BACKUP},
)
await hass.async_block_till_done()
assert upload_backup_result["type"] is FlowResultType.FORM
assert upload_backup_result["step_id"] == "upload_manual_backup"
@@ -2674,7 +2809,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ
# The radio requires user confirmation for restore
assert confirm_restore_result["type"] is FlowResultType.FORM
assert confirm_restore_result["step_id"] == "maybe_confirm_ezsp_restore"
assert confirm_restore_result["step_id"] == "confirm_ezsp_restore"
final_result = await hass.config_entries.flow.async_configure(
confirm_restore_result["flow_id"],
@@ -2721,20 +2856,28 @@ async def test_migrate_setup_options_with_ignored_discovery(
description="zigbee radio",
manufacturer="test manufacturer",
)
discovery_result = await hass.config_entries.flow.async_init(
result_discovery = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USB}, data=discovery_info
)
await hass.async_block_till_done()
assert result_discovery["type"] is FlowResultType.FORM
assert result_discovery["step_id"] == "confirm"
# Progress the discovery
confirm_result = await hass.config_entries.flow.async_configure(
discovery_result["flow_id"], user_input={}
result_confirm = await hass.config_entries.flow.async_configure(
result_discovery["flow_id"], user_input={}
)
assert result_confirm["type"] is FlowResultType.SHOW_PROGRESS
assert result_confirm["step_id"] == "detect_radio_type"
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result_confirm["flow_id"])
# We only show "setup" options, not "migrate"
assert confirm_result["step_id"] == "choose_setup_strategy"
assert confirm_result["menu_options"] == [
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "choose_setup_strategy"
assert result["menu_options"] == [
"setup_strategy_recommended",
"setup_strategy_advanced",
]