"""Support for alexa Smart Home Skill API.""" import asyncio import logging import math from datetime import datetime from uuid import uuid4 from homeassistant.components import ( alert, automation, cover, fan, group, input_boolean, light, lock, media_player, scene, script, switch, http) import homeassistant.core as ha import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, 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) from .const import CONF_FILTER, CONF_ENTITY_CONFIG _LOGGER = logging.getLogger(__name__) API_DIRECTIVE = 'directive' API_ENDPOINT = 'endpoint' API_EVENT = 'event' API_HEADER = 'header' API_PAYLOAD = 'payload' SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' CONF_DESCRIPTION = 'description' 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 _Cause(object): """Possible causes for property changes. https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object """ # Indicates that the event was caused by a customer interaction with an # application. For example, a customer switches on a light, or locks a door # using the Alexa app or an app provided by a device vendor. APP_INTERACTION = 'APP_INTERACTION' # Indicates that the event was caused by a physical interaction with an # endpoint. For example manually switching on a light or manually locking a # door lock PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION' # Indicates that the event was caused by the periodic poll of an appliance, # which found a change in value. For example, you might poll a temperature # sensor every hour, and send the updated temperature to Alexa. PERIODIC_POLL = 'PERIODIC_POLL' # Indicates that the event was caused by the application of a device rule. # For example, a customer configures a rule to switch on a light if a # motion sensor detects motion. In this case, Alexa receives an event from # the motion sensor, and another event from the light to indicate that its # state change was caused by the rule. RULE_TRIGGER = 'RULE_TRIGGER' # Indicates that the event was caused by a voice interaction with Alexa. # For example a user speaking to their Echo device. VOICE_INTERACTION = 'VOICE_INTERACTION' class Config: """Hold the configuration for Alexa.""" def __init__(self, should_expose, entity_config=None): """Initialize the configuration.""" self.should_expose = should_expose self.entity_config = entity_config or {} @ha.callback def async_setup(hass, config): """Activate Smart Home functionality of Alexa component. This is optional, triggered by having a `smart_home:` sub-section in the alexa configuration. Even if that's disabled, the functionality in this module may still be used by the cloud component which will call async_handle_message directly. """ smart_home_config = Config( should_expose=config[CONF_FILTER], entity_config=config.get(CONF_ENTITY_CONFIG), ) hass.http.register_view(SmartHomeView(smart_home_config)) class SmartHomeView(http.HomeAssistantView): """Expose Smart Home v3 payload interface via HTTP POST.""" url = SMART_HOME_HTTP_ENDPOINT name = 'api:alexa:smart_home' def __init__(self, smart_home_config): """Initialize.""" self.smart_home_config = smart_home_config @asyncio.coroutine def post(self, request): """Handle Alexa Smart Home requests. The Smart Home API requires the endpoint to be implemented in AWS Lambda, which will need to forward the requests to here and pass back the response. """ hass = request.app['hass'] message = yield from request.json() _LOGGER.debug("Received Alexa Smart Home request: %s", message) response = yield from async_handle_message( hass, self.smart_home_config, message) return b'' if response is None else self.json(response) @asyncio.coroutine def async_handle_message(hass, config, message): """Handle incoming API messages.""" 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? funct_ref = HANDLERS.get((namespace, name)) if not funct_ref: _LOGGER.warning( "Unsupported API request %s/%s", namespace, name) return api_error(message) return (yield from funct_ref(hass, config, message)) def api_message(request, name='Response', namespace='Alexa', payload=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 exsits, 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() return response def api_error(request, error_type='INTERNAL_ERROR', error_message=""): """Create a API formatted error response. Async friendly. """ payload = { 'type': error_type, 'message': error_message, } return api_message(request, name='ErrorResponse', payload=payload) @HANDLERS.register(('Alexa.Discovery', 'Discover')) @asyncio.coroutine def async_api_discovery(hass, config, request): """Create a API formatted discovery response. Async friendly. """ discovery_endpoints = [] for entity in hass.states.async_all(): if not config.should_expose(entity.entity_id): _LOGGER.debug("Not exposing %s because filtered by config", entity.entity_id) continue class_data = MAPPING_COMPONENT.get(entity.domain) if not class_data: continue entity_conf = config.entity_config.get(entity.entity_id, {}) friendly_name = entity_conf.get(CONF_NAME, entity.name) description = entity_conf.get(CONF_DESCRIPTION, entity.entity_id) # Required description as per Amazon Scene docs if entity.domain == scene.DOMAIN: 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], '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 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.""" @asyncio.coroutine def async_api_entity_wrapper(hass, config, request): """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 (yield from funct(hass, config, request, entity)) return async_api_entity_wrapper @HANDLERS.register(('Alexa.PowerController', 'TurnOn')) @extract_entity @asyncio.coroutine 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: service = cover.SERVICE_OPEN_COVER yield from hass.services.async_call(domain, service, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.PowerController', 'TurnOff')) @extract_entity @asyncio.coroutine def async_api_turn_off(hass, config, request, entity): """Process a turn off request.""" domain = entity.domain if entity.domain == group.DOMAIN: domain = ha.DOMAIN service = SERVICE_TURN_OFF if entity.domain == cover.DOMAIN: service = cover.SERVICE_CLOSE_COVER yield from hass.services.async_call(domain, service, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) @extract_entity @asyncio.coroutine def async_api_set_brightness(hass, config, request, entity): """Process a set brightness request.""" brightness = int(request[API_PAYLOAD]['brightness']) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness, }, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) @extract_entity @asyncio.coroutine def async_api_adjust_brightness(hass, config, request, entity): """Process a adjust brightness request.""" brightness_delta = int(request[API_PAYLOAD]['brightnessDelta']) # read current state try: current = math.floor( int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100) except ZeroDivisionError: current = 0 # set brightness brightness = max(0, brightness_delta + current) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness, }, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.ColorController', 'SetColor')) @extract_entity @asyncio.coroutine def async_api_set_color(hass, config, request, entity): """Process a set color request.""" supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES) 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']) ) if supported & light.SUPPORT_RGB_COLOR > 0: yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_RGB_COLOR: rgb, }, blocking=False) else: xyz = color_util.color_RGB_to_xy(*rgb) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_XY_COLOR: (xyz[0], xyz[1]), light.ATTR_BRIGHTNESS: xyz[2], }, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) @extract_entity @asyncio.coroutine def async_api_set_color_temperature(hass, config, request, entity): """Process a set color temperature request.""" kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin']) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin, }, blocking=False) return api_message(request) @HANDLERS.register( ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) @extract_entity @asyncio.coroutine def async_api_decrease_color_temp(hass, config, request, entity): """Process a decrease color temperature request.""" current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) value = min(max_mireds, current + 50) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value, }, blocking=False) return api_message(request) @HANDLERS.register( ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) @extract_entity @asyncio.coroutine def async_api_increase_color_temp(hass, config, request, entity): """Process a increase color temperature request.""" current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) value = max(min_mireds, current - 50) yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value, }, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.SceneController', 'Activate')) @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, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False) payload = { 'cause': {'type': _Cause.VOICE_INTERACTION}, 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) } return api_message( request, name='ActivationStarted', namespace='Alexa.SceneController', payload=payload, ) @HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) @extract_entity @asyncio.coroutine def async_api_set_percentage(hass, config, request, entity): """Process a set percentage request.""" percentage = int(request[API_PAYLOAD]['percentage']) service = None data = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == fan.DOMAIN: service = fan.SERVICE_SET_SPEED speed = "off" if percentage <= 33: speed = "low" elif percentage <= 66: speed = "medium" elif percentage <= 100: speed = "high" data[fan.ATTR_SPEED] = speed elif entity.domain == cover.DOMAIN: service = SERVICE_SET_COVER_POSITION data[cover.ATTR_POSITION] = percentage yield from hass.services.async_call( entity.domain, service, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) @extract_entity @asyncio.coroutine def async_api_adjust_percentage(hass, config, request, entity): """Process a adjust percentage request.""" percentage_delta = int(request[API_PAYLOAD]['percentageDelta']) service = None data = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == fan.DOMAIN: service = fan.SERVICE_SET_SPEED speed = entity.attributes.get(fan.ATTR_SPEED) if speed == "off": current = 0 elif speed == "low": current = 33 elif speed == "medium": current = 66 elif speed == "high": current = 100 # set percentage percentage = max(0, percentage_delta + current) speed = "off" if percentage <= 33: speed = "low" elif percentage <= 66: speed = "medium" elif percentage <= 100: speed = "high" data[fan.ATTR_SPEED] = speed elif entity.domain == cover.DOMAIN: service = SERVICE_SET_COVER_POSITION current = entity.attributes.get(cover.ATTR_POSITION) data[cover.ATTR_POSITION] = max(0, percentage_delta + current) yield from hass.services.async_call( entity.domain, service, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.LockController', 'Lock')) @extract_entity @asyncio.coroutine def async_api_lock(hass, config, request, entity): """Process a lock request.""" yield from hass.services.async_call(entity.domain, SERVICE_LOCK, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False) return api_message(request) # Not supported by Alexa yet @HANDLERS.register(('Alexa.LockController', 'Unlock')) @extract_entity @asyncio.coroutine def async_api_unlock(hass, config, request, entity): """Process a unlock request.""" yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, { ATTR_ENTITY_ID: entity.entity_id }, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.Speaker', 'SetVolume')) @extract_entity @asyncio.coroutine def async_api_set_volume(hass, config, request, entity): """Process a set volume request.""" volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2) data = { ATTR_ENTITY_ID: entity.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } yield from hass.services.async_call( entity.domain, SERVICE_VOLUME_SET, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) @extract_entity @asyncio.coroutine def async_api_adjust_volume(hass, config, request, entity): """Process a adjust volume request.""" volume_delta = int(request[API_PAYLOAD]['volume']) current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) # read current state try: current = math.floor(int(current_level * 100)) except ZeroDivisionError: current = 0 volume = float(max(0, volume_delta + current) / 100) data = { ATTR_ENTITY_ID: entity.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } yield from hass.services.async_call( entity.domain, media_player.SERVICE_VOLUME_SET, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.Speaker', 'SetMute')) @extract_entity @asyncio.coroutine def async_api_set_mute(hass, config, request, entity): """Process a set mute request.""" mute = bool(request[API_PAYLOAD]['mute']) data = { ATTR_ENTITY_ID: entity.entity_id, media_player.ATTR_MEDIA_VOLUME_MUTED: mute, } yield from hass.services.async_call( entity.domain, media_player.SERVICE_VOLUME_MUTE, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.PlaybackController', 'Play')) @extract_entity @asyncio.coroutine def async_api_play(hass, config, request, entity): """Process a play request.""" data = { ATTR_ENTITY_ID: entity.entity_id } yield from hass.services.async_call( entity.domain, SERVICE_MEDIA_PLAY, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.PlaybackController', 'Pause')) @extract_entity @asyncio.coroutine def async_api_pause(hass, config, request, entity): """Process a pause request.""" data = { ATTR_ENTITY_ID: entity.entity_id } yield from hass.services.async_call( entity.domain, SERVICE_MEDIA_PAUSE, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.PlaybackController', 'Stop')) @extract_entity @asyncio.coroutine def async_api_stop(hass, config, request, entity): """Process a stop request.""" data = { ATTR_ENTITY_ID: entity.entity_id } yield from hass.services.async_call( entity.domain, SERVICE_MEDIA_STOP, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.PlaybackController', 'Next')) @extract_entity @asyncio.coroutine def async_api_next(hass, config, request, entity): """Process a next request.""" data = { ATTR_ENTITY_ID: entity.entity_id } yield from hass.services.async_call( entity.domain, SERVICE_MEDIA_NEXT_TRACK, data, blocking=False) return api_message(request) @HANDLERS.register(('Alexa.PlaybackController', 'Previous')) @extract_entity @asyncio.coroutine def async_api_previous(hass, config, request, entity): """Process a previous request.""" data = { ATTR_ENTITY_ID: entity.entity_id } yield from hass.services.async_call( entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, data, blocking=False) return api_message(request)