From 8b57777ce99284b0bf5dd02b9271142172ecad5b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 Jan 2018 12:33:22 -0800 Subject: [PATCH] Alexa to not use customize for entity config (#11461) * Alexa to not use customize for entity config * Test Alexa entity config * Improve tests * Fix test --- homeassistant/components/alexa/smart_home.py | 34 +++++------ homeassistant/components/cloud/__init__.py | 58 ++++++++++++------- homeassistant/components/cloud/iot.py | 2 +- tests/components/alexa/test_smart_home.py | 43 +++++++++++++- tests/components/cloud/test_iot.py | 60 ++++++++++++++------ 5 files changed, 140 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index d303ca57704..3c14826037c 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,6 +1,5 @@ """Support for alexa Smart Home Skill API.""" import asyncio -from collections import namedtuple import logging import math from uuid import uuid4 @@ -27,10 +26,9 @@ API_EVENT = 'event' API_HEADER = 'header' API_PAYLOAD = 'payload' -ATTR_ALEXA_DESCRIPTION = 'alexa_description' -ATTR_ALEXA_DISPLAY_CATEGORIES = 'alexa_display_categories' -ATTR_ALEXA_HIDDEN = 'alexa_hidden' -ATTR_ALEXA_NAME = 'alexa_name' +CONF_DESCRIPTION = 'description' +CONF_DISPLAY_CATEGORIES = 'display_categories' +CONF_NAME = 'name' MAPPING_COMPONENT = { @@ -73,7 +71,13 @@ MAPPING_COMPONENT = { } -Config = namedtuple('AlexaConfig', 'filter') +class Config: + """Hold the configuration for Alexa.""" + + def __init__(self, should_expose, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.entity_config = entity_config or {} @asyncio.coroutine @@ -150,32 +154,28 @@ def async_api_discovery(hass, config, request): discovery_endpoints = [] for entity in hass.states.async_all(): - if not config.filter(entity.entity_id): + if not config.should_expose(entity.entity_id): _LOGGER.debug("Not exposing %s because filtered by config", entity.entity_id) continue - if entity.attributes.get(ATTR_ALEXA_HIDDEN, False): - _LOGGER.debug("Not exposing %s because alexa_hidden is true", - entity.entity_id) - continue - class_data = MAPPING_COMPONENT.get(entity.domain) if not class_data: continue - friendly_name = entity.attributes.get(ATTR_ALEXA_NAME, entity.name) - description = entity.attributes.get(ATTR_ALEXA_DESCRIPTION, - entity.entity_id) + entity_conf = config.entity_config.get(entity.entity_id, {}) + + friendly_name = entity_conf.get(CONF_NAME, entity.name) + description = entity_conf.get(CONF_DESCRIPTION, entity.entity_id) # Required description as per Amazon Scene docs if entity.domain == scene.DOMAIN: scene_fmt = '{} (Scene connected via Home Assistant)' description = scene_fmt.format(description) - cat_key = ATTR_ALEXA_DISPLAY_CATEGORIES - display_categories = entity.attributes.get(cat_key, class_data[0]) + display_categories = entity_conf.get(CONF_DISPLAY_CATEGORIES, + class_data[0]) endpoint = { 'displayCategories': [display_categories], diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index a3cedd38cd9..e93eb086fd0 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) from homeassistant.helpers import entityfilter +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh @@ -25,7 +26,7 @@ REQUIREMENTS = ['warrant==0.6.1'] _LOGGER = logging.getLogger(__name__) CONF_ALEXA = 'alexa' -CONF_GOOGLE_ASSISTANT = 'google_assistant' +CONF_GOOGLE_ACTIONS = 'google_actions' CONF_FILTER = 'filter' CONF_COGNITO_CLIENT_ID = 'cognito_client_id' CONF_RELAYER = 'relayer' @@ -35,6 +36,14 @@ MODE_DEV = 'development' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] +CONF_ENTITY_CONFIG = 'entity_config' + +ALEXA_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(alexa_sh.CONF_NAME): cv.string, +}) + ASSISTANT_SCHEMA = vol.Schema({ vol.Optional( CONF_FILTER, @@ -42,6 +51,10 @@ ASSISTANT_SCHEMA = vol.Schema({ ): entityfilter.FILTER_SCHEMA, }) +ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): @@ -51,8 +64,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, - vol.Optional(CONF_ALEXA): ASSISTANT_SCHEMA, - vol.Optional(CONF_GOOGLE_ASSISTANT): ASSISTANT_SCHEMA, + vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, + vol.Optional(CONF_GOOGLE_ACTIONS): ASSISTANT_SCHEMA, }), }, extra=vol.ALLOW_EXTRA) @@ -61,18 +74,19 @@ CONFIG_SCHEMA = vol.Schema({ def async_setup(hass, config): """Initialize the Home Assistant cloud.""" if DOMAIN in config: - kwargs = config[DOMAIN] + kwargs = dict(config[DOMAIN]) else: kwargs = {CONF_MODE: DEFAULT_MODE} - if CONF_ALEXA not in kwargs: - kwargs[CONF_ALEXA] = ASSISTANT_SCHEMA({}) + alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) + gactions_conf = (kwargs.pop(CONF_GOOGLE_ACTIONS, None) or + ASSISTANT_SCHEMA({})) - if CONF_GOOGLE_ASSISTANT not in kwargs: - kwargs[CONF_GOOGLE_ASSISTANT] = ASSISTANT_SCHEMA({}) - - kwargs[CONF_ALEXA] = alexa_sh.Config(**kwargs[CONF_ALEXA]) - kwargs['gass_should_expose'] = kwargs.pop(CONF_GOOGLE_ASSISTANT)['filter'] + kwargs[CONF_ALEXA] = alexa_sh.Config( + should_expose=alexa_conf[CONF_FILTER], + entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), + ) + kwargs['gactions_should_expose'] = gactions_conf[CONF_FILTER] cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) success = yield from cloud.initialize() @@ -87,15 +101,15 @@ def async_setup(hass, config): class Cloud: """Store the configuration of the cloud connection.""" - def __init__(self, hass, mode, alexa, gass_should_expose, + def __init__(self, hass, mode, alexa, gactions_should_expose, cognito_client_id=None, user_pool_id=None, region=None, relayer=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode self.alexa_config = alexa - self._gass_should_expose = gass_should_expose - self._gass_config = None + self._gactions_should_expose = gactions_should_expose + self._gactions_config = None self.jwt_keyset = None self.id_token = None self.access_token = None @@ -144,15 +158,19 @@ class Cloud: return self.path('{}_auth.json'.format(self.mode)) @property - def gass_config(self): + def gactions_config(self): """Return the Google Assistant config.""" - if self._gass_config is None: - self._gass_config = ga_sh.Config( - should_expose=self._gass_should_expose, + if self._gactions_config is None: + def should_expose(entity): + """If an entity should be exposed.""" + return self._gactions_should_expose(entity.entity_id) + + self._gactions_config = ga_sh.Config( + should_expose=should_expose, agent_user_id=self.claims['cognito:username'] ) - return self._gass_config + return self._gactions_config @asyncio.coroutine def initialize(self): @@ -182,7 +200,7 @@ class Cloud: self.id_token = None self.access_token = None self.refresh_token = None - self._gass_config = None + self._gactions_config = None yield from self.hass.async_add_job( lambda: os.remove(self.user_info_path)) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index a7ff30025c7..ffe68c3c877 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -214,7 +214,7 @@ def async_handle_alexa(hass, cloud, payload): @asyncio.coroutine def async_handle_google_actions(hass, cloud, payload): """Handle an incoming IoT message for Google Actions.""" - result = yield from ga.async_handle_message(hass, cloud.gass_config, + result = yield from ga.async_handle_message(hass, cloud.gactions_config, payload) return result diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index baa05ed0994..6ac56bc10a3 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -9,7 +9,7 @@ from homeassistant.helpers import entityfilter from tests.common import async_mock_service -DEFAULT_CONFIG = smart_home.Config(filter=lambda entity_id: True) +DEFAULT_CONFIG = smart_home.Config(should_expose=lambda entity_id: True) def get_new_request(namespace, name, endpoint=None): @@ -338,7 +338,7 @@ def test_exclude_filters(hass): hass.states.async_set( 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) - config = smart_home.Config(filter=entityfilter.generate_filter( + config = smart_home.Config(should_expose=entityfilter.generate_filter( include_domains=[], include_entities=[], exclude_domains=['script'], @@ -371,7 +371,7 @@ def test_include_filters(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config(filter=entityfilter.generate_filter( + config = smart_home.Config(should_expose=entityfilter.generate_filter( include_domains=['automation', 'group'], include_entities=['script.deny'], exclude_domains=[], @@ -1116,3 +1116,40 @@ def test_api_mute(hass, domain): assert len(call) == 1 assert call[0].data['entity_id'] == '{}.test'.format(domain) assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_entity_config(hass): + """Test that we can configure things via entity config.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + hass.states.async_set( + 'light.test_1', 'on', {'friendly_name': "Test light 1"}) + + config = smart_home.Config( + should_expose=lambda entity_id: True, + entity_config={ + 'light.test_1': { + 'name': 'Config name', + 'display_categories': 'SWITCH', + 'description': 'Config description' + } + } + ) + + msg = yield from smart_home.async_handle_message( + hass, config, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 1 + + appliance = msg['payload']['endpoints'][0] + assert appliance['endpointId'] == 'light#test_1' + assert appliance['displayCategories'][0] == "SWITCH" + assert appliance['friendlyName'] == "Config name" + assert appliance['description'] == "Config description" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index e74d89c744d..d829134eb21 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -38,16 +38,6 @@ def mock_cloud(): return MagicMock(subscription_expired=False) -@pytest.fixture -def cloud_instance(loop, hass): - """Instance of an initialized cloud class.""" - with patch('homeassistant.components.cloud.Cloud.initialize', - return_value=mock_coro(True)): - loop.run_until_complete(async_setup_component(hass, 'cloud', {})) - - yield hass.data['cloud'] - - @asyncio.coroutine def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): """Test we call handle message with correct info.""" @@ -269,13 +259,35 @@ def test_refresh_token_before_expiration_fails(hass, mock_cloud): @asyncio.coroutine -def test_handler_alexa(hass, cloud_instance): +def test_handler_alexa(hass): """Test handler Alexa.""" hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + + with patch('homeassistant.components.cloud.Cloud.initialize', + return_value=mock_coro(True)): + setup = yield from async_setup_component(hass, 'cloud', { + 'cloud': { + 'alexa': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'description': 'Config description', + 'display_categories': 'LIGHT' + } + } + } + } + }) + assert setup resp = yield from iot.async_handle_alexa( - hass, cloud_instance, + hass, hass.data['cloud'], test_alexa.get_new_request('Alexa.Discovery', 'Discover')) endpoints = resp['event']['payload']['endpoints'] @@ -283,16 +295,32 @@ def test_handler_alexa(hass, cloud_instance): assert len(endpoints) == 1 device = endpoints[0] - assert device['description'] == 'switch.test' - assert device['friendlyName'] == 'Test switch' + assert device['description'] == 'Config description' + assert device['friendlyName'] == 'Config name' + assert device['displayCategories'] == ['LIGHT'] assert device['manufacturerName'] == 'Home Assistant' @asyncio.coroutine -def test_handler_google_actions(hass, cloud_instance): +def test_handler_google_actions(hass): """Test handler Google Actions.""" hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + + with patch('homeassistant.components.cloud.Cloud.initialize', + return_value=mock_coro(True)): + setup = yield from async_setup_component(hass, 'cloud', { + 'cloud': { + 'google_actions': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + } + } + }) + assert setup reqid = '5711642932632160983' data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} @@ -300,7 +328,7 @@ def test_handler_google_actions(hass, cloud_instance): with patch('homeassistant.components.cloud.Cloud._decode_claims', return_value={'cognito:username': 'myUserName'}): resp = yield from iot.async_handle_google_actions( - hass, cloud_instance, data) + hass, hass.data['cloud'], data) assert resp['requestId'] == reqid payload = resp['payload']