From e16793013a65a42503a922866c828dc9bc64c2bb Mon Sep 17 00:00:00 2001 From: Phil Frost Date: Mon, 29 Oct 2018 22:16:35 -0400 Subject: [PATCH] Refactor Alexa API, fix thermostats (#17969) * Refactor Alexa API to use objects for requests This introduces _AlexaDirective to stand in for the previous model of passing basic dict and list data structures to and from handlers. This gives a more expressive platform for functionality common to most or all handlers. I had two use cases in mind: 1) Most responses should include current properties. In the case of locks and thermostats, the response must include the properties or Alexa will give the user a vague error like "Hmm, $device is not responding." Locks currently work, but thermostats do not. I wanted a way to automatically include properties in all responses. This is implemented in a subsequent commit. 2) The previous model had a 1:1 mapping between Alexa endpoints and Home Assistant entities. This works most of the time, but sometimes it's not so great. For example, my Z-wave thermostat shows as three devices in Alexa: one for the temperature sensor, one for the heat, and one for the AC. I'd like to merge these into one device from Alexa's perspective. I believe this will be facilitated with the `endpoint` attribute on `_AlexaDirective`. * Include properties in all Alexa responses The added _AlexaResponse class provides a richer vocabulary for handlers. Among that vocabulary is .merge_context_properties(), which is invoked automatically for any request directed at an endpoint. This adds all supported properties to the response as recommended by the Alexa API docs, and in some cases (locks, thermostats at least) the user will get an error "Hmm, $device is not responding" if properties are not provided in the response. * Fix setting temperature with Alexa thermostats Fixes https://github.com/home-assistant/home-assistant/issues/16577 --- homeassistant/components/alexa/smart_home.py | 803 ++++++++++++------- homeassistant/components/cloud/iot.py | 6 +- tests/components/alexa/test_smart_home.py | 69 +- 3 files changed, 572 insertions(+), 306 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index c7fedc34e03..475f507439c 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,28 +1,33 @@ -"""Support for alexa Smart Home Skill API.""" +"""Support for alexa Smart Home Skill API. + +API documentation: +https://developer.amazon.com/docs/smarthome/understand-the-smart-home-skill-api.html +https://developer.amazon.com/docs/device-apis/message-guide.html +""" + from collections import OrderedDict +from datetime import datetime import logging import math -from datetime import datetime from uuid import uuid4 from homeassistant.components import ( - alert, automation, binary_sensor, cover, climate, fan, group, - input_boolean, light, lock, media_player, scene, script, switch, http, - sensor) -import homeassistant.core as ha -import homeassistant.util.color as color_util -from homeassistant.util.temperature import convert as convert_temperature -from homeassistant.util.decorator import Registry + alert, automation, binary_sensor, climate, cover, fan, group, http, + input_boolean, light, lock, media_player, scene, script, sensor, switch) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_UNLOCK, SERVICE_VOLUME_SET, TEMP_FAHRENHEIT, TEMP_CELSIUS, - STATE_LOCKED, STATE_UNLOCKED, STATE_ON) + SERVICE_UNLOCK, SERVICE_VOLUME_SET, STATE_LOCKED, STATE_ON, STATE_UNLOCKED, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +import homeassistant.core as ha +import homeassistant.util.color as color_util +from homeassistant.util.decorator import Registry +from homeassistant.util.temperature import convert as convert_temperature -from .const import CONF_FILTER, CONF_ENTITY_CONFIG +from .const import CONF_ENTITY_CONFIG, CONF_FILTER _LOGGER = logging.getLogger(__name__) @@ -166,6 +171,70 @@ class _UnsupportedProperty(Exception): """This entity does not support the requested Smart Home API property.""" +class _AlexaError(Exception): + """Base class for errors that can be serialized by the Alexa API. + + A handler can raise subclasses of this to return an error to the request. + """ + + namespace = None + error_type = None + + def __init__(self, error_message, payload=None): + Exception.__init__(self) + self.error_message = error_message + self.payload = None + + +class _AlexaInvalidEndpointError(_AlexaError): + """The endpoint in the request does not exist.""" + + namespace = 'Alexa' + error_type = 'NO_SUCH_ENDPOINT' + + def __init__(self, endpoint_id): + msg = 'The endpoint {} does not exist'.format(endpoint_id) + _AlexaError.__init__(self, msg) + self.endpoint_id = endpoint_id + + +class _AlexaInvalidValueError(_AlexaError): + namespace = 'Alexa' + error_type = 'INVALID_VALUE' + + +class _AlexaUnsupportedThermostatModeError(_AlexaError): + namespace = 'Alexa.ThermostatController' + error_type = 'UNSUPPORTED_THERMOSTAT_MODE' + + +class _AlexaTempRangeError(_AlexaError): + namespace = 'Alexa' + error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + def __init__(self, hass, temp, min_temp, max_temp): + unit = hass.config.units.temperature_unit + temp_range = { + 'minimumValue': { + 'value': min_temp, + 'scale': API_TEMP_UNITS[unit], + }, + 'maximumValue': { + 'value': max_temp, + 'scale': API_TEMP_UNITS[unit], + }, + } + payload = {'validRange': temp_range} + msg = 'The requested temperature {} is out of range'.format(temp) + + _AlexaError.__init__(self, msg, payload) + + +class _AlexaBridgeUnreachableError(_AlexaError): + namespace = 'Alexa' + error_type = 'BRIDGE_UNREACHABLE' + + class _AlexaEntity: """An adaptation of an entity, expressed in Alexa's terms. @@ -221,8 +290,23 @@ class _AlexaEntity: """ raise NotImplementedError + def serialize_properties(self): + """Yield each supported property in API format.""" + for interface in self.interfaces(): + for prop in interface.serialize_properties(): + yield prop + class _AlexaInterface: + """Base class for Alexa capability interfaces. + + The Smart Home Skills API defines a number of "capability interfaces", + roughly analogous to domains in Home Assistant. The supported interfaces + describe what actions can be performed on a particular device. + + https://developer.amazon.com/docs/device-apis/message-guide.html + """ + def __init__(self, entity): self.entity = entity @@ -295,6 +379,11 @@ class _AlexaInterface: class _AlexaPowerController(_AlexaInterface): + """Implements Alexa.PowerController. + + https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html + """ + def name(self): return 'Alexa.PowerController' @@ -314,6 +403,11 @@ class _AlexaPowerController(_AlexaInterface): class _AlexaLockController(_AlexaInterface): + """Implements Alexa.LockController. + + https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html + """ + def name(self): return 'Alexa.LockController' @@ -335,6 +429,11 @@ class _AlexaLockController(_AlexaInterface): class _AlexaSceneController(_AlexaInterface): + """Implements Alexa.SceneController. + + https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html + """ + def __init__(self, entity, supports_deactivation): _AlexaInterface.__init__(self, entity) self.supports_deactivation = lambda: supports_deactivation @@ -344,6 +443,11 @@ class _AlexaSceneController(_AlexaInterface): class _AlexaBrightnessController(_AlexaInterface): + """Implements Alexa.BrightnessController. + + https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html + """ + def name(self): return 'Alexa.BrightnessController' @@ -362,41 +466,81 @@ class _AlexaBrightnessController(_AlexaInterface): class _AlexaColorController(_AlexaInterface): + """Implements Alexa.ColorController. + + https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html + """ + def name(self): return 'Alexa.ColorController' class _AlexaColorTemperatureController(_AlexaInterface): + """Implements Alexa.ColorTemperatureController. + + https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html + """ + def name(self): return 'Alexa.ColorTemperatureController' class _AlexaPercentageController(_AlexaInterface): + """Implements Alexa.PercentageController. + + https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html + """ + def name(self): return 'Alexa.PercentageController' class _AlexaSpeaker(_AlexaInterface): + """Implements Alexa.Speaker. + + https://developer.amazon.com/docs/device-apis/alexa-speaker.html + """ + def name(self): return 'Alexa.Speaker' class _AlexaStepSpeaker(_AlexaInterface): + """Implements Alexa.StepSpeaker. + + https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html + """ + def name(self): return 'Alexa.StepSpeaker' class _AlexaPlaybackController(_AlexaInterface): + """Implements Alexa.PlaybackController. + + https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html + """ + def name(self): return 'Alexa.PlaybackController' class _AlexaInputController(_AlexaInterface): + """Implements Alexa.InputController. + + https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html + """ + def name(self): return 'Alexa.InputController' class _AlexaTemperatureSensor(_AlexaInterface): + """Implements Alexa.TemperatureSensor. + + https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html + """ + def __init__(self, hass, entity): _AlexaInterface.__init__(self, entity) self.hass = hass @@ -427,6 +571,16 @@ class _AlexaTemperatureSensor(_AlexaInterface): class _AlexaContactSensor(_AlexaInterface): + """Implements Alexa.ContactSensor. + + The Alexa.ContactSensor interface describes the properties and events used + to report the state of an endpoint that detects contact between two + surfaces. For example, a contact sensor can report whether a door or window + is open. + + https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html + """ + def __init__(self, hass, entity): _AlexaInterface.__init__(self, entity) self.hass = hass @@ -473,6 +627,11 @@ class _AlexaMotionSensor(_AlexaInterface): class _AlexaThermostatController(_AlexaInterface): + """Implements Alexa.ThermostatController. + + https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html + """ + def __init__(self, hass, entity): _AlexaInterface.__init__(self, entity) self.hass = hass @@ -803,111 +962,231 @@ class SmartHomeView(http.HomeAssistantView): return b'' if response is None else self.json(response) -async def async_handle_message(hass, config, request, context=None): - """Handle incoming API messages.""" +class _AlexaDirective: + def __init__(self, request): + self._directive = request[API_DIRECTIVE] + self.namespace = self._directive[API_HEADER]['namespace'] + self.name = self._directive[API_HEADER]['name'] + self.payload = self._directive[API_PAYLOAD] + self.has_endpoint = API_ENDPOINT in self._directive + + self.entity = self.entity_id = self.endpoint = None + + def load_entity(self, hass, config): + """Set attributes related to the entity for this request. + + Sets these attributes when self.has_endpoint is True: + + - entity + - entity_id + - endpoint + + Behavior when self.has_endpoint is False is undefined. + + Will raise _AlexaInvalidEndpointError if the endpoint in the request is + malformed or nonexistant. + """ + _endpoint_id = self._directive[API_ENDPOINT]['endpointId'] + self.entity_id = _endpoint_id.replace('#', '.') + + self.entity = hass.states.get(self.entity_id) + if not self.entity: + raise _AlexaInvalidEndpointError(_endpoint_id) + + self.endpoint = ENTITY_ADAPTERS[self.entity.domain]( + hass, config, self.entity) + + def response(self, + name='Response', + namespace='Alexa', + payload=None): + """Create an API formatted response. + + Async friendly. + """ + response = _AlexaResponse(name, namespace, payload) + + token = self._directive[API_HEADER].get('correlationToken') + if token: + response.set_correlation_token(token) + + if self.has_endpoint: + response.set_endpoint(self._directive[API_ENDPOINT].copy()) + + return response + + def error( + self, + namespace='Alexa', + error_type='INTERNAL_ERROR', + error_message="", + payload=None + ): + """Create a API formatted error response. + + Async friendly. + """ + payload = payload or {} + payload['type'] = error_type + payload['message'] = error_message + + _LOGGER.info("Request %s/%s error %s: %s", + self._directive[API_HEADER]['namespace'], + self._directive[API_HEADER]['name'], + error_type, error_message) + + return self.response( + name='ErrorResponse', + namespace=namespace, + payload=payload + ) + + +class _AlexaResponse: + def __init__(self, name, namespace, payload=None): + payload = payload or {} + self._response = { + API_EVENT: { + API_HEADER: { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'payloadVersion': '3', + }, + API_PAYLOAD: payload, + } + } + + @property + def name(self): + """Return the name of this response.""" + return self._response[API_EVENT][API_HEADER]['name'] + + @property + def namespace(self): + """Return the namespace of this response.""" + return self._response[API_EVENT][API_HEADER]['namespace'] + + def set_correlation_token(self, token): + """Set the correlationToken. + + This should normally mirror the value from a request, and is set by + _AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_HEADER]['correlationToken'] = token + + def set_endpoint(self, endpoint): + """Set the endpoint. + + This should normally mirror the value from a request, and is set by + _AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_ENDPOINT] = endpoint + + def _properties(self): + context = self._response.setdefault(API_CONTEXT, {}) + return context.setdefault('properties', []) + + def add_context_property(self, prop): + """Add a property to the response context. + + The Alexa response includes a list of properties which provides + feedback on how states have changed. For example if a user asks, + "Alexa, set theromstat to 20 degrees", the API expects a response with + the new value of the property, and Alexa will respond to the user + "Thermostat set to 20 degrees". + + async_handle_message() will call .merge_context_properties() for every + request automatically, however often handlers will call services to + change state but the effects of those changes are applied + asynchronously. Thus, handlers should call this method to confirm + changes before returning. + """ + self._properties().append(prop) + + def merge_context_properties(self, endpoint): + """Add all properties from given endpoint if not already set. + + Handlers should be using .add_context_property(). + """ + properties = self._properties() + already_set = {(p['namespace'], p['name']) for p in properties} + + for prop in endpoint.serialize_properties(): + if (prop['namespace'], prop['name']) not in already_set: + self.add_context_property(prop) + + def serialize(self): + """Return response as a JSON-able data structure.""" + return self._response + + +async def async_handle_message( + hass, + config, + request, + context=None, + enabled=True, +): + """Handle incoming API messages. + + If enabled is False, the response to all messagess will be a + BRIDGE_UNREACHABLE error. This can be used if the API has been disabled in + configuration. + """ assert request[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' if context is None: context = ha.Context() - # Read head data - request = request[API_DIRECTIVE] - namespace = request[API_HEADER]['namespace'] - name = request[API_HEADER]['name'] + directive = _AlexaDirective(request) - # Do we support this API request? - funct_ref = HANDLERS.get((namespace, name)) - if funct_ref: - response = await funct_ref(hass, config, request, context) - else: - _LOGGER.warning( - "Unsupported API request %s/%s", namespace, name) - response = api_error(request) + try: + if not enabled: + raise _AlexaBridgeUnreachableError( + 'Alexa API not enabled in Home Assistant configuration') + + if directive.has_endpoint: + directive.load_entity(hass, config) + + funct_ref = HANDLERS.get((directive.namespace, directive.name)) + if funct_ref: + response = await funct_ref(hass, config, directive, context) + if directive.has_endpoint: + response.merge_context_properties(directive.endpoint) + else: + _LOGGER.warning( + "Unsupported API request %s/%s", + directive.namespace, + directive.name, + ) + response = directive.error() + except _AlexaError as err: + response = directive.error( + error_type=err.error_type, + error_message=err.error_message) request_info = { - 'namespace': namespace, - 'name': name, + 'namespace': directive.namespace, + 'name': directive.name, } - if API_ENDPOINT in request and 'endpointId' in request[API_ENDPOINT]: - request_info['entity_id'] = \ - request[API_ENDPOINT]['endpointId'].replace('#', '.') - - response_header = response[API_EVENT][API_HEADER] + if directive.has_endpoint: + request_info['entity_id'] = directive.entity_id hass.bus.async_fire(EVENT_ALEXA_SMART_HOME, { 'request': request_info, 'response': { - 'namespace': response_header['namespace'], - 'name': response_header['name'], + 'namespace': response.namespace, + 'name': response.name, } }, context=context) - return response - - -def api_message(request, - name='Response', - namespace='Alexa', - payload=None, - context=None): - """Create a API formatted response message. - - Async friendly. - """ - payload = payload or {} - - response = { - API_EVENT: { - API_HEADER: { - 'namespace': namespace, - 'name': name, - 'messageId': str(uuid4()), - 'payloadVersion': '3', - }, - API_PAYLOAD: payload, - } - } - - # If a correlation token exists, add it to header / Need by Async requests - token = request[API_HEADER].get('correlationToken') - if token: - response[API_EVENT][API_HEADER]['correlationToken'] = token - - # Extend event with endpoint object / Need by Async requests - if API_ENDPOINT in request: - response[API_EVENT][API_ENDPOINT] = request[API_ENDPOINT].copy() - - if context is not None: - response[API_CONTEXT] = context - - return response - - -def api_error(request, - namespace='Alexa', - error_type='INTERNAL_ERROR', - error_message="", - payload=None): - """Create a API formatted error response. - - Async friendly. - """ - payload = payload or {} - payload['type'] = error_type - payload['message'] = error_message - - _LOGGER.info("Request %s/%s error %s: %s", - request[API_HEADER]['namespace'], - request[API_HEADER]['name'], - error_type, error_message) - - return api_message( - request, name='ErrorResponse', namespace=namespace, payload=payload) + return response.serialize() @HANDLERS.register(('Alexa.Discovery', 'Discover')) -async def async_api_discovery(hass, config, request, context): +async def async_api_discovery(hass, config, directive, context): """Create a API formatted discovery response. Async friendly. @@ -942,52 +1221,36 @@ async def async_api_discovery(hass, config, request, context): continue discovery_endpoints.append(endpoint) - return api_message( - request, name='Discover.Response', namespace='Alexa.Discovery', - payload={'endpoints': discovery_endpoints}) - - -def extract_entity(funct): - """Decorate for extract entity object from request.""" - async def async_api_entity_wrapper(hass, config, request, context): - """Process a turn on request.""" - 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[API_HEADER]['name'], entity_id) - return api_error(request, error_type='NO_SUCH_ENDPOINT') - - return await funct(hass, config, request, context, entity) - - return async_api_entity_wrapper + return directive.response( + name='Discover.Response', + namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}, + ) @HANDLERS.register(('Alexa.PowerController', 'TurnOn')) -@extract_entity -async def async_api_turn_on(hass, config, request, context, entity): +async def async_api_turn_on(hass, config, directive, context): """Process a turn on request.""" + entity = directive.entity domain = entity.domain - if entity.domain == group.DOMAIN: + if domain == group.DOMAIN: domain = ha.DOMAIN service = SERVICE_TURN_ON - if entity.domain == cover.DOMAIN: + if domain == cover.DOMAIN: service = cover.SERVICE_OPEN_COVER await hass.services.async_call(domain, service, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.PowerController', 'TurnOff')) -@extract_entity -async def async_api_turn_off(hass, config, request, context, entity): +async def async_api_turn_off(hass, config, directive, context): """Process a turn off request.""" + entity = directive.entity domain = entity.domain if entity.domain == group.DOMAIN: domain = ha.DOMAIN @@ -1000,28 +1263,28 @@ async def async_api_turn_off(hass, config, request, context, entity): ATTR_ENTITY_ID: entity.entity_id }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) -@extract_entity -async def async_api_set_brightness(hass, config, request, context, entity): +async def async_api_set_brightness(hass, config, directive, context): """Process a set brightness request.""" - brightness = int(request[API_PAYLOAD]['brightness']) + entity = directive.entity + brightness = int(directive.payload['brightness']) await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness, }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) -@extract_entity -async def async_api_adjust_brightness(hass, config, request, context, entity): +async def async_api_adjust_brightness(hass, config, directive, context): """Process an adjust brightness request.""" - brightness_delta = int(request[API_PAYLOAD]['brightnessDelta']) + entity = directive.entity + brightness_delta = int(directive.payload['brightnessDelta']) # read current state try: @@ -1037,17 +1300,17 @@ async def async_api_adjust_brightness(hass, config, request, context, entity): light.ATTR_BRIGHTNESS_PCT: brightness, }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.ColorController', 'SetColor')) -@extract_entity -async def async_api_set_color(hass, config, request, context, entity): +async def async_api_set_color(hass, config, directive, context): """Process a set color request.""" + entity = directive.entity rgb = color_util.color_hsb_to_RGB( - float(request[API_PAYLOAD]['color']['hue']), - float(request[API_PAYLOAD]['color']['saturation']), - float(request[API_PAYLOAD]['color']['brightness']) + float(directive.payload['color']['hue']), + float(directive.payload['color']['saturation']), + float(directive.payload['color']['brightness']) ) await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { @@ -1055,30 +1318,28 @@ async def async_api_set_color(hass, config, request, context, entity): light.ATTR_RGB_COLOR: rgb, }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) -@extract_entity -async def async_api_set_color_temperature(hass, config, request, context, - entity): +async def async_api_set_color_temperature(hass, config, directive, context): """Process a set color temperature request.""" - kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin']) + entity = directive.entity + kelvin = int(directive.payload['colorTemperatureInKelvin']) await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin, }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register( ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) -@extract_entity -async def async_api_decrease_color_temp(hass, config, request, context, - entity): +async def async_api_decrease_color_temp(hass, config, directive, context): """Process a decrease color temperature request.""" + entity = directive.entity current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) @@ -1088,15 +1349,14 @@ async def async_api_decrease_color_temp(hass, config, request, context, light.ATTR_COLOR_TEMP: value, }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register( ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) -@extract_entity -async def async_api_increase_color_temp(hass, config, request, context, - entity): +async def async_api_increase_color_temp(hass, config, directive, context): """Process an increase color temperature request.""" + entity = directive.entity current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) @@ -1106,13 +1366,13 @@ async def async_api_increase_color_temp(hass, config, request, context, light.ATTR_COLOR_TEMP: value, }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.SceneController', 'Activate')) -@extract_entity -async def async_api_activate(hass, config, request, context, entity): +async def async_api_activate(hass, config, directive, context): """Process an activate request.""" + entity = directive.entity domain = entity.domain await hass.services.async_call(domain, SERVICE_TURN_ON, { @@ -1124,8 +1384,7 @@ async def async_api_activate(hass, config, request, context, entity): 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) } - return api_message( - request, + return directive.response( name='ActivationStarted', namespace='Alexa.SceneController', payload=payload, @@ -1133,9 +1392,9 @@ async def async_api_activate(hass, config, request, context, entity): @HANDLERS.register(('Alexa.SceneController', 'Deactivate')) -@extract_entity -async def async_api_deactivate(hass, config, request, context, entity): +async def async_api_deactivate(hass, config, directive, context): """Process a deactivate request.""" + entity = directive.entity domain = entity.domain await hass.services.async_call(domain, SERVICE_TURN_OFF, { @@ -1147,8 +1406,7 @@ async def async_api_deactivate(hass, config, request, context, entity): 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) } - return api_message( - request, + return directive.response( name='DeactivationStarted', namespace='Alexa.SceneController', payload=payload, @@ -1156,10 +1414,10 @@ async def async_api_deactivate(hass, config, request, context, entity): @HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) -@extract_entity -async def async_api_set_percentage(hass, config, request, context, entity): +async def async_api_set_percentage(hass, config, directive, context): """Process a set percentage request.""" - percentage = int(request[API_PAYLOAD]['percentage']) + entity = directive.entity + percentage = int(directive.payload['percentage']) service = None data = {ATTR_ENTITY_ID: entity.entity_id} @@ -1182,14 +1440,14 @@ async def async_api_set_percentage(hass, config, request, context, entity): await hass.services.async_call( entity.domain, service, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) -@extract_entity -async def async_api_adjust_percentage(hass, config, request, context, entity): +async def async_api_adjust_percentage(hass, config, directive, context): """Process an adjust percentage request.""" - percentage_delta = int(request[API_PAYLOAD]['percentageDelta']) + entity = directive.entity + percentage_delta = int(directive.payload['percentageDelta']) service = None data = {ATTR_ENTITY_ID: entity.entity_id} @@ -1229,46 +1487,43 @@ async def async_api_adjust_percentage(hass, config, request, context, entity): await hass.services.async_call( entity.domain, service, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.LockController', 'Lock')) -@extract_entity -async def async_api_lock(hass, config, request, context, entity): +async def async_api_lock(hass, config, directive, context): """Process a lock request.""" + entity = directive.entity await hass.services.async_call(entity.domain, SERVICE_LOCK, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False, context=context) - # Alexa expects a lockState in the response, we don't know the actual - # lockState at this point but assume it is locked. It is reported - # correctly later when ReportState is called. The alt. to this approach - # is to implement DeferredResponse - properties = [{ + response = directive.response() + response.add_context_property({ 'name': 'lockState', 'namespace': 'Alexa.LockController', 'value': 'LOCKED' - }] - return api_message(request, context={'properties': properties}) + }) + return response # Not supported by Alexa yet @HANDLERS.register(('Alexa.LockController', 'Unlock')) -@extract_entity -async def async_api_unlock(hass, config, request, context, entity): +async def async_api_unlock(hass, config, directive, context): """Process an unlock request.""" + entity = directive.entity await hass.services.async_call(entity.domain, SERVICE_UNLOCK, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.Speaker', 'SetVolume')) -@extract_entity -async def async_api_set_volume(hass, config, request, context, entity): +async def async_api_set_volume(hass, config, directive, context): """Process a set volume request.""" - volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2) + volume = round(float(directive.payload['volume'] / 100), 2) + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id, @@ -1279,14 +1534,14 @@ async def async_api_set_volume(hass, config, request, context, entity): entity.domain, SERVICE_VOLUME_SET, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.InputController', 'SelectInput')) -@extract_entity -async def async_api_select_input(hass, config, request, context, entity): +async def async_api_select_input(hass, config, directive, context): """Process a set input request.""" - media_input = request[API_PAYLOAD]['input'] + media_input = directive.payload['input'] + entity = directive.entity # attempt to map the ALL UPPERCASE payload name to a source source_list = entity.attributes[media_player.ATTR_INPUT_SOURCE_LIST] or [] @@ -1300,8 +1555,7 @@ async def async_api_select_input(hass, config, request, context, entity): else: msg = 'failed to map input {} to a media source on {}'.format( media_input, entity.entity_id) - return api_error( - request, error_type='INVALID_VALUE', error_message=msg) + raise _AlexaInvalidValueError(msg) data = { ATTR_ENTITY_ID: entity.entity_id, @@ -1312,15 +1566,15 @@ async def async_api_select_input(hass, config, request, context, entity): entity.domain, media_player.SERVICE_SELECT_SOURCE, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) -@extract_entity -async def async_api_adjust_volume(hass, config, request, context, entity): +async def async_api_adjust_volume(hass, config, directive, context): """Process an adjust volume request.""" - volume_delta = int(request[API_PAYLOAD]['volume']) + volume_delta = int(directive.payload['volume']) + entity = directive.entity current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) # read current state @@ -1340,18 +1594,18 @@ async def async_api_adjust_volume(hass, config, request, context, entity): entity.domain, media_player.SERVICE_VOLUME_SET, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) -@extract_entity -async def async_api_adjust_volume_step(hass, config, request, context, entity): +async def async_api_adjust_volume_step(hass, config, directive, context): """Process an adjust volume step request.""" # media_player volume up/down service does not support specifying steps # each component handles it differently e.g. via config. # For now we use the volumeSteps returned to figure out if we # should step up/down - volume_step = request[API_PAYLOAD]['volumeSteps'] + volume_step = directive.payload['volumeSteps'] + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id, @@ -1366,15 +1620,15 @@ async def async_api_adjust_volume_step(hass, config, request, context, entity): entity.domain, media_player.SERVICE_VOLUME_DOWN, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) @HANDLERS.register(('Alexa.Speaker', 'SetMute')) -@extract_entity -async def async_api_set_mute(hass, config, request, context, entity): +async def async_api_set_mute(hass, config, directive, context): """Process a set mute request.""" - mute = bool(request[API_PAYLOAD]['mute']) + mute = bool(directive.payload['mute']) + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id, @@ -1385,13 +1639,13 @@ async def async_api_set_mute(hass, config, request, context, entity): entity.domain, media_player.SERVICE_VOLUME_MUTE, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.PlaybackController', 'Play')) -@extract_entity -async def async_api_play(hass, config, request, context, entity): +async def async_api_play(hass, config, directive, context): """Process a play request.""" + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id } @@ -1400,13 +1654,13 @@ async def async_api_play(hass, config, request, context, entity): entity.domain, SERVICE_MEDIA_PLAY, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.PlaybackController', 'Pause')) -@extract_entity -async def async_api_pause(hass, config, request, context, entity): +async def async_api_pause(hass, config, directive, context): """Process a pause request.""" + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id } @@ -1415,13 +1669,13 @@ async def async_api_pause(hass, config, request, context, entity): entity.domain, SERVICE_MEDIA_PAUSE, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.PlaybackController', 'Stop')) -@extract_entity -async def async_api_stop(hass, config, request, context, entity): +async def async_api_stop(hass, config, directive, context): """Process a stop request.""" + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id } @@ -1430,13 +1684,13 @@ async def async_api_stop(hass, config, request, context, entity): entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.PlaybackController', 'Next')) -@extract_entity -async def async_api_next(hass, config, request, context, entity): +async def async_api_next(hass, config, directive, context): """Process a next request.""" + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id } @@ -1445,13 +1699,13 @@ async def async_api_next(hass, config, request, context, entity): entity.domain, SERVICE_MEDIA_NEXT_TRACK, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa.PlaybackController', 'Previous')) -@extract_entity -async def async_api_previous(hass, config, request, context, entity): +async def async_api_previous(hass, config, directive, context): """Process a previous request.""" + entity = directive.entity data = { ATTR_ENTITY_ID: entity.entity_id } @@ -1460,33 +1714,7 @@ async def async_api_previous(hass, config, request, context, entity): entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, data, blocking=False, context=context) - return api_message(request) - - -def api_error_temp_range(hass, request, temp, min_temp, max_temp): - """Create temperature value out of range API error response. - - Async friendly. - """ - unit = hass.config.units.temperature_unit - temp_range = { - 'minimumValue': { - 'value': min_temp, - 'scale': API_TEMP_UNITS[unit], - }, - 'maximumValue': { - 'value': max_temp, - 'scale': API_TEMP_UNITS[unit], - }, - } - - msg = 'The requested temperature {} is out of range'.format(temp) - return api_error( - request, - error_type='TEMPERATURE_VALUE_OUT_OF_RANGE', - error_message=msg, - payload={'validRange': temp_range}, - ) + return directive.response() def temperature_from_object(hass, temp_obj, interval=False): @@ -1506,76 +1734,95 @@ def temperature_from_object(hass, temp_obj, interval=False): @HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature')) -@extract_entity -async def async_api_set_target_temp(hass, config, request, context, entity): +async def async_api_set_target_temp(hass, config, directive, context): """Process a set target temperature request.""" + entity = directive.entity min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit data = { ATTR_ENTITY_ID: entity.entity_id } - payload = request[API_PAYLOAD] + payload = directive.payload + response = directive.response() if 'targetSetpoint' in payload: temp = temperature_from_object(hass, payload['targetSetpoint']) if temp < min_temp or temp > max_temp: - return api_error_temp_range( - hass, request, temp, min_temp, max_temp) + raise _AlexaTempRangeError(hass, temp, min_temp, max_temp) data[ATTR_TEMPERATURE] = temp + response.add_context_property({ + 'name': 'targetSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp, 'scale': API_TEMP_UNITS[unit]}, + }) if 'lowerSetpoint' in payload: temp_low = temperature_from_object(hass, payload['lowerSetpoint']) if temp_low < min_temp or temp_low > max_temp: - return api_error_temp_range( - hass, request, temp_low, min_temp, max_temp) + raise _AlexaTempRangeError(hass, temp_low, min_temp, max_temp) data[climate.ATTR_TARGET_TEMP_LOW] = temp_low + response.add_context_property({ + 'name': 'lowerSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp_low, 'scale': API_TEMP_UNITS[unit]}, + }) if 'upperSetpoint' in payload: temp_high = temperature_from_object(hass, payload['upperSetpoint']) if temp_high < min_temp or temp_high > max_temp: - return api_error_temp_range( - hass, request, temp_high, min_temp, max_temp) + raise _AlexaTempRangeError(hass, temp_high, min_temp, max_temp) data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high + response.add_context_property({ + 'name': 'upperSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': temp_high, 'scale': API_TEMP_UNITS[unit]}, + }) await hass.services.async_call( entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, context=context) - return api_message(request) + return response @HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) -@extract_entity -async def async_api_adjust_target_temp(hass, config, request, context, entity): +async def async_api_adjust_target_temp(hass, config, directive, context): """Process an adjust target temperature request.""" + entity = directive.entity min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + unit = hass.config.units.temperature_unit temp_delta = temperature_from_object( - hass, request[API_PAYLOAD]['targetSetpointDelta'], interval=True) + hass, directive.payload['targetSetpointDelta'], interval=True) target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta if target_temp < min_temp or target_temp > max_temp: - return api_error_temp_range( - hass, request, target_temp, min_temp, max_temp) + raise _AlexaTempRangeError(hass, target_temp, min_temp, max_temp) data = { ATTR_ENTITY_ID: entity.entity_id, ATTR_TEMPERATURE: target_temp, } + response = directive.response() await hass.services.async_call( entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False, context=context) + response.add_context_property({ + 'name': 'targetSetpoint', + 'namespace': 'Alexa.ThermostatController', + 'value': {'value': target_temp, 'scale': API_TEMP_UNITS[unit]}, + }) - return api_message(request) + return response @HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) -@extract_entity -async def async_api_set_thermostat_mode(hass, config, request, context, - entity): +async def async_api_set_thermostat_mode(hass, config, directive, context): """Process a set thermostat mode request.""" - mode = request[API_PAYLOAD]['thermostatMode'] + entity = directive.entity + mode = directive.payload['thermostatMode'] mode = mode if isinstance(mode, str) else mode['value'] operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) @@ -1585,12 +1832,7 @@ async def async_api_set_thermostat_mode(hass, config, request, context, ) if ha_mode not in operation_list: msg = 'The requested thermostat mode {} is not supported'.format(mode) - return api_error( - request, - namespace='Alexa.ThermostatController', - error_type='UNSUPPORTED_THERMOSTAT_MODE', - error_message=msg - ) + raise _AlexaUnsupportedThermostatModeError(msg) data = { ATTR_ENTITY_ID: entity.entity_id, @@ -1601,25 +1843,10 @@ async def async_api_set_thermostat_mode(hass, config, request, context, entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, blocking=False, context=context) - return api_message(request) + return directive.response() @HANDLERS.register(('Alexa', 'ReportState')) -@extract_entity -async def async_api_reportstate(hass, config, request, context, entity): +async def async_api_reportstate(hass, config, directive, context): """Process a ReportState request.""" - alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity) - properties = [] - for interface in alexa_entity.interfaces(): - properties.extend(interface.serialize_properties()) - - return api_message( - request, - name='StateReport', - context={'properties': properties} - ) - - -def turned_off_response(message): - """Return a device turned off response.""" - return api_error(message[API_DIRECTIVE], error_type='BRIDGE_UNREACHABLE') + return directive.response(name='StateReport') diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index fe89c263488..b4f228a630d 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -227,11 +227,9 @@ def async_handle_message(hass, cloud, handler_name, payload): @asyncio.coroutine def async_handle_alexa(hass, cloud, payload): """Handle an incoming IoT message for Alexa.""" - if not cloud.alexa_enabled: - return alexa.turned_off_response(payload) - result = yield from alexa.async_handle_message( - hass, cloud.alexa_config, payload) + hass, cloud.alexa_config, payload, + enabled=cloud.alexa_enabled) return result diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index ab268fe860f..1cf01c6092d 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -57,20 +57,21 @@ def get_new_request(namespace, name, endpoint=None): return raw_msg -def test_create_api_message_defaults(): +def test_create_api_message_defaults(hass): """Create a API message response of a request with defaults.""" request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy') - request = request['directive'] + directive_header = request['directive']['header'] + directive = smart_home._AlexaDirective(request) - msg = smart_home.api_message(request, payload={'test': 3}) + msg = directive.response(payload={'test': 3})._response assert 'event' in msg msg = msg['event'] assert msg['header']['messageId'] is not None - assert msg['header']['messageId'] != request['header']['messageId'] + assert msg['header']['messageId'] != directive_header['messageId'] assert msg['header']['correlationToken'] == \ - request['header']['correlationToken'] + directive_header['correlationToken'] assert msg['header']['name'] == 'Response' assert msg['header']['namespace'] == 'Alexa' assert msg['header']['payloadVersion'] == '3' @@ -78,23 +79,24 @@ def test_create_api_message_defaults(): assert 'test' in msg['payload'] assert msg['payload']['test'] == 3 - assert msg['endpoint'] == request['endpoint'] + assert msg['endpoint'] == request['directive']['endpoint'] + assert msg['endpoint'] is not request['directive']['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'] + directive_header = request['directive']['header'] + directive_header.pop('correlationToken') + directive = smart_home._AlexaDirective(request) - request['header'].pop('correlationToken') - - msg = smart_home.api_message(request, 'testName', 'testNameSpace') + msg = directive.response('testName', 'testNameSpace')._response assert 'event' in msg msg = msg['event'] assert msg['header']['messageId'] is not None - assert msg['header']['messageId'] != request['header']['messageId'] + assert msg['header']['messageId'] != directive_header['messageId'] assert 'correlationToken' not in msg['header'] assert msg['header']['name'] == 'testName' assert msg['header']['namespace'] == 'testNameSpace' @@ -785,13 +787,17 @@ async def test_thermostat(hass): 'Alexa.TemperatureSensor', 'temperature', {'value': 75.0, 'scale': 'FAHRENHEIT'}) - call, _ = await assert_request_calls_service( + call, msg = await assert_request_calls_service( 'Alexa.ThermostatController', 'SetTargetTemperature', 'climate#test_thermostat', 'climate.set_temperature', hass, payload={'targetSetpoint': {'value': 69.0, 'scale': 'FAHRENHEIT'}} ) assert call.data['temperature'] == 69.0 + properties = _ReportedProperties(msg['context']['properties']) + properties.assert_equal( + 'Alexa.ThermostatController', 'targetSetpoint', + {'value': 69.0, 'scale': 'FAHRENHEIT'}) msg = await assert_request_fails( 'Alexa.ThermostatController', 'SetTargetTemperature', @@ -801,7 +807,7 @@ async def test_thermostat(hass): ) assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' - call, _ = await assert_request_calls_service( + call, msg = await assert_request_calls_service( 'Alexa.ThermostatController', 'SetTargetTemperature', 'climate#test_thermostat', 'climate.set_temperature', hass, @@ -814,6 +820,16 @@ async def test_thermostat(hass): assert call.data['temperature'] == 70.0 assert call.data['target_temp_low'] == 68.0 assert call.data['target_temp_high'] == 86.0 + properties = _ReportedProperties(msg['context']['properties']) + properties.assert_equal( + 'Alexa.ThermostatController', 'targetSetpoint', + {'value': 70.0, 'scale': 'FAHRENHEIT'}) + properties.assert_equal( + 'Alexa.ThermostatController', 'lowerSetpoint', + {'value': 68.0, 'scale': 'FAHRENHEIT'}) + properties.assert_equal( + 'Alexa.ThermostatController', 'upperSetpoint', + {'value': 86.0, 'scale': 'FAHRENHEIT'}) msg = await assert_request_fails( 'Alexa.ThermostatController', 'SetTargetTemperature', @@ -837,13 +853,17 @@ async def test_thermostat(hass): ) assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' - call, _ = await assert_request_calls_service( + call, msg = await assert_request_calls_service( 'Alexa.ThermostatController', 'AdjustTargetTemperature', 'climate#test_thermostat', 'climate.set_temperature', hass, payload={'targetSetpointDelta': {'value': -10.0, 'scale': 'KELVIN'}} ) assert call.data['temperature'] == 52.0 + properties = _ReportedProperties(msg['context']['properties']) + properties.assert_equal( + 'Alexa.ThermostatController', 'targetSetpoint', + {'value': 52.0, 'scale': 'FAHRENHEIT'}) msg = await assert_request_fails( 'Alexa.ThermostatController', 'AdjustTargetTemperature', @@ -1467,3 +1487,24 @@ async def test_logging_request_with_entity(hass, events): 'name': 'ErrorResponse' } assert event.context == context + + +async def test_disabled(hass): + """When enabled=False, everything fails.""" + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test') + + call_switch = async_mock_service(hass, 'switch', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request, enabled=False) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert not call_switch + assert msg['header']['name'] == 'ErrorResponse' + assert msg['header']['namespace'] == 'Alexa' + assert msg['payload']['type'] == 'BRIDGE_UNREACHABLE'