diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f47eae74986..eadb1731bd0 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -17,7 +17,9 @@ from homeassistant.util.aiohttp import MockRequest from . import utils from .const import ( - CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE) + CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE, + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) from .prefs import CloudPreferences @@ -98,12 +100,26 @@ class CloudClient(Interface): if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return google_conf['filter'](entity.entity_id) + if not google_conf['filter'].empty_filter: + return google_conf['filter'](entity.entity_id) + + entity_configs = self.prefs.google_entity_configs + entity_config = entity_configs.get(entity.entity_id, {}) + return entity_config.get( + PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + def should_2fa(entity): + """If an entity should be checked for 2FA.""" + entity_configs = self.prefs.google_entity_configs + entity_config = entity_configs.get(entity.entity_id, {}) + return not entity_config.get( + PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) username = self._hass.data[DOMAIN].claims["cognito:username"] self._google_config = ga_h.Config( should_expose=should_expose, + should_2fa=should_2fa, secure_devices_pin=self._prefs.google_secure_devices_pin, entity_config=google_conf.get(CONF_ENTITY_CONFIG), agent_user_id=username, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 5002286edb9..e2f4b9c0785 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -8,6 +8,13 @@ PREF_ENABLE_REMOTE = 'remote_enabled' PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin' PREF_CLOUDHOOKS = 'cloudhooks' PREF_CLOUD_USER = 'cloud_user' +PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs' +PREF_OVERRIDE_NAME = 'override_name' +PREF_DISABLE_2FA = 'disable_2fa' +PREF_ALIASES = 'aliases' +PREF_SHOULD_EXPOSE = 'should_expose' +DEFAULT_SHOULD_EXPOSE = True +DEFAULT_DISABLE_2FA = False CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 40d19c198be..e6151a917af 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -14,8 +14,7 @@ from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import ( - const as google_const) +from homeassistant.components.google_assistant import helpers as google_helpers from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, @@ -81,6 +80,12 @@ async def async_setup(hass): websocket_remote_connect) hass.components.websocket_api.async_register_command( websocket_remote_disconnect) + + hass.components.websocket_api.async_register_command( + google_assistant_list) + hass.components.websocket_api.async_register_command( + google_assistant_update) + hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -411,7 +416,6 @@ def _account_data(cloud): 'cloud': cloud.iot.state, 'prefs': client.prefs.as_dict(), 'google_entities': client.google_user_config['filter'].config, - 'google_domains': list(google_const.DOMAIN_TO_GOOGLE_TYPES), 'alexa_entities': client.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), 'remote_domain': remote.instance_domain, @@ -448,3 +452,55 @@ async def websocket_remote_disconnect(hass, connection, msg): await cloud.client.prefs.async_update(remote_enabled=False) await cloud.remote.disconnect() connection.send_result(msg['id'], _account_data(cloud)) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/google_assistant/entities' +}) +async def google_assistant_list(hass, connection, msg): + """List all google assistant entities.""" + cloud = hass.data[DOMAIN] + entities = google_helpers.async_get_entities( + hass, cloud.client.google_config + ) + + result = [] + + for entity in entities: + result.append({ + 'entity_id': entity.entity_id, + 'traits': [trait.name for trait in entity.traits()], + 'might_2fa': entity.might_2fa(), + }) + + connection.send_result(msg['id'], result) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/google_assistant/entities/update', + 'entity_id': str, + vol.Optional('should_expose'): bool, + vol.Optional('override_name'): str, + vol.Optional('aliases'): [str], + vol.Optional('disable_2fa'): bool, +}) +async def google_assistant_update(hass, connection, msg): + """List all google assistant entities.""" + cloud = hass.data[DOMAIN] + changes = dict(msg) + changes.pop('type') + changes.pop('id') + + await cloud.client.prefs.async_update_google_entity_config(**changes) + + connection.send_result( + msg['id'], + cloud.client.prefs.google_entity_configs.get(msg['entity_id'])) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 0e2abae15b0..0f45f25c49b 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -4,6 +4,8 @@ from ipaddress import ip_address from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, + PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA, + PREF_ALIASES, PREF_SHOULD_EXPOSE, InvalidTrustedNetworks) STORAGE_KEY = DOMAIN @@ -30,6 +32,7 @@ class CloudPreferences: PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_CLOUDHOOKS: {}, PREF_CLOUD_USER: None, } @@ -39,7 +42,7 @@ class CloudPreferences: async def async_update(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF, google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF, - cloud_user=_UNDEF): + cloud_user=_UNDEF, google_entity_configs=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), @@ -48,6 +51,7 @@ class CloudPreferences: (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), + (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), ): if value is not _UNDEF: self._prefs[key] = value @@ -57,9 +61,48 @@ class CloudPreferences: await self._store.async_save(self._prefs) + async def async_update_google_entity_config( + self, *, entity_id, override_name=_UNDEF, disable_2fa=_UNDEF, + aliases=_UNDEF, should_expose=_UNDEF): + """Update config for a Google entity.""" + entities = self.google_entity_configs + entity = entities.get(entity_id, {}) + + changes = {} + for key, value in ( + (PREF_OVERRIDE_NAME, override_name), + (PREF_DISABLE_2FA, disable_2fa), + (PREF_ALIASES, aliases), + (PREF_SHOULD_EXPOSE, should_expose), + ): + if value is not _UNDEF: + changes[key] = value + + if not changes: + return + + updated_entity = { + **entity, + **changes, + } + + updated_entities = { + **entities, + entity_id: updated_entity, + } + await self.async_update(google_entity_configs=updated_entities) + def as_dict(self): """Return dictionary version.""" - return self._prefs + return { + PREF_ENABLE_ALEXA: self.alexa_enabled, + PREF_ENABLE_GOOGLE: self.google_enabled, + PREF_ENABLE_REMOTE: self.remote_enabled, + PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, + PREF_CLOUDHOOKS: self.cloudhooks, + PREF_CLOUD_USER: self.cloud_user, + } @property def remote_enabled(self): @@ -89,6 +132,11 @@ class CloudPreferences: """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) + @property + def google_entity_configs(self): + """Return Google Entity configurations.""" + return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property def cloudhooks(self): """Return the published cloud webhooks.""" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 4d3f2855b31..770a502ad5d 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,17 +1,18 @@ """Helper classes for Google Assistant integration.""" from asyncio import gather from collections.abc import Mapping +from typing import List from homeassistant.core import Context, callback from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES, - ATTR_DEVICE_CLASS + ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES ) from . import trait from .const import ( DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED, - DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT, + DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT ) from .error import SmartHomeError @@ -21,15 +22,20 @@ class Config: def __init__(self, should_expose, entity_config=None, secure_devices_pin=None, - agent_user_id=None): + agent_user_id=None, should_2fa=None): """Initialize the configuration.""" self.should_expose = should_expose self.entity_config = entity_config or {} self.secure_devices_pin = secure_devices_pin + self._should_2fa = should_2fa # Agent User Id to use for query responses self.agent_user_id = agent_user_id + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + return self._should_2fa is None or self._should_2fa(state) + class RequestData: """Hold data associated with a particular request.""" @@ -79,6 +85,22 @@ class GoogleEntity: if Trait.supported(domain, features, device_class)] return self._traits + @callback + def is_supported(self) -> bool: + """Return if the entity is supported by Google.""" + return self.state.state != STATE_UNAVAILABLE and bool(self.traits()) + + @callback + def might_2fa(self) -> bool: + """Return if the entity might encounter 2FA.""" + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + return any(trait.might_2fa(domain, features, device_class) + for trait in self.traits()) + async def sync_serialize(self): """Serialize entity for a SYNC response. @@ -86,27 +108,13 @@ class GoogleEntity: """ state = self.state - # When a state is unavailable, the attributes that describe - # capabilities will be stripped. For example, a light entity will miss - # the min/max mireds. Therefore they will be excluded from a sync. - if state.state == STATE_UNAVAILABLE: - return None - entity_config = self.config.entity_config.get(state.entity_id, {}) name = (entity_config.get(CONF_NAME) or state.name).strip() domain = state.domain device_class = state.attributes.get(ATTR_DEVICE_CLASS) - # If an empty string - if not name: - return None - traits = self.traits() - # Found no supported traits for this entity - if not traits: - return None - device_type = get_google_type(domain, device_class) @@ -213,3 +221,19 @@ def deep_update(target, source): else: target[key] = value return target + + +@callback +def async_get_entities(hass, config) -> List[GoogleEntity]: + """Return all entities that are supported by Google.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + entity = GoogleEntity(hass, config, state) + + if entity.is_supported(): + entities.append(entity) + + return entities diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1ec47bbedd6..07548ee95eb 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,17 +1,17 @@ """Support for Google Assistant Smart Home API.""" +import asyncio from itertools import product import logging from homeassistant.util.decorator import Registry -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, ATTR_ENTITY_ID) +from homeassistant.const import ATTR_ENTITY_ID from .const import ( ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR, EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED ) -from .helpers import RequestData, GoogleEntity +from .helpers import RequestData, GoogleEntity, async_get_entities from .error import SmartHomeError HANDLERS = Registry() @@ -81,22 +81,11 @@ async def async_devices_sync(hass, data, payload): {'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 data.config.should_expose(state): - continue - - entity = GoogleEntity(hass, data.config, state) - serialized = await entity.sync_serialize() - - if serialized is None: - _LOGGER.debug("No mapping for %s domain", entity.state) - continue - - devices.append(serialized) + devices = await asyncio.gather(*[ + entity.sync_serialize() for entity in + async_get_entities(hass, data.config) + if data.config.should_expose(entity.state) + ]) response = { 'agentUserId': data.config.agent_user_id or data.context.user_id, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index cb2bf688ad0..f9590a07b95 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -104,6 +104,11 @@ class _Trait: commands = [] + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return False + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass @@ -732,6 +737,11 @@ class LockUnlockTrait(_Trait): """Test if state is supported.""" return domain == lock.DOMAIN + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return True + def sync_attributes(self): """Return LockUnlock attributes for a sync request.""" return {} @@ -745,7 +755,7 @@ class LockUnlockTrait(_Trait): if params['lock']: service = lock.SERVICE_LOCK else: - _verify_pin_challenge(data, challenge) + _verify_pin_challenge(data, self.state, challenge) service = lock.SERVICE_UNLOCK await self.hass.services.async_call(lock.DOMAIN, service, { @@ -1021,6 +1031,9 @@ class OpenCloseTrait(_Trait): https://developers.google.com/actions/smarthome/traits/openclose """ + # Cover device classes that require 2FA + COVER_2FA = (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE) + name = TRAIT_OPENCLOSE commands = [ COMMAND_OPENCLOSE @@ -1042,6 +1055,12 @@ class OpenCloseTrait(_Trait): binary_sensor.DEVICE_CLASS_WINDOW, ) + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return (domain == cover.DOMAIN and + device_class in OpenCloseTrait.COVER_2FA) + def sync_attributes(self): """Return opening direction.""" response = {} @@ -1114,9 +1133,8 @@ class OpenCloseTrait(_Trait): if (should_verify and self.state.attributes.get(ATTR_DEVICE_CLASS) - in (cover.DEVICE_CLASS_DOOR, - cover.DEVICE_CLASS_GARAGE)): - _verify_pin_challenge(data, challenge) + in OpenCloseTrait.COVER_2FA): + _verify_pin_challenge(data, self.state, challenge) await self.hass.services.async_call( cover.DOMAIN, service, svc_params, @@ -1202,8 +1220,11 @@ class VolumeTrait(_Trait): ERR_NOT_SUPPORTED, 'Command not supported') -def _verify_pin_challenge(data, challenge): +def _verify_pin_challenge(data, state, challenge): """Verify a pin challenge.""" + if not data.config.should_2fa(state): + return + if not data.config.secure_devices_pin: raise SmartHomeError( ERR_CHALLENGE_NOT_SETUP, 'Challenge is not set up') @@ -1217,7 +1238,7 @@ def _verify_pin_challenge(data, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, challenge): +def _verify_ack_challenge(data, state, challenge): """Verify a pin challenge.""" if not challenge or not challenge.get('ack'): raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 7db577dfdc6..590aba02670 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,5 +1,5 @@ """Helper class to implement include/exclude of entities and domains.""" -from typing import Callable, Dict, Iterable +from typing import Callable, Dict, List import voluptuous as vol @@ -12,7 +12,7 @@ CONF_EXCLUDE_DOMAINS = 'exclude_domains' CONF_EXCLUDE_ENTITIES = 'exclude_entities' -def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]: +def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]: filt = generate_filter( config[CONF_INCLUDE_DOMAINS], config[CONF_INCLUDE_ENTITIES], @@ -20,6 +20,8 @@ def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]: config[CONF_EXCLUDE_ENTITIES], ) setattr(filt, 'config', config) + setattr( + filt, 'empty_filter', sum(len(val) for val in config.values()) == 0) return filt @@ -34,10 +36,10 @@ FILTER_SCHEMA = vol.All( }), _convert_filter) -def generate_filter(include_domains: Iterable[str], - include_entities: Iterable[str], - exclude_domains: Iterable[str], - exclude_entities: Iterable[str]) -> Callable[[str], bool]: +def generate_filter(include_domains: List[str], + include_entities: List[str], + exclude_domains: List[str], + exclude_entities: List[str]) -> Callable[[str], bool]: """Return a function that will filter entities based on the args.""" include_d = set(include_domains) include_e = set(include_entities) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 4440651d089..fa1d8cf8b9b 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -2,9 +2,12 @@ from unittest.mock import patch, MagicMock from aiohttp import web +import jwt import pytest +from homeassistant.core import State from homeassistant.setup import async_setup_component +from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa @@ -19,6 +22,25 @@ def mock_cloud(): return MagicMock(subscription_expired=False) +@pytest.fixture +async def mock_cloud_setup(hass): + """Set up the cloud.""" + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + assert await async_setup_component(hass, 'cloud', { + 'cloud': {} + }) + + +@pytest.fixture +def mock_cloud_login(hass, mock_cloud_setup): + """Mock cloud is logged in.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03', + 'cognito:username': 'abcdefghjkl', + }, 'test') + + async def test_handler_alexa(hass): """Test handler Alexa.""" hass.states.async_set( @@ -197,3 +219,35 @@ async def test_webhook_msg(hass): assert await received[0].json() == { 'hello': 'world' } + + +async def test_google_config_expose_entity( + hass, mock_cloud_setup, mock_cloud_login): + """Test Google config exposing entity method uses latest config.""" + cloud_client = hass.data[DOMAIN].client + state = State('light.kitchen', 'on') + + assert cloud_client.google_config.should_expose(state) + + await cloud_client.prefs.async_update_google_entity_config( + entity_id='light.kitchen', + should_expose=False, + ) + + assert not cloud_client.google_config.should_expose(state) + + +async def test_google_config_should_2fa( + hass, mock_cloud_setup, mock_cloud_login): + """Test Google config disabling 2FA method uses latest config.""" + cloud_client = hass.data[DOMAIN].client + state = State('light.kitchen', 'on') + + assert cloud_client.google_config.should_2fa(state) + + await cloud_client.prefs.async_update_google_entity_config( + entity_id='light.kitchen', + disable_2fa=True, + ) + + assert not cloud_client.google_config.should_2fa(state) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 4aebc5679a0..5ccaba14be6 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,10 +7,13 @@ from jose import jwt from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED +from homeassistant.core import State from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.cloud.const import ( PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN, DOMAIN) +from homeassistant.components.google_assistant.helpers import ( + GoogleEntity, Config) from tests.common import mock_coro @@ -32,7 +35,8 @@ def mock_cloud_login(hass, setup_api): """Mock cloud is logged in.""" hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' + 'custom:sub-exp': '2018-01-03', + 'cognito:username': 'abcdefghjkl', }, 'test') @@ -349,7 +353,15 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', - 'prefs': mock_cloud_fixture, + 'prefs': { + 'alexa_enabled': True, + 'cloud_user': None, + 'cloudhooks': {}, + 'google_enabled': True, + 'google_entity_configs': {}, + 'google_secure_devices_pin': None, + 'remote_enabled': False, + }, 'alexa_entities': { 'include_domains': [], 'include_entities': ['light.kitchen', 'switch.ac'], @@ -363,7 +375,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, 'exclude_domains': [], 'exclude_entities': [], }, - 'google_domains': ['light'], 'remote_domain': None, 'remote_connected': False, 'remote_certificate': None, @@ -689,3 +700,52 @@ async def test_enabling_remote_trusted_networks_other( assert cloud.client.remote_autostart assert len(mock_connect.mock_calls) == 1 + + +async def test_list_google_entities( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that we can list Google entities.""" + client = await hass_ws_client(hass) + entity = GoogleEntity(hass, Config(lambda *_: False), State( + 'light.kitchen', 'on' + )) + with patch('homeassistant.components.google_assistant.helpers' + '.async_get_entities', return_value=[entity]): + await client.send_json({ + 'id': 5, + 'type': 'cloud/google_assistant/entities', + }) + response = await client.receive_json() + + assert response['success'] + assert len(response['result']) == 1 + assert response['result'][0] == { + 'entity_id': 'light.kitchen', + 'might_2fa': False, + 'traits': ['action.devices.traits.OnOff'], + } + + +async def test_update_google_entity( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test that we can update config of a Google entity.""" + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'cloud/google_assistant/entities/update', + 'entity_id': 'light.kitchen', + 'should_expose': False, + 'override_name': 'updated name', + 'aliases': ['lefty', 'righty'], + 'disable_2fa': False, + }) + response = await client.receive_json() + + assert response['success'] + prefs = hass.data[DOMAIN].client.prefs + assert prefs.google_entity_configs['light.kitchen'] == { + 'should_expose': False, + 'override_name': 'updated name', + 'aliases': ['lefty', 'righty'], + 'disable_2fa': False, + } diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 519a55fbc00..a65387d48a2 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -530,34 +530,6 @@ async def test_unavailable_state_doesnt_sync(hass): } -async def test_empty_name_doesnt_sync(hass): - """Test that an entity with empty name does not sync over.""" - light = DemoLight( - None, ' ', - state=False, - ) - light.hass = hass - light.entity_id = 'light.demo_light' - await light.async_update_ha_state() - - result = await sh.async_handle_message( - hass, BASIC_CONFIG, 'test-agent', - { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) - - assert result == { - 'requestId': REQ_ID, - 'payload': { - 'agentUserId': 'test-agent', - 'devices': [] - } - } - - @pytest.mark.parametrize("device_class,google_type", [ ('non_existing_class', 'action.devices.types.SWITCH'), ('switch', 'action.devices.types.SWITCH'), diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 5e6dadf14f4..28cab008201 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -843,6 +843,8 @@ async def test_lock_unlock_lock(hass): assert helpers.get_google_type(lock.DOMAIN, None) is not None assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, + None) trt = trait.LockUnlockTrait(hass, State('lock.front_door', lock.STATE_LOCKED), @@ -922,6 +924,13 @@ async def test_lock_unlock_unlock(hass): assert len(calls) == 1 assert err.value.code == const.ERR_CHALLENGE_NOT_SETUP + # Test with 2FA override + with patch('homeassistant.components.google_assistant.helpers' + '.Config.should_2fa', return_value=False): + await trt.execute( + trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {}) + assert len(calls) == 2 + async def test_fan_speed(hass): """Test FanSpeed trait speed control support for fan domain.""" @@ -1216,6 +1225,8 @@ async def test_openclose_cover_secure(hass, device_class): assert helpers.get_google_type(cover.DOMAIN, device_class) is not None assert trait.OpenCloseTrait.supported( cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class) + assert trait.OpenCloseTrait.might_2fa( + cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class) trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, { ATTR_DEVICE_CLASS: device_class,