From 7e2278f1cc865055f749b3b262ebc7637736ecdd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Jun 2019 08:43:57 -0700 Subject: [PATCH] Clean up Alexa smart home code (#24514) * Clean up Alexa smart home code * lint * Lint * Lint --- homeassistant/components/alexa/__init__.py | 13 +- homeassistant/components/alexa/auth.py | 3 +- .../components/alexa/capabilities.py | 597 +++++ homeassistant/components/alexa/config.py | 13 + homeassistant/components/alexa/const.py | 84 +- homeassistant/components/alexa/entities.py | 445 ++++ homeassistant/components/alexa/errors.py | 87 + homeassistant/components/alexa/handlers.py | 728 ++++++ homeassistant/components/alexa/messages.py | 200 ++ homeassistant/components/alexa/smart_home.py | 2109 +---------------- .../components/alexa/smart_home_http.py | 81 + .../components/alexa/state_report.py | 109 + homeassistant/components/cloud/__init__.py | 8 +- homeassistant/components/cloud/client.py | 13 +- homeassistant/components/cloud/http_api.py | 6 +- tests/components/alexa/__init__.py | 178 ++ tests/components/alexa/test_auth.py | 67 + tests/components/alexa/test_capabilities.py | 340 +++ tests/components/alexa/test_entities.py | 19 + tests/components/alexa/test_smart_home.py | 720 +----- .../components/alexa/test_smart_home_http.py | 46 + tests/components/alexa/test_state_report.py | 40 + tests/components/cloud/test_http_api.py | 2 +- 23 files changed, 3132 insertions(+), 2776 deletions(-) create mode 100644 homeassistant/components/alexa/capabilities.py create mode 100644 homeassistant/components/alexa/config.py create mode 100644 homeassistant/components/alexa/entities.py create mode 100644 homeassistant/components/alexa/errors.py create mode 100644 homeassistant/components/alexa/handlers.py create mode 100644 homeassistant/components/alexa/messages.py create mode 100644 homeassistant/components/alexa/smart_home_http.py create mode 100644 homeassistant/components/alexa/state_report.py create mode 100644 tests/components/alexa/test_auth.py create mode 100644 tests/components/alexa/test_capabilities.py create mode 100644 tests/components/alexa/test_entities.py create mode 100644 tests/components/alexa/test_smart_home_http.py create mode 100644 tests/components/alexa/test_state_report.py diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 862605b64b5..a15d87175db 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -5,12 +5,13 @@ import voluptuous as vol from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entityfilter +from homeassistant.const import CONF_NAME -from . import flash_briefings, intent, smart_home +from . import flash_briefings, intent, smart_home_http from .const import ( CONF_AUDIO, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY_URL, CONF_ENDPOINT, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, CONF_FILTER, - CONF_ENTITY_CONFIG) + CONF_ENTITY_CONFIG, CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES) _LOGGER = logging.getLogger(__name__) @@ -18,9 +19,9 @@ CONF_FLASH_BRIEFINGS = 'flash_briefings' CONF_SMART_HOME = 'smart_home' ALEXA_ENTITY_SCHEMA = vol.Schema({ - vol.Optional(smart_home.CONF_DESCRIPTION): cv.string, - vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string, - vol.Optional(smart_home.CONF_NAME): cv.string, + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(CONF_NAME): cv.string, }) SMART_HOME_SCHEMA = vol.Schema({ @@ -65,6 +66,6 @@ async def async_setup(hass, config): pass else: smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) - await smart_home.async_setup(hass, smart_home_config) + await smart_home_http.async_setup(hass, smart_home_config) return True diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 0717532f64d..dd61018d739 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -9,7 +9,6 @@ import async_timeout from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.util import dt -from .const import DEFAULT_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -97,7 +96,7 @@ class Auth: try: session = aiohttp_client.async_get_clientsession(self.hass) - with async_timeout.timeout(DEFAULT_TIMEOUT): + with async_timeout.timeout(10): response = await session.post(LWA_TOKEN_URI, headers=LWA_HEADERS, data=lwa_params, diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py new file mode 100644 index 00000000000..801005b4b4a --- /dev/null +++ b/homeassistant/components/alexa/capabilities.py @@ -0,0 +1,597 @@ +"""Alexa capabilities.""" +from datetime import datetime +import logging + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + STATE_LOCKED, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNLOCKED, +) +import homeassistant.components.climate.const as climate +from homeassistant.components import ( + light, + fan, + cover, +) +import homeassistant.util.color as color_util + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + DATE_FORMAT, + PERCENTAGE_FAN_MAP, +) +from .errors import UnsupportedProperty + + +_LOGGER = logging.getLogger(__name__) + + +class AlexaCapibility: + """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): + """Initialize an Alexa capibility.""" + self.entity = entity + + def name(self): + """Return the Alexa API name of this interface.""" + raise NotImplementedError + + @staticmethod + def properties_supported(): + """Return what properties this entity supports.""" + return [] + + @staticmethod + def properties_proactively_reported(): + """Return True if properties asynchronously reported.""" + return False + + @staticmethod + def properties_retrievable(): + """Return True if properties can be retrieved.""" + return False + + @staticmethod + def get_property(name): + """Read and return a property. + + Return value should be a dict, or raise UnsupportedProperty. + + Properties can also have a timeOfSample and uncertaintyInMilliseconds, + but returning those metadata is not yet implemented. + """ + raise UnsupportedProperty(name) + + @staticmethod + def supports_deactivation(): + """Applicable only to scenes.""" + return None + + def serialize_discovery(self): + """Serialize according to the Discovery API.""" + result = { + 'type': 'AlexaInterface', + 'interface': self.name(), + 'version': '3', + 'properties': { + 'supported': self.properties_supported(), + 'proactivelyReported': self.properties_proactively_reported(), + 'retrievable': self.properties_retrievable(), + }, + } + + # pylint: disable=assignment-from-none + supports_deactivation = self.supports_deactivation() + if supports_deactivation is not None: + result['supportsDeactivation'] = supports_deactivation + return result + + def serialize_properties(self): + """Return properties serialized for an API response.""" + for prop in self.properties_supported(): + prop_name = prop['name'] + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': prop_value, + 'timeOfSample': datetime.now().strftime(DATE_FORMAT), + 'uncertaintyInMilliseconds': 0 + } + + +class AlexaEndpointHealth(AlexaCapibility): + """Implements Alexa.EndpointHealth. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.EndpointHealth' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'connectivity'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return False + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'connectivity': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_UNAVAILABLE: + return {'value': 'UNREACHABLE'} + return {'value': 'OK'} + + +class AlexaPowerController(AlexaCapibility): + """Implements Alexa.PowerController. + + https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PowerController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'powerState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'powerState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_OFF: + return 'OFF' + return 'ON' + + +class AlexaLockController(AlexaCapibility): + """Implements Alexa.LockController. + + https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.LockController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'lockState'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'lockState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_LOCKED: + return 'LOCKED' + if self.entity.state == STATE_UNLOCKED: + return 'UNLOCKED' + return 'JAMMED' + + +class AlexaSceneController(AlexaCapibility): + """Implements Alexa.SceneController. + + https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html + """ + + def __init__(self, entity, supports_deactivation): + """Initialize the entity.""" + super().__init__(entity) + self.supports_deactivation = lambda: supports_deactivation + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.SceneController' + + +class AlexaBrightnessController(AlexaCapibility): + """Implements Alexa.BrightnessController. + + https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.BrightnessController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'brightness'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'brightness': + raise UnsupportedProperty(name) + if 'brightness' in self.entity.attributes: + return round(self.entity.attributes['brightness'] / 255.0 * 100) + return 0 + + +class AlexaColorController(AlexaCapibility): + """Implements Alexa.ColorController. + + https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ColorController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'color'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'color': + raise UnsupportedProperty(name) + + hue, saturation = self.entity.attributes.get( + light.ATTR_HS_COLOR, (0, 0)) + + return { + 'hue': hue, + 'saturation': saturation / 100.0, + 'brightness': self.entity.attributes.get( + light.ATTR_BRIGHTNESS, 0) / 255.0, + } + + +class AlexaColorTemperatureController(AlexaCapibility): + """Implements Alexa.ColorTemperatureController. + + https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ColorTemperatureController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'colorTemperatureInKelvin'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'colorTemperatureInKelvin': + raise UnsupportedProperty(name) + if 'color_temp' in self.entity.attributes: + return color_util.color_temperature_mired_to_kelvin( + self.entity.attributes['color_temp']) + return 0 + + +class AlexaPercentageController(AlexaCapibility): + """Implements Alexa.PercentageController. + + https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PercentageController' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'percentage'}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'percentage': + raise UnsupportedProperty(name) + + if self.entity.domain == fan.DOMAIN: + speed = self.entity.attributes.get(fan.ATTR_SPEED) + + return PERCENTAGE_FAN_MAP.get(speed, 0) + + if self.entity.domain == cover.DOMAIN: + return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0) + + return 0 + + +class AlexaSpeaker(AlexaCapibility): + """Implements Alexa.Speaker. + + https://developer.amazon.com/docs/device-apis/alexa-speaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.Speaker' + + +class AlexaStepSpeaker(AlexaCapibility): + """Implements Alexa.StepSpeaker. + + https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.StepSpeaker' + + +class AlexaPlaybackController(AlexaCapibility): + """Implements Alexa.PlaybackController. + + https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.PlaybackController' + + +class AlexaInputController(AlexaCapibility): + """Implements Alexa.InputController. + + https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html + """ + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.InputController' + + +class AlexaTemperatureSensor(AlexaCapibility): + """Implements Alexa.TemperatureSensor. + + https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.TemperatureSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'temperature'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'temperature': + raise UnsupportedProperty(name) + + unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = self.entity.state + if self.entity.domain == climate.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get( + climate.ATTR_CURRENT_TEMPERATURE) + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } + + +class AlexaContactSensor(AlexaCapibility): + """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): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ContactSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'detectionState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class AlexaMotionSensor(AlexaCapibility): + """Implements Alexa.MotionSensor. + + https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.MotionSensor' + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{'name': 'detectionState'}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != 'detectionState': + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'DETECTED' + return 'NOT_DETECTED' + + +class AlexaThermostatController(AlexaCapibility): + """Implements Alexa.ThermostatController. + + https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return 'Alexa.ThermostatController' + + def properties_supported(self): + """Return what properties this entity supports.""" + properties = [] + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_TARGET_TEMPERATURE: + properties.append({'name': 'targetSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW: + properties.append({'name': 'lowerSetpoint'}) + if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH: + properties.append({'name': 'upperSetpoint'}) + if supported & climate.SUPPORT_OPERATION_MODE: + properties.append({'name': 'thermostatMode'}) + return properties + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name == 'thermostatMode': + ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE) + mode = API_THERMOSTAT_MODES.get(ha_mode) + if mode is None: + _LOGGER.error("%s (%s) has unsupported %s value '%s'", + self.entity.entity_id, type(self.entity), + climate.ATTR_OPERATION_MODE, ha_mode) + raise UnsupportedProperty(name) + return mode + + unit = self.hass.config.units.temperature_unit + if name == 'targetSetpoint': + temp = self.entity.attributes.get(ATTR_TEMPERATURE) + elif name == 'lowerSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) + elif name == 'upperSetpoint': + temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) + else: + raise UnsupportedProperty(name) + + if temp is None: + return None + + return { + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py new file mode 100644 index 00000000000..df9c9b013dc --- /dev/null +++ b/homeassistant/components/alexa/config.py @@ -0,0 +1,13 @@ +"""Config helpers for Alexa.""" + + +class Config: + """Hold the configuration for Alexa.""" + + def __init__(self, endpoint, async_get_access_token, should_expose, + entity_config=None): + """Initialize the configuration.""" + self.endpoint = endpoint + self.async_get_access_token = async_get_access_token + self.should_expose = should_expose + self.entity_config = entity_config or {} diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 78f7d02f5f0..9931406ff0e 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -1,4 +1,15 @@ """Constants for the Alexa integration.""" +from collections import OrderedDict + +from homeassistant.const import ( + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.components.climate import const as climate +from homeassistant.components import fan + + DOMAIN = 'alexa' # Flash briefing constants @@ -25,4 +36,75 @@ SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH' DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' -DEFAULT_TIMEOUT = 30 +API_DIRECTIVE = 'directive' +API_ENDPOINT = 'endpoint' +API_EVENT = 'event' +API_CONTEXT = 'context' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_SCOPE = 'scope' +API_CHANGE = 'change' + +CONF_DESCRIPTION = 'description' +CONF_DISPLAY_CATEGORIES = 'display_categories' + +AUTH_KEY = "alexa.smart_home.auth" + +API_TEMP_UNITS = { + TEMP_FAHRENHEIT: 'FAHRENHEIT', + TEMP_CELSIUS: 'CELSIUS', +} + +# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a +# reverse mapping of this dict and we want to map the first occurrance of OFF +# back to HA state. +API_THERMOSTAT_MODES = OrderedDict([ + (climate.STATE_HEAT, 'HEAT'), + (climate.STATE_COOL, 'COOL'), + (climate.STATE_AUTO, 'AUTO'), + (climate.STATE_ECO, 'ECO'), + (climate.STATE_MANUAL, 'AUTO'), + (STATE_OFF, 'OFF'), + (climate.STATE_IDLE, 'OFF'), + (climate.STATE_FAN_ONLY, 'OFF'), + (climate.STATE_DRY, 'OFF'), +]) + +PERCENTAGE_FAN_MAP = { + fan.SPEED_LOW: 33, + fan.SPEED_MEDIUM: 66, + fan.SPEED_HIGH: 100, +} + + +class Cause: + """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' diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py new file mode 100644 index 00000000000..30dfbbb8867 --- /dev/null +++ b/homeassistant/components/alexa/entities.py @@ -0,0 +1,445 @@ +"""Alexa entity adapters.""" +from typing import List + +from homeassistant.core import callback +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util.decorator import Registry +from homeassistant.components.climate import const as climate +from homeassistant.components import ( + alert, automation, binary_sensor, cover, fan, group, + input_boolean, light, lock, media_player, scene, script, sensor, switch) + +from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES +from .capabilities import ( + AlexaBrightnessController, + AlexaColorController, + AlexaColorTemperatureController, + AlexaContactSensor, + AlexaEndpointHealth, + AlexaInputController, + AlexaLockController, + AlexaMotionSensor, + AlexaPercentageController, + AlexaPlaybackController, + AlexaPowerController, + AlexaSceneController, + AlexaSpeaker, + AlexaStepSpeaker, + AlexaTemperatureSensor, + AlexaThermostatController, +) + +ENTITY_ADAPTERS = Registry() + + +class DisplayCategory: + """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 + # Netflix" 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 an endpoint that detects and reports contact. + CONTACT_SENSOR = "CONTACT_SENSOR" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # Indicates an endpoint that detects and reports motion. + MOTION_SENSOR = "MOTION_SENSOR" + + # 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. + TV = "TV" + + +class AlexaEntity: + """An adaptation of an entity, expressed in Alexa's terms. + + The API handlers should manipulate entities only through this interface. + """ + + def __init__(self, hass, config, entity): + """Initialize Alexa Entity.""" + self.hass = hass + self.config = config + self.entity = entity + self.entity_conf = config.entity_config.get(entity.entity_id, {}) + + @property + def entity_id(self): + """Return the Entity ID.""" + return self.entity.entity_id + + def friendly_name(self): + """Return the Alexa API friendly name.""" + return self.entity_conf.get(CONF_NAME, self.entity.name) + + def description(self): + """Return the Alexa API description.""" + return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) + + def alexa_id(self): + """Return the Alexa API entity id.""" + return self.entity.entity_id.replace('.', '#') + + 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 get_interface(self, capability): + """Return the given AlexaInterface. + + Raises _UnsupportedInterface. + """ + pass + + def interfaces(self): + """Return a list of supported interfaces. + + Used for discovery. The list should contain AlexaInterface instances. + If the list is empty, this entity will not be discovered. + """ + 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 + + +@callback +def async_get_entities(hass, config) -> List[AlexaEntity]: + """Return all entities that are supported by Alexa.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + if state.domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + + if not list(alexa_entity.interfaces()): + continue + + entities.append(alexa_entity) + + return entities + + +@ENTITY_ADAPTERS.register(alert.DOMAIN) +@ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) +class GenericCapabilities(AlexaEntity): + """A generic, on/off device. + + The choice of last resort. + """ + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(switch.DOMAIN) +class SwitchCapabilities(AlexaEntity): + """Class to represent Switch capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SWITCH] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(climate.DOMAIN) +class ClimateCapabilities(AlexaEntity): + """Class to represent Climate capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.THERMOSTAT] + + def interfaces(self): + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_ON_OFF: + yield AlexaPowerController(self.entity) + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(cover.DOMAIN) +class CoverCapabilities(AlexaEntity): + """Class to represent Cover capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.DOOR] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.SUPPORT_SET_POSITION: + yield AlexaPercentageController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(light.DOMAIN) +class LightCapabilities(AlexaEntity): + """Class to represent Light capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.LIGHT] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & light.SUPPORT_BRIGHTNESS: + yield AlexaBrightnessController(self.entity) + if supported & light.SUPPORT_COLOR: + yield AlexaColorController(self.entity) + if supported & light.SUPPORT_COLOR_TEMP: + yield AlexaColorTemperatureController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(fan.DOMAIN) +class FanCapabilities(AlexaEntity): + """Class to represent Fan capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & fan.SUPPORT_SET_SPEED: + yield AlexaPercentageController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(lock.DOMAIN) +class LockCapabilities(AlexaEntity): + """Class to represent Lock capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SMARTLOCK] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaLockController(self.entity), + AlexaEndpointHealth(self.hass, self.entity)] + + +@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) +class MediaPlayerCapabilities(AlexaEntity): + """Class to represent MediaPlayer capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.TV] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaEndpointHealth(self.hass, self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.const.SUPPORT_VOLUME_SET: + yield AlexaSpeaker(self.entity) + + power_features = (media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF) + if supported & power_features: + yield AlexaPowerController(self.entity) + + step_volume_features = (media_player.const.SUPPORT_VOLUME_MUTE | + media_player.const.SUPPORT_VOLUME_STEP) + if supported & step_volume_features: + yield AlexaStepSpeaker(self.entity) + + playback_features = (media_player.const.SUPPORT_PLAY | + media_player.const.SUPPORT_PAUSE | + media_player.const.SUPPORT_STOP | + media_player.const.SUPPORT_NEXT_TRACK | + media_player.const.SUPPORT_PREVIOUS_TRACK) + if supported & playback_features: + yield AlexaPlaybackController(self.entity) + + if supported & media_player.SUPPORT_SELECT_SOURCE: + yield AlexaInputController(self.entity) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class SceneCapabilities(AlexaEntity): + """Class to represent Scene capabilities.""" + + def description(self): + """Return the description of the entity.""" + # Required description as per Amazon Scene docs + scene_fmt = '{} (Scene connected via Home Assistant)' + return scene_fmt.format(AlexaEntity.description(self)) + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SCENE_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [AlexaSceneController(self.entity, + supports_deactivation=False)] + + +@ENTITY_ADAPTERS.register(script.DOMAIN) +class ScriptCapabilities(AlexaEntity): + """Class to represent Script capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + can_cancel = bool(self.entity.attributes.get('can_cancel')) + return [AlexaSceneController(self.entity, + supports_deactivation=can_cancel)] + + +@ENTITY_ADAPTERS.register(sensor.DOMAIN) +class SensorCapabilities(AlexaEntity): + """Class to represent Sensor capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [DisplayCategory.TEMPERATURE_SENSOR] + + def interfaces(self): + """Yield the supported interfaces.""" + attrs = self.entity.attributes + if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in ( + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + ): + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + + +@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) +class BinarySensorCapabilities(AlexaEntity): + """Class to represent BinarySensor capabilities.""" + + TYPE_CONTACT = 'contact' + TYPE_MOTION = 'motion' + + def default_display_categories(self): + """Return the display categories for this entity.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + return [DisplayCategory.CONTACT_SENSOR] + if sensor_type is self.TYPE_MOTION: + return [DisplayCategory.MOTION_SENSOR] + + def interfaces(self): + """Yield the supported interfaces.""" + sensor_type = self.get_type() + if sensor_type is self.TYPE_CONTACT: + yield AlexaContactSensor(self.hass, self.entity) + elif sensor_type is self.TYPE_MOTION: + yield AlexaMotionSensor(self.hass, self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + + def get_type(self): + """Return the type of binary sensor.""" + attrs = self.entity.attributes + if attrs.get(ATTR_DEVICE_CLASS) in ( + 'door', + 'garage_door', + 'opening', + 'window', + ): + return self.TYPE_CONTACT + if attrs.get(ATTR_DEVICE_CLASS) == 'motion': + return self.TYPE_MOTION diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py new file mode 100644 index 00000000000..651ddc5b187 --- /dev/null +++ b/homeassistant/components/alexa/errors.py @@ -0,0 +1,87 @@ +"""Alexa related errors.""" +from homeassistant.exceptions import HomeAssistantError + +from .const import API_TEMP_UNITS + + +class UnsupportedInterface(HomeAssistantError): + """This entity does not support the requested Smart Home API interface.""" + + +class UnsupportedProperty(HomeAssistantError): + """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): + """Initialize an alexa error.""" + 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): + """Initialize invalid endpoint error.""" + msg = 'The endpoint {} does not exist'.format(endpoint_id) + AlexaError.__init__(self, msg) + self.endpoint_id = endpoint_id + + +class AlexaInvalidValueError(AlexaError): + """Class to represent InvalidValue errors.""" + + namespace = 'Alexa' + error_type = 'INVALID_VALUE' + + +class AlexaUnsupportedThermostatModeError(AlexaError): + """Class to represent UnsupportedThermostatMode errors.""" + + namespace = 'Alexa.ThermostatController' + error_type = 'UNSUPPORTED_THERMOSTAT_MODE' + + +class AlexaTempRangeError(AlexaError): + """Class to represent TempRange errors.""" + + namespace = 'Alexa' + error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + def __init__(self, hass, temp, min_temp, max_temp): + """Initialize TempRange error.""" + 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): + """Class to represent BridgeUnreachable errors.""" + + namespace = 'Alexa' + error_type = 'BRIDGE_UNREACHABLE' diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py new file mode 100644 index 00000000000..a17381b3e17 --- /dev/null +++ b/homeassistant/components/alexa/handlers.py @@ -0,0 +1,728 @@ +"""Alexa message handlers.""" +from datetime import datetime +import logging +import math + +from homeassistant import core as ha +from homeassistant.util.decorator import Registry +import homeassistant.util.color as color_util +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + 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_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.components.climate import const as climate +from homeassistant.components import cover, fan, group, light, media_player +from homeassistant.util.temperature import convert as convert_temperature + +from .const import ( + AUTH_KEY, + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + Cause, +) +from .entities import async_get_entities +from .state_report import async_enable_proactive_mode +from .errors import ( + AlexaInvalidValueError, + AlexaTempRangeError, + AlexaUnsupportedThermostatModeError, +) + +_LOGGER = logging.getLogger(__name__) +HANDLERS = Registry() + + +@HANDLERS.register(('Alexa.Discovery', 'Discover')) +async def async_api_discovery(hass, config, directive, context): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [ + { + 'displayCategories': alexa_entity.display_categories(), + 'cookie': {}, + 'endpointId': alexa_entity.alexa_id(), + 'friendlyName': alexa_entity.friendly_name(), + 'description': alexa_entity.description(), + 'manufacturerName': 'Home Assistant', + 'capabilities': [ + i.serialize_discovery() for i in alexa_entity.interfaces() + ] + } + for alexa_entity in async_get_entities(hass, config) + if config.should_expose(alexa_entity.entity_id) + ] + + return directive.response( + name='Discover.Response', + namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}, + ) + + +@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant')) +async def async_api_accept_grant(hass, config, directive, context): + """Create a API formatted AcceptGrant response. + + Async friendly. + """ + auth_code = directive.payload['grant']['code'] + _LOGGER.debug("AcceptGrant code: %s", auth_code) + + if AUTH_KEY in hass.data: + await hass.data[AUTH_KEY].async_do_auth(auth_code) + await async_enable_proactive_mode(hass, config) + + return directive.response( + name='AcceptGrant.Response', + namespace='Alexa.Authorization', + payload={}) + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) +async def async_api_turn_on(hass, config, directive, context): + """Process a turn on request.""" + entity = directive.entity + domain = entity.domain + if domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_ON + 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 directive.response() + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) +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 + + service = SERVICE_TURN_OFF + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + + await hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) +async def async_api_set_brightness(hass, config, directive, context): + """Process a set brightness request.""" + 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 directive.response() + + +@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) +async def async_api_adjust_brightness(hass, config, directive, context): + """Process an adjust brightness request.""" + entity = directive.entity + brightness_delta = int(directive.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) + 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 directive.response() + + +@HANDLERS.register(('Alexa.ColorController', 'SetColor')) +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(directive.payload['color']['hue']), + float(directive.payload['color']['saturation']), + float(directive.payload['color']['brightness']) + ) + + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_RGB_COLOR: rgb, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) +async def async_api_set_color_temperature(hass, config, directive, context): + """Process a set color temperature request.""" + 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 directive.response() + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) +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)) + + value = min(max_mireds, current + 50) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) +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)) + + value = max(min_mireds, current - 50) + await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.SceneController', 'Activate')) +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, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + payload = { + 'cause': {'type': Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return directive.response( + name='ActivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.SceneController', 'Deactivate')) +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, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False, context=context) + + payload = { + 'cause': {'type': Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return directive.response( + name='DeactivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) +async def async_api_set_percentage(hass, config, directive, context): + """Process a set percentage request.""" + entity = directive.entity + percentage = int(directive.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 + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) +async def async_api_adjust_percentage(hass, config, directive, context): + """Process an adjust percentage request.""" + entity = directive.entity + percentage_delta = int(directive.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) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.LockController', 'Lock')) +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) + + response = directive.response() + response.add_context_property({ + 'name': 'lockState', + 'namespace': 'Alexa.LockController', + 'value': 'LOCKED' + }) + return response + + +# Not supported by Alexa yet +@HANDLERS.register(('Alexa.LockController', 'Unlock')) +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 directive.response() + + +@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) +async def async_api_set_volume(hass, config, directive, context): + """Process a set volume request.""" + volume = round(float(directive.payload['volume'] / 100), 2) + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.InputController', 'SelectInput')) +async def async_api_select_input(hass, config, directive, context): + """Process a set input request.""" + 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.const.ATTR_INPUT_SOURCE_LIST] or [] + for source in source_list: + # response will always be space separated, so format the source in the + # most likely way to find a match + formatted_source = source.lower().replace('-', ' ').replace('_', ' ') + if formatted_source in media_input.lower(): + media_input = source + break + else: + msg = 'failed to map input {} to a media source on {}'.format( + media_input, entity.entity_id) + raise AlexaInvalidValueError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_INPUT_SOURCE: media_input, + } + + await hass.services.async_call( + entity.domain, media_player.SERVICE_SELECT_SOURCE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) +async def async_api_adjust_volume(hass, config, directive, context): + """Process an adjust volume request.""" + volume_delta = int(directive.payload['volume']) + + entity = directive.entity + current_level = entity.attributes.get( + media_player.const.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.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) +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 = directive.payload['volumeSteps'] + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + + if volume_step > 0: + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_UP, + data, blocking=False, context=context) + elif volume_step < 0: + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_DOWN, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) +@HANDLERS.register(('Alexa.Speaker', 'SetMute')) +async def async_api_set_mute(hass, config, directive, context): + """Process a set mute request.""" + mute = bool(directive.payload['mute']) + entity = directive.entity + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + await hass.services.async_call( + entity.domain, SERVICE_VOLUME_MUTE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Play')) +async def async_api_play(hass, config, directive, context): + """Process a play request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PLAY, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) +async def async_api_pause(hass, config, directive, context): + """Process a pause request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PAUSE, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) +async def async_api_stop(hass, config, directive, context): + """Process a stop request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Next')) +async def async_api_next(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_NEXT_TRACK, + data, blocking=False, context=context) + + return directive.response() + + +@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) +async def async_api_previous(hass, config, directive, context): + """Process a previous request.""" + entity = directive.entity + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + await hass.services.async_call( + entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, + data, blocking=False, context=context) + + return directive.response() + + +def temperature_from_object(hass, temp_obj, interval=False): + """Get temperature from Temperature object in requested unit.""" + to_unit = hass.config.units.temperature_unit + from_unit = TEMP_CELSIUS + temp = float(temp_obj['value']) + + if temp_obj['scale'] == 'FAHRENHEIT': + from_unit = TEMP_FAHRENHEIT + elif temp_obj['scale'] == 'KELVIN': + # convert to Celsius if absolute temperature + if not interval: + temp -= 273.15 + + return convert_temperature(temp, from_unit, to_unit, interval) + + +@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature')) +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 = directive.payload + response = directive.response() + if 'targetSetpoint' in payload: + temp = temperature_from_object(hass, payload['targetSetpoint']) + if temp < min_temp or 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: + 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: + 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 response + + +@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) +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, 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: + 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 response + + +@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) +async def async_api_set_thermostat_mode(hass, config, directive, context): + """Process a set thermostat mode request.""" + 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) + ha_mode = next( + (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), + None + ) + if ha_mode not in operation_list: + msg = 'The requested thermostat mode {} is not supported'.format(mode) + raise AlexaUnsupportedThermostatModeError(msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + climate.ATTR_OPERATION_MODE: ha_mode, + } + + response = directive.response() + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, + blocking=False, context=context) + response.add_context_property({ + 'name': 'thermostatMode', + 'namespace': 'Alexa.ThermostatController', + 'value': mode, + }) + + return response + + +@HANDLERS.register(('Alexa', 'ReportState')) +async def async_api_reportstate(hass, config, directive, context): + """Process a ReportState request.""" + return directive.response(name='StateReport') diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py new file mode 100644 index 00000000000..3dd72c11294 --- /dev/null +++ b/homeassistant/components/alexa/messages.py @@ -0,0 +1,200 @@ +"""Alexa models.""" +import logging +from uuid import uuid4 + +from .const import ( + API_CONTEXT, + API_DIRECTIVE, + API_ENDPOINT, + API_EVENT, + API_HEADER, + API_PAYLOAD, + API_SCOPE, +) +from .entities import ENTITY_ADAPTERS +from .errors import AlexaInvalidEndpointError + +_LOGGER = logging.getLogger(__name__) + + +class AlexaDirective: + """An incoming Alexa directive.""" + + def __init__(self, request): + """Initialize a directive.""" + 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: + """Class to hold a response.""" + + def __init__(self, name, namespace, payload=None): + """Initialize the response.""" + 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_full(self, bearer_token, endpoint_id, cookie=None): + """Set the endpoint dictionary. + + This is used to send proactive messages to Alexa. + """ + self._response[API_EVENT][API_ENDPOINT] = { + API_SCOPE: { + 'type': 'BearerToken', + 'token': bearer_token + } + } + + if endpoint_id is not None: + self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id + + if cookie is not None: + self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie + + 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 diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a69a0cf6ec7..f87e6bdee35 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,1338 +1,54 @@ """Support for alexa Smart Home Skill API.""" -import asyncio -import json import logging -import math -from collections import OrderedDict -from datetime import datetime -from uuid import uuid4 - -import aiohttp -import async_timeout import homeassistant.core as ha -import homeassistant.util.color as color_util -from homeassistant.components import ( - alert, automation, binary_sensor, cover, fan, group, http, - input_boolean, light, lock, media_player, scene, script, sensor, switch) -from homeassistant.components.climate import const as climate -from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, - 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_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET, - SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, - STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL) -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.event import async_track_state_change -from homeassistant.util.decorator import Registry -from homeassistant.util.temperature import convert as convert_temperature -from .auth import Auth -from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \ - CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT +from .const import API_DIRECTIVE, API_HEADER +from .errors import ( + AlexaError, + AlexaBridgeUnreachableError, +) +from .handlers import HANDLERS +from .messages import AlexaDirective _LOGGER = logging.getLogger(__name__) -API_DIRECTIVE = 'directive' -API_ENDPOINT = 'endpoint' -API_EVENT = 'event' -API_CONTEXT = 'context' -API_HEADER = 'header' -API_PAYLOAD = 'payload' -API_SCOPE = 'scope' -API_CHANGE = 'change' - -API_TEMP_UNITS = { - TEMP_FAHRENHEIT: 'FAHRENHEIT', - TEMP_CELSIUS: 'CELSIUS', -} - -# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a -# reverse mapping of this dict and we want to map the first occurrance of OFF -# back to HA state. -API_THERMOSTAT_MODES = OrderedDict([ - (climate.STATE_HEAT, 'HEAT'), - (climate.STATE_COOL, 'COOL'), - (climate.STATE_AUTO, 'AUTO'), - (climate.STATE_ECO, 'ECO'), - (climate.STATE_MANUAL, 'AUTO'), - (STATE_OFF, 'OFF'), - (climate.STATE_IDLE, 'OFF'), - (climate.STATE_FAN_ONLY, 'OFF'), - (climate.STATE_DRY, 'OFF'), -]) - -PERCENTAGE_FAN_MAP = { - fan.SPEED_LOW: 33, - fan.SPEED_MEDIUM: 66, - fan.SPEED_HIGH: 100, -} - -SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' - -CONF_DESCRIPTION = 'description' -CONF_DISPLAY_CATEGORIES = 'display_categories' - -HANDLERS = Registry() -ENTITY_ADAPTERS = Registry() EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' -AUTH_KEY = "alexa.smart_home.auth" +# def _capability(interface, +# version=3, +# supports_deactivation=None, +# retrievable=None, +# properties_supported=None, +# cap_type='AlexaInterface'): +# """Return a Smart Home API capability object. -class _DisplayCategory: - """Possible display categories for Discovery response. +# https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object - https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories - """ +# There are some additional fields allowed but not implemented here since +# we've no use case for them yet: - # Describes a combination of devices set to a specific state, when the - # state change must occur in a specific order. For example, a "watch - # Netflix" scene might require the: 1. TV to be powered on & 2. Input set - # to HDMI1. Applies to Scenes - ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER" +# - proactively_reported - # Indicates media devices with video or photo capabilities. - CAMERA = "CAMERA" +# `supports_deactivation` applies only to scenes. +# """ +# result = { +# 'type': cap_type, +# 'interface': interface, +# 'version': version, +# } - # Indicates an endpoint that detects and reports contact. - CONTACT_SENSOR = "CONTACT_SENSOR" +# if supports_deactivation is not None: +# result['supportsDeactivation'] = supports_deactivation - # Indicates a door. - DOOR = "DOOR" +# if retrievable is not None: +# result['retrievable'] = retrievable - # Indicates light sources or fixtures. - LIGHT = "LIGHT" +# if properties_supported is not None: +# result['properties'] = {'supported': properties_supported} - # Indicates an endpoint that detects and reports motion. - MOTION_SENSOR = "MOTION_SENSOR" - - # 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. - TV = "TV" - - -def _capability(interface, - version=3, - supports_deactivation=None, - retrievable=None, - properties_supported=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: - - - proactively_reported - - `supports_deactivation` applies only to scenes. - """ - result = { - 'type': cap_type, - 'interface': interface, - 'version': version, - } - - if supports_deactivation is not None: - result['supportsDeactivation'] = supports_deactivation - - if retrievable is not None: - result['retrievable'] = retrievable - - if properties_supported is not None: - result['properties'] = {'supported': properties_supported} - - return result - - -class _UnsupportedInterface(Exception): - """This entity does not support the requested Smart Home API interface.""" - - -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. - - The API handlers should manipulate entities only through this interface. - """ - - def __init__(self, hass, config, entity): - self.hass = hass - self.config = config - self.entity = entity - self.entity_conf = config.entity_config.get(entity.entity_id, {}) - - def friendly_name(self): - """Return the Alexa API friendly name.""" - return self.entity_conf.get(CONF_NAME, self.entity.name) - - def description(self): - """Return the Alexa API description.""" - return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) - - def entity_id(self): - """Return the Alexa API entity id.""" - return self.entity.entity_id.replace('.', '#') - - 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 get_interface(self, capability): - """Return the given _AlexaInterface. - - Raises _UnsupportedInterface. - """ - pass - - def interfaces(self): - """Return a list of supported interfaces. - - Used for discovery. The list should contain _AlexaInterface instances. - If the list is empty, this entity will not be discovered. - """ - 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 - - def name(self): - """Return the Alexa API name of this interface.""" - raise NotImplementedError - - @staticmethod - def properties_supported(): - """Return what properties this entity supports.""" - return [] - - @staticmethod - def properties_proactively_reported(): - """Return True if properties asynchronously reported.""" - return False - - @staticmethod - def properties_retrievable(): - """Return True if properties can be retrieved.""" - return False - - @staticmethod - def get_property(name): - """Read and return a property. - - Return value should be a dict, or raise _UnsupportedProperty. - - Properties can also have a timeOfSample and uncertaintyInMilliseconds, - but returning those metadata is not yet implemented. - """ - raise _UnsupportedProperty(name) - - @staticmethod - def supports_deactivation(): - """Applicable only to scenes.""" - return None - - def serialize_discovery(self): - """Serialize according to the Discovery API.""" - result = { - 'type': 'AlexaInterface', - 'interface': self.name(), - 'version': '3', - 'properties': { - 'supported': self.properties_supported(), - 'proactivelyReported': self.properties_proactively_reported(), - 'retrievable': self.properties_retrievable(), - }, - } - - # pylint: disable=assignment-from-none - supports_deactivation = self.supports_deactivation() - if supports_deactivation is not None: - result['supportsDeactivation'] = supports_deactivation - return result - - def serialize_properties(self): - """Return properties serialized for an API response.""" - for prop in self.properties_supported(): - prop_name = prop['name'] - # pylint: disable=assignment-from-no-return - prop_value = self.get_property(prop_name) - if prop_value is not None: - yield { - 'name': prop_name, - 'namespace': self.name(), - 'value': prop_value, - 'timeOfSample': datetime.now().strftime(DATE_FORMAT), - 'uncertaintyInMilliseconds': 0 - } - - -class _AlexaEndpointHealth(_AlexaInterface): - """Implements Alexa.EndpointHealth. - - https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it - """ - - def __init__(self, hass, entity): - super().__init__(entity) - self.hass = hass - - def name(self): - return 'Alexa.EndpointHealth' - - def properties_supported(self): - return [{'name': 'connectivity'}] - - def properties_proactively_reported(self): - return False - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'connectivity': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_UNAVAILABLE: - return {'value': 'UNREACHABLE'} - return {'value': 'OK'} - - -class _AlexaPowerController(_AlexaInterface): - """Implements Alexa.PowerController. - - https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html - """ - - def name(self): - return 'Alexa.PowerController' - - def properties_supported(self): - return [{'name': 'powerState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'powerState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_OFF: - return 'OFF' - return 'ON' - - -class _AlexaLockController(_AlexaInterface): - """Implements Alexa.LockController. - - https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html - """ - - def name(self): - return 'Alexa.LockController' - - def properties_supported(self): - return [{'name': 'lockState'}] - - def properties_retrievable(self): - return True - - def properties_proactively_reported(self): - return True - - def get_property(self, name): - if name != 'lockState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_LOCKED: - return 'LOCKED' - if self.entity.state == STATE_UNLOCKED: - return 'UNLOCKED' - return 'JAMMED' - - -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 - - def name(self): - return 'Alexa.SceneController' - - -class _AlexaBrightnessController(_AlexaInterface): - """Implements Alexa.BrightnessController. - - https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html - """ - - def name(self): - return 'Alexa.BrightnessController' - - def properties_supported(self): - return [{'name': 'brightness'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'brightness': - raise _UnsupportedProperty(name) - if 'brightness' in self.entity.attributes: - return round(self.entity.attributes['brightness'] / 255.0 * 100) - return 0 - - -class _AlexaColorController(_AlexaInterface): - """Implements Alexa.ColorController. - - https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html - """ - - def name(self): - return 'Alexa.ColorController' - - def properties_supported(self): - return [{'name': 'color'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'color': - raise _UnsupportedProperty(name) - - hue, saturation = self.entity.attributes.get( - light.ATTR_HS_COLOR, (0, 0)) - - return { - 'hue': hue, - 'saturation': saturation / 100.0, - 'brightness': self.entity.attributes.get( - light.ATTR_BRIGHTNESS, 0) / 255.0, - } - - -class _AlexaColorTemperatureController(_AlexaInterface): - """Implements Alexa.ColorTemperatureController. - - https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html - """ - - def name(self): - return 'Alexa.ColorTemperatureController' - - def properties_supported(self): - return [{'name': 'colorTemperatureInKelvin'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'colorTemperatureInKelvin': - raise _UnsupportedProperty(name) - if 'color_temp' in self.entity.attributes: - return color_util.color_temperature_mired_to_kelvin( - self.entity.attributes['color_temp']) - return 0 - - -class _AlexaPercentageController(_AlexaInterface): - """Implements Alexa.PercentageController. - - https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html - """ - - def name(self): - return 'Alexa.PercentageController' - - def properties_supported(self): - return [{'name': 'percentage'}] - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'percentage': - raise _UnsupportedProperty(name) - - if self.entity.domain == fan.DOMAIN: - speed = self.entity.attributes.get(fan.ATTR_SPEED) - - return PERCENTAGE_FAN_MAP.get(speed, 0) - - if self.entity.domain == cover.DOMAIN: - return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0) - - return 0 - - -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 - - def name(self): - return 'Alexa.TemperatureSensor' - - def properties_supported(self): - return [{'name': 'temperature'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'temperature': - raise _UnsupportedProperty(name) - - unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - temp = self.entity.state - if self.entity.domain == climate.DOMAIN: - unit = self.hass.config.units.temperature_unit - temp = self.entity.attributes.get( - climate.ATTR_CURRENT_TEMPERATURE) - return { - 'value': float(temp), - 'scale': API_TEMP_UNITS[unit], - } - - -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 - - def name(self): - return 'Alexa.ContactSensor' - - def properties_supported(self): - return [{'name': 'detectionState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'detectionState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_ON: - return 'DETECTED' - return 'NOT_DETECTED' - - -class _AlexaMotionSensor(_AlexaInterface): - def __init__(self, hass, entity): - _AlexaInterface.__init__(self, entity) - self.hass = hass - - def name(self): - return 'Alexa.MotionSensor' - - def properties_supported(self): - return [{'name': 'detectionState'}] - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name != 'detectionState': - raise _UnsupportedProperty(name) - - if self.entity.state == STATE_ON: - return 'DETECTED' - return 'NOT_DETECTED' - - -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 - - def name(self): - return 'Alexa.ThermostatController' - - def properties_supported(self): - properties = [] - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.SUPPORT_TARGET_TEMPERATURE: - properties.append({'name': 'targetSetpoint'}) - if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW: - properties.append({'name': 'lowerSetpoint'}) - if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH: - properties.append({'name': 'upperSetpoint'}) - if supported & climate.SUPPORT_OPERATION_MODE: - properties.append({'name': 'thermostatMode'}) - return properties - - def properties_proactively_reported(self): - return True - - def properties_retrievable(self): - return True - - def get_property(self, name): - if name == 'thermostatMode': - ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE) - mode = API_THERMOSTAT_MODES.get(ha_mode) - if mode is None: - _LOGGER.error("%s (%s) has unsupported %s value '%s'", - self.entity.entity_id, type(self.entity), - climate.ATTR_OPERATION_MODE, ha_mode) - raise _UnsupportedProperty(name) - return mode - - unit = self.hass.config.units.temperature_unit - if name == 'targetSetpoint': - temp = self.entity.attributes.get(ATTR_TEMPERATURE) - elif name == 'lowerSetpoint': - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) - elif name == 'upperSetpoint': - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) - else: - raise _UnsupportedProperty(name) - - if temp is None: - return None - - return { - 'value': float(temp), - 'scale': API_TEMP_UNITS[unit], - } - - -@ENTITY_ADAPTERS.register(alert.DOMAIN) -@ENTITY_ADAPTERS.register(automation.DOMAIN) -@ENTITY_ADAPTERS.register(group.DOMAIN) -@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) -class _GenericCapabilities(_AlexaEntity): - """A generic, on/off device. - - The choice of last resort. - """ - - def default_display_categories(self): - return [_DisplayCategory.OTHER] - - def interfaces(self): - return [_AlexaPowerController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(switch.DOMAIN) -class _SwitchCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SWITCH] - - def interfaces(self): - return [_AlexaPowerController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(climate.DOMAIN) -class _ClimateCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.THERMOSTAT] - - def interfaces(self): - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.SUPPORT_ON_OFF: - yield _AlexaPowerController(self.entity) - yield _AlexaThermostatController(self.hass, self.entity) - yield _AlexaTemperatureSensor(self.hass, self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(cover.DOMAIN) -class _CoverCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.DOOR] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & cover.SUPPORT_SET_POSITION: - yield _AlexaPercentageController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(light.DOMAIN) -class _LightCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.LIGHT] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & light.SUPPORT_BRIGHTNESS: - yield _AlexaBrightnessController(self.entity) - if supported & light.SUPPORT_COLOR: - yield _AlexaColorController(self.entity) - if supported & light.SUPPORT_COLOR_TEMP: - yield _AlexaColorTemperatureController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(fan.DOMAIN) -class _FanCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.OTHER] - - def interfaces(self): - yield _AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & fan.SUPPORT_SET_SPEED: - yield _AlexaPercentageController(self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(lock.DOMAIN) -class _LockCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SMARTLOCK] - - def interfaces(self): - return [_AlexaLockController(self.entity), - _AlexaEndpointHealth(self.hass, self.entity)] - - -@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) -class _MediaPlayerCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.TV] - - def interfaces(self): - yield _AlexaEndpointHealth(self.hass, self.entity) - - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & media_player.const.SUPPORT_VOLUME_SET: - yield _AlexaSpeaker(self.entity) - - power_features = (media_player.SUPPORT_TURN_ON | - media_player.SUPPORT_TURN_OFF) - if supported & power_features: - yield _AlexaPowerController(self.entity) - - step_volume_features = (media_player.const.SUPPORT_VOLUME_MUTE | - media_player.const.SUPPORT_VOLUME_STEP) - if supported & step_volume_features: - yield _AlexaStepSpeaker(self.entity) - - playback_features = (media_player.const.SUPPORT_PLAY | - media_player.const.SUPPORT_PAUSE | - media_player.const.SUPPORT_STOP | - media_player.const.SUPPORT_NEXT_TRACK | - media_player.const.SUPPORT_PREVIOUS_TRACK) - if supported & playback_features: - yield _AlexaPlaybackController(self.entity) - - if supported & media_player.SUPPORT_SELECT_SOURCE: - yield _AlexaInputController(self.entity) - - -@ENTITY_ADAPTERS.register(scene.DOMAIN) -class _SceneCapabilities(_AlexaEntity): - def description(self): - # Required description as per Amazon Scene docs - scene_fmt = '{} (Scene connected via Home Assistant)' - return scene_fmt.format(_AlexaEntity.description(self)) - - def default_display_categories(self): - return [_DisplayCategory.SCENE_TRIGGER] - - def interfaces(self): - return [_AlexaSceneController(self.entity, - supports_deactivation=False)] - - -@ENTITY_ADAPTERS.register(script.DOMAIN) -class _ScriptCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.ACTIVITY_TRIGGER] - - def interfaces(self): - can_cancel = bool(self.entity.attributes.get('can_cancel')) - return [_AlexaSceneController(self.entity, - supports_deactivation=can_cancel)] - - -@ENTITY_ADAPTERS.register(sensor.DOMAIN) -class _SensorCapabilities(_AlexaEntity): - def default_display_categories(self): - # although there are other kinds of sensors, all but temperature - # sensors are currently ignored. - return [_DisplayCategory.TEMPERATURE_SENSOR] - - def interfaces(self): - attrs = self.entity.attributes - if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in ( - TEMP_FAHRENHEIT, - TEMP_CELSIUS, - ): - yield _AlexaTemperatureSensor(self.hass, self.entity) - yield _AlexaEndpointHealth(self.hass, self.entity) - - -@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) -class _BinarySensorCapabilities(_AlexaEntity): - TYPE_CONTACT = 'contact' - TYPE_MOTION = 'motion' - - def default_display_categories(self): - sensor_type = self.get_type() - if sensor_type is self.TYPE_CONTACT: - return [_DisplayCategory.CONTACT_SENSOR] - if sensor_type is self.TYPE_MOTION: - return [_DisplayCategory.MOTION_SENSOR] - - def interfaces(self): - sensor_type = self.get_type() - if sensor_type is self.TYPE_CONTACT: - yield _AlexaContactSensor(self.hass, self.entity) - elif sensor_type is self.TYPE_MOTION: - yield _AlexaMotionSensor(self.hass, self.entity) - - yield _AlexaEndpointHealth(self.hass, self.entity) - - def get_type(self): - """Return the type of binary sensor.""" - attrs = self.entity.attributes - if attrs.get(ATTR_DEVICE_CLASS) in ( - 'door', - 'garage_door', - 'opening', - 'window', - ): - return self.TYPE_CONTACT - if attrs.get(ATTR_DEVICE_CLASS) == 'motion': - return self.TYPE_MOTION - - -class _Cause: - """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, endpoint, async_get_access_token, should_expose, - entity_config=None): - """Initialize the configuration.""" - self.endpoint = endpoint - self.async_get_access_token = async_get_access_token - self.should_expose = should_expose - self.entity_config = entity_config or {} - - -async 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. - """ - if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): - hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET]) - - async_get_access_token = \ - hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \ - else None - - smart_home_config = Config( - endpoint=config.get(CONF_ENDPOINT), - async_get_access_token=async_get_access_token, - should_expose=config[CONF_FILTER], - entity_config=config.get(CONF_ENTITY_CONFIG), - ) - hass.http.register_view(SmartHomeView(smart_home_config)) - - if AUTH_KEY in hass.data: - await async_enable_proactive_mode(hass, smart_home_config) - - -async def async_enable_proactive_mode(hass, smart_home_config): - """Enable the proactive mode. - - Proactive mode makes this component report state changes to Alexa. - """ - if smart_home_config.async_get_access_token is None: - # no function to call to get token - return - - if await smart_home_config.async_get_access_token() is None: - # not ready yet - return - - async def async_entity_state_listener(changed_entity, old_state, - new_state): - if not smart_home_config.should_expose(changed_entity): - _LOGGER.debug("Not exposing %s because filtered by config", - changed_entity) - return - - if new_state.domain not in ENTITY_ADAPTERS: - return - - alexa_changed_entity = \ - ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config, - new_state) - - for interface in alexa_changed_entity.interfaces(): - if interface.properties_proactively_reported(): - await async_send_changereport_message(hass, smart_home_config, - alexa_changed_entity) - return - - async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) - - -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 - - async 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'] - user = request[http.KEY_HASS_USER] - message = await request.json() - - _LOGGER.debug("Received Alexa Smart Home request: %s", message) - - response = await async_handle_message( - hass, self.smart_home_config, message, - context=ha.Context(user_id=user.id) - ) - _LOGGER.debug("Sending Alexa Smart Home response: %s", response) - return b'' if response is None else self.json(response) - - -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_full(self, bearer_token, endpoint_id, cookie=None): - """Set the endpoint dictionary. - - This is used to send proactive messages to Alexa. - """ - self._response[API_EVENT][API_ENDPOINT] = { - API_SCOPE: { - 'type': 'BearerToken', - 'token': bearer_token - } - } - - if endpoint_id is not None: - self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id - - if cookie is not None: - self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie - - 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 +# return result async def async_handle_message( @@ -1353,11 +69,11 @@ async def async_handle_message( if context is None: context = ha.Context() - directive = _AlexaDirective(request) + directive = AlexaDirective(request) try: if not enabled: - raise _AlexaBridgeUnreachableError( + raise AlexaBridgeUnreachableError( 'Alexa API not enabled in Home Assistant configuration') if directive.has_endpoint: @@ -1375,7 +91,7 @@ async def async_handle_message( directive.name, ) response = directive.error() - except _AlexaError as err: + except AlexaError as err: response = directive.error( error_type=err.error_type, error_message=err.error_message) @@ -1397,758 +113,3 @@ async def async_handle_message( }, context=context) return response.serialize() - - -async def async_send_changereport_message(hass, config, alexa_entity): - """Send a ChangeReport message for an Alexa entity.""" - token = await config.async_get_access_token() - if not token: - _LOGGER.error("Invalid access token.") - return - - headers = { - "Authorization": "Bearer {}".format(token) - } - - endpoint = alexa_entity.entity_id() - - # this sends all the properties of the Alexa Entity, whether they have - # changed or not. this should be improved, and properties that have not - # changed should be moved to the 'context' object - properties = list(alexa_entity.serialize_properties()) - - payload = { - API_CHANGE: { - 'cause': {'type': _Cause.APP_INTERACTION}, - 'properties': properties - } - } - - message = _AlexaResponse(name='ChangeReport', namespace='Alexa', - payload=payload) - message.set_endpoint_full(token, endpoint) - - message_serialized = message.serialize() - - try: - session = aiohttp_client.async_get_clientsession(hass) - with async_timeout.timeout(DEFAULT_TIMEOUT): - response = await session.post(config.endpoint, - headers=headers, - json=message_serialized, - allow_redirects=True) - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Timeout calling LWA to get auth token.") - return None - - response_text = await response.text() - - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) - - if response.status != 202: - response_json = json.loads(response_text) - _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", - response_json["payload"]["code"], - response_json["payload"]["description"]) - - -@HANDLERS.register(('Alexa.Discovery', 'Discover')) -async def async_api_discovery(hass, config, directive, context): - """Create a API formatted discovery response. - - Async friendly. - """ - discovery_endpoints = [] - - for entity in hass.states.async_all(): - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - _LOGGER.debug("Not exposing %s because it is never exposed", - entity.entity_id) - continue - - if not config.should_expose(entity.entity_id): - _LOGGER.debug("Not exposing %s because filtered by config", - entity.entity_id) - continue - - if entity.domain not in ENTITY_ADAPTERS: - continue - alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity) - - endpoint = { - 'displayCategories': alexa_entity.display_categories(), - 'cookie': {}, - 'endpointId': alexa_entity.entity_id(), - 'friendlyName': alexa_entity.friendly_name(), - 'description': alexa_entity.description(), - 'manufacturerName': 'Home Assistant', - } - - endpoint['capabilities'] = [ - i.serialize_discovery() for i in alexa_entity.interfaces()] - - if not endpoint['capabilities']: - _LOGGER.debug( - "Not exposing %s because it has no capabilities", - entity.entity_id) - continue - discovery_endpoints.append(endpoint) - - return directive.response( - name='Discover.Response', - namespace='Alexa.Discovery', - payload={'endpoints': discovery_endpoints}, - ) - - -@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant')) -async def async_api_accept_grant(hass, config, directive, context): - """Create a API formatted AcceptGrant response. - - Async friendly. - """ - auth_code = directive.payload['grant']['code'] - _LOGGER.debug("AcceptGrant code: %s", auth_code) - - if AUTH_KEY in hass.data: - await hass.data[AUTH_KEY].async_do_auth(auth_code) - await async_enable_proactive_mode(hass, config) - - return directive.response( - name='AcceptGrant.Response', - namespace='Alexa.Authorization', - payload={}) - - -@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) -async def async_api_turn_on(hass, config, directive, context): - """Process a turn on request.""" - entity = directive.entity - domain = entity.domain - if domain == group.DOMAIN: - domain = ha.DOMAIN - - service = SERVICE_TURN_ON - 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 directive.response() - - -@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) -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 - - service = SERVICE_TURN_OFF - if entity.domain == cover.DOMAIN: - service = cover.SERVICE_CLOSE_COVER - - await hass.services.async_call(domain, service, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) -async def async_api_set_brightness(hass, config, directive, context): - """Process a set brightness request.""" - 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 directive.response() - - -@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) -async def async_api_adjust_brightness(hass, config, directive, context): - """Process an adjust brightness request.""" - entity = directive.entity - brightness_delta = int(directive.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) - 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 directive.response() - - -@HANDLERS.register(('Alexa.ColorController', 'SetColor')) -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(directive.payload['color']['hue']), - float(directive.payload['color']['saturation']), - float(directive.payload['color']['brightness']) - ) - - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_RGB_COLOR: rgb, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) -async def async_api_set_color_temperature(hass, config, directive, context): - """Process a set color temperature request.""" - 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 directive.response() - - -@HANDLERS.register( - ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) -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)) - - value = min(max_mireds, current + 50) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_COLOR_TEMP: value, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register( - ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) -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)) - - value = max(min_mireds, current - 50) - await hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_COLOR_TEMP: value, - }, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.SceneController', 'Activate')) -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, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - payload = { - 'cause': {'type': _Cause.VOICE_INTERACTION}, - 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) - } - - return directive.response( - name='ActivationStarted', - namespace='Alexa.SceneController', - payload=payload, - ) - - -@HANDLERS.register(('Alexa.SceneController', 'Deactivate')) -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, { - ATTR_ENTITY_ID: entity.entity_id - }, blocking=False, context=context) - - payload = { - 'cause': {'type': _Cause.VOICE_INTERACTION}, - 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) - } - - return directive.response( - name='DeactivationStarted', - namespace='Alexa.SceneController', - payload=payload, - ) - - -@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) -async def async_api_set_percentage(hass, config, directive, context): - """Process a set percentage request.""" - entity = directive.entity - percentage = int(directive.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 - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) -async def async_api_adjust_percentage(hass, config, directive, context): - """Process an adjust percentage request.""" - entity = directive.entity - percentage_delta = int(directive.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) - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.LockController', 'Lock')) -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) - - response = directive.response() - response.add_context_property({ - 'name': 'lockState', - 'namespace': 'Alexa.LockController', - 'value': 'LOCKED' - }) - return response - - -# Not supported by Alexa yet -@HANDLERS.register(('Alexa.LockController', 'Unlock')) -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 directive.response() - - -@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) -async def async_api_set_volume(hass, config, directive, context): - """Process a set volume request.""" - volume = round(float(directive.payload['volume'] / 100), 2) - entity = directive.entity - - data = { - ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, - } - - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_SET, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.InputController', 'SelectInput')) -async def async_api_select_input(hass, config, directive, context): - """Process a set input request.""" - 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.const.ATTR_INPUT_SOURCE_LIST] or [] - for source in source_list: - # response will always be space separated, so format the source in the - # most likely way to find a match - formatted_source = source.lower().replace('-', ' ').replace('_', ' ') - if formatted_source in media_input.lower(): - media_input = source - break - else: - msg = 'failed to map input {} to a media source on {}'.format( - media_input, entity.entity_id) - raise _AlexaInvalidValueError(msg) - - data = { - ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_INPUT_SOURCE: media_input, - } - - await hass.services.async_call( - entity.domain, media_player.SERVICE_SELECT_SOURCE, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) -async def async_api_adjust_volume(hass, config, directive, context): - """Process an adjust volume request.""" - volume_delta = int(directive.payload['volume']) - - entity = directive.entity - current_level = entity.attributes.get( - media_player.const.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.const.ATTR_MEDIA_VOLUME_LEVEL: volume, - } - - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_SET, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) -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 = directive.payload['volumeSteps'] - entity = directive.entity - - data = { - ATTR_ENTITY_ID: entity.entity_id, - } - - if volume_step > 0: - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_UP, - data, blocking=False, context=context) - elif volume_step < 0: - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_DOWN, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) -@HANDLERS.register(('Alexa.Speaker', 'SetMute')) -async def async_api_set_mute(hass, config, directive, context): - """Process a set mute request.""" - mute = bool(directive.payload['mute']) - entity = directive.entity - - data = { - ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, - } - - await hass.services.async_call( - entity.domain, SERVICE_VOLUME_MUTE, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Play')) -async def async_api_play(hass, config, directive, context): - """Process a play request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PLAY, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) -async def async_api_pause(hass, config, directive, context): - """Process a pause request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PAUSE, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) -async def async_api_stop(hass, config, directive, context): - """Process a stop request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_STOP, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Next')) -async def async_api_next(hass, config, directive, context): - """Process a next request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_NEXT_TRACK, - data, blocking=False, context=context) - - return directive.response() - - -@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) -async def async_api_previous(hass, config, directive, context): - """Process a previous request.""" - entity = directive.entity - data = { - ATTR_ENTITY_ID: entity.entity_id - } - - await hass.services.async_call( - entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, - data, blocking=False, context=context) - - return directive.response() - - -def temperature_from_object(hass, temp_obj, interval=False): - """Get temperature from Temperature object in requested unit.""" - to_unit = hass.config.units.temperature_unit - from_unit = TEMP_CELSIUS - temp = float(temp_obj['value']) - - if temp_obj['scale'] == 'FAHRENHEIT': - from_unit = TEMP_FAHRENHEIT - elif temp_obj['scale'] == 'KELVIN': - # convert to Celsius if absolute temperature - if not interval: - temp -= 273.15 - - return convert_temperature(temp, from_unit, to_unit, interval) - - -@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature')) -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 = directive.payload - response = directive.response() - if 'targetSetpoint' in payload: - temp = temperature_from_object(hass, payload['targetSetpoint']) - if temp < min_temp or 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: - 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: - 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 response - - -@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) -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, 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: - 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 response - - -@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) -async def async_api_set_thermostat_mode(hass, config, directive, context): - """Process a set thermostat mode request.""" - 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) - ha_mode = next( - (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), - None - ) - if ha_mode not in operation_list: - msg = 'The requested thermostat mode {} is not supported'.format(mode) - raise _AlexaUnsupportedThermostatModeError(msg) - - data = { - ATTR_ENTITY_ID: entity.entity_id, - climate.ATTR_OPERATION_MODE: ha_mode, - } - - response = directive.response() - await hass.services.async_call( - entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, - blocking=False, context=context) - response.add_context_property({ - 'name': 'thermostatMode', - 'namespace': 'Alexa.ThermostatController', - 'value': mode, - }) - - return response - - -@HANDLERS.register(('Alexa', 'ReportState')) -async def async_api_reportstate(hass, config, directive, context): - """Process a ReportState request.""" - return directive.response(name='StateReport') diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py new file mode 100644 index 00000000000..cb70fb86253 --- /dev/null +++ b/homeassistant/components/alexa/smart_home_http.py @@ -0,0 +1,81 @@ +"""Alexa HTTP interface.""" +import logging + +from homeassistant import core +from homeassistant.components.http.view import HomeAssistantView + +from .auth import Auth +from .config import Config +from .const import ( + AUTH_KEY, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER +) +from .state_report import async_enable_proactive_mode +from .smart_home import async_handle_message + +_LOGGER = logging.getLogger(__name__) +SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' + + +async 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. + """ + if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): + hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET]) + + async_get_access_token = \ + hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \ + else None + + smart_home_config = Config( + endpoint=config.get(CONF_ENDPOINT), + async_get_access_token=async_get_access_token, + should_expose=config[CONF_FILTER], + entity_config=config.get(CONF_ENTITY_CONFIG), + ) + hass.http.register_view(SmartHomeView(smart_home_config)) + + if AUTH_KEY in hass.data: + await async_enable_proactive_mode(hass, smart_home_config) + + +class SmartHomeView(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 + + async 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'] + user = request['hass_user'] + message = await request.json() + + _LOGGER.debug("Received Alexa Smart Home request: %s", message) + + response = await async_handle_message( + hass, self.smart_home_config, message, + context=core.Context(user_id=user.id) + ) + _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + return b'' if response is None else self.json(response) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py new file mode 100644 index 00000000000..568502fb6bf --- /dev/null +++ b/homeassistant/components/alexa/state_report.py @@ -0,0 +1,109 @@ +"""Alexa state report code.""" +import asyncio +import json +import logging + +import aiohttp +import async_timeout + +from homeassistant.const import MATCH_ALL + +from .const import API_CHANGE, Cause +from .entities import ENTITY_ADAPTERS +from .messages import AlexaResponse + +_LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEOUT = 10 + + +async def async_enable_proactive_mode(hass, smart_home_config): + """Enable the proactive mode. + + Proactive mode makes this component report state changes to Alexa. + """ + if smart_home_config.async_get_access_token is None: + # no function to call to get token + return + + if await smart_home_config.async_get_access_token() is None: + # not ready yet + return + + async def async_entity_state_listener(changed_entity, old_state, + new_state): + if not smart_home_config.should_expose(changed_entity): + _LOGGER.debug("Not exposing %s because filtered by config", + changed_entity) + return + + if new_state.domain not in ENTITY_ADAPTERS: + return + + alexa_changed_entity = \ + ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config, + new_state) + + for interface in alexa_changed_entity.interfaces(): + if interface.properties_proactively_reported(): + await async_send_changereport_message(hass, smart_home_config, + alexa_changed_entity) + return + + hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) + + +async def async_send_changereport_message(hass, config, alexa_entity): + """Send a ChangeReport message for an Alexa entity.""" + token = await config.async_get_access_token() + if not token: + _LOGGER.error("Invalid access token.") + return + + headers = { + "Authorization": "Bearer {}".format(token) + } + + endpoint = alexa_entity.alexa_id() + + # this sends all the properties of the Alexa Entity, whether they have + # changed or not. this should be improved, and properties that have not + # changed should be moved to the 'context' object + properties = list(alexa_entity.serialize_properties()) + + payload = { + API_CHANGE: { + 'cause': {'type': Cause.APP_INTERACTION}, + 'properties': properties + } + } + + message = AlexaResponse(name='ChangeReport', namespace='Alexa', + payload=payload) + message.set_endpoint_full(token, endpoint) + + message_serialized = message.serialize() + + try: + session = hass.helpers.aiohttp_client.async_get_clientsession() + with async_timeout.timeout(DEFAULT_TIMEOUT): + response = await session.post(config.endpoint, + headers=headers, + json=message_serialized, + allow_redirects=True) + + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Timeout calling LWA to get auth token.") + return None + + response_text = await response.text() + + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) + + if response.status != 202: + response_json = json.loads(response_text) + _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"]) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d4d443a692d..5490a0da156 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.alexa import const as alexa_const from homeassistant.components.google_assistant import const as ga_c from homeassistant.const import ( CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, @@ -33,9 +33,9 @@ SERVICE_REMOTE_DISCONNECT = 'remote_disconnect' ALEXA_ENTITY_SCHEMA = vol.Schema({ - vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, - vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, - vol.Optional(alexa_sh.CONF_NAME): cv.string, + vol.Optional(alexa_const.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_const.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(CONF_NAME): cv.string, }) GOOGLE_ENTITY_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index eadb1731bd0..f5edefeee43 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -7,7 +7,10 @@ import aiohttp from hass_nabucasa.client import CloudClient as Interface from homeassistant.core import callback -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.alexa import ( + config as alexa_config, + smart_home as alexa_sh, +) from homeassistant.components.google_assistant import ( helpers as ga_h, smart_home as ga) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES @@ -28,12 +31,12 @@ class CloudClient(Interface): def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences, websession: aiohttp.ClientSession, - alexa_config: Dict[str, Any], google_config: Dict[str, Any]): + alexa_cfg: Dict[str, Any], google_config: Dict[str, Any]): """Initialize client interface to Cloud.""" self._hass = hass self._prefs = prefs self._websession = websession - self._alexa_user_config = alexa_config + self._alexa_user_config = alexa_cfg self._google_user_config = google_config self._alexa_config = None @@ -75,12 +78,12 @@ class CloudClient(Interface): return self._prefs.remote_enabled @property - def alexa_config(self) -> alexa_sh.Config: + def alexa_config(self) -> alexa_config.Config: """Return Alexa config.""" if not self._alexa_config: alexa_conf = self._alexa_user_config - self._alexa_config = alexa_sh.Config( + self._alexa_config = alexa_config.Config( endpoint=None, async_get_access_token=None, should_expose=alexa_conf[CONF_FILTER], diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 9908268b252..9c167d25601 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -13,7 +13,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api -from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.alexa import entities as alexa_entities from homeassistant.components.google_assistant import helpers as google_helpers from .const import ( @@ -421,7 +421,7 @@ def _account_data(cloud): 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, 'alexa_entities': client.alexa_config.should_expose.config, - 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), + 'alexa_domains': list(alexa_entities.ENTITY_ADAPTERS), 'remote_domain': remote.instance_domain, 'remote_connected': remote.is_connected, 'remote_certificate': certificate, @@ -497,7 +497,7 @@ async def google_assistant_list(hass, connection, msg): vol.Optional('disable_2fa'): bool, }) async def google_assistant_update(hass, connection, msg): - """List all google assistant entities.""" + """Update google assistant config.""" cloud = hass.data[DOMAIN] changes = dict(msg) changes.pop('type') diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 88ecc63d200..b1c8c6aa8bd 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1 +1,179 @@ """Tests for the Alexa integration.""" +from uuid import uuid4 + +from homeassistant.core import Context +from homeassistant.components.alexa import config, smart_home + +from tests.common import async_mock_service + +TEST_URL = "https://api.amazonalexa.com/v3/events" +TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" + + +async def get_access_token(): + """Return a test access token.""" + return "thisisnotanacesstoken" + + +DEFAULT_CONFIG = config.Config( + endpoint=TEST_URL, + async_get_access_token=get_access_token, + should_expose=lambda entity_id: True) + + +def get_new_request(namespace, name, endpoint=None): + """Generate a new API message.""" + raw_msg = { + 'directive': { + 'header': { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'correlationToken': str(uuid4()), + 'payloadVersion': '3', + }, + 'endpoint': { + 'scope': { + 'type': 'BearerToken', + 'token': str(uuid4()), + }, + 'endpointId': endpoint, + }, + 'payload': {}, + } + } + + if not endpoint: + raw_msg['directive'].pop('endpoint') + + return raw_msg + + +async def assert_request_calls_service( + namespace, + name, + endpoint, + service, + hass, + response_type='Response', + payload=None): + """Assert an API request calls a hass service.""" + context = Context() + request = get_new_request(namespace, name, endpoint) + if payload: + request['directive']['payload'] = payload + + domain, service_name = service.split('.') + calls = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request, context) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert 'event' in msg + assert call.data['entity_id'] == endpoint.replace('#', '.') + assert msg['event']['header']['name'] == response_type + assert call.context == context + + return call, msg + + +async def assert_request_fails( + namespace, + name, + endpoint, + service_not_called, + hass, + payload=None): + """Assert an API request returns an ErrorResponse.""" + request = get_new_request(namespace, name, endpoint) + if payload: + request['directive']['payload'] = payload + + domain, service_name = service_not_called.split('.') + call = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert not call + assert 'event' in msg + assert msg['event']['header']['name'] == 'ErrorResponse' + + return msg + + +async def assert_power_controller_works( + endpoint, + on_service, + off_service, + hass +): + """Assert PowerController API requests work.""" + await assert_request_calls_service( + 'Alexa.PowerController', 'TurnOn', endpoint, + on_service, hass) + + await assert_request_calls_service( + 'Alexa.PowerController', 'TurnOff', endpoint, + off_service, hass) + + +async def assert_scene_controller_works( + endpoint, + activate_service, + deactivate_service, + hass): + """Assert SceneController API requests work.""" + _, response = await assert_request_calls_service( + 'Alexa.SceneController', 'Activate', endpoint, + activate_service, hass, + response_type='ActivationStarted') + assert response['event']['payload']['cause']['type'] == 'VOICE_INTERACTION' + assert 'timestamp' in response['event']['payload'] + + if deactivate_service: + await assert_request_calls_service( + 'Alexa.SceneController', 'Deactivate', endpoint, + deactivate_service, hass, + response_type='DeactivationStarted') + cause_type = response['event']['payload']['cause']['type'] + assert cause_type == 'VOICE_INTERACTION' + assert 'timestamp' in response['event']['payload'] + + +async def reported_properties(hass, endpoint): + """Use ReportState to get properties and return them. + + The result is a ReportedProperties instance, which has methods to make + assertions about the properties. + """ + request = get_new_request('Alexa', 'ReportState', endpoint) + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + return ReportedProperties(msg['context']['properties']) + + +class ReportedProperties: + """Class to help assert reported properties.""" + + def __init__(self, properties): + """Initialize class.""" + self.properties = properties + + def assert_equal(self, namespace, name, value): + """Assert a property is equal to a given value.""" + for prop in self.properties: + if prop['namespace'] == namespace and prop['name'] == name: + assert prop['value'] == value + return prop + + assert False, 'property %s:%s not in %r' % ( + namespace, + name, + self.properties, + ) diff --git a/tests/components/alexa/test_auth.py b/tests/components/alexa/test_auth.py new file mode 100644 index 00000000000..aefb5e82225 --- /dev/null +++ b/tests/components/alexa/test_auth.py @@ -0,0 +1,67 @@ +"""Test Alexa auth endpoints.""" +from homeassistant.components.alexa.auth import Auth +from . import TEST_TOKEN_URL + + +async def run_auth_get_access_token(hass, aioclient_mock, expires_in, + client_id, client_secret, + accept_grant_code, refresh_token): + """Do auth and request a new token for tests.""" + aioclient_mock.post(TEST_TOKEN_URL, + json={'access_token': 'the_access_token', + 'refresh_token': refresh_token, + 'expires_in': expires_in}) + + auth = Auth(hass, client_id, client_secret) + await auth.async_do_auth(accept_grant_code) + await auth.async_get_access_token() + + +async def test_auth_get_access_token_expired(hass, aioclient_mock): + """Test the auth get access token function.""" + client_id = "client123" + client_secret = "shhhhh" + accept_grant_code = "abcdefg" + refresh_token = "refresher" + + await run_auth_get_access_token(hass, aioclient_mock, -5, + client_id, client_secret, + accept_grant_code, refresh_token) + + assert len(aioclient_mock.mock_calls) == 2 + calls = aioclient_mock.mock_calls + + auth_call_json = calls[0][2] + token_call_json = calls[1][2] + + assert auth_call_json["grant_type"] == "authorization_code" + assert auth_call_json["code"] == accept_grant_code + assert auth_call_json["client_id"] == client_id + assert auth_call_json["client_secret"] == client_secret + + assert token_call_json["grant_type"] == "refresh_token" + assert token_call_json["refresh_token"] == refresh_token + assert token_call_json["client_id"] == client_id + assert token_call_json["client_secret"] == client_secret + + +async def test_auth_get_access_token_not_expired(hass, aioclient_mock): + """Test the auth get access token function.""" + client_id = "client123" + client_secret = "shhhhh" + accept_grant_code = "abcdefg" + refresh_token = "refresher" + + await run_auth_get_access_token(hass, aioclient_mock, 555, + client_id, client_secret, + accept_grant_code, refresh_token) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + auth_call_json = call[0][2] + + assert auth_call_json["grant_type"] == "authorization_code" + assert auth_call_json["code"] == accept_grant_code + assert auth_call_json["client_id"] == client_id + assert auth_call_json["client_secret"] == client_secret diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py new file mode 100644 index 00000000000..c47dae6d3a3 --- /dev/null +++ b/tests/components/alexa/test_capabilities.py @@ -0,0 +1,340 @@ +"""Test Alexa capabilities.""" +import pytest + +from homeassistant.const import ( + STATE_LOCKED, + STATE_UNLOCKED, + STATE_UNKNOWN, +) +from homeassistant.components.alexa import smart_home +from tests.common import async_mock_service + +from . import ( + DEFAULT_CONFIG, + get_new_request, + assert_request_calls_service, + assert_request_fails, + reported_properties, +) + + +@pytest.mark.parametrize( + "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) +async def test_api_adjust_brightness(hass, result, adjust): + """Test api adjust brightness process.""" + request = get_new_request( + 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test') + + # add payload + request['directive']['payload']['brightnessDelta'] = adjust + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'brightness': '77' + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['brightness_pct'] == result + assert msg['header']['name'] == 'Response' + + +async def test_api_set_color_rgb(hass): + """Test api set color process.""" + request = get_new_request( + 'Alexa.ColorController', 'SetColor', 'light#test') + + # add payload + request['directive']['payload']['color'] = { + 'hue': '120', + 'saturation': '0.612', + 'brightness': '0.342', + } + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", + 'supported_features': 16, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['rgb_color'] == (33, 87, 33) + assert msg['header']['name'] == 'Response' + + +async def test_api_set_color_temperature(hass): + """Test api set color temperature process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'SetColorTemperature', + 'light#test') + + # add payload + request['directive']['payload']['colorTemperatureInKelvin'] = '7500' + + # setup test devices + hass.states.async_set( + 'light.test', 'off', {'friendly_name': "Test light"}) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['kelvin'] == 7500 + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')]) +async def test_api_decrease_color_temp(hass, result, initial): + """Test api decrease color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', + 'light#test') + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'max_mireds': 500, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')]) +async def test_api_increase_color_temp(hass, result, initial): + """Test api increase color temp process.""" + request = get_new_request( + 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', + 'light#test') + + # setup test devices + hass.states.async_set( + 'light.test', 'off', { + 'friendly_name': "Test light", 'color_temp': initial, + 'min_mireds': 142, + }) + + call_light = async_mock_service(hass, 'light', 'turn_on') + + msg = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + await hass.async_block_till_done() + + assert 'event' in msg + msg = msg['event'] + + assert len(call_light) == 1 + assert call_light[0].data['entity_id'] == 'light.test' + assert call_light[0].data['color_temp'] == result + assert msg['header']['name'] == 'Response' + + +@pytest.mark.parametrize( + "domain,payload,source_list,idx", [ + ('media_player', 'GAME CONSOLE', ['tv', 'game console'], 1), + ('media_player', 'SATELLITE TV', ['satellite-tv', 'game console'], 0), + ('media_player', 'SATELLITE TV', ['satellite_tv', 'game console'], 0), + ('media_player', 'BAD DEVICE', ['satellite_tv', 'game console'], None), + ] +) +async def test_api_select_input(hass, domain, payload, source_list, idx): + """Test api set input process.""" + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", + 'source': 'unknown', + 'source_list': source_list, + }) + + # test where no source matches + if idx is None: + await assert_request_fails( + 'Alexa.InputController', 'SelectInput', 'media_player#test', + 'media_player.select_source', + hass, + payload={'input': payload}) + return + + call, _ = await assert_request_calls_service( + 'Alexa.InputController', 'SelectInput', 'media_player#test', + 'media_player.select_source', + hass, + payload={'input': payload}) + assert call.data['source'] == source_list[idx] + + +async def test_report_lock_state(hass): + """Test LockController implements lockState property.""" + hass.states.async_set( + 'lock.locked', STATE_LOCKED, {}) + hass.states.async_set( + 'lock.unlocked', STATE_UNLOCKED, {}) + hass.states.async_set( + 'lock.unknown', STATE_UNKNOWN, {}) + + properties = await reported_properties(hass, 'lock.locked') + properties.assert_equal('Alexa.LockController', 'lockState', 'LOCKED') + + properties = await reported_properties(hass, 'lock.unlocked') + properties.assert_equal('Alexa.LockController', 'lockState', 'UNLOCKED') + + properties = await reported_properties(hass, 'lock.unknown') + properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED') + + +async def test_report_dimmable_light_state(hass): + """Test BrightnessController reports brightness correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'brightness': 128, 'supported_features': 1}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 1}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 50) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) + + +async def test_report_colored_light_state(hass): + """Test ColorController reports color correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'hs_color': (180, 75), + 'brightness': 128, + 'supported_features': 17}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 17}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 180, + 'saturation': 0.75, + 'brightness': 128 / 255.0, + }) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 0, + 'saturation': 0, + 'brightness': 0, + }) + + +async def test_report_colored_temp_light_state(hass): + """Test ColorTemperatureController reports color temp correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'color_temp': 240, + 'supported_features': 2}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 2}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 4166) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 0) + + +async def test_report_fan_speed_state(hass): + """Test PercentageController reports fan speed correctly.""" + hass.states.async_set( + 'fan.off', 'off', {'friendly_name': "Off fan", + 'speed': "off", + 'supported_features': 1}) + hass.states.async_set( + 'fan.low_speed', 'on', {'friendly_name': "Low speed fan", + 'speed': "low", + 'supported_features': 1}) + hass.states.async_set( + 'fan.medium_speed', 'on', {'friendly_name': "Medium speed fan", + 'speed': "medium", + 'supported_features': 1}) + hass.states.async_set( + 'fan.high_speed', 'on', {'friendly_name': "High speed fan", + 'speed': "high", + 'supported_features': 1}) + + properties = await reported_properties(hass, 'fan.off') + properties.assert_equal('Alexa.PercentageController', 'percentage', 0) + + properties = await reported_properties(hass, 'fan.low_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 33) + + properties = await reported_properties(hass, 'fan.medium_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 66) + + properties = await reported_properties(hass, 'fan.high_speed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 100) + + +async def test_report_cover_percentage_state(hass): + """Test PercentageController reports cover percentage correctly.""" + hass.states.async_set( + 'cover.fully_open', 'open', {'friendly_name': "Fully open cover", + 'current_position': 100, + 'supported_features': 15}) + hass.states.async_set( + 'cover.half_open', 'open', {'friendly_name': "Half open cover", + 'current_position': 50, + 'supported_features': 15}) + hass.states.async_set( + 'cover.closed', 'closed', {'friendly_name': "Closed cover", + 'current_position': 0, + 'supported_features': 15}) + + properties = await reported_properties(hass, 'cover.fully_open') + properties.assert_equal('Alexa.PercentageController', 'percentage', 100) + + properties = await reported_properties(hass, 'cover.half_open') + properties.assert_equal('Alexa.PercentageController', 'percentage', 50) + + properties = await reported_properties(hass, 'cover.closed') + properties.assert_equal('Alexa.PercentageController', 'percentage', 0) diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py new file mode 100644 index 00000000000..a2193b09019 --- /dev/null +++ b/tests/components/alexa/test_entities.py @@ -0,0 +1,19 @@ +"""Test Alexa entity representation.""" +from homeassistant.components.alexa import smart_home +from . import get_new_request, DEFAULT_CONFIG + + +async 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 = await smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert not msg['payload']['endpoints'] diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 20b4495cd1a..da7063f8acd 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,34 +1,27 @@ """Test for smart home alexa support.""" -import json -from uuid import uuid4 - import pytest from homeassistant.core import Context, callback -from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_LOCKED, - STATE_UNLOCKED, STATE_UNKNOWN) -from homeassistant.setup import async_setup_component -from homeassistant.components import alexa -from homeassistant.components.alexa import smart_home -from homeassistant.components.alexa.auth import Auth +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.alexa import ( + config, + smart_home, + messages, +) from homeassistant.helpers import entityfilter from tests.common import async_mock_service - -async def get_access_token(): - """Return a test access token.""" - return "thisisnotanacesstoken" - - -TEST_URL = "https://api.amazonalexa.com/v3/events" -TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" - -DEFAULT_CONFIG = smart_home.Config( - endpoint=TEST_URL, - async_get_access_token=get_access_token, - should_expose=lambda entity_id: True) +from . import ( + get_new_request, + DEFAULT_CONFIG, + assert_request_calls_service, + assert_request_fails, + ReportedProperties, + assert_power_controller_works, + assert_scene_controller_works, + reported_properties, +) @pytest.fixture @@ -42,39 +35,11 @@ def events(hass): yield events -def get_new_request(namespace, name, endpoint=None): - """Generate a new API message.""" - raw_msg = { - 'directive': { - 'header': { - 'namespace': namespace, - 'name': name, - 'messageId': str(uuid4()), - 'correlationToken': str(uuid4()), - 'payloadVersion': '3', - }, - 'endpoint': { - 'scope': { - 'type': 'BearerToken', - 'token': str(uuid4()), - }, - 'endpointId': endpoint, - }, - 'payload': {}, - } - } - - if not endpoint: - raw_msg['directive'].pop('endpoint') - - return raw_msg - - def test_create_api_message_defaults(hass): """Create a API message response of a request with defaults.""" request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy') directive_header = request['directive']['header'] - directive = smart_home._AlexaDirective(request) + directive = messages.AlexaDirective(request) msg = directive.response(payload={'test': 3})._response @@ -101,7 +66,7 @@ def test_create_api_message_special(): request = get_new_request('Alexa.PowerController', 'TurnOn') directive_header = request['directive']['header'] directive_header.pop('correlationToken') - directive = smart_home._AlexaDirective(request) + directive = messages.AlexaDirective(request) msg = directive.response('testName', 'testNameSpace')._response @@ -901,7 +866,7 @@ async def test_thermostat(hass): payload={'targetSetpoint': {'value': 69.0, 'scale': 'FAHRENHEIT'}} ) assert call.data['temperature'] == 69.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 69.0, 'scale': 'FAHRENHEIT'}) @@ -927,7 +892,7 @@ 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 = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 70.0, 'scale': 'FAHRENHEIT'}) @@ -967,7 +932,7 @@ async def test_thermostat(hass): payload={'targetSetpointDelta': {'value': -10.0, 'scale': 'KELVIN'}} ) assert call.data['temperature'] == 52.0 - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'targetSetpoint', {'value': 52.0, 'scale': 'FAHRENHEIT'}) @@ -988,7 +953,7 @@ async def test_thermostat(hass): payload={'thermostatMode': {'value': 'HEAT'}} ) assert call.data['operation_mode'] == 'heat' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'HEAT') @@ -999,7 +964,7 @@ async def test_thermostat(hass): payload={'thermostatMode': {'value': 'COOL'}} ) assert call.data['operation_mode'] == 'cool' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'COOL') @@ -1011,7 +976,7 @@ async def test_thermostat(hass): payload={'thermostatMode': 'HEAT'} ) assert call.data['operation_mode'] == 'heat' - properties = _ReportedProperties(msg['context']['properties']) + properties = ReportedProperties(msg['context']['properties']) properties.assert_equal( 'Alexa.ThermostatController', 'thermostatMode', 'HEAT') @@ -1047,7 +1012,7 @@ async def test_exclude_filters(hass): hass.states.async_set( 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=entityfilter.generate_filter( @@ -1057,7 +1022,7 @@ async def test_exclude_filters(hass): exclude_entities=['cover.deny'], )) - msg = await smart_home.async_handle_message(hass, config, request) + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1082,7 +1047,7 @@ async def test_include_filters(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=entityfilter.generate_filter( @@ -1092,7 +1057,7 @@ async def test_include_filters(hass): exclude_entities=[], )) - msg = await smart_home.async_handle_message(hass, config, request) + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1111,7 +1076,7 @@ async def test_never_exposed_entities(hass): hass.states.async_set( 'group.allow', 'off', {'friendly_name': "Allowed group"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=entityfilter.generate_filter( @@ -1121,7 +1086,7 @@ async def test_never_exposed_entities(hass): exclude_entities=[], )) - msg = await smart_home.async_handle_message(hass, config, request) + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() msg = msg['event'] @@ -1162,267 +1127,20 @@ async def test_api_function_not_implemented(hass): assert msg['payload']['type'] == 'INTERNAL_ERROR' -async def assert_request_fails( - namespace, - name, - endpoint, - service_not_called, - hass, - payload=None): - """Assert an API request returns an ErrorResponse.""" - request = get_new_request(namespace, name, endpoint) - if payload: - request['directive']['payload'] = payload - - domain, service_name = service_not_called.split('.') - call = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert not call - assert 'event' in msg - assert msg['event']['header']['name'] == 'ErrorResponse' - - return msg - - -async def assert_request_calls_service( - namespace, - name, - endpoint, - service, - hass, - response_type='Response', - payload=None): - """Assert an API request calls a hass service.""" - context = Context() - request = get_new_request(namespace, name, endpoint) - if payload: - request['directive']['payload'] = payload - - domain, service_name = service.split('.') - calls = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request, context) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert 'event' in msg - assert call.data['entity_id'] == endpoint.replace('#', '.') - assert msg['event']['header']['name'] == response_type - assert call.context == context - - return call, msg - - -async def assert_power_controller_works( - endpoint, - on_service, - off_service, - hass -): - """Assert PowerController API requests work.""" - await assert_request_calls_service( - 'Alexa.PowerController', 'TurnOn', endpoint, - on_service, hass) - - await assert_request_calls_service( - 'Alexa.PowerController', 'TurnOff', endpoint, - off_service, hass) - - -async def assert_scene_controller_works( - endpoint, - activate_service, - deactivate_service, - hass): - """Assert SceneController API requests work.""" - _, response = await assert_request_calls_service( - 'Alexa.SceneController', 'Activate', endpoint, - activate_service, hass, - response_type='ActivationStarted') - assert response['event']['payload']['cause']['type'] == 'VOICE_INTERACTION' - assert 'timestamp' in response['event']['payload'] - - if deactivate_service: - await assert_request_calls_service( - 'Alexa.SceneController', 'Deactivate', endpoint, - deactivate_service, hass, - response_type='DeactivationStarted') - cause_type = response['event']['payload']['cause']['type'] - assert cause_type == 'VOICE_INTERACTION' - assert 'timestamp' in response['event']['payload'] - - -@pytest.mark.parametrize( - "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) -async def test_api_adjust_brightness(hass, result, adjust): - """Test api adjust brightness process.""" - request = get_new_request( - 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test') - - # add payload - request['directive']['payload']['brightnessDelta'] = adjust - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'brightness': '77' - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['brightness_pct'] == result - assert msg['header']['name'] == 'Response' - - -async def test_api_set_color_rgb(hass): - """Test api set color process.""" - request = get_new_request( - 'Alexa.ColorController', 'SetColor', 'light#test') - - # add payload - request['directive']['payload']['color'] = { - 'hue': '120', - 'saturation': '0.612', - 'brightness': '0.342', - } - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", - 'supported_features': 16, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['rgb_color'] == (33, 87, 33) - assert msg['header']['name'] == 'Response' - - -async def test_api_set_color_temperature(hass): - """Test api set color temperature process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'SetColorTemperature', - 'light#test') - - # add payload - request['directive']['payload']['colorTemperatureInKelvin'] = '7500' - - # setup test devices - hass.states.async_set( - 'light.test', 'off', {'friendly_name': "Test light"}) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['kelvin'] == 7500 - assert msg['header']['name'] == 'Response' - - -@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')]) -async def test_api_decrease_color_temp(hass, result, initial): - """Test api decrease color temp process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', - 'light#test') - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'color_temp': initial, - 'max_mireds': 500, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['color_temp'] == result - assert msg['header']['name'] == 'Response' - - -@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')]) -async def test_api_increase_color_temp(hass, result, initial): - """Test api increase color temp process.""" - request = get_new_request( - 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', - 'light#test') - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", 'color_temp': initial, - 'min_mireds': 142, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['color_temp'] == result - assert msg['header']['name'] == 'Response' - - async def test_api_accept_grant(hass): """Test api AcceptGrant process.""" request = get_new_request("Alexa.Authorization", "AcceptGrant") # add payload request['directive']['payload'] = { - 'grant': { - 'type': 'OAuth2.AuthorizationCode', - 'code': 'VGhpcyBpcyBhbiBhdXRob3JpemF0aW9uIGNvZGUuIDotKQ==' - }, - 'grantee': { - 'type': 'BearerToken', - 'token': 'access-token-from-skill' - } + 'grant': { + 'type': 'OAuth2.AuthorizationCode', + 'code': 'VGhpcyBpcyBhbiBhdXRob3JpemF0aW9uIGNvZGUuIDotKQ==' + }, + 'grantee': { + 'type': 'BearerToken', + 'token': 'access-token-from-skill' + } } # setup test devices @@ -1436,174 +1154,6 @@ async def test_api_accept_grant(hass): assert msg['header']['name'] == 'AcceptGrant.Response' -async def test_report_lock_state(hass): - """Test LockController implements lockState property.""" - hass.states.async_set( - 'lock.locked', STATE_LOCKED, {}) - hass.states.async_set( - 'lock.unlocked', STATE_UNLOCKED, {}) - hass.states.async_set( - 'lock.unknown', STATE_UNKNOWN, {}) - - properties = await reported_properties(hass, 'lock.locked') - properties.assert_equal('Alexa.LockController', 'lockState', 'LOCKED') - - properties = await reported_properties(hass, 'lock.unlocked') - properties.assert_equal('Alexa.LockController', 'lockState', 'UNLOCKED') - - properties = await reported_properties(hass, 'lock.unknown') - properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED') - - -async def test_report_dimmable_light_state(hass): - """Test BrightnessController reports brightness correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'brightness': 128, 'supported_features': 1}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 1}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.BrightnessController', 'brightness', 50) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) - - -async def test_report_colored_light_state(hass): - """Test ColorController reports color correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'hs_color': (180, 75), - 'brightness': 128, - 'supported_features': 17}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 17}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.ColorController', 'color', { - 'hue': 180, - 'saturation': 0.75, - 'brightness': 128 / 255.0, - }) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.ColorController', 'color', { - 'hue': 0, - 'saturation': 0, - 'brightness': 0, - }) - - -async def test_report_colored_temp_light_state(hass): - """Test ColorTemperatureController reports color temp correctly.""" - hass.states.async_set( - 'light.test_on', 'on', {'friendly_name': "Test light On", - 'color_temp': 240, - 'supported_features': 2}) - hass.states.async_set( - 'light.test_off', 'off', {'friendly_name': "Test light Off", - 'supported_features': 2}) - - properties = await reported_properties(hass, 'light.test_on') - properties.assert_equal('Alexa.ColorTemperatureController', - 'colorTemperatureInKelvin', 4166) - - properties = await reported_properties(hass, 'light.test_off') - properties.assert_equal('Alexa.ColorTemperatureController', - 'colorTemperatureInKelvin', 0) - - -async def test_report_fan_speed_state(hass): - """Test PercentageController reports fan speed correctly.""" - hass.states.async_set( - 'fan.off', 'off', {'friendly_name': "Off fan", - 'speed': "off", - 'supported_features': 1}) - hass.states.async_set( - 'fan.low_speed', 'on', {'friendly_name': "Low speed fan", - 'speed': "low", - 'supported_features': 1}) - hass.states.async_set( - 'fan.medium_speed', 'on', {'friendly_name': "Medium speed fan", - 'speed': "medium", - 'supported_features': 1}) - hass.states.async_set( - 'fan.high_speed', 'on', {'friendly_name': "High speed fan", - 'speed': "high", - 'supported_features': 1}) - - properties = await reported_properties(hass, 'fan.off') - properties.assert_equal('Alexa.PercentageController', 'percentage', 0) - - properties = await reported_properties(hass, 'fan.low_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 33) - - properties = await reported_properties(hass, 'fan.medium_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 66) - - properties = await reported_properties(hass, 'fan.high_speed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 100) - - -async def test_report_cover_percentage_state(hass): - """Test PercentageController reports cover percentage correctly.""" - hass.states.async_set( - 'cover.fully_open', 'open', {'friendly_name': "Fully open cover", - 'current_position': 100, - 'supported_features': 15}) - hass.states.async_set( - 'cover.half_open', 'open', {'friendly_name': "Half open cover", - 'current_position': 50, - 'supported_features': 15}) - hass.states.async_set( - 'cover.closed', 'closed', {'friendly_name': "Closed cover", - 'current_position': 0, - 'supported_features': 15}) - - properties = await reported_properties(hass, 'cover.fully_open') - properties.assert_equal('Alexa.PercentageController', 'percentage', 100) - - properties = await reported_properties(hass, 'cover.half_open') - properties.assert_equal('Alexa.PercentageController', 'percentage', 50) - - properties = await reported_properties(hass, 'cover.closed') - properties.assert_equal('Alexa.PercentageController', 'percentage', 0) - - -async def reported_properties(hass, endpoint): - """Use ReportState to get properties and return them. - - The result is a _ReportedProperties instance, which has methods to make - assertions about the properties. - """ - request = get_new_request('Alexa', 'ReportState', endpoint) - msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - await hass.async_block_till_done() - return _ReportedProperties(msg['context']['properties']) - - -class _ReportedProperties: - def __init__(self, properties): - self.properties = properties - - def assert_equal(self, namespace, name, value): - """Assert a property is equal to a given value.""" - for prop in self.properties: - if prop['namespace'] == namespace and prop['name'] == name: - assert prop['value'] == value - return prop - - assert False, 'property %s:%s not in %r' % ( - namespace, - name, - self.properties, - ) - - async def test_entity_config(hass): """Test that we can configure things via entity config.""" request = get_new_request('Alexa.Discovery', 'Discover') @@ -1611,7 +1161,7 @@ async def test_entity_config(hass): hass.states.async_set( 'light.test_1', 'on', {'friendly_name': "Test light 1"}) - config = smart_home.Config( + alexa_config = config.Config( endpoint=None, async_get_access_token=None, should_expose=lambda entity_id: True, @@ -1625,7 +1175,7 @@ async def test_entity_config(hass): ) msg = await smart_home.async_handle_message( - hass, config, request) + hass, alexa_config, request) assert 'event' in msg msg = msg['event'] @@ -1644,95 +1194,6 @@ async def test_entity_config(hass): ) -async 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 = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - - assert 'event' in msg - msg = msg['event'] - - assert not msg['payload']['endpoints'] - - -async def do_http_discovery(config, hass, hass_client): - """Submit a request to the Smart Home HTTP API.""" - await async_setup_component(hass, alexa.DOMAIN, config) - http_client = await hass_client() - - request = get_new_request('Alexa.Discovery', 'Discover') - response = await http_client.post( - smart_home.SMART_HOME_HTTP_ENDPOINT, - data=json.dumps(request), - headers={'content-type': 'application/json'}) - return response - - -async def test_http_api(hass, hass_client): - """With `smart_home:` HTTP API is exposed.""" - config = { - 'alexa': { - 'smart_home': None - } - } - - response = await do_http_discovery(config, hass, hass_client) - response_data = await response.json() - - # Here we're testing just the HTTP view glue -- details of discovery are - # covered in other tests. - assert response_data['event']['header']['name'] == 'Discover.Response' - - -async def test_http_api_disabled(hass, hass_client): - """Without `smart_home:`, the HTTP API is disabled.""" - config = { - 'alexa': {} - } - response = await do_http_discovery(config, hass, hass_client) - - assert response.status == 404 - - -@pytest.mark.parametrize( - "domain,payload,source_list,idx", [ - ('media_player', 'GAME CONSOLE', ['tv', 'game console'], 1), - ('media_player', 'SATELLITE TV', ['satellite-tv', 'game console'], 0), - ('media_player', 'SATELLITE TV', ['satellite_tv', 'game console'], 0), - ('media_player', 'BAD DEVICE', ['satellite_tv', 'game console'], None), - ] -) -async def test_api_select_input(hass, domain, payload, source_list, idx): - """Test api set input process.""" - hass.states.async_set( - 'media_player.test', 'off', { - 'friendly_name': "Test media player", - 'source': 'unknown', - 'source_list': source_list, - }) - - # test where no source matches - if idx is None: - await assert_request_fails( - 'Alexa.InputController', 'SelectInput', 'media_player#test', - 'media_player.select_source', - hass, - payload={'input': payload}) - return - - call, _ = await assert_request_calls_service( - 'Alexa.InputController', 'SelectInput', 'media_player#test', - 'media_player.select_source', - hass, - payload={'input': payload}) - assert call.data['source'] == source_list[idx] - - async def test_logging_request(hass, events): """Test that we log requests.""" context = Context() @@ -1834,104 +1295,3 @@ async def test_endpoint_bad_health(hass): properties = await reported_properties(hass, 'binary_sensor#test_contact') properties.assert_equal('Alexa.EndpointHealth', 'connectivity', {'value': 'UNREACHABLE'}) - - -async def test_report_state(hass, aioclient_mock): - """Test proactive state reports.""" - aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) - - hass.states.async_set( - 'binary_sensor.test_contact', - 'on', - { - 'friendly_name': "Test Contact Sensor", - 'device_class': 'door', - } - ) - - await smart_home.async_enable_proactive_mode(hass, DEFAULT_CONFIG) - - hass.states.async_set( - 'binary_sensor.test_contact', - 'off', - { - 'friendly_name': "Test Contact Sensor", - 'device_class': 'door', - } - ) - - # To trigger event listener - await hass.async_block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - call = aioclient_mock.mock_calls - - call_json = call[0][2] - assert call_json["event"]["payload"]["change"]["properties"][0][ - "value"] == "NOT_DETECTED" - assert call_json["event"]["endpoint"][ - "endpointId"] == "binary_sensor#test_contact" - - -async def run_auth_get_access_token(hass, aioclient_mock, expires_in, - client_id, client_secret, - accept_grant_code, refresh_token): - """Do auth and request a new token for tests.""" - aioclient_mock.post(TEST_TOKEN_URL, - json={'access_token': 'the_access_token', - 'refresh_token': refresh_token, - 'expires_in': expires_in}) - - auth = Auth(hass, client_id, client_secret) - await auth.async_do_auth(accept_grant_code) - await auth.async_get_access_token() - - -async def test_auth_get_access_token_expired(hass, aioclient_mock): - """Test the auth get access token function.""" - client_id = "client123" - client_secret = "shhhhh" - accept_grant_code = "abcdefg" - refresh_token = "refresher" - - await run_auth_get_access_token(hass, aioclient_mock, -5, - client_id, client_secret, - accept_grant_code, refresh_token) - - assert len(aioclient_mock.mock_calls) == 2 - calls = aioclient_mock.mock_calls - - auth_call_json = calls[0][2] - token_call_json = calls[1][2] - - assert auth_call_json["grant_type"] == "authorization_code" - assert auth_call_json["code"] == accept_grant_code - assert auth_call_json["client_id"] == client_id - assert auth_call_json["client_secret"] == client_secret - - assert token_call_json["grant_type"] == "refresh_token" - assert token_call_json["refresh_token"] == refresh_token - assert token_call_json["client_id"] == client_id - assert token_call_json["client_secret"] == client_secret - - -async def test_auth_get_access_token_not_expired(hass, aioclient_mock): - """Test the auth get access token function.""" - client_id = "client123" - client_secret = "shhhhh" - accept_grant_code = "abcdefg" - refresh_token = "refresher" - - await run_auth_get_access_token(hass, aioclient_mock, 555, - client_id, client_secret, - accept_grant_code, refresh_token) - - assert len(aioclient_mock.mock_calls) == 1 - call = aioclient_mock.mock_calls - - auth_call_json = call[0][2] - - assert auth_call_json["grant_type"] == "authorization_code" - assert auth_call_json["code"] == accept_grant_code - assert auth_call_json["client_id"] == client_id - assert auth_call_json["client_secret"] == client_secret diff --git a/tests/components/alexa/test_smart_home_http.py b/tests/components/alexa/test_smart_home_http.py new file mode 100644 index 00000000000..fb410e4c4d6 --- /dev/null +++ b/tests/components/alexa/test_smart_home_http.py @@ -0,0 +1,46 @@ +"""Test Smart Home HTTP endpoints.""" +import json + +from homeassistant.setup import async_setup_component +from homeassistant.components.alexa import DOMAIN, smart_home_http + +from . import get_new_request + + +async def do_http_discovery(config, hass, hass_client): + """Submit a request to the Smart Home HTTP API.""" + await async_setup_component(hass, DOMAIN, config) + http_client = await hass_client() + + request = get_new_request('Alexa.Discovery', 'Discover') + response = await http_client.post( + smart_home_http.SMART_HOME_HTTP_ENDPOINT, + data=json.dumps(request), + headers={'content-type': 'application/json'}) + return response + + +async def test_http_api(hass, hass_client): + """With `smart_home:` HTTP API is exposed.""" + config = { + 'alexa': { + 'smart_home': None + } + } + + response = await do_http_discovery(config, hass, hass_client) + response_data = await response.json() + + # Here we're testing just the HTTP view glue -- details of discovery are + # covered in other tests. + assert response_data['event']['header']['name'] == 'Discover.Response' + + +async def test_http_api_disabled(hass, hass_client): + """Without `smart_home:`, the HTTP API is disabled.""" + config = { + 'alexa': {} + } + response = await do_http_discovery(config, hass, hass_client) + + assert response.status == 404 diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py new file mode 100644 index 00000000000..c5f95a96218 --- /dev/null +++ b/tests/components/alexa/test_state_report.py @@ -0,0 +1,40 @@ +"""Test report state.""" +from homeassistant.components.alexa import state_report +from . import TEST_URL, DEFAULT_CONFIG + + +async def test_report_state(hass, aioclient_mock): + """Test proactive state reports.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'off', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + # To trigger event listener + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["payload"]["change"]["properties"][0]["value"] \ + == "NOT_DETECTED" + assert call_json["event"]["endpoint"]["endpointId"] \ + == "binary_sensor#test_contact" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 24bd647405a..68cd7fab891 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -343,7 +343,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, with patch.dict( 'homeassistant.components.google_assistant.const.' 'DOMAIN_TO_GOOGLE_TYPES', {'light': None}, clear=True - ), patch.dict('homeassistant.components.alexa.smart_home.ENTITY_ADAPTERS', + ), patch.dict('homeassistant.components.alexa.entities.ENTITY_ADAPTERS', {'switch': None}, clear=True): await client.send_json({ 'id': 5,