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:
parent
184f2be83e
commit
5801d78017
3 changed files with 374 additions and 17 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."""
|
||||
|
|
Loading…
Add table
Reference in a new issue