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
This commit is contained in:
Paulus Schoutsen 2017-02-13 21:34:36 -08:00 committed by GitHub
parent 6005933451
commit 36c196f9e8
10 changed files with 295 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', {})

View File

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

View File

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

View File

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

View File

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