Add zwave_js add-on manager (#47251)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
pull/47292/head
Martin Hjelmare 2021-03-02 23:22:42 +01:00 committed by GitHub
parent e443597b46
commit d3721bcf26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 797 additions and 155 deletions

View File

@ -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):

View File

@ -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()

View File

@ -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."""

View File

@ -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

View File

@ -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"

View File

@ -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": {

View File

@ -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"
}
}
}
},

View File

@ -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):

View File

@ -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},

View File

@ -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