From 349fd21709064c18e66d77cc98a5f3f8c6c7e372 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Mar 2023 17:10:23 +0100 Subject: [PATCH 01/16] Refactor handling of exposed entities for cloud Alexa --- .strict-typing | 1 + .../components/cloud/alexa_config.py | 75 +++-- homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/http_api.py | 32 -- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/prefs.py | 57 ++-- .../components/homeassistant/__init__.py | 8 +- .../components/homeassistant/const.py | 6 + .../homeassistant/exposed_entities.py | 298 ++++++++++++++++++ mypy.ini | 10 + tests/components/cloud/__init__.py | 3 +- tests/components/cloud/test_alexa_config.py | 160 ++++++---- tests/components/cloud/test_http_api.py | 40 +-- 13 files changed, 533 insertions(+), 160 deletions(-) create mode 100644 homeassistant/components/homeassistant/const.py create mode 100644 homeassistant/components/homeassistant/exposed_entities.py diff --git a/.strict-typing b/.strict-typing index 533d5239cab2..84915e3f1b38 100644 --- a/.strict-typing +++ b/.strict-typing @@ -137,6 +137,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_hardware.* diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 377da7d60b75..f4fc5e677e23 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,6 +20,11 @@ errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.homeassistant.exposed_entities import ( + async_get_exposed_entities, + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import entity_registry as er, start @@ -30,16 +35,17 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, + DOMAIN as CLOUD_DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" + # Time to wait when entity preferences have changed before syncing it to # the cloud. SYNC_DELAY = 1 @@ -64,7 +70,7 @@ def __init__( self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_entity_prefs = async_get_exposed_entities(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -115,10 +121,30 @@ def user_identifier(self): """Return an identifier for the user that represents this config.""" return self._cloud_user + def _migrate_alexa_entity_settings_v1(self): + """Migrate alexa entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + 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 +152,19 @@ async def hass_started(hass): start.async_at_start(self.hass, hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, ) - def should_expose(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -160,6 +186,15 @@ def should_expose(self, entity_id): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) + @callback def async_invalidate_access_token(self): """Invalidate access token.""" @@ -233,32 +268,30 @@ async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: if not any( key in updated_prefs for key in ( - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, ) ): return - # If we update just entity preferences, delay updating - # as we might update more - if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: - if self._alexa_sync_unsub: - self._alexa_sync_unsub() + await self.async_sync_entities() - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) - return + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + # Delay updating as we might update more + if self._alexa_sync_unsub: + self._alexa_sync_unsub() - await self.async_sync_entities() + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs - new_prefs = self._prefs.alexa_entity_configs + new_prefs = async_get_exposed_entities(self.hass, CLOUD_ALEXA) seen = set() to_update = [] diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9d5ed2ca28e0..9230c29176f7 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -19,6 +19,7 @@ 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_TTS_DEFAULT_VOICE = "tts_default_voice" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6c4115ae28a7..25bf08692122 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -28,7 +28,6 @@ from .const import ( DOMAIN, - PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, @@ -70,7 +69,6 @@ async def async_setup(hass): 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,7 +348,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( @@ -611,35 +608,6 @@ async def alexa_list( connection.send_result(msg["id"], result) -@websocket_api.require_admin -@_require_cloud_login -@websocket_api.websocket_command( - { - "type": "cloud/alexa/entities/update", - "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - } -) -@websocket_api.async_response -@_ws_handle_cloud_errors -async def alexa_update( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Update alexa entity config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") - - await cloud.client.prefs.async_update_alexa_entity_config(**changes) - - connection.send_result( - msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) - ) - - @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 2bff4003669a..daf65865fc0c 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Cloud", "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], - "dependencies": ["http", "webhook"], + "dependencies": ["homeassistant", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 7f27e7cf39ba..81660058f88a 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,8 @@ """Preference management for cloud.""" from __future__ import annotations +from typing import Any + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook @@ -18,6 +20,7 @@ PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, + PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, PREF_DISABLE_2FA, @@ -37,6 +40,23 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 + +ALEXA_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) + + return old_data class CloudPreferences: @@ -45,7 +65,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() @@ -80,13 +102,12 @@ async def async_update( 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, ): """Update user preferences.""" prefs = {**self._prefs} @@ -99,11 +120,10 @@ async def async_update( (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_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), ): @@ -139,26 +159,6 @@ async def async_update_google_entity_config( 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,7 +186,6 @@ def as_dict(self): """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, - PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_ENABLE_ALEXA: self.alexa_enabled, @@ -235,6 +234,11 @@ def alexa_entity_configs(self): """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property + def alexa_settings_version(self): + """Return version of Alexa settings.""" + return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + @property def google_enabled(self): """Return if Google is enabled.""" @@ -319,6 +323,7 @@ def _empty_config(username): return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER: None, PREF_CLOUDHOOKS: {}, PREF_ENABLE_ALEXA: True, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 91dd742e802b..987a4317ba84 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -33,10 +33,12 @@ from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType +from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .exposed_entities import ExposedEntities + ATTR_ENTRY_ID = "entry_id" _LOGGER = logging.getLogger(__name__) -DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" @@ -340,4 +342,8 @@ async def async_handle_reload_all(call: ha.ServiceCall) -> None: hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all ) + exposed_entities = ExposedEntities(hass) + await exposed_entities.async_initialize() + hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + return True diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py new file mode 100644 index 000000000000..f3bc95dd1ee0 --- /dev/null +++ b/homeassistant/components/homeassistant/const.py @@ -0,0 +1,6 @@ +"""Constants for the Homeassistant integration.""" +import homeassistant.core as ha + +DOMAIN = ha.DOMAIN + +DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites" diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py new file mode 100644 index 000000000000..413295f3fbe9 --- /dev/null +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -0,0 +1,298 @@ +"""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.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 + +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) + 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)): + return + + if ( + assistant_options := registry_entry.options.get(assistant) + ) and assistant_options["should_expose"] == should_expose: + return + + 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_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_exposed_entities( + self, assistant: str + ) -> dict[str, Mapping[str, Any]]: + """Get all exposed entities.""" + entity_registry = er.async_get(self._hass) + result = {} + + 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: + should_expose = registry_entry.options[assistant]["should_expose"] + return should_expose + + if not (prefs := self._assistants.get(assistant)) or not prefs.expose_new: + should_expose = False + else: + should_expose = self._is_default_exposed(entity_id, registry_entry) + + 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("assistant"): str, + vol.Required("entity_id"): 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 assistatant.""" + entity_id: str = msg["entity_id"] + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose {entity_id}" + ) + return + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_expose_entity( + msg["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", + vol.Required("assistant"): str, + vol.Required("expose_new"): bool, + } +) +def ws_expose_new_entities( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistatant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_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_exposed_entities( + hass: HomeAssistant, assistant: str +) -> dict[str, Mapping[str, Any]]: + """Get all exposed entities.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_exposed_entities(assistant) + + +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_should_expose(assistant, entity_id) diff --git a/mypy.ini b/mypy.ini index b3a4cafba361..389e639a1a43 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1132,6 +1132,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homeassistant.exposed_entities] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homeassistant.triggers.event] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 40809d2759c5..df0c9957d150 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from homeassistant.components import cloud -from homeassistant.components.cloud import const +from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component @@ -18,6 +18,7 @@ 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, diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 73dd69db447a..7c4911a761e4 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -6,10 +6,15 @@ from homeassistant.components.alexa import errors from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant 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 +26,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_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 +71,61 @@ 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, "light.kitchen", 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 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 +159,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 +208,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 +251,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 +262,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 +275,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 +327,30 @@ def sync_helper(to_upd, to_rem): async def test_alexa_update_expose_trigger_sync( - hass: HomeAssistant, cloud_prefs, cloud_stub + hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to updating exposed entities.""" - hass.states.async_set("binary_sensor.door", "on") + assert await async_setup_component(hass, "homeassistant", {}) + # Enable exposing new entities to Alexa + expose_new(hass, True) + # Register entities + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique", suggested_object_id="door" + ) + sensor_entry = entity_registry.async_get_or_create( + "sensor", "test", "unique", suggested_object_id="temp" + ) + light_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + + hass.states.async_set(binary_sensor_entry.entity_id, "on") hass.states.async_set( - "sensor.temp", + sensor_entry.entity_id, "23", {"device_class": "temperature", "unit_of_measurement": "°C"}, ) - hass.states.async_set("light.kitchen", "off") + hass.states.async_set(light_entry.entity_id, "off") await cloud_prefs.async_update( alexa_enabled=True, @@ -313,34 +362,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 +391,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 +461,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 +501,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 ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 92c0ca70a17a..86f0969ec53f 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -15,6 +15,7 @@ from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.util.location import LocationInfo from . import mock_cloud, mock_cloud_prefs @@ -403,7 +404,6 @@ async def test_websocket_status( "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, @@ -521,7 +521,6 @@ async def test_websocket_update_preferences( "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"], } ) @@ -532,7 +531,6 @@ async def test_websocket_update_preferences( 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") @@ -782,37 +780,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", + "assistant": "cloud.alexa", + "entity_id": 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( From d157f37b784c18a6cf09a696eafc48b56a1b9848 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Mar 2023 09:44:53 +0100 Subject: [PATCH 02/16] Tweak WS API --- .../homeassistant/exposed_entities.py | 20 ++++++++++--------- tests/components/cloud/test_http_api.py | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 413295f3fbe9..3368dac7cc9e 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -231,27 +231,29 @@ def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]: @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_entity", - vol.Required("assistant"): str, - vol.Required("entity_id"): str, + vol.Required("assistants"): [str], + 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 assistatant.""" - entity_id: str = msg["entity_id"] + """Expose an entity to an assistant.""" + entity_ids: str = msg["entity_ids"] - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + if any(entity_id in CLOUD_NEVER_EXPOSED_ENTITIES for entity_id in entity_ids): connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose {entity_id}" + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose {entity_ids}" ) return exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity( - msg["assistant"], entity_id, msg["should_expose"] - ) + 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"]) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 86f0969ec53f..ee2a0df226a2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -794,8 +794,8 @@ async def test_update_alexa_entity( await client.send_json_auto_id( { "type": "homeassistant/expose_entity", - "assistant": "cloud.alexa", - "entity_id": entry.entity_id, + "assistants": ["cloud.alexa"], + "entity_ids": [entry.entity_id], "should_expose": False, } ) From 45181f30d8ae9f4c61422c103b3796cef5c7386c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Mar 2023 12:32:34 +0100 Subject: [PATCH 03/16] Validate assistant parameter --- homeassistant/components/homeassistant/exposed_entities.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 3368dac7cc9e..d92127d15c42 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -18,6 +18,8 @@ from .const import DATA_EXPOSED_ENTITIES, DOMAIN +KNOWN_ASSISTANTS = ("cloud.alexa",) + STORAGE_KEY = f"{DOMAIN}.exposed_entities" STORAGE_VERSION = 1 @@ -231,7 +233,7 @@ def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]: @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_entity", - vol.Required("assistants"): [str], + vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)], vol.Required("entity_ids"): [str], vol.Required("should_expose"): bool, } @@ -262,7 +264,7 @@ def ws_expose_entity( @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_new_entities", - vol.Required("assistant"): str, + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), vol.Required("expose_new"): bool, } ) From bf293eebfc682199a52279754c527c93ac997ba5 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Mar 2023 13:45:45 +0100 Subject: [PATCH 04/16] Address some review comments --- homeassistant/components/cloud/alexa_config.py | 1 + homeassistant/components/homeassistant/exposed_entities.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index f4fc5e677e23..315658611c49 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -124,6 +124,7 @@ def user_identifier(self): 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) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index d92127d15c42..bc9ec7d32b8b 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -125,7 +125,6 @@ def async_expose_entity( @callback def async_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() @@ -160,10 +159,10 @@ def async_should_expose(self, assistant: str, entity_id: str) -> bool: should_expose = registry_entry.options[assistant]["should_expose"] return should_expose - if not (prefs := self._assistants.get(assistant)) or not prefs.expose_new: - should_expose = False - else: + 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 = {"should_expose": should_expose} entity_registry.async_update_entity_options( From b9ef042497067309d89807410def14272b3984e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Mar 2023 13:53:20 +0100 Subject: [PATCH 05/16] Refactor handling of exposed entities for cloud Google --- homeassistant/components/cloud/const.py | 1 + .../components/cloud/google_config.py | 89 +++++++++--- homeassistant/components/cloud/http_api.py | 36 +++-- homeassistant/components/cloud/prefs.py | 45 ++---- .../homeassistant/exposed_entities.py | 16 ++- tests/components/cloud/__init__.py | 1 + tests/components/cloud/test_client.py | 35 ++++- tests/components/cloud/test_google_config.py | 130 ++++++++++++------ tests/components/cloud/test_http_api.py | 42 +++--- 9 files changed, 248 insertions(+), 147 deletions(-) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9230c29176f7..49b4b905ed3f 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -20,6 +20,7 @@ PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" +PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index cf5a1de73afd..bcded5c28593 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -9,6 +9,10 @@ from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -22,14 +26,18 @@ from .const import ( CONF_ENTITY_CONFIG, + CONF_FILTER, DEFAULT_DISABLE_2FA, + DOMAIN as CLOUD_DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -48,8 +56,6 @@ def __init__( self._user = cloud_user self._prefs = prefs self._cloud = cloud - self._cur_entity_prefs = self._prefs.google_entity_configs - self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() @property @@ -89,10 +95,35 @@ def cloud_user(self): """Return Cloud User account.""" return self._user + def _migrate_google_entity_settings_v1(self): + """Migrate Google entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_GOOGLE in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + options[PREF_DISABLE_2FA] = _2fa_disabled + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, options + ) + async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2: + self._migrate_google_entity_settings_v1() + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) @@ -109,7 +140,9 @@ async def hass_started(hass): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, @@ -123,14 +156,11 @@ def should_expose(self, state): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_entity_id(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config["filter"].empty_filter: - return self._config["filter"](entity_id) - entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -154,6 +184,15 @@ def _should_expose_entity_id(self, entity_id): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def _should_expose_entity_id(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) + @property def agent_user_id(self): """Return Agent User Id to use for query responses.""" @@ -168,11 +207,23 @@ def get_agent_user_id(self, context): """Get agent user ID making request.""" return self.agent_user_id - def should_2fa(self, state): + def _2fa_disabled_legacy(self, entity_id): """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) - return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_DISABLE_2FA) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_registry = er.async_get(self.hass) + + registry_entry = entity_registry.async_get(state.entity_id) + if not registry_entry: + # Handle the entity has been removed + return + + assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {}) + return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) async def async_report_state(self, message, agent_user_id: str): """Send a state report to Google.""" @@ -218,14 +269,6 @@ async def _async_prefs_updated(self, prefs): # So when we change it, we need to sync all entities. sync_entities = True - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. - elif ( - self._cur_entity_prefs is not prefs.google_entity_configs - or self._cur_default_expose is not prefs.google_default_expose - ) and self._config["filter"].empty_filter: - self.async_schedule_google_sync_all() - if self.enabled and not self.is_local_sdk_active: self.async_enable_local_sdk() sync_entities = True @@ -233,12 +276,14 @@ async def _async_prefs_updated(self, prefs): self.async_disable_local_sdk() sync_entities = True - self._cur_entity_prefs = prefs.google_entity_configs - self._cur_default_expose = prefs.google_default_expose - if sync_entities and self.hass.is_running: await self.async_sync_entities_all() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self.async_schedule_google_sync_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 25bf08692122..1a2329b2a7ff 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -23,20 +23,22 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator 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_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 @@ -348,7 +350,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_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) @@ -555,8 +556,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 @@ -566,17 +566,29 @@ async def google_assistant_update( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Update google assistant config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") + """Update google assistant entity config.""" + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] + + if not (registry_entry := entity_registry.async_get(entity_id)): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_ALLOWED, + f"can't configure {entity_id}", + ) + return - await cloud.client.prefs.async_update_google_entity_config(**changes) + disable_2fa = msg[PREF_DISABLE_2FA] + if ( + assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {}) + ) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + return - connection.send_result( - msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa} + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, assistant_options ) + connection.send_result(msg["id"]) @websocket_api.require_admin diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 81660058f88a..75e1856503c0 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -23,7 +23,6 @@ PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, - PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, @@ -32,8 +31,8 @@ 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, ) @@ -43,6 +42,7 @@ STORAGE_VERSION_MINOR = 2 ALEXA_SETTINGS_VERSION = 2 +GOOGLE_SETTINGS_VERSION = 2 class CloudPreferencesStore(Store): @@ -55,6 +55,7 @@ async def _async_migrate_func( 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 @@ -101,13 +102,12 @@ async def async_update( google_secure_devices_pin=UNDEFINED, cloudhooks=UNDEFINED, cloud_user=UNDEFINED, - google_entity_configs=UNDEFINED, alexa_report_state=UNDEFINED, google_report_state=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} @@ -119,11 +119,10 @@ async def async_update( (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), - (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), (PREF_ALEXA_REPORT_STATE, alexa_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state), - (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), ): @@ -132,33 +131,6 @@ async def async_update( await self._save_prefs(prefs) - async def async_update_google_entity_config( - self, - *, - entity_id, - disable_2fa=UNDEFINED, - should_expose=UNDEFINED, - ): - """Update config for a Google entity.""" - entities = self.google_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ( - (PREF_DISABLE_2FA, disable_2fa), - (PREF_SHOULD_EXPOSE, should_expose), - ): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(google_entity_configs=updated_entities) - async def async_set_username(self, username) -> bool: """Set the username that is logged in.""" # Logging out. @@ -192,7 +164,6 @@ def as_dict(self): 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, @@ -259,6 +230,11 @@ def google_entity_configs(self): """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def google_settings_version(self): + """Return version of Google settings.""" + return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + @property def google_local_webhook_id(self): """Return Google webhook ID to receive local messages.""" @@ -331,6 +307,7 @@ def _empty_config(username): PREF_ENABLE_REMOTE: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index bc9ec7d32b8b..aa35296723a5 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -18,7 +18,7 @@ from .const import DATA_EXPOSED_ENTITIES, DOMAIN -KNOWN_ASSISTANTS = ("cloud.alexa",) +KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant") STORAGE_KEY = f"{DOMAIN}.exposed_entities" STORAGE_VERSION = 1 @@ -111,11 +111,11 @@ def async_expose_entity( return if ( - assistant_options := registry_entry.options.get(assistant) - ) and assistant_options["should_expose"] == should_expose: + assistant_options := registry_entry.options.get(assistant, {}) + ) and assistant_options.get("should_expose") == should_expose: return - assistant_options = {"should_expose": should_expose} + assistant_options = assistant_options | {"should_expose": should_expose} entity_registry.async_update_entity_options( entity_id, assistant, assistant_options ) @@ -156,15 +156,17 @@ def async_should_expose(self, assistant: str, entity_id: str) -> bool: return False if assistant in registry_entry.options: - should_expose = registry_entry.options[assistant]["should_expose"] - return should_expose + 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 = {"should_expose": should_expose} + assistant_options = registry_entry.options.get(assistant, {}) + assistant_options = assistant_options | {"should_expose": should_expose} entity_registry.async_update_entity_options( entity_id, assistant, assistant_options ) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index df0c9957d150..7933d8639c18 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -22,6 +22,7 @@ def mock_cloud_prefs(hass, prefs={}): const.PREF_ENABLE_ALEXA: True, const.PREF_ENABLE_GOOGLE: True, const.PREF_GOOGLE_SECURE_DEVICES_PIN: None, + const.PREF_GOOGLE_SETTINGS_VERSION: cloud_prefs.GOOGLE_SETTINGS_VERSION, } prefs_to_set.update(prefs) hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index b7bfed53aacb..9ee0424979c3 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -13,8 +13,13 @@ PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, ) +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -245,14 +250,25 @@ async def test_google_config_expose_entity( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config exposing entity method uses latest config.""" + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_expose_new_entities("cloud.google_assistant", True) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") gconf = await cloud_client.get_google_config() assert gconf.should_expose(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=False + exposed_entities.async_expose_entity( + "cloud.google_assistant", entity_entry.entity_id, False ) assert not gconf.should_expose(state) @@ -262,14 +278,21 @@ async def test_google_config_should_2fa( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config disabling 2FA method uses latest config.""" + entity_registry = er.async_get(hass) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client gconf = await cloud_client.get_google_config() - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") assert gconf.should_2fa(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", disable_2fa=True + entity_registry.async_update_entity_options( + entity_entry.entity_id, "cloud.google_assistant", {"disable_2fa": True} ) assert not gconf.should_2fa(state) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 6725fbea633b..7370eb111e8a 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -8,9 +8,14 @@ from homeassistant.components.cloud import GACTIONS_SCHEMA from homeassistant.components.cloud.google_config import CloudGoogleConfig from homeassistant.components.google_assistant import helpers as ga_helpers +from homeassistant.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.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 +33,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_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 +72,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 +91,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 +113,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 +143,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 +153,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 +167,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 +184,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 +194,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 +205,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 +216,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 +226,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 +241,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 +334,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 +363,48 @@ 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, "light.kitchen", 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 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 +424,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 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index ee2a0df226a2..051614d728e8 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -400,7 +400,6 @@ 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, @@ -520,7 +519,6 @@ async def test_websocket_update_preferences( "alexa_enabled": False, "google_enabled": False, "google_secure_devices_pin": "1234", - "google_default_expose": ["light", "switch"], "tts_default_voice": ["en-GB", "male"], } ) @@ -530,7 +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.tts_default_voice == ("en-GB", "male") @@ -715,44 +712,41 @@ async def test_list_google_entities( 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( From 7ba6749d7125f0245a426f53dc126437170bb742 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Mar 2023 13:50:51 +0200 Subject: [PATCH 06/16] Raise when attempting to expose an unknown entity --- .../homeassistant/exposed_entities.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index aa35296723a5..dd843c944810 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -12,6 +12,7 @@ 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 @@ -108,7 +109,7 @@ def async_expose_entity( """ entity_registry = er.async_get(self._hass) if not (registry_entry := entity_registry.async_get(entity_id)): - return + raise HomeAssistantError("Unknown entity") if ( assistant_options := registry_entry.options.get(assistant, {}) @@ -243,11 +244,32 @@ 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 any(entity_id in CLOUD_NEVER_EXPOSED_ENTITIES for entity_id in 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_ALLOWED, f"can't expose {entity_ids}" + msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'" ) return From dffea223829c54888dfa78fc5cdc9bf8c6716952 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Mar 2023 14:52:19 +0200 Subject: [PATCH 07/16] Add tests --- .../homeassistant/test_exposed_entities.py | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 tests/components/homeassistant/test_exposed_entities.py diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py new file mode 100644 index 000000000000..a8cda56ad2da --- /dev/null +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -0,0 +1,329 @@ +"""Test Home Assistant exposed entities helper.""" +import pytest + +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + async_get_exposed_entities, + 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_expose_new_entities("test1", True) + exposed_entities.async_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") + + # 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", + "assistant": "cloud.alexa", + "expose_new": expose_new, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # 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_updated( + 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_exposed_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test get exposed entities.""" + 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_exposed_entities(hass, "cloud.alexa") == {} + + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + assert async_get_exposed_entities(hass, "cloud.alexa") == { + "climate.test_unique1": {"should_expose": True} + } + assert async_get_exposed_entities(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", + "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 From a16ff6df399db64148adea56d44a65b878dccdb0 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Mar 2023 15:19:12 +0200 Subject: [PATCH 08/16] Adjust cloud tests --- tests/components/cloud/test_alexa_config.py | 4 +++- tests/components/cloud/test_google_config.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 7c4911a761e4..f56b097287c7 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -12,6 +12,7 @@ ) 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 @@ -89,7 +90,6 @@ async def test_alexa_config_expose_entity_prefs( alexa_report_state=False, ) expose_new(hass, True) - expose_entity(hass, "light.kitchen", True) expose_entity(hass, entity_entry5.entity_id, False) conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub @@ -97,6 +97,8 @@ async def test_alexa_config_expose_entity_prefs( 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") # categorized and hidden entities should not be exposed assert not conf.should_expose(entity_entry1.entity_id) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 7370eb111e8a..4583d1957c7b 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -14,6 +14,7 @@ ) 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 @@ -377,7 +378,6 @@ async def test_google_config_expose_entity_prefs( ) expose_new(hass, True) - expose_entity(hass, "light.kitchen", True) expose_entity(hass, entity_entry5.entity_id, False) state = State("light.kitchen", "on") @@ -389,6 +389,8 @@ async def test_google_config_expose_entity_prefs( 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) # categorized and hidden entities should not be exposed assert not mock_conf.should_expose(state_config) From c1f607a90a1ad2dd9b0bb1e0b344056e4a95c967 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Mar 2023 15:46:14 +0200 Subject: [PATCH 09/16] Allow getting expose new entities flag --- .../homeassistant/exposed_entities.py | 37 ++++++++++++++++--- tests/components/cloud/test_alexa_config.py | 2 +- tests/components/cloud/test_client.py | 2 +- tests/components/cloud/test_google_config.py | 2 +- .../homeassistant/test_exposed_entities.py | 29 ++++++++++++--- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index dd843c944810..2d895bb71f29 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -89,7 +89,8 @@ def __init__(self, hass: HomeAssistant) -> None: 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) + 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 @@ -124,7 +125,14 @@ def async_expose_entity( listener() @callback - def async_expose_new_entities(self, assistant: str, expose_new: bool) -> None: + 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() @@ -286,17 +294,34 @@ def ws_expose_entity( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required("type"): "homeassistant/expose_new_entities", + 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 assistatant.""" + 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( +def ws_expose_new_entities_set( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Expose an entity to an assistatant.""" + """Expose new entities to an assistatant.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_new_entities(msg["assistant"], msg["expose_new"]) + exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) connection.send_result(msg["id"]) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index f56b097287c7..1dbf5ad28e70 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -30,7 +30,7 @@ def cloud_stub(): def expose_new(hass, expose_new): """Enable exposing new entities to Alexa.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_new_entities("cloud.alexa", expose_new) + exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new) def expose_entity(hass, entity_id, should_expose): diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 9ee0424979c3..1afe9956288a 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -254,7 +254,7 @@ async def test_google_config_expose_entity( # Enable exposing new entities to Google exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_new_entities("cloud.google_assistant", True) + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", True) # Register a light entity entity_entry = entity_registry.async_get_or_create( diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 4583d1957c7b..75fb1433a1ec 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -37,7 +37,7 @@ 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_expose_new_entities("cloud.google_assistant", expose_new) + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new) def expose_entity(hass, entity_id, should_expose): diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index a8cda56ad2da..442fe255526e 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -25,8 +25,8 @@ async def test_load_preferences(hass: HomeAssistant) -> None: exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] assert exposed_entities._assistants == {} - exposed_entities.async_expose_new_entities("test1", True) - exposed_entities.async_expose_new_entities("test2", False) + 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"] @@ -167,19 +167,38 @@ async def test_expose_new_entities( 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", + "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 @@ -188,7 +207,7 @@ async def test_expose_new_entities( assert async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new -async def test_listen_updated( +async def test_listen_updates( hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: @@ -259,7 +278,7 @@ async def test_should_expose( # Expose new entities to Alexa await ws_client.send_json_auto_id( { - "type": "homeassistant/expose_new_entities", + "type": "homeassistant/expose_new_entities/set", "assistant": "cloud.alexa", "expose_new": True, } From 6b1b1212c8e672349d54fef2c6ba19229f506808 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Mar 2023 16:19:06 +0200 Subject: [PATCH 10/16] Test Alexa migration --- tests/components/cloud/test_alexa_config.py | 121 ++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 1dbf5ad28e70..2cb363b04209 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -6,6 +6,12 @@ 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, @@ -529,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}} From 2e22dd31bf213305fdb3ad7cd4a9ddc69deafd85 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Mar 2023 17:08:10 +0200 Subject: [PATCH 11/16] Test Google migration --- tests/components/cloud/test_google_config.py | 140 +++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 75fb1433a1ec..738b3fa7cd76 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -6,7 +6,14 @@ 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, @@ -471,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}} From 05ffa413d152c8db93577315867beb8d59e99387 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Apr 2023 09:49:40 +0200 Subject: [PATCH 12/16] Add WS command cloud/google_assistant/entities/get --- homeassistant/components/cloud/http_api.py | 56 ++++++++++ tests/components/cloud/test_http_api.py | 113 ++++++++++++++++++++- 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 1a2329b2a7ff..b47b2f7c414d 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -22,6 +22,7 @@ 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 @@ -67,6 +68,7 @@ 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) @@ -521,6 +523,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"}) @@ -534,11 +584,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, @@ -604,11 +657,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, diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 051614d728e8..115e77f118e2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -678,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) @@ -694,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] == { @@ -711,6 +731,74 @@ 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, entity_registry: er.EntityRegistry, @@ -750,7 +838,11 @@ async def test_update_google_entity( 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) @@ -761,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] == { From 26249b637636ff6f493cf7fb59cccffb3522b7a6 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Apr 2023 09:50:45 +0200 Subject: [PATCH 13/16] Fix return value --- homeassistant/components/cloud/google_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index bcded5c28593..c47b05c264c7 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -220,7 +220,7 @@ def should_2fa(self, state): registry_entry = entity_registry.async_get(state.entity_id) if not registry_entry: # Handle the entity has been removed - return + return False assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {}) return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) From aa9798e5531b4273756da930a4674bd408675a1f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Apr 2023 14:06:48 +0200 Subject: [PATCH 14/16] Update typing --- homeassistant/components/cloud/http_api.py | 2 ++ homeassistant/components/homeassistant/exposed_entities.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index b47b2f7c414d..c25de5463b5d 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,5 +1,6 @@ """The HTTP api to control the cloud integration.""" import asyncio +from collections.abc import Mapping import dataclasses from functools import wraps from http import HTTPStatus @@ -632,6 +633,7 @@ async def google_assistant_update( return 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: diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 2d895bb71f29..7b2c5ecf2a04 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -112,6 +112,7 @@ def async_expose_entity( 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: @@ -143,7 +144,7 @@ def async_get_exposed_entities( ) -> dict[str, Mapping[str, Any]]: """Get all exposed entities.""" entity_registry = er.async_get(self._hass) - result = {} + result: dict[str, Mapping[str, Any]] = {} for entity_id, entry in entity_registry.entities.items(): if options := entry.options.get(assistant): @@ -174,7 +175,7 @@ def async_should_expose(self, assistant: str, entity_id: str) -> bool: else: should_expose = False - assistant_options = registry_entry.options.get(assistant, {}) + 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 From 795ace6b3cb43a6f12834fd5ee6c4655eaf31d21 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Apr 2023 15:51:14 +0200 Subject: [PATCH 15/16] Address review comments --- .../components/homeassistant/exposed_entities.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 7b2c5ecf2a04..108d51bf647d 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -26,7 +26,7 @@ SAVE_DELAY = 10 -DEFAULT_EXPOSED_DOMAINS = [ +DEFAULT_EXPOSED_DOMAINS = { "climate", "cover", "fan", @@ -38,9 +38,9 @@ "switch", "vacuum", "water_heater", -] +} -DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = [ +DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = { BinarySensorDeviceClass.DOOR, BinarySensorDeviceClass.GARAGE_DOOR, BinarySensorDeviceClass.LOCK, @@ -48,9 +48,9 @@ BinarySensorDeviceClass.OPENING, BinarySensorDeviceClass.PRESENCE, BinarySensorDeviceClass.WINDOW, -] +} -DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = [ +DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = { SensorDeviceClass.AQI, SensorDeviceClass.CO, SensorDeviceClass.CO2, @@ -59,7 +59,7 @@ SensorDeviceClass.PM25, SensorDeviceClass.TEMPERATURE, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, -] +} @dataclasses.dataclass(frozen=True) @@ -302,7 +302,7 @@ def ws_expose_entity( 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 assistatant.""" + """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}) From 8f00d05a52a1da394a2e128c2eb018c35f7b7271 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Apr 2023 16:48:48 +0200 Subject: [PATCH 16/16] Rename async_get_exposed_entities to async_get_assistant_settings --- homeassistant/components/cloud/alexa_config.py | 6 +++--- .../components/homeassistant/exposed_entities.py | 10 +++++----- .../homeassistant/test_exposed_entities.py | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 315658611c49..44a42c78f092 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -21,7 +21,7 @@ state_report as alexa_state_report, ) from homeassistant.components.homeassistant.exposed_entities import ( - async_get_exposed_entities, + async_get_assistant_settings, async_listen_entity_updates, async_should_expose, ) @@ -70,7 +70,7 @@ def __init__( self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = async_get_exposed_entities(hass, CLOUD_ALEXA) + self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -292,7 +292,7 @@ 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 = async_get_exposed_entities(self.hass, CLOUD_ALEXA) + new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA) seen = set() to_update = [] diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 108d51bf647d..9317f43ea750 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -139,10 +139,10 @@ def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> Non self._async_schedule_save() @callback - def async_get_exposed_entities( + def async_get_assistant_settings( self, assistant: str ) -> dict[str, Mapping[str, Any]]: - """Get all exposed entities.""" + """Get all entity expose settings for an assistant.""" entity_registry = er.async_get(self._hass) result: dict[str, Mapping[str, Any]] = {} @@ -336,12 +336,12 @@ def async_listen_entity_updates( @callback -def async_get_exposed_entities( +def async_get_assistant_settings( hass: HomeAssistant, assistant: str ) -> dict[str, Mapping[str, Any]]: - """Get all exposed entities.""" + """Get all entity expose settings for an assistant.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - return exposed_entities.async_get_exposed_entities(assistant) + return exposed_entities.async_get_assistant_settings(assistant) @callback diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 442fe255526e..1aa98ab423f4 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -4,7 +4,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, - async_get_exposed_entities, + async_get_assistant_settings, async_listen_entity_updates, async_should_expose, ) @@ -244,11 +244,11 @@ def listener(): assert len(calls) == 2 -async def test_get_exposed_entities( +async def test_get_assistant_settings( hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: - """Test get exposed entities.""" + """Test get assistant settings.""" assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() @@ -256,13 +256,13 @@ async def test_get_exposed_entities( entry = entity_registry.async_get_or_create("climate", "test", "unique1") - assert async_get_exposed_entities(hass, "cloud.alexa") == {} + assert async_get_assistant_settings(hass, "cloud.alexa") == {} exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) - assert async_get_exposed_entities(hass, "cloud.alexa") == { + assert async_get_assistant_settings(hass, "cloud.alexa") == { "climate.test_unique1": {"should_expose": True} } - assert async_get_exposed_entities(hass, "cloud.google_assistant") == {} + assert async_get_assistant_settings(hass, "cloud.google_assistant") == {} async def test_should_expose(