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
This commit is contained in:
Pascal Vizeli 2017-09-16 21:35:28 +02:00 committed by Paulus Schoutsen
parent 73a15ddd64
commit c2bbc2f74e
2 changed files with 367 additions and 0 deletions

View File

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

View File

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