From 0e5040d917614ddfaf9863103c6de767d2b21cda Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 24 Jun 2021 13:15:42 +0200 Subject: [PATCH] Add zwave_js options flow to reconfigure server (#51840) --- homeassistant/components/zwave_js/addon.py | 20 + .../components/zwave_js/config_flow.py | 269 ++++++- homeassistant/components/zwave_js/const.py | 2 + .../components/zwave_js/strings.json | 46 ++ .../components/zwave_js/translations/en.json | 50 ++ tests/components/zwave_js/conftest.py | 53 +- tests/components/zwave_js/test_config_flow.py | 753 +++++++++++++++++- 7 files changed, 1176 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index ff74b5d5a44..a0caaa15488 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -12,6 +12,7 @@ from homeassistant.components.hassio import ( async_get_addon_discovery_info, async_get_addon_info, async_install_addon, + async_restart_addon, async_set_addon_options, async_start_addon, async_stop_addon, @@ -89,6 +90,7 @@ class AddonManager: """Set up the add-on manager.""" self._hass = hass self._install_task: asyncio.Task | None = None + self._restart_task: asyncio.Task | None = None self._start_task: asyncio.Task | None = None self._update_task: asyncio.Task | None = None @@ -222,6 +224,11 @@ class AddonManager: """Start the Z-Wave JS add-on.""" await async_start_addon(self._hass, ADDON_SLUG) + @api_error("Failed to restart the Z-Wave JS add-on") + async def async_restart_addon(self) -> None: + """Restart the Z-Wave JS add-on.""" + await async_restart_addon(self._hass, ADDON_SLUG) + @callback def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task: """Schedule a task that starts the Z-Wave JS add-on. @@ -235,6 +242,19 @@ class AddonManager: ) return self._start_task + @callback + def async_schedule_restart_addon(self, catch_error: bool = False) -> asyncio.Task: + """Schedule a task that restarts the Z-Wave JS add-on. + + Only schedule a new restart task if the there's no running task. + """ + if not self._restart_task or self._restart_task.done(): + LOGGER.info("Restarting Z-Wave JS add-on") + self._restart_task = self._async_schedule_addon_operation( + self.async_restart_addon, catch_error=catch_error + ) + return self._restart_task + @api_error("Failed to stop the Z-Wave JS add-on") async def async_stop_addon(self) -> None: """Stop the Z-Wave JS add-on.""" diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 4dd0a084711..ced8b2c68cb 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -23,9 +23,12 @@ from homeassistant.data_entry_flow import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import disconnect_client from .addon import AddonError, AddonInfo, AddonManager, AddonState, get_addon_manager from .const import ( CONF_ADDON_DEVICE, + CONF_ADDON_EMULATE_HARDWARE, + CONF_ADDON_LOG_LEVEL, CONF_ADDON_NETWORK_KEY, CONF_INTEGRATION_CREATED_ADDON, CONF_NETWORK_KEY, @@ -41,8 +44,25 @@ TITLE = "Z-Wave JS" ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 4 +CONF_EMULATE_HARDWARE = "emulate_hardware" +CONF_LOG_LEVEL = "log_level" SERVER_VERSION_TIMEOUT = 10 +ADDON_LOG_LEVELS = { + "error": "Error", + "warn": "Warn", + "info": "Info", + "verbose": "Verbose", + "debug": "Debug", + "silly": "Silly", +} +ADDON_USER_INPUT_MAP = { + CONF_ADDON_DEVICE: CONF_USB_PATH, + CONF_ADDON_NETWORK_KEY: CONF_NETWORK_KEY, + CONF_ADDON_LOG_LEVEL: CONF_LOG_LEVEL, + CONF_ADDON_EMULATE_HARDWARE: CONF_EMULATE_HARDWARE, +} + ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) @@ -52,6 +72,12 @@ def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: return vol.Schema({vol.Required(CONF_URL, default=default_url): str}) +def get_on_supervisor_schema(user_input: dict[str, Any]) -> vol.Schema: + """Return a schema for the on Supervisor step.""" + default_use_addon = user_input[CONF_USE_ADDON] + return vol.Schema({vol.Optional(CONF_USE_ADDON, default=default_use_addon): bool}) + + async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: """Validate if the user input allows us to connect.""" ws_address = user_input[CONF_URL] @@ -89,6 +115,7 @@ class BaseZwaveJSFlow(FlowHandler): self.network_key: str | None = None self.usb_path: str | None = None self.ws_address: str | None = None + self.restart_addon: bool = False # If we install the add-on we should uninstall it on entry remove. self.integration_created_addon = False self.install_task: asyncio.Task | None = None @@ -159,7 +186,10 @@ class BaseZwaveJSFlow(FlowHandler): addon_manager: AddonManager = get_addon_manager(self.hass) self.version_info = None try: - await addon_manager.async_schedule_start_addon() + if self.restart_addon: + await addon_manager.async_schedule_restart_addon() + else: + await addon_manager.async_schedule_start_addon() # Sleep some seconds to let the add-on start properly before connecting. for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): await asyncio.sleep(ADDON_SETUP_TIMEOUT) @@ -262,6 +292,14 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): """Return the correct flow manager.""" return self.hass.config_entries.flow + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Return the options flow.""" + return OptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -445,6 +483,235 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): ) +class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): + """Handle an options flow for Z-Wave JS.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Set up the options flow.""" + super().__init__() + self.config_entry = config_entry + self.original_addon_config: dict[str, Any] | None = None + self.revert_reason: str | None = None + + @property + def flow_manager(self) -> config_entries.OptionsFlowManager: + """Return the correct flow manager.""" + return self.hass.config_entries.options + + @callback + def _async_update_entry(self, data: dict[str, Any]) -> None: + """Update the config entry with new data.""" + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if is_hassio(self.hass): + return await self.async_step_on_supervisor() + + return await self.async_step_manual() + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a manual configuration.""" + if user_input is None: + return self.async_show_form( + step_id="manual", + data_schema=get_manual_schema( + {CONF_URL: self.config_entry.data[CONF_URL]} + ), + ) + + errors = {} + + try: + version_info = await validate_input(self.hass, user_input) + except InvalidInput as err: + errors["base"] = err.error + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.config_entry.unique_id != version_info.home_id: + return self.async_abort(reason="different_device") + + # Make sure we disable any add-on handling + # if the controller is reconfigured in a manual step. + self._async_update_entry( + { + **self.config_entry.data, + **user_input, + CONF_USE_ADDON: False, + CONF_INTEGRATION_CREATED_ADDON: False, + } + ) + + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + return self.async_create_entry(title=TITLE, data={}) + + return self.async_show_form( + step_id="manual", data_schema=get_manual_schema(user_input), errors=errors + ) + + async def async_step_on_supervisor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle logic when on Supervisor host.""" + if user_input is None: + return self.async_show_form( + step_id="on_supervisor", + data_schema=get_on_supervisor_schema( + {CONF_USE_ADDON: self.config_entry.data.get(CONF_USE_ADDON, True)} + ), + ) + if not user_input[CONF_USE_ADDON]: + return await self.async_step_manual() + + addon_info = await self._async_get_addon_info() + + if addon_info.state == AddonState.NOT_INSTALLED: + return await self.async_step_install_addon() + + return await self.async_step_configure_addon() + + async def async_step_configure_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Ask for config for Z-Wave JS add-on.""" + addon_info = await self._async_get_addon_info() + addon_config = addon_info.options + + if user_input is not None: + self.network_key = user_input[CONF_NETWORK_KEY] + self.usb_path = user_input[CONF_USB_PATH] + + new_addon_config = { + **addon_config, + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_NETWORK_KEY: self.network_key, + CONF_ADDON_LOG_LEVEL: user_input[CONF_LOG_LEVEL], + CONF_ADDON_EMULATE_HARDWARE: user_input[CONF_EMULATE_HARDWARE], + } + + if new_addon_config != addon_config: + 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) + + if addon_info.state == AddonState.RUNNING and not self.restart_addon: + return await self.async_step_finish_addon_setup() + + if ( + self.config_entry.data.get(CONF_USE_ADDON) + and self.config_entry.state == config_entries.ConfigEntryState.LOADED + ): + # Disconnect integration before restarting add-on. + await disconnect_client(self.hass, self.config_entry) + + return await self.async_step_start_addon() + + usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") + log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") + emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH, default=usb_path): str, + vol.Optional(CONF_NETWORK_KEY, default=network_key): str, + vol.Optional(CONF_LOG_LEVEL, default=log_level): vol.In( + ADDON_LOG_LEVELS + ), + vol.Optional(CONF_EMULATE_HARDWARE, default=emulate_hardware): bool, + } + ) + + return self.async_show_form(step_id="configure_addon", data_schema=data_schema) + + async def async_step_start_failed( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add-on start failed.""" + return await self.async_revert_addon_config(reason="addon_start_failed") + + async def async_step_finish_addon_setup( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Prepare info needed to complete the config entry update. + + Get add-on discovery info and server version info. + Check for same unique id and abort if not the same unique id. + """ + if self.revert_reason: + self.original_addon_config = None + reason = self.revert_reason + self.revert_reason = None + return await self.async_revert_addon_config(reason=reason) + + if not self.ws_address: + discovery_info = await self._async_get_addon_discovery_info() + self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" + + if not self.version_info: + try: + self.version_info = await async_get_version_info( + self.hass, self.ws_address + ) + except CannotConnect: + return await self.async_revert_addon_config(reason="cannot_connect") + + if self.config_entry.unique_id != self.version_info.home_id: + return await self.async_revert_addon_config(reason="different_device") + + self._async_update_entry( + { + **self.config_entry.data, + CONF_URL: self.ws_address, + CONF_USB_PATH: self.usb_path, + CONF_NETWORK_KEY: self.network_key, + CONF_USE_ADDON: True, + CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, + } + ) + # Always reload entry since we may have disconnected the client. + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + return self.async_create_entry(title=TITLE, data={}) + + async def async_revert_addon_config(self, reason: str) -> FlowResult: + """Abort the options flow. + + If the add-on options have been changed, revert those and restart add-on. + """ + # If reverting the add-on options failed, abort immediately. + if self.revert_reason: + _LOGGER.error( + "Failed to revert add-on options before aborting flow, reason: %s", + reason, + ) + + if self.revert_reason or not self.original_addon_config: + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + return self.async_abort(reason=reason) + + self.revert_reason = reason + addon_config_input = { + ADDON_USER_INPUT_MAP[addon_key]: addon_val + for addon_key, addon_val in self.original_addon_config.items() + } + _LOGGER.debug("Reverting add-on options, reason: %s", reason) + return await self.async_step_configure_addon(addon_config_input) + + class CannotConnect(exceptions.HomeAssistantError): """Indicate connection error.""" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index d73f05c4c47..9e6e37b4ee7 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -2,6 +2,8 @@ import logging CONF_ADDON_DEVICE = "device" +CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware" +CONF_ADDON_LOG_LEVEL = "log_level" CONF_ADDON_NETWORK_KEY = "network_key" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" CONF_NETWORK_KEY = "network_key" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index eb13ad512e3..b942a75b27a 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -47,5 +47,51 @@ "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." } + }, + "options": { + "step": { + "manual": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "on_supervisor": { + "title": "Select connection method", + "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "data": { "use_addon": "Use the Z-Wave JS Supervisor add-on" } + }, + "install_addon": { + "title": "The Z-Wave JS add-on installation has started" + }, + "configure_addon": { + "title": "Enter the Z-Wave JS add-on configuration", + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]", + "network_key": "Network Key", + "log_level": "Log level", + "emulate_hardware": "Emulate Hardware" + } + }, + "start_addon": { "title": "The Z-Wave JS add-on is starting." } + }, + "error": { + "invalid_ws_url": "Invalid websocket URL", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "addon_info_failed": "Failed to get Z-Wave JS add-on info.", + "addon_install_failed": "Failed to install the Z-Wave JS add-on.", + "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", + "addon_start_failed": "Failed to start the Z-Wave JS add-on.", + "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", + "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." + }, + "progress": { + "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." + } } } diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 101942dc717..27cafb6af6e 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -51,5 +51,55 @@ } } }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", + "addon_info_failed": "Failed to get Z-Wave JS add-on info.", + "addon_install_failed": "Failed to install the Z-Wave JS add-on.", + "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", + "addon_start_failed": "Failed to start the Z-Wave JS add-on.", + "already_configured": "Device is already configured", + "cannot_connect": "Failed to 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." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_ws_url": "Invalid websocket URL", + "unknown": "Unexpected error" + }, + "progress": { + "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "Emulate Hardware", + "log_level": "Log level", + "network_key": "Network Key", + "usb_path": "USB Device Path" + }, + "title": "Enter the Z-Wave JS add-on configuration" + }, + "install_addon": { + "title": "The Z-Wave JS add-on installation has started" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "Use the Z-Wave JS Supervisor add-on" + }, + "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "title": "Select connection method" + }, + "start_addon": { + "title": "The Z-Wave JS add-on is starting." + } + } + }, "title": "Z-Wave JS" } \ No newline at end of file diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index caddbb050a5..f0c69709031 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -60,9 +60,14 @@ def mock_addon_options(addon_info): @pytest.fixture(name="set_addon_options_side_effect") -def set_addon_options_side_effect_fixture(): +def set_addon_options_side_effect_fixture(addon_options): """Return the set add-on options side effect.""" - return None + + async def set_addon_options(hass, slug, options): + """Mock set add-on options.""" + addon_options.update(options["options"]) + + return set_addon_options @pytest.fixture(name="set_addon_options") @@ -75,11 +80,24 @@ def mock_set_addon_options(set_addon_options_side_effect): yield set_options +@pytest.fixture(name="install_addon_side_effect") +def install_addon_side_effect_fixture(addon_info): + """Return the install add-on side effect.""" + + async def install_addon(hass, slug): + """Mock install add-on.""" + addon_info.return_value["state"] = "stopped" + addon_info.return_value["version"] = "1.0" + + return install_addon + + @pytest.fixture(name="install_addon") -def mock_install_addon(): +def mock_install_addon(install_addon_side_effect): """Mock install add-on.""" with patch( - "homeassistant.components.zwave_js.addon.async_install_addon" + "homeassistant.components.zwave_js.addon.async_install_addon", + side_effect=install_addon_side_effect, ) as install_addon: yield install_addon @@ -94,9 +112,14 @@ def mock_update_addon(): @pytest.fixture(name="start_addon_side_effect") -def start_addon_side_effect_fixture(): - """Return the set add-on options side effect.""" - return None +def start_addon_side_effect_fixture(addon_info): + """Return the start add-on options side effect.""" + + async def start_addon(hass, slug): + """Mock start add-on.""" + addon_info.return_value["state"] = "started" + + return start_addon @pytest.fixture(name="start_addon") @@ -118,6 +141,22 @@ def stop_addon_fixture(): yield stop_addon +@pytest.fixture(name="restart_addon_side_effect") +def restart_addon_side_effect_fixture(): + """Return the restart add-on options side effect.""" + return None + + +@pytest.fixture(name="restart_addon") +def mock_restart_addon(restart_addon_side_effect): + """Mock restart add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_restart_addon", + side_effect=restart_addon_side_effect, + ) as restart_addon: + yield restart_addon + + @pytest.fixture(name="uninstall_addon") def uninstall_addon_fixture(): """Mock uninstall add-on.""" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 2414d2aea00..7d02c215d45 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2,6 +2,7 @@ import asyncio from unittest.mock import DEFAULT, call, patch +import aiohttp import pytest from zwave_js_server.version import VersionInfo @@ -19,6 +20,21 @@ ADDON_DISCOVERY_INFO = { } +@pytest.fixture(name="persistent_notification", autouse=True) +async def setup_persistent_notification(hass): + """Set up persistent notification integration.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + +@pytest.fixture(name="setup_entry") +def setup_entry_fixture(): + """Mock entry setup.""" + with patch( + "homeassistant.components.zwave_js.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="supervisor") def mock_supervisor_fixture(): """Mock Supervisor.""" @@ -134,6 +150,19 @@ async def slow_server_version(*args): await asyncio.sleep(0.1) +@pytest.mark.parametrize( + "flow, flow_params", + [ + ( + "flow", + lambda entry: { + "handler": DOMAIN, + "context": {"source": config_entries.SOURCE_USER}, + }, + ), + ("options", lambda entry: {"handler": entry.entry_id}), + ], +) @pytest.mark.parametrize( "url, server_version_side_effect, server_version_timeout, error", [ @@ -157,20 +186,15 @@ async def slow_server_version(*args): ), ], ) -async def test_manual_errors( - hass, - url, - error, -): +async def test_manual_errors(hass, integration, url, error, flow, flow_params): """Test all errors with a manual set up.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + entry = integration + result = await getattr(hass.config_entries, flow).async_init(**flow_params(entry)) assert result["type"] == "form" assert result["step_id"] == "manual" - result = await hass.config_entries.flow.async_configure( + result = await getattr(hass.config_entries, flow).async_configure( result["flow_id"], { "url": url, @@ -1059,3 +1083,714 @@ async def test_install_addon_failure(hass, supervisor, addon_installed, install_ assert result["type"] == "abort" assert result["reason"] == "addon_install_failed" + + +async def test_options_manual(hass, client, integration): + """Test manual settings in options flow.""" + entry = integration + entry.unique_id = 1234 + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "manual" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"url": "ws://1.1.1.1:3001"} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert entry.data["url"] == "ws://1.1.1.1:3001" + assert entry.data["use_addon"] is False + assert entry.data["integration_created_addon"] is False + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1 + + +async def test_options_manual_different_device(hass, integration): + """Test options flow manual step connecting to different device.""" + entry = integration + entry.unique_id = 5678 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "manual" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"url": "ws://1.1.1.1:3001"} + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "different_device" + + +async def test_options_not_addon(hass, client, supervisor, integration): + """Test options flow and opting out of add-on on Supervisor.""" + entry = integration + entry.unique_id = 1234 + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": False} + ) + + assert result["type"] == "form" + assert result["step_id"] == "manual" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + "url": "ws://localhost:3000", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert entry.data["url"] == "ws://localhost:3000" + assert entry.data["use_addon"] is False + assert entry.data["integration_created_addon"] is False + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + "discovery_info, entry_data, old_addon_options, new_addon_options, disconnect_calls", + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + {}, + {"device": "/test", "network_key": "abc123"}, + { + "usb_path": "/new", + "network_key": "new123", + "log_level": "info", + "emulate_hardware": False, + }, + 0, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + {"use_addon": True}, + {"device": "/test", "network_key": "abc123"}, + { + "usb_path": "/new", + "network_key": "new123", + "log_level": "info", + "emulate_hardware": False, + }, + 1, + ), + ], +) +async def test_options_addon_running( + hass, + client, + supervisor, + integration, + addon_running, + addon_options, + set_addon_options, + restart_addon, + get_addon_discovery_info, + discovery_info, + entry_data, + old_addon_options, + new_addon_options, + disconnect_calls, +): + """Test options flow and add-on already running on Supervisor.""" + addon_options.update(old_addon_options) + entry = integration + entry.unique_id = 1234 + data = {**entry.data, **entry_data} + hass.config_entries.async_update_entry(entry, data=data) + + assert entry.data["url"] == "ws://test.org" + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + new_addon_options, + ) + + new_addon_options["device"] = new_addon_options.pop("usb_path") + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": new_addon_options}, + ) + assert client.disconnect.call_count == disconnect_calls + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert restart_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "create_entry" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["use_addon"] is True + assert entry.data["integration_created_addon"] is False + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + "discovery_info, entry_data, old_addon_options, new_addon_options", + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + {}, + { + "device": "/test", + "network_key": "abc123", + "log_level": "info", + "emulate_hardware": False, + }, + { + "usb_path": "/test", + "network_key": "abc123", + "log_level": "info", + "emulate_hardware": False, + }, + ), + ], +) +async def test_options_addon_running_no_changes( + hass, + client, + supervisor, + integration, + addon_running, + addon_options, + set_addon_options, + restart_addon, + get_addon_discovery_info, + discovery_info, + entry_data, + old_addon_options, + new_addon_options, +): + """Test options flow without changes, and add-on already running on Supervisor.""" + addon_options.update(old_addon_options) + entry = integration + entry.unique_id = 1234 + data = {**entry.data, **entry_data} + hass.config_entries.async_update_entry(entry, data=data) + + assert entry.data["url"] == "ws://test.org" + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + new_addon_options, + ) + await hass.async_block_till_done() + + new_addon_options["device"] = new_addon_options.pop("usb_path") + assert set_addon_options.call_count == 0 + assert restart_addon.call_count == 0 + + assert result["type"] == "create_entry" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["use_addon"] is True + assert entry.data["integration_created_addon"] is False + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1 + + +async def different_device_server_version(*args): + """Return server version for a device with different home id.""" + return VersionInfo( + driver_version="mock-driver-version", + server_version="mock-server-version", + home_id=5678, + min_schema_version=0, + max_schema_version=1, + ) + + +@pytest.mark.parametrize( + "discovery_info, entry_data, old_addon_options, new_addon_options, disconnect_calls, server_version_side_effect", + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + {}, + { + "device": "/test", + "network_key": "abc123", + "log_level": "info", + "emulate_hardware": False, + }, + { + "usb_path": "/new", + "network_key": "new123", + "log_level": "info", + "emulate_hardware": False, + }, + 0, + different_device_server_version, + ), + ], +) +async def test_options_different_device( + hass, + client, + supervisor, + integration, + addon_running, + addon_options, + set_addon_options, + restart_addon, + get_addon_discovery_info, + discovery_info, + entry_data, + old_addon_options, + new_addon_options, + disconnect_calls, + server_version_side_effect, +): + """Test options flow and configuring a different device.""" + addon_options.update(old_addon_options) + entry = integration + entry.unique_id = 1234 + data = {**entry.data, **entry_data} + hass.config_entries.async_update_entry(entry, data=data) + + assert entry.data["url"] == "ws://test.org" + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + new_addon_options, + ) + + assert set_addon_options.call_count == 1 + new_addon_options["device"] = new_addon_options.pop("usb_path") + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": new_addon_options}, + ) + assert client.disconnect.call_count == disconnect_calls + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + assert restart_addon.call_count == 1 + assert restart_addon.call_args == call(hass, "core_zwave_js") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert set_addon_options.call_count == 2 + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": old_addon_options}, + ) + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + assert restart_addon.call_count == 2 + assert restart_addon.call_args == call(hass, "core_zwave_js") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "different_device" + assert entry.data == data + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + "discovery_info, entry_data, old_addon_options, new_addon_options, disconnect_calls, restart_addon_side_effect", + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + {}, + { + "device": "/test", + "network_key": "abc123", + "log_level": "info", + "emulate_hardware": False, + }, + { + "usb_path": "/new", + "network_key": "new123", + "log_level": "info", + "emulate_hardware": False, + }, + 0, + [HassioAPIError(), None], + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + {}, + { + "device": "/test", + "network_key": "abc123", + "log_level": "info", + "emulate_hardware": False, + }, + { + "usb_path": "/new", + "network_key": "new123", + "log_level": "info", + "emulate_hardware": False, + }, + 0, + [ + HassioAPIError(), + HassioAPIError(), + ], + ), + ], +) +async def test_options_addon_restart_failed( + hass, + client, + supervisor, + integration, + addon_running, + addon_options, + set_addon_options, + restart_addon, + get_addon_discovery_info, + discovery_info, + entry_data, + old_addon_options, + new_addon_options, + disconnect_calls, + restart_addon_side_effect, +): + """Test options flow and add-on restart failure.""" + addon_options.update(old_addon_options) + entry = integration + entry.unique_id = 1234 + data = {**entry.data, **entry_data} + hass.config_entries.async_update_entry(entry, data=data) + + assert entry.data["url"] == "ws://test.org" + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + new_addon_options, + ) + + assert set_addon_options.call_count == 1 + new_addon_options["device"] = new_addon_options.pop("usb_path") + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": new_addon_options}, + ) + assert client.disconnect.call_count == disconnect_calls + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + assert restart_addon.call_count == 1 + assert restart_addon.call_args == call(hass, "core_zwave_js") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert set_addon_options.call_count == 2 + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": old_addon_options}, + ) + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + assert restart_addon.call_count == 2 + assert restart_addon.call_args == call(hass, "core_zwave_js") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "addon_start_failed" + assert entry.data == data + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + "discovery_info, entry_data, old_addon_options, new_addon_options, disconnect_calls, server_version_side_effect", + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + {}, + { + "device": "/test", + "network_key": "abc123", + "log_level": "info", + "emulate_hardware": False, + }, + { + "usb_path": "/test", + "network_key": "abc123", + "log_level": "info", + "emulate_hardware": False, + }, + 0, + aiohttp.ClientError("Boom"), + ), + ], +) +async def test_options_addon_running_server_info_failure( + hass, + client, + supervisor, + integration, + addon_running, + addon_options, + set_addon_options, + restart_addon, + get_addon_discovery_info, + discovery_info, + entry_data, + old_addon_options, + new_addon_options, + disconnect_calls, + server_version_side_effect, +): + """Test options flow and add-on already running with server info failure.""" + addon_options.update(old_addon_options) + entry = integration + entry.unique_id = 1234 + data = {**entry.data, **entry_data} + hass.config_entries.async_update_entry(entry, data=data) + + assert entry.data["url"] == "ws://test.org" + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + new_addon_options, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert entry.data == data + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + "discovery_info, entry_data, old_addon_options, new_addon_options, disconnect_calls", + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + {}, + {"device": "/test", "network_key": "abc123"}, + { + "usb_path": "/new", + "network_key": "new123", + "log_level": "info", + "emulate_hardware": False, + }, + 0, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + {"use_addon": True}, + {"device": "/test", "network_key": "abc123"}, + { + "usb_path": "/new", + "network_key": "new123", + "log_level": "info", + "emulate_hardware": False, + }, + 1, + ), + ], +) +async def test_options_addon_not_installed( + hass, + client, + supervisor, + addon_installed, + install_addon, + integration, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, + discovery_info, + entry_data, + old_addon_options, + new_addon_options, + disconnect_calls, +): + """Test options flow and add-on not installed on Supervisor.""" + addon_installed.return_value["version"] = None + addon_options.update(old_addon_options) + entry = integration + entry.unique_id = 1234 + data = {**entry.data, **entry_data} + hass.config_entries.async_update_entry(entry, data=data) + + assert entry.data["url"] == "ws://test.org" + + assert client.connect.call_count == 1 + assert client.disconnect.call_count == 0 + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "progress" + assert result["step_id"] == "install_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + + assert install_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + new_addon_options, + ) + + new_addon_options["device"] = new_addon_options.pop("usb_path") + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": new_addon_options}, + ) + assert client.disconnect.call_count == disconnect_calls + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + + assert start_addon.call_count == 1 + assert start_addon.call_args == call(hass, "core_zwave_js") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == new_addon_options["device"] + assert entry.data["network_key"] == new_addon_options["network_key"] + assert entry.data["use_addon"] is True + assert entry.data["integration_created_addon"] is True + assert client.connect.call_count == 2 + assert client.disconnect.call_count == 1