From cf249e3e5eba22674b73e4e32a27334ae5d0345a Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 9 Oct 2018 10:30:55 -0400 Subject: [PATCH] Z-Wave Config Entry Support (#17119) * Initial Z-Wave Config Entry Support * Use conf.get() for config import * Uncomment test * Re-add line breaks * tabs -> space * Unused import cleanup & lint fixes * Remove unused config flow link step * Address comments * Remove unused import * Fix tests * Check for valid usb_path * Test for Z-Stick in config flow * Pass config dir to ZWaveOption * Auto-generate Network Key if none provided * Test fixes * Address comments & more start network service registration * add_executor_job for options.lock() --- .../components/zwave/.translations/en.json | 22 +++++ homeassistant/components/zwave/__init__.py | 79 +++++++++------ homeassistant/components/zwave/config_flow.py | 95 +++++++++++++++++++ homeassistant/components/zwave/const.py | 12 +++ homeassistant/components/zwave/strings.json | 22 +++++ homeassistant/config_entries.py | 1 + tests/components/zwave/test_init.py | 36 ++++--- 7 files changed, 227 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/zwave/.translations/en.json create mode 100644 homeassistant/components/zwave/config_flow.py create mode 100644 homeassistant/components/zwave/strings.json diff --git a/homeassistant/components/zwave/.translations/en.json b/homeassistant/components/zwave/.translations/en.json new file mode 100644 index 00000000000..081d5c858cb --- /dev/null +++ b/homeassistant/components/zwave/.translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Z-Wave is already configured", + "one_instance_only": "Component only supports one Z-Wave instance" + }, + "error": { + "option_error": "Z-Wave validation failed. Is the path to the USB stick correct?" + }, + "step": { + "user": { + "data": { + "network_key": "Network Key (leave blank to auto-generate)", + "usb_path": "USB Path" + }, + "description": "See https://www.home-assistant.io/docs/z-wave/installation/ for information on the configuration variables", + "title": "Set up Z-Wave" + } + }, + "title": "Z-Wave" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index fa78f719557..d48cac6a1e2 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -11,6 +11,7 @@ from pprint import pprint import voluptuous as vol +from homeassistant import config_entries from homeassistant.core import callback, CoreState from homeassistant.loader import get_platform from homeassistant.helpers import discovery @@ -28,7 +29,13 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send) from . import const -from .const import DOMAIN, DATA_DEVICES, DATA_NETWORK, DATA_ENTITY_VALUES +from . import config_flow # noqa # pylint: disable=unused-import +from .const import ( + CONF_AUTOHEAL, CONF_DEBUG, CONF_POLLING_INTERVAL, + CONF_USB_STICK_PATH, CONF_CONFIG_PATH, CONF_NETWORK_KEY, + DEFAULT_CONF_AUTOHEAL, DEFAULT_CONF_USB_STICK_PATH, + DEFAULT_POLLING_INTERVAL, DEFAULT_DEBUG, DOMAIN, + DATA_DEVICES, DATA_NETWORK, DATA_ENTITY_VALUES) from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS @@ -40,12 +47,10 @@ REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.9'] _LOGGER = logging.getLogger(__name__) CLASS_ID = 'class_id' -CONF_AUTOHEAL = 'autoheal' -CONF_DEBUG = 'debug' + +ATTR_POWER = 'power_consumption' + CONF_POLLING_INTENSITY = 'polling_intensity' -CONF_POLLING_INTERVAL = 'polling_interval' -CONF_USB_STICK_PATH = 'usb_path' -CONF_CONFIG_PATH = 'config_path' CONF_IGNORED = 'ignored' CONF_INVERT_OPENCLOSE_BUTTONS = 'invert_openclose_buttons' CONF_REFRESH_VALUE = 'refresh_value' @@ -53,14 +58,9 @@ CONF_REFRESH_DELAY = 'delay' CONF_DEVICE_CONFIG = 'device_config' CONF_DEVICE_CONFIG_GLOB = 'device_config_glob' CONF_DEVICE_CONFIG_DOMAIN = 'device_config_domain' -CONF_NETWORK_KEY = 'network_key' -ATTR_POWER = 'power_consumption' +DATA_ZWAVE_CONFIG = 'zwave_config' -DEFAULT_CONF_AUTOHEAL = True -DEFAULT_CONF_USB_STICK_PATH = '/zwaveusbstick' -DEFAULT_POLLING_INTERVAL = 60000 -DEFAULT_DEBUG = False DEFAULT_CONF_IGNORED = False DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS = False DEFAULT_CONF_REFRESH_VALUE = False @@ -230,7 +230,27 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup(hass, config): - """Set up Z-Wave. + """Set up Z-Wave components.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + hass.data[DATA_ZWAVE_CONFIG] = conf + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ + CONF_USB_STICK_PATH: conf[CONF_USB_STICK_PATH], + CONF_NETWORK_KEY: conf.get(CONF_NETWORK_KEY), + } + )) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Z-Wave from a config entry. Will automatically load components to support devices found on the network. """ @@ -240,27 +260,31 @@ async def async_setup(hass, config): from openzwave.network import ZWaveNetwork from openzwave.group import ZWaveGroup + config = {} + if DATA_ZWAVE_CONFIG in hass.data: + config = hass.data[DATA_ZWAVE_CONFIG] + # Load configuration - use_debug = config[DOMAIN].get(CONF_DEBUG) - autoheal = config[DOMAIN].get(CONF_AUTOHEAL) + use_debug = config.get(CONF_DEBUG, DEFAULT_DEBUG) + autoheal = config.get(CONF_AUTOHEAL, + DEFAULT_CONF_AUTOHEAL) device_config = EntityValues( - config[DOMAIN][CONF_DEVICE_CONFIG], - config[DOMAIN][CONF_DEVICE_CONFIG_DOMAIN], - config[DOMAIN][CONF_DEVICE_CONFIG_GLOB]) + config.get(CONF_DEVICE_CONFIG), + config.get(CONF_DEVICE_CONFIG_DOMAIN), + config.get(CONF_DEVICE_CONFIG_GLOB)) # Setup options options = ZWaveOption( - config[DOMAIN].get(CONF_USB_STICK_PATH), + config_entry.data[CONF_USB_STICK_PATH], user_path=hass.config.config_dir, - config_path=config[DOMAIN].get(CONF_CONFIG_PATH)) + config_path=config.get(CONF_CONFIG_PATH)) options.set_console_output(use_debug) - if CONF_NETWORK_KEY in config[DOMAIN]: - options.addOption("NetworkKey", config[DOMAIN][CONF_NETWORK_KEY]) - - options.lock() + if CONF_NETWORK_KEY in config_entry.data: + options.addOption("NetworkKey", config_entry.data[CONF_NETWORK_KEY]) + await hass.async_add_executor_job(options.lock) network = hass.data[DATA_NETWORK] = ZWaveNetwork(options, autostart=False) hass.data[DATA_DEVICES] = {} hass.data[DATA_ENTITY_VALUES] = [] @@ -666,7 +690,7 @@ async def async_setup(hass, config): def _finalize_start(): """Perform final initializations after Z-Wave network is awaked.""" polling_interval = convert( - config[DOMAIN].get(CONF_POLLING_INTERVAL), int) + config.get(CONF_POLLING_INTERVAL), int) if polling_interval is not None: network.set_poll_interval(polling_interval, False) @@ -691,8 +715,6 @@ async def async_setup(hass, config): test_network) hass.services.register(DOMAIN, const.SERVICE_STOP_NETWORK, stop_network) - hass.services.register(DOMAIN, const.SERVICE_START_NETWORK, - start_zwave) hass.services.register(DOMAIN, const.SERVICE_RENAME_NODE, rename_node, schema=RENAME_NODE_SCHEMA) hass.services.register(DOMAIN, const.SERVICE_RENAME_VALUE, @@ -752,6 +774,9 @@ async def async_setup(hass, config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_zwave) + hass.services.async_register(DOMAIN, const.SERVICE_START_NETWORK, + start_zwave) + return True diff --git a/homeassistant/components/zwave/config_flow.py b/homeassistant/components/zwave/config_flow.py new file mode 100644 index 00000000000..2b853ffa81d --- /dev/null +++ b/homeassistant/components/zwave/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow to configure Z-Wave.""" +from collections import OrderedDict +import logging + +import voluptuous as vol + +from homeassistant import config_entries + +from .const import ( + CONF_USB_STICK_PATH, CONF_NETWORK_KEY, + DEFAULT_CONF_USB_STICK_PATH, DOMAIN) + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class ZwaveFlowHandler(config_entries.ConfigFlow): + """Handle a Z-Wave config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the Z-Wave config flow.""" + self.usb_path = CONF_USB_STICK_PATH + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_only') + + errors = {} + + fields = OrderedDict() + fields[vol.Required(CONF_USB_STICK_PATH, + default=DEFAULT_CONF_USB_STICK_PATH)] = str + fields[vol.Optional(CONF_NETWORK_KEY)] = str + + if user_input is not None: + # Check if USB path is valid + from openzwave.option import ZWaveOption + from openzwave.object import ZWaveException + + try: + from functools import partial + # pylint: disable=unused-variable + option = await self.hass.async_add_executor_job( # noqa: F841 + partial(ZWaveOption, + user_input[CONF_USB_STICK_PATH], + user_path=self.hass.config.config_dir) + ) + except ZWaveException: + errors['base'] = 'option_error' + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(fields), + errors=errors + ) + + if user_input.get(CONF_NETWORK_KEY) is None: + # Generate a random key + from random import choice + key = str() + for i in range(16): + key += '0x' + key += choice('1234567890ABCDEF') + key += choice('1234567890ABCDEF') + if i < 15: + key += ', ' + user_input[CONF_NETWORK_KEY] = key + + return self.async_create_entry( + title='Z-Wave', + data={ + CONF_USB_STICK_PATH: user_input[CONF_USB_STICK_PATH], + CONF_NETWORK_KEY: user_input[CONF_NETWORK_KEY], + }, + ) + + return self.async_show_form( + step_id='user', data_schema=vol.Schema(fields) + ) + + async def async_step_import(self, info): + """Import existing configuration from Z-Wave.""" + if self._async_current_entries(): + return self.async_abort(reason='already_setup') + + return self.async_create_entry( + title="Z-Wave (import from configuration.yaml)", + data={ + CONF_USB_STICK_PATH: info.get(CONF_USB_STICK_PATH), + CONF_NETWORK_KEY: info.get(CONF_NETWORK_KEY), + }, + ) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b84f0287349..fece48655df 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -22,6 +22,18 @@ ATTR_VALUE_INSTANCE = "value_instance" NETWORK_READY_WAIT_SECS = 300 NODE_READY_WAIT_SECS = 30 +CONF_AUTOHEAL = 'autoheal' +CONF_DEBUG = 'debug' +CONF_POLLING_INTERVAL = 'polling_interval' +CONF_USB_STICK_PATH = 'usb_path' +CONF_CONFIG_PATH = 'config_path' +CONF_NETWORK_KEY = 'network_key' + +DEFAULT_CONF_AUTOHEAL = True +DEFAULT_CONF_USB_STICK_PATH = '/zwaveusbstick' +DEFAULT_POLLING_INTERVAL = 60000 +DEFAULT_DEBUG = False + DISCOVERY_DEVICE = 'device' DATA_DEVICES = 'zwave_devices' diff --git a/homeassistant/components/zwave/strings.json b/homeassistant/components/zwave/strings.json new file mode 100644 index 00000000000..0ac55e46791 --- /dev/null +++ b/homeassistant/components/zwave/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Z-Wave", + "step": { + "user": { + "title": "Set up Z-Wave", + "description": "See https://www.home-assistant.io/docs/z-wave/installation/ for information on the configuration variables", + "data": { + "usb_path": "USB Path", + "network_key": "Network Key (leave blank to auto-generate)" + } + } + }, + "error": { + "option_error": "Z-Wave validation failed. Is the path to the USB stick correct?" + }, + "abort": { + "already_configured": "Z-Wave is already configured", + "one_instance_only": "Component only supports one Z-Wave instance" + } + } +} \ No newline at end of file diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 56d4d24eea2..053aa079617 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -151,6 +151,7 @@ FLOWS = [ 'tradfri', 'zone', 'upnp', + 'zwave' ] diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index a2290d8aabf..ef330b48f72 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -2,6 +2,7 @@ import asyncio from collections import OrderedDict from datetime import datetime +from pytz import utc import unittest from unittest.mock import patch, MagicMock @@ -22,13 +23,6 @@ from tests.common import ( from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues -@asyncio.coroutine -def test_missing_openzwave(hass): - """Test that missing openzwave lib stops setup.""" - result = yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - assert not result - - @asyncio.coroutine def test_valid_device_config(hass, mock_openzwave): """Test valid device config.""" @@ -41,6 +35,7 @@ def test_valid_device_config(hass, mock_openzwave): 'zwave': { 'device_config': device_config }}) + yield from hass.async_block_till_done() assert result @@ -57,6 +52,7 @@ def test_invalid_device_config(hass, mock_openzwave): 'zwave': { 'device_config': device_config }}) + yield from hass.async_block_till_done() assert not result @@ -81,6 +77,7 @@ def test_network_options(hass, mock_openzwave): 'usb_path': 'mock_usb_path', 'config_path': 'mock_config_path', }}) + yield from hass.async_block_till_done() assert result @@ -92,14 +89,16 @@ def test_network_options(hass, mock_openzwave): @asyncio.coroutine def test_auto_heal_midnight(hass, mock_openzwave): """Test network auto-heal at midnight.""" - assert (yield from async_setup_component(hass, 'zwave', { + yield from async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': True, - }})) + }}) + yield from hass.async_block_till_done() + network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called - time = datetime(2017, 5, 6, 0, 0, 0) + time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) yield from hass.async_block_till_done() assert network.heal.called @@ -109,14 +108,16 @@ def test_auto_heal_midnight(hass, mock_openzwave): @asyncio.coroutine def test_auto_heal_disabled(hass, mock_openzwave): """Test network auto-heal disabled.""" - assert (yield from async_setup_component(hass, 'zwave', { + yield from async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': False, - }})) + }}) + yield from hass.async_block_till_done() + network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called - time = datetime(2017, 5, 6, 0, 0, 0) + time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) yield from hass.async_block_till_done() assert not network.heal.called @@ -215,6 +216,7 @@ def test_node_discovery(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -235,6 +237,7 @@ async def test_unparsed_node_discovery(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -282,6 +285,7 @@ def test_node_ignored(hass, mock_openzwave): 'zwave.mock_node': { 'ignored': True, }}}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -303,6 +307,7 @@ def test_value_discovery(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -328,6 +333,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -373,6 +379,7 @@ def test_power_schemes(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -415,6 +422,7 @@ def test_network_ready(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -442,6 +450,7 @@ def test_network_complete(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -469,6 +478,7 @@ def test_network_complete_some_dead(hass, mock_openzwave): with patch('pydispatch.dispatcher.connect', new=mock_connect): yield from async_setup_component(hass, 'zwave', {'zwave': {}}) + yield from hass.async_block_till_done() assert len(mock_receivers) == 1