diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 3afb0ce2e86..1bb74053ea4 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -14,18 +14,13 @@ from homeassistant.components.alexa import ( state_report as alexa_state_report, ) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, HTTP_BAD_REQUEST -from homeassistant.core import callback +from homeassistant.core import callback, split_entity_id from homeassistant.helpers import entity_registry from homeassistant.helpers.event import async_call_later from homeassistant.util.dt import utcnow -from .const import ( - CONF_ENTITY_CONFIG, - CONF_FILTER, - DEFAULT_SHOULD_EXPOSE, - PREF_SHOULD_EXPOSE, - RequireRelink, -) +from .const import CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, RequireRelink +from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -37,7 +32,7 @@ SYNC_DELAY = 1 class AlexaConfig(alexa_config.AbstractConfig): """Alexa Configuration.""" - def __init__(self, hass, config, prefs, cloud): + def __init__(self, hass, config, prefs: CloudPreferences, cloud): """Initialize the Alexa config.""" super().__init__(hass) self._config = config @@ -46,6 +41,7 @@ class AlexaConfig(alexa_config.AbstractConfig): self._token = None self._token_valid = None self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_default_expose = prefs.alexa_default_expose self._alexa_sync_unsub = None self._endpoint = None @@ -99,7 +95,17 @@ class AlexaConfig(alexa_config.AbstractConfig): entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) - return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + if entity_expose is not None: + return entity_expose + + default_expose = self._prefs.alexa_default_expose + + # Backwards compat + if default_expose is None: + return True + + return split_entity_id(entity_id)[0] in default_expose @callback def async_invalidate_access_token(self): @@ -147,16 +153,24 @@ class AlexaConfig(alexa_config.AbstractConfig): await self.async_sync_entities() return - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. + # If user has filter in config.yaml, don't sync. + if not self._config[CONF_FILTER].empty_filter: + return + + # If entity prefs are the same, don't sync. if ( self._cur_entity_prefs is prefs.alexa_entity_configs - or not self._config[CONF_FILTER].empty_filter + and self._cur_default_expose is prefs.alexa_default_expose ): return if self._alexa_sync_unsub: self._alexa_sync_unsub() + self._alexa_sync_unsub = None + + if self._cur_default_expose is not prefs.alexa_default_expose: + await self.async_sync_entities() + return self._alexa_sync_unsub = async_call_later( self.hass, SYNC_DELAY, self._sync_prefs diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 3d930f0c2e5..3c7804970fb 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -18,10 +18,25 @@ PREF_ALIASES = "aliases" PREF_SHOULD_EXPOSE = "should_expose" PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" PREF_USERNAME = "username" -DEFAULT_SHOULD_EXPOSE = True +PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" +PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = False DEFAULT_GOOGLE_REPORT_STATE = False +DEFAULT_EXPOSED_DOMAINS = [ + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "scene", + "script", + "sensor", + "switch", + "vacuum", + "water_heater", +] CONF_ALEXA = "alexa" CONF_ALIASES = "aliases" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 9b94b77ca45..882124af45c 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -11,16 +11,16 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, HTTP_OK, ) -from homeassistant.core import CoreState, callback +from homeassistant.core import CoreState, callback, split_entity_id from homeassistant.helpers import entity_registry from .const import ( CONF_ENTITY_CONFIG, DEFAULT_DISABLE_2FA, - DEFAULT_SHOULD_EXPOSE, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) +from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" - def __init__(self, hass, config, cloud_user, prefs, cloud): + def __init__(self, hass, config, cloud_user, prefs: CloudPreferences, cloud): """Initialize the Google config.""" super().__init__(hass) self._config = config @@ -36,6 +36,7 @@ class CloudGoogleConfig(AbstractConfig): self._prefs = prefs self._cloud = cloud self._cur_entity_prefs = self._prefs.google_entity_configs + self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() self._sync_on_started = False @@ -104,7 +105,17 @@ class CloudGoogleConfig(AbstractConfig): entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) - return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + if entity_expose is not None: + return entity_expose + + default_expose = self._prefs.google_default_expose + + # Backwards compat + if default_expose is None: + return True + + return split_entity_id(entity_id)[0] in default_expose @property def agent_user_id(self): @@ -153,8 +164,8 @@ class CloudGoogleConfig(AbstractConfig): # don't sync. elif ( self._cur_entity_prefs is not prefs.google_entity_configs - and self._config["filter"].empty_filter - ): + or self._cur_default_expose is not prefs.google_default_expose + ) and self._config["filter"].empty_filter: self.async_schedule_google_sync_all() if self.enabled and not self.is_local_sdk_active: @@ -162,6 +173,9 @@ class CloudGoogleConfig(AbstractConfig): elif not self.enabled and self.is_local_sdk_active: self.async_disable_local_sdk() + self._cur_entity_prefs = prefs.google_entity_configs + self._cur_default_expose = prefs.google_default_expose + async def _handle_entity_registry_updated(self, event): """Handle when entity registry updated.""" if not self.enabled or not self._cloud.is_logged_in: diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6589f2b43f5..00a2ddb4663 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -24,9 +24,11 @@ from homeassistant.core import callback from .const import ( DOMAIN, + PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, REQUEST_TIMEOUT, @@ -371,6 +373,8 @@ async def websocket_subscription(hass, connection, msg): vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, + vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str], + vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str], vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), } ) @@ -514,7 +518,7 @@ async def google_assistant_list(hass, connection, msg): { "type": "cloud/google_assistant/entities/update", "entity_id": str, - vol.Optional("should_expose"): bool, + vol.Optional("should_expose"): vol.Any(None, bool), vol.Optional("override_name"): str, vol.Optional("aliases"): [str], vol.Optional("disable_2fa"): bool, @@ -566,7 +570,7 @@ async def alexa_list(hass, connection, msg): { "type": "cloud/alexa/entities/update", "entity_id": str, - vol.Optional("should_expose"): bool, + vol.Optional("should_expose"): vol.Any(None, bool), } ) async def alexa_update(hass, connection, msg): diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index a7d1b59fd39..0a41f8e2a8f 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,6 @@ """Preference management for cloud.""" from ipaddress import ip_address -from typing import Optional +from typing import List, Optional from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User @@ -9,8 +9,10 @@ from homeassistant.util.logging import async_create_catching_coro from .const import ( DEFAULT_ALEXA_REPORT_STATE, + DEFAULT_EXPOSED_DOMAINS, DEFAULT_GOOGLE_REPORT_STATE, DOMAIN, + PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, PREF_ALIASES, @@ -20,6 +22,7 @@ from .const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, + PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_ENTITY_CONFIGS, PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, @@ -81,6 +84,8 @@ class CloudPreferences: alexa_entity_configs=_UNDEF, alexa_report_state=_UNDEF, google_report_state=_UNDEF, + alexa_default_expose=_UNDEF, + google_default_expose=_UNDEF, ): """Update user preferences.""" prefs = {**self._prefs} @@ -96,6 +101,8 @@ class CloudPreferences: (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), (PREF_ALEXA_REPORT_STATE, alexa_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state), + (PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose), + (PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose), ): if value is not _UNDEF: prefs[key] = value @@ -185,15 +192,17 @@ class CloudPreferences: def as_dict(self): """Return dictionary version.""" return { + PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, + PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, + PREF_ALEXA_REPORT_STATE: self.alexa_report_state, + PREF_CLOUDHOOKS: self.cloudhooks, 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_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, - PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, - PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_GOOGLE_REPORT_STATE: self.google_report_state, - PREF_CLOUDHOOKS: self.cloudhooks, + PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, } @property @@ -219,6 +228,19 @@ class CloudPreferences: """Return if Alexa report state is enabled.""" return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) + @property + def alexa_default_expose(self) -> Optional[List[str]]: + """Return array of entity domains that are exposed by default to Alexa. + + Can return None, in which case for backwards should be interpreted as allow all domains. + """ + return self._prefs.get(PREF_ALEXA_DEFAULT_EXPOSE) + + @property + def alexa_entity_configs(self): + """Return Alexa Entity configurations.""" + return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property def google_enabled(self): """Return if Google is enabled.""" @@ -245,9 +267,12 @@ class CloudPreferences: return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] @property - def alexa_entity_configs(self): - """Return Alexa Entity configurations.""" - return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + def google_default_expose(self) -> Optional[List[str]]: + """Return array of entity domains that are exposed by default to Google. + + Can return None, in which case for backwards should be interpreted as allow all domains. + """ + return self._prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE) @property def cloudhooks(self): @@ -322,14 +347,16 @@ class CloudPreferences: def _empty_config(self, username): """Return an empty config.""" return { + PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, + PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_CLOUD_USER: None, + PREF_CLOUDHOOKS: {}, PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, - PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, - PREF_ALEXA_ENTITY_CONFIGS: {}, - PREF_CLOUDHOOKS: {}, - PREF_CLOUD_USER: None, - PREF_USERNAME: username, PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(), + PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_USERNAME: username, } diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index b064a5c9605..cbeda41dac7 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -12,13 +12,22 @@ from tests.common import async_fire_time_changed async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs): """Test Alexa config should expose using prefs.""" entity_conf = {"should_expose": False} - await cloud_prefs.async_update(alexa_entity_configs={"light.kitchen": entity_conf}) + await cloud_prefs.async_update( + alexa_entity_configs={"light.kitchen": entity_conf}, + alexa_default_expose=["light"], + ) conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) assert not conf.should_expose("light.kitchen") entity_conf["should_expose"] = True assert conf.should_expose("light.kitchen") + entity_conf["should_expose"] = None + assert conf.should_expose("light.kitchen") + + await cloud_prefs.async_update(alexa_default_expose=["sensor"],) + assert not conf.should_expose("light.kitchen") + async def test_alexa_config_report_state(hass, cloud_prefs): """Test Alexa config should expose using prefs.""" diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 5dd4afe883c..977df95051e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -1,9 +1,11 @@ """Test the Cloud Google Config.""" +import pytest + from homeassistant.components.cloud import GACTIONS_SCHEMA from homeassistant.components.cloud.google_config import CloudGoogleConfig from homeassistant.components.google_assistant import helpers as ga_helpers from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, HTTP_NOT_FOUND -from homeassistant.core import CoreState +from homeassistant.core import CoreState, State from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.util.dt import utcnow @@ -11,19 +13,24 @@ from tests.async_mock import AsyncMock, Mock, patch from tests.common import async_fire_time_changed -async def test_google_update_report_state(hass, cloud_prefs): - """Test Google config responds to updating preference.""" - config = CloudGoogleConfig( +@pytest.fixture +def mock_conf(hass, cloud_prefs): + """Mock Google conf.""" + return CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(claims={"cognito:username": "abcdefghjkl"}), ) - await config.async_initialize() - await config.async_connect_agent_user("mock-user-id") - with patch.object(config, "async_sync_entities") as mock_sync, patch( + +async def test_google_update_report_state(mock_conf, hass, cloud_prefs): + """Test Google config responds to updating preference.""" + await mock_conf.async_initialize() + await mock_conf.async_connect_agent_user("mock-user-id") + + with patch.object(mock_conf, "async_sync_entities") as mock_sync, patch( "homeassistant.components.google_assistant.report_state.async_enable_report_state" ) as mock_report_state: await cloud_prefs.async_update(google_report_state=True) @@ -161,3 +168,24 @@ async def test_google_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(mock_sync.mock_calls) == 1 + + +async def test_google_config_expose_entity_prefs(mock_conf, cloud_prefs): + """Test Google config should expose using prefs.""" + entity_conf = {"should_expose": False} + await cloud_prefs.async_update( + google_entity_configs={"light.kitchen": entity_conf}, + google_default_expose=["light"], + ) + + state = State("light.kitchen", "on") + + assert not mock_conf.should_expose(state) + entity_conf["should_expose"] = True + assert mock_conf.should_expose(state) + + entity_conf["should_expose"] = None + assert mock_conf.should_expose(state) + + await cloud_prefs.async_update(google_default_expose=["sensor"],) + assert not mock_conf.should_expose(state) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index aa59e935a86..a3c33b31ebb 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -355,6 +355,8 @@ async def test_websocket_status( "google_enabled": True, "google_entity_configs": {}, "google_secure_devices_pin": None, + "google_default_expose": None, + "alexa_default_expose": None, "alexa_entity_configs": {}, "alexa_report_state": False, "google_report_state": False, @@ -487,6 +489,8 @@ async def test_websocket_update_preferences( "alexa_enabled": False, "google_enabled": False, "google_secure_devices_pin": "1234", + "google_default_expose": ["light", "switch"], + "alexa_default_expose": ["sensor", "media_player"], } ) response = await client.receive_json() @@ -495,6 +499,8 @@ async def test_websocket_update_preferences( assert not setup_api.google_enabled assert not setup_api.alexa_enabled assert setup_api.google_secure_devices_pin == "1234" + assert setup_api.google_default_expose == ["light", "switch"] + assert setup_api.alexa_default_expose == ["sensor", "media_player"] async def test_websocket_update_preferences_require_relink( @@ -746,6 +752,25 @@ async def test_update_google_entity(hass, hass_ws_client, setup_api, mock_cloud_ "disable_2fa": False, } + await client.send_json( + { + "id": 6, + "type": "cloud/google_assistant/entities/update", + "entity_id": "light.kitchen", + "should_expose": None, + } + ) + response = await client.receive_json() + + assert response["success"] + prefs = hass.data[DOMAIN].client.prefs + assert prefs.google_entity_configs["light.kitchen"] == { + "should_expose": None, + "override_name": "updated name", + "aliases": ["lefty", "righty"], + "disable_2fa": False, + } + async def test_enabling_remote_trusted_proxies_local4( hass, hass_ws_client, setup_api, mock_cloud_login @@ -834,6 +859,20 @@ async def test_update_alexa_entity(hass, hass_ws_client, setup_api, mock_cloud_l prefs = hass.data[DOMAIN].client.prefs assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False} + await client.send_json( + { + "id": 6, + "type": "cloud/alexa/entities/update", + "entity_id": "light.kitchen", + "should_expose": None, + } + ) + response = await client.receive_json() + + assert response["success"] + prefs = hass.data[DOMAIN].client.prefs + assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None} + async def test_sync_alexa_entities_timeout( hass, hass_ws_client, setup_api, mock_cloud_login