Implement thermostat support for Alexa (#13340)

* Implement thermostat support for Alexa

* util.temperature: Support interval conversions

* Use climate.ATTR_OPERATION_MODE for Alexa thermostat mode

* Switch coroutines to async/await

* Log all Alexa error events
This commit is contained in:
Albert Lee 2018-03-30 01:49:08 -05:00 committed by Paulus Schoutsen
parent 184f2be83e
commit 5801d78017
3 changed files with 374 additions and 17 deletions

View file

@ -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

View file

@ -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)

View file

@ -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."""