From c2bbc2f74edfbee1b5e62dd5835ca708ab5d63fa Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 16 Sep 2017 21:35:28 +0200 Subject: [PATCH] Alexa smart home native support (#9443) * Init commit for alexa component * more struct component * Add mapping for device/component to alexa action * finish discovery * First version with support on/off/percent * fix spell * First init tests * fix tests & lint * add tests & fix bugs * optimaze tests * more tests * Finish tests * fix lint * Address paulus comments * fix lint * Fix lint p2 * Optimaze & paulus comment --- homeassistant/components/alexa/smart_home.py | 185 +++++++++++++++++++ tests/components/alexa/test_smart_home.py | 182 ++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 homeassistant/components/alexa/smart_home.py create mode 100644 tests/components/alexa/test_smart_home.py diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py new file mode 100644 index 00000000000..aa4b1cbec70 --- /dev/null +++ b/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,185 @@ +"""Support for alexa Smart Home Skill API.""" +import asyncio +import logging +from uuid import uuid4 + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) +from homeassistant.components import switch, light + +_LOGGER = logging.getLogger(__name__) + +ATTR_HEADER = 'header' +ATTR_NAME = 'name' +ATTR_NAMESPACE = 'namespace' +ATTR_MESSAGE_ID = 'messageId' +ATTR_PAYLOAD = 'payload' +ATTR_PAYLOAD_VERSION = 'payloadVersion' + + +MAPPING_COMPONENT = { + switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None], + light.DOMAIN: [ + 'LIGHT', ('turnOff', 'turnOn'), { + light.SUPPORT_BRIGHTNESS: 'setPercentage' + } + ], +} + + +def mapping_api_function(name): + """Return function pointer to api function for name. + + Async friendly. + """ + mapping = { + 'DiscoverAppliancesRequest': async_api_discovery, + 'TurnOnRequest': async_api_turn_on, + 'TurnOffRequest': async_api_turn_off, + 'SetPercentageRequest': async_api_set_percentage, + } + return mapping.get(name, None) + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle incomming API messages.""" + assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2 + + # Do we support this API request? + funct_ref = mapping_api_function(message[ATTR_HEADER][ATTR_NAME]) + if not funct_ref: + _LOGGER.warning( + "Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME]) + return api_error(message) + + return (yield from funct_ref(hass, message)) + + +def api_message(name, namespace, payload=None): + """Create a API formated response message. + + Async friendly. + """ + payload = payload or {} + return { + ATTR_HEADER: { + ATTR_MESSAGE_ID: uuid4(), + ATTR_NAME: name, + ATTR_NAMESPACE: namespace, + ATTR_PAYLOAD_VERSION: '2', + }, + ATTR_PAYLOAD: payload, + } + + +def api_error(request, exc='DriverInternalError'): + """Create a API formated error response. + + Async friendly. + """ + return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE]) + + +@asyncio.coroutine +def async_api_discovery(hass, request): + """Create a API formated discovery response. + + Async friendly. + """ + discovered_appliances = [] + + for entity in hass.states.async_all(): + class_data = MAPPING_COMPONENT.get(entity.domain) + + if not class_data: + continue + + appliance = { + 'actions': [], + 'applianceTypes': [class_data[0]], + 'additionalApplianceDetails': {}, + 'applianceId': entity.entity_id.replace('.', '#'), + 'friendlyDescription': '', + 'friendlyName': entity.name, + 'isReachable': True, + 'manufacturerName': 'Unknown', + 'modelName': 'Unknown', + 'version': 'Unknown', + } + + # static actions + if class_data[1]: + appliance['actions'].extend(list(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) + + discovered_appliances.append(appliance) + + return api_message( + 'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery', + payload={'discoveredAppliances': discovered_appliances}) + + +def extract_entity(funct): + """Decorator for extract entity object from request.""" + @asyncio.coroutine + def async_api_entity_wrapper(hass, request): + """Process a turn on request.""" + entity_id = \ + request[ATTR_PAYLOAD]['appliance']['applianceId'].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) + + return (yield from funct(hass, request, entity)) + + return async_api_entity_wrapper + + +@extract_entity +@asyncio.coroutine +def async_api_turn_on(hass, request, entity): + """Process a turn on request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control') + + +@extract_entity +@asyncio.coroutine +def async_api_turn_off(hass, request, entity): + """Process a turn off request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control') + + +@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) + + return api_message( + 'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control') diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py new file mode 100644 index 00000000000..0c2b133bdfb --- /dev/null +++ b/tests/components/alexa/test_smart_home.py @@ -0,0 +1,182 @@ +"""Test for smart home alexa support.""" +import asyncio + +import pytest + +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') + + assert msg['header']['messageId'] is not None + assert msg['header']['name'] == 'testName' + assert msg['header']['namespace'] == 'testNameSpace' + assert msg['header']['payloadVersion'] == '2' + assert msg['payload'] == {} + + +def test_mapping_api_funct(): + """Test function ref from mapping function.""" + assert smart_home.mapping_api_function('notExists') is None + assert smart_home.mapping_api_function('DiscoverAppliancesRequest') == \ + smart_home.async_api_discovery + assert smart_home.mapping_api_function('TurnOnRequest') == \ + smart_home.async_api_turn_on + assert smart_home.mapping_api_function('TurnOffRequest') == \ + smart_home.async_api_turn_off + assert smart_home.mapping_api_function('SetPercentageRequest') == \ + smart_home.async_api_set_percentage + + +@asyncio.coroutine +def test_wrong_version(hass): + """Test with wrong version.""" + msg = smart_home.api_message('testName', 'testNameSpace') + msg['header']['payloadVersion'] = '3' + + with pytest.raises(AssertionError): + yield from smart_home.async_handle_message(hass, msg) + + +@asyncio.coroutine +def test_discovery_request(hass): + """Test alexa discovery request.""" + msg = smart_home.api_message( + 'DiscoverAppliancesRequest', 'Alexa.ConnectedHome.Discovery') + + # settup test devices + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + + hass.states.async_set( + 'light.test_1', 'on', {'friendly_name': "Test light 1"}) + hass.states.async_set( + 'light.test_2', 'on', { + 'friendly_name': "Test light 2", 'supported_features': 1 + }) + + resp = yield from smart_home.async_api_discovery(hass, msg) + + assert len(resp['payload']['discoveredAppliances']) == 3 + assert resp['header']['name'] == 'DiscoverAppliancesResponse' + assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Discovery' + + for i, appliance in enumerate(resp['payload']['discoveredAppliances']): + if appliance['applianceId'] == 'switch#test': + assert appliance['applianceTypes'][0] == "SWITCH" + assert appliance['friendlyName'] == "Test switch" + assert appliance['actions'] == ['turnOff', 'turnOn'] + continue + + if appliance['applianceId'] == 'light#test_1': + assert appliance['applianceTypes'][0] == "LIGHT" + assert appliance['friendlyName'] == "Test light 1" + assert appliance['actions'] == ['turnOff', 'turnOn'] + continue + + if appliance['applianceId'] == 'light#test_2': + assert appliance['applianceTypes'][0] == "LIGHT" + assert appliance['friendlyName'] == "Test light 2" + assert appliance['actions'] == \ + ['turnOff', 'turnOn', 'setPercentage'] + continue + + raise AssertionError("Unknown appliance!") + + +@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' + } + }) + + call_switch = async_mock_service(hass, 'switch', 'turn_on') + + resp = yield from smart_home.async_api_turn_on(hass, msg_switch) + assert len(call_switch) == 0 + assert resp['header']['name'] == 'DriverInternalError' + assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Control' + + +@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) + } + }) + + # settup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_on') + + resp = yield from smart_home.async_api_turn_on(hass, msg) + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert resp['header']['name'] == 'TurnOnConfirmation' + + +@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) + } + }) + + # settup test devices + hass.states.async_set( + '{}.test'.format(domain), 'on', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_off') + + resp = yield from smart_home.async_api_turn_off(hass, msg) + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert resp['header']['name'] == 'TurnOffConfirmation' + + +@asyncio.coroutine +def test_api_set_percentage_light(hass): + """Test api set brightness process.""" + msg_light = smart_home.api_message( + 'SetPercentageRequest', 'Alexa.ConnectedHome.Control', { + 'appliance': { + 'applianceId': 'light#test' + }, + 'percentageState': { + 'value': '50' + } + }) + + # settup test devices + hass.states.async_set( + 'light.test', 'off', {'friendly_name': "Test light"}) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + resp = yield from smart_home.async_api_set_percentage(hass, msg_light) + 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'