From f387e833c3bc561c578a02a69fb0046e19a8f14f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 27 Jan 2021 08:56:16 +0100 Subject: [PATCH] Add zwave_js add-on config flow support (#45552) --- homeassistant/components/zwave_js/__init__.py | 18 + .../components/zwave_js/config_flow.py | 320 ++++++- homeassistant/components/zwave_js/const.py | 5 +- .../components/zwave_js/strings.json | 33 +- .../components/zwave_js/translations/en.json | 35 +- tests/components/zwave_js/conftest.py | 23 + tests/components/zwave_js/test_config_flow.py | 853 +++++++++++++++++- tests/components/zwave_js/test_init.py | 85 ++ 8 files changed, 1320 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index debd688671c..4cf5a50460e 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -6,6 +6,7 @@ from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.node import Node as ZwaveNode +from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -16,6 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .api import async_register_api from .const import ( + CONF_INTEGRATION_CREATED_ADDON, DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN, @@ -196,3 +198,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await info[DATA_CLIENT].disconnect() return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON): + return + + try: + await hass.components.hassio.async_stop_addon("core_zwave_js") + except HassioAPIError as err: + LOGGER.error("Failed to stop the Z-Wave JS add-on: %s", err) + return + try: + await hass.components.hassio.async_uninstall_addon("core_zwave_js") + except HassioAPIError as err: + LOGGER.error("Failed to uninstall the Z-Wave JS add-on: %s", err) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 75d7692c858..5faaa02d03d 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Z-Wave JS integration.""" import asyncio import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, cast import aiohttp from async_timeout import timeout @@ -10,14 +10,27 @@ from zwave_js_server.version import VersionInfo, get_server_version from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_URL +from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, NAME # pylint:disable=unused-import +from .const import ( # pylint:disable=unused-import + CONF_INTEGRATION_CREATED_ADDON, + CONF_USE_ADDON, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) +CONF_ADDON_DEVICE = "device" +CONF_ADDON_NETWORK_KEY = "network_key" +CONF_NETWORK_KEY = "network_key" +CONF_USB_PATH = "usb_path" DEFAULT_URL = "ws://localhost:3000" +TITLE = "Z-Wave JS" +ADDON_SETUP_TIME = 10 + +ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_URL, default=DEFAULT_URL): str}) @@ -28,11 +41,26 @@ async def validate_input(hass: core.HomeAssistant, user_input: dict) -> VersionI if not ws_address.startswith(("ws://", "wss://")): raise InvalidInput("invalid_ws_url") + try: + return await async_get_version_info(hass, ws_address) + except CannotConnect as err: + raise InvalidInput("cannot_connect") from err + + +async def async_get_version_info( + hass: core.HomeAssistant, ws_address: str +) -> VersionInfo: + """Return Z-Wave JS version info.""" async with timeout(10): try: - return await get_server_version(ws_address, async_get_clientsession(hass)) + version_info: VersionInfo = await get_server_version( + ws_address, async_get_clientsession(hass) + ) except (asyncio.TimeoutError, aiohttp.ClientError) as err: - raise InvalidInput("cannot_connect") from err + _LOGGER.error("Failed to connect to Z-Wave JS server: %s", err) + raise CannotConnect from err + + return version_info class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -41,19 +69,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + def __init__(self) -> None: + """Set up flow instance.""" + self.addon_config: Optional[dict] = None + self.network_key: Optional[str] = None + self.usb_path: Optional[str] = None + self.use_addon = False + self.ws_address: Optional[str] = None + # If we install the add-on we should uninstall it on entry remove. + self.integration_created_addon = False + self.install_task: Optional[asyncio.Task] = None + async def async_step_user( self, user_input: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Handle the initial step.""" + assert self.hass # typing + if self.hass.components.hassio.is_hassio(): + return await self.async_step_on_supervisor() + + return await self.async_step_manual() + + async def async_step_manual( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Handle a manual configuration.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="manual", data_schema=STEP_USER_DATA_SCHEMA ) errors = {} assert self.hass # typing - try: version_info = await validate_input(self.hass, user_input) except InvalidInput as err: @@ -62,14 +110,268 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(version_info.home_id) + await self.async_set_unique_id( + version_info.home_id, raise_on_progress=False + ) self._abort_if_unique_id_configured(user_input) - return self.async_create_entry(title=NAME, data=user_input) + self.ws_address = user_input[CONF_URL] + return self._async_create_entry_from_vars() return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="manual", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_hassio( # type: ignore + self, discovery_info: Dict[str, Any] + ) -> Dict[str, Any]: + """Receive configuration from add-on discovery info. + + This flow is triggered by the Z-Wave JS add-on. + """ + assert self.hass + self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" + try: + version_info = await async_get_version_info(self.hass, self.ws_address) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(version_info.home_id) + self._abort_if_unique_id_configured(updates={CONF_URL: self.ws_address}) + + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Confirm the add-on discovery.""" + if user_input is not None: + return await self.async_step_on_supervisor( + user_input={CONF_USE_ADDON: True} + ) + + return self.async_show_form(step_id="hassio_confirm") + + def _async_create_entry_from_vars(self) -> Dict[str, Any]: + """Return a config entry for the flow.""" + return self.async_create_entry( + title=TITLE, + data={ + CONF_URL: self.ws_address, + CONF_USB_PATH: self.usb_path, + CONF_NETWORK_KEY: self.network_key, + CONF_USE_ADDON: self.use_addon, + CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, + }, + ) + + async def async_step_on_supervisor( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Handle logic when on Supervisor host.""" + if user_input is None: + return self.async_show_form( + step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA + ) + if not user_input[CONF_USE_ADDON]: + return await self.async_step_manual() + + self.use_addon = True + + if await self._async_is_addon_running(): + discovery_info = await self._async_get_addon_discovery_info() + self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" + + if not self.unique_id: + assert self.hass + try: + version_info = await async_get_version_info( + self.hass, self.ws_address + ) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id( + version_info.home_id, raise_on_progress=False + ) + + self._abort_if_unique_id_configured() + addon_config = await self._async_get_addon_config() + self.usb_path = addon_config[CONF_ADDON_DEVICE] + self.network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, "") + return self._async_create_entry_from_vars() + + if await self._async_is_addon_installed(): + return await self.async_step_start_addon() + + return await self.async_step_install_addon() + + async def async_step_install_addon( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Install Z-Wave JS add-on.""" + assert self.hass + if not self.install_task: + self.install_task = self.hass.async_create_task(self._async_install_addon()) + return self.async_show_progress( + step_id="install_addon", progress_action="install_addon" + ) + + assert self.hass + try: + await self.install_task + except self.hass.components.hassio.HassioAPIError as err: + _LOGGER.error("Failed to install Z-Wave JS add-on: %s", err) + return self.async_show_progress_done(next_step_id="install_failed") + + self.integration_created_addon = True + + return self.async_show_progress_done(next_step_id="start_addon") + + async def async_step_install_failed( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Add-on installation failed.""" + return self.async_abort(reason="addon_install_failed") + + async def async_step_start_addon( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Ask for config and start Z-Wave JS add-on.""" + if self.addon_config is None: + self.addon_config = await self._async_get_addon_config() + + errors = {} + + 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 = { + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_NETWORK_KEY: self.network_key, + } + + if new_addon_config != self.addon_config: + await self._async_set_addon_config(new_addon_config) + + assert self.hass + try: + await self.hass.components.hassio.async_start_addon("core_zwave_js") + except self.hass.components.hassio.HassioAPIError as err: + _LOGGER.error("Failed to start Z-Wave JS add-on: %s", err) + errors["base"] = "addon_start_failed" + else: + # Sleep some seconds to let the add-on start properly before connecting. + await asyncio.sleep(ADDON_SETUP_TIME) + discovery_info = await self._async_get_addon_discovery_info() + self.ws_address = ( + f"ws://{discovery_info['host']}:{discovery_info['port']}" + ) + + if not self.unique_id: + try: + version_info = await async_get_version_info( + self.hass, self.ws_address + ) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id( + version_info.home_id, raise_on_progress=False + ) + + self._abort_if_unique_id_configured() + return self._async_create_entry_from_vars() + + usb_path = self.addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + network_key = self.addon_config.get( + CONF_ADDON_NETWORK_KEY, self.network_key or "" + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH, default=usb_path): str, + vol.Optional(CONF_NETWORK_KEY, default=network_key): str, + } + ) + + return self.async_show_form( + step_id="start_addon", data_schema=data_schema, errors=errors + ) + + async def _async_get_addon_info(self) -> dict: + """Return and cache Z-Wave JS add-on info.""" + assert self.hass + try: + addon_info: dict = await self.hass.components.hassio.async_get_addon_info( + "core_zwave_js" + ) + except self.hass.components.hassio.HassioAPIError as err: + _LOGGER.error("Failed to get Z-Wave JS add-on info: %s", err) + raise AbortFlow("addon_info_failed") from err + + return addon_info + + async def _async_is_addon_running(self) -> bool: + """Return True if Z-Wave JS add-on is running.""" + addon_info = await self._async_get_addon_info() + return bool(addon_info["state"] == "started") + + async def _async_is_addon_installed(self) -> bool: + """Return True if Z-Wave JS add-on is installed.""" + addon_info = await self._async_get_addon_info() + return addon_info["version"] is not None + + async def _async_get_addon_config(self) -> dict: + """Get Z-Wave JS add-on config.""" + addon_info = await self._async_get_addon_info() + return cast(dict, addon_info["options"]) + + async def _async_set_addon_config(self, config: dict) -> None: + """Set Z-Wave JS add-on config.""" + assert self.hass + options = {"options": config} + try: + await self.hass.components.hassio.async_set_addon_options( + "core_zwave_js", options + ) + except self.hass.components.hassio.HassioAPIError as err: + _LOGGER.error("Failed to set Z-Wave JS add-on config: %s", err) + raise AbortFlow("addon_set_config_failed") from err + + async def _async_install_addon(self) -> None: + """Install the Z-Wave JS add-on.""" + assert self.hass + try: + await self.hass.components.hassio.async_install_addon("core_zwave_js") + finally: + # Continue the flow after show progress when the task is done. + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + async def _async_get_addon_discovery_info(self) -> dict: + """Return add-on discovery info.""" + assert self.hass + try: + discovery_info: dict = ( + await self.hass.components.hassio.async_get_addon_discovery_info( + "core_zwave_js" + ) + ) + except self.hass.components.hassio.HassioAPIError as err: + _LOGGER.error("Failed to get Z-Wave JS add-on discovery info: %s", err) + raise AbortFlow("addon_get_discovery_info_failed") from err + + if not discovery_info: + _LOGGER.error("Failed to get Z-Wave JS add-on discovery info") + raise AbortFlow("addon_missing_discovery_info") + + discovery_info_config: dict = discovery_info["config"] + return discovery_info_config + + +class CannotConnect(exceptions.HomeAssistantError): + """Indicate connection error.""" + class InvalidInput(exceptions.HomeAssistantError): """Error to indicate input data is invalid.""" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 248553648e8..526a8429bd4 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,8 +1,7 @@ """Constants for the Z-Wave JS integration.""" - - +CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" +CONF_USE_ADDON = "use_addon" DOMAIN = "zwave_js" -NAME = "Z-Wave JS" PLATFORMS = [ "binary_sensor", "climate", diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 29136a03f48..212bef70889 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -2,19 +2,48 @@ "title": "Z-Wave JS", "config": { "step": { - "user": { + "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" + }, + "start_addon": { + "title": "Enter the Z-Wave JS add-on configuration", + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]", + "network_key": "Network Key" + } + }, + "hassio_confirm": { + "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" } }, "error": { + "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", "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%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "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_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", + "addon_missing_discovery_info": "Missing Z-Wave JS add-on discovery info.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "progress": { + "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes." } } } diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 13b2e736bae..4aa510df6be 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -1,18 +1,49 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "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_missing_discovery_info": "Missing Z-Wave JS add-on discovery info.", + "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect" }, "error": { + "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", "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." + }, "step": { - "user": { + "hassio_confirm": { + "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" + }, + "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": { + "data": { + "network_key": "Network Key", + "usb_path": "USB Device Path" + }, + "title": "Enter the Z-Wave JS add-on configuration" } } }, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 7774037b480..470b4c0227b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -21,6 +21,29 @@ async def device_registry_fixture(hass): return await async_get_device_registry(hass) +@pytest.fixture(name="discovery_info") +def discovery_info_fixture(): + """Return the discovery info from the supervisor.""" + return DEFAULT + + +@pytest.fixture(name="discovery_info_side_effect") +def discovery_info_side_effect_fixture(): + """Return the discovery info from the supervisor.""" + return None + + +@pytest.fixture(name="get_addon_discovery_info") +def mock_get_addon_discovery_info(discovery_info, discovery_info_side_effect): + """Mock get add-on discovery info.""" + with patch( + "homeassistant.components.hassio.async_get_addon_discovery_info", + side_effect=discovery_info_side_effect, + return_value=discovery_info, + ) as get_addon_discovery_info: + yield get_addon_discovery_info + + @pytest.fixture(name="controller_state", scope="session") def controller_state_fixture(): """Load the controller state fixture data.""" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index c6885377490..0270383174e 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2,14 +2,141 @@ import asyncio from unittest.mock import patch +import pytest from zwave_js_server.version import VersionInfo from homeassistant import config_entries, setup +from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.zwave_js.config_flow import TITLE from homeassistant.components.zwave_js.const import DOMAIN +from tests.common import MockConfigEntry -async def test_user_step_full(hass): - """Test we create an entry with user step.""" +ADDON_DISCOVERY_INFO = { + "addon": "Z-Wave JS", + "host": "host1", + "port": 3001, +} + + +@pytest.fixture(name="supervisor") +def mock_supervisor_fixture(): + """Mock Supervisor.""" + with patch("homeassistant.components.hassio.is_hassio", return_value=True): + yield + + +@pytest.fixture(name="addon_info_side_effect") +def addon_info_side_effect_fixture(): + """Return the add-on info side effect.""" + return None + + +@pytest.fixture(name="addon_info") +def mock_addon_info(addon_info_side_effect): + """Mock Supervisor add-on info.""" + with patch( + "homeassistant.components.hassio.async_get_addon_info", + side_effect=addon_info_side_effect, + ) as addon_info: + addon_info.return_value = {} + yield addon_info + + +@pytest.fixture(name="addon_running") +def mock_addon_running(addon_info): + """Mock add-on already running.""" + addon_info.return_value["state"] = "started" + return addon_info + + +@pytest.fixture(name="addon_installed") +def mock_addon_installed(addon_info): + """Mock add-on already installed but not running.""" + addon_info.return_value["state"] = "stopped" + addon_info.return_value["version"] = "1.0" + return addon_info + + +@pytest.fixture(name="addon_options") +def mock_addon_options(addon_info): + """Mock add-on options.""" + addon_info.return_value["options"] = {} + return addon_info.return_value["options"] + + +@pytest.fixture(name="set_addon_options_side_effect") +def set_addon_options_side_effect_fixture(): + """Return the set add-on options side effect.""" + return None + + +@pytest.fixture(name="set_addon_options") +def mock_set_addon_options(set_addon_options_side_effect): + """Mock set add-on options.""" + with patch( + "homeassistant.components.hassio.async_set_addon_options", + side_effect=set_addon_options_side_effect, + ) as set_options: + yield set_options + + +@pytest.fixture(name="install_addon") +def mock_install_addon(): + """Mock install add-on.""" + with patch("homeassistant.components.hassio.async_install_addon") as install_addon: + yield install_addon + + +@pytest.fixture(name="start_addon_side_effect") +def start_addon_side_effect_fixture(): + """Return the set add-on options side effect.""" + return None + + +@pytest.fixture(name="start_addon") +def mock_start_addon(start_addon_side_effect): + """Mock start add-on.""" + with patch( + "homeassistant.components.hassio.async_start_addon", + side_effect=start_addon_side_effect, + ) as start_addon: + yield start_addon + + +@pytest.fixture(name="server_version_side_effect") +def server_version_side_effect_fixture(): + """Return the server version side effect.""" + return None + + +@pytest.fixture(name="get_server_version", autouse=True) +def mock_get_server_version(server_version_side_effect): + """Mock server version.""" + version_info = VersionInfo( + driver_version="mock-driver-version", + server_version="mock-server-version", + home_id=1234, + ) + with patch( + "homeassistant.components.zwave_js.config_flow.get_server_version", + side_effect=server_version_side_effect, + return_value=version_info, + ) as mock_version: + yield mock_version + + +@pytest.fixture(name="addon_setup_time", autouse=True) +def mock_addon_setup_time(): + """Mock add-on setup sleep time.""" + with patch( + "homeassistant.components.zwave_js.config_flow.ADDON_SETUP_TIME", new=0 + ) as addon_setup_time: + yield addon_setup_time + + +async def test_manual(hass): + """Test we create an entry with manual step.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -17,13 +144,6 @@ async def test_user_step_full(hass): assert result["type"] == "form" with patch( - "homeassistant.components.zwave_js.config_flow.get_server_version", - return_value=VersionInfo( - driver_version="mock-driver-version", - server_version="mock-server-version", - home_id=1234, - ), - ), patch( "homeassistant.components.zwave_js.async_setup", return_value=True ) as mock_setup, patch( "homeassistant.components.zwave_js.async_setup_entry", @@ -41,59 +161,720 @@ async def test_user_step_full(hass): assert result2["title"] == "Z-Wave JS" assert result2["data"] == { "url": "ws://localhost:3000", + "usb_path": None, + "network_key": None, + "use_addon": False, + "integration_created_addon": False, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == 1234 -async def test_user_step_invalid_input(hass): - """Test we handle invalid auth in the user step.""" +@pytest.mark.parametrize( + "url, server_version_side_effect, error", + [ + ( + "not-ws-url", + None, + "invalid_ws_url", + ), + ( + "ws://localhost:3000", + asyncio.TimeoutError, + "cannot_connect", + ), + ( + "ws://localhost:3000", + Exception("Boom"), + "unknown", + ), + ], +) +async def test_manual_errors( + hass, + url, + error, +): + """Test all errors with a manual set up.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.zwave_js.config_flow.get_server_version", - side_effect=asyncio.TimeoutError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "url": "ws://localhost:3000", - }, - ) + assert result["type"] == "form" + assert result["step_id"] == "manual" - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "url": "not-ws-url", + "url": url, }, ) - assert result3["type"] == "form" - assert result3["errors"] == {"base": "invalid_ws_url"} + assert result["type"] == "form" + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error} -async def test_user_step_unexpected_exception(hass): - """Test we handle unexpected exception.""" +async def test_manual_already_configured(hass): + """Test that only one unique instance is allowed.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234) + entry.add_to_hass(hass) + + await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:3000", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_supervisor_discovery( + hass, supervisor, addon_running, addon_options, get_addon_discovery_info +): + """Test flow started from Supervisor discovery.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + addon_options["device"] = "/test" + addon_options["network_key"] = "abc123" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + with patch( - "homeassistant.components.zwave_js.config_flow.get_server_version", - side_effect=Exception("Boom"), - ): - result2 = await hass.config_entries.flow.async_configure( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "network_key": "abc123", + "use_addon": True, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "discovery_info, server_version_side_effect", + [({"config": ADDON_DISCOVERY_INFO}, asyncio.TimeoutError())], +) +async def test_supervisor_discovery_cannot_connect( + hass, supervisor, get_addon_discovery_info +): + """Test Supervisor discovery and cannot connect.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_clean_discovery_on_user_create( + hass, supervisor, addon_running, addon_options, get_addon_discovery_info +): + """Test discovery flow is cleaned up when a user flow is finished.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + addon_options["device"] = "/test" + addon_options["network_key"] = "abc123" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": False} + ) + + assert result["type"] == "form" + assert result["step_id"] == "manual" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "url": "ws://localhost:3000", }, ) + await hass.async_block_till_done() - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} + assert len(hass.config_entries.flow.async_progress()) == 0 + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://localhost:3000", + "usb_path": None, + "network_key": None, + "use_addon": False, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_discovery_with_existing_entry( + hass, supervisor, addon_running, addon_options +): + """Test discovery flow is aborted if an entry already exists.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry( + domain=DOMAIN, data={"url": "ws://localhost:3000"}, title=TITLE, unique_id=1234 + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + # Assert that the entry data is updated with discovery info. + assert entry.data["url"] == "ws://host1:3001" + + +async def test_discovery_addon_not_running( + hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon +): + """Test discovery with add-on already installed but not running.""" + addon_options["device"] = None + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["step_id"] == "hassio_confirm" + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["step_id"] == "start_addon" + assert result["type"] == "form" + + +async def test_discovery_addon_not_installed( + hass, supervisor, addon_installed, install_addon, addon_options +): + """Test discovery with add-on not installed.""" + addon_installed.return_value["version"] = None + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["step_id"] == "hassio_confirm" + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["step_id"] == "install_addon" + assert result["type"] == "progress" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == "form" + assert result["step_id"] == "start_addon" + + +async def test_not_addon(hass, supervisor): + """Test opting out of add-on on Supervisor.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": False} + ) + + assert result["type"] == "form" + assert result["step_id"] == "manual" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:3000", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://localhost:3000", + "usb_path": None, + "network_key": None, + "use_addon": False, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_addon_running( + hass, + supervisor, + addon_running, + addon_options, + get_addon_discovery_info, +): + """Test add-on already running on Supervisor.""" + addon_options["device"] = "/test" + addon_options["network_key"] = "abc123" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "network_key": "abc123", + "use_addon": True, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "discovery_info, discovery_info_side_effect, server_version_side_effect, " + "addon_info_side_effect, abort_reason", + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + HassioAPIError(), + None, + None, + "addon_get_discovery_info_failed", + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + asyncio.TimeoutError, + None, + "cannot_connect", + ), + ( + None, + None, + None, + None, + "addon_missing_discovery_info", + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + None, + HassioAPIError(), + "addon_info_failed", + ), + ], +) +async def test_addon_running_failures( + hass, + supervisor, + addon_running, + get_addon_discovery_info, + abort_reason, +): + """Test all failures when add-on is running.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "abort" + assert result["reason"] == abort_reason + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_addon_running_already_configured( + hass, supervisor, addon_running, get_addon_discovery_info +): + """Test that only one unique instance is allowed when add-on is running.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234) + entry.add_to_hass(hass) + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_addon_installed( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test add-on already installed but not running on Supervisor.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "start_addon" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "network_key": "abc123", + "use_addon": True, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "discovery_info, start_addon_side_effect", + [({"config": ADDON_DISCOVERY_INFO}, HassioAPIError())], +) +async def test_addon_installed_start_failure( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test add-on start failure when add-on is installed.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "start_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "addon_start_failed"} + + +@pytest.mark.parametrize( + "set_addon_options_side_effect, start_addon_side_effect, discovery_info, " + "server_version_side_effect, abort_reason", + [ + ( + HassioAPIError(), + None, + {"config": ADDON_DISCOVERY_INFO}, + None, + "addon_set_config_failed", + ), + ( + None, + None, + {"config": ADDON_DISCOVERY_INFO}, + asyncio.TimeoutError, + "cannot_connect", + ), + ( + None, + None, + None, + None, + "addon_missing_discovery_info", + ), + ], +) +async def test_addon_installed_failures( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, + abort_reason, +): + """Test all failures when add-on is installed.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "start_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "abort" + assert result["reason"] == abort_reason + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_addon_installed_already_configured( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test that only one unique instance is allowed when add-on is installed.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234) + entry.add_to_hass(hass) + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "start_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_addon_not_installed( + hass, + supervisor, + addon_installed, + install_addon, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test add-on not installed.""" + addon_installed.return_value["version"] = None + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "progress" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == "form" + assert result["step_id"] == "start_addon" + + with patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test", + "network_key": "abc123", + "use_addon": True, + "integration_created_addon": True, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_install_addon_failure(hass, supervisor, addon_installed, install_addon): + """Test add-on install failure.""" + addon_installed.return_value["version"] = None + install_addon.side_effect = HassioAPIError() + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "progress" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == "abort" + assert result["reason"] == "addon_install_failed" diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 46b75331379..b17945d05c6 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import patch import pytest from zwave_js_server.model.node import Node +from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.config_entries import ( + CONN_CLASS_LOCAL_PUSH, ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED, ENTRY_STATE_SETUP_RETRY, @@ -25,6 +27,22 @@ def connect_timeout_fixture(): yield timeout +@pytest.fixture(name="stop_addon") +def stop_addon_fixture(): + """Mock stop add-on.""" + with patch("homeassistant.components.hassio.async_stop_addon") as stop_addon: + yield stop_addon + + +@pytest.fixture(name="uninstall_addon") +def uninstall_addon_fixture(): + """Mock uninstall add-on.""" + with patch( + "homeassistant.components.hassio.async_uninstall_addon" + ) as uninstall_addon: + yield uninstall_addon + + async def test_entry_setup_unload(hass, client, integration): """Test the integration set up and unload.""" entry = integration @@ -205,3 +223,70 @@ async def test_existing_node_not_ready(hass, client, multisensor_6, device_regis assert device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id)} ) + + +async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): + """Test remove the config entry.""" + # test successful remove without created add-on + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"integration_created_addon": False}, + ) + entry.add_to_hass(hass) + assert entry.state == ENTRY_STATE_NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + await hass.config_entries.async_remove(entry.entry_id) + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + # test successful remove with created add-on + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"integration_created_addon": True}, + ) + entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + await hass.config_entries.async_remove(entry.entry_id) + + assert stop_addon.call_count == 1 + assert uninstall_addon.call_count == 1 + assert entry.state == ENTRY_STATE_NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + stop_addon.reset_mock() + uninstall_addon.reset_mock() + + # test add-on stop failure + entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + stop_addon.side_effect = HassioAPIError() + + await hass.config_entries.async_remove(entry.entry_id) + + assert stop_addon.call_count == 1 + assert uninstall_addon.call_count == 0 + assert entry.state == ENTRY_STATE_NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert "Failed to stop the Z-Wave JS add-on" in caplog.text + stop_addon.side_effect = None + stop_addon.reset_mock() + uninstall_addon.reset_mock() + + # test add-on uninstall failure + entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + uninstall_addon.side_effect = HassioAPIError() + + await hass.config_entries.async_remove(entry.entry_id) + + assert stop_addon.call_count == 1 + assert uninstall_addon.call_count == 1 + assert entry.state == ENTRY_STATE_NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text