diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 5e5155b3db8..707f8d02958 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -6,18 +6,20 @@ from datetime import datetime from uuid import uuid4 from homeassistant.components import ( - alert, automation, cover, fan, group, input_boolean, light, lock, + alert, automation, cover, climate, fan, group, input_boolean, light, lock, media_player, scene, script, switch, http, sensor) import homeassistant.core as ha import homeassistant.util.color as color_util +from homeassistant.util.temperature import convert as convert_temperature from homeassistant.util.decorator import Registry from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_NAME, SERVICE_LOCK, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, - SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, CONF_NAME, + SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_SET, TEMP_FAHRENHEIT, TEMP_CELSIUS, CONF_UNIT_OF_MEASUREMENT, STATE_LOCKED, STATE_UNLOCKED, STATE_ON) + from .const import CONF_FILTER, CONF_ENTITY_CONFIG _LOGGER = logging.getLogger(__name__) @@ -34,6 +36,16 @@ API_TEMP_UNITS = { TEMP_CELSIUS: 'CELSIUS', } +API_THERMOSTAT_MODES = { + climate.STATE_HEAT: 'HEAT', + climate.STATE_COOL: 'COOL', + climate.STATE_AUTO: 'AUTO', + climate.STATE_ECO: 'ECO', + climate.STATE_IDLE: 'OFF', + climate.STATE_FAN_ONLY: 'OFF', + climate.STATE_DRY: 'OFF', +} + SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' CONF_DESCRIPTION = 'description' @@ -383,8 +395,60 @@ class _AlexaTemperatureSensor(_AlexaInterface): raise _UnsupportedProperty(name) unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT] + temp = self.entity.state + if self.entity.domain == climate.DOMAIN: + temp = self.entity.attributes.get( + climate.ATTR_CURRENT_TEMPERATURE) return { - 'value': float(self.entity.state), + 'value': float(temp), + 'scale': API_TEMP_UNITS[unit], + } + + +class _AlexaThermostatController(_AlexaInterface): + 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_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.entity.attributes[CONF_UNIT_OF_MEASUREMENT] + temp = None + 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) + if temp is None: + raise _UnsupportedProperty(name) + + return { + 'value': float(temp), 'scale': API_TEMP_UNITS[unit], } @@ -415,6 +479,16 @@ class _SwitchCapabilities(_AlexaEntity): return [_AlexaPowerController(self.entity)] +@ENTITY_ADAPTERS.register(climate.DOMAIN) +class _ClimateCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.THERMOSTAT] + + def interfaces(self): + yield _AlexaThermostatController(self.entity) + yield _AlexaTemperatureSensor(self.entity) + + @ENTITY_ADAPTERS.register(cover.DOMAIN) class _CoverCapabilities(_AlexaEntity): def default_display_categories(self): @@ -682,17 +756,26 @@ def api_message(request, return response -def api_error(request, error_type='INTERNAL_ERROR', error_message=""): +def api_error(request, + namespace='Alexa', + error_type='INTERNAL_ERROR', + error_message="", + payload=None): """Create a API formatted error response. Async friendly. """ - payload = { - 'type': error_type, - 'message': error_message, - } + payload = payload or {} + payload['type'] = error_type + payload['message'] = error_message - return api_message(request, name='ErrorResponse', payload=payload) + _LOGGER.info("Request %s/%s error %s: %s", + request[API_HEADER]['namespace'], + request[API_HEADER]['name'], + error_type, error_message) + + return api_message( + request, name='ErrorResponse', namespace=namespace, payload=payload) @HANDLERS.register(('Alexa.Discovery', 'Discover')) @@ -1104,7 +1187,6 @@ def async_api_select_input(hass, config, request, entity): else: msg = 'failed to map input {} to a media source on {}'.format( media_input, entity.entity_id) - _LOGGER.error(msg) return api_error( request, error_type='INVALID_VALUE', error_message=msg) @@ -1276,6 +1358,149 @@ def async_api_previous(hass, config, request, entity): return api_message(request) +def api_error_temp_range(request, temp, min_temp, max_temp, unit): + """Create temperature value out of range API error response. + + Async friendly. + """ + temp_range = { + 'minimumValue': { + 'value': min_temp, + 'scale': API_TEMP_UNITS[unit], + }, + 'maximumValue': { + 'value': max_temp, + 'scale': API_TEMP_UNITS[unit], + }, + } + + msg = 'The requested temperature {} is out of range'.format(temp) + return api_error( + request, + error_type='TEMPERATURE_VALUE_OUT_OF_RANGE', + error_message=msg, + payload={'validRange': temp_range}, + ) + + +def temperature_from_object(temp_obj, to_unit, interval=False): + """Get temperature from Temperature object in requested 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')) +@extract_entity +async def async_api_set_target_temp(hass, config, request, entity): + """Process a set target temperature request.""" + unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT] + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + payload = request[API_PAYLOAD] + if 'targetSetpoint' in payload: + temp = temperature_from_object( + payload['targetSetpoint'], unit) + if temp < min_temp or temp > max_temp: + return api_error_temp_range( + request, temp, min_temp, max_temp, unit) + data[ATTR_TEMPERATURE] = temp + if 'lowerSetpoint' in payload: + temp_low = temperature_from_object( + payload['lowerSetpoint'], unit) + if temp_low < min_temp or temp_low > max_temp: + return api_error_temp_range( + request, temp_low, min_temp, max_temp, unit) + data[climate.ATTR_TARGET_TEMP_LOW] = temp_low + if 'upperSetpoint' in payload: + temp_high = temperature_from_object( + payload['upperSetpoint'], unit) + if temp_high < min_temp or temp_high > max_temp: + return api_error_temp_range( + request, temp_high, min_temp, max_temp, unit) + data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high + + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature')) +@extract_entity +async def async_api_adjust_target_temp(hass, config, request, entity): + """Process an adjust target temperature request.""" + unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT] + min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) + max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + + temp_delta = temperature_from_object( + request[API_PAYLOAD]['targetSetpointDelta'], unit, interval=True) + target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta + + if target_temp < min_temp or target_temp > max_temp: + return api_error_temp_range( + request, target_temp, min_temp, max_temp, unit) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + ATTR_TEMPERATURE: target_temp, + } + + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode')) +@extract_entity +async def async_api_set_thermostat_mode(hass, config, request, entity): + """Process a set thermostat mode request.""" + mode = request[API_PAYLOAD]['thermostatMode'] + + operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) + # Work around a pylint false positive due to + # https://github.com/PyCQA/pylint/issues/1830 + # pylint: disable=stop-iteration-return + 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) + return api_error( + request, + namespace='Alexa.ThermostatController', + error_type='UNSUPPORTED_THERMOSTAT_MODE', + error_message=msg + ) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + climate.ATTR_OPERATION_MODE: ha_mode, + } + + await hass.services.async_call( + entity.domain, climate.SERVICE_SET_OPERATION_MODE, data, + blocking=False) + + return api_message(request) + + @HANDLERS.register(('Alexa', 'ReportState')) @extract_entity @asyncio.coroutine diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index b7e2412f293..913d6456906 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -3,17 +3,22 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_NOT_RECOGNIZED_TEMPLATE, TEMPERATURE) -def fahrenheit_to_celsius(fahrenheit: float) -> float: +def fahrenheit_to_celsius(fahrenheit: float, interval: bool = False) -> float: """Convert a temperature in Fahrenheit to Celsius.""" + if interval: + return fahrenheit / 1.8 return (fahrenheit - 32.0) / 1.8 -def celsius_to_fahrenheit(celsius: float) -> float: +def celsius_to_fahrenheit(celsius: float, interval: bool = False) -> float: """Convert a temperature in Celsius to Fahrenheit.""" + if interval: + return celsius * 1.8 return celsius * 1.8 + 32.0 -def convert(temperature: float, from_unit: str, to_unit: str) -> float: +def convert(temperature: float, from_unit: str, to_unit: str, + interval: bool = False) -> float: """Convert a temperature from one unit to another.""" if from_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT): raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format( @@ -25,5 +30,5 @@ def convert(temperature: float, from_unit: str, to_unit: str) -> float: if from_unit == to_unit: return temperature elif from_unit == TEMP_CELSIUS: - return celsius_to_fahrenheit(temperature) - return fahrenheit_to_celsius(temperature) + return celsius_to_fahrenheit(temperature, interval) + return fahrenheit_to_celsius(temperature, interval) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 8199652d09e..dd404b7d57a 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -693,6 +693,133 @@ def test_unknown_sensor(hass): yield from discovery_test(device, hass, expected_endpoints=0) +async def test_thermostat(hass): + """Test thermostat discovery.""" + device = ( + 'climate.test_thermostat', + 'cool', + { + 'operation_mode': 'cool', + 'temperature': 70.0, + 'target_temp_high': 80.0, + 'target_temp_low': 60.0, + 'current_temperature': 75.0, + 'friendly_name': "Test Thermostat", + 'supported_features': 1 | 2 | 4 | 128, + 'operation_list': ['heat', 'cool', 'auto', 'off'], + 'min_temp': 50, + 'max_temp': 90, + 'unit_of_measurement': TEMP_FAHRENHEIT, + } + ) + appliance = await discovery_test(device, hass) + + assert appliance['endpointId'] == 'climate#test_thermostat' + assert appliance['displayCategories'][0] == 'THERMOSTAT' + assert appliance['friendlyName'] == "Test Thermostat" + + assert_endpoint_capabilities( + appliance, + 'Alexa.ThermostatController', + 'Alexa.TemperatureSensor', + ) + + properties = await reported_properties( + hass, 'climate#test_thermostat') + properties.assert_equal( + 'Alexa.ThermostatController', 'thermostatMode', 'COOL') + properties.assert_equal( + 'Alexa.ThermostatController', 'targetSetpoint', + {'value': 70.0, 'scale': 'FAHRENHEIT'}) + properties.assert_equal( + 'Alexa.TemperatureSensor', 'temperature', + {'value': 75.0, 'scale': 'FAHRENHEIT'}) + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpoint': {'value': 69.0, 'scale': 'FAHRENHEIT'}} + ) + assert call.data['temperature'] == 69.0 + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpoint': {'value': 0.0, 'scale': 'CELSIUS'}} + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={ + 'targetSetpoint': {'value': 70.0, 'scale': 'FAHRENHEIT'}, + 'lowerSetpoint': {'value': 293.15, 'scale': 'KELVIN'}, + 'upperSetpoint': {'value': 30.0, 'scale': 'CELSIUS'}, + } + ) + assert call.data['temperature'] == 70.0 + assert call.data['target_temp_low'] == 68.0 + assert call.data['target_temp_high'] == 86.0 + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={ + 'lowerSetpoint': {'value': 273.15, 'scale': 'KELVIN'}, + 'upperSetpoint': {'value': 75.0, 'scale': 'FAHRENHEIT'}, + } + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={ + 'lowerSetpoint': {'value': 293.15, 'scale': 'FAHRENHEIT'}, + 'upperSetpoint': {'value': 75.0, 'scale': 'CELSIUS'}, + } + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'AdjustTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpointDelta': {'value': -10.0, 'scale': 'KELVIN'}} + ) + assert call.data['temperature'] == 52.0 + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'AdjustTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpointDelta': {'value': 20.0, 'scale': 'CELSIUS'}} + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetThermostatMode', + 'climate#test_thermostat', 'climate.set_operation_mode', + hass, + payload={'thermostatMode': 'HEAT'} + ) + assert call.data['operation_mode'] == 'heat' + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetThermostatMode', + 'climate#test_thermostat', 'climate.set_operation_mode', + hass, + payload={'thermostatMode': 'INVALID'} + ) + assert msg['event']['payload']['type'] == 'UNSUPPORTED_THERMOSTAT_MODE' + + @asyncio.coroutine def test_exclude_filters(hass): """Test exclusion filters."""