From d1038ea79fa4e6d6bdbb37b2ae7ba830ab5b84d6 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 6 Mar 2019 12:00:53 +0800 Subject: [PATCH] Google Assistant: Create and pass context to service calls (#21551) * Google Assistant: Create and pass context to service calls * Refactor request data into separate object and pass to execute. --- homeassistant/components/cloud/__init__.py | 1 - homeassistant/components/cloud/iot.py | 4 +- .../components/google_assistant/helpers.py | 14 +- .../components/google_assistant/http.py | 15 +- .../components/google_assistant/smart_home.py | 84 ++++--- .../components/google_assistant/trait.py | 61 ++--- .../google_assistant/test_smart_home.py | 232 ++++++++++-------- .../components/google_assistant/test_trait.py | 199 ++++++++------- 8 files changed, 345 insertions(+), 265 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4b1a60133db024..85ed7391ec7329 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -236,7 +236,6 @@ def should_expose(entity): self._gactions_config = ga_h.Config( should_expose=should_expose, allow_unlock=self.prefs.google_allow_unlock, - agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), ) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 4a7215305b2d2c..76999e703fefda 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -334,7 +334,9 @@ def async_handle_google_actions(hass, cloud, payload): return ga.turned_off_response(payload) result = yield from ga.async_handle_message( - hass, cloud.gactions_config, payload) + hass, cloud.gactions_config, + cloud.claims['cognito:username'], + payload) return result diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index f20a4106a161b7..8afa55acc5c6b7 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,4 +1,5 @@ """Helper classes for Google Assistant integration.""" +from homeassistant.core import Context class SmartHomeError(Exception): @@ -16,10 +17,19 @@ def __init__(self, code, msg): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, allow_unlock, agent_user_id, + def __init__(self, should_expose, allow_unlock, entity_config=None): """Initialize the configuration.""" self.should_expose = should_expose - self.agent_user_id = agent_user_id self.entity_config = entity_config or {} self.allow_unlock = allow_unlock + + +class RequestData: + """Hold data associated with a particular request.""" + + def __init__(self, config, user_id, request_id): + """Initialize the request data.""" + self.config = config + self.request_id = request_id + self.context = Context(user_id=user_id) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f0294c3bcb23e7..cbe2015f4f900d 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -71,17 +71,16 @@ class GoogleAssistantView(HomeAssistantView): def __init__(self, is_exposed, entity_config, allow_unlock): """Initialize the Google Assistant request handler.""" - self.is_exposed = is_exposed - self.entity_config = entity_config - self.allow_unlock = allow_unlock + self.config = Config(is_exposed, + allow_unlock, + entity_config) async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" message = await request.json() # type: dict - config = Config(self.is_exposed, - self.allow_unlock, - request['hass_user'].id, - self.entity_config) result = await async_handle_message( - request.app['hass'], config, message) + request.app['hass'], + self.config, + request['hass_user'].id, + message) return self.json(result) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 31323decd6c155..fa272c250121a5 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -36,7 +36,7 @@ ERR_UNKNOWN_ERROR, EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED ) -from .helpers import SmartHomeError +from .helpers import SmartHomeError, RequestData HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) @@ -87,7 +87,8 @@ def traits(self): domain = state.domain features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - return [Trait(self.hass, state, self.config) for Trait in trait.TRAITS + return [Trait(self.hass, state, self.config) + for Trait in trait.TRAITS if Trait.supported(domain, features)] async def sync_serialize(self): @@ -178,7 +179,7 @@ def query_serialize(self): return attrs - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a command. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute @@ -186,7 +187,7 @@ async def execute(self, command, params): executed = False for trt in self.traits(): if trt.can_execute(command, params): - await trt.execute(command, params) + await trt.execute(command, data, params) executed = True break @@ -202,9 +203,13 @@ def async_update(self): self.state = self.hass.states.get(self.entity_id) -async def async_handle_message(hass, config, message): +async def async_handle_message(hass, config, user_id, message): """Handle incoming API messages.""" - response = await _process(hass, config, message) + request_id = message.get('requestId') # type: str + + data = RequestData(config, user_id, request_id) + + response = await _process(hass, data, message) if response and 'errorCode' in response['payload']: _LOGGER.error('Error handling message %s: %s', @@ -213,14 +218,13 @@ async def async_handle_message(hass, config, message): return response -async def _process(hass, config, message): +async def _process(hass, data, message): """Process a message.""" - request_id = message.get('requestId') # type: str inputs = message.get('inputs') # type: list if len(inputs) != 1: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_PROTOCOL_ERROR} } @@ -228,49 +232,49 @@ async def _process(hass, config, message): if handler is None: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_PROTOCOL_ERROR} } try: - result = await handler(hass, config, request_id, - inputs[0].get('payload')) + result = await handler(hass, data, inputs[0].get('payload')) except SmartHomeError as err: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': err.code} } except Exception: # pylint: disable=broad-except _LOGGER.exception('Unexpected error') return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_UNKNOWN_ERROR} } if result is None: return None - return {'requestId': request_id, 'payload': result} + return {'requestId': data.request_id, 'payload': result} @HANDLERS.register('action.devices.SYNC') -async def async_devices_sync(hass, config, request_id, payload): +async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. https://developers.google.com/actions/smarthome/create-app#actiondevicessync """ - hass.bus.async_fire(EVENT_SYNC_RECEIVED, { - 'request_id': request_id - }) + hass.bus.async_fire( + EVENT_SYNC_RECEIVED, + {'request_id': data.request_id}, + context=data.context) devices = [] for state in hass.states.async_all(): if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: continue - if not config.should_expose(state): + if not data.config.should_expose(state): continue - entity = _GoogleEntity(hass, config, state) + entity = _GoogleEntity(hass, data.config, state) serialized = await entity.sync_serialize() if serialized is None: @@ -280,7 +284,7 @@ async def async_devices_sync(hass, config, request_id, payload): devices.append(serialized) response = { - 'agentUserId': config.agent_user_id, + 'agentUserId': data.context.user_id, 'devices': devices, } @@ -288,7 +292,7 @@ async def async_devices_sync(hass, config, request_id, payload): @HANDLERS.register('action.devices.QUERY') -async def async_devices_query(hass, config, request_id, payload): +async def async_devices_query(hass, data, payload): """Handle action.devices.QUERY request. https://developers.google.com/actions/smarthome/create-app#actiondevicesquery @@ -298,23 +302,27 @@ async def async_devices_query(hass, config, request_id, payload): devid = device['id'] state = hass.states.get(devid) - hass.bus.async_fire(EVENT_QUERY_RECEIVED, { - 'request_id': request_id, - ATTR_ENTITY_ID: devid, - }) + hass.bus.async_fire( + EVENT_QUERY_RECEIVED, + { + 'request_id': data.request_id, + ATTR_ENTITY_ID: devid, + }, + context=data.context) if not state: # If we can't find a state, the device is offline devices[devid] = {'online': False} continue - devices[devid] = _GoogleEntity(hass, config, state).query_serialize() + entity = _GoogleEntity(hass, data.config, state) + devices[devid] = entity.query_serialize() return {'devices': devices} @HANDLERS.register('action.devices.EXECUTE') -async def handle_devices_execute(hass, config, request_id, payload): +async def handle_devices_execute(hass, data, payload): """Handle action.devices.EXECUTE request. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute @@ -327,11 +335,14 @@ async def handle_devices_execute(hass, config, request_id, payload): command['execution']): entity_id = device['id'] - hass.bus.async_fire(EVENT_COMMAND_RECEIVED, { - 'request_id': request_id, - ATTR_ENTITY_ID: entity_id, - 'execution': execution - }) + hass.bus.async_fire( + EVENT_COMMAND_RECEIVED, + { + 'request_id': data.request_id, + ATTR_ENTITY_ID: entity_id, + 'execution': execution + }, + context=data.context) # Happens if error occurred. Skip entity for further processing if entity_id in results: @@ -348,10 +359,11 @@ async def handle_devices_execute(hass, config, request_id, payload): } continue - entities[entity_id] = _GoogleEntity(hass, config, state) + entities[entity_id] = _GoogleEntity(hass, data.config, state) try: await entities[entity_id].execute(execution['command'], + data, execution.get('params', {})) except SmartHomeError as err: results[entity_id] = { @@ -378,7 +390,7 @@ async def handle_devices_execute(hass, config, request_id, payload): @HANDLERS.register('action.devices.DISCONNECT') -async def async_devices_disconnect(hass, config, request_id, payload): +async def async_devices_disconnect(hass, data, payload): """Handle action.devices.DISCONNECT request. https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d0368ee077596e..aff24f3051257f 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -102,7 +102,7 @@ def can_execute(self, command, params): """Test if command can be executed.""" return command in self.commands - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a trait command.""" raise NotImplementedError @@ -159,7 +159,7 @@ def query_attributes(self): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a brightness command.""" domain = self.state.domain @@ -168,20 +168,20 @@ async def execute(self, command, params): light.DOMAIN, light.SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_BRIGHTNESS_PCT: params['brightness'] - }, blocking=True) + }, blocking=True, context=data.context) elif domain == cover.DOMAIN: await self.hass.services.async_call( cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { ATTR_ENTITY_ID: self.state.entity_id, cover.ATTR_POSITION: params['brightness'] - }, blocking=True) + }, blocking=True, context=data.context) elif domain == media_player.DOMAIN: await self.hass.services.async_call( media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: params['brightness'] / 100 - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -221,7 +221,7 @@ def query_attributes(self): return {'on': self.state.state != cover.STATE_CLOSED} return {'on': self.state.state != STATE_OFF} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an OnOff command.""" domain = self.state.domain @@ -242,7 +242,7 @@ async def execute(self, command, params): await self.hass.services.async_call(service_domain, service, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -288,7 +288,7 @@ def can_execute(self, command, params): return (command in self.commands and 'spectrumRGB' in params.get('color', {})) - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a color spectrum command.""" # Convert integer to hex format and left pad with 0's till length 6 hex_value = "{0:06x}".format(params['color']['spectrumRGB']) @@ -298,7 +298,7 @@ async def execute(self, command, params): await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_HS_COLOR: color - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -355,7 +355,7 @@ def can_execute(self, command, params): return (command in self.commands and 'temperature' in params.get('color', {})) - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a color temperature command.""" temp = color_util.color_temperature_kelvin_to_mired( params['color']['temperature']) @@ -371,7 +371,7 @@ async def execute(self, command, params): await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp, - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -400,13 +400,14 @@ def query_attributes(self): """Return scene query attributes.""" return {} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a scene command.""" # Don't block for scripts as they can be slow. await self.hass.services.async_call( self.state.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=self.state.domain != script.DOMAIN) + }, blocking=self.state.domain != script.DOMAIN, + context=data.context) @register_trait @@ -434,12 +435,12 @@ def query_attributes(self): """Return dock query attributes.""" return {'isDocked': self.state.state == vacuum.STATE_DOCKED} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a dock command.""" await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -473,30 +474,30 @@ def query_attributes(self): 'isPaused': self.state.state == vacuum.STATE_PAUSED, } - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a StartStop command.""" if command == COMMAND_STARTSTOP: if params['start']: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_START, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) else: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_STOP, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_PAUSEUNPAUSE: if params['pause']: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_PAUSE, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) else: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_START, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -584,7 +585,7 @@ def query_attributes(self): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a temperature point or mode command.""" # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit @@ -608,7 +609,7 @@ async def execute(self, command, params): climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: temp_high = temp_util.convert( @@ -640,7 +641,7 @@ async def execute(self, command, params): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_TARGET_TEMP_HIGH: temp_high, climate.ATTR_TARGET_TEMP_LOW: temp_low, - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_THERMOSTAT_SET_MODE: await self.hass.services.async_call( @@ -648,7 +649,7 @@ async def execute(self, command, params): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_OPERATION_MODE: self.google_to_hass[params['thermostatMode']], - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -681,7 +682,7 @@ def can_execute(self, command, params): allowed_unlock = not params['lock'] and self.config.allow_unlock return params['lock'] or allowed_unlock - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an LockUnlock command.""" if params['lock']: service = lock.SERVICE_LOCK @@ -690,7 +691,7 @@ async def execute(self, command, params): await self.hass.services.async_call(lock.DOMAIN, service, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -760,13 +761,13 @@ def query_attributes(self): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an SetFanSpeed command.""" await self.hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_SPEED, { ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params['fanSpeed'] - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -934,7 +935,7 @@ def query_attributes(self): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an SetModes command.""" settings = params.get('updateModeSettings') requested_source = settings.get( @@ -951,4 +952,4 @@ async def execute(self, command, params): media_player.SERVICE_SELECT_SOURCE, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_INPUT_SOURCE: source - }, blocking=True) + }, blocking=True, context=data.context) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 76fb7b5ddded55..302e8d8674f710 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,7 +1,7 @@ """Test Google Smart Home.""" import pytest -from homeassistant.core import State +from homeassistant.core import State, EVENT_CALL_SERVICE from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) from homeassistant.setup import async_setup_component @@ -19,8 +19,7 @@ BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False, - agent_user_id='test-agent', + allow_unlock=False ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -56,7 +55,6 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', allow_unlock=False, - agent_user_id='test-agent', entity_config={ 'light.demo_light': { const.CONF_ROOM_HINT: 'Living Room', @@ -68,12 +66,14 @@ async def test_sync_message(hass): events = [] hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) - result = await sh.async_handle_message(hass, config, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, config, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -114,6 +114,7 @@ async def test_sync_message(hass): } +# pylint: disable=redefined-outer-name async def test_sync_in_area(hass, registries): """Test a sync message where room hint comes from area.""" area = registries.area.async_create("Living Room") @@ -142,19 +143,20 @@ async def test_sync_in_area(hass, registries): config = helpers.Config( should_expose=lambda _: True, allow_unlock=False, - agent_user_id='test-agent', entity_config={} ) events = [] hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) - result = await sh.async_handle_message(hass, config, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, config, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -216,21 +218,23 @@ async def test_query_message(hass): events = [] hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append) - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.QUERY", - "payload": { - "devices": [{ - "id": "light.demo_light", - }, { - "id": "light.another_light", - }, { - "id": "light.non_existing", - }] - } - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.QUERY", + "payload": { + "devices": [{ + "id": "light.demo_light", + }, { + "id": "light.another_light", + }, { + "id": "light.non_existing", + }] + } + }] + }) assert result == { 'requestId': REQ_ID, @@ -280,39 +284,44 @@ async def test_execute(hass): 'light': {'platform': 'demo'} }) - events = [] - hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) - await hass.services.async_call( 'light', 'turn_off', {'entity_id': 'light.ceiling_lights'}, blocking=True) - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [{ - "devices": [ - {"id": "light.non_existing"}, - {"id": "light.ceiling_lights"}, - ], - "execution": [{ - "command": "action.devices.commands.OnOff", - "params": { - "on": True - } - }, { - "command": - "action.devices.commands.BrightnessAbsolute", - "params": { - "brightness": 20 - } + events = [] + hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) + + service_events = [] + hass.bus.async_listen(EVENT_CALL_SERVICE, service_events.append) + + result = await sh.async_handle_message( + hass, BASIC_CONFIG, None, + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + ], + "execution": [{ + "command": "action.devices.commands.OnOff", + "params": { + "on": True + } + }, { + "command": + "action.devices.commands.BrightnessAbsolute", + "params": { + "brightness": 20 + } + }] }] - }] - } - }] - }) + } + }] + }) assert result == { "requestId": REQ_ID, @@ -383,6 +392,24 @@ async def test_execute(hass): } } + assert len(service_events) == 2 + assert service_events[0].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': {'entity_id': 'light.ceiling_lights'} + } + assert service_events[0].context == events[2].context + assert service_events[1].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': { + 'brightness_pct': 20, + 'entity_id': 'light.ceiling_lights' + } + } + assert service_events[1].context == events[2].context + assert service_events[1].context == events[3].context + async def test_raising_error_trait(hass): """Test raising an error while executing a trait command.""" @@ -397,26 +424,28 @@ async def test_raising_error_trait(hass): hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) await hass.async_block_till_done() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [{ - "devices": [ - {"id": "climate.bla"}, - ], - "execution": [{ - "command": "action.devices.commands." - "ThermostatTemperatureSetpoint", - "params": { - "thermostatTemperatureSetpoint": 10 - } + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "climate.bla"}, + ], + "execution": [{ + "command": "action.devices.commands." + "ThermostatTemperatureSetpoint", + "params": { + "thermostatTemperatureSetpoint": 10 + } + }] }] - }] - } - }] - }) + } + }] + }) assert result == { "requestId": REQ_ID, @@ -446,6 +475,7 @@ async def test_raising_error_trait(hass): async def test_serialize_input_boolean(hass): """Test serializing an input boolean entity.""" state = State('input_boolean.bla', 'on') + # pylint: disable=protected-access entity = sh._GoogleEntity(hass, BASIC_CONFIG, state) result = await entity.sync_serialize() assert result == { @@ -466,15 +496,17 @@ async def test_unavailable_state_doesnt_sync(hass): ) light.hass = hass light.entity_id = 'light.demo_light' - light._available = False + light._available = False # pylint: disable=protected-access await light.async_update_ha_state() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -495,12 +527,14 @@ async def test_empty_name_doesnt_sync(hass): light.entity_id = 'light.demo_light' await light.async_update_ha_state() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -513,11 +547,13 @@ async def test_empty_name_doesnt_sync(hass): async def test_query_disconnect(hass): """Test a disconnect message.""" - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - 'inputs': [ - {'intent': 'action.devices.DISCONNECT'} - ], - 'requestId': REQ_ID - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + 'inputs': [ + {'intent': 'action.devices.DISCONNECT'} + ], + 'requestId': REQ_ID + }) assert result is None diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index e051a5de4da462..301de9c8c256b8 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -19,19 +19,25 @@ from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE) -from homeassistant.core import State, DOMAIN as HA_DOMAIN +from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE from homeassistant.util import color from tests.common import async_mock_service BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False, - agent_user_id='test-agent', + allow_unlock=False +) + +REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' + +BASIC_DATA = helpers.RequestData( + BASIC_CONFIG, + 'test-agent', + REQ_ID, ) UNSAFE_CONFIG = helpers.Config( should_expose=lambda state: True, - agent_user_id='test-agent', allow_unlock=True, ) @@ -51,16 +57,28 @@ async def test_brightness_light(hass): 'brightness': 95 } + events = [] + hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 50 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 50}) + await hass.async_block_till_done() + assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', light.ATTR_BRIGHTNESS_PCT: 50 } + assert len(events) == 1 + assert events[0].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': {'brightness_pct': 50, 'entity_id': 'light.bla'} + } + async def test_brightness_cover(hass): """Test brightness trait support for cover domain.""" @@ -79,9 +97,9 @@ async def test_brightness_cover(hass): calls = async_mock_service( hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 50 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 50}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -107,9 +125,9 @@ async def test_brightness_media_player(hass): calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 60 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 60}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -137,18 +155,18 @@ async def test_onoff_group(hass): } on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', } off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', @@ -176,9 +194,9 @@ async def test_onoff_input_boolean(hass): } on_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -186,9 +204,9 @@ async def test_onoff_input_boolean(hass): off_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -216,18 +234,18 @@ async def test_onoff_switch(hass): } on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', } off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', @@ -252,18 +270,18 @@ async def test_onoff_fan(hass): } on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', } off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', @@ -290,18 +308,18 @@ async def test_onoff_light(hass): } on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', } off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -329,9 +347,9 @@ async def test_onoff_cover(hass): } on_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -339,9 +357,9 @@ async def test_onoff_cover(hass): off_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -369,9 +387,9 @@ async def test_onoff_media_player(hass): } on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -380,9 +398,9 @@ async def test_onoff_media_player(hass): off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -410,9 +428,9 @@ async def test_onoff_climate(hass): } on_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -421,9 +439,9 @@ async def test_onoff_climate(hass): off_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -445,7 +463,8 @@ async def test_dock_vacuum(hass): calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_RETURN_TO_BASE) - await trt.execute(trait.COMMAND_DOCK, {}) + await trt.execute( + trait.COMMAND_DOCK, BASIC_DATA, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -469,7 +488,7 @@ async def test_startstop_vacuum(hass): start_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_STARTSTOP, {'start': True}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}) assert len(start_calls) == 1 assert start_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -477,7 +496,7 @@ async def test_startstop_vacuum(hass): stop_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_STOP) - await trt.execute(trait.COMMAND_STARTSTOP, {'start': False}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}) assert len(stop_calls) == 1 assert stop_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -485,7 +504,7 @@ async def test_startstop_vacuum(hass): pause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_PAUSE) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, {'pause': True}) + await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}) assert len(pause_calls) == 1 assert pause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -493,7 +512,7 @@ async def test_startstop_vacuum(hass): unpause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, {'pause': False}) + await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}) assert len(unpause_calls) == 1 assert unpause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -532,7 +551,7 @@ async def test_color_spectrum_light(hass): }) calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'spectrumRGB': 1052927 } @@ -581,14 +600,14 @@ async def test_color_temperature_light(hass): calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) with pytest.raises(helpers.SmartHomeError) as err: - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 5555 } }) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 2857 } @@ -626,7 +645,7 @@ async def test_scene_scene(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'scene.bla', @@ -643,7 +662,7 @@ async def test_scene_script(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) # We don't wait till script execution is done. await hass.async_block_till_done() @@ -695,10 +714,11 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, { - 'thermostatTemperatureSetpointHigh': 25, - 'thermostatTemperatureSetpointLow': 20, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, BASIC_DATA, { + 'thermostatTemperatureSetpointHigh': 25, + 'thermostatTemperatureSetpointLow': 20, + }) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -708,7 +728,7 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) - await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, { + await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'heatcool', }) assert len(calls) == 1 @@ -718,9 +738,9 @@ async def test_temperature_setting_climate_range(hass): } with pytest.raises(helpers.SmartHomeError) as err: - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': -100, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': -100}) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE hass.config.units.temperature_unit = TEMP_CELSIUS @@ -762,13 +782,13 @@ async def test_temperature_setting_climate_setpoint(hass): hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) with pytest.raises(helpers.SmartHomeError): - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': -100, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': -100}) - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': 19, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': 19}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -793,7 +813,7 @@ async def test_lock_unlock_lock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) + await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': True}) assert len(calls) == 1 assert calls[0].data == { @@ -830,7 +850,7 @@ async def test_lock_unlock_unlock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}) assert len(calls) == 1 assert calls[0].data == { @@ -910,7 +930,8 @@ async def test_fan_speed(hass): trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) - await trt.execute(trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) + await trt.execute( + trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}) assert len(calls) == 1 assert calls[0].data == { @@ -995,7 +1016,7 @@ async def test_modes(hass): calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE) await trt.execute( - trait.COMMAND_MODES, params={ + trait.COMMAND_MODES, BASIC_DATA, { 'updateModeSettings': { trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' }})