Do not factory reset old Z-Wave controller during migration (#147576)

* Do not factory reset old Z-Wave controller during migration

* PR comments

* remove obsolete test
This commit is contained in:
Petar Petrov 2025-06-27 09:02:12 +03:00 committed by GitHub
parent 61b43ca1fc
commit 1ca03c8ae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 7 additions and 427 deletions

View File

@ -845,11 +845,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
if user_input is not None:
self._migrating = True
return await self.async_step_backup_nvm()
return self.async_show_form(step_id="intent_migrate")
self._migrating = True
return await self.async_step_backup_nvm()
async def async_step_backup_nvm(
self, user_input: dict[str, Any] | None = None
@ -904,7 +901,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_instruct_unplug(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reset the current controller, and instruct the user to unplug it."""
"""Instruct the user to unplug the old controller."""
if user_input is not None:
if self.usb_path:
@ -914,63 +911,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
# Now that the old controller is gone, we can scan for serial ports again
return await self.async_step_choose_serial_port()
try:
driver = self._get_driver()
except AbortFlow:
return self.async_abort(reason="config_entry_not_loaded")
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
wait_driver_ready = asyncio.Event()
unsubscribe = driver.once("driver ready", set_driver_ready)
# reset the old controller
try:
await driver.async_hard_reset()
except FailedCommand as err:
unsubscribe()
_LOGGER.error("Failed to reset controller: %s", err)
return self.async_abort(reason="reset_failed")
# Update the unique id of the config entry
# to the new home id, which requires waiting for the driver
# to be ready before getting the new home id.
# If the backup restore, done later in the flow, fails,
# the config entry unique id should be the new home id
# after the controller reset.
try:
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
except TimeoutError:
pass
finally:
unsubscribe()
config_entry = self._reconfigure_config_entry
assert config_entry is not None
try:
version_info = await async_get_version_info(
self.hass, config_entry.data[CONF_URL]
)
except CannotConnect:
# Just log this error, as there's nothing to do about it here.
# The stale unique id needs to be handled by a repair flow,
# after the config entry has been reloaded, if the backup restore
# also fails.
_LOGGER.debug(
"Failed to get server version, cannot update config entry "
"unique id with new home id, after controller reset"
)
else:
self.hass.config_entries.async_update_entry(
config_entry, unique_id=str(version_info.home_id)
)
# Unload the config entry before asking the user to unplug the controller.
await self.hass.config_entries.async_unload(config_entry.entry_id)

View File

@ -108,13 +108,9 @@
"intent_reconfigure": "Re-configure the current controller"
}
},
"intent_migrate": {
"title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]",
"description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?"
},
"instruct_unplug": {
"title": "Unplug your old controller",
"description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing."
"description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing."
},
"restore_failed": {
"title": "Restoring unsuccessful",

View File

@ -867,8 +867,6 @@ async def test_usb_discovery_migration(
get_server_version: AsyncMock,
) -> None:
"""Test usb discovery migration."""
version_info = get_server_version.return_value
version_info.home_id = 4321
addon_options["device"] = "/dev/ttyUSB0"
entry = integration
assert client.connect.call_count == 1
@ -893,13 +891,6 @@ async def test_usb_discovery_migration(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None):
client.driver.controller.emit(
"nvm convert progress",
@ -927,10 +918,6 @@ async def test_usb_discovery_migration(
)
assert mock_usb_serial_by_id.call_count == 2
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
@ -947,7 +934,6 @@ async def test_usb_discovery_migration(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.unique_id == "4321"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
@ -962,6 +948,7 @@ async def test_usb_discovery_migration(
assert restart_addon.call_args == call("core_zwave_js")
version_info = get_server_version.return_value
version_info.home_id = 5678
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@ -1024,13 +1011,6 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None):
client.driver.controller.emit(
"nvm convert progress",
@ -1055,10 +1035,6 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout(
)
assert mock_usb_serial_by_id.call_count == 2
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
@ -3401,21 +3377,12 @@ async def test_reconfigure_migrate_low_sdk_version(
@pytest.mark.usefixtures("supervisor", "addon_running")
@pytest.mark.parametrize(
(
"reset_server_version_side_effect",
"reset_unique_id",
"restore_server_version_side_effect",
"final_unique_id",
),
[
(None, "4321", None, "3245146787"),
(aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"),
(None, "4321", aiohttp.ClientError("Boom"), "5678"),
(
aiohttp.ClientError("Boom"),
"3245146787",
aiohttp.ClientError("Boom"),
"5678",
),
(None, "3245146787"),
(aiohttp.ClientError("Boom"), "5678"),
],
)
async def test_reconfigure_migrate_with_addon(
@ -3428,15 +3395,11 @@ async def test_reconfigure_migrate_with_addon(
addon_options: dict[str, Any],
set_addon_options: AsyncMock,
get_server_version: AsyncMock,
reset_server_version_side_effect: Exception | None,
reset_unique_id: str,
restore_server_version_side_effect: Exception | None,
final_unique_id: str,
) -> None:
"""Test migration flow with add-on."""
get_server_version.side_effect = reset_server_version_side_effect
version_info = get_server_version.return_value
version_info.home_id = 4321
entry = integration
assert client.connect.call_count == 1
assert client.driver.controller.home_id == 3245146787
@ -3494,13 +3457,6 @@ async def test_reconfigure_migrate_with_addon(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None):
client.driver.controller.emit(
"nvm convert progress",
@ -3531,11 +3487,6 @@ async def test_reconfigure_migrate_with_addon(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
@ -3552,7 +3503,6 @@ async def test_reconfigure_migrate_with_addon(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
assert entry.unique_id == reset_unique_id
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
@ -3565,8 +3515,6 @@ async def test_reconfigure_migrate_with_addon(
with pytest.raises(InInvalid):
data_schema.schema[CONF_USB_PATH](addon_options["device"])
# Reset side effect before starting the add-on.
get_server_version.side_effect = None
version_info.home_id = 5678
result = await hass.config_entries.flow.async_configure(
@ -3646,156 +3594,6 @@ async def test_reconfigure_migrate_with_addon(
assert client.driver.controller.home_id == 3245146787
@pytest.mark.usefixtures("supervisor", "addon_running")
async def test_reconfigure_migrate_reset_driver_ready_timeout(
hass: HomeAssistant,
client: MagicMock,
integration: MockConfigEntry,
restart_addon: AsyncMock,
set_addon_options: AsyncMock,
get_server_version: AsyncMock,
) -> None:
"""Test migration flow with driver ready timeout after controller reset."""
version_info = get_server_version.return_value
version_info.home_id = 4321
entry = integration
assert client.connect.call_count == 1
hass.config_entries.async_update_entry(
entry,
unique_id="1234",
data={
"url": "ws://localhost:3000",
"use_addon": True,
"usb_path": "/dev/ttyUSB0",
},
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm backup progress", {"bytesRead": 100, "total": 200}
)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
await asyncio.sleep(0)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None):
client.driver.controller.emit(
"nvm convert progress",
{"event": "nvm convert progress", "bytesRead": 100, "total": 200},
)
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm restore progress",
{"event": "nvm restore progress", "bytesWritten": 100, "total": 200},
)
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm)
events = async_capture_events(
hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE
)
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
with (
patch(
("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"),
new=0,
),
patch("pathlib.Path.write_bytes") as mock_file,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
assert len(events) == 1
assert events[0].data["progress"] == 0.5
events.clear()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
assert entry.unique_id == "4321"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
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 data_schema.schema[CONF_USB_PATH]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_USB_PATH: "/test",
},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert set_addon_options.call_args == call(
"core_zwave_js", AddonsOptions(config={"device": "/test"})
)
await hass.async_block_till_done()
assert restart_addon.call_args == call("core_zwave_js")
version_info.home_id = 5678
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
assert client.connect.call_count == 2
await hass.async_block_till_done()
assert client.connect.call_count == 4
assert entry.state is config_entries.ConfigEntryState.LOADED
assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2
assert events[0].data["progress"] == 0.25
assert events[1].data["progress"] == 0.75
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "migration_successful"
assert entry.data["url"] == "ws://host1:3001"
assert entry.data["usb_path"] == "/test"
assert entry.data["use_addon"] is True
assert entry.unique_id == "5678"
assert "keep_old_devices" not in entry.data
@pytest.mark.usefixtures("supervisor", "addon_running")
async def test_reconfigure_migrate_restore_driver_ready_timeout(
hass: HomeAssistant,
@ -3828,13 +3626,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None):
client.driver.controller.emit(
"nvm convert progress",
@ -3861,11 +3652,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
@ -3960,11 +3746,6 @@ async def test_reconfigure_migrate_backup_failure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "backup_failed"
assert "keep_old_devices" not in entry.data
@ -3998,11 +3779,6 @@ async def test_reconfigure_migrate_backup_file_failure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
@ -4040,13 +3816,6 @@ async def test_reconfigure_migrate_start_addon_failure(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.MENU
@ -4056,11 +3825,6 @@ async def test_reconfigure_migrate_start_addon_failure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
@ -4124,12 +3888,6 @@ async def test_reconfigure_migrate_restore_failure(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
client.driver.controller.async_restore_nvm = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
@ -4143,11 +3901,6 @@ async def test_reconfigure_migrate_restore_failure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
@ -4242,106 +3995,6 @@ async def test_get_driver_failure_intent_migrate(
assert "keep_old_devices" not in entry.data
async def test_get_driver_failure_instruct_unplug(
hass: HomeAssistant,
client: MagicMock,
integration: MockConfigEntry,
) -> None:
"""Test get driver failure in instruct unplug step."""
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm backup progress", {"bytesRead": 100, "total": 200}
)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes") as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
await hass.config_entries.async_unload(entry.entry_id)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "config_entry_not_loaded"
async def test_hard_reset_failure(
hass: HomeAssistant,
integration: MockConfigEntry,
client: MagicMock,
) -> None:
"""Test hard reset failure."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
client.driver.async_hard_reset = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes") as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reset_failed"
async def test_choose_serial_port_usb_ports_failure(
hass: HomeAssistant,
integration: MockConfigEntry,
@ -4361,13 +4014,6 @@ async def test_choose_serial_port_usb_ports_failure(
side_effect=mock_backup_nvm_raw
)
async def mock_reset_controller():
client.driver.emit(
"driver ready", {"event": "driver ready", "source": "driver"}
)
client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller)
result = await entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.MENU
@ -4377,11 +4023,6 @@ async def test_choose_serial_port_usb_ports_failure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"