From 7bbef68b2a0c48b874ee7b15fddf1039021107f1 Mon Sep 17 00:00:00 2001 From: Phil Frost Date: Fri, 26 Jan 2018 18:40:39 +0000 Subject: [PATCH] Implement Alexa temperature sensors (#11930) --- homeassistant/components/alexa/smart_home.py | 84 ++++++++++++++++++-- tests/components/alexa/test_smart_home.py | 56 ++++++++++++- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 2a37fba8b43..2fae0b323a0 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -7,7 +7,7 @@ from uuid import uuid4 from homeassistant.components import ( alert, automation, cover, fan, group, input_boolean, light, lock, - media_player, scene, script, switch, http) + media_player, scene, script, switch, http, sensor) import homeassistant.core as ha import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry @@ -16,7 +16,8 @@ from homeassistant.const import ( 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) + SERVICE_UNLOCK, SERVICE_VOLUME_SET, TEMP_FAHRENHEIT, TEMP_CELSIUS, + CONF_UNIT_OF_MEASUREMENT) from .const import CONF_FILTER, CONF_ENTITY_CONFIG _LOGGER = logging.getLogger(__name__) @@ -24,9 +25,15 @@ _LOGGER = logging.getLogger(__name__) API_DIRECTIVE = 'directive' API_ENDPOINT = 'endpoint' API_EVENT = 'event' +API_CONTEXT = 'context' API_HEADER = 'header' API_PAYLOAD = 'payload' +API_TEMP_UNITS = { + TEMP_FAHRENHEIT: 'FAHRENHEIT', + TEMP_CELSIUS: 'CELSIUS', +} + SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home' CONF_DESCRIPTION = 'description' @@ -94,6 +101,8 @@ class _DisplayCategory(object): def _capability(interface, version=3, supports_deactivation=None, + retrievable=None, + properties_supported=None, cap_type='AlexaInterface'): """Return a Smart Home API capability object. @@ -102,9 +111,7 @@ def _capability(interface, There are some additional fields allowed but not implemented here since we've no use case for them yet: - - properties.supported - proactively_reported - - retrievable `supports_deactivation` applies only to scenes. """ @@ -117,6 +124,12 @@ def _capability(interface, 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 @@ -144,6 +157,8 @@ class _EntityCapabilities(object): def capabilities(self): """Return a list of supported capabilities. + If the returned list is empty, the entity will not be discovered. + You might find _capability() useful. """ raise NotImplementedError @@ -269,6 +284,28 @@ class _GroupCapabilities(_EntityCapabilities): supports_deactivation=True)] +class _SensorCapabilities(_EntityCapabilities): + def default_display_categories(self): + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [_DisplayCategory.TEMPERATURE_SENSOR] + + def capabilities(self): + capabilities = [] + + attrs = self.entity.attributes + if attrs.get(CONF_UNIT_OF_MEASUREMENT) in ( + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + ): + capabilities.append(_capability( + 'Alexa.TemperatureSensor', + retrievable=True, + properties_supported=[{'name': 'temperature'}])) + + return capabilities + + class _UnknownEntityDomainError(Exception): pass @@ -296,6 +333,7 @@ _CAPABILITIES_FOR_DOMAIN = { scene.DOMAIN: _SceneCapabilities, script.DOMAIN: _ScriptCapabilities, switch.DOMAIN: _SwitchCapabilities, + sensor.DOMAIN: _SensorCapabilities, } @@ -407,7 +445,11 @@ def async_handle_message(hass, config, message): return (yield from funct_ref(hass, config, message)) -def api_message(request, name='Response', namespace='Alexa', payload=None): +def api_message(request, + name='Response', + namespace='Alexa', + payload=None, + context=None): """Create a API formatted response message. Async friendly. @@ -435,6 +477,9 @@ def api_message(request, name='Response', namespace='Alexa', payload=None): if API_ENDPOINT in request: response[API_EVENT][API_ENDPOINT] = request[API_ENDPOINT].copy() + if context is not None: + response[API_CONTEXT] = context + return response @@ -490,7 +535,12 @@ def async_api_discovery(hass, config, request): 'manufacturerName': 'Home Assistant', } - endpoint['capabilities'] = entity_capabilities.capabilities() + alexa_capabilities = entity_capabilities.capabilities() + if not alexa_capabilities: + _LOGGER.debug("Not exposing %s because it has no capabilities", + entity.entity_id) + continue + endpoint['capabilities'] = alexa_capabilities discovery_endpoints.append(endpoint) return api_message( @@ -976,3 +1026,25 @@ def async_api_previous(hass, config, request, entity): data, blocking=False) return api_message(request) + + +@HANDLERS.register(('Alexa', 'ReportState')) +@extract_entity +@asyncio.coroutine +def async_api_reportstate(hass, config, request, entity): + """Process a ReportState request.""" + unit = entity.attributes[CONF_UNIT_OF_MEASUREMENT] + temp_property = { + 'namespace': 'Alexa.TemperatureSensor', + 'name': 'temperature', + 'value': { + 'value': float(entity.state), + 'scale': API_TEMP_UNITS[unit], + }, + } + + return api_message( + request, + name='StateReport', + context={'properties': [temp_property]} + ) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0f81d687278..3416dfbe367 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -5,6 +5,7 @@ from uuid import uuid4 import pytest +from homeassistant.const import TEMP_FAHRENHEIT, CONF_UNIT_OF_MEASUREMENT from homeassistant.setup import async_setup_component from homeassistant.components import alexa from homeassistant.components.alexa import smart_home @@ -166,13 +167,27 @@ def test_discovery_request(hass): 'position': 85 }) + hass.states.async_set( + 'sensor.test_temp', '59', { + 'friendly_name': "Test Temp Sensor", + 'unit_of_measurement': TEMP_FAHRENHEIT, + }) + + # This sensor measures a quantity not applicable to Alexa, and should not + # be discovered. + hass.states.async_set( + 'sensor.test_sickness', '0.1', { + 'friendly_name': "Test Space Sickness Sensor", + 'unit_of_measurement': 'garn', + }) + msg = yield from smart_home.async_handle_message( hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] - assert len(msg['payload']['endpoints']) == 16 + assert len(msg['payload']['endpoints']) == 17 assert msg['header']['name'] == 'Discover.Response' assert msg['header']['namespace'] == 'Alexa.Discovery' @@ -334,6 +349,17 @@ def test_discovery_request(hass): assert 'Alexa.PowerController' in caps continue + if appliance['endpointId'] == 'sensor#test_temp': + assert appliance['displayCategories'][0] == 'TEMPERATURE_SENSOR' + assert appliance['friendlyName'] == 'Test Temp Sensor' + assert len(appliance['capabilities']) == 1 + capability = appliance['capabilities'][0] + assert capability['interface'] == 'Alexa.TemperatureSensor' + assert capability['retrievable'] is True + properties = capability['properties'] + assert {'name': 'temperature'} in properties['supported'] + continue + raise AssertionError("Unknown appliance!") @@ -1170,6 +1196,34 @@ def test_api_mute(hass, domain): assert msg['header']['name'] == 'Response' +@asyncio.coroutine +def test_api_report_temperature(hass): + """Test API ReportState response for a temperature sensor.""" + request = get_new_request('Alexa', 'ReportState', 'sensor#test') + + # setup test devices + hass.states.async_set( + 'sensor.test', '42', { + 'friendly_name': 'test sensor', + CONF_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, + }) + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + yield from hass.async_block_till_done() + + header = msg['event']['header'] + assert header['namespace'] == 'Alexa' + assert header['name'] == 'StateReport' + + properties = msg['context']['properties'] + assert len(properties) == 1 + prop = properties[0] + assert prop['namespace'] == 'Alexa.TemperatureSensor' + assert prop['name'] == 'temperature' + assert prop['value'] == {'value': 42.0, 'scale': 'FAHRENHEIT'} + + @asyncio.coroutine def test_entity_config(hass): """Test that we can configure things via entity config."""