From 6a36fc75cfb355f65dead61808bac2af75b42998 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Apr 2025 20:36:34 -1000 Subject: [PATCH 1/7] Fix flakey ESPHome dashboard tests (attempt 2) (#143123) These tests do not need a config entry, only the integration to be set up. Since I cannot replicate the issue locally after 1000 runs, I switched it to use async_setup_component to minimize the potential problem area and hopefully fix the flakey test I also modified the test to explictly set up hassio to ensure the patch is effective since we have to patch a late import last observed flake: https://github.com/home-assistant/core/actions/runs/14503715101/job/40689452294?pr=143106 --- tests/components/esphome/test_dashboard.py | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 4f46e4ddc0e..90b4469e475 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -6,10 +6,16 @@ from unittest.mock import patch from aioesphomeapi import DeviceInfo, InvalidAuthAPIError import pytest -from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard +from homeassistant.components.esphome import ( + CONF_NOISE_PSK, + DOMAIN, + coordinator, + dashboard, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from . import VALID_NOISE_PSK @@ -34,7 +40,6 @@ async def test_dashboard_storage( async def test_restore_dashboard_storage( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Restore dashboard url and slug from storage.""" @@ -47,14 +52,13 @@ async def test_restore_dashboard_storage( with patch.object( dashboard, "async_get_or_create_dashboard_manager" ) as mock_get_or_create: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert mock_get_or_create.call_count == 1 async def test_restore_dashboard_storage_end_to_end( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Restore dashboard url and slug from storage.""" @@ -72,15 +76,13 @@ async def test_restore_dashboard_storage_end_to_end( "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" ) as mock_dashboard_api, ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052" async def test_restore_dashboard_storage_skipped_if_addon_uninstalled( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture, ) -> None: @@ -103,27 +105,25 @@ async def test_restore_dashboard_storage_skipped_if_addon_uninstalled( return_value={}, ), ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() # wait for dashboard setup assert "test-slug is no longer installed" in caplog.text assert not mock_dashboard_api.called async def test_setup_dashboard_fails( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Test that nothing is stored on failed dashboard setup when there was no dashboard before.""" with patch.object( coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError ) as mock_get_devices: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_get_devices.call_count == 1 # The dashboard addon might recover later so we still From 54def1ae0e950bf50540df0cf54424d8107751dc Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Thu, 17 Apr 2025 08:47:37 +0200 Subject: [PATCH 2/7] Meteofrance: adding new states provided by MF API since mid April (#143137) --- homeassistant/components/meteo_france/const.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 2230f43b754..e64a55651d3 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -74,6 +74,7 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Pluie modérée", "Pluie / Averses", "Averses", + "Averses faibles", "Pluie", ], ATTR_CONDITION_SNOWY: [ @@ -81,10 +82,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Neige", "Averses de neige", "Neige forte", + "Neige faible", "Quelques flocons", ], ATTR_CONDITION_SNOWY_RAINY: ["Pluie et neige", "Pluie verglaçante"], - ATTR_CONDITION_SUNNY: ["Ensoleillé"], + ATTR_CONDITION_SUNNY: ["Ensoleillé", "Ciel clair"], ATTR_CONDITION_WINDY: [], ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], From 5eee47d1e4765b34d669c3be81110f8c8c2ddf12 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Thu, 17 Apr 2025 09:44:40 +0200 Subject: [PATCH 3/7] Bump eheimdigital to 1.1.0 (#143138) --- homeassistant/components/eheimdigital/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 1d1ca6f84c7..c3c8a251300 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.6"], + "requirements": ["eheimdigital==1.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/requirements_all.txt b/requirements_all.txt index 36f556c533d..1e24953c1ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -829,7 +829,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.6 +eheimdigital==1.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b1a8c4f6b0..1f40b2f6d65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -708,7 +708,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.6 +eheimdigital==1.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 From dd4334e3baa662d3d444433477628d1b860c12fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Apr 2025 21:55:30 -1000 Subject: [PATCH 4/7] Bump yarl to 1.20.0 (#143124) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 30b7718bad4..e28ecba0950 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -74,7 +74,7 @@ voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.19.0 +yarl==1.20.0 zeroconf==0.146.5 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index c66f8ba6363..e100863510d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", - "yarl==1.19.0", + "yarl==1.20.0", "webrtc-models==0.3.0", "zeroconf==0.146.5", ] diff --git a/requirements.txt b/requirements.txt index 40200563ec1..bfc330650e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,6 +58,6 @@ uv==0.6.10 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 -yarl==1.19.0 +yarl==1.20.0 webrtc-models==0.3.0 zeroconf==0.146.5 From 1fb3d8d601ae0e553c07cf430340e1bf524d6ad4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Apr 2025 21:56:38 -1000 Subject: [PATCH 5/7] Bump habluetooth to 3.39.0 (#143125) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e824720adab..b83bc37e473 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.27.0", "dbus-fast==2.43.0", - "habluetooth==3.38.1" + "habluetooth==3.39.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e28ecba0950..3baebae8a6e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.43.0 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.38.1 +habluetooth==3.39.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 1e24953c1ec..5b1aced22ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1114,7 +1114,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.38.1 +habluetooth==3.39.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f40b2f6d65..533392f640f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -956,7 +956,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.38.1 +habluetooth==3.39.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 4d959fb91c931fb2a3a8dbd2c0f8680ba7633c16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Apr 2025 21:57:55 -1000 Subject: [PATCH 6/7] Bump esphome-dashboard-api to 1.3.0 (#143128) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7b0f8083db1..5433056c2bb 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "requirements": [ "aioesphomeapi==30.0.1", - "esphome-dashboard-api==1.2.3", + "esphome-dashboard-api==1.3.0", "bleak-esphome==2.13.1" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/requirements_all.txt b/requirements_all.txt index 5b1aced22ed..9e7329d4b78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -889,7 +889,7 @@ epson-projector==0.5.1 eq3btsmart==1.4.1 # homeassistant.components.esphome -esphome-dashboard-api==1.2.3 +esphome-dashboard-api==1.3.0 # homeassistant.components.netgear_lte eternalegypt==0.0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 533392f640f..42def0664fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -759,7 +759,7 @@ epson-projector==0.5.1 eq3btsmart==1.4.1 # homeassistant.components.esphome -esphome-dashboard-api==1.2.3 +esphome-dashboard-api==1.3.0 # homeassistant.components.netgear_lte eternalegypt==0.0.16 From cadbb623c75d2af831dbc14de5213da25870da82 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 17 Apr 2025 11:14:47 +0300 Subject: [PATCH 7/7] New ZWave-JS migration flow (#142717) * ZwaveJS radio migration flow * Partial migration flow * basic migration flow * report exact progress to frontend * Display backup file path * string tweak * update tests * improve exception handling * radio -> controller * test tweak * test tweak * clean up and test error handling * more tests * test progress * PR comments * fix tests * test restore progress * more coverage * coverage * coverage * make mypy happy * PR comments * Apply suggestions from code review Co-authored-by: Martin Hjelmare * ruff --------- Co-authored-by: Martin Hjelmare --- .../components/zwave_js/config_flow.py | 253 ++++++- .../components/zwave_js/strings.json | 43 +- tests/components/zwave_js/test_config_flow.py | 645 +++++++++++++++++- 3 files changed, 917 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 1337331bfb6..1877658ce42 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -4,12 +4,17 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio +from datetime import datetime import logging +from pathlib import Path from typing import Any import aiohttp from serial.tools import list_ports import voluptuous as vol +from zwave_js_server.client import Client +from zwave_js_server.exceptions import FailedCommand +from zwave_js_server.model.driver import Driver from zwave_js_server.version import VersionInfo, get_server_version from homeassistant.components import usb @@ -23,6 +28,7 @@ from homeassistant.config_entries import ( SOURCE_USB, ConfigEntry, ConfigEntryBaseFlow, + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -60,6 +66,7 @@ from .const import ( CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, + DATA_CLIENT, DOMAIN, ) @@ -74,6 +81,9 @@ CONF_EMULATE_HARDWARE = "emulate_hardware" CONF_LOG_LEVEL = "log_level" SERVER_VERSION_TIMEOUT = 10 +OPTIONS_INTENT_MIGRATE = "intent_migrate" +OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure" + ADDON_LOG_LEVELS = { "error": "Error", "warn": "Warn", @@ -636,7 +646,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): } if not self._usb_discovery: - ports = await async_get_usb_ports(self.hass) + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + schema = { vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), **schema, @@ -717,6 +732,10 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): super().__init__() self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None + self.backup_task: asyncio.Task | None = None + self.restore_backup_task: asyncio.Task | None = None + self.backup_data: bytes | None = None + self.backup_filepath: str | None = None @callback def _async_update_entry(self, data: dict[str, Any]) -> None: @@ -725,6 +744,18 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): async def async_step_init( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm if we are migrating adapters or just re-configuring.""" + return self.async_show_menu( + step_id="init", + menu_options=[ + OPTIONS_INTENT_RECONFIGURE, + OPTIONS_INTENT_MIGRATE, + ], + ) + + async def async_step_intent_reconfigure( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" if is_hassio(self.hass): @@ -732,6 +763,91 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): return await self.async_step_manual() + async def async_step_intent_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the user wants to reset their current controller.""" + if not self.config_entry.data.get(CONF_USE_ADDON): + return self.async_abort(reason="addon_required") + + if user_input is not None: + return await self.async_step_backup_nvm() + + return self.async_show_form(step_id="intent_migrate") + + async def async_step_backup_nvm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Backup the current network.""" + if self.backup_task is None: + self.backup_task = self.hass.async_create_task(self._async_backup_network()) + + if not self.backup_task.done(): + return self.async_show_progress( + step_id="backup_nvm", + progress_action="backup_nvm", + progress_task=self.backup_task, + ) + + try: + await self.backup_task + except AbortFlow as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="backup_failed") + finally: + self.backup_task = None + + return self.async_show_progress_done(next_step_id="instruct_unplug") + + async def async_step_restore_nvm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Restore the backup.""" + if self.restore_backup_task is None: + self.restore_backup_task = self.hass.async_create_task( + self._async_restore_network_backup() + ) + + if not self.restore_backup_task.done(): + return self.async_show_progress( + step_id="restore_nvm", + progress_action="restore_nvm", + progress_task=self.restore_backup_task, + ) + + try: + await self.restore_backup_task + except AbortFlow as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="restore_failed") + finally: + self.restore_backup_task = None + + return self.async_show_progress_done(next_step_id="migration_done") + + 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.""" + + if user_input is not None: + # Now that the old controller is gone, we can scan for serial ports again + return await self.async_step_choose_serial_port() + + # reset the old controller + try: + await self._get_driver().async_hard_reset() + except FailedCommand as err: + _LOGGER.error("Failed to reset controller: %s", err) + return self.async_abort(reason="reset_failed") + + return self.async_show_form( + step_id="instruct_unplug", + description_placeholders={ + "file_path": str(self.backup_filepath), + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -881,7 +997,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) - ports = await async_get_usb_ports(self.hass) + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") data_schema = vol.Schema( { @@ -911,12 +1031,64 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): return self.async_show_form(step_id="configure_addon", data_schema=data_schema) + async def async_step_choose_serial_port( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose a serial port.""" + if user_input is not None: + addon_info = await self._async_get_addon_info() + addon_config = addon_info.options + self.usb_path = user_input[CONF_USB_PATH] + new_addon_config = { + **addon_config, + CONF_ADDON_DEVICE: self.usb_path, + } + if addon_info.state == AddonState.RUNNING: + self.restart_addon = True + # Copy the add-on config to keep the objects separate. + self.original_addon_config = dict(addon_config) + await self._async_set_addon_config(new_addon_config) + return await self.async_step_start_addon() + + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH): vol.In(ports), + } + ) + return self.async_show_form( + step_id="choose_serial_port", data_schema=data_schema + ) + async def async_step_start_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Add-on start failed.""" return await self.async_revert_addon_config(reason="addon_start_failed") + async def async_step_backup_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Backup failed.""" + return self.async_abort(reason="backup_failed") + + async def async_step_restore_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Restore failed.""" + return self.async_abort(reason="restore_failed") + + async def async_step_migration_done( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Migration done.""" + return self.async_create_entry(title=TITLE, data={}) + async def async_step_finish_addon_setup( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -943,12 +1115,16 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): except CannotConnect: return await self.async_revert_addon_config(reason="cannot_connect") - if self.config_entry.unique_id != str(self.version_info.home_id): + if self.backup_data is None and self.config_entry.unique_id != str( + self.version_info.home_id + ): return await self.async_revert_addon_config(reason="different_device") self._async_update_entry( { **self.config_entry.data, + # this will only be different in a migration flow + "unique_id": str(self.version_info.home_id), CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, CONF_S0_LEGACY_KEY: self.s0_legacy_key, @@ -961,6 +1137,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, } ) + if self.backup_data: + return await self.async_step_restore_nvm() + # Always reload entry since we may have disconnected the client. self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) return self.async_create_entry(title=TITLE, data={}) @@ -990,6 +1169,74 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): _LOGGER.debug("Reverting add-on options, reason: %s", reason) return await self.async_step_configure_addon(addon_config_input) + async def _async_backup_network(self) -> None: + """Backup the current network.""" + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to frontend.""" + self.async_update_progress(event["bytesRead"] / event["total"]) + + controller = self._get_driver().controller + unsub = controller.on("nvm backup progress", forward_progress) + try: + self.backup_data = await controller.async_backup_nvm_raw() + except FailedCommand as err: + raise AbortFlow(f"Failed to backup network: {err}") from err + finally: + unsub() + + # save the backup to a file just in case + self.backup_filepath = self.hass.config.path( + f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin" + ) + try: + await self.hass.async_add_executor_job( + Path(self.backup_filepath).write_bytes, + self.backup_data, + ) + except OSError as err: + raise AbortFlow(f"Failed to save backup file: {err}") from err + + async def _async_restore_network_backup(self) -> None: + """Restore the backup.""" + assert self.backup_data is not None + + # Reload the config entry to reconnect the client after the addon restart + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to frontend.""" + if event["event"] == "nvm convert progress": + # assume convert is 50% of the total progress + self.async_update_progress(event["bytesRead"] / event["total"] * 0.5) + elif event["event"] == "nvm restore progress": + # assume restore is the rest of the progress + self.async_update_progress( + event["bytesWritten"] / event["total"] * 0.5 + 0.5 + ) + + controller = self._get_driver().controller + unsubs = [ + controller.on("nvm convert progress", forward_progress), + controller.on("nvm restore progress", forward_progress), + ] + try: + await controller.async_restore_nvm(self.backup_data) + except FailedCommand as err: + raise AbortFlow(f"Failed to restore network: {err}") from err + finally: + for unsub in unsubs: + unsub() + + def _get_driver(self) -> Driver: + if self.config_entry.state != ConfigEntryState.LOADED: + raise AbortFlow("Configuration entry is not loaded") + client: Client = self.config_entry.runtime_data[DATA_CLIENT] + assert client.driver is not None + return client.driver + class CannotConnect(HomeAssistantError): """Indicate connection error.""" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 644d829b032..8f445beaf23 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -11,7 +11,11 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_requires_supervisor": "Discovery requires the supervisor.", "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on." + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", + "backup_failed": "Failed to backup network.", + "restore_failed": "Failed to restore network.", + "reset_failed": "Failed to reset controller.", + "usb_ports_failed": "Failed to get USB devices." }, "error": { "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", @@ -22,7 +26,9 @@ "flow_title": "{name}", "progress": { "install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds." + "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds.", + "backup_nvm": "Please wait while the network backup completes.", + "restore_nvm": "Please wait while the network restore completes." }, "step": { "configure_addon": { @@ -217,7 +223,12 @@ "addon_stop_failed": "Failed to stop the Z-Wave add-on.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." + "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", + "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.", + "backup_failed": "[%key:component::zwave_js::config::abort::backup_failed%]", + "restore_failed": "[%key:component::zwave_js::config::abort::restore_failed%]", + "reset_failed": "[%key:component::zwave_js::config::abort::reset_failed%]", + "usb_ports_failed": "[%key:component::zwave_js::config::abort::usb_ports_failed%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -226,9 +237,27 @@ }, "progress": { "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", - "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" + "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]", + "backup_nvm": "[%key:component::zwave_js::config::progress::backup_nvm%]", + "restore_nvm": "[%key:component::zwave_js::config::progress::restore_nvm%]" }, "step": { + "init": { + "title": "Migrate or re-configure", + "description": "Are you migrating to a new controller or re-configuring the current controller?", + "menu_options": { + "intent_migrate": "Migrate to a new controller", + "intent_reconfigure": "Re-configure the current controller" + } + }, + "intent_migrate": { + "title": "[%key:component::zwave_js::options::step::init::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." + }, "configure_addon": { "data": { "emulate_hardware": "Emulate Hardware", @@ -242,6 +271,12 @@ "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", "title": "[%key:component::zwave_js::config::step::configure_addon::title%]" }, + "choose_serial_port": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Select your Z-Wave device" + }, "install_addon": { "title": "[%key:component::zwave_js::config::step::install_addon::title%]" }, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 990c73c3aca..aaa7353882c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -13,18 +13,23 @@ from aiohasupervisor.models import AddonsOptions, Discovery import aiohttp import pytest from serial.tools.list_ports_common import ListPortInfo +from zwave_js_server.exceptions import FailedCommand from zwave_js_server.version import VersionInfo -from homeassistant import config_entries -from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE -from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.zwave_js.config_flow import ( + SERVER_VERSION_TIMEOUT, + TITLE, + OptionsFlowHandler, +) +from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_capture_events ADDON_DISCOVERY_INFO = { "addon": "Z-Wave JS", @@ -229,18 +234,48 @@ async def slow_server_version(*args): @pytest.mark.parametrize( - ("flow", "flow_params"), + ("url", "server_version_side_effect", "server_version_timeout", "error"), [ ( - "flow", - lambda entry: { - "handler": DOMAIN, - "context": {"source": config_entries.SOURCE_USER}, - }, + "not-ws-url", + None, + SERVER_VERSION_TIMEOUT, + "invalid_ws_url", + ), + ( + "ws://localhost:3000", + slow_server_version, + 0, + "cannot_connect", + ), + ( + "ws://localhost:3000", + Exception("Boom"), + SERVER_VERSION_TIMEOUT, + "unknown", ), - ("options", lambda entry: {"handler": entry.entry_id}), ], ) +async def test_manual_errors(hass: HomeAssistant, integration, url, error) -> None: + """Test all errors with a manual set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": url, + }, + ) + + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error} + + @pytest.mark.parametrize( ("url", "server_version_side_effect", "server_version_timeout", "error"), [ @@ -264,24 +299,28 @@ async def slow_server_version(*args): ), ], ) -async def test_manual_errors( - hass: HomeAssistant, integration, url, error, flow, flow_params +async def test_manual_errors_options_flow( + hass: HomeAssistant, integration, url, error ) -> None: """Test all errors with a manual set up.""" - entry = integration - result = await getattr(hass.config_entries, flow).async_init(**flow_params(entry)) + result = await hass.config_entries.options.async_init(integration.entry_id) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" - result = await getattr(hass.config_entries, flow).async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], { "url": url, }, ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" assert result["errors"] == {"base": error} @@ -1717,6 +1756,32 @@ async def test_addon_installed_set_options_failure( assert start_addon.call_count == 0 +async def test_addon_installed_usb_ports_failure( + hass: HomeAssistant, + supervisor, + addon_installed, +) -> None: + """Test usb ports failure when add-on is installed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor" + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" + + @pytest.mark.parametrize( "discovery_info", [ @@ -1972,6 +2037,13 @@ async def test_options_manual(hass: HomeAssistant, client, integration) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" @@ -1997,6 +2069,13 @@ async def test_options_manual_different_device( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" @@ -2021,6 +2100,13 @@ async def test_options_not_addon( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2069,6 +2155,13 @@ async def test_options_not_addon_with_addon( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2129,6 +2222,13 @@ async def test_options_not_addon_with_addon_stop_fail( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2259,6 +2359,13 @@ async def test_options_addon_running( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2386,6 +2493,13 @@ async def test_options_addon_running_no_changes( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2559,6 +2673,13 @@ async def test_options_different_device( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2735,6 +2856,13 @@ async def test_options_addon_restart_failed( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2869,6 +2997,13 @@ async def test_options_addon_running_server_info_failure( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -2999,6 +3134,13 @@ async def test_options_addon_not_installed( result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "on_supervisor" @@ -3100,3 +3242,472 @@ async def test_zeroconf(hass: HomeAssistant) -> None: } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_migrate_no_addon(hass: HomeAssistant, integration) -> None: + """Test migration flow fails when not using add-on.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": False} + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_required" + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_options_migrate_with_addon( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, +) -> None: + """Test migration flow with add-on.""" + hass.config_entries.async_update_entry( + integration, + 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_restore_nvm(data: bytes): + 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.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + hass.config_entries.async_reload = AsyncMock() + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await hass.config_entries.options.async_init(integration.entry_id) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) 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 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + assert result["data_schema"].schema[CONF_USB_PATH] + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] == 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") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + assert hass.config_entries.async_reload.called + 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.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert integration.data["url"] == "ws://host1:3001" + assert integration.data["usb_path"] == "/test" + assert integration.data["use_addon"] is True + + +async def test_options_migrate_backup_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test backup failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "backup_failed" + + +async def test_options_migrate_backup_file_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test backup file 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 + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch( + "pathlib.Path.write_bytes", MagicMock(side_effect=OSError("test_error")) + ): + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "backup_failed" + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_options_migrate_restore_failure( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, +) -> None: + """Test restore failure.""" + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.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.controller.async_restore_nvm = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await hass.config_entries.options.async_init(integration.entry_id) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) 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.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + + assert client.driver.controller.async_restore_nvm.call_count == 1 + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "restore_failed" + + +async def test_get_driver_failure(hass: HomeAssistant, integration, client) -> None: + """Test get driver failure.""" + + handler = OptionsFlowHandler() + handler.hass = hass + handler._config_entry = integration + await hass.config_entries.async_unload(integration.entry_id) + + with pytest.raises(data_entry_flow.AbortFlow): + await handler._get_driver() + + +async def test_hard_reset_failure(hass: HomeAssistant, integration, client) -> None: + """Test hard reset failure.""" + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.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 hass.config_entries.options.async_init(integration.entry_id) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) 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.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reset_failed" + + +async def test_choose_serial_port_usb_ports_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test choose serial port usb ports failure.""" + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.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 + ) + + result = await hass.config_entries.options.async_init(integration.entry_id) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", MagicMock()) 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.options.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], {} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" + + +async def test_configure_addon_usb_ports_failure( + hass: HomeAssistant, integration, addon_installed, supervisor +) -> None: + """Test configure addon usb ports failure.""" + result = await hass.config_entries.options.async_init(integration.entry_id) + + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "on_supervisor" + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": True} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed"