diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4e9a78e75a8..c09927fa7d2 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -183,6 +183,18 @@ async def async_uninstall_addon(hass: HomeAssistantType, slug: str) -> dict: return await hassio.send_command(command, timeout=60) +@bind_hass +@api_data +async def async_update_addon(hass: HomeAssistantType, slug: str) -> dict: + """Update add-on. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = f"/addons/{slug}/update" + return await hassio.send_command(command, timeout=None) + + @bind_hass @api_data async def async_start_addon(hass: HomeAssistantType, slug: str) -> dict: @@ -232,6 +244,21 @@ async def async_get_addon_discovery_info( return next((addon for addon in discovered_addons if addon["addon"] == slug), None) +@bind_hass +@api_data +async def async_create_snapshot( + hass: HomeAssistantType, payload: dict, partial: bool = False +) -> dict: + """Create a full or partial snapshot. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + snapshot_type = "partial" if partial else "full" + command = f"/snapshots/new/{snapshot_type}" + return await hassio.send_command(command, payload=payload, timeout=None) + + @callback @bind_hass def get_info(hass): diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 8272bfdac2c..0c5a55c25fb 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -1,16 +1,14 @@ """The Z-Wave JS integration.""" import asyncio -import logging from typing import Callable, List from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.notification import Notification from zwave_js_server.model.value import ValueNotification -from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DOMAIN, CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -19,9 +17,9 @@ from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from .addon import AddonError, AddonManager, get_addon_manager from .api import async_register_api from .const import ( - ADDON_SLUG, ATTR_COMMAND_CLASS, ATTR_COMMAND_CLASS_NAME, ATTR_DEVICE_ID, @@ -38,10 +36,14 @@ from .const import ( ATTR_VALUE, ATTR_VALUE_RAW, CONF_INTEGRATION_CREATED_ADDON, + CONF_NETWORK_KEY, + CONF_USB_PATH, + CONF_USE_ADDON, DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LOGGER, PLATFORMS, ZWAVE_JS_EVENT, ) @@ -49,10 +51,11 @@ from .discovery import async_discover_values from .helpers import get_device_id, get_old_value_id, get_unique_id from .services import ZWaveServices -LOGGER = logging.getLogger(__package__) CONNECT_TIMEOUT = 10 DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_START_PLATFORM_TASK = "start_platform_task" +DATA_CONNECT_FAILED_LOGGED = "connect_failed_logged" +DATA_INVALID_SERVER_VERSION_LOGGED = "invalid_server_version_logged" async def async_setup(hass: HomeAssistant, config: dict) -> bool: @@ -87,6 +90,10 @@ def register_node_in_dev_reg( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Z-Wave JS from a config entry.""" + use_addon = entry.data.get(CONF_USE_ADDON) + if use_addon: + await async_ensure_addon_running(hass, entry) + client = ZwaveClient(entry.data[CONF_URL], async_get_clientsession(hass)) dev_reg = await device_registry.async_get_registry(hass) ent_reg = entity_registry.async_get(hass) @@ -257,21 +264,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: }, ) + entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {}) # connect and throw error if connection failed try: async with timeout(CONNECT_TIMEOUT): await client.connect() + except InvalidServerVersion as err: + if not entry_hass_data.get(DATA_INVALID_SERVER_VERSION_LOGGED): + LOGGER.error("Invalid server version: %s", err) + entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = True + if use_addon: + async_ensure_addon_updated(hass) + raise ConfigEntryNotReady from err except (asyncio.TimeoutError, BaseZwaveJSServerError) as err: - LOGGER.error("Failed to connect: %s", err) + if not entry_hass_data.get(DATA_CONNECT_FAILED_LOGGED): + LOGGER.error("Failed to connect: %s", err) + entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = True raise ConfigEntryNotReady from err else: LOGGER.info("Connected to Zwave JS Server") + entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = False + entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = False unsubscribe_callbacks: List[Callable] = [] - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: client, - DATA_UNSUBSCRIBE: unsubscribe_callbacks, - } + entry_hass_data[DATA_CLIENT] = client + entry_hass_data[DATA_UNSUBSCRIBE] = unsubscribe_callbacks services = ZWaveServices(hass, ent_reg) services.async_register() @@ -298,7 +315,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: listen_task = asyncio.create_task( client_listen(hass, entry, client, driver_ready) ) - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT_LISTEN_TASK] = listen_task + entry_hass_data[DATA_CLIENT_LISTEN_TASK] = listen_task unsubscribe_callbacks.append( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) ) @@ -340,7 +357,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) platform_task = hass.async_create_task(start_platforms()) - hass.data[DOMAIN][entry.entry_id][DATA_START_PLATFORM_TASK] = platform_task + entry_hass_data[DATA_START_PLATFORM_TASK] = platform_task return True @@ -416,6 +433,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: platform_task=info[DATA_START_PLATFORM_TASK], ) + if entry.data.get(CONF_USE_ADDON) and entry.disabled_by: + addon_manager: AddonManager = get_addon_manager(hass) + LOGGER.debug("Stopping Z-Wave JS add-on") + try: + await addon_manager.async_stop_addon() + except AddonError as err: + LOGGER.error("Failed to stop the Z-Wave JS add-on: %s", err) + return False + return True @@ -424,12 +450,51 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON): return + addon_manager: AddonManager = get_addon_manager(hass) try: - await hass.components.hassio.async_stop_addon(ADDON_SLUG) - except HassioAPIError as err: - LOGGER.error("Failed to stop the Z-Wave JS add-on: %s", err) + await addon_manager.async_stop_addon() + except AddonError as err: + LOGGER.error(err) return try: - await hass.components.hassio.async_uninstall_addon(ADDON_SLUG) - except HassioAPIError as err: - LOGGER.error("Failed to uninstall the Z-Wave JS add-on: %s", err) + await addon_manager.async_create_snapshot() + except AddonError as err: + LOGGER.error(err) + return + try: + await addon_manager.async_uninstall_addon() + except AddonError as err: + LOGGER.error(err) + + +async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Ensure that Z-Wave JS add-on is installed and running.""" + addon_manager: AddonManager = get_addon_manager(hass) + if addon_manager.task_in_progress(): + raise ConfigEntryNotReady + try: + addon_is_installed = await addon_manager.async_is_addon_installed() + addon_is_running = await addon_manager.async_is_addon_running() + except AddonError as err: + LOGGER.error("Failed to get the Z-Wave JS add-on info") + raise ConfigEntryNotReady from err + + usb_path: str = entry.data[CONF_USB_PATH] + network_key: str = entry.data[CONF_NETWORK_KEY] + + if not addon_is_installed: + addon_manager.async_schedule_install_addon(usb_path, network_key) + raise ConfigEntryNotReady + + if not addon_is_running: + addon_manager.async_schedule_setup_addon(usb_path, network_key) + raise ConfigEntryNotReady + + +@callback +def async_ensure_addon_updated(hass: HomeAssistant) -> None: + """Ensure that Z-Wave JS add-on is updated and running.""" + addon_manager: AddonManager = get_addon_manager(hass) + if addon_manager.task_in_progress(): + raise ConfigEntryNotReady + addon_manager.async_schedule_update_addon() diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py new file mode 100644 index 00000000000..54169dcaf94 --- /dev/null +++ b/homeassistant/components/zwave_js/addon.py @@ -0,0 +1,246 @@ +"""Provide add-on management.""" +from __future__ import annotations + +import asyncio +from functools import partial +from typing import Any, Callable, Optional, TypeVar, cast + +from homeassistant.components.hassio import ( + async_create_snapshot, + async_get_addon_discovery_info, + async_get_addon_info, + async_install_addon, + async_set_addon_options, + async_start_addon, + async_stop_addon, + async_uninstall_addon, + async_update_addon, +) +from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.singleton import singleton + +from .const import ADDON_SLUG, CONF_ADDON_DEVICE, CONF_ADDON_NETWORK_KEY, DOMAIN, LOGGER + +F = TypeVar("F", bound=Callable[..., Any]) # pylint: disable=invalid-name + +DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager" + + +@singleton(DATA_ADDON_MANAGER) +@callback +def get_addon_manager(hass: HomeAssistant) -> AddonManager: + """Get the add-on manager.""" + return AddonManager(hass) + + +def api_error(error_message: str) -> Callable[[F], F]: + """Handle HassioAPIError and raise a specific AddonError.""" + + def handle_hassio_api_error(func: F) -> F: + """Handle a HassioAPIError.""" + + async def wrapper(*args, **kwargs): # type: ignore + """Wrap an add-on manager method.""" + try: + return_value = await func(*args, **kwargs) + except HassioAPIError as err: + raise AddonError(error_message) from err + + return return_value + + return cast(F, wrapper) + + return handle_hassio_api_error + + +class AddonManager: + """Manage the add-on. + + Methods may raise AddonError. + Only one instance of this class may exist + to keep track of running add-on tasks. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Set up the add-on manager.""" + self._hass = hass + self._install_task: Optional[asyncio.Task] = None + self._update_task: Optional[asyncio.Task] = None + self._setup_task: Optional[asyncio.Task] = None + + def task_in_progress(self) -> bool: + """Return True if any of the add-on tasks are in progress.""" + return any( + task and not task.done() + for task in ( + self._install_task, + self._setup_task, + self._update_task, + ) + ) + + @api_error("Failed to get Z-Wave JS add-on discovery info") + async def async_get_addon_discovery_info(self) -> dict: + """Return add-on discovery info.""" + discovery_info = await async_get_addon_discovery_info(self._hass, ADDON_SLUG) + + if not discovery_info: + raise AddonError("Failed to get Z-Wave JS add-on discovery info") + + discovery_info_config: dict = discovery_info["config"] + return discovery_info_config + + @api_error("Failed to get the Z-Wave JS add-on info") + async def async_get_addon_info(self) -> dict: + """Return and cache Z-Wave JS add-on info.""" + addon_info: dict = await async_get_addon_info(self._hass, ADDON_SLUG) + 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_options(self) -> dict: + """Get Z-Wave JS add-on options.""" + addon_info = await self.async_get_addon_info() + return cast(dict, addon_info["options"]) + + @api_error("Failed to set the Z-Wave JS add-on options") + async def async_set_addon_options(self, config: dict) -> None: + """Set Z-Wave JS add-on options.""" + options = {"options": config} + await async_set_addon_options(self._hass, ADDON_SLUG, options) + + @api_error("Failed to install the Z-Wave JS add-on") + async def async_install_addon(self) -> None: + """Install the Z-Wave JS add-on.""" + await async_install_addon(self._hass, ADDON_SLUG) + + @callback + def async_schedule_install_addon( + self, usb_path: str, network_key: str + ) -> asyncio.Task: + """Schedule a task that installs and sets up the Z-Wave JS add-on. + + Only schedule a new install task if the there's no running task. + """ + if not self._install_task or self._install_task.done(): + LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") + self._install_task = self._async_schedule_addon_operation( + self.async_install_addon, + partial(self.async_setup_addon, usb_path, network_key), + ) + return self._install_task + + @api_error("Failed to uninstall the Z-Wave JS add-on") + async def async_uninstall_addon(self) -> None: + """Uninstall the Z-Wave JS add-on.""" + await async_uninstall_addon(self._hass, ADDON_SLUG) + + @api_error("Failed to update the Z-Wave JS add-on") + async def async_update_addon(self) -> None: + """Update the Z-Wave JS add-on if needed.""" + addon_info = await self.async_get_addon_info() + addon_version = addon_info["version"] + update_available = addon_info["update_available"] + + if addon_version is None: + raise AddonError("Z-Wave JS add-on is not installed") + + if not update_available: + return + + await async_update_addon(self._hass, ADDON_SLUG) + + @callback + def async_schedule_update_addon(self) -> asyncio.Task: + """Schedule a task that updates and sets up the Z-Wave JS add-on. + + Only schedule a new update task if the there's no running task. + """ + if not self._update_task or self._update_task.done(): + LOGGER.info("Trying to update the Z-Wave JS add-on") + self._update_task = self._async_schedule_addon_operation( + self.async_create_snapshot, self.async_update_addon + ) + return self._update_task + + @api_error("Failed to start the Z-Wave JS add-on") + async def async_start_addon(self) -> None: + """Start the Z-Wave JS add-on.""" + await async_start_addon(self._hass, ADDON_SLUG) + + @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.""" + await async_stop_addon(self._hass, ADDON_SLUG) + + async def async_setup_addon(self, usb_path: str, network_key: str) -> None: + """Configure and start Z-Wave JS add-on.""" + addon_options = await self.async_get_addon_options() + + new_addon_options = { + CONF_ADDON_DEVICE: usb_path, + CONF_ADDON_NETWORK_KEY: network_key, + } + + if new_addon_options != addon_options: + await self.async_set_addon_options(new_addon_options) + + await self.async_start_addon() + + @callback + def async_schedule_setup_addon( + self, usb_path: str, network_key: str + ) -> asyncio.Task: + """Schedule a task that configures and starts the Z-Wave JS add-on. + + Only schedule a new setup task if the there's no running task. + """ + if not self._setup_task or self._setup_task.done(): + LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") + self._setup_task = self._async_schedule_addon_operation( + partial(self.async_setup_addon, usb_path, network_key) + ) + return self._setup_task + + @api_error("Failed to create a snapshot of the Z-Wave JS add-on.") + async def async_create_snapshot(self) -> None: + """Create a partial snapshot of the Z-Wave JS add-on.""" + addon_info = await self.async_get_addon_info() + addon_version = addon_info["version"] + name = f"addon_{ADDON_SLUG}_{addon_version}" + + LOGGER.debug("Creating snapshot: %s", name) + await async_create_snapshot( + self._hass, + {"name": name, "addons": [ADDON_SLUG]}, + partial=True, + ) + + @callback + def _async_schedule_addon_operation(self, *funcs: Callable) -> asyncio.Task: + """Schedule an add-on task.""" + + async def addon_operation() -> None: + """Do the add-on operation and catch AddonError.""" + for func in funcs: + try: + await func() + except AddonError as err: + LOGGER.error(err) + break + + return self._hass.async_create_task(addon_operation()) + + +class AddonError(HomeAssistantError): + """Represent an error with Z-Wave JS add-on.""" diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index cc19fb85d3a..37923c574b4 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -9,33 +9,25 @@ import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version from homeassistant import config_entries, exceptions -from homeassistant.components.hassio import ( - async_get_addon_discovery_info, - async_get_addon_info, - async_install_addon, - async_set_addon_options, - async_start_addon, - is_hassio, -) -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import is_hassio from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .addon import AddonError, AddonManager, get_addon_manager from .const import ( # pylint:disable=unused-import - ADDON_SLUG, + CONF_ADDON_DEVICE, + CONF_ADDON_NETWORK_KEY, CONF_INTEGRATION_CREATED_ADDON, + CONF_NETWORK_KEY, + CONF_USB_PATH, 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" @@ -180,6 +172,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Handle logic when on Supervisor host.""" + # Only one entry with Supervisor add-on support is allowed. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data.get(CONF_USE_ADDON): + return await self.async_step_manual() + if user_input is None: return self.async_show_form( step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA @@ -212,7 +209,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.install_task - except HassioAPIError as err: + except AddonError 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") @@ -275,7 +272,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.start_task - except (CannotConnect, HassioAPIError) as err: + except (CannotConnect, AddonError) as err: _LOGGER.error("Failed to start Z-Wave JS add-on: %s", err) return self.async_show_progress_done(next_step_id="start_failed") @@ -290,8 +287,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_start_addon(self) -> None: """Start the Z-Wave JS add-on.""" assert self.hass + addon_manager: AddonManager = get_addon_manager(self.hass) try: - await async_start_addon(self.hass, ADDON_SLUG) + await addon_manager.async_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) @@ -345,9 +343,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_get_addon_info(self) -> dict: """Return and cache Z-Wave JS add-on info.""" + addon_manager: AddonManager = get_addon_manager(self.hass) try: - addon_info: dict = await async_get_addon_info(self.hass, ADDON_SLUG) - except HassioAPIError as err: + addon_info: dict = await addon_manager.async_get_addon_info() + except AddonError as err: _LOGGER.error("Failed to get Z-Wave JS add-on info: %s", err) raise AbortFlow("addon_info_failed") from err @@ -371,16 +370,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_set_addon_config(self, config: dict) -> None: """Set Z-Wave JS add-on config.""" options = {"options": config} + addon_manager: AddonManager = get_addon_manager(self.hass) try: - await async_set_addon_options(self.hass, ADDON_SLUG, options) - except HassioAPIError as err: + await addon_manager.async_set_addon_options(options) + except AddonError 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.""" + addon_manager: AddonManager = get_addon_manager(self.hass) try: - await async_install_addon(self.hass, ADDON_SLUG) + await addon_manager.async_install_addon() finally: # Continue the flow after show progress when the task is done. self.hass.async_create_task( @@ -389,17 +390,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" + addon_manager: AddonManager = get_addon_manager(self.hass) try: - discovery_info = await async_get_addon_discovery_info(self.hass, ADDON_SLUG) - except HassioAPIError as err: + discovery_info_config = await addon_manager.async_get_addon_discovery_info() + except AddonError 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 diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 27be45c43a0..ffd6031349a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,5 +1,11 @@ """Constants for the Z-Wave JS integration.""" +import logging + +CONF_ADDON_DEVICE = "device" +CONF_ADDON_NETWORK_KEY = "network_key" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" +CONF_NETWORK_KEY = "network_key" +CONF_USB_PATH = "usb_path" CONF_USE_ADDON = "use_addon" DOMAIN = "zwave_js" PLATFORMS = [ @@ -19,6 +25,8 @@ DATA_UNSUBSCRIBE = "unsubs" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" +LOGGER = logging.getLogger(__package__) + # constants for events ZWAVE_JS_EVENT = f"{DOMAIN}_event" ATTR_NODE_ID = "node_id" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 5d3aa730a7c..eb13ad512e3 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -41,7 +41,6 @@ "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.", - "addon_missing_discovery_info": "Missing Z-Wave JS add-on discovery info.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "progress": { diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 5be980d52cb..101942dc717 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -4,7 +4,6 @@ "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.", "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "already_configured": "Device is already configured", @@ -49,11 +48,6 @@ }, "start_addon": { "title": "The Z-Wave JS add-on is starting." - }, - "user": { - "data": { - "url": "URL" - } } } }, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 72835fb17c1..50cacd97422 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -14,6 +14,124 @@ from homeassistant.helpers.device_registry import async_get as async_get_device_ from tests.common import MockConfigEntry, load_fixture +# Add-on fixtures + + +@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.zwave_js.addon.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.zwave_js.addon.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.zwave_js.addon.async_install_addon" + ) as install_addon: + yield install_addon + + +@pytest.fixture(name="update_addon") +def mock_update_addon(): + """Mock update add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_update_addon" + ) as update_addon: + yield 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 + + +@pytest.fixture(name="start_addon") +def mock_start_addon(start_addon_side_effect): + """Mock start add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.async_start_addon", + side_effect=start_addon_side_effect, + ) as start_addon: + yield start_addon + + +@pytest.fixture(name="stop_addon") +def stop_addon_fixture(): + """Mock stop add-on.""" + with patch( + "homeassistant.components.zwave_js.addon.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.zwave_js.addon.async_uninstall_addon" + ) as uninstall_addon: + yield uninstall_addon + + +@pytest.fixture(name="create_shapshot") +def create_snapshot_fixture(): + """Mock create snapshot.""" + with patch( + "homeassistant.components.zwave_js.addon.async_create_snapshot" + ) as create_shapshot: + yield create_shapshot + @pytest.fixture(name="device_registry") async def device_registry_fixture(hass): diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 08b0ffe3080..fc97f7420cf 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -44,93 +44,13 @@ def discovery_info_side_effect_fixture(): def mock_get_addon_discovery_info(discovery_info, discovery_info_side_effect): """Mock get add-on discovery info.""" with patch( - "homeassistant.components.zwave_js.config_flow.async_get_addon_discovery_info", + "homeassistant.components.zwave_js.addon.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="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.zwave_js.config_flow.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.zwave_js.config_flow.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.zwave_js.config_flow.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.zwave_js.config_flow.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.""" @@ -587,6 +507,49 @@ async def test_not_addon(hass, supervisor): assert len(mock_setup_entry.mock_calls) == 1 +async def test_addon_already_configured(hass, supervisor): + """Test add-on already configured leads to manual step.""" + entry = MockConfigEntry( + domain=DOMAIN, data={"use_addon": True}, title=TITLE, unique_id=5678 + ) + 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" + + 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) == 2 + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running( hass, @@ -654,7 +617,7 @@ async def test_addon_running( None, None, None, - "addon_missing_discovery_info", + "addon_get_discovery_info_failed", ), ( {"config": ADDON_DISCOVERY_INFO}, diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 2a2f249c361..6f60bbc0300 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,9 +1,9 @@ """Test the Z-Wave JS init module.""" from copy import deepcopy -from unittest.mock import patch +from unittest.mock import call, patch import pytest -from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion from zwave_js_server.model.node import Node from homeassistant.components.hassio.handler import HassioAPIError @@ -11,6 +11,7 @@ from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ( CONN_CLASS_LOCAL_PUSH, + DISABLED_USER, ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED, ENTRY_STATE_SETUP_RETRY, @@ -34,22 +35,6 @@ 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 @@ -367,7 +352,203 @@ async def test_existing_node_not_ready(hass, client, multisensor_6, device_regis ) -async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): +async def test_start_addon( + hass, addon_installed, install_addon, addon_options, set_addon_options, start_addon +): + """Test start the Z-Wave JS add-on during entry setup.""" + device = "/test" + network_key = "abc123" + addon_options = { + "device": device, + "network_key": network_key, + } + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"use_addon": True, "usb_path": device, "network_key": network_key}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert install_addon.call_count == 0 + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + hass, "core_zwave_js", {"options": addon_options} + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call(hass, "core_zwave_js") + + +async def test_install_addon( + hass, addon_installed, install_addon, addon_options, set_addon_options, start_addon +): + """Test install and start the Z-Wave JS add-on during entry setup.""" + addon_installed.return_value["version"] = None + device = "/test" + network_key = "abc123" + addon_options = { + "device": device, + "network_key": network_key, + } + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"use_addon": True, "usb_path": device, "network_key": network_key}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert install_addon.call_count == 1 + assert install_addon.call_args == call(hass, "core_zwave_js") + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + hass, "core_zwave_js", {"options": addon_options} + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call(hass, "core_zwave_js") + + +@pytest.mark.parametrize("addon_info_side_effect", [HassioAPIError("Boom")]) +async def test_addon_info_failure( + hass, + addon_installed, + install_addon, + addon_options, + set_addon_options, + start_addon, +): + """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" + device = "/test" + network_key = "abc123" + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={"use_addon": True, "usb_path": device, "network_key": network_key}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert install_addon.call_count == 0 + assert start_addon.call_count == 0 + + +@pytest.mark.parametrize( + "addon_version, update_available, update_calls, update_addon_side_effect", + [ + ("1.0", True, 1, None), + ("1.0", False, 0, None), + ("1.0", True, 1, HassioAPIError("Boom")), + ], +) +async def test_update_addon( + hass, + client, + addon_info, + addon_installed, + addon_running, + create_shapshot, + update_addon, + addon_options, + addon_version, + update_available, + update_calls, + update_addon_side_effect, +): + """Test update the Z-Wave JS add-on during entry setup.""" + addon_info.return_value["version"] = addon_version + addon_info.return_value["update_available"] = update_available + update_addon.side_effect = update_addon_side_effect + client.connect.side_effect = InvalidServerVersion("Invalid version") + device = "/test" + network_key = "abc123" + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={ + "url": "ws://host1:3001", + "use_addon": True, + "usb_path": device, + "network_key": network_key, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": f"addon_core_zwave_js_{addon_version}", "addons": ["core_zwave_js"]}, + partial=True, + ) + assert update_addon.call_count == update_calls + + +@pytest.mark.parametrize( + "stop_addon_side_effect, entry_state", + [ + (None, ENTRY_STATE_NOT_LOADED), + (HassioAPIError("Boom"), ENTRY_STATE_LOADED), + ], +) +async def test_stop_addon( + hass, + client, + addon_installed, + addon_running, + addon_options, + stop_addon, + stop_addon_side_effect, + entry_state, +): + """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" + stop_addon.side_effect = stop_addon_side_effect + device = "/test" + network_key = "abc123" + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + connection_class=CONN_CLASS_LOCAL_PUSH, + data={ + "url": "ws://host1:3001", + "use_addon": True, + "usb_path": device, + "network_key": network_key, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_set_disabled_by(entry.entry_id, DISABLED_USER) + await hass.async_block_till_done() + + assert entry.state == entry_state + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + + +async def test_remove_entry( + hass, addon_installed, stop_addon, create_shapshot, uninstall_addon, caplog +): """Test remove the config entry.""" # test successful remove without created add-on entry = MockConfigEntry( @@ -398,10 +579,19 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, + partial=True, + ) assert uninstall_addon.call_count == 1 + assert uninstall_addon.call_args == call(hass, "core_zwave_js") assert entry.state == ENTRY_STATE_NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() + create_shapshot.reset_mock() uninstall_addon.reset_mock() # test add-on stop failure @@ -412,12 +602,39 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 0 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() + create_shapshot.reset_mock() + uninstall_addon.reset_mock() + + # test create snapshot failure + entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + create_shapshot.side_effect = HassioAPIError() + + await hass.config_entries.async_remove(entry.entry_id) + + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, + partial=True, + ) + 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 create a snapshot of the Z-Wave JS add-on" in caplog.text + create_shapshot.side_effect = None + stop_addon.reset_mock() + create_shapshot.reset_mock() uninstall_addon.reset_mock() # test add-on uninstall failure @@ -428,7 +645,15 @@ async def test_remove_entry(hass, stop_addon, uninstall_addon, caplog): await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 + assert stop_addon.call_args == call(hass, "core_zwave_js") + assert create_shapshot.call_count == 1 + assert create_shapshot.call_args == call( + hass, + {"name": "addon_core_zwave_js_1.0", "addons": ["core_zwave_js"]}, + partial=True, + ) assert uninstall_addon.call_count == 1 + assert uninstall_addon.call_args == call(hass, "core_zwave_js") 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