From c1f156fd2b2bd8c901bfe0b53111d2bef60461db Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 7 Oct 2017 22:31:57 +0200 Subject: [PATCH] Rewrite Alexa Smart-Home skill to v3 (#9699) * Rewrite Alexa Smart-Home skill to v3 * add discovery & fix brigness * Rewrite Tests * fix lint * fix lint p2 * fix version * fix tests * fix test message generator * Update smart_home.py * fix test * fix set bug * fix list * fix response name for discovery * fix flucky tests --- homeassistant/components/alexa/smart_home.py | 144 +++++++------ tests/components/alexa/test_smart_home.py | 212 +++++++++++++------ 2 files changed, 237 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index dbf66a63901..61db142ac42 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -11,19 +11,18 @@ from homeassistant.util.decorator import Registry HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -ATTR_HEADER = 'header' -ATTR_NAME = 'name' -ATTR_NAMESPACE = 'namespace' -ATTR_MESSAGE_ID = 'messageId' -ATTR_PAYLOAD = 'payload' -ATTR_PAYLOAD_VERSION = 'payloadVersion' +API_DIRECTIVE = 'directive' +API_EVENT = 'event' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_ENDPOINT = 'endpoint' MAPPING_COMPONENT = { - switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None], + switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], light.DOMAIN: [ - 'LIGHT', ('turnOff', 'turnOn'), { - light.SUPPORT_BRIGHTNESS: 'setPercentage' + 'LIGHT', ('Alexa.PowerController',), { + light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController' } ], } @@ -32,51 +31,75 @@ MAPPING_COMPONENT = { @asyncio.coroutine def async_handle_message(hass, message): """Handle incoming API messages.""" - assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2 + assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' + + # Read head data + message = message[API_DIRECTIVE] + namespace = message[API_HEADER]['namespace'] + name = message[API_HEADER]['name'] # Do we support this API request? - funct_ref = HANDLERS.get(message[ATTR_HEADER][ATTR_NAME]) + funct_ref = HANDLERS.get((namespace, name)) if not funct_ref: _LOGGER.warning( - "Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME]) + "Unsupported API request %s/%s", namespace, name) return api_error(message) return (yield from funct_ref(hass, message)) -def api_message(name, namespace, payload=None): +def api_message(request, name='Response', namespace='Alexa', payload=None): """Create a API formatted response message. Async friendly. """ payload = payload or {} - return { - ATTR_HEADER: { - ATTR_MESSAGE_ID: str(uuid4()), - ATTR_NAME: name, - ATTR_NAMESPACE: namespace, - ATTR_PAYLOAD_VERSION: '2', - }, - ATTR_PAYLOAD: payload, + + response = { + API_EVENT: { + API_HEADER: { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'payloadVersion': '3', + }, + API_PAYLOAD: payload, + } } + # If a correlation token exsits, add it to header / Need by Async requests + token = request[API_HEADER].get('correlationToken') + if token: + response[API_EVENT][API_HEADER]['correlationToken'] = token -def api_error(request, exc='DriverInternalError'): + # Extend event with endpoint object / Need by Async requests + if API_ENDPOINT in request: + response[API_EVENT][API_ENDPOINT] = request[API_ENDPOINT].copy() + + return response + + +def api_error(request, error_type='INTERNAL_ERROR', error_message=""): """Create a API formatted error response. Async friendly. """ - return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE]) + payload = { + 'type': error_type, + 'message': error_message, + } + + return api_message(request, name='ErrorResponse', payload=payload) -@HANDLERS.register('DiscoverAppliancesRequest') +@HANDLERS.register(('Alexa.Discovery', 'Discover')) @asyncio.coroutine def async_api_discovery(hass, request): """Create a API formatted discovery response. Async friendly. """ - discovered_appliances = [] + discovery_endpoints = [] for entity in hass.states.async_all(): class_data = MAPPING_COMPONENT.get(entity.domain) @@ -84,35 +107,42 @@ def async_api_discovery(hass, request): if not class_data: continue - appliance = { - 'actions': [], - 'applianceTypes': [class_data[0]], + endpoint = { + 'displayCategories': [class_data[0]], 'additionalApplianceDetails': {}, - 'applianceId': entity.entity_id.replace('.', '#'), - 'friendlyDescription': '', + 'endpointId': entity.entity_id.replace('.', '#'), 'friendlyName': entity.name, - 'isReachable': True, + 'description': '', 'manufacturerName': 'Unknown', - 'modelName': 'Unknown', - 'version': 'Unknown', } + actions = set() # static actions if class_data[1]: - appliance['actions'].extend(list(class_data[1])) + actions |= set(class_data[1]) # dynamic actions if class_data[2]: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) for feature, action_name in class_data[2].items(): if feature & supported > 0: - appliance['actions'].append(action_name) + actions.add(action_name) - discovered_appliances.append(appliance) + # Write action into capabilities + capabilities = [] + for action in actions: + capabilities.append({ + 'type': 'AlexaInterface', + 'interface': action, + 'version': 3, + }) + + endpoint['capabilities'] = capabilities + discovery_endpoints.append(endpoint) return api_message( - 'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery', - payload={'discoveredAppliances': discovered_appliances}) + request, name='Discover.Response', namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}) def extract_entity(funct): @@ -120,22 +150,21 @@ def extract_entity(funct): @asyncio.coroutine def async_api_entity_wrapper(hass, request): """Process a turn on request.""" - entity_id = \ - request[ATTR_PAYLOAD]['appliance']['applianceId'].replace('#', '.') + entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.') # extract state object entity = hass.states.get(entity_id) if not entity: _LOGGER.error("Can't process %s for %s", - request[ATTR_HEADER][ATTR_NAME], entity_id) - return api_error(request) + request[API_HEADER]['name'], entity_id) + return api_error(request, error_type='NO_SUCH_ENDPOINT') return (yield from funct(hass, request, entity)) return async_api_entity_wrapper -@HANDLERS.register('TurnOnRequest') +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) @extract_entity @asyncio.coroutine def async_api_turn_on(hass, request, entity): @@ -144,10 +173,10 @@ def async_api_turn_on(hass, request, entity): ATTR_ENTITY_ID: entity.entity_id }, blocking=True) - return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control') + return api_message(request) -@HANDLERS.register('TurnOffRequest') +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) @extract_entity @asyncio.coroutine def async_api_turn_off(hass, request, entity): @@ -156,22 +185,19 @@ def async_api_turn_off(hass, request, entity): ATTR_ENTITY_ID: entity.entity_id }, blocking=True) - return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control') + return api_message(request) -@HANDLERS.register('SetPercentageRequest') +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) @extract_entity @asyncio.coroutine -def async_api_set_percentage(hass, request, entity): - """Process a set percentage request.""" - if entity.domain == light.DOMAIN: - brightness = request[ATTR_PAYLOAD]['percentageState']['value'] - yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_BRIGHTNESS: brightness, - }, blocking=True) - else: - return api_error(request) +def async_api_set_brightness(hass, request, entity): + """Process a set brightness request.""" + brightness = request[API_PAYLOAD]['brightness'] - return api_message( - 'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control') + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS: brightness, + }, blocking=True) + + return api_message(request) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 22cd149009f..1c1fcfb7594 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,5 +1,6 @@ """Test for smart home alexa support.""" import asyncio +from uuid import uuid4 import pytest @@ -8,22 +9,86 @@ from homeassistant.components.alexa import smart_home from tests.common import async_mock_service -def test_create_api_message(): - """Create a API message.""" - msg = smart_home.api_message('testName', 'testNameSpace') +def get_new_request(namespace, name, endpoint=None): + """Generate a new API message.""" + raw_msg = { + 'directive': { + 'header': { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'correlationToken': str(uuid4()), + 'payloadVersion': '3', + }, + 'endpoint': { + 'scope': { + 'type': 'BearerToken', + 'token': str(uuid4()), + }, + 'endpointId': endpoint, + }, + 'payload': {}, + } + } + + if not endpoint: + raw_msg['directive'].pop('endpoint') + + return raw_msg + + +def test_create_api_message_defaults(): + """Create a API message response of a request with defaults.""" + request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy') + request = request['directive'] + + msg = smart_home.api_message(request, payload={'test': 3}) + + assert 'event' in msg + msg = msg['event'] assert msg['header']['messageId'] is not None + assert msg['header']['messageId'] != request['header']['messageId'] + assert msg['header']['correlationToken'] == \ + request['header']['correlationToken'] + assert msg['header']['name'] == 'Response' + assert msg['header']['namespace'] == 'Alexa' + assert msg['header']['payloadVersion'] == '3' + + assert 'test' in msg['payload'] + assert msg['payload']['test'] == 3 + + assert msg['endpoint'] == request['endpoint'] + + +def test_create_api_message_special(): + """Create a API message response of a request with non defaults.""" + request = get_new_request('Alexa.PowerController', 'TurnOn') + request = request['directive'] + + request['header'].pop('correlationToken') + + msg = smart_home.api_message(request, 'testName', 'testNameSpace') + + assert 'event' in msg + msg = msg['event'] + + assert msg['header']['messageId'] is not None + assert msg['header']['messageId'] != request['header']['messageId'] + assert 'correlationToken' not in msg['header'] assert msg['header']['name'] == 'testName' assert msg['header']['namespace'] == 'testNameSpace' - assert msg['header']['payloadVersion'] == '2' + assert msg['header']['payloadVersion'] == '3' + assert msg['payload'] == {} + assert 'endpoint' not in msg @asyncio.coroutine def test_wrong_version(hass): """Test with wrong version.""" - msg = smart_home.api_message('testName', 'testNameSpace') - msg['header']['payloadVersion'] = '3' + msg = get_new_request('Alexa.PowerController', 'TurnOn') + msg['directive']['header']['payloadVersion'] = '2' with pytest.raises(AssertionError): yield from smart_home.async_handle_message(hass, msg) @@ -32,8 +97,7 @@ def test_wrong_version(hass): @asyncio.coroutine def test_discovery_request(hass): """Test alexa discovery request.""" - msg = smart_home.api_message( - 'DiscoverAppliancesRequest', 'Alexa.ConnectedHome.Discovery') + request = get_new_request('Alexa.Discovery', 'Discover') # settup test devices hass.states.async_set( @@ -46,30 +110,44 @@ def test_discovery_request(hass): 'friendly_name': "Test light 2", 'supported_features': 1 }) - resp = yield from smart_home.async_api_discovery(hass, msg) + msg = yield from smart_home.async_handle_message(hass, request) - assert len(resp['payload']['discoveredAppliances']) == 3 - assert resp['header']['name'] == 'DiscoverAppliancesResponse' - assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Discovery' + assert 'event' in msg + msg = msg['event'] - for i, appliance in enumerate(resp['payload']['discoveredAppliances']): - if appliance['applianceId'] == 'switch#test': - assert appliance['applianceTypes'][0] == "SWITCH" + assert len(msg['payload']['endpoints']) == 3 + assert msg['header']['name'] == 'Discover.Response' + assert msg['header']['namespace'] == 'Alexa.Discovery' + + for appliance in msg['payload']['endpoints']: + if appliance['endpointId'] == 'switch#test': + assert appliance['displayCategories'][0] == "SWITCH" assert appliance['friendlyName'] == "Test switch" - assert appliance['actions'] == ['turnOff', 'turnOn'] + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' continue - if appliance['applianceId'] == 'light#test_1': - assert appliance['applianceTypes'][0] == "LIGHT" + if appliance['endpointId'] == 'light#test_1': + assert appliance['displayCategories'][0] == "LIGHT" assert appliance['friendlyName'] == "Test light 1" - assert appliance['actions'] == ['turnOff', 'turnOn'] + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' continue - if appliance['applianceId'] == 'light#test_2': - assert appliance['applianceTypes'][0] == "LIGHT" + if appliance['endpointId'] == 'light#test_2': + assert appliance['displayCategories'][0] == "LIGHT" assert appliance['friendlyName'] == "Test light 2" - assert appliance['actions'] == \ - ['turnOff', 'turnOn', 'setPercentage'] + assert len(appliance['capabilities']) == 2 + + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.BrightnessController' in caps + assert 'Alexa.PowerController' in caps + continue raise AssertionError("Unknown appliance!") @@ -78,31 +156,41 @@ def test_discovery_request(hass): @asyncio.coroutine def test_api_entity_not_exists(hass): """Test api turn on process without entity.""" - msg_switch = smart_home.api_message( - 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': 'switch#test' - } - }) + request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test') call_switch = async_mock_service(hass, 'switch', 'turn_on') - resp = yield from smart_home.async_api_turn_on(hass, msg_switch) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call_switch) == 0 - assert resp['header']['name'] == 'DriverInternalError' - assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Control' + assert msg['header']['name'] == 'ErrorResponse' + assert msg['header']['namespace'] == 'Alexa' + assert msg['payload']['type'] == 'NO_SUCH_ENDPOINT' + + +@asyncio.coroutine +def test_api_function_not_implemented(hass): + """Test api call that is not implemented to us.""" + request = get_new_request('Alexa.HAHAAH', 'Sweet') + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert msg['header']['name'] == 'ErrorResponse' + assert msg['header']['namespace'] == 'Alexa' + assert msg['payload']['type'] == 'INTERNAL_ERROR' @asyncio.coroutine @pytest.mark.parametrize("domain", ['light', 'switch']) def test_api_turn_on(hass, domain): """Test api turn on process.""" - msg = smart_home.api_message( - 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': '{}#test'.format(domain) - } - }) + request = get_new_request( + 'Alexa.PowerController', 'TurnOn', '{}#test'.format(domain)) # settup test devices hass.states.async_set( @@ -112,22 +200,22 @@ def test_api_turn_on(hass, domain): call = async_mock_service(hass, domain, 'turn_on') - resp = yield from smart_home.async_api_turn_on(hass, msg) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call) == 1 assert call[0].data['entity_id'] == '{}.test'.format(domain) - assert resp['header']['name'] == 'TurnOnConfirmation' + assert msg['header']['name'] == 'Response' @asyncio.coroutine @pytest.mark.parametrize("domain", ['light', 'switch']) def test_api_turn_off(hass, domain): """Test api turn on process.""" - msg = smart_home.api_message( - 'TurnOffRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': '{}#test'.format(domain) - } - }) + request = get_new_request( + 'Alexa.PowerController', 'TurnOff', '{}#test'.format(domain)) # settup test devices hass.states.async_set( @@ -137,24 +225,24 @@ def test_api_turn_off(hass, domain): call = async_mock_service(hass, domain, 'turn_off') - resp = yield from smart_home.async_api_turn_off(hass, msg) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call) == 1 assert call[0].data['entity_id'] == '{}.test'.format(domain) - assert resp['header']['name'] == 'TurnOffConfirmation' + assert msg['header']['name'] == 'Response' @asyncio.coroutine -def test_api_set_percentage_light(hass): +def test_api_set_brightness(hass): """Test api set brightness process.""" - msg_light = smart_home.api_message( - 'SetPercentageRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': 'light#test' - }, - 'percentageState': { - 'value': '50' - } - }) + request = get_new_request( + 'Alexa.BrightnessController', 'SetBrightness', 'light#test') + + # add payload + request['directive']['payload']['brightness'] = '50' # settup test devices hass.states.async_set( @@ -162,8 +250,12 @@ def test_api_set_percentage_light(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - resp = yield from smart_home.async_api_set_percentage(hass, msg_light) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call_light) == 1 assert call_light[0].data['entity_id'] == 'light.test' assert call_light[0].data['brightness'] == '50' - assert resp['header']['name'] == 'SetPercentageConfirmation' + assert msg['header']['name'] == 'Response'