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
pull/90940/head^2
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.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_hardware.*

View File

@ -20,6 +20,11 @@ from homeassistant.components.alexa import (
errors as alexa_errors,
state_report as alexa_state_report,
)
from homeassistant.components.homeassistant.exposed_entities import (
async_get_assistant_settings,
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.helpers import entity_registry as er, start
@ -30,16 +35,17 @@ from homeassistant.util.dt import utcnow
from .const import (
CONF_ENTITY_CONFIG,
CONF_FILTER,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
DOMAIN as CLOUD_DOMAIN,
PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA,
PREF_SHOULD_EXPOSE,
)
from .prefs import CloudPreferences
from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences
_LOGGER = logging.getLogger(__name__)
CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
# Time to wait when entity preferences have changed before syncing it to
# the cloud.
SYNC_DELAY = 1
@ -64,7 +70,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self._cloud = cloud
self._token = None
self._token_valid = None
self._cur_entity_prefs = prefs.alexa_entity_configs
self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA)
self._alexa_sync_unsub: Callable[[], None] | None = None
self._endpoint = None
@ -115,10 +121,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
"""Return an identifier for the user that represents this config."""
return self._cloud_user
def _migrate_alexa_entity_settings_v1(self):
"""Migrate alexa entity settings to entity registry options."""
if not self._config[CONF_FILTER].empty_filter:
# Don't migrate if there's a YAML config
return
entity_registry = er.async_get(self.hass)
for entity_id, entry in entity_registry.entities.items():
if CLOUD_ALEXA in entry.options:
continue
options = {"should_expose": self._should_expose_legacy(entity_id)}
entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options)
async def async_initialize(self):
"""Initialize the Alexa config."""
await super().async_initialize()
if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
if self._prefs.alexa_settings_version < 2:
self._migrate_alexa_entity_settings_v1()
await self._prefs.async_update(
alexa_settings_version=ALEXA_SETTINGS_VERSION
)
async def hass_started(hass):
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
await async_setup_component(self.hass, ALEXA_DOMAIN, {})
@ -126,19 +153,19 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
start.async_at_start(self.hass, hass_started)
self._prefs.async_listen_updates(self._async_prefs_updated)
async_listen_entity_updates(
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
)
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entity_registry_updated,
)
def should_expose(self, entity_id):
def _should_expose_legacy(self, entity_id):
"""If an entity should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
if not self._config[CONF_FILTER].empty_filter:
return self._config[CONF_FILTER](entity_id)
entity_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {})
entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
@ -160,6 +187,15 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose
def should_expose(self, entity_id):
"""If an entity should be exposed."""
if not self._config[CONF_FILTER].empty_filter:
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
return self._config[CONF_FILTER](entity_id)
return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)
@callback
def async_invalidate_access_token(self):
"""Invalidate access token."""
@ -233,32 +269,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
if not any(
key in updated_prefs
for key in (
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA,
)
):
return
# If we update just entity preferences, delay updating
# as we might update more
if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}:
if self._alexa_sync_unsub:
self._alexa_sync_unsub()
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs
)
return
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):
"""Sync the updated preferences to Alexa."""
self._alexa_sync_unsub = None
old_prefs = self._cur_entity_prefs
new_prefs = self._prefs.alexa_entity_configs
new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA)
seen = set()
to_update = []

View File

@ -19,6 +19,8 @@ PREF_USERNAME = "username"
PREF_REMOTE_DOMAIN = "remote_domain"
PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose"
PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose"
PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version"
PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female")
DEFAULT_DISABLE_2FA = False

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

View File

@ -1,5 +1,6 @@
"""The HTTP api to control the cloud integration."""
import asyncio
from collections.abc import Mapping
import dataclasses
from functools import wraps
from http import HTTPStatus
@ -22,22 +23,24 @@ from homeassistant.components.alexa import (
from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.location import async_detect_location_info
from .const import (
DOMAIN,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_REPORT_STATE,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_TTS_DEFAULT_VOICE,
REQUEST_TIMEOUT,
)
from .google_config import CLOUD_GOOGLE
from .repairs import async_manage_legacy_subscription_issue
from .subscription import async_subscription_info
@ -66,11 +69,11 @@ async def async_setup(hass):
websocket_api.async_register_command(hass, websocket_remote_connect)
websocket_api.async_register_command(hass, websocket_remote_disconnect)
websocket_api.async_register_command(hass, google_assistant_get)
websocket_api.async_register_command(hass, google_assistant_list)
websocket_api.async_register_command(hass, google_assistant_update)
websocket_api.async_register_command(hass, alexa_list)
websocket_api.async_register_command(hass, alexa_update)
websocket_api.async_register_command(hass, alexa_sync)
websocket_api.async_register_command(hass, thingtalk_convert)
@ -350,8 +353,6 @@ async def websocket_subscription(
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), vol.In(MAP_VOICE)
@ -523,6 +524,54 @@ async def websocket_remote_disconnect(
connection.send_result(msg["id"], await _account_data(hass, cloud))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
"type": "cloud/google_assistant/entities/get",
"entity_id": str,
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def google_assistant_get(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get data for a single google assistant entity."""
cloud = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config()
entity_registry = er.async_get(hass)
entity_id: str = msg["entity_id"]
state = hass.states.get(entity_id)
if not entity_registry.async_is_registered(entity_id) or not state:
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_FOUND,
f"{entity_id} unknown or not in the entity registry",
)
return
entity = google_helpers.GoogleEntity(hass, gconf, state)
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported():
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_SUPPORTED,
f"{entity_id} not supported by Google assistant",
)
return
result = {
"entity_id": entity.entity_id,
"traits": [trait.name for trait in entity.traits()],
"might_2fa": entity.might_2fa_traits(),
}
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"})
@ -536,11 +585,14 @@ async def google_assistant_list(
"""List all google assistant entities."""
cloud = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config()
entity_registry = er.async_get(hass)
entities = google_helpers.async_get_entities(hass, gconf)
result = []
for entity in entities:
if not entity_registry.async_is_registered(entity.entity_id):
continue
result.append(
{
"entity_id": entity.entity_id,
@ -558,8 +610,7 @@ async def google_assistant_list(
{
"type": "cloud/google_assistant/entities/update",
"entity_id": str,
vol.Optional("should_expose"): vol.Any(None, bool),
vol.Optional("disable_2fa"): bool,
vol.Optional(PREF_DISABLE_2FA): bool,
}
)
@websocket_api.async_response
@ -569,17 +620,30 @@ async def google_assistant_update(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update google assistant config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop("type")
changes.pop("id")
"""Update google assistant entity config."""
entity_registry = er.async_get(hass)
entity_id: str = msg["entity_id"]
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(
msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"])
disable_2fa = msg[PREF_DISABLE_2FA]
assistant_options: Mapping[str, Any]
if (
assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {})
) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa:
return
assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa}
entity_registry.async_update_entity_options(
entity_id, CLOUD_GOOGLE, assistant_options
)
connection.send_result(msg["id"])
@websocket_api.require_admin
@ -595,11 +659,14 @@ async def alexa_list(
"""List all alexa entities."""
cloud = hass.data[DOMAIN]
alexa_config = await cloud.client.get_alexa_config()
entity_registry = er.async_get(hass)
entities = alexa_entities.async_get_entities(hass, alexa_config)
result = []
for entity in entities:
if not entity_registry.async_is_registered(entity.entity_id):
continue
result.append(
{
"entity_id": entity.entity_id,
@ -611,35 +678,6 @@ async def alexa_list(
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
"type": "cloud/alexa/entities/update",
"entity_id": str,
vol.Optional("should_expose"): vol.Any(None, bool),
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def alexa_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update alexa entity config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop("type")
changes.pop("id")
await cloud.client.prefs.async_update_alexa_entity_config(**changes)
connection.send_result(
msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"])
)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/alexa/sync"})

View File

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

View File

@ -1,6 +1,8 @@
"""Preference management for cloud."""
from __future__ import annotations
from typing import Any
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
from homeassistant.components import webhook
@ -18,9 +20,9 @@ from .const import (
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE,
PREF_ALEXA_SETTINGS_VERSION,
PREF_CLOUD_USER,
PREF_CLOUDHOOKS,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
PREF_ENABLE_REMOTE,
@ -29,14 +31,33 @@ from .const import (
PREF_GOOGLE_LOCAL_WEBHOOK_ID,
PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_GOOGLE_SETTINGS_VERSION,
PREF_REMOTE_DOMAIN,
PREF_SHOULD_EXPOSE,
PREF_TTS_DEFAULT_VOICE,
PREF_USERNAME,
)
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
ALEXA_SETTINGS_VERSION = 2
GOOGLE_SETTINGS_VERSION = 2
class CloudPreferencesStore(Store):
"""Store entity registry data."""
async def _async_migrate_func(
self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any]
) -> dict[str, Any]:
"""Migrate to the new version."""
if old_major_version == 1:
if old_minor_version < 2:
old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1)
old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1)
return old_data
class CloudPreferences:
@ -45,7 +66,9 @@ class CloudPreferences:
def __init__(self, hass):
"""Initialize cloud prefs."""
self._hass = hass
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
self._store = CloudPreferencesStore(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
)
self._prefs = None
self._listeners = []
self.last_updated: set[str] = set()
@ -79,14 +102,12 @@ class CloudPreferences:
google_secure_devices_pin=UNDEFINED,
cloudhooks=UNDEFINED,
cloud_user=UNDEFINED,
google_entity_configs=UNDEFINED,
alexa_entity_configs=UNDEFINED,
alexa_report_state=UNDEFINED,
google_report_state=UNDEFINED,
alexa_default_expose=UNDEFINED,
google_default_expose=UNDEFINED,
tts_default_voice=UNDEFINED,
remote_domain=UNDEFINED,
alexa_settings_version=UNDEFINED,
google_settings_version=UNDEFINED,
):
"""Update user preferences."""
prefs = {**self._prefs}
@ -98,12 +119,10 @@ class CloudPreferences:
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_CLOUDHOOKS, cloudhooks),
(PREF_CLOUD_USER, cloud_user),
(PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
(PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs),
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose),
(PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose),
(PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
(PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
(PREF_TTS_DEFAULT_VOICE, tts_default_voice),
(PREF_REMOTE_DOMAIN, remote_domain),
):
@ -112,53 +131,6 @@ class CloudPreferences:
await self._save_prefs(prefs)
async def async_update_google_entity_config(
self,
*,
entity_id,
disable_2fa=UNDEFINED,
should_expose=UNDEFINED,
):
"""Update config for a Google entity."""
entities = self.google_entity_configs
entity = entities.get(entity_id, {})
changes = {}
for key, value in (
(PREF_DISABLE_2FA, disable_2fa),
(PREF_SHOULD_EXPOSE, should_expose),
):
if value is not UNDEFINED:
changes[key] = value
if not changes:
return
updated_entity = {**entity, **changes}
updated_entities = {**entities, entity_id: updated_entity}
await self.async_update(google_entity_configs=updated_entities)
async def async_update_alexa_entity_config(
self, *, entity_id, should_expose=UNDEFINED
):
"""Update config for an Alexa entity."""
entities = self.alexa_entity_configs
entity = entities.get(entity_id, {})
changes = {}
for key, value in ((PREF_SHOULD_EXPOSE, should_expose),):
if value is not UNDEFINED:
changes[key] = value
if not changes:
return
updated_entity = {**entity, **changes}
updated_entities = {**entities, entity_id: updated_entity}
await self.async_update(alexa_entity_configs=updated_entities)
async def async_set_username(self, username) -> bool:
"""Set the username that is logged in."""
# Logging out.
@ -186,14 +158,12 @@ class CloudPreferences:
"""Return dictionary version."""
return {
PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose,
PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs,
PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
PREF_GOOGLE_REPORT_STATE: self.google_report_state,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
@ -235,6 +205,11 @@ class CloudPreferences:
"""Return Alexa Entity configurations."""
return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {})
@property
def alexa_settings_version(self):
"""Return version of Alexa settings."""
return self._prefs[PREF_ALEXA_SETTINGS_VERSION]
@property
def google_enabled(self):
"""Return if Google is enabled."""
@ -255,6 +230,11 @@ class CloudPreferences:
"""Return Google Entity configurations."""
return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {})
@property
def google_settings_version(self):
"""Return version of Google settings."""
return self._prefs[PREF_GOOGLE_SETTINGS_VERSION]
@property
def google_local_webhook_id(self):
"""Return Google webhook ID to receive local messages."""
@ -319,6 +299,7 @@ class CloudPreferences:
return {
PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_ALEXA_ENTITY_CONFIGS: {},
PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION,
PREF_CLOUD_USER: None,
PREF_CLOUDHOOKS: {},
PREF_ENABLE_ALEXA: True,
@ -326,6 +307,7 @@ class CloudPreferences:
PREF_ENABLE_REMOTE: False,
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_GOOGLE_ENTITY_CONFIGS: {},
PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION,
PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(),
PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_REMOTE_DOMAIN: None,

View File

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

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_unreachable = true
[mypy-homeassistant.components.homeassistant.exposed_entities]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.homeassistant.triggers.event]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

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

View File

@ -6,10 +6,22 @@ import pytest
from homeassistant.components.alexa import errors
from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config
from homeassistant.components.cloud.const import (
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_SHOULD_EXPOSE,
)
from homeassistant.components.cloud.prefs import CloudPreferences
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.setup import async_setup_component
from tests.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
@ -21,10 +33,23 @@ def cloud_stub():
return Mock(is_logged_in=True, subscription_expired=False)
def expose_new(hass, expose_new):
"""Enable exposing new entities to Alexa."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new)
def expose_entity(hass, entity_id, should_expose):
"""Expose an entity to Alexa."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose)
async def test_alexa_config_expose_entity_prefs(
hass: HomeAssistant, cloud_prefs, cloud_stub, entity_registry: er.EntityRegistry
) -> None:
"""Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
entity_entry1 = entity_registry.async_get_or_create(
"light",
"test",
@ -53,54 +78,62 @@ async def test_alexa_config_expose_entity_prefs(
suggested_object_id="hidden_user_light",
hidden_by=er.RegistryEntryHider.USER,
)
entity_entry5 = entity_registry.async_get_or_create(
"light",
"test",
"light_basement_id",
suggested_object_id="basement",
)
entity_entry6 = entity_registry.async_get_or_create(
"light",
"test",
"light_entrance_id",
suggested_object_id="entrance",
)
entity_conf = {"should_expose": False}
await cloud_prefs.async_update(
alexa_entity_configs={"light.kitchen": entity_conf},
alexa_default_expose=["light"],
alexa_enabled=True,
alexa_report_state=False,
)
expose_new(hass, True)
expose_entity(hass, entity_entry5.entity_id, False)
conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
await conf.async_initialize()
# can't expose an entity which is not in the entity registry
with pytest.raises(HomeAssistantError):
expose_entity(hass, "light.kitchen", True)
assert not conf.should_expose("light.kitchen")
assert not conf.should_expose(entity_entry1.entity_id)
assert not conf.should_expose(entity_entry2.entity_id)
assert not conf.should_expose(entity_entry3.entity_id)
assert not conf.should_expose(entity_entry4.entity_id)
entity_conf["should_expose"] = True
assert conf.should_expose("light.kitchen")
# categorized and hidden entities should not be exposed
assert not conf.should_expose(entity_entry1.entity_id)
assert not conf.should_expose(entity_entry2.entity_id)
assert not conf.should_expose(entity_entry3.entity_id)
assert not conf.should_expose(entity_entry4.entity_id)
# this has been hidden
assert not conf.should_expose(entity_entry5.entity_id)
# exposed by default
assert conf.should_expose(entity_entry6.entity_id)
entity_conf["should_expose"] = None
assert conf.should_expose("light.kitchen")
# categorized and hidden entities should not be exposed
assert not conf.should_expose(entity_entry1.entity_id)
assert not conf.should_expose(entity_entry2.entity_id)
assert not conf.should_expose(entity_entry3.entity_id)
assert not conf.should_expose(entity_entry4.entity_id)
expose_entity(hass, entity_entry5.entity_id, True)
assert conf.should_expose(entity_entry5.entity_id)
expose_entity(hass, entity_entry5.entity_id, None)
assert not conf.should_expose(entity_entry5.entity_id)
assert "alexa" not in hass.config.components
await cloud_prefs.async_update(
alexa_default_expose=["sensor"],
)
await hass.async_block_till_done()
assert "alexa" in hass.config.components
assert not conf.should_expose("light.kitchen")
assert not conf.should_expose(entity_entry5.entity_id)
async def test_alexa_config_report_state(
hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
await cloud_prefs.async_update(
alexa_report_state=False,
)
@ -134,6 +167,8 @@ async def test_alexa_config_invalidate_token(
hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
aioclient_mock.post(
"https://example/access_token",
json={
@ -181,10 +216,18 @@ async def test_alexa_config_fail_refresh_token(
hass: HomeAssistant,
cloud_prefs,
aioclient_mock: AiohttpClientMocker,
entity_registry: er.EntityRegistry,
reject_reason,
expected_exception,
) -> None:
"""Test Alexa config failing to refresh token."""
assert await async_setup_component(hass, "homeassistant", {})
# Enable exposing new entities to Alexa
expose_new(hass, True)
# Register a fan entity
entity_entry = entity_registry.async_get_or_create(
"fan", "test", "unique", suggested_object_id="test_fan"
)
aioclient_mock.post(
"https://example/access_token",
@ -216,7 +259,7 @@ async def test_alexa_config_fail_refresh_token(
assert conf.should_report_state is False
assert conf.is_reporting_states is False
hass.states.async_set("fan.test_fan", "off")
hass.states.async_set(entity_entry.entity_id, "off")
# Enable state reporting
await cloud_prefs.async_update(alexa_report_state=True)
@ -227,7 +270,7 @@ async def test_alexa_config_fail_refresh_token(
assert conf.is_reporting_states is True
# Change states to trigger event listener
hass.states.async_set("fan.test_fan", "on")
hass.states.async_set(entity_entry.entity_id, "on")
await hass.async_block_till_done()
# Invalidate the token and try to fetch another
@ -240,7 +283,7 @@ async def test_alexa_config_fail_refresh_token(
)
# Change states to trigger event listener
hass.states.async_set("fan.test_fan", "off")
hass.states.async_set(entity_entry.entity_id, "off")
await hass.async_block_till_done()
# Check state reporting is still wanted in cloud prefs, but disabled for Alexa
@ -292,16 +335,30 @@ def patch_sync_helper():
async def test_alexa_update_expose_trigger_sync(
hass: HomeAssistant, cloud_prefs, cloud_stub
hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config responds to updating exposed entities."""
hass.states.async_set("binary_sensor.door", "on")
assert await async_setup_component(hass, "homeassistant", {})
# Enable exposing new entities to Alexa
expose_new(hass, True)
# Register entities
binary_sensor_entry = entity_registry.async_get_or_create(
"binary_sensor", "test", "unique", suggested_object_id="door"
)
sensor_entry = entity_registry.async_get_or_create(
"sensor", "test", "unique", suggested_object_id="temp"
)
light_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
hass.states.async_set(binary_sensor_entry.entity_id, "on")
hass.states.async_set(
"sensor.temp",
sensor_entry.entity_id,
"23",
{"device_class": "temperature", "unit_of_measurement": "°C"},
)
hass.states.async_set("light.kitchen", "off")
hass.states.async_set(light_entry.entity_id, "off")
await cloud_prefs.async_update(
alexa_enabled=True,
@ -313,34 +370,26 @@ async def test_alexa_update_expose_trigger_sync(
await conf.async_initialize()
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config(
entity_id="light.kitchen", should_expose=True
)
expose_entity(hass, light_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, fire_all=True)
await hass.async_block_till_done()
assert conf._alexa_sync_unsub is None
assert to_update == ["light.kitchen"]
assert to_update == [light_entry.entity_id]
assert to_remove == []
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config(
entity_id="light.kitchen", should_expose=False
)
await cloud_prefs.async_update_alexa_entity_config(
entity_id="binary_sensor.door", should_expose=True
)
await cloud_prefs.async_update_alexa_entity_config(
entity_id="sensor.temp", should_expose=True
)
expose_entity(hass, light_entry.entity_id, False)
expose_entity(hass, binary_sensor_entry.entity_id, True)
expose_entity(hass, sensor_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, fire_all=True)
await hass.async_block_till_done()
assert conf._alexa_sync_unsub is None
assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"]
assert to_remove == ["light.kitchen"]
assert sorted(to_update) == [binary_sensor_entry.entity_id, sensor_entry.entity_id]
assert to_remove == [light_entry.entity_id]
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update(
@ -350,56 +399,65 @@ async def test_alexa_update_expose_trigger_sync(
assert conf._alexa_sync_unsub is None
assert to_update == []
assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"]
assert to_remove == [
binary_sensor_entry.entity_id,
sensor_entry.entity_id,
light_entry.entity_id,
]
async def test_alexa_entity_registry_sync(
hass: HomeAssistant, mock_cloud_login, cloud_prefs
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_cloud_login,
cloud_prefs,
) -> None:
"""Test Alexa config responds to entity registry."""
# Enable exposing new entities to Alexa
expose_new(hass, True)
await alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
).async_initialize()
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"},
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
await hass.async_block_till_done()
assert to_update == ["light.kitchen"]
assert to_update == [entry.entity_id]
assert to_remove == []
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "remove", "entity_id": "light.kitchen"},
{"action": "remove", "entity_id": entry.entity_id},
)
await hass.async_block_till_done()
assert to_update == []
assert to_remove == ["light.kitchen"]
assert to_remove == [entry.entity_id]
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{
"action": "update",
"entity_id": "light.kitchen",
"entity_id": entry.entity_id,
"changes": ["entity_id"],
"old_entity_id": "light.living_room",
},
)
await hass.async_block_till_done()
assert to_update == ["light.kitchen"]
assert to_update == [entry.entity_id]
assert to_remove == ["light.living_room"]
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]},
{"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]},
)
await hass.async_block_till_done()
@ -411,6 +469,7 @@ async def test_alexa_update_report_state(
hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config responds to reporting state."""
assert await async_setup_component(hass, "homeassistant", {})
await cloud_prefs.async_update(
alexa_report_state=False,
)
@ -450,6 +509,7 @@ async def test_alexa_handle_logout(
hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config responds to logging out."""
assert await async_setup_component(hass, "homeassistant", {})
aconf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
@ -475,3 +535,118 @@ async def test_alexa_handle_logout(
await hass.async_block_till_done()
assert len(mock_enable.return_value.mock_calls) == 1
async def test_alexa_config_migrate_expose_entity_prefs(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
cloud_stub,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Alexa entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_exposed = entity_registry.async_get_or_create(
"light",
"test",
"light_exposed",
suggested_object_id="exposed",
)
entity_migrated = entity_registry.async_get_or_create(
"light",
"test",
"light_migrated",
suggested_object_id="migrated",
)
entity_config = entity_registry.async_get_or_create(
"light",
"test",
"light_config",
suggested_object_id="config",
entity_category=EntityCategory.CONFIG,
)
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
entity_blocked = entity_registry.async_get_or_create(
"group",
"test",
"group_all_locks",
suggested_object_id="all_locks",
)
assert entity_blocked.entity_id == "group.all_locks"
await cloud_prefs.async_update(
alexa_enabled=True,
alexa_report_state=False,
alexa_settings_version=1,
)
expose_entity(hass, entity_migrated.entity_id, False)
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_exposed.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
await conf.async_initialize()
entity_exposed = entity_registry.async_get(entity_exposed.entity_id)
assert entity_exposed.options == {"cloud.alexa": {"should_expose": True}}
entity_migrated = entity_registry.async_get(entity_migrated.entity_id)
assert entity_migrated.options == {"cloud.alexa": {"should_expose": False}}
entity_config = entity_registry.async_get(entity_config.entity_id)
assert entity_config.options == {"cloud.alexa": {"should_expose": False}}
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.alexa": {"should_expose": True}}
entity_blocked = entity_registry.async_get(entity_blocked.entity_id)
assert entity_blocked.options == {"cloud.alexa": {"should_expose": False}}
async def test_alexa_config_migrate_expose_entity_prefs_default_none(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
cloud_stub,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Alexa entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
await cloud_prefs.async_update(
alexa_enabled=True,
alexa_report_state=False,
alexa_settings_version=1,
)
cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = None
conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
await conf.async_initialize()
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.alexa": {"should_expose": True}}

View File

@ -13,8 +13,13 @@ from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
)
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@ -245,14 +250,25 @@ async def test_google_config_expose_entity(
hass: HomeAssistant, mock_cloud_setup, mock_cloud_login
) -> None:
"""Test Google config exposing entity method uses latest config."""
entity_registry = er.async_get(hass)
# Enable exposing new entities to Google
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities("cloud.google_assistant", True)
# Register a light entity
entity_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
cloud_client = hass.data[DOMAIN].client
state = State("light.kitchen", "on")
state = State(entity_entry.entity_id, "on")
gconf = await cloud_client.get_google_config()
assert gconf.should_expose(state)
await cloud_client.prefs.async_update_google_entity_config(
entity_id="light.kitchen", should_expose=False
exposed_entities.async_expose_entity(
"cloud.google_assistant", entity_entry.entity_id, False
)
assert not gconf.should_expose(state)
@ -262,14 +278,21 @@ async def test_google_config_should_2fa(
hass: HomeAssistant, mock_cloud_setup, mock_cloud_login
) -> None:
"""Test Google config disabling 2FA method uses latest config."""
entity_registry = er.async_get(hass)
# Register a light entity
entity_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
cloud_client = hass.data[DOMAIN].client
gconf = await cloud_client.get_google_config()
state = State("light.kitchen", "on")
state = State(entity_entry.entity_id, "on")
assert gconf.should_2fa(state)
await cloud_client.prefs.async_update_google_entity_config(
entity_id="light.kitchen", disable_2fa=True
entity_registry.async_update_entity_options(
entity_entry.entity_id, "cloud.google_assistant", {"disable_2fa": True}
)
assert not gconf.should_2fa(state)

View File

@ -6,11 +6,24 @@ from freezegun import freeze_time
import pytest
from homeassistant.components.cloud import GACTIONS_SCHEMA
from homeassistant.components.cloud.const import (
PREF_DISABLE_2FA,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_ENTITY_CONFIGS,
PREF_SHOULD_EXPOSE,
)
from homeassistant.components.cloud.google_config import CloudGoogleConfig
from homeassistant.components.cloud.prefs import CloudPreferences
from homeassistant.components.google_assistant import helpers as ga_helpers
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from tests.common import async_fire_time_changed
@ -28,10 +41,26 @@ def mock_conf(hass, cloud_prefs):
)
def expose_new(hass, expose_new):
"""Enable exposing new entities to Google."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new)
def expose_entity(hass, entity_id, should_expose):
"""Expose an entity to Google."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_expose_entity(
"cloud.google_assistant", entity_id, should_expose
)
async def test_google_update_report_state(
mock_conf, hass: HomeAssistant, cloud_prefs
) -> None:
"""Test Google config responds to updating preference."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id")
@ -51,6 +80,8 @@ async def test_google_update_report_state_subscription_expired(
mock_conf, hass: HomeAssistant, cloud_prefs
) -> None:
"""Test Google config not reporting state when subscription has expired."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id")
@ -68,6 +99,8 @@ async def test_google_update_report_state_subscription_expired(
async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> None:
"""Test sync devices."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id")
@ -88,6 +121,22 @@ async def test_google_update_expose_trigger_sync(
hass: HomeAssistant, cloud_prefs
) -> None:
"""Test Google config responds to updating exposed entities."""
assert await async_setup_component(hass, "homeassistant", {})
entity_registry = er.async_get(hass)
# Enable exposing new entities to Google
expose_new(hass, True)
# Register entities
binary_sensor_entry = entity_registry.async_get_or_create(
"binary_sensor", "test", "unique", suggested_object_id="door"
)
sensor_entry = entity_registry.async_get_or_create(
"sensor", "test", "unique", suggested_object_id="temp"
)
light_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
with freeze_time(utcnow()):
config = CloudGoogleConfig(
hass,
@ -102,9 +151,7 @@ async def test_google_update_expose_trigger_sync(
with patch.object(config, "async_sync_entities") as mock_sync, patch.object(
ga_helpers, "SYNC_DELAY", 0
):
await cloud_prefs.async_update_google_entity_config(
entity_id="light.kitchen", should_expose=True
)
expose_entity(hass, light_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done()
@ -114,15 +161,9 @@ async def test_google_update_expose_trigger_sync(
with patch.object(config, "async_sync_entities") as mock_sync, patch.object(
ga_helpers, "SYNC_DELAY", 0
):
await cloud_prefs.async_update_google_entity_config(
entity_id="light.kitchen", should_expose=False
)
await cloud_prefs.async_update_google_entity_config(
entity_id="binary_sensor.door", should_expose=True
)
await cloud_prefs.async_update_google_entity_config(
entity_id="sensor.temp", should_expose=True
)
expose_entity(hass, light_entry.entity_id, False)
expose_entity(hass, binary_sensor_entry.entity_id, True)
expose_entity(hass, sensor_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done()
@ -134,6 +175,11 @@ async def test_google_entity_registry_sync(
hass: HomeAssistant, mock_cloud_login, cloud_prefs
) -> None:
"""Test Google config responds to entity registry."""
entity_registry = er.async_get(hass)
# Enable exposing new entities to Google
expose_new(hass, True)
config = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
)
@ -146,9 +192,8 @@ async def test_google_entity_registry_sync(
ga_helpers, "SYNC_DELAY", 0
):
# Created entity
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"},
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
await hass.async_block_till_done()
@ -157,7 +202,7 @@ async def test_google_entity_registry_sync(
# Removed entity
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "remove", "entity_id": "light.kitchen"},
{"action": "remove", "entity_id": entry.entity_id},
)
await hass.async_block_till_done()
@ -168,7 +213,7 @@ async def test_google_entity_registry_sync(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{
"action": "update",
"entity_id": "light.kitchen",
"entity_id": entry.entity_id,
"changes": ["entity_id"],
},
)
@ -179,7 +224,7 @@ async def test_google_entity_registry_sync(
# Entity registry updated with non-relevant changes
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]},
{"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]},
)
await hass.async_block_till_done()
@ -189,7 +234,7 @@ async def test_google_entity_registry_sync(
hass.state = CoreState.starting
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"},
{"action": "create", "entity_id": entry.entity_id},
)
await hass.async_block_till_done()
@ -204,6 +249,10 @@ async def test_google_device_registry_sync(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
)
ent_reg = er.async_get(hass)
# Enable exposing new entities to Google
expose_new(hass, True)
entity_entry = ent_reg.async_get_or_create("light", "hue", "1234", device_id="1234")
entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD")
@ -293,6 +342,7 @@ async def test_google_config_expose_entity_prefs(
hass: HomeAssistant, mock_conf, cloud_prefs, entity_registry: er.EntityRegistry
) -> None:
"""Test Google config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
entity_entry1 = entity_registry.async_get_or_create(
"light",
"test",
@ -321,45 +371,49 @@ async def test_google_config_expose_entity_prefs(
suggested_object_id="hidden_user_light",
hidden_by=er.RegistryEntryHider.USER,
)
entity_conf = {"should_expose": False}
await cloud_prefs.async_update(
google_entity_configs={"light.kitchen": entity_conf},
google_default_expose=["light"],
entity_entry5 = entity_registry.async_get_or_create(
"light",
"test",
"light_basement_id",
suggested_object_id="basement",
)
entity_entry6 = entity_registry.async_get_or_create(
"light",
"test",
"light_entrance_id",
suggested_object_id="entrance",
)
expose_new(hass, True)
expose_entity(hass, entity_entry5.entity_id, False)
state = State("light.kitchen", "on")
state_config = State(entity_entry1.entity_id, "on")
state_diagnostic = State(entity_entry2.entity_id, "on")
state_hidden_integration = State(entity_entry3.entity_id, "on")
state_hidden_user = State(entity_entry4.entity_id, "on")
state_not_exposed = State(entity_entry5.entity_id, "on")
state_exposed_default = State(entity_entry6.entity_id, "on")
# can't expose an entity which is not in the entity registry
with pytest.raises(HomeAssistantError):
expose_entity(hass, "light.kitchen", True)
assert not mock_conf.should_expose(state)
assert not mock_conf.should_expose(state_config)
assert not mock_conf.should_expose(state_diagnostic)
assert not mock_conf.should_expose(state_hidden_integration)
assert not mock_conf.should_expose(state_hidden_user)
entity_conf["should_expose"] = True
assert mock_conf.should_expose(state)
# categorized and hidden entities should not be exposed
assert not mock_conf.should_expose(state_config)
assert not mock_conf.should_expose(state_diagnostic)
assert not mock_conf.should_expose(state_hidden_integration)
assert not mock_conf.should_expose(state_hidden_user)
# this has been hidden
assert not mock_conf.should_expose(state_not_exposed)
# exposed by default
assert mock_conf.should_expose(state_exposed_default)
entity_conf["should_expose"] = None
assert mock_conf.should_expose(state)
# categorized and hidden entities should not be exposed
assert not mock_conf.should_expose(state_config)
assert not mock_conf.should_expose(state_diagnostic)
assert not mock_conf.should_expose(state_hidden_integration)
assert not mock_conf.should_expose(state_hidden_user)
expose_entity(hass, entity_entry5.entity_id, True)
assert mock_conf.should_expose(state_not_exposed)
await cloud_prefs.async_update(
google_default_expose=["sensor"],
)
assert not mock_conf.should_expose(state)
expose_entity(hass, entity_entry5.entity_id, None)
assert not mock_conf.should_expose(state_not_exposed)
def test_enabled_requires_valid_sub(
@ -379,6 +433,7 @@ def test_enabled_requires_valid_sub(
async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None:
"""Test that we set up the integration if used."""
assert await async_setup_component(hass, "homeassistant", {})
mock_conf._cloud.subscription_expired = False
assert "google_assistant" not in hass.config.components
@ -423,3 +478,136 @@ async def test_google_handle_logout(
await hass.async_block_till_done()
assert len(mock_enable.return_value.mock_calls) == 1
async def test_google_config_migrate_expose_entity_prefs(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Google entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_exposed = entity_registry.async_get_or_create(
"light",
"test",
"light_exposed",
suggested_object_id="exposed",
)
entity_no_2fa_exposed = entity_registry.async_get_or_create(
"light",
"test",
"light_no_2fa_exposed",
suggested_object_id="no_2fa_exposed",
)
entity_migrated = entity_registry.async_get_or_create(
"light",
"test",
"light_migrated",
suggested_object_id="migrated",
)
entity_config = entity_registry.async_get_or_create(
"light",
"test",
"light_config",
suggested_object_id="config",
entity_category=EntityCategory.CONFIG,
)
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
entity_blocked = entity_registry.async_get_or_create(
"group",
"test",
"group_all_locks",
suggested_object_id="all_locks",
)
assert entity_blocked.entity_id == "group.all_locks"
await cloud_prefs.async_update(
google_enabled=True,
google_report_state=False,
google_settings_version=1,
)
expose_entity(hass, entity_migrated.entity_id, False)
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_exposed.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_no_2fa_exposed.entity_id] = {
PREF_SHOULD_EXPOSE: True,
PREF_DISABLE_2FA: True,
}
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
conf = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
)
await conf.async_initialize()
entity_exposed = entity_registry.async_get(entity_exposed.entity_id)
assert entity_exposed.options == {"cloud.google_assistant": {"should_expose": True}}
entity_migrated = entity_registry.async_get(entity_migrated.entity_id)
assert entity_migrated.options == {
"cloud.google_assistant": {"should_expose": False}
}
entity_no_2fa_exposed = entity_registry.async_get(entity_no_2fa_exposed.entity_id)
assert entity_no_2fa_exposed.options == {
"cloud.google_assistant": {"disable_2fa": True, "should_expose": True}
}
entity_config = entity_registry.async_get(entity_config.entity_id)
assert entity_config.options == {"cloud.google_assistant": {"should_expose": False}}
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}}
entity_blocked = entity_registry.async_get(entity_blocked.entity_id)
assert entity_blocked.options == {
"cloud.google_assistant": {"should_expose": False}
}
async def test_google_config_migrate_expose_entity_prefs_default_none(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Google entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
await cloud_prefs.async_update(
google_enabled=True,
google_report_state=False,
google_settings_version=1,
)
cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = None
conf = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
)
await conf.async_initialize()
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}}

View File

@ -15,6 +15,7 @@ from homeassistant.components.alexa.entities import LightCapabilities
from homeassistant.components.cloud.const import DOMAIN
from homeassistant.components.google_assistant.helpers import GoogleEntity
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.util.location import LocationInfo
from . import mock_cloud, mock_cloud_prefs
@ -399,11 +400,9 @@ async def test_websocket_status(
"alexa_enabled": True,
"cloudhooks": {},
"google_enabled": True,
"google_entity_configs": {},
"google_secure_devices_pin": None,
"google_default_expose": None,
"alexa_default_expose": None,
"alexa_entity_configs": {},
"alexa_report_state": True,
"google_report_state": True,
"remote_enabled": False,
@ -520,8 +519,6 @@ async def test_websocket_update_preferences(
"alexa_enabled": False,
"google_enabled": False,
"google_secure_devices_pin": "1234",
"google_default_expose": ["light", "switch"],
"alexa_default_expose": ["sensor", "media_player"],
"tts_default_voice": ["en-GB", "male"],
}
)
@ -531,8 +528,6 @@ async def test_websocket_update_preferences(
assert not setup_api.google_enabled
assert not setup_api.alexa_enabled
assert setup_api.google_secure_devices_pin == "1234"
assert setup_api.google_default_expose == ["light", "switch"]
assert setup_api.alexa_default_expose == ["sensor", "media_player"]
assert setup_api.tts_default_voice == ("en-GB", "male")
@ -683,7 +678,11 @@ async def test_enabling_remote(
async def test_list_google_entities(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can list Google entities."""
client = await hass_ws_client(hass)
@ -699,9 +698,25 @@ async def test_list_google_entities(
"homeassistant.components.google_assistant.helpers.async_get_entities",
return_value=[entity, entity2],
):
await client.send_json({"id": 5, "type": "cloud/google_assistant/entities"})
await client.send_json_auto_id({"type": "cloud/google_assistant/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 0
# Add the entities to the entity registry
entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
entity_registry.async_get_or_create(
"cover", "test", "unique", suggested_object_id="garage"
)
with patch(
"homeassistant.components.google_assistant.helpers.async_get_entities",
return_value=[entity, entity2],
):
await client.send_json_auto_id({"type": "cloud/google_assistant/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 2
assert response["result"][0] == {
@ -716,49 +731,118 @@ async def test_list_google_entities(
}
async def test_get_google_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can get a Google entity."""
client = await hass_ws_client(hass)
# Test getting an unknown entity
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_found",
"message": "light.kitchen unknown or not in the entity registry",
}
# Test getting a blocked entity
entity_registry.async_get_or_create(
"group", "test", "unique", suggested_object_id="all_locks"
)
hass.states.async_set("group.all_locks", "bla")
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_supported",
"message": "group.all_locks not supported by Google assistant",
}
entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
entity_registry.async_get_or_create(
"cover", "test", "unique", suggested_object_id="garage"
)
hass.states.async_set("light.kitchen", "on")
hass.states.async_set("cover.garage", "open", {"device_class": "garage"})
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"entity_id": "light.kitchen",
"might_2fa": False,
"traits": ["action.devices.traits.OnOff"],
}
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"entity_id": "cover.garage",
"might_2fa": True,
"traits": ["action.devices.traits.OpenClose"],
}
async def test_update_google_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can update config of a Google entity."""
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "cloud/google_assistant/entities/update",
"entity_id": "light.kitchen",
"should_expose": False,
"disable_2fa": False,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.google_entity_configs["light.kitchen"] == {
"should_expose": False,
"disable_2fa": False,
}
await client.send_json(
await client.send_json_auto_id(
{
"id": 6,
"type": "cloud/google_assistant/entities/update",
"entity_id": "light.kitchen",
"should_expose": None,
"type": "homeassistant/expose_entity",
"assistants": ["cloud.google_assistant"],
"entity_ids": [entry.entity_id],
"should_expose": False,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.google_entity_configs["light.kitchen"] == {
"should_expose": None,
"disable_2fa": False,
}
assert entity_registry.async_get(entry.entity_id).options[
"cloud.google_assistant"
] == {"disable_2fa": False, "should_expose": False}
async def test_list_alexa_entities(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can list Alexa entities."""
client = await hass_ws_client(hass)
@ -769,9 +853,22 @@ async def test_list_alexa_entities(
"homeassistant.components.alexa.entities.async_get_entities",
return_value=[entity],
):
await client.send_json({"id": 5, "type": "cloud/alexa/entities"})
await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 0
# Add the entity to the entity registry
entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
with patch(
"homeassistant.components.alexa.entities.async_get_entities",
return_value=[entity],
):
await client.send_json_auto_id({"type": "cloud/alexa/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 1
assert response["result"][0] == {
@ -782,37 +879,31 @@ async def test_list_alexa_entities(
async def test_update_alexa_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can update config of an Alexa entity."""
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "cloud/alexa/entities/update",
"entity_id": "light.kitchen",
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa"],
"entity_ids": [entry.entity_id],
"should_expose": False,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False}
await client.send_json(
{
"id": 6,
"type": "cloud/alexa/entities/update",
"entity_id": "light.kitchen",
"should_expose": None,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None}
assert entity_registry.async_get(entry.entity_id).options["cloud.alexa"] == {
"should_expose": False
}
async def test_sync_alexa_entities_timeout(

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