Report scripts and groups as scenes to Alexa (#11900)

* Send Alexa Smart Home responses to debug log

* Report scripts and groups as scenes to Alexa

The Alexa API docs have a couple display categories that sound relevant
to scenes or scripts:

    ACTIVITY_TRIGGER: Describes a combination of devices set to a
    specific state, when the state change must occur in a specific
    order.  For example, a “watch Neflix” scene might require the: 1. TV
    to be powered on & 2. Input set to HDMI1.

    SCENE_TRIGGER: Describes a combination of devices set to a specific
    state, when the order of the state change is not important. For
    example a bedtime scene might include turning off lights and
    lowering the thermostat, but the order is unimportant.

Additionally, Alexa has a notion of scenes that support deactivation.
This is a natural fit for groups, and scripts with delays which can be
cancelled.

https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories

The mechanism to map entities to the Alexa Discovery response is
refactored since extending the data structures in MAPPING_COMPONENT to
implement supportsDeactivation would have added complication to what I
already found to be a confusing construct.
This commit is contained in:
Phil Frost 2018-01-26 05:06:57 +00:00 committed by Paulus Schoutsen
parent 3aa3130d05
commit 920f9f132b
2 changed files with 382 additions and 86 deletions

View file

@ -34,43 +34,268 @@ CONF_DISPLAY_CATEGORIES = 'display_categories'
HANDLERS = Registry()
MAPPING_COMPONENT = {
alert.DOMAIN: ['OTHER', ('Alexa.PowerController',), None],
automation.DOMAIN: ['OTHER', ('Alexa.PowerController',), None],
cover.DOMAIN: [
'DOOR', ('Alexa.PowerController',), {
cover.SUPPORT_SET_POSITION: 'Alexa.PercentageController',
}
],
fan.DOMAIN: [
'OTHER', ('Alexa.PowerController',), {
fan.SUPPORT_SET_SPEED: 'Alexa.PercentageController',
}
],
group.DOMAIN: ['OTHER', ('Alexa.PowerController',), None],
input_boolean.DOMAIN: ['OTHER', ('Alexa.PowerController',), None],
light.DOMAIN: [
'LIGHT', ('Alexa.PowerController',), {
light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController',
light.SUPPORT_RGB_COLOR: 'Alexa.ColorController',
light.SUPPORT_XY_COLOR: 'Alexa.ColorController',
light.SUPPORT_COLOR_TEMP: 'Alexa.ColorTemperatureController',
}
],
lock.DOMAIN: ['SMARTLOCK', ('Alexa.LockController',), None],
media_player.DOMAIN: [
'TV', ('Alexa.PowerController',), {
media_player.SUPPORT_VOLUME_SET: 'Alexa.Speaker',
media_player.SUPPORT_PLAY: 'Alexa.PlaybackController',
media_player.SUPPORT_PAUSE: 'Alexa.PlaybackController',
media_player.SUPPORT_STOP: 'Alexa.PlaybackController',
media_player.SUPPORT_NEXT_TRACK: 'Alexa.PlaybackController',
media_player.SUPPORT_PREVIOUS_TRACK: 'Alexa.PlaybackController',
}
],
scene.DOMAIN: ['ACTIVITY_TRIGGER', ('Alexa.SceneController',), None],
script.DOMAIN: ['OTHER', ('Alexa.PowerController',), None],
switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None],
class _DisplayCategory(object):
"""Possible display categories for Discovery response.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories
"""
# Describes a combination of devices set to a specific state, when the
# state change must occur in a specific order. For example, a "watch
# Neflix" scene might require the: 1. TV to be powered on & 2. Input set to
# HDMI1. Applies to Scenes
ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER"
# Indicates media devices with video or photo capabilities.
CAMERA = "CAMERA"
# Indicates a door.
DOOR = "DOOR"
# Indicates light sources or fixtures.
LIGHT = "LIGHT"
# An endpoint that cannot be described in on of the other categories.
OTHER = "OTHER"
# Describes a combination of devices set to a specific state, when the
# order of the state change is not important. For example a bedtime scene
# might include turning off lights and lowering the thermostat, but the
# order is unimportant. Applies to Scenes
SCENE_TRIGGER = "SCENE_TRIGGER"
# Indicates an endpoint that locks.
SMARTLOCK = "SMARTLOCK"
# Indicates modules that are plugged into an existing electrical outlet.
# Can control a variety of devices.
SMARTPLUG = "SMARTPLUG"
# Indicates the endpoint is a speaker or speaker system.
SPEAKER = "SPEAKER"
# Indicates in-wall switches wired to the electrical system. Can control a
# variety of devices.
SWITCH = "SWITCH"
# Indicates endpoints that report the temperature only.
TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR"
# Indicates endpoints that control temperature, stand-alone air
# conditioners, or heaters with direct temperature control.
THERMOSTAT = "THERMOSTAT"
# Indicates the endpoint is a television.
# pylint: disable=invalid-name
TV = "TV"
def _capability(interface,
version=3,
supports_deactivation=None,
cap_type='AlexaInterface'):
"""Return a Smart Home API capability object.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object
There are some additional fields allowed but not implemented here since
we've no use case for them yet:
- properties.supported
- proactively_reported
- retrievable
`supports_deactivation` applies only to scenes.
"""
result = {
'type': cap_type,
'interface': interface,
'version': version,
}
if supports_deactivation is not None:
result['supportsDeactivation'] = supports_deactivation
return result
class _EntityCapabilities(object):
def __init__(self, config, entity):
self.config = config
self.entity = entity
def display_categories(self):
"""Return a list of display categories."""
entity_conf = self.config.entity_config.get(self.entity.entity_id, {})
if CONF_DISPLAY_CATEGORIES in entity_conf:
return [entity_conf[CONF_DISPLAY_CATEGORIES]]
return self.default_display_categories()
def default_display_categories(self):
"""Return a list of default display categories.
This can be overridden by the user in the Home Assistant configuration.
See also _DisplayCategory.
"""
raise NotImplementedError
def capabilities(self):
"""Return a list of supported capabilities.
You might find _capability() useful.
"""
raise NotImplementedError
class _GenericCapabilities(_EntityCapabilities):
"""A generic, on/off device.
The choice of last resort.
"""
def default_display_categories(self):
return [_DisplayCategory.OTHER]
def capabilities(self):
return [_capability('Alexa.PowerController')]
class _SwitchCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.SWITCH]
def capabilities(self):
return [_capability('Alexa.PowerController')]
class _CoverCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.DOOR]
def capabilities(self):
capabilities = [_capability('Alexa.PowerController')]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & cover.SUPPORT_SET_POSITION:
capabilities.append(_capability('Alexa.PercentageController'))
return capabilities
class _LightCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.LIGHT]
def capabilities(self):
capabilities = [_capability('Alexa.PowerController')]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & light.SUPPORT_BRIGHTNESS:
capabilities.append(_capability('Alexa.BrightnessController'))
if supported & light.SUPPORT_RGB_COLOR:
capabilities.append(_capability('Alexa.ColorController'))
if supported & light.SUPPORT_XY_COLOR:
capabilities.append(_capability('Alexa.ColorController'))
if supported & light.SUPPORT_COLOR_TEMP:
capabilities.append(
_capability('Alexa.ColorTemperatureController'))
return capabilities
class _FanCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.OTHER]
def capabilities(self):
capabilities = [_capability('Alexa.PowerController')]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & fan.SUPPORT_SET_SPEED:
capabilities.append(_capability('Alexa.PercentageController'))
return capabilities
class _LockCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.SMARTLOCK]
def capabilities(self):
return [_capability('Alexa.LockController')]
class _MediaPlayerCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.TV]
def capabilities(self):
capabilities = [_capability('Alexa.PowerController')]
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & media_player.SUPPORT_VOLUME_SET:
capabilities.append(_capability('Alexa.Speaker'))
playback_features = (media_player.SUPPORT_PLAY |
media_player.SUPPORT_PAUSE |
media_player.SUPPORT_STOP |
media_player.SUPPORT_NEXT_TRACK |
media_player.SUPPORT_PREVIOUS_TRACK)
if supported & playback_features:
capabilities.append(_capability('Alexa.PlaybackController'))
return capabilities
class _SceneCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.SCENE_TRIGGER]
def capabilities(self):
return [_capability('Alexa.SceneController')]
class _ScriptCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.ACTIVITY_TRIGGER]
def capabilities(self):
can_cancel = bool(self.entity.attributes.get('can_cancel'))
return [_capability('Alexa.SceneController',
supports_deactivation=can_cancel)]
class _GroupCapabilities(_EntityCapabilities):
def default_display_categories(self):
return [_DisplayCategory.SCENE_TRIGGER]
def capabilities(self):
return [_capability('Alexa.SceneController',
supports_deactivation=True)]
class _UnknownEntityDomainError(Exception):
pass
def _capabilities_for_entity(config, entity):
"""Return an _EntityCapabilities appropriate for given entity.
raises _UnknownEntityDomainError if the given domain is unsupported.
"""
if entity.domain not in _CAPABILITIES_FOR_DOMAIN:
raise _UnknownEntityDomainError()
return _CAPABILITIES_FOR_DOMAIN[entity.domain](config, entity)
_CAPABILITIES_FOR_DOMAIN = {
alert.DOMAIN: _GenericCapabilities,
automation.DOMAIN: _GenericCapabilities,
cover.DOMAIN: _CoverCapabilities,
fan.DOMAIN: _FanCapabilities,
group.DOMAIN: _GroupCapabilities,
input_boolean.DOMAIN: _GenericCapabilities,
light.DOMAIN: _LightCapabilities,
lock.DOMAIN: _LockCapabilities,
media_player.DOMAIN: _MediaPlayerCapabilities,
scene.DOMAIN: _SceneCapabilities,
script.DOMAIN: _ScriptCapabilities,
switch.DOMAIN: _SwitchCapabilities,
}
@ -158,6 +383,7 @@ class SmartHomeView(http.HomeAssistantView):
response = yield from async_handle_message(
hass, self.smart_home_config, message)
_LOGGER.debug("Sending Alexa Smart Home response: %s", response)
return b'' if response is None else self.json(response)
@ -240,9 +466,9 @@ def async_api_discovery(hass, config, request):
entity.entity_id)
continue
class_data = MAPPING_COMPONENT.get(entity.domain)
if not class_data:
try:
entity_capabilities = _capabilities_for_entity(config, entity)
except _UnknownEntityDomainError:
continue
entity_conf = config.entity_config.get(entity.entity_id, {})
@ -255,40 +481,16 @@ def async_api_discovery(hass, config, request):
scene_fmt = '{} (Scene connected via Home Assistant)'
description = scene_fmt.format(description)
display_categories = entity_conf.get(
CONF_DISPLAY_CATEGORIES, class_data[0])
endpoint = {
'displayCategories': [display_categories],
'displayCategories': entity_capabilities.display_categories(),
'additionalApplianceDetails': {},
'endpointId': entity.entity_id.replace('.', '#'),
'friendlyName': friendly_name,
'description': description,
'manufacturerName': 'Home Assistant',
}
actions = set()
# static actions
if 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:
actions.add(action_name)
# Write action into capabilities
capabilities = []
for action in actions:
capabilities.append({
'type': 'AlexaInterface',
'interface': action,
'version': 3,
})
endpoint['capabilities'] = capabilities
endpoint['capabilities'] = entity_capabilities.capabilities()
discovery_endpoints.append(endpoint)
return api_message(
@ -321,8 +523,6 @@ def extract_entity(funct):
def async_api_turn_on(hass, config, request, entity):
"""Process a turn on request."""
domain = entity.domain
if entity.domain == group.DOMAIN:
domain = ha.DOMAIN
service = SERVICE_TURN_ON
if entity.domain == cover.DOMAIN:
@ -460,7 +660,7 @@ def async_api_decrease_color_temp(hass, config, request, entity):
@extract_entity
@asyncio.coroutine
def async_api_increase_color_temp(hass, config, request, entity):
"""Process a increase color temperature request."""
"""Process an increase color temperature request."""
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
@ -477,8 +677,13 @@ def async_api_increase_color_temp(hass, config, request, entity):
@extract_entity
@asyncio.coroutine
def async_api_activate(hass, config, request, entity):
"""Process a activate request."""
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
"""Process an activate request."""
if entity.domain == group.DOMAIN:
domain = ha.DOMAIN
else:
domain = entity.domain
yield from hass.services.async_call(domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False)
@ -495,6 +700,33 @@ def async_api_activate(hass, config, request, entity):
)
@HANDLERS.register(('Alexa.SceneController', 'Deactivate'))
@extract_entity
@asyncio.coroutine
def async_api_deactivate(hass, config, request, entity):
"""Process a deactivate request."""
if entity.domain == group.DOMAIN:
domain = ha.DOMAIN
else:
domain = entity.domain
yield from hass.services.async_call(domain, SERVICE_TURN_OFF, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False)
payload = {
'cause': {'type': _Cause.VOICE_INTERACTION},
'timestamp': '%sZ' % (datetime.utcnow().isoformat(),)
}
return api_message(
request,
name='DeactivationStarted',
namespace='Alexa.SceneController',
payload=payload,
)
@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage'))
@extract_entity
@asyncio.coroutine

View file

@ -122,6 +122,9 @@ def test_discovery_request(hass):
hass.states.async_set(
'script.test', 'off', {'friendly_name': "Test script"})
hass.states.async_set(
'script.test_2', 'off', {'friendly_name': "Test script 2",
'can_cancel': True})
hass.states.async_set(
'input_boolean.test', 'off', {'friendly_name': "Test input boolean"})
@ -169,7 +172,7 @@ def test_discovery_request(hass):
assert 'event' in msg
msg = msg['event']
assert len(msg['payload']['endpoints']) == 15
assert len(msg['payload']['endpoints']) == 16
assert msg['header']['name'] == 'Discover.Response'
assert msg['header']['namespace'] == 'Alexa.Discovery'
@ -221,11 +224,18 @@ def test_discovery_request(hass):
continue
if appliance['endpointId'] == 'script#test':
assert appliance['displayCategories'][0] == "OTHER"
assert appliance['displayCategories'][0] == "ACTIVITY_TRIGGER"
assert appliance['friendlyName'] == "Test script"
assert len(appliance['capabilities']) == 1
assert appliance['capabilities'][-1]['interface'] == \
'Alexa.PowerController'
capability = appliance['capabilities'][-1]
assert capability['interface'] == 'Alexa.SceneController'
assert not capability['supportsDeactivation']
continue
if appliance['endpointId'] == 'script#test_2':
assert len(appliance['capabilities']) == 1
capability = appliance['capabilities'][-1]
assert capability['supportsDeactivation']
continue
if appliance['endpointId'] == 'input_boolean#test':
@ -237,7 +247,7 @@ def test_discovery_request(hass):
continue
if appliance['endpointId'] == 'scene#test':
assert appliance['displayCategories'][0] == "ACTIVITY_TRIGGER"
assert appliance['displayCategories'][0] == "SCENE_TRIGGER"
assert appliance['friendlyName'] == "Test scene"
assert len(appliance['capabilities']) == 1
assert appliance['capabilities'][-1]['interface'] == \
@ -303,11 +313,12 @@ def test_discovery_request(hass):
continue
if appliance['endpointId'] == 'group#test':
assert appliance['displayCategories'][0] == "OTHER"
assert appliance['displayCategories'][0] == "SCENE_TRIGGER"
assert appliance['friendlyName'] == "Test group"
assert len(appliance['capabilities']) == 1
assert appliance['capabilities'][-1]['interface'] == \
'Alexa.PowerController'
capability = appliance['capabilities'][-1]
assert capability['interface'] == 'Alexa.SceneController'
assert capability['supportsDeactivation'] is True
continue
if appliance['endpointId'] == 'cover#test':
@ -425,8 +436,8 @@ def test_api_function_not_implemented(hass):
@asyncio.coroutine
@pytest.mark.parametrize("domain", ['alert', 'automation', 'cover', 'group',
'input_boolean', 'light', 'script',
@pytest.mark.parametrize("domain", ['alert', 'automation', 'cover',
'input_boolean', 'light',
'switch'])
def test_api_turn_on(hass, domain):
"""Test api turn on process."""
@ -441,9 +452,6 @@ def test_api_turn_on(hass, domain):
call_domain = domain
if domain == 'group':
call_domain = 'homeassistant'
if domain == 'cover':
call = async_mock_service(hass, call_domain, 'open_cover')
else:
@ -719,7 +727,7 @@ def test_api_increase_color_temp(hass, result, initial):
@asyncio.coroutine
@pytest.mark.parametrize("domain", ['scene'])
@pytest.mark.parametrize("domain", ['scene', 'group', 'script'])
def test_api_activate(hass, domain):
"""Test api activate process."""
request = get_new_request(
@ -731,7 +739,12 @@ def test_api_activate(hass, domain):
'friendly_name': "Test {}".format(domain)
})
call = async_mock_service(hass, domain, 'turn_on')
if domain == 'group':
call_domain = 'homeassistant'
else:
call_domain = domain
call = async_mock_service(hass, call_domain, 'turn_on')
msg = yield from smart_home.async_handle_message(
hass, DEFAULT_CONFIG, request)
@ -747,6 +760,40 @@ def test_api_activate(hass, domain):
assert 'timestamp' in msg['payload']
@asyncio.coroutine
@pytest.mark.parametrize("domain", ['group', 'script'])
def test_api_deactivate(hass, domain):
"""Test api deactivate process."""
request = get_new_request(
'Alexa.SceneController', 'Deactivate', '{}#test'.format(domain))
# setup test devices
hass.states.async_set(
'{}.test'.format(domain), 'off', {
'friendly_name': "Test {}".format(domain)
})
if domain == 'group':
call_domain = 'homeassistant'
else:
call_domain = domain
call = async_mock_service(hass, call_domain, 'turn_off')
msg = yield from smart_home.async_handle_message(
hass, DEFAULT_CONFIG, request)
yield from hass.async_block_till_done()
assert 'event' in msg
msg = msg['event']
assert len(call) == 1
assert call[0].data['entity_id'] == '{}.test'.format(domain)
assert msg['header']['name'] == 'DeactivationStarted'
assert msg['payload']['cause']['type'] == 'VOICE_INTERACTION'
assert 'timestamp' in msg['payload']
@asyncio.coroutine
def test_api_set_percentage_fan(hass):
"""Test api set percentage for fan process."""
@ -1160,6 +1207,23 @@ def test_entity_config(hass):
'Alexa.PowerController'
@asyncio.coroutine
def test_unsupported_domain(hass):
"""Discovery ignores entities of unknown domains."""
request = get_new_request('Alexa.Discovery', 'Discover')
hass.states.async_set(
'woz.boop', 'on', {'friendly_name': "Boop Woz"})
msg = yield from smart_home.async_handle_message(
hass, DEFAULT_CONFIG, request)
assert 'event' in msg
msg = msg['event']
assert len(msg['payload']['endpoints']) == 0
@asyncio.coroutine
def do_http_discovery(config, hass, test_client):
"""Submit a request to the Smart Home HTTP API."""