Refactor handling of exposed entities for cloud Alexa and Google (#89877)

* Refactor handling of exposed entities for cloud Alexa

* Tweak WS API

* Validate assistant parameter

* Address some review comments

* Refactor handling of exposed entities for cloud Google

* Raise when attempting to expose an unknown entity

* Add tests

* Adjust cloud tests

* Allow getting expose new entities flag

* Test Alexa migration

* Test Google migration

* Add WS command cloud/google_assistant/entities/get

* Fix return value

* Update typing

* Address review comments

* Rename async_get_exposed_entities to async_get_assistant_settings
This commit is contained in:
Erik Montnemery 2023-04-06 19:09:45 +02:00 committed by GitHub
parent 0d84106947
commit 44c89a6b6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1607 additions and 305 deletions

View file

@ -137,6 +137,7 @@ homeassistant.components.hardkernel.*
homeassistant.components.hardware.* homeassistant.components.hardware.*
homeassistant.components.here_travel_time.* homeassistant.components.here_travel_time.*
homeassistant.components.history.* homeassistant.components.history.*
homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_hardware.* homeassistant.components.homeassistant_hardware.*

View file

@ -20,6 +20,11 @@ from homeassistant.components.alexa import (
errors as alexa_errors, errors as alexa_errors,
state_report as alexa_state_report, 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.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.helpers import entity_registry as er, start from homeassistant.helpers import entity_registry as er, start
@ -30,16 +35,17 @@ from homeassistant.util.dt import utcnow
from .const import ( from .const import (
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
CONF_FILTER, CONF_FILTER,
PREF_ALEXA_DEFAULT_EXPOSE, DOMAIN as CLOUD_DOMAIN,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA, PREF_ENABLE_ALEXA,
PREF_SHOULD_EXPOSE, PREF_SHOULD_EXPOSE,
) )
from .prefs import CloudPreferences from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
# Time to wait when entity preferences have changed before syncing it to # Time to wait when entity preferences have changed before syncing it to
# the cloud. # the cloud.
SYNC_DELAY = 1 SYNC_DELAY = 1
@ -64,7 +70,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self._cloud = cloud self._cloud = cloud
self._token = None self._token = None
self._token_valid = 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._alexa_sync_unsub: Callable[[], None] | None = None
self._endpoint = None self._endpoint = None
@ -115,10 +121,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
"""Return an identifier for the user that represents this config.""" """Return an identifier for the user that represents this config."""
return self._cloud_user 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): async def async_initialize(self):
"""Initialize the Alexa config.""" """Initialize the Alexa config."""
await super().async_initialize() 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): async def hass_started(hass):
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
await async_setup_component(self.hass, ALEXA_DOMAIN, {}) await async_setup_component(self.hass, ALEXA_DOMAIN, {})
@ -126,19 +153,19 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
start.async_at_start(self.hass, hass_started) start.async_at_start(self.hass, hass_started)
self._prefs.async_listen_updates(self._async_prefs_updated) 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( self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED, er.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_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 an entity should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False 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_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {}) entity_config = entity_configs.get(entity_id, {})
entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
@ -160,6 +187,15 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose 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 @callback
def async_invalidate_access_token(self): def async_invalidate_access_token(self):
"""Invalidate access token.""" """Invalidate access token."""
@ -233,32 +269,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
if not any( if not any(
key in updated_prefs key in updated_prefs
for key in ( for key in (
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA, PREF_ENABLE_ALEXA,
) )
): ):
return 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()
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs
)
return
await self.async_sync_entities() await self.async_sync_entities()
@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()
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs
)
async def _sync_prefs(self, _now): async def _sync_prefs(self, _now):
"""Sync the updated preferences to Alexa.""" """Sync the updated preferences to Alexa."""
self._alexa_sync_unsub = None self._alexa_sync_unsub = None
old_prefs = self._cur_entity_prefs 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() seen = set()
to_update = [] to_update = []

View file

@ -19,6 +19,8 @@ PREF_USERNAME = "username"
PREF_REMOTE_DOMAIN = "remote_domain" PREF_REMOTE_DOMAIN = "remote_domain"
PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose"
PREF_GOOGLE_DEFAULT_EXPOSE = "google_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_TTS_DEFAULT_VOICE = "tts_default_voice"
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female")
DEFAULT_DISABLE_2FA = False DEFAULT_DISABLE_2FA = False

View file

@ -9,6 +9,10 @@ from hass_nabucasa.google_report_state import ErrorResponse
from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
from homeassistant.components.google_assistant.helpers import AbstractConfig 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.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import ( from homeassistant.core import (
CoreState, CoreState,
@ -22,14 +26,18 @@ from homeassistant.setup import async_setup_component
from .const import ( from .const import (
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
CONF_FILTER,
DEFAULT_DISABLE_2FA, DEFAULT_DISABLE_2FA,
DOMAIN as CLOUD_DOMAIN,
PREF_DISABLE_2FA, PREF_DISABLE_2FA,
PREF_SHOULD_EXPOSE, PREF_SHOULD_EXPOSE,
) )
from .prefs import CloudPreferences from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"
class CloudGoogleConfig(AbstractConfig): class CloudGoogleConfig(AbstractConfig):
"""HA Cloud Configuration for Google Assistant.""" """HA Cloud Configuration for Google Assistant."""
@ -48,8 +56,6 @@ class CloudGoogleConfig(AbstractConfig):
self._user = cloud_user self._user = cloud_user
self._prefs = prefs self._prefs = prefs
self._cloud = cloud self._cloud = cloud
self._cur_entity_prefs = self._prefs.google_entity_configs
self._cur_default_expose = self._prefs.google_default_expose
self._sync_entities_lock = asyncio.Lock() self._sync_entities_lock = asyncio.Lock()
@property @property
@ -89,10 +95,35 @@ class CloudGoogleConfig(AbstractConfig):
"""Return Cloud User account.""" """Return Cloud User account."""
return self._user 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): async def async_initialize(self):
"""Perform async initialization of config.""" """Perform async initialization of config."""
await super().async_initialize() 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): async def hass_started(hass):
if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components:
await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) await async_setup_component(self.hass, GOOGLE_DOMAIN, {})
@ -109,7 +140,9 @@ class CloudGoogleConfig(AbstractConfig):
await self.async_disconnect_agent_user(agent_user_id) await self.async_disconnect_agent_user(agent_user_id)
self._prefs.async_listen_updates(self._async_prefs_updated) 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( self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED, er.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entity_registry_updated, self._handle_entity_registry_updated,
@ -123,14 +156,11 @@ class CloudGoogleConfig(AbstractConfig):
"""If a state object should be exposed.""" """If a state object should be exposed."""
return self._should_expose_entity_id(state.entity_id) 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 an entity ID should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False return False
if not self._config["filter"].empty_filter:
return self._config["filter"](entity_id)
entity_configs = self._prefs.google_entity_configs entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(entity_id, {}) entity_config = entity_configs.get(entity_id, {})
entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
@ -154,6 +184,15 @@ class CloudGoogleConfig(AbstractConfig):
return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose 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 @property
def agent_user_id(self): def agent_user_id(self):
"""Return Agent User Id to use for query responses.""" """Return Agent User Id to use for query responses."""
@ -168,11 +207,23 @@ class CloudGoogleConfig(AbstractConfig):
"""Get agent user ID making request.""" """Get agent user ID making request."""
return self.agent_user_id 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.""" """If an entity should be checked for 2FA."""
entity_configs = self._prefs.google_entity_configs entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(state.entity_id, {}) entity_config = entity_configs.get(entity_id, {})
return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) 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): async def async_report_state(self, message, agent_user_id: str):
"""Send a state report to Google.""" """Send a state report to Google."""
@ -218,14 +269,6 @@ class CloudGoogleConfig(AbstractConfig):
# So when we change it, we need to sync all entities. # So when we change it, we need to sync all entities.
sync_entities = True 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: if self.enabled and not self.is_local_sdk_active:
self.async_enable_local_sdk() self.async_enable_local_sdk()
sync_entities = True sync_entities = True
@ -233,12 +276,14 @@ class CloudGoogleConfig(AbstractConfig):
self.async_disable_local_sdk() self.async_disable_local_sdk()
sync_entities = True 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: if sync_entities and self.hass.is_running:
await self.async_sync_entities_all() await self.async_sync_entities_all()
@callback
def _async_exposed_entities_updated(self) -> None:
"""Handle updated preferences."""
self.async_schedule_google_sync_all()
@callback @callback
def _handle_entity_registry_updated(self, event: Event) -> None: def _handle_entity_registry_updated(self, event: Event) -> None:
"""Handle when entity registry updated.""" """Handle when entity registry updated."""

View file

@ -1,5 +1,6 @@
"""The HTTP api to control the cloud integration.""" """The HTTP api to control the cloud integration."""
import asyncio import asyncio
from collections.abc import Mapping
import dataclasses import dataclasses
from functools import wraps from functools import wraps
from http import HTTPStatus from http import HTTPStatus
@ -22,22 +23,24 @@ from homeassistant.components.alexa import (
from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.location import async_detect_location_info from homeassistant.util.location import async_detect_location_info
from .const import ( from .const import (
DOMAIN, DOMAIN,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA, PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_TTS_DEFAULT_VOICE, PREF_TTS_DEFAULT_VOICE,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
) )
from .google_config import CLOUD_GOOGLE
from .repairs import async_manage_legacy_subscription_issue from .repairs import async_manage_legacy_subscription_issue
from .subscription import async_subscription_info 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_connect)
websocket_api.async_register_command(hass, websocket_remote_disconnect) 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_list)
websocket_api.async_register_command(hass, google_assistant_update) websocket_api.async_register_command(hass, google_assistant_update)
websocket_api.async_register_command(hass, alexa_list) 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, alexa_sync)
websocket_api.async_register_command(hass, thingtalk_convert) 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_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_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_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), vol.In(MAP_VOICE) 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)) 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 @websocket_api.require_admin
@_require_cloud_login @_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"})
@ -536,11 +585,14 @@ async def google_assistant_list(
"""List all google assistant entities.""" """List all google assistant entities."""
cloud = hass.data[DOMAIN] cloud = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config() gconf = await cloud.client.get_google_config()
entity_registry = er.async_get(hass)
entities = google_helpers.async_get_entities(hass, gconf) entities = google_helpers.async_get_entities(hass, gconf)
result = [] result = []
for entity in entities: for entity in entities:
if not entity_registry.async_is_registered(entity.entity_id):
continue
result.append( result.append(
{ {
"entity_id": entity.entity_id, "entity_id": entity.entity_id,
@ -558,8 +610,7 @@ async def google_assistant_list(
{ {
"type": "cloud/google_assistant/entities/update", "type": "cloud/google_assistant/entities/update",
"entity_id": str, "entity_id": str,
vol.Optional("should_expose"): vol.Any(None, bool), vol.Optional(PREF_DISABLE_2FA): bool,
vol.Optional("disable_2fa"): bool,
} }
) )
@websocket_api.async_response @websocket_api.async_response
@ -569,17 +620,30 @@ async def google_assistant_update(
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Update google assistant config.""" """Update google assistant entity config."""
cloud = hass.data[DOMAIN] entity_registry = er.async_get(hass)
changes = dict(msg) entity_id: str = msg["entity_id"]
changes.pop("type")
changes.pop("id")
await cloud.client.prefs.async_update_google_entity_config(**changes) 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
connection.send_result( disable_2fa = msg[PREF_DISABLE_2FA]
msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) assistant_options: Mapping[str, Any]
if (
assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {})
) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa:
return
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 @websocket_api.require_admin
@ -595,11 +659,14 @@ async def alexa_list(
"""List all alexa entities.""" """List all alexa entities."""
cloud = hass.data[DOMAIN] cloud = hass.data[DOMAIN]
alexa_config = await cloud.client.get_alexa_config() alexa_config = await cloud.client.get_alexa_config()
entity_registry = er.async_get(hass)
entities = alexa_entities.async_get_entities(hass, alexa_config) entities = alexa_entities.async_get_entities(hass, alexa_config)
result = [] result = []
for entity in entities: for entity in entities:
if not entity_registry.async_is_registered(entity.entity_id):
continue
result.append( result.append(
{ {
"entity_id": entity.entity_id, "entity_id": entity.entity_id,
@ -611,35 +678,6 @@ async def alexa_list(
connection.send_result(msg["id"], result) 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 @websocket_api.require_admin
@_require_cloud_login @_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/alexa/sync"}) @websocket_api.websocket_command({"type": "cloud/alexa/sync"})

View file

@ -3,7 +3,7 @@
"name": "Home Assistant Cloud", "name": "Home Assistant Cloud",
"after_dependencies": ["google_assistant", "alexa"], "after_dependencies": ["google_assistant", "alexa"],
"codeowners": ["@home-assistant/cloud"], "codeowners": ["@home-assistant/cloud"],
"dependencies": ["http", "webhook"], "dependencies": ["homeassistant", "http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/cloud", "documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system", "integration_type": "system",
"iot_class": "cloud_push", "iot_class": "cloud_push",

View file

@ -1,6 +1,8 @@
"""Preference management for cloud.""" """Preference management for cloud."""
from __future__ import annotations from __future__ import annotations
from typing import Any
from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User from homeassistant.auth.models import User
from homeassistant.components import webhook from homeassistant.components import webhook
@ -18,9 +20,9 @@ from .const import (
PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE, PREF_ALEXA_REPORT_STATE,
PREF_ALEXA_SETTINGS_VERSION,
PREF_CLOUD_USER, PREF_CLOUD_USER,
PREF_CLOUDHOOKS, PREF_CLOUDHOOKS,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA, PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE, PREF_ENABLE_GOOGLE,
PREF_ENABLE_REMOTE, PREF_ENABLE_REMOTE,
@ -29,14 +31,33 @@ from .const import (
PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_LOCAL_WEBHOOK_ID,
PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_GOOGLE_SETTINGS_VERSION,
PREF_REMOTE_DOMAIN, PREF_REMOTE_DOMAIN,
PREF_SHOULD_EXPOSE,
PREF_TTS_DEFAULT_VOICE, PREF_TTS_DEFAULT_VOICE,
PREF_USERNAME, PREF_USERNAME,
) )
STORAGE_KEY = DOMAIN STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1 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: class CloudPreferences:
@ -45,7 +66,9 @@ class CloudPreferences:
def __init__(self, hass): def __init__(self, hass):
"""Initialize cloud prefs.""" """Initialize cloud prefs."""
self._hass = hass 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._prefs = None
self._listeners = [] self._listeners = []
self.last_updated: set[str] = set() self.last_updated: set[str] = set()
@ -79,14 +102,12 @@ class CloudPreferences:
google_secure_devices_pin=UNDEFINED, google_secure_devices_pin=UNDEFINED,
cloudhooks=UNDEFINED, cloudhooks=UNDEFINED,
cloud_user=UNDEFINED, cloud_user=UNDEFINED,
google_entity_configs=UNDEFINED,
alexa_entity_configs=UNDEFINED,
alexa_report_state=UNDEFINED, alexa_report_state=UNDEFINED,
google_report_state=UNDEFINED, google_report_state=UNDEFINED,
alexa_default_expose=UNDEFINED,
google_default_expose=UNDEFINED,
tts_default_voice=UNDEFINED, tts_default_voice=UNDEFINED,
remote_domain=UNDEFINED, remote_domain=UNDEFINED,
alexa_settings_version=UNDEFINED,
google_settings_version=UNDEFINED,
): ):
"""Update user preferences.""" """Update user preferences."""
prefs = {**self._prefs} prefs = {**self._prefs}
@ -98,12 +119,10 @@ class CloudPreferences:
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUDHOOKS, cloudhooks),
(PREF_CLOUD_USER, cloud_user), (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_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_GOOGLE_REPORT_STATE, google_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose), (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
(PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose), (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
(PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_TTS_DEFAULT_VOICE, tts_default_voice),
(PREF_REMOTE_DOMAIN, remote_domain), (PREF_REMOTE_DOMAIN, remote_domain),
): ):
@ -112,53 +131,6 @@ class CloudPreferences:
await self._save_prefs(prefs) 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: async def async_set_username(self, username) -> bool:
"""Set the username that is logged in.""" """Set the username that is logged in."""
# Logging out. # Logging out.
@ -186,14 +158,12 @@ class CloudPreferences:
"""Return dictionary version.""" """Return dictionary version."""
return { return {
PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, 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_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks, PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled, PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled, PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, 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_REPORT_STATE: self.google_report_state,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
@ -235,6 +205,11 @@ class CloudPreferences:
"""Return Alexa Entity configurations.""" """Return Alexa Entity configurations."""
return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) 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 @property
def google_enabled(self): def google_enabled(self):
"""Return if Google is enabled.""" """Return if Google is enabled."""
@ -255,6 +230,11 @@ class CloudPreferences:
"""Return Google Entity configurations.""" """Return Google Entity configurations."""
return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) 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 @property
def google_local_webhook_id(self): def google_local_webhook_id(self):
"""Return Google webhook ID to receive local messages.""" """Return Google webhook ID to receive local messages."""
@ -319,6 +299,7 @@ class CloudPreferences:
return { return {
PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_ALEXA_ENTITY_CONFIGS: {}, PREF_ALEXA_ENTITY_CONFIGS: {},
PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION,
PREF_CLOUD_USER: None, PREF_CLOUD_USER: None,
PREF_CLOUDHOOKS: {}, PREF_CLOUDHOOKS: {},
PREF_ENABLE_ALEXA: True, PREF_ENABLE_ALEXA: True,
@ -326,6 +307,7 @@ class CloudPreferences:
PREF_ENABLE_REMOTE: False, PREF_ENABLE_REMOTE: False,
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_GOOGLE_ENTITY_CONFIGS: {},
PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION,
PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(),
PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_REMOTE_DOMAIN: None, PREF_REMOTE_DOMAIN: None,

View file

@ -33,10 +33,12 @@ from homeassistant.helpers.service import (
from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.template import async_load_custom_templates
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import DATA_EXPOSED_ENTITIES, DOMAIN
from .exposed_entities import ExposedEntities
ATTR_ENTRY_ID = "entry_id" ATTR_ENTRY_ID = "entry_id"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = ha.DOMAIN
SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CORE_CONFIG = "reload_core_config"
SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry"
SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates"
@ -340,4 +342,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no
hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all 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 return True

View file

@ -0,0 +1,6 @@
"""Constants for the Homeassistant integration."""
import homeassistant.core as ha
DOMAIN = ha.DOMAIN
DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites"

View file

@ -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)

View file

@ -1132,6 +1132,16 @@ disallow_untyped_defs = true
warn_return_any = true warn_return_any = true
warn_unreachable = 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] [mypy-homeassistant.components.homeassistant.triggers.event]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View file

@ -3,7 +3,7 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from homeassistant.components import cloud 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 from homeassistant.setup import async_setup_component
@ -18,9 +18,11 @@ async def mock_cloud(hass, config=None):
def mock_cloud_prefs(hass, prefs={}): def mock_cloud_prefs(hass, prefs={}):
"""Fixture for cloud component.""" """Fixture for cloud component."""
prefs_to_set = { prefs_to_set = {
const.PREF_ALEXA_SETTINGS_VERSION: cloud_prefs.ALEXA_SETTINGS_VERSION,
const.PREF_ENABLE_ALEXA: True, const.PREF_ENABLE_ALEXA: True,
const.PREF_ENABLE_GOOGLE: True, const.PREF_ENABLE_GOOGLE: True,
const.PREF_GOOGLE_SECURE_DEVICES_PIN: None, const.PREF_GOOGLE_SECURE_DEVICES_PIN: None,
const.PREF_GOOGLE_SETTINGS_VERSION: cloud_prefs.GOOGLE_SETTINGS_VERSION,
} }
prefs_to_set.update(prefs) prefs_to_set.update(prefs)
hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set

View file

@ -6,10 +6,22 @@ import pytest
from homeassistant.components.alexa import errors from homeassistant.components.alexa import errors
from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config 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.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
@ -21,10 +33,23 @@ def cloud_stub():
return Mock(is_logged_in=True, subscription_expired=False) 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( async def test_alexa_config_expose_entity_prefs(
hass: HomeAssistant, cloud_prefs, cloud_stub, entity_registry: er.EntityRegistry hass: HomeAssistant, cloud_prefs, cloud_stub, entity_registry: er.EntityRegistry
) -> None: ) -> None:
"""Test Alexa config should expose using prefs.""" """Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
entity_entry1 = entity_registry.async_get_or_create( entity_entry1 = entity_registry.async_get_or_create(
"light", "light",
"test", "test",
@ -53,54 +78,62 @@ async def test_alexa_config_expose_entity_prefs(
suggested_object_id="hidden_user_light", suggested_object_id="hidden_user_light",
hidden_by=er.RegistryEntryHider.USER, 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( await cloud_prefs.async_update(
alexa_entity_configs={"light.kitchen": entity_conf},
alexa_default_expose=["light"],
alexa_enabled=True, alexa_enabled=True,
alexa_report_state=False, alexa_report_state=False,
) )
expose_new(hass, True)
expose_entity(hass, entity_entry5.entity_id, False)
conf = alexa_config.CloudAlexaConfig( conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
) )
await conf.async_initialize() 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("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 # categorized and hidden entities should not be exposed
assert not conf.should_expose(entity_entry1.entity_id) assert not conf.should_expose(entity_entry1.entity_id)
assert not conf.should_expose(entity_entry2.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_entry3.entity_id)
assert not conf.should_expose(entity_entry4.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 expose_entity(hass, entity_entry5.entity_id, True)
assert conf.should_expose("light.kitchen") assert conf.should_expose(entity_entry5.entity_id)
# categorized and hidden entities should not be exposed
assert not conf.should_expose(entity_entry1.entity_id) expose_entity(hass, entity_entry5.entity_id, None)
assert not conf.should_expose(entity_entry2.entity_id) assert not conf.should_expose(entity_entry5.entity_id)
assert not conf.should_expose(entity_entry3.entity_id)
assert not conf.should_expose(entity_entry4.entity_id)
assert "alexa" not in hass.config.components assert "alexa" not in hass.config.components
await cloud_prefs.async_update(
alexa_default_expose=["sensor"],
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert "alexa" in hass.config.components 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( async def test_alexa_config_report_state(
hass: HomeAssistant, cloud_prefs, cloud_stub hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None: ) -> None:
"""Test Alexa config should expose using prefs.""" """Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
await cloud_prefs.async_update( await cloud_prefs.async_update(
alexa_report_state=False, alexa_report_state=False,
) )
@ -134,6 +167,8 @@ async def test_alexa_config_invalidate_token(
hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test Alexa config should expose using prefs.""" """Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
aioclient_mock.post( aioclient_mock.post(
"https://example/access_token", "https://example/access_token",
json={ json={
@ -181,10 +216,18 @@ async def test_alexa_config_fail_refresh_token(
hass: HomeAssistant, hass: HomeAssistant,
cloud_prefs, cloud_prefs,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
entity_registry: er.EntityRegistry,
reject_reason, reject_reason,
expected_exception, expected_exception,
) -> None: ) -> None:
"""Test Alexa config failing to refresh token.""" """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( aioclient_mock.post(
"https://example/access_token", "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.should_report_state is False
assert conf.is_reporting_states 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 # Enable state reporting
await cloud_prefs.async_update(alexa_report_state=True) 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 assert conf.is_reporting_states is True
# Change states to trigger event listener # 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() await hass.async_block_till_done()
# Invalidate the token and try to fetch another # 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 # 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() await hass.async_block_till_done()
# Check state reporting is still wanted in cloud prefs, but disabled for Alexa # Check state reporting is still wanted in cloud prefs, but disabled for Alexa
@ -292,16 +335,30 @@ def patch_sync_helper():
async def test_alexa_update_expose_trigger_sync( 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: ) -> None:
"""Test Alexa config responds to updating exposed entities.""" """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( hass.states.async_set(
"sensor.temp", sensor_entry.entity_id,
"23", "23",
{"device_class": "temperature", "unit_of_measurement": "°C"}, {"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( await cloud_prefs.async_update(
alexa_enabled=True, alexa_enabled=True,
@ -313,34 +370,26 @@ async def test_alexa_update_expose_trigger_sync(
await conf.async_initialize() await conf.async_initialize()
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config( expose_entity(hass, light_entry.entity_id, True)
entity_id="light.kitchen", should_expose=True
)
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, fire_all=True) async_fire_time_changed(hass, fire_all=True)
await hass.async_block_till_done() await hass.async_block_till_done()
assert conf._alexa_sync_unsub is None assert conf._alexa_sync_unsub is None
assert to_update == ["light.kitchen"] assert to_update == [light_entry.entity_id]
assert to_remove == [] assert to_remove == []
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config( expose_entity(hass, light_entry.entity_id, False)
entity_id="light.kitchen", should_expose=False expose_entity(hass, binary_sensor_entry.entity_id, True)
) expose_entity(hass, sensor_entry.entity_id, True)
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
)
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, fire_all=True) async_fire_time_changed(hass, fire_all=True)
await hass.async_block_till_done() await hass.async_block_till_done()
assert conf._alexa_sync_unsub is None assert conf._alexa_sync_unsub is None
assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"] assert sorted(to_update) == [binary_sensor_entry.entity_id, sensor_entry.entity_id]
assert to_remove == ["light.kitchen"] assert to_remove == [light_entry.entity_id]
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update( 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 conf._alexa_sync_unsub is None
assert to_update == [] 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( 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: ) -> None:
"""Test Alexa config responds to entity registry.""" """Test Alexa config responds to entity registry."""
# Enable exposing new entities to Alexa
expose_new(hass, True)
await alexa_config.CloudAlexaConfig( await alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
).async_initialize() ).async_initialize()
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire( entry = entity_registry.async_get_or_create(
er.EVENT_ENTITY_REGISTRY_UPDATED, "light", "test", "unique", suggested_object_id="kitchen"
{"action": "create", "entity_id": "light.kitchen"},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert to_update == ["light.kitchen"] assert to_update == [entry.entity_id]
assert to_remove == [] assert to_remove == []
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire( hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED, er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "remove", "entity_id": "light.kitchen"}, {"action": "remove", "entity_id": entry.entity_id},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert to_update == [] assert to_update == []
assert to_remove == ["light.kitchen"] assert to_remove == [entry.entity_id]
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire( hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED, er.EVENT_ENTITY_REGISTRY_UPDATED,
{ {
"action": "update", "action": "update",
"entity_id": "light.kitchen", "entity_id": entry.entity_id,
"changes": ["entity_id"], "changes": ["entity_id"],
"old_entity_id": "light.living_room", "old_entity_id": "light.living_room",
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert to_update == ["light.kitchen"] assert to_update == [entry.entity_id]
assert to_remove == ["light.living_room"] assert to_remove == ["light.living_room"]
with patch_sync_helper() as (to_update, to_remove): with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire( hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED, 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() await hass.async_block_till_done()
@ -411,6 +469,7 @@ async def test_alexa_update_report_state(
hass: HomeAssistant, cloud_prefs, cloud_stub hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None: ) -> None:
"""Test Alexa config responds to reporting state.""" """Test Alexa config responds to reporting state."""
assert await async_setup_component(hass, "homeassistant", {})
await cloud_prefs.async_update( await cloud_prefs.async_update(
alexa_report_state=False, alexa_report_state=False,
) )
@ -450,6 +509,7 @@ async def test_alexa_handle_logout(
hass: HomeAssistant, cloud_prefs, cloud_stub hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None: ) -> None:
"""Test Alexa config responds to logging out.""" """Test Alexa config responds to logging out."""
assert await async_setup_component(hass, "homeassistant", {})
aconf = alexa_config.CloudAlexaConfig( aconf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub 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() await hass.async_block_till_done()
assert len(mock_enable.return_value.mock_calls) == 1 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}}

View file

@ -13,8 +13,13 @@ from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA, PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE, PREF_ENABLE_GOOGLE,
) )
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util 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 hass: HomeAssistant, mock_cloud_setup, mock_cloud_login
) -> None: ) -> None:
"""Test Google config exposing entity method uses latest config.""" """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 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() gconf = await cloud_client.get_google_config()
assert gconf.should_expose(state) assert gconf.should_expose(state)
await cloud_client.prefs.async_update_google_entity_config( exposed_entities.async_expose_entity(
entity_id="light.kitchen", should_expose=False "cloud.google_assistant", entity_entry.entity_id, False
) )
assert not gconf.should_expose(state) 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 hass: HomeAssistant, mock_cloud_setup, mock_cloud_login
) -> None: ) -> None:
"""Test Google config disabling 2FA method uses latest config.""" """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 cloud_client = hass.data[DOMAIN].client
gconf = await cloud_client.get_google_config() gconf = await cloud_client.get_google_config()
state = State("light.kitchen", "on") state = State(entity_entry.entity_id, "on")
assert gconf.should_2fa(state) assert gconf.should_2fa(state)
await cloud_client.prefs.async_update_google_entity_config( entity_registry.async_update_entity_options(
entity_id="light.kitchen", disable_2fa=True entity_entry.entity_id, "cloud.google_assistant", {"disable_2fa": True}
) )
assert not gconf.should_2fa(state) assert not gconf.should_2fa(state)

View file

@ -6,11 +6,24 @@ from freezegun import freeze_time
import pytest import pytest
from homeassistant.components.cloud import GACTIONS_SCHEMA 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.google_config import CloudGoogleConfig
from homeassistant.components.cloud.prefs import CloudPreferences
from homeassistant.components.google_assistant import helpers as ga_helpers 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.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory
from homeassistant.core import CoreState, HomeAssistant, State 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.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from tests.common import async_fire_time_changed 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( async def test_google_update_report_state(
mock_conf, hass: HomeAssistant, cloud_prefs mock_conf, hass: HomeAssistant, cloud_prefs
) -> None: ) -> None:
"""Test Google config responds to updating preference.""" """Test Google config responds to updating preference."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize() await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id") 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 mock_conf, hass: HomeAssistant, cloud_prefs
) -> None: ) -> None:
"""Test Google config not reporting state when subscription has expired.""" """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_initialize()
await mock_conf.async_connect_agent_user("mock-user-id") 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: async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> None:
"""Test sync devices.""" """Test sync devices."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize() await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id") 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 hass: HomeAssistant, cloud_prefs
) -> None: ) -> None:
"""Test Google config responds to updating exposed entities.""" """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()): with freeze_time(utcnow()):
config = CloudGoogleConfig( config = CloudGoogleConfig(
hass, 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( with patch.object(config, "async_sync_entities") as mock_sync, patch.object(
ga_helpers, "SYNC_DELAY", 0 ga_helpers, "SYNC_DELAY", 0
): ):
await cloud_prefs.async_update_google_entity_config( expose_entity(hass, light_entry.entity_id, True)
entity_id="light.kitchen", should_expose=True
)
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow()) async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done() 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( with patch.object(config, "async_sync_entities") as mock_sync, patch.object(
ga_helpers, "SYNC_DELAY", 0 ga_helpers, "SYNC_DELAY", 0
): ):
await cloud_prefs.async_update_google_entity_config( expose_entity(hass, light_entry.entity_id, False)
entity_id="light.kitchen", should_expose=False expose_entity(hass, binary_sensor_entry.entity_id, True)
) expose_entity(hass, sensor_entry.entity_id, True)
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
)
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow()) async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done() await hass.async_block_till_done()
@ -134,6 +175,11 @@ async def test_google_entity_registry_sync(
hass: HomeAssistant, mock_cloud_login, cloud_prefs hass: HomeAssistant, mock_cloud_login, cloud_prefs
) -> None: ) -> None:
"""Test Google config responds to entity registry.""" """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( config = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] 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 ga_helpers, "SYNC_DELAY", 0
): ):
# Created entity # Created entity
hass.bus.async_fire( entry = entity_registry.async_get_or_create(
er.EVENT_ENTITY_REGISTRY_UPDATED, "light", "test", "unique", suggested_object_id="kitchen"
{"action": "create", "entity_id": "light.kitchen"},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -157,7 +202,7 @@ async def test_google_entity_registry_sync(
# Removed entity # Removed entity
hass.bus.async_fire( hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED, er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "remove", "entity_id": "light.kitchen"}, {"action": "remove", "entity_id": entry.entity_id},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -168,7 +213,7 @@ async def test_google_entity_registry_sync(
er.EVENT_ENTITY_REGISTRY_UPDATED, er.EVENT_ENTITY_REGISTRY_UPDATED,
{ {
"action": "update", "action": "update",
"entity_id": "light.kitchen", "entity_id": entry.entity_id,
"changes": ["entity_id"], "changes": ["entity_id"],
}, },
) )
@ -179,7 +224,7 @@ async def test_google_entity_registry_sync(
# Entity registry updated with non-relevant changes # Entity registry updated with non-relevant changes
hass.bus.async_fire( hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED, 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() await hass.async_block_till_done()
@ -189,7 +234,7 @@ async def test_google_entity_registry_sync(
hass.state = CoreState.starting hass.state = CoreState.starting
hass.bus.async_fire( hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED, er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"}, {"action": "create", "entity_id": entry.entity_id},
) )
await hass.async_block_till_done() 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"] hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
) )
ent_reg = er.async_get(hass) 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_get_or_create("light", "hue", "1234", device_id="1234")
entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD") 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 hass: HomeAssistant, mock_conf, cloud_prefs, entity_registry: er.EntityRegistry
) -> None: ) -> None:
"""Test Google config should expose using prefs.""" """Test Google config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
entity_entry1 = entity_registry.async_get_or_create( entity_entry1 = entity_registry.async_get_or_create(
"light", "light",
"test", "test",
@ -321,45 +371,49 @@ async def test_google_config_expose_entity_prefs(
suggested_object_id="hidden_user_light", suggested_object_id="hidden_user_light",
hidden_by=er.RegistryEntryHider.USER, hidden_by=er.RegistryEntryHider.USER,
) )
entity_entry5 = entity_registry.async_get_or_create(
entity_conf = {"should_expose": False} "light",
await cloud_prefs.async_update( "test",
google_entity_configs={"light.kitchen": entity_conf}, "light_basement_id",
google_default_expose=["light"], 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 = State("light.kitchen", "on")
state_config = State(entity_entry1.entity_id, "on") state_config = State(entity_entry1.entity_id, "on")
state_diagnostic = State(entity_entry2.entity_id, "on") state_diagnostic = State(entity_entry2.entity_id, "on")
state_hidden_integration = State(entity_entry3.entity_id, "on") state_hidden_integration = State(entity_entry3.entity_id, "on")
state_hidden_user = State(entity_entry4.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)
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 # categorized and hidden entities should not be exposed
assert not mock_conf.should_expose(state_config) assert not mock_conf.should_expose(state_config)
assert not mock_conf.should_expose(state_diagnostic) 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_integration)
assert not mock_conf.should_expose(state_hidden_user) 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 expose_entity(hass, entity_entry5.entity_id, True)
assert mock_conf.should_expose(state) assert mock_conf.should_expose(state_not_exposed)
# 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)
await cloud_prefs.async_update( expose_entity(hass, entity_entry5.entity_id, None)
google_default_expose=["sensor"], assert not mock_conf.should_expose(state_not_exposed)
)
assert not mock_conf.should_expose(state)
def test_enabled_requires_valid_sub( 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: async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None:
"""Test that we set up the integration if used.""" """Test that we set up the integration if used."""
assert await async_setup_component(hass, "homeassistant", {})
mock_conf._cloud.subscription_expired = False mock_conf._cloud.subscription_expired = False
assert "google_assistant" not in hass.config.components assert "google_assistant" not in hass.config.components
@ -423,3 +478,136 @@ async def test_google_handle_logout(
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_enable.return_value.mock_calls) == 1 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}}

View file

@ -15,6 +15,7 @@ from homeassistant.components.alexa.entities import LightCapabilities
from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.cloud.const import DOMAIN
from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.google_assistant.helpers import GoogleEntity
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.util.location import LocationInfo from homeassistant.util.location import LocationInfo
from . import mock_cloud, mock_cloud_prefs from . import mock_cloud, mock_cloud_prefs
@ -399,11 +400,9 @@ async def test_websocket_status(
"alexa_enabled": True, "alexa_enabled": True,
"cloudhooks": {}, "cloudhooks": {},
"google_enabled": True, "google_enabled": True,
"google_entity_configs": {},
"google_secure_devices_pin": None, "google_secure_devices_pin": None,
"google_default_expose": None, "google_default_expose": None,
"alexa_default_expose": None, "alexa_default_expose": None,
"alexa_entity_configs": {},
"alexa_report_state": True, "alexa_report_state": True,
"google_report_state": True, "google_report_state": True,
"remote_enabled": False, "remote_enabled": False,
@ -520,8 +519,6 @@ async def test_websocket_update_preferences(
"alexa_enabled": False, "alexa_enabled": False,
"google_enabled": False, "google_enabled": False,
"google_secure_devices_pin": "1234", "google_secure_devices_pin": "1234",
"google_default_expose": ["light", "switch"],
"alexa_default_expose": ["sensor", "media_player"],
"tts_default_voice": ["en-GB", "male"], "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.google_enabled
assert not setup_api.alexa_enabled assert not setup_api.alexa_enabled
assert setup_api.google_secure_devices_pin == "1234" 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") assert setup_api.tts_default_voice == ("en-GB", "male")
@ -683,7 +678,11 @@ async def test_enabling_remote(
async def test_list_google_entities( 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: ) -> None:
"""Test that we can list Google entities.""" """Test that we can list Google entities."""
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
@ -699,9 +698,25 @@ async def test_list_google_entities(
"homeassistant.components.google_assistant.helpers.async_get_entities", "homeassistant.components.google_assistant.helpers.async_get_entities",
return_value=[entity, entity2], 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() 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 response["success"]
assert len(response["result"]) == 2 assert len(response["result"]) == 2
assert response["result"][0] == { 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( 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: ) -> None:
"""Test that we can update config of a Google entity.""" """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) client = await hass_ws_client(hass)
await client.send_json( await client.send_json_auto_id(
{ {
"id": 5,
"type": "cloud/google_assistant/entities/update", "type": "cloud/google_assistant/entities/update",
"entity_id": "light.kitchen", "entity_id": "light.kitchen",
"should_expose": False,
"disable_2fa": False, "disable_2fa": False,
} }
) )
response = await client.receive_json() response = await client.receive_json()
assert response["success"] 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": "homeassistant/expose_entity",
"type": "cloud/google_assistant/entities/update", "assistants": ["cloud.google_assistant"],
"entity_id": "light.kitchen", "entity_ids": [entry.entity_id],
"should_expose": None, "should_expose": False,
} }
) )
response = await client.receive_json() response = await client.receive_json()
assert response["success"] assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.google_entity_configs["light.kitchen"] == { assert entity_registry.async_get(entry.entity_id).options[
"should_expose": None, "cloud.google_assistant"
"disable_2fa": False, ] == {"disable_2fa": False, "should_expose": False}
}
async def test_list_alexa_entities( 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: ) -> None:
"""Test that we can list Alexa entities.""" """Test that we can list Alexa entities."""
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
@ -769,9 +853,22 @@ async def test_list_alexa_entities(
"homeassistant.components.alexa.entities.async_get_entities", "homeassistant.components.alexa.entities.async_get_entities",
return_value=[entity], 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() 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 response["success"]
assert len(response["result"]) == 1 assert len(response["result"]) == 1
assert response["result"][0] == { assert response["result"][0] == {
@ -782,37 +879,31 @@ async def test_list_alexa_entities(
async def test_update_alexa_entity( 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: ) -> None:
"""Test that we can update config of an Alexa entity.""" """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) client = await hass_ws_client(hass)
await client.send_json( await client.send_json_auto_id(
{ {
"id": 5, "type": "homeassistant/expose_entity",
"type": "cloud/alexa/entities/update", "assistants": ["cloud.alexa"],
"entity_id": "light.kitchen", "entity_ids": [entry.entity_id],
"should_expose": False, "should_expose": False,
} }
) )
response = await client.receive_json() response = await client.receive_json()
assert response["success"] assert response["success"]
prefs = hass.data[DOMAIN].client.prefs assert entity_registry.async_get(entry.entity_id).options["cloud.alexa"] == {
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False} "should_expose": False
}
await client.send_json(
{
"id": 6,
"type": "cloud/alexa/entities/update",
"entity_id": "light.kitchen",
"should_expose": None,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None}
async def test_sync_alexa_entities_timeout( async def test_sync_alexa_entities_timeout(

View file

@ -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