Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Google Assistant QUERY support for climate entities and support Fahrenheit. #10346

Merged
merged 1 commit into from
Nov 15, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Google Assistant for climate entities: Support QUERY and respect syst…
…em-wide unit_system setting.
  • Loading branch information
emosenkis committed Nov 14, 2017
commit 0c747d5f83a1d22fc4e152098cb11fabf459af81
20 changes: 10 additions & 10 deletions homeassistant/components/google_assistant/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,17 @@ def handle_sync(self, hass: HomeAssistant, request_id: str):
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

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,
Expand All @@ -112,10 +112,10 @@ def handle_query(self,
# 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,
Expand All @@ -130,7 +130,8 @@ def handle_execute(self,
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": {}}
Expand All @@ -141,7 +142,7 @@ def handle_execute(self,
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:
Expand Down Expand Up @@ -181,6 +182,5 @@ def post(self, request: Request) -> Response:
"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}
58 changes: 45 additions & 13 deletions homeassistant/components/google_assistant/smart_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@
# 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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While you are here, I suggest wrapping the lines about state and brightness into if entity.domain = FOO as well. There is no point trying to return state for domains to which those states do not apply.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@infernix It's a good suggestion and I agree that the code for different domains should be clearly delineated throughout the google_assistant component, that cleanup is unrelated to this PR and I don't want to hold it back while I go sort things out (there's no documentation beyond the code itself showing which domains are supported and which states they expose).

final_brightness = entity.attributes.get(light.ATTR_BRIGHTNESS, 255
if final_state else 0)
Expand All @@ -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.

Expand All @@ -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(
Expand Down
96 changes: 96 additions & 0 deletions tests/components/google_assistant/test_google_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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

Expand Down Expand Up @@ -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."""
Expand Down
26 changes: 25 additions & 1 deletion tests/components/google_assistant/test_smart_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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',
Expand Down Expand Up @@ -82,6 +83,15 @@
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,
Expand All @@ -94,6 +104,19 @@
{'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,
Expand Down Expand Up @@ -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']