From 0b4de54725de83cfed5597dcc2ac0ec552c688ae Mon Sep 17 00:00:00 2001 From: Eitan Mosenkis Date: Wed, 15 Nov 2017 06:19:42 +0200 Subject: [PATCH] Google Assistant for climate entities: Support QUERY and respect system-wide unit_system setting. (#10346) --- .../components/google_assistant/http.py | 20 ++-- .../components/google_assistant/smart_home.py | 58 ++++++++--- .../google_assistant/test_google_assistant.py | 96 +++++++++++++++++++ .../google_assistant/test_smart_home.py | 26 ++++- 4 files changed, 176 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 1458d695163..71a4ff9ce3a 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -81,7 +81,7 @@ class GoogleAssistantView(HomeAssistantView): if not self.is_entity_exposed(entity): continue - device = entity_to_device(entity) + device = entity_to_device(entity, hass.config.units) if device is None: _LOGGER.warning("No mapping for %s domain", entity.domain) continue @@ -89,9 +89,9 @@ class GoogleAssistantView(HomeAssistantView): devices.append(device) return self.json( - make_actions_response(request_id, - {'agentUserId': self.agent_user_id, - 'devices': devices})) + _make_actions_response(request_id, + {'agentUserId': self.agent_user_id, + 'devices': devices})) @asyncio.coroutine def handle_query(self, @@ -112,10 +112,10 @@ class GoogleAssistantView(HomeAssistantView): # If we can't find a state, the device is offline devices[devid] = {'online': False} - devices[devid] = query_device(state) + devices[devid] = query_device(state, hass.config.units) return self.json( - make_actions_response(request_id, {'devices': devices})) + _make_actions_response(request_id, {'devices': devices})) @asyncio.coroutine def handle_execute(self, @@ -130,7 +130,8 @@ class GoogleAssistantView(HomeAssistantView): for eid in ent_ids: domain = eid.split('.')[0] (service, service_data) = determine_service( - eid, execution.get('command'), execution.get('params')) + eid, execution.get('command'), execution.get('params'), + hass.config.units) success = yield from hass.services.async_call( domain, service, service_data, blocking=True) result = {"ids": [eid], "states": {}} @@ -141,7 +142,7 @@ class GoogleAssistantView(HomeAssistantView): commands.append(result) return self.json( - make_actions_response(request_id, {'commands': commands})) + _make_actions_response(request_id, {'commands': commands})) @asyncio.coroutine def post(self, request: Request) -> Response: @@ -181,6 +182,5 @@ class GoogleAssistantView(HomeAssistantView): "invalid intent", status_code=HTTP_BAD_REQUEST) -def make_actions_response(request_id: str, payload: dict) -> dict: - """Helper to simplify format for response.""" +def _make_actions_response(request_id: str, payload: dict) -> dict: return {'requestId': request_id, 'payload': payload} diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1c8adf3d8f7..42cb555fe3c 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -5,18 +5,21 @@ import logging # pylint: disable=using-constant-test,unused-import,ungrouped-imports # if False: from aiohttp.web import Request, Response # NOQA -from typing import Dict, Tuple, Any # NOQA +from typing import Dict, Tuple, Any, Optional # NOQA from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA +from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_OFF, - SERVICE_TURN_OFF, SERVICE_TURN_ON + SERVICE_TURN_OFF, SERVICE_TURN_ON, + TEMP_FAHRENHEIT, TEMP_CELSIUS, ) from homeassistant.components import ( switch, light, cover, media_player, group, fan, scene, script, climate ) +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( ATTR_GOOGLE_ASSISTANT_NAME, ATTR_GOOGLE_ASSISTANT_TYPE, @@ -65,7 +68,7 @@ def make_actions_response(request_id: str, payload: dict) -> dict: return {'requestId': request_id, 'payload': payload} -def entity_to_device(entity: Entity): +def entity_to_device(entity: Entity, units: UnitSystem): """Convert a hass entity into an google actions device.""" class_data = MAPPING_COMPONENT.get( entity.attributes.get(ATTR_GOOGLE_ASSISTANT_TYPE) or entity.domain) @@ -105,14 +108,39 @@ def entity_to_device(entity: Entity): if m in CLIMATE_SUPPORTED_MODES) device['attributes'] = { 'availableThermostatModes': modes, - 'thermostatTemperatureUnit': 'C', + 'thermostatTemperatureUnit': + 'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C', } return device -def query_device(entity: Entity) -> dict: +def query_device(entity: Entity, units: UnitSystem) -> dict: """Take an entity and return a properly formatted device object.""" + def celsius(deg: Optional[float]) -> Optional[float]: + """Convert a float to Celsius and rounds to one decimal place.""" + if deg is None: + return None + return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1) + if entity.domain == climate.DOMAIN: + mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) + if mode not in CLIMATE_SUPPORTED_MODES: + mode = 'on' + response = { + 'thermostatMode': mode, + 'thermostatTemperatureSetpoint': + celsius(entity.attributes.get(climate.ATTR_TEMPERATURE)), + 'thermostatTemperatureAmbient': + celsius(entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE)), + 'thermostatTemperatureSetpointHigh': + celsius(entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)), + 'thermostatTemperatureSetpointLow': + celsius(entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)), + 'thermostatHumidityAmbient': + entity.attributes.get(climate.ATTR_CURRENT_HUMIDITY), + } + return {k: v for k, v in response.items() if v is not None} + final_state = entity.state != STATE_OFF final_brightness = entity.attributes.get(light.ATTR_BRIGHTNESS, 255 if final_state else 0) @@ -138,8 +166,9 @@ def query_device(entity: Entity) -> dict: # erroneous bug on old pythons and pylint # https://github.com/PyCQA/pylint/issues/1212 # pylint: disable=invalid-sequence-index -def determine_service(entity_id: str, command: str, - params: dict) -> Tuple[str, dict]: +def determine_service( + entity_id: str, command: str, params: dict, + units: UnitSystem) -> Tuple[str, dict]: """ Determine service and service_data. @@ -166,14 +195,17 @@ def determine_service(entity_id: str, command: str, # special climate handling if domain == climate.DOMAIN: if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - service_data['temperature'] = params.get( - 'thermostatTemperatureSetpoint', 25) + service_data['temperature'] = units.temperature( + params.get('thermostatTemperatureSetpoint', 25), + TEMP_CELSIUS) return (climate.SERVICE_SET_TEMPERATURE, service_data) if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: - service_data['target_temp_high'] = params.get( - 'thermostatTemperatureSetpointHigh', 25) - service_data['target_temp_low'] = params.get( - 'thermostatTemperatureSetpointLow', 18) + service_data['target_temp_high'] = units.temperature( + params.get('thermostatTemperatureSetpointHigh', 25), + TEMP_CELSIUS) + service_data['target_temp_low'] = units.temperature( + params.get('thermostatTemperatureSetpointLow', 18), + TEMP_CELSIUS) return (climate.SERVICE_SET_TEMPERATURE, service_data) if command == COMMAND_THERMOSTAT_SET_MODE: service_data['operation_mode'] = params.get( diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 7ad59779f94..c21c63b0d52 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -11,6 +11,7 @@ from homeassistant import core, const, setup from homeassistant.components import ( fan, http, cover, light, switch, climate, async_setup, media_player) from homeassistant.components import google_assistant as ga +from homeassistant.util.unit_system import IMPERIAL_SYSTEM from . import DEMO_DEVICES @@ -198,6 +199,101 @@ def test_query_request(hass_fixture, assistant_client): assert devices['light.ceiling_lights']['brightness'] == 70 +@asyncio.coroutine +def test_query_climate_request(hass_fixture, assistant_client): + """Test a query request.""" + reqid = '5711642932632160984' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.QUERY', + 'payload': { + 'devices': [ + {'id': 'climate.hvac'}, + {'id': 'climate.heatpump'}, + {'id': 'climate.ecobee'}, + ] + } + }] + } + result = yield from assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=AUTH_HEADER) + assert result.status == 200 + body = yield from result.json() + assert body.get('requestId') == reqid + devices = body['payload']['devices'] + assert devices == { + 'climate.heatpump': { + 'thermostatTemperatureSetpoint': 20.0, + 'thermostatTemperatureAmbient': 25.0, + 'thermostatMode': 'heat', + }, + 'climate.ecobee': { + 'thermostatTemperatureSetpointHigh': 24, + 'thermostatTemperatureAmbient': 23, + 'thermostatMode': 'on', + 'thermostatTemperatureSetpointLow': 21 + }, + 'climate.hvac': { + 'thermostatTemperatureSetpoint': 21, + 'thermostatTemperatureAmbient': 22, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, + } + } + + +@asyncio.coroutine +def test_query_climate_request_f(hass_fixture, assistant_client): + """Test a query request.""" + hass_fixture.config.units = IMPERIAL_SYSTEM + reqid = '5711642932632160984' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.QUERY', + 'payload': { + 'devices': [ + {'id': 'climate.hvac'}, + {'id': 'climate.heatpump'}, + {'id': 'climate.ecobee'}, + ] + } + }] + } + result = yield from assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=AUTH_HEADER) + assert result.status == 200 + body = yield from result.json() + assert body.get('requestId') == reqid + devices = body['payload']['devices'] + assert devices == { + 'climate.heatpump': { + 'thermostatTemperatureSetpoint': -6.7, + 'thermostatTemperatureAmbient': -3.9, + 'thermostatMode': 'heat', + }, + 'climate.ecobee': { + 'thermostatTemperatureSetpointHigh': -4.4, + 'thermostatTemperatureAmbient': -5, + 'thermostatMode': 'on', + 'thermostatTemperatureSetpointLow': -6.1, + }, + 'climate.hvac': { + 'thermostatTemperatureSetpoint': -6.1, + 'thermostatTemperatureAmbient': -5.6, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, + } + } + + @asyncio.coroutine def test_execute_request(hass_fixture, assistant_client): """Test a execute request.""" diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 20db85b998e..6712b390dbb 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -5,6 +5,7 @@ import asyncio from homeassistant import const from homeassistant.components import climate from homeassistant.components import google_assistant as ga +from homeassistant.util.unit_system import (IMPERIAL_SYSTEM, METRIC_SYSTEM) DETERMINE_SERVICE_TESTS = [{ # Test light brightness 'entity_id': 'light.test', @@ -82,6 +83,15 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness climate.SERVICE_SET_TEMPERATURE, {'entity_id': 'climate.living_room', 'temperature': 24.5} ), +}, { # Test climate temperature Fahrenheit + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + 'params': {'thermostatTemperatureSetpoint': 24.5}, + 'units': IMPERIAL_SYSTEM, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', 'temperature': 76.1} + ), }, { # Test climate temperature range 'entity_id': 'climate.living_room', 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, @@ -94,6 +104,19 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness {'entity_id': 'climate.living_room', 'target_temp_high': 24.5, 'target_temp_low': 20.5} ), +}, { # Test climate temperature range Fahrenheit + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + 'params': { + 'thermostatTemperatureSetpointHigh': 24.5, + 'thermostatTemperatureSetpointLow': 20.5, + }, + 'units': IMPERIAL_SYSTEM, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', + 'target_temp_high': 76.1, 'target_temp_low': 68.9} + ), }, { # Test climate operation mode 'entity_id': 'climate.living_room', 'command': ga.const.COMMAND_THERMOSTAT_SET_MODE, @@ -122,5 +145,6 @@ def test_determine_service(): result = ga.smart_home.determine_service( test['entity_id'], test['command'], - test['params']) + test['params'], + test.get('units', METRIC_SYSTEM)) assert result == test['expected']