diff --git a/.strict-typing b/.strict-typing index 533d5239cab228..84915e3f1b38e5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -137,6 +137,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_hardware.* diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 377da7d60b7587..44a42c78f09224 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,6 +20,11 @@ errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.homeassistant.exposed_entities import ( + async_get_assistant_settings, + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import entity_registry as er, start @@ -30,16 +35,17 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, + DOMAIN as CLOUD_DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" + # Time to wait when entity preferences have changed before syncing it to # the cloud. SYNC_DELAY = 1 @@ -64,7 +70,7 @@ def __init__( self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -115,10 +121,31 @@ def user_identifier(self): """Return an identifier for the user that represents this config.""" return self._cloud_user + def _migrate_alexa_entity_settings_v1(self): + """Migrate alexa entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_ALEXA in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options) + async def async_initialize(self): """Initialize the Alexa config.""" await super().async_initialize() + if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + if self._prefs.alexa_settings_version < 2: + self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( + alexa_settings_version=ALEXA_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -126,19 +153,19 @@ async def hass_started(hass): start.async_at_start(self.hass, hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, ) - def should_expose(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -160,6 +187,15 @@ def should_expose(self, entity_id): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) + @callback def async_invalidate_access_token(self): """Invalidate access token.""" @@ -233,32 +269,30 @@ async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: if not any( key in updated_prefs for key in ( - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, ) ): return - # If we update just entity preferences, delay updating - # as we might update more - if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: - if self._alexa_sync_unsub: - self._alexa_sync_unsub() + await self.async_sync_entities() - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) - return + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + # Delay updating as we might update more + if self._alexa_sync_unsub: + self._alexa_sync_unsub() - await self.async_sync_entities() + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs - new_prefs = self._prefs.alexa_entity_configs + new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA) seen = set() to_update = [] diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9d5ed2ca28e092..49b4b905ed3ff3 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -19,6 +19,8 @@ PREF_REMOTE_DOMAIN = "remote_domain" PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" +PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" +PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index cf5a1de73afdc5..c47b05c264c717 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -9,6 +9,10 @@ from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -22,14 +26,18 @@ from .const import ( CONF_ENTITY_CONFIG, + CONF_FILTER, DEFAULT_DISABLE_2FA, + DOMAIN as CLOUD_DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -48,8 +56,6 @@ def __init__( self._user = cloud_user 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() @property @@ -89,10 +95,35 @@ def cloud_user(self): """Return Cloud User account.""" return self._user + def _migrate_google_entity_settings_v1(self): + """Migrate Google entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_GOOGLE in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + options[PREF_DISABLE_2FA] = _2fa_disabled + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, options + ) + async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2: + self._migrate_google_entity_settings_v1() + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) @@ -109,7 +140,9 @@ async def hass_started(hass): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, @@ -123,14 +156,11 @@ def should_expose(self, state): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_entity_id(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config["filter"].empty_filter: - return self._config["filter"](entity_id) - entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -154,6 +184,15 @@ def _should_expose_entity_id(self, entity_id): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def _should_expose_entity_id(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) + @property def agent_user_id(self): """Return Agent User Id to use for query responses.""" @@ -168,11 +207,23 @@ def get_agent_user_id(self, context): """Get agent user ID making request.""" return self.agent_user_id - def should_2fa(self, state): + def _2fa_disabled_legacy(self, entity_id): """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) - return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_DISABLE_2FA) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_registry = er.async_get(self.hass) + + registry_entry = entity_registry.async_get(state.entity_id) + if not registry_entry: + # Handle the entity has been removed + return False + + assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {}) + return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) async def async_report_state(self, message, agent_user_id: str): """Send a state report to Google.""" @@ -218,14 +269,6 @@ async def _async_prefs_updated(self, prefs): # So when we change it, we need to sync all entities. sync_entities = True - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. - elif ( - self._cur_entity_prefs is not prefs.google_entity_configs - 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: self.async_enable_local_sdk() sync_entities = True @@ -233,12 +276,14 @@ async def _async_prefs_updated(self, prefs): self.async_disable_local_sdk() sync_entities = True - self._cur_entity_prefs = prefs.google_entity_configs - self._cur_default_expose = prefs.google_default_expose - if sync_entities and self.hass.is_running: await self.async_sync_entities_all() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self.async_schedule_google_sync_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6c4115ae28a75f..c25de5463b5d9c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,5 +1,6 @@ """The HTTP api to control the cloud integration.""" import asyncio +from collections.abc import Mapping import dataclasses from functools import wraps from http import HTTPStatus @@ -22,22 +23,24 @@ from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .const import ( DOMAIN, - PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, + PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) +from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -66,11 +69,11 @@ async def async_setup(hass): websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) + websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_update) websocket_api.async_register_command(hass, alexa_list) - websocket_api.async_register_command(hass, alexa_update) websocket_api.async_register_command(hass, alexa_sync) websocket_api.async_register_command(hass, thingtalk_convert) @@ -350,8 +353,6 @@ async def websocket_subscription( 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), vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) @@ -523,6 +524,54 @@ async def websocket_remote_disconnect( connection.send_result(msg["id"], await _account_data(hass, cloud)) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/google_assistant/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def google_assistant_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single google assistant entity.""" + cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] + state = hass.states.get(entity_id) + + if not entity_registry.async_is_registered(entity_id) or not state: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"{entity_id} unknown or not in the entity registry", + ) + return + + entity = google_helpers.GoogleEntity(hass, gconf, state) + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Google assistant", + ) + return + + result = { + "entity_id": entity.entity_id, + "traits": [trait.name for trait in entity.traits()], + "might_2fa": entity.might_2fa_traits(), + } + + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @@ -536,11 +585,14 @@ async def google_assistant_list( """List all google assistant entities.""" cloud = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) entities = google_helpers.async_get_entities(hass, gconf) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -558,8 +610,7 @@ async def google_assistant_list( { "type": "cloud/google_assistant/entities/update", "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("disable_2fa"): bool, + vol.Optional(PREF_DISABLE_2FA): bool, } ) @websocket_api.async_response @@ -569,17 +620,30 @@ async def google_assistant_update( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Update google assistant config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") + """Update google assistant entity config.""" + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] + + if not (registry_entry := entity_registry.async_get(entity_id)): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_ALLOWED, + f"can't configure {entity_id}", + ) + return - await cloud.client.prefs.async_update_google_entity_config(**changes) + disable_2fa = msg[PREF_DISABLE_2FA] + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {}) + ) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + return - connection.send_result( - msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa} + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, assistant_options ) + connection.send_result(msg["id"]) @websocket_api.require_admin @@ -595,11 +659,14 @@ async def alexa_list( """List all alexa entities.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() + entity_registry = er.async_get(hass) entities = alexa_entities.async_get_entities(hass, alexa_config) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -611,35 +678,6 @@ async def alexa_list( connection.send_result(msg["id"], result) -@websocket_api.require_admin -@_require_cloud_login -@websocket_api.websocket_command( - { - "type": "cloud/alexa/entities/update", - "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - } -) -@websocket_api.async_response -@_ws_handle_cloud_errors -async def alexa_update( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Update alexa entity config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") - - await cloud.client.prefs.async_update_alexa_entity_config(**changes) - - connection.send_result( - msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) - ) - - @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 2bff4003669a14..daf65865fc0cc3 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Cloud", "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], - "dependencies": ["http", "webhook"], + "dependencies": ["homeassistant", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 7f27e7cf39bafc..75e1856503c0dd 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,8 @@ """Preference management for cloud.""" from __future__ import annotations +from typing import Any + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook @@ -18,9 +20,9 @@ PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, + PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, - PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, @@ -29,14 +31,33 @@ PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_GOOGLE_SETTINGS_VERSION, PREF_REMOTE_DOMAIN, - PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, ) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 + +ALEXA_SETTINGS_VERSION = 2 +GOOGLE_SETTINGS_VERSION = 2 + + +class CloudPreferencesStore(Store): + """Store entity registry data.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + if old_minor_version < 2: + old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) + old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + + return old_data class CloudPreferences: @@ -45,7 +66,9 @@ class CloudPreferences: def __init__(self, hass): """Initialize cloud prefs.""" self._hass = hass - self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = CloudPreferencesStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) self._prefs = None self._listeners = [] self.last_updated: set[str] = set() @@ -79,14 +102,12 @@ async def async_update( google_secure_devices_pin=UNDEFINED, cloudhooks=UNDEFINED, cloud_user=UNDEFINED, - google_entity_configs=UNDEFINED, - alexa_entity_configs=UNDEFINED, alexa_report_state=UNDEFINED, google_report_state=UNDEFINED, - alexa_default_expose=UNDEFINED, - google_default_expose=UNDEFINED, tts_default_voice=UNDEFINED, remote_domain=UNDEFINED, + alexa_settings_version=UNDEFINED, + google_settings_version=UNDEFINED, ): """Update user preferences.""" prefs = {**self._prefs} @@ -98,12 +119,10 @@ async def async_update( (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_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), + (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), + (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), ): @@ -112,53 +131,6 @@ async def async_update( await self._save_prefs(prefs) - async def async_update_google_entity_config( - self, - *, - entity_id, - disable_2fa=UNDEFINED, - should_expose=UNDEFINED, - ): - """Update config for a Google entity.""" - entities = self.google_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ( - (PREF_DISABLE_2FA, disable_2fa), - (PREF_SHOULD_EXPOSE, should_expose), - ): - if value is not UNDEFINED: - 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) - - async def async_update_alexa_entity_config( - self, *, entity_id, should_expose=UNDEFINED - ): - """Update config for an Alexa entity.""" - entities = self.alexa_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ((PREF_SHOULD_EXPOSE, should_expose),): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(alexa_entity_configs=updated_entities) - async def async_set_username(self, username) -> bool: """Set the username that is logged in.""" # Logging out. @@ -186,14 +158,12 @@ 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_DEFAULT_EXPOSE: self.google_default_expose, - PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, @@ -235,6 +205,11 @@ def alexa_entity_configs(self): """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property + def alexa_settings_version(self): + """Return version of Alexa settings.""" + return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + @property def google_enabled(self): """Return if Google is enabled.""" @@ -255,6 +230,11 @@ def google_entity_configs(self): """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def google_settings_version(self): + """Return version of Google settings.""" + return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + @property def google_local_webhook_id(self): """Return Google webhook ID to receive local messages.""" @@ -319,6 +299,7 @@ def _empty_config(username): return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER: None, PREF_CLOUDHOOKS: {}, PREF_ENABLE_ALEXA: True, @@ -326,6 +307,7 @@ def _empty_config(username): PREF_ENABLE_REMOTE: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 91dd742e802b64..987a4317ba84ac 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -33,10 +33,12 @@ from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType +from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .exposed_entities import ExposedEntities + ATTR_ENTRY_ID = "entry_id" _LOGGER = logging.getLogger(__name__) -DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" @@ -340,4 +342,8 @@ async def async_handle_reload_all(call: ha.ServiceCall) -> None: hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all ) + exposed_entities = ExposedEntities(hass) + await exposed_entities.async_initialize() + hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + return True diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py new file mode 100644 index 00000000000000..f3bc95dd1ee0c0 --- /dev/null +++ b/homeassistant/components/homeassistant/const.py @@ -0,0 +1,6 @@ +"""Constants for the Homeassistant integration.""" +import homeassistant.core as ha + +DOMAIN = ha.DOMAIN + +DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites" diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py new file mode 100644 index 00000000000000..9317f43ea750b5 --- /dev/null +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -0,0 +1,351 @@ +"""Control which entities are exposed to voice assistants.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import dataclasses +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.storage import Store + +from .const import DATA_EXPOSED_ENTITIES, DOMAIN + +KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant") + +STORAGE_KEY = f"{DOMAIN}.exposed_entities" +STORAGE_VERSION = 1 + +SAVE_DELAY = 10 + +DEFAULT_EXPOSED_DOMAINS = { + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "scene", + "script", + "switch", + "vacuum", + "water_heater", +} + +DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + + +@dataclasses.dataclass(frozen=True) +class AssistantPreferences: + """Preferences for an assistant.""" + + expose_new: bool + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return {"expose_new": self.expose_new} + + +class ExposedEntities: + """Control assistant settings.""" + + _assistants: dict[str, AssistantPreferences] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + self._listeners: dict[str, list[Callable[[], None]]] = {} + self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + + async def async_initialize(self) -> None: + """Finish initializing.""" + websocket_api.async_register_command(self._hass, ws_expose_entity) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) + await self.async_load() + + @callback + def async_listen_entity_updates( + self, assistant: str, listener: Callable[[], None] + ) -> None: + """Listen for updates to entity expose settings.""" + self._listeners.setdefault(assistant, []).append(listener) + + @callback + def async_expose_entity( + self, assistant: str, entity_id: str, should_expose: bool + ) -> None: + """Expose an entity to an assistant. + + Notify listeners if expose flag was changed. + """ + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError("Unknown entity") + + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(assistant, {}) + ) and assistant_options.get("should_expose") == should_expose: + return + + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + for listener in self._listeners.get(assistant, []): + listener() + + @callback + def async_get_expose_new_entities(self, assistant: str) -> bool: + """Check if new entities are exposed to an assistant.""" + if prefs := self._assistants.get(assistant): + return prefs.expose_new + return False + + @callback + def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None: + """Enable an assistant to expose new entities.""" + self._assistants[assistant] = AssistantPreferences(expose_new=expose_new) + self._async_schedule_save() + + @callback + def async_get_assistant_settings( + self, assistant: str + ) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + for entity_id, entry in entity_registry.entities.items(): + if options := entry.options.get(assistant): + result[entity_id] = options + + return result + + @callback + def async_should_expose(self, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + # Entities which are not in the entity registry are not exposed + return False + + if assistant in registry_entry.options: + if "should_expose" in registry_entry.options[assistant]: + should_expose = registry_entry.options[assistant]["should_expose"] + return should_expose + + if (prefs := self._assistants.get(assistant)) and prefs.expose_new: + should_expose = self._is_default_exposed(entity_id, registry_entry) + else: + should_expose = False + + assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + + return should_expose + + def _is_default_exposed( + self, entity_id: str, registry_entry: er.RegistryEntry + ) -> bool: + """Return True if an entity is exposed by default.""" + if ( + registry_entry.entity_category is not None + or registry_entry.hidden_by is not None + ): + return False + + domain = split_entity_id(entity_id)[0] + if domain in DEFAULT_EXPOSED_DOMAINS: + return True + + device_class = get_device_class(self._hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES: + return True + + return False + + async def async_load(self) -> None: + """Load from the store.""" + data = await self._store.async_load() + + assistants: dict[str, AssistantPreferences] = {} + + if data: + for domain, preferences in data["assistants"].items(): + assistants[domain] = AssistantPreferences(**preferences) + + self._assistants = assistants + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the preferences.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]: + """Return data to store in a file.""" + data = {} + + data["assistants"] = { + domain: preferences.to_json() + for domain, preferences in self._assistants.items() + } + + return data + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity", + vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)], + vol.Required("entity_ids"): [str], + vol.Required("should_expose"): bool, + } +) +def ws_expose_entity( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + entity_registry = er.async_get(hass) + entity_ids: str = msg["entity_ids"] + + if blocked := next( + ( + entity_id + for entity_id in entity_ids + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" + ) + return + + if unknown := next( + ( + entity_id + for entity_id in entity_ids + if entity_id not in entity_registry.entities + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'" + ) + return + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + for entity_id in entity_ids: + for assistant in msg["assistants"]: + exposed_entities.async_expose_entity( + assistant, entity_id, msg["should_expose"] + ) + connection.send_result(msg["id"]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/get", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + } +) +def ws_expose_new_entities_get( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Check if new entities are exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"]) + connection.send_result(msg["id"], {"expose_new": expose_new}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/set", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + vol.Required("expose_new"): bool, + } +) +def ws_expose_new_entities_set( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose new entities to an assistatant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) + connection.send_result(msg["id"]) + + +@callback +def async_listen_entity_updates( + hass: HomeAssistant, assistant: str, listener: Callable[[], None] +) -> None: + """Listen for updates to entity expose settings.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_listen_entity_updates(assistant, listener) + + +@callback +def async_get_assistant_settings( + hass: HomeAssistant, assistant: str +) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_assistant_settings(assistant) + + +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_should_expose(assistant, entity_id) diff --git a/mypy.ini b/mypy.ini index b3a4cafba36132..389e639a1a4390 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1132,6 +1132,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homeassistant.exposed_entities] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homeassistant.triggers.event] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 40809d2759c551..7933d8639c18bb 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from homeassistant.components import cloud -from homeassistant.components.cloud import const +from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component @@ -18,9 +18,11 @@ async def mock_cloud(hass, config=None): def mock_cloud_prefs(hass, prefs={}): """Fixture for cloud component.""" prefs_to_set = { + const.PREF_ALEXA_SETTINGS_VERSION: cloud_prefs.ALEXA_SETTINGS_VERSION, const.PREF_ENABLE_ALEXA: True, const.PREF_ENABLE_GOOGLE: True, const.PREF_GOOGLE_SECURE_DEVICES_PIN: None, + const.PREF_GOOGLE_SETTINGS_VERSION: cloud_prefs.GOOGLE_SETTINGS_VERSION, } prefs_to_set.update(prefs) hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 73dd69db447a48..2cb363b04209d9 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -6,10 +6,22 @@ from homeassistant.components.alexa import errors from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config +from homeassistant.components.cloud.const import ( + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_SHOULD_EXPOSE, +) +from homeassistant.components.cloud.prefs import CloudPreferences +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.setup import async_setup_component from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -21,10 +33,23 @@ def cloud_stub(): return Mock(is_logged_in=True, subscription_expired=False) +def expose_new(hass, expose_new): + """Enable exposing new entities to Alexa.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new) + + +def expose_entity(hass, entity_id, should_expose): + """Expose an entity to Alexa.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose) + + async def test_alexa_config_expose_entity_prefs( hass: HomeAssistant, cloud_prefs, cloud_stub, entity_registry: er.EntityRegistry ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) entity_entry1 = entity_registry.async_get_or_create( "light", "test", @@ -53,54 +78,62 @@ async def test_alexa_config_expose_entity_prefs( suggested_object_id="hidden_user_light", hidden_by=er.RegistryEntryHider.USER, ) + entity_entry5 = entity_registry.async_get_or_create( + "light", + "test", + "light_basement_id", + suggested_object_id="basement", + ) + entity_entry6 = entity_registry.async_get_or_create( + "light", + "test", + "light_entrance_id", + suggested_object_id="entrance", + ) - entity_conf = {"should_expose": False} await cloud_prefs.async_update( - alexa_entity_configs={"light.kitchen": entity_conf}, - alexa_default_expose=["light"], alexa_enabled=True, alexa_report_state=False, ) + expose_new(hass, True) + expose_entity(hass, entity_entry5.entity_id, False) conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + # can't expose an entity which is not in the entity registry + with pytest.raises(HomeAssistantError): + expose_entity(hass, "light.kitchen", True) assert not conf.should_expose("light.kitchen") - assert not conf.should_expose(entity_entry1.entity_id) - assert not conf.should_expose(entity_entry2.entity_id) - assert not conf.should_expose(entity_entry3.entity_id) - assert not conf.should_expose(entity_entry4.entity_id) - - entity_conf["should_expose"] = True - assert conf.should_expose("light.kitchen") # categorized and hidden entities should not be exposed assert not conf.should_expose(entity_entry1.entity_id) assert not conf.should_expose(entity_entry2.entity_id) assert not conf.should_expose(entity_entry3.entity_id) assert not conf.should_expose(entity_entry4.entity_id) + # this has been hidden + assert not conf.should_expose(entity_entry5.entity_id) + # exposed by default + assert conf.should_expose(entity_entry6.entity_id) - entity_conf["should_expose"] = None - assert conf.should_expose("light.kitchen") - # categorized and hidden entities should not be exposed - assert not conf.should_expose(entity_entry1.entity_id) - assert not conf.should_expose(entity_entry2.entity_id) - assert not conf.should_expose(entity_entry3.entity_id) - assert not conf.should_expose(entity_entry4.entity_id) + expose_entity(hass, entity_entry5.entity_id, True) + assert conf.should_expose(entity_entry5.entity_id) + + expose_entity(hass, entity_entry5.entity_id, None) + assert not conf.should_expose(entity_entry5.entity_id) assert "alexa" not in hass.config.components - await cloud_prefs.async_update( - alexa_default_expose=["sensor"], - ) await hass.async_block_till_done() assert "alexa" in hass.config.components - assert not conf.should_expose("light.kitchen") + assert not conf.should_expose(entity_entry5.entity_id) async def test_alexa_config_report_state( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) + await cloud_prefs.async_update( alexa_report_state=False, ) @@ -134,6 +167,8 @@ async def test_alexa_config_invalidate_token( hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) + aioclient_mock.post( "https://example/access_token", json={ @@ -181,10 +216,18 @@ async def test_alexa_config_fail_refresh_token( hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, reject_reason, expected_exception, ) -> None: """Test Alexa config failing to refresh token.""" + assert await async_setup_component(hass, "homeassistant", {}) + # Enable exposing new entities to Alexa + expose_new(hass, True) + # Register a fan entity + entity_entry = entity_registry.async_get_or_create( + "fan", "test", "unique", suggested_object_id="test_fan" + ) aioclient_mock.post( "https://example/access_token", @@ -216,7 +259,7 @@ async def test_alexa_config_fail_refresh_token( assert conf.should_report_state is False assert conf.is_reporting_states is False - hass.states.async_set("fan.test_fan", "off") + hass.states.async_set(entity_entry.entity_id, "off") # Enable state reporting await cloud_prefs.async_update(alexa_report_state=True) @@ -227,7 +270,7 @@ async def test_alexa_config_fail_refresh_token( assert conf.is_reporting_states is True # Change states to trigger event listener - hass.states.async_set("fan.test_fan", "on") + hass.states.async_set(entity_entry.entity_id, "on") await hass.async_block_till_done() # Invalidate the token and try to fetch another @@ -240,7 +283,7 @@ async def test_alexa_config_fail_refresh_token( ) # Change states to trigger event listener - hass.states.async_set("fan.test_fan", "off") + hass.states.async_set(entity_entry.entity_id, "off") await hass.async_block_till_done() # Check state reporting is still wanted in cloud prefs, but disabled for Alexa @@ -292,16 +335,30 @@ def sync_helper(to_upd, to_rem): async def test_alexa_update_expose_trigger_sync( - hass: HomeAssistant, cloud_prefs, cloud_stub + hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to updating exposed entities.""" - hass.states.async_set("binary_sensor.door", "on") + assert await async_setup_component(hass, "homeassistant", {}) + # Enable exposing new entities to Alexa + expose_new(hass, True) + # Register entities + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique", suggested_object_id="door" + ) + sensor_entry = entity_registry.async_get_or_create( + "sensor", "test", "unique", suggested_object_id="temp" + ) + light_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + + hass.states.async_set(binary_sensor_entry.entity_id, "on") hass.states.async_set( - "sensor.temp", + sensor_entry.entity_id, "23", {"device_class": "temperature", "unit_of_measurement": "°C"}, ) - hass.states.async_set("light.kitchen", "off") + hass.states.async_set(light_entry.entity_id, "off") await cloud_prefs.async_update( alexa_enabled=True, @@ -313,34 +370,26 @@ async def test_alexa_update_expose_trigger_sync( await conf.async_initialize() with patch_sync_helper() as (to_update, to_remove): - await cloud_prefs.async_update_alexa_entity_config( - entity_id="light.kitchen", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() assert conf._alexa_sync_unsub is None - assert to_update == ["light.kitchen"] + assert to_update == [light_entry.entity_id] assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): - await cloud_prefs.async_update_alexa_entity_config( - entity_id="light.kitchen", should_expose=False - ) - await cloud_prefs.async_update_alexa_entity_config( - entity_id="binary_sensor.door", should_expose=True - ) - await cloud_prefs.async_update_alexa_entity_config( - entity_id="sensor.temp", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() assert conf._alexa_sync_unsub is None - assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"] - assert to_remove == ["light.kitchen"] + assert sorted(to_update) == [binary_sensor_entry.entity_id, sensor_entry.entity_id] + assert to_remove == [light_entry.entity_id] with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update( @@ -350,56 +399,65 @@ async def test_alexa_update_expose_trigger_sync( assert conf._alexa_sync_unsub is None assert to_update == [] - assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"] + assert to_remove == [ + binary_sensor_entry.entity_id, + sensor_entry.entity_id, + light_entry.entity_id, + ] async def test_alexa_entity_registry_sync( - hass: HomeAssistant, mock_cloud_login, cloud_prefs + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_login, + cloud_prefs, ) -> None: """Test Alexa config responds to entity registry.""" + # Enable exposing new entities to Alexa + expose_new(hass, True) + await alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ).async_initialize() with patch_sync_helper() as (to_update, to_remove): - hass.bus.async_fire( - er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" ) await hass.async_block_till_done() - assert to_update == ["light.kitchen"] + assert to_update == [entry.entity_id] assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "remove", "entity_id": "light.kitchen"}, + {"action": "remove", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() assert to_update == [] - assert to_remove == ["light.kitchen"] + assert to_remove == [entry.entity_id] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, { "action": "update", - "entity_id": "light.kitchen", + "entity_id": entry.entity_id, "changes": ["entity_id"], "old_entity_id": "light.living_room", }, ) await hass.async_block_till_done() - assert to_update == ["light.kitchen"] + assert to_update == [entry.entity_id] assert to_remove == ["light.living_room"] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]}, + {"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]}, ) await hass.async_block_till_done() @@ -411,6 +469,7 @@ async def test_alexa_update_report_state( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to reporting state.""" + assert await async_setup_component(hass, "homeassistant", {}) await cloud_prefs.async_update( alexa_report_state=False, ) @@ -450,6 +509,7 @@ async def test_alexa_handle_logout( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to logging out.""" + assert await async_setup_component(hass, "homeassistant", {}) aconf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) @@ -475,3 +535,118 @@ async def test_alexa_handle_logout( await hass.async_block_till_done() assert len(mock_enable.return_value.mock_calls) == 1 + + +async def test_alexa_config_migrate_expose_entity_prefs( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_exposed", + suggested_object_id="exposed", + ) + + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + + entity_config = entity_registry.async_get_or_create( + "light", + "test", + "light_config", + suggested_object_id="config", + entity_category=EntityCategory.CONFIG, + ) + + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + entity_blocked = entity_registry.async_get_or_create( + "group", + "test", + "group_all_locks", + suggested_object_id="all_locks", + ) + assert entity_blocked.entity_id == "group.all_locks" + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + + entity_exposed = entity_registry.async_get(entity_exposed.entity_id) + assert entity_exposed.options == {"cloud.alexa": {"should_expose": True}} + + entity_migrated = entity_registry.async_get(entity_migrated.entity_id) + assert entity_migrated.options == {"cloud.alexa": {"should_expose": False}} + + entity_config = entity_registry.async_get(entity_config.entity_id) + assert entity_config.options == {"cloud.alexa": {"should_expose": False}} + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.alexa": {"should_expose": True}} + + entity_blocked = entity_registry.async_get(entity_blocked.entity_id) + assert entity_blocked.options == {"cloud.alexa": {"should_expose": False}} + + +async def test_alexa_config_migrate_expose_entity_prefs_default_none( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + + cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = None + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.alexa": {"should_expose": True}} diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index b7bfed53aacb3b..1afe9956288a9f 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -13,8 +13,13 @@ PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, ) +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -245,14 +250,25 @@ async def test_google_config_expose_entity( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config exposing entity method uses latest config.""" + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", True) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") gconf = await cloud_client.get_google_config() assert gconf.should_expose(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=False + exposed_entities.async_expose_entity( + "cloud.google_assistant", entity_entry.entity_id, False ) assert not gconf.should_expose(state) @@ -262,14 +278,21 @@ async def test_google_config_should_2fa( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config disabling 2FA method uses latest config.""" + entity_registry = er.async_get(hass) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client gconf = await cloud_client.get_google_config() - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") assert gconf.should_2fa(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", disable_2fa=True + entity_registry.async_update_entity_options( + entity_entry.entity_id, "cloud.google_assistant", {"disable_2fa": True} ) assert not gconf.should_2fa(state) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 6725fbea633b49..738b3fa7cd769e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -6,11 +6,24 @@ import pytest from homeassistant.components.cloud import GACTIONS_SCHEMA +from homeassistant.components.cloud.const import ( + PREF_DISABLE_2FA, + PREF_GOOGLE_DEFAULT_EXPOSE, + PREF_GOOGLE_ENTITY_CONFIGS, + PREF_SHOULD_EXPOSE, +) from homeassistant.components.cloud.google_config import CloudGoogleConfig +from homeassistant.components.cloud.prefs import CloudPreferences from homeassistant.components.google_assistant import helpers as ga_helpers +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -28,10 +41,26 @@ def mock_conf(hass, cloud_prefs): ) +def expose_new(hass, expose_new): + """Enable exposing new entities to Google.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new) + + +def expose_entity(hass, entity_id, should_expose): + """Expose an entity to Google.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_expose_entity( + "cloud.google_assistant", entity_id, should_expose + ) + + async def test_google_update_report_state( mock_conf, hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config responds to updating preference.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -51,6 +80,8 @@ async def test_google_update_report_state_subscription_expired( mock_conf, hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config not reporting state when subscription has expired.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -68,6 +99,8 @@ async def test_google_update_report_state_subscription_expired( async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> None: """Test sync devices.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -88,6 +121,22 @@ async def test_google_update_expose_trigger_sync( hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config responds to updating exposed entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + # Register entities + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique", suggested_object_id="door" + ) + sensor_entry = entity_registry.async_get_or_create( + "sensor", "test", "unique", suggested_object_id="temp" + ) + light_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + with freeze_time(utcnow()): config = CloudGoogleConfig( hass, @@ -102,9 +151,7 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await cloud_prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -114,15 +161,9 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await cloud_prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=False - ) - await cloud_prefs.async_update_google_entity_config( - entity_id="binary_sensor.door", should_expose=True - ) - await cloud_prefs.async_update_google_entity_config( - entity_id="sensor.temp", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -134,6 +175,11 @@ async def test_google_entity_registry_sync( hass: HomeAssistant, mock_cloud_login, cloud_prefs ) -> None: """Test Google config responds to entity registry.""" + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + config = CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) @@ -146,9 +192,8 @@ async def test_google_entity_registry_sync( ga_helpers, "SYNC_DELAY", 0 ): # Created entity - hass.bus.async_fire( - er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" ) await hass.async_block_till_done() @@ -157,7 +202,7 @@ async def test_google_entity_registry_sync( # Removed entity hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "remove", "entity_id": "light.kitchen"}, + {"action": "remove", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() @@ -168,7 +213,7 @@ async def test_google_entity_registry_sync( er.EVENT_ENTITY_REGISTRY_UPDATED, { "action": "update", - "entity_id": "light.kitchen", + "entity_id": entry.entity_id, "changes": ["entity_id"], }, ) @@ -179,7 +224,7 @@ async def test_google_entity_registry_sync( # Entity registry updated with non-relevant changes hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]}, + {"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]}, ) await hass.async_block_till_done() @@ -189,7 +234,7 @@ async def test_google_entity_registry_sync( hass.state = CoreState.starting hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + {"action": "create", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() @@ -204,6 +249,10 @@ async def test_google_device_registry_sync( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) ent_reg = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + entity_entry = ent_reg.async_get_or_create("light", "hue", "1234", device_id="1234") entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD") @@ -293,6 +342,7 @@ async def test_google_config_expose_entity_prefs( hass: HomeAssistant, mock_conf, cloud_prefs, entity_registry: er.EntityRegistry ) -> None: """Test Google config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) entity_entry1 = entity_registry.async_get_or_create( "light", "test", @@ -321,45 +371,49 @@ async def test_google_config_expose_entity_prefs( suggested_object_id="hidden_user_light", hidden_by=er.RegistryEntryHider.USER, ) - - entity_conf = {"should_expose": False} - await cloud_prefs.async_update( - google_entity_configs={"light.kitchen": entity_conf}, - google_default_expose=["light"], + entity_entry5 = entity_registry.async_get_or_create( + "light", + "test", + "light_basement_id", + suggested_object_id="basement", + ) + entity_entry6 = entity_registry.async_get_or_create( + "light", + "test", + "light_entrance_id", + suggested_object_id="entrance", ) + expose_new(hass, True) + expose_entity(hass, entity_entry5.entity_id, False) + state = State("light.kitchen", "on") state_config = State(entity_entry1.entity_id, "on") state_diagnostic = State(entity_entry2.entity_id, "on") state_hidden_integration = State(entity_entry3.entity_id, "on") state_hidden_user = State(entity_entry4.entity_id, "on") + state_not_exposed = State(entity_entry5.entity_id, "on") + state_exposed_default = State(entity_entry6.entity_id, "on") + # can't expose an entity which is not in the entity registry + with pytest.raises(HomeAssistantError): + expose_entity(hass, "light.kitchen", True) assert not mock_conf.should_expose(state) - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) - - entity_conf["should_expose"] = True - assert mock_conf.should_expose(state) # categorized and hidden entities should not be exposed assert not mock_conf.should_expose(state_config) assert not mock_conf.should_expose(state_diagnostic) assert not mock_conf.should_expose(state_hidden_integration) assert not mock_conf.should_expose(state_hidden_user) + # this has been hidden + assert not mock_conf.should_expose(state_not_exposed) + # exposed by default + assert mock_conf.should_expose(state_exposed_default) - entity_conf["should_expose"] = None - assert mock_conf.should_expose(state) - # categorized and hidden entities should not be exposed - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) + expose_entity(hass, entity_entry5.entity_id, True) + assert mock_conf.should_expose(state_not_exposed) - await cloud_prefs.async_update( - google_default_expose=["sensor"], - ) - assert not mock_conf.should_expose(state) + expose_entity(hass, entity_entry5.entity_id, None) + assert not mock_conf.should_expose(state_not_exposed) def test_enabled_requires_valid_sub( @@ -379,6 +433,7 @@ def test_enabled_requires_valid_sub( async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None: """Test that we set up the integration if used.""" + assert await async_setup_component(hass, "homeassistant", {}) mock_conf._cloud.subscription_expired = False assert "google_assistant" not in hass.config.components @@ -423,3 +478,136 @@ async def test_google_handle_logout( await hass.async_block_till_done() assert len(mock_enable.return_value.mock_calls) == 1 + + +async def test_google_config_migrate_expose_entity_prefs( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_exposed", + suggested_object_id="exposed", + ) + + entity_no_2fa_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_no_2fa_exposed", + suggested_object_id="no_2fa_exposed", + ) + + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + + entity_config = entity_registry.async_get_or_create( + "light", + "test", + "light_config", + suggested_object_id="config", + entity_category=EntityCategory.CONFIG, + ) + + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + entity_blocked = entity_registry.async_get_or_create( + "group", + "test", + "group_all_locks", + suggested_object_id="all_locks", + ) + assert entity_blocked.entity_id == "group.all_locks" + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_no_2fa_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True, + PREF_DISABLE_2FA: True, + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + + entity_exposed = entity_registry.async_get(entity_exposed.entity_id) + assert entity_exposed.options == {"cloud.google_assistant": {"should_expose": True}} + + entity_migrated = entity_registry.async_get(entity_migrated.entity_id) + assert entity_migrated.options == { + "cloud.google_assistant": {"should_expose": False} + } + + entity_no_2fa_exposed = entity_registry.async_get(entity_no_2fa_exposed.entity_id) + assert entity_no_2fa_exposed.options == { + "cloud.google_assistant": {"disable_2fa": True, "should_expose": True} + } + + entity_config = entity_registry.async_get(entity_config.entity_id) + assert entity_config.options == {"cloud.google_assistant": {"should_expose": False}} + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}} + + entity_blocked = entity_registry.async_get(entity_blocked.entity_id) + assert entity_blocked.options == { + "cloud.google_assistant": {"should_expose": False} + } + + +async def test_google_config_migrate_expose_entity_prefs_default_none( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + + cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = None + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}} diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 92c0ca70a17a66..115e77f118e208 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -15,6 +15,7 @@ from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.util.location import LocationInfo from . import mock_cloud, mock_cloud_prefs @@ -399,11 +400,9 @@ async def test_websocket_status( "alexa_enabled": True, "cloudhooks": {}, "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": True, "google_report_state": True, "remote_enabled": False, @@ -520,8 +519,6 @@ 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"], "tts_default_voice": ["en-GB", "male"], } ) @@ -531,8 +528,6 @@ 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"] assert setup_api.tts_default_voice == ("en-GB", "male") @@ -683,7 +678,11 @@ async def test_enabling_remote( async def test_list_google_entities( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can list Google entities.""" client = await hass_ws_client(hass) @@ -699,9 +698,25 @@ async def test_list_google_entities( "homeassistant.components.google_assistant.helpers.async_get_entities", return_value=[entity, entity2], ): - await client.send_json({"id": 5, "type": "cloud/google_assistant/entities"}) + await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 0 + # Add the entities to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + entity_registry.async_get_or_create( + "cover", "test", "unique", suggested_object_id="garage" + ) + + with patch( + "homeassistant.components.google_assistant.helpers.async_get_entities", + return_value=[entity, entity2], + ): + await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) + response = await client.receive_json() assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -716,49 +731,118 @@ async def test_list_google_entities( } +async def test_get_google_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, +) -> None: + """Test that we can get a Google entity.""" + client = await hass_ws_client(hass) + + # Test getting an unknown entity + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "light.kitchen unknown or not in the entity registry", + } + + # Test getting a blocked entity + entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "group.all_locks not supported by Google assistant", + } + + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + entity_registry.async_get_or_create( + "cover", "test", "unique", suggested_object_id="garage" + ) + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("cover.garage", "open", {"device_class": "garage"}) + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "entity_id": "light.kitchen", + "might_2fa": False, + "traits": ["action.devices.traits.OnOff"], + } + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "entity_id": "cover.garage", + "might_2fa": True, + "traits": ["action.devices.traits.OpenClose"], + } + + async def test_update_google_entity( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can update config of a Google entity.""" + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, "type": "cloud/google_assistant/entities/update", "entity_id": "light.kitchen", - "should_expose": False, "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, - "disable_2fa": False, - } - await client.send_json( + await client.send_json_auto_id( { - "id": 6, - "type": "cloud/google_assistant/entities/update", - "entity_id": "light.kitchen", - "should_expose": None, + "type": "homeassistant/expose_entity", + "assistants": ["cloud.google_assistant"], + "entity_ids": [entry.entity_id], + "should_expose": False, } ) response = await client.receive_json() - assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.google_entity_configs["light.kitchen"] == { - "should_expose": None, - "disable_2fa": False, - } + + assert entity_registry.async_get(entry.entity_id).options[ + "cloud.google_assistant" + ] == {"disable_2fa": False, "should_expose": False} async def test_list_alexa_entities( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can list Alexa entities.""" client = await hass_ws_client(hass) @@ -769,9 +853,22 @@ async def test_list_alexa_entities( "homeassistant.components.alexa.entities.async_get_entities", return_value=[entity], ): - await client.send_json({"id": 5, "type": "cloud/alexa/entities"}) + await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 0 + # Add the entity to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + + with patch( + "homeassistant.components.alexa.entities.async_get_entities", + return_value=[entity], + ): + await client.send_json_auto_id({"type": "cloud/alexa/entities"}) + response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -782,37 +879,31 @@ async def test_list_alexa_entities( async def test_update_alexa_entity( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can update config of an Alexa entity.""" + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, - "type": "cloud/alexa/entities/update", - "entity_id": "light.kitchen", + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry.entity_id], "should_expose": False, } ) response = await client.receive_json() assert response["success"] - 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} + assert entity_registry.async_get(entry.entity_id).options["cloud.alexa"] == { + "should_expose": False + } async def test_sync_alexa_entities_timeout( diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py new file mode 100644 index 00000000000000..1aa98ab423f418 --- /dev/null +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -0,0 +1,348 @@ +"""Test Home Assistant exposed entities helper.""" +import pytest + +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + async_get_assistant_settings, + async_listen_entity_updates, + async_should_expose, +) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import flush_store +from tests.typing import WebSocketGenerator + + +async def test_load_preferences(hass: HomeAssistant) -> None: + """Make sure that we can load/save data correctly.""" + assert await async_setup_component(hass, "homeassistant", {}) + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + assert exposed_entities._assistants == {} + + exposed_entities.async_set_expose_new_entities("test1", True) + exposed_entities.async_set_expose_new_entities("test2", False) + + assert list(exposed_entities._assistants) == ["test1", "test2"] + + exposed_entities2 = ExposedEntities(hass) + await flush_store(exposed_entities._store) + await exposed_entities2.async_load() + + assert exposed_entities._assistants == exposed_entities2._assistants + + +async def test_expose_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("test", "test", "unique1") + entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry1.entity_id], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + entry1 = entity_registry.async_get(entry1.entity_id) + assert entry1.options == {"cloud.alexa": {"should_expose": True}} + entry2 = entity_registry.async_get(entry2.entity_id) + assert entry2.options == {} + + # Update options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry1.entity_id, entry2.entity_id], + "should_expose": False, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + entry1 = entity_registry.async_get(entry1.entity_id) + assert entry1.options == { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + entry2 = entity_registry.async_get(entry2.entity_id) + assert entry2.options == { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + + +async def test_expose_entity_unknown( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test behavior when exposing an unknown entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": ["test.test"], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "can't expose 'test.test'", + } + + with pytest.raises(HomeAssistantError): + exposed_entities.async_expose_entity("cloud.alexa", "test.test", True) + + +async def test_expose_entity_blocked( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test behavior when exposing a blocked entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": ["group.all_locks"], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_allowed", + "message": "can't expose 'group.all_locks'", + } + + +@pytest.mark.parametrize("expose_new", [True, False]) +async def test_expose_new_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + expose_new, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("climate", "test", "unique1") + entry2 = entity_registry.async_get_or_create("climate", "test", "unique2") + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/get", + "assistant": "cloud.alexa", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"expose_new": False} + + # Check if exposed - should be False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": expose_new, + } + ) + response = await ws_client.receive_json() + assert response["success"] + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/get", + "assistant": "cloud.alexa", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"expose_new": expose_new} + + # Check again if exposed - should still be False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + + # Check if exposed - should be True + assert async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new + + +async def test_listen_updates( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test listen to updates.""" + calls = [] + + def listener(): + calls.append(None) + + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + async_listen_entity_updates(hass, "cloud.alexa", listener) + + entry = entity_registry.async_get_or_create("climate", "test", "unique1") + + # Call for another assistant - listener not called + exposed_entities.async_expose_entity( + "cloud.google_assistant", entry.entity_id, True + ) + assert len(calls) == 0 + + # Call for our assistant - listener called + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + assert len(calls) == 1 + + # Settings not changed - listener not called + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + assert len(calls) == 1 + + # Settings changed - listener called + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False) + assert len(calls) == 2 + + +async def test_get_assistant_settings( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test get assistant settings.""" + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + + entry = entity_registry.async_get_or_create("climate", "test", "unique1") + + assert async_get_assistant_settings(hass, "cloud.alexa") == {} + + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + assert async_get_assistant_settings(hass, "cloud.alexa") == { + "climate.test_unique1": {"should_expose": True} + } + assert async_get_assistant_settings(hass, "cloud.google_assistant") == {} + + +async def test_should_expose( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # Unknown entity is not exposed + assert async_should_expose(hass, "test.test", "test.test") is False + + # Blocked entity is not exposed + entry_blocked = entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", entry_blocked.entity_id) is False + + # Lock is exposed + lock1 = entity_registry.async_get_or_create("lock", "test", "unique1") + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", lock1.entity_id) is True + + # Hidden entity is not exposed + lock2 = entity_registry.async_get_or_create( + "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", lock2.entity_id) is False + + # Entity with category is not exposed + lock3 = entity_registry.async_get_or_create( + "lock", "test", "unique3", entity_category=EntityCategory.CONFIG + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", lock3.entity_id) is False + + # Binary sensor without device class is not exposed + binarysensor1 = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique1" + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", binarysensor1.entity_id) is False + + # Binary sensor with certain device class is exposed + binarysensor2 = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "unique2", + original_device_class="door", + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", binarysensor2.entity_id) is True + + # Sensor without device class is not exposed + sensor1 = entity_registry.async_get_or_create("sensor", "test", "unique1") + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", sensor1.entity_id) is False + + # Sensor with certain device class is exposed + sensor2 = entity_registry.async_get_or_create( + "sensor", + "test", + "unique2", + original_device_class="temperature", + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", sensor2.entity_id) is True