From 36c196f9e8813b41776bb7655f3d91a188163886 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Feb 2017 21:34:36 -0800 Subject: [PATCH] Add initial Z-Wave config panel (#5937) * Add Z-Wave config panel * Add config to Z-Wave dependencies * Lint * lint * Add tests * Remove temp workaround * Lint * Fix tests * Address comments * Fix tests under Py34 --- homeassistant/components/config/__init__.py | 38 +++++++-- homeassistant/components/config/zwave.py | 78 +++++++++++++++++ homeassistant/components/zwave/__init__.py | 4 +- homeassistant/util/yaml.py | 38 ++++++++- tests/common.py | 10 +++ tests/components/config/test_core.py | 2 +- tests/components/config/test_init.py | 47 ++++++++++- tests/components/config/test_zwave.py | 89 ++++++++++++++++++++ tests/components/device_tracker/test_init.py | 4 +- tests/components/zwave/test_init.py | 4 +- 10 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/config/zwave.py create mode 100644 tests/components/config/test_zwave.py diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index bf05ba9d99f8..a664d64a5e20 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,29 +1,55 @@ -"""Component to interact with Hassbian tools.""" +"""Component to configure Home Assistant via an API.""" import asyncio -from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.core import callback +from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.bootstrap import ( + async_prepare_setup_platform, ATTR_COMPONENT) from homeassistant.components.frontend import register_built_in_panel DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ('core', 'hassbian') +ON_DEMAND = ('zwave', ) @asyncio.coroutine def async_setup(hass, config): - """Setup the hassbian component.""" + """Setup the config component.""" register_built_in_panel(hass, 'config', 'Configuration', 'mdi:settings') - for panel_name in SECTIONS: + @asyncio.coroutine + def setup_panel(panel_name): + """Setup a panel.""" panel = yield from async_prepare_setup_platform(hass, config, DOMAIN, panel_name) if not panel: - continue + return success = yield from panel.async_setup(hass) if success: - hass.config.components.add('{}.{}'.format(DOMAIN, panel_name)) + key = '{}.{}'.format(DOMAIN, panel_name) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) + hass.config.components.add(key) + + tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + + for panel_name in ON_DEMAND: + if panel_name in hass.config.components: + tasks.append(setup_panel(panel_name)) + + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + @callback + def component_loaded(event): + """Respond to components being loaded.""" + panel_name = event.data.get(ATTR_COMPONENT) + if panel_name in ON_DEMAND: + hass.async_add_job(setup_panel(panel_name)) + + hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) return True diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py new file mode 100644 index 000000000000..ec01e2dad6e4 --- /dev/null +++ b/homeassistant/components/config/zwave.py @@ -0,0 +1,78 @@ +"""Provide configuration end points for Z-Wave.""" +import asyncio +import os +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.zwave import DEVICE_CONFIG_SCHEMA_ENTRY +from homeassistant.util.yaml import load_yaml, dump + + +DEVICE_CONFIG = 'zwave_device_config.yml' + + +@asyncio.coroutine +def async_setup(hass): + """Setup the hassbian config.""" + hass.http.register_view(DeviceConfigView) + return True + + +class DeviceConfigView(HomeAssistantView): + """Configure a Z-Wave device endpoint.""" + + url = '/api/config/zwave/device_config/{entity_id}' + name = 'api:config:zwave:device_config:update' + + @asyncio.coroutine + def get(self, request, entity_id): + """Fetch device specific config.""" + hass = request.app['hass'] + current = yield from hass.loop.run_in_executor( + None, _read, hass.config.path(DEVICE_CONFIG)) + return self.json(current.get(entity_id, {})) + + @asyncio.coroutine + def post(self, request, entity_id): + """Validate config and return results.""" + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON specified', 400) + + try: + # We just validate, we don't store that data because + # we don't want to store the defaults. + DEVICE_CONFIG_SCHEMA_ENTRY(data) + except vol.Invalid as err: + print(data, err) + return self.json_message('Message malformed: {}'.format(err), 400) + + hass = request.app['hass'] + path = hass.config.path(DEVICE_CONFIG) + current = yield from hass.loop.run_in_executor( + None, _read, hass.config.path(DEVICE_CONFIG)) + current.setdefault(entity_id, {}).update(data) + + yield from hass.loop.run_in_executor( + None, _write, hass.config.path(path), current) + + return self.json({ + 'result': 'ok', + }) + + +def _read(path): + """Read YAML helper.""" + if not os.path.isfile(path): + with open(path, 'w'): + pass + return {} + + return load_yaml(path) + + +def _write(path, data): + """Write YAML helper.""" + with open(path, 'w') as outfile: + dump(data, outfile) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index b2c471595abc..c729f6b2e123 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -160,7 +160,7 @@ SET_WAKEUP_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), cv.positive_int), }) -_DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ +DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(CONF_POLLING_INTENSITY): cv.positive_int, vol.Optional(CONF_IGNORED, default=DEFAULT_CONF_IGNORED): cv.boolean, vol.Optional(CONF_REFRESH_VALUE, default=DEFAULT_CONF_REFRESH_VALUE): @@ -174,7 +174,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_AUTOHEAL, default=DEFAULT_CONF_AUTOHEAL): cv.boolean, vol.Optional(CONF_CONFIG_PATH): cv.string, vol.Optional(CONF_DEVICE_CONFIG, default={}): - vol.Schema({cv.entity_id: _DEVICE_CONFIG_SCHEMA_ENTRY}), + vol.Schema({cv.entity_id: DEVICE_CONFIG_SCHEMA_ENTRY}), vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean, vol.Optional(CONF_POLLING_INTERVAL, default=DEFAULT_POLLING_INTERVAL): cv.positive_int, diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 005088622795..8100c6e5f68d 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -69,9 +69,9 @@ def load_yaml(fname: str) -> Union[List, Dict]: raise HomeAssistantError(exc) -def dump(_dict: dict) -> str: +def dump(_dict: dict, outfile=None) -> str: """Dump yaml to a string and remove null.""" - return yaml.safe_dump(_dict, default_flow_style=False) \ + return yaml.safe_dump(_dict, outfile, default_flow_style=False) \ .replace(': null\n', ':\n') @@ -272,3 +272,37 @@ yaml.SafeLoader.add_constructor('!include_dir_merge_list', yaml.SafeLoader.add_constructor('!include_dir_named', _include_dir_named_yaml) yaml.SafeLoader.add_constructor('!include_dir_merge_named', _include_dir_merge_named_yaml) + + +# From: https://gist.github.com/miracle2k/3184458 +# pylint: disable=redefined-outer-name +def represent_odict(dump, tag, mapping, flow_style=None): + """Like BaseRepresenter.represent_mapping but does not issue the sort().""" + value = [] + node = yaml.MappingNode(tag, value, flow_style=flow_style) + if dump.alias_key is not None: + dump.represented_objects[dump.alias_key] = node + best_style = True + if hasattr(mapping, 'items'): + mapping = mapping.items() + for item_key, item_value in mapping: + node_key = dump.represent_data(item_key) + node_value = dump.represent_data(item_value) + if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): + best_style = False + if not (isinstance(node_value, yaml.ScalarNode) and + not node_value.style): + best_style = False + value.append((node_key, node_value)) + if flow_style is None: + if dump.default_flow_style is not None: + node.flow_style = dump.default_flow_style + else: + node.flow_style = best_style + return node + + +yaml.SafeDumper.add_representer( + OrderedDict, + lambda dumper, value: + represent_odict(dumper, u'tag:yaml.org,2002:map', value)) diff --git a/tests/common.py b/tests/common.py index 5527a26b63e1..ec32d4135975 100644 --- a/tests/common.py +++ b/tests/common.py @@ -401,6 +401,16 @@ def mock_generator(return_value=None): return mock_coro(return_value)() +def mock_coro_func(return_value=None): + """Helper method to return a coro that returns a value.""" + @asyncio.coroutine + def coro(*args, **kwargs): + """Fake coroutine.""" + return return_value + + return coro + + @contextmanager def assert_setup_component(count, domain=None): """Collect valid configuration from setup_component. diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 6492f2fabe6a..1db50873906c 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -10,7 +10,7 @@ from tests.common import mock_http_component_app, mock_coro @asyncio.coroutine def test_validate_config_ok(hass, test_client): - """Test getting suites.""" + """Test checking config.""" app = mock_http_component_app(hass) with patch.object(config, 'SECTIONS', ['core']): yield from async_setup_component(hass, 'config', {}) diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 1194c6c2b3d9..dfe3c3fb4362 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -1,9 +1,14 @@ """Test config init.""" +import asyncio +from unittest.mock import patch + import pytest -from homeassistant.bootstrap import async_setup_component +from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.bootstrap import async_setup_component, ATTR_COMPONENT +from homeassistant.components import config -from tests.common import mock_http_component +from tests.common import mock_http_component, mock_coro @pytest.fixture(autouse=True) @@ -12,7 +17,43 @@ def stub_http(hass): mock_http_component(hass) +@asyncio.coroutine def test_config_setup(hass, loop): """Test it sets up hassbian.""" - loop.run_until_complete(async_setup_component(hass, 'config', {})) + yield from async_setup_component(hass, 'config', {}) assert 'config' in hass.config.components + + +@asyncio.coroutine +def test_load_on_demand_already_loaded(hass, test_client): + """Test getting suites.""" + hass.config.components.add('zwave') + + with patch.object(config, 'SECTIONS', []), \ + patch.object(config, 'ON_DEMAND', ['zwave']), \ + patch('homeassistant.components.config.zwave.async_setup') as stp: + stp.return_value = mock_coro(True)() + + yield from async_setup_component(hass, 'config', {}) + + yield from hass.async_block_till_done() + assert 'config.zwave' in hass.config.components + assert stp.called + + +@asyncio.coroutine +def test_load_on_demand_on_load(hass, test_client): + """Test getting suites.""" + with patch.object(config, 'SECTIONS', []), \ + patch.object(config, 'ON_DEMAND', ['zwave']): + yield from async_setup_component(hass, 'config', {}) + + assert 'config.zwave' not in hass.config.components + + with patch('homeassistant.components.config.zwave.async_setup') as stp: + stp.return_value = mock_coro(True)() + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: 'zwave'}) + yield from hass.async_block_till_done() + + assert 'config.zwave' in hass.config.components + assert stp.called diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py new file mode 100644 index 000000000000..0a5d38b540a4 --- /dev/null +++ b/tests/components/config/test_zwave.py @@ -0,0 +1,89 @@ +"""Test Z-Wave config panel.""" +import asyncio +import json +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config +from homeassistant.components.config.zwave import DeviceConfigView +from tests.common import mock_http_component_app + + +@asyncio.coroutine +def test_get_device_config(hass, test_client): + """Test getting device config.""" + app = mock_http_component_app(hass) + + with patch.object(config, 'SECTIONS', ['zwave']): + yield from async_setup_component(hass, 'config', {}) + + hass.http.views[DeviceConfigView.name].register(app.router) + + client = yield from test_client(app) + + def mock_read(path): + """Mock reading data.""" + return { + 'hello.beer': { + 'free': 'beer', + }, + 'other.entity': { + 'do': 'something', + }, + } + + with patch('homeassistant.components.config.zwave._read', mock_read): + resp = yield from client.get( + '/api/config/zwave/device_config/hello.beer') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {'free': 'beer'} + + +@asyncio.coroutine +def test_update_device_config(hass, test_client): + """Test updating device config.""" + app = mock_http_component_app(hass) + + with patch.object(config, 'SECTIONS', ['zwave']): + yield from async_setup_component(hass, 'config', {}) + + hass.http.views[DeviceConfigView.name].register(app.router) + + client = yield from test_client(app) + + orig_data = { + 'hello.beer': { + 'ignored': True, + }, + 'other.entity': { + 'polling_intensity': 2, + }, + } + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch('homeassistant.components.config.zwave._read', mock_read), \ + patch('homeassistant.components.config.zwave._write', mock_write): + resp = yield from client.post( + '/api/config/zwave/device_config/hello.beer', data=json.dumps({ + 'polling_intensity': 2 + })) + + assert resp.status == 200 + result = yield from resp.json() + assert result == {'result': 'ok'} + + orig_data['hello.beer']['polling_intensity'] = 2 + + assert written[0] == orig_data diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 524eda22634f..4f932cd177fa 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -48,10 +48,8 @@ class TestComponentsDeviceTracker(unittest.TestCase): # pylint: disable=invalid-name def tearDown(self): """Stop everything that was started.""" - try: + if os.path.isfile(self.yaml_devices): os.remove(self.yaml_devices) - except FileNotFoundError: - pass self.hass.stop() diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 2bfb8986544f..8114a872c2ab 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -6,7 +6,7 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components.zwave import ( - DATA_DEVICE_CONFIG, _DEVICE_CONFIG_SCHEMA_ENTRY) + DATA_DEVICE_CONFIG, DEVICE_CONFIG_SCHEMA_ENTRY) @pytest.fixture(autouse=True) @@ -39,7 +39,7 @@ def test_device_config(hass): assert DATA_DEVICE_CONFIG in hass.data test_data = { - key: _DEVICE_CONFIG_SCHEMA_ENTRY(value) + key: DEVICE_CONFIG_SCHEMA_ENTRY(value) for key, value in device_config.items() }