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
This commit is contained in:
Pascal Vizeli 2017-10-07 22:31:57 +02:00 committed by Paulus Schoutsen
parent 4342d7aa17
commit c1f156fd2b
2 changed files with 237 additions and 119 deletions

View file

@ -11,19 +11,18 @@ from homeassistant.util.decorator import Registry
HANDLERS = Registry() HANDLERS = Registry()
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_HEADER = 'header' API_DIRECTIVE = 'directive'
ATTR_NAME = 'name' API_EVENT = 'event'
ATTR_NAMESPACE = 'namespace' API_HEADER = 'header'
ATTR_MESSAGE_ID = 'messageId' API_PAYLOAD = 'payload'
ATTR_PAYLOAD = 'payload' API_ENDPOINT = 'endpoint'
ATTR_PAYLOAD_VERSION = 'payloadVersion'
MAPPING_COMPONENT = { MAPPING_COMPONENT = {
switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None], switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None],
light.DOMAIN: [ light.DOMAIN: [
'LIGHT', ('turnOff', 'turnOn'), { 'LIGHT', ('Alexa.PowerController',), {
light.SUPPORT_BRIGHTNESS: 'setPercentage' light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController'
} }
], ],
} }
@ -32,51 +31,75 @@ MAPPING_COMPONENT = {
@asyncio.coroutine @asyncio.coroutine
def async_handle_message(hass, message): def async_handle_message(hass, message):
"""Handle incoming API messages.""" """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? # 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: if not funct_ref:
_LOGGER.warning( _LOGGER.warning(
"Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME]) "Unsupported API request %s/%s", namespace, name)
return api_error(message) return api_error(message)
return (yield from funct_ref(hass, 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. """Create a API formatted response message.
Async friendly. Async friendly.
""" """
payload = payload or {} payload = payload or {}
return {
ATTR_HEADER: { response = {
ATTR_MESSAGE_ID: str(uuid4()), API_EVENT: {
ATTR_NAME: name, API_HEADER: {
ATTR_NAMESPACE: namespace, 'namespace': namespace,
ATTR_PAYLOAD_VERSION: '2', 'name': name,
}, 'messageId': str(uuid4()),
ATTR_PAYLOAD: payload, '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. """Create a API formatted error response.
Async friendly. 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 @asyncio.coroutine
def async_api_discovery(hass, request): def async_api_discovery(hass, request):
"""Create a API formatted discovery response. """Create a API formatted discovery response.
Async friendly. Async friendly.
""" """
discovered_appliances = [] discovery_endpoints = []
for entity in hass.states.async_all(): for entity in hass.states.async_all():
class_data = MAPPING_COMPONENT.get(entity.domain) class_data = MAPPING_COMPONENT.get(entity.domain)
@ -84,35 +107,42 @@ def async_api_discovery(hass, request):
if not class_data: if not class_data:
continue continue
appliance = { endpoint = {
'actions': [], 'displayCategories': [class_data[0]],
'applianceTypes': [class_data[0]],
'additionalApplianceDetails': {}, 'additionalApplianceDetails': {},
'applianceId': entity.entity_id.replace('.', '#'), 'endpointId': entity.entity_id.replace('.', '#'),
'friendlyDescription': '',
'friendlyName': entity.name, 'friendlyName': entity.name,
'isReachable': True, 'description': '',
'manufacturerName': 'Unknown', 'manufacturerName': 'Unknown',
'modelName': 'Unknown',
'version': 'Unknown',
} }
actions = set()
# static actions # static actions
if class_data[1]: if class_data[1]:
appliance['actions'].extend(list(class_data[1])) actions |= set(class_data[1])
# dynamic actions # dynamic actions
if class_data[2]: if class_data[2]:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
for feature, action_name in class_data[2].items(): for feature, action_name in class_data[2].items():
if feature & supported > 0: 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( return api_message(
'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery', request, name='Discover.Response', namespace='Alexa.Discovery',
payload={'discoveredAppliances': discovered_appliances}) payload={'endpoints': discovery_endpoints})
def extract_entity(funct): def extract_entity(funct):
@ -120,22 +150,21 @@ def extract_entity(funct):
@asyncio.coroutine @asyncio.coroutine
def async_api_entity_wrapper(hass, request): def async_api_entity_wrapper(hass, request):
"""Process a turn on request.""" """Process a turn on request."""
entity_id = \ entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.')
request[ATTR_PAYLOAD]['appliance']['applianceId'].replace('#', '.')
# extract state object # extract state object
entity = hass.states.get(entity_id) entity = hass.states.get(entity_id)
if not entity: if not entity:
_LOGGER.error("Can't process %s for %s", _LOGGER.error("Can't process %s for %s",
request[ATTR_HEADER][ATTR_NAME], entity_id) request[API_HEADER]['name'], entity_id)
return api_error(request) return api_error(request, error_type='NO_SUCH_ENDPOINT')
return (yield from funct(hass, request, entity)) return (yield from funct(hass, request, entity))
return async_api_entity_wrapper return async_api_entity_wrapper
@HANDLERS.register('TurnOnRequest') @HANDLERS.register(('Alexa.PowerController', 'TurnOn'))
@extract_entity @extract_entity
@asyncio.coroutine @asyncio.coroutine
def async_api_turn_on(hass, request, entity): 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 ATTR_ENTITY_ID: entity.entity_id
}, blocking=True) }, blocking=True)
return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control') return api_message(request)
@HANDLERS.register('TurnOffRequest') @HANDLERS.register(('Alexa.PowerController', 'TurnOff'))
@extract_entity @extract_entity
@asyncio.coroutine @asyncio.coroutine
def async_api_turn_off(hass, request, entity): 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 ATTR_ENTITY_ID: entity.entity_id
}, blocking=True) }, blocking=True)
return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control') return api_message(request)
@HANDLERS.register('SetPercentageRequest') @HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness'))
@extract_entity @extract_entity
@asyncio.coroutine @asyncio.coroutine
def async_api_set_percentage(hass, request, entity): def async_api_set_brightness(hass, request, entity):
"""Process a set percentage request.""" """Process a set brightness request."""
if entity.domain == light.DOMAIN: brightness = request[API_PAYLOAD]['brightness']
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( yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control') ATTR_ENTITY_ID: entity.entity_id,
light.ATTR_BRIGHTNESS: brightness,
}, blocking=True)
return api_message(request)

View file

@ -1,5 +1,6 @@
"""Test for smart home alexa support.""" """Test for smart home alexa support."""
import asyncio import asyncio
from uuid import uuid4
import pytest import pytest
@ -8,22 +9,86 @@ from homeassistant.components.alexa import smart_home
from tests.common import async_mock_service from tests.common import async_mock_service
def test_create_api_message(): def get_new_request(namespace, name, endpoint=None):
"""Create a API message.""" """Generate a new API message."""
msg = smart_home.api_message('testName', 'testNameSpace') 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'] 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']['name'] == 'testName'
assert msg['header']['namespace'] == 'testNameSpace' assert msg['header']['namespace'] == 'testNameSpace'
assert msg['header']['payloadVersion'] == '2' assert msg['header']['payloadVersion'] == '3'
assert msg['payload'] == {} assert msg['payload'] == {}
assert 'endpoint' not in msg
@asyncio.coroutine @asyncio.coroutine
def test_wrong_version(hass): def test_wrong_version(hass):
"""Test with wrong version.""" """Test with wrong version."""
msg = smart_home.api_message('testName', 'testNameSpace') msg = get_new_request('Alexa.PowerController', 'TurnOn')
msg['header']['payloadVersion'] = '3' msg['directive']['header']['payloadVersion'] = '2'
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
yield from smart_home.async_handle_message(hass, msg) yield from smart_home.async_handle_message(hass, msg)
@ -32,8 +97,7 @@ def test_wrong_version(hass):
@asyncio.coroutine @asyncio.coroutine
def test_discovery_request(hass): def test_discovery_request(hass):
"""Test alexa discovery request.""" """Test alexa discovery request."""
msg = smart_home.api_message( request = get_new_request('Alexa.Discovery', 'Discover')
'DiscoverAppliancesRequest', 'Alexa.ConnectedHome.Discovery')
# settup test devices # settup test devices
hass.states.async_set( hass.states.async_set(
@ -46,30 +110,44 @@ def test_discovery_request(hass):
'friendly_name': "Test light 2", 'supported_features': 1 '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 'event' in msg
assert resp['header']['name'] == 'DiscoverAppliancesResponse' msg = msg['event']
assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Discovery'
for i, appliance in enumerate(resp['payload']['discoveredAppliances']): assert len(msg['payload']['endpoints']) == 3
if appliance['applianceId'] == 'switch#test': assert msg['header']['name'] == 'Discover.Response'
assert appliance['applianceTypes'][0] == "SWITCH" 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['friendlyName'] == "Test switch"
assert appliance['actions'] == ['turnOff', 'turnOn'] assert len(appliance['capabilities']) == 1
assert appliance['capabilities'][-1]['interface'] == \
'Alexa.PowerController'
continue continue
if appliance['applianceId'] == 'light#test_1': if appliance['endpointId'] == 'light#test_1':
assert appliance['applianceTypes'][0] == "LIGHT" assert appliance['displayCategories'][0] == "LIGHT"
assert appliance['friendlyName'] == "Test light 1" assert appliance['friendlyName'] == "Test light 1"
assert appliance['actions'] == ['turnOff', 'turnOn'] assert len(appliance['capabilities']) == 1
assert appliance['capabilities'][-1]['interface'] == \
'Alexa.PowerController'
continue continue
if appliance['applianceId'] == 'light#test_2': if appliance['endpointId'] == 'light#test_2':
assert appliance['applianceTypes'][0] == "LIGHT" assert appliance['displayCategories'][0] == "LIGHT"
assert appliance['friendlyName'] == "Test light 2" assert appliance['friendlyName'] == "Test light 2"
assert appliance['actions'] == \ assert len(appliance['capabilities']) == 2
['turnOff', 'turnOn', 'setPercentage']
caps = set()
for feature in appliance['capabilities']:
caps.add(feature['interface'])
assert 'Alexa.BrightnessController' in caps
assert 'Alexa.PowerController' in caps
continue continue
raise AssertionError("Unknown appliance!") raise AssertionError("Unknown appliance!")
@ -78,31 +156,41 @@ def test_discovery_request(hass):
@asyncio.coroutine @asyncio.coroutine
def test_api_entity_not_exists(hass): def test_api_entity_not_exists(hass):
"""Test api turn on process without entity.""" """Test api turn on process without entity."""
msg_switch = smart_home.api_message( request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test')
'TurnOnRequest', 'Alexa.ConnectedHome.Control', {
'appliance': {
'applianceId': 'switch#test'
}
})
call_switch = async_mock_service(hass, 'switch', 'turn_on') 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 len(call_switch) == 0
assert resp['header']['name'] == 'DriverInternalError' assert msg['header']['name'] == 'ErrorResponse'
assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Control' 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 @asyncio.coroutine
@pytest.mark.parametrize("domain", ['light', 'switch']) @pytest.mark.parametrize("domain", ['light', 'switch'])
def test_api_turn_on(hass, domain): def test_api_turn_on(hass, domain):
"""Test api turn on process.""" """Test api turn on process."""
msg = smart_home.api_message( request = get_new_request(
'TurnOnRequest', 'Alexa.ConnectedHome.Control', { 'Alexa.PowerController', 'TurnOn', '{}#test'.format(domain))
'appliance': {
'applianceId': '{}#test'.format(domain)
}
})
# settup test devices # settup test devices
hass.states.async_set( hass.states.async_set(
@ -112,22 +200,22 @@ def test_api_turn_on(hass, domain):
call = async_mock_service(hass, domain, 'turn_on') 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 len(call) == 1
assert call[0].data['entity_id'] == '{}.test'.format(domain) assert call[0].data['entity_id'] == '{}.test'.format(domain)
assert resp['header']['name'] == 'TurnOnConfirmation' assert msg['header']['name'] == 'Response'
@asyncio.coroutine @asyncio.coroutine
@pytest.mark.parametrize("domain", ['light', 'switch']) @pytest.mark.parametrize("domain", ['light', 'switch'])
def test_api_turn_off(hass, domain): def test_api_turn_off(hass, domain):
"""Test api turn on process.""" """Test api turn on process."""
msg = smart_home.api_message( request = get_new_request(
'TurnOffRequest', 'Alexa.ConnectedHome.Control', { 'Alexa.PowerController', 'TurnOff', '{}#test'.format(domain))
'appliance': {
'applianceId': '{}#test'.format(domain)
}
})
# settup test devices # settup test devices
hass.states.async_set( hass.states.async_set(
@ -137,24 +225,24 @@ def test_api_turn_off(hass, domain):
call = async_mock_service(hass, domain, 'turn_off') 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 len(call) == 1
assert call[0].data['entity_id'] == '{}.test'.format(domain) assert call[0].data['entity_id'] == '{}.test'.format(domain)
assert resp['header']['name'] == 'TurnOffConfirmation' assert msg['header']['name'] == 'Response'
@asyncio.coroutine @asyncio.coroutine
def test_api_set_percentage_light(hass): def test_api_set_brightness(hass):
"""Test api set brightness process.""" """Test api set brightness process."""
msg_light = smart_home.api_message( request = get_new_request(
'SetPercentageRequest', 'Alexa.ConnectedHome.Control', { 'Alexa.BrightnessController', 'SetBrightness', 'light#test')
'appliance': {
'applianceId': 'light#test' # add payload
}, request['directive']['payload']['brightness'] = '50'
'percentageState': {
'value': '50'
}
})
# settup test devices # settup test devices
hass.states.async_set( hass.states.async_set(
@ -162,8 +250,12 @@ def test_api_set_percentage_light(hass):
call_light = async_mock_service(hass, 'light', 'turn_on') 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 len(call_light) == 1
assert call_light[0].data['entity_id'] == 'light.test' assert call_light[0].data['entity_id'] == 'light.test'
assert call_light[0].data['brightness'] == '50' assert call_light[0].data['brightness'] == '50'
assert resp['header']['name'] == 'SetPercentageConfirmation' assert msg['header']['name'] == 'Response'