diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c738af0a641..d5cb7412209 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,9 @@ env: # - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023) # 10.10 is the latest short-term-support # - 10.10.3 is the latest (as of 6 Feb 2023) - MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3']" + # mysql 8.0.32 does not always behave the same as MariaDB + # and some queries that work on MariaDB do not work on MySQL + MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']" # 12 is the oldest supported version # - 12.14 is the latest (as of 9 Feb 2023) # 15 is the latest version diff --git a/.strict-typing b/.strict-typing index 533d5239cab..84915e3f1b3 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/CODEOWNERS b/CODEOWNERS index c00a0f853b4..906787e0452 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -217,8 +217,6 @@ build.json @home-assistant/supervisor /tests/components/conversation/ @home-assistant/core @synesthesiam /homeassistant/components/coolmaster/ @OnFreund /tests/components/coolmaster/ @OnFreund -/homeassistant/components/coronavirus/ @home-assistant/core -/tests/components/coronavirus/ @home-assistant/core /homeassistant/components/counter/ @fabaff /tests/components/counter/ @fabaff /homeassistant/components/cover/ @home-assistant/core diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index bd07303df3e..9dbd4507774 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["aioambient"], - "requirements": ["aioambient==2021.11.0"] + "requirements": ["aioambient==2022.10.0"] } diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 33521b3d066..f57d6c82b7f 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -75,7 +75,7 @@ class AuthorizationServer: token_url: str -class ApplicationCredentialsStorageCollection(collection.StorageCollection): +class ApplicationCredentialsStorageCollection(collection.DictStorageCollection): """Application credential collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -94,7 +94,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection): return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}" async def _update_data( - self, data: dict[str, str], update_data: dict[str, str] + self, item: dict[str, str], update_data: dict[str, str] ) -> dict[str, str]: """Return a new updated data object.""" raise ValueError("Updates not supported") @@ -144,7 +144,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = collection.IDManager() storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 377da7d60b7..44a42c78f09 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,6 +20,11 @@ from homeassistant.components.alexa import ( errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.homeassistant.exposed_entities import ( + async_get_assistant_settings, + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import entity_registry as er, start @@ -30,16 +35,17 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, + DOMAIN as CLOUD_DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" + # Time to wait when entity preferences have changed before syncing it to # the cloud. SYNC_DELAY = 1 @@ -64,7 +70,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._cloud = cloud self._token = None self._token_valid = None - self._cur_entity_prefs = prefs.alexa_entity_configs + self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @@ -115,10 +121,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): """Return an identifier for the user that represents this config.""" return self._cloud_user + def _migrate_alexa_entity_settings_v1(self): + """Migrate alexa entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_ALEXA in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options) + async def async_initialize(self): """Initialize the Alexa config.""" await super().async_initialize() + if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + if self._prefs.alexa_settings_version < 2: + self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( + alexa_settings_version=ALEXA_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -126,19 +153,19 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): start.async_at_start(self.hass, hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, ) - def should_expose(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -160,6 +187,15 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) + @callback def async_invalidate_access_token(self): """Invalidate access token.""" @@ -233,32 +269,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): if not any( key in updated_prefs for key in ( - PREF_ALEXA_DEFAULT_EXPOSE, - PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, ) ): return - # If we update just entity preferences, delay updating - # as we might update more - if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: - if self._alexa_sync_unsub: - self._alexa_sync_unsub() - - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) - return - await self.async_sync_entities() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + # Delay updating as we might update more + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) + async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs - new_prefs = self._prefs.alexa_entity_configs + new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA) seen = set() to_update = [] diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9d5ed2ca28e..49b4b905ed3 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -19,6 +19,8 @@ PREF_USERNAME = "username" PREF_REMOTE_DOMAIN = "remote_domain" PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose" PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" +PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" +PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index cf5a1de73af..c47b05c264c 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -9,6 +9,10 @@ from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.components.homeassistant.exposed_entities import ( + async_listen_entity_updates, + async_should_expose, +) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -22,14 +26,18 @@ from homeassistant.setup import async_setup_component from .const import ( CONF_ENTITY_CONFIG, + CONF_FILTER, DEFAULT_DISABLE_2FA, + DOMAIN as CLOUD_DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) -from .prefs import CloudPreferences +from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences _LOGGER = logging.getLogger(__name__) +CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -48,8 +56,6 @@ class CloudGoogleConfig(AbstractConfig): self._user = cloud_user self._prefs = prefs self._cloud = cloud - self._cur_entity_prefs = self._prefs.google_entity_configs - self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() @property @@ -89,10 +95,35 @@ class CloudGoogleConfig(AbstractConfig): """Return Cloud User account.""" return self._user + def _migrate_google_entity_settings_v1(self): + """Migrate Google entity settings to entity registry options.""" + if not self._config[CONF_FILTER].empty_filter: + # Don't migrate if there's a YAML config + return + + entity_registry = er.async_get(self.hass) + + for entity_id, entry in entity_registry.entities.items(): + if CLOUD_GOOGLE in entry.options: + continue + options = {"should_expose": self._should_expose_legacy(entity_id)} + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + options[PREF_DISABLE_2FA] = _2fa_disabled + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, options + ) + async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2: + self._migrate_google_entity_settings_v1() + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async def hass_started(hass): if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) @@ -109,7 +140,9 @@ class CloudGoogleConfig(AbstractConfig): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, @@ -123,14 +156,11 @@ class CloudGoogleConfig(AbstractConfig): """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_entity_id(self, entity_id): + def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - if not self._config["filter"].empty_filter: - return self._config["filter"](entity_id) - entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) @@ -154,6 +184,15 @@ class CloudGoogleConfig(AbstractConfig): return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + def _should_expose_entity_id(self, entity_id): + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + return self._config[CONF_FILTER](entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) + @property def agent_user_id(self): """Return Agent User Id to use for query responses.""" @@ -168,11 +207,23 @@ class CloudGoogleConfig(AbstractConfig): """Get agent user ID making request.""" return self.agent_user_id - def should_2fa(self, state): + def _2fa_disabled_legacy(self, entity_id): """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs - entity_config = entity_configs.get(state.entity_id, {}) - return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_DISABLE_2FA) + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_registry = er.async_get(self.hass) + + registry_entry = entity_registry.async_get(state.entity_id) + if not registry_entry: + # Handle the entity has been removed + return False + + assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {}) + return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) async def async_report_state(self, message, agent_user_id: str): """Send a state report to Google.""" @@ -218,14 +269,6 @@ class CloudGoogleConfig(AbstractConfig): # So when we change it, we need to sync all entities. sync_entities = True - # If entity prefs are the same or we have filter in config.yaml, - # don't sync. - elif ( - self._cur_entity_prefs is not prefs.google_entity_configs - or self._cur_default_expose is not prefs.google_default_expose - ) and self._config["filter"].empty_filter: - self.async_schedule_google_sync_all() - if self.enabled and not self.is_local_sdk_active: self.async_enable_local_sdk() sync_entities = True @@ -233,12 +276,14 @@ class CloudGoogleConfig(AbstractConfig): self.async_disable_local_sdk() sync_entities = True - self._cur_entity_prefs = prefs.google_entity_configs - self._cur_default_expose = prefs.google_default_expose - if sync_entities and self.hass.is_running: await self.async_sync_entities_all() + @callback + def _async_exposed_entities_updated(self) -> None: + """Handle updated preferences.""" + self.async_schedule_google_sync_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6c4115ae28a..c25de5463b5 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,5 +1,6 @@ """The HTTP api to control the cloud integration.""" import asyncio +from collections.abc import Mapping import dataclasses from functools import wraps from http import HTTPStatus @@ -22,22 +23,24 @@ from homeassistant.components.alexa import ( from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .const import ( DOMAIN, - PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_REPORT_STATE, + PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) +from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -66,11 +69,11 @@ async def async_setup(hass): websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) + websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_update) websocket_api.async_register_command(hass, alexa_list) - websocket_api.async_register_command(hass, alexa_update) websocket_api.async_register_command(hass, alexa_sync) websocket_api.async_register_command(hass, thingtalk_convert) @@ -350,8 +353,6 @@ async def websocket_subscription( vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, - vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str], - vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str], vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) @@ -523,6 +524,54 @@ async def websocket_remote_disconnect( connection.send_result(msg["id"], await _account_data(hass, cloud)) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/google_assistant/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def google_assistant_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single google assistant entity.""" + cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] + state = hass.states.get(entity_id) + + if not entity_registry.async_is_registered(entity_id) or not state: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"{entity_id} unknown or not in the entity registry", + ) + return + + entity = google_helpers.GoogleEntity(hass, gconf, state) + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Google assistant", + ) + return + + result = { + "entity_id": entity.entity_id, + "traits": [trait.name for trait in entity.traits()], + "might_2fa": entity.might_2fa_traits(), + } + + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @@ -536,11 +585,14 @@ async def google_assistant_list( """List all google assistant entities.""" cloud = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() + entity_registry = er.async_get(hass) entities = google_helpers.async_get_entities(hass, gconf) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -558,8 +610,7 @@ async def google_assistant_list( { "type": "cloud/google_assistant/entities/update", "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("disable_2fa"): bool, + vol.Optional(PREF_DISABLE_2FA): bool, } ) @websocket_api.async_response @@ -569,17 +620,30 @@ async def google_assistant_update( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Update google assistant config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") + """Update google assistant entity config.""" + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] - await cloud.client.prefs.async_update_google_entity_config(**changes) + if not (registry_entry := entity_registry.async_get(entity_id)): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_ALLOWED, + f"can't configure {entity_id}", + ) + return - connection.send_result( - msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"]) + disable_2fa = msg[PREF_DISABLE_2FA] + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {}) + ) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + return + + assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa} + entity_registry.async_update_entity_options( + entity_id, CLOUD_GOOGLE, assistant_options ) + connection.send_result(msg["id"]) @websocket_api.require_admin @@ -595,11 +659,14 @@ async def alexa_list( """List all alexa entities.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() + entity_registry = er.async_get(hass) entities = alexa_entities.async_get_entities(hass, alexa_config) result = [] for entity in entities: + if not entity_registry.async_is_registered(entity.entity_id): + continue result.append( { "entity_id": entity.entity_id, @@ -611,35 +678,6 @@ async def alexa_list( connection.send_result(msg["id"], result) -@websocket_api.require_admin -@_require_cloud_login -@websocket_api.websocket_command( - { - "type": "cloud/alexa/entities/update", - "entity_id": str, - vol.Optional("should_expose"): vol.Any(None, bool), - } -) -@websocket_api.async_response -@_ws_handle_cloud_errors -async def alexa_update( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Update alexa entity config.""" - cloud = hass.data[DOMAIN] - changes = dict(msg) - changes.pop("type") - changes.pop("id") - - await cloud.client.prefs.async_update_alexa_entity_config(**changes) - - connection.send_result( - msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) - ) - - @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 2bff4003669..daf65865fc0 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 7f27e7cf39b..75e1856503c 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,8 @@ """Preference management for cloud.""" from __future__ import annotations +from typing import Any + from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook @@ -18,9 +20,9 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, + PREF_ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER, PREF_CLOUDHOOKS, - PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, @@ -29,14 +31,33 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_GOOGLE_SETTINGS_VERSION, PREF_REMOTE_DOMAIN, - PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, ) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 + +ALEXA_SETTINGS_VERSION = 2 +GOOGLE_SETTINGS_VERSION = 2 + + +class CloudPreferencesStore(Store): + """Store entity registry data.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + if old_minor_version < 2: + old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) + old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + + return old_data class CloudPreferences: @@ -45,7 +66,9 @@ class CloudPreferences: def __init__(self, hass): """Initialize cloud prefs.""" self._hass = hass - self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = CloudPreferencesStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) self._prefs = None self._listeners = [] self.last_updated: set[str] = set() @@ -79,14 +102,12 @@ class CloudPreferences: google_secure_devices_pin=UNDEFINED, cloudhooks=UNDEFINED, cloud_user=UNDEFINED, - google_entity_configs=UNDEFINED, - alexa_entity_configs=UNDEFINED, alexa_report_state=UNDEFINED, google_report_state=UNDEFINED, - alexa_default_expose=UNDEFINED, - google_default_expose=UNDEFINED, tts_default_voice=UNDEFINED, remote_domain=UNDEFINED, + alexa_settings_version=UNDEFINED, + google_settings_version=UNDEFINED, ): """Update user preferences.""" prefs = {**self._prefs} @@ -98,12 +119,10 @@ class CloudPreferences: (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_CLOUDHOOKS, cloudhooks), (PREF_CLOUD_USER, cloud_user), - (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs), - (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), (PREF_ALEXA_REPORT_STATE, alexa_report_state), (PREF_GOOGLE_REPORT_STATE, google_report_state), - (PREF_ALEXA_DEFAULT_EXPOSE, alexa_default_expose), - (PREF_GOOGLE_DEFAULT_EXPOSE, google_default_expose), + (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), + (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), ): @@ -112,53 +131,6 @@ class CloudPreferences: await self._save_prefs(prefs) - async def async_update_google_entity_config( - self, - *, - entity_id, - disable_2fa=UNDEFINED, - should_expose=UNDEFINED, - ): - """Update config for a Google entity.""" - entities = self.google_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ( - (PREF_DISABLE_2FA, disable_2fa), - (PREF_SHOULD_EXPOSE, should_expose), - ): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(google_entity_configs=updated_entities) - - async def async_update_alexa_entity_config( - self, *, entity_id, should_expose=UNDEFINED - ): - """Update config for an Alexa entity.""" - entities = self.alexa_entity_configs - entity = entities.get(entity_id, {}) - - changes = {} - for key, value in ((PREF_SHOULD_EXPOSE, should_expose),): - if value is not UNDEFINED: - changes[key] = value - - if not changes: - return - - updated_entity = {**entity, **changes} - - updated_entities = {**entities, entity_id: updated_entity} - await self.async_update(alexa_entity_configs=updated_entities) - async def async_set_username(self, username) -> bool: """Set the username that is logged in.""" # Logging out. @@ -186,14 +158,12 @@ class CloudPreferences: """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, - PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_ENABLE_ALEXA: self.alexa_enabled, PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_REMOTE: self.remote_enabled, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, - PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, @@ -235,6 +205,11 @@ class CloudPreferences: """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property + def alexa_settings_version(self): + """Return version of Alexa settings.""" + return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + @property def google_enabled(self): """Return if Google is enabled.""" @@ -255,6 +230,11 @@ class CloudPreferences: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + @property + def google_settings_version(self): + """Return version of Google settings.""" + return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + @property def google_local_webhook_id(self): """Return Google webhook ID to receive local messages.""" @@ -319,6 +299,7 @@ class CloudPreferences: return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION, PREF_CLOUD_USER: None, PREF_CLOUDHOOKS: {}, PREF_ENABLE_ALEXA: True, @@ -326,6 +307,7 @@ class CloudPreferences: PREF_ENABLE_REMOTE: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index bbf4ef287d6..a10b0c98cd8 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -4,11 +4,16 @@ from hass_nabucasa import Cloud from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError import voluptuous as vol -from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.components.tts import ( + ATTR_AUDIO_OUTPUT, + CONF_LANG, + PLATFORM_SCHEMA, + Provider, +) from .const import DOMAIN -CONF_GENDER = "gender" +ATTR_GENDER = "gender" SUPPORT_LANGUAGES = list({key[0] for key in MAP_VOICE}) @@ -18,8 +23,8 @@ def validate_lang(value): if (lang := value.get(CONF_LANG)) is None: return value - if (gender := value.get(CONF_GENDER)) is None: - gender = value[CONF_GENDER] = next( + if (gender := value.get(ATTR_GENDER)) is None: + gender = value[ATTR_GENDER] = next( (chk_gender for chk_lang, chk_gender in MAP_VOICE if chk_lang == lang), None ) @@ -33,7 +38,7 @@ PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG): str, - vol.Optional(CONF_GENDER): str, + vol.Optional(ATTR_GENDER): str, } ), validate_lang, @@ -49,7 +54,7 @@ async def async_get_engine(hass, config, discovery_info=None): gender = None else: language = config[CONF_LANG] - gender = config[CONF_GENDER] + gender = config[ATTR_GENDER] return CloudProvider(cloud, language, gender) @@ -87,12 +92,15 @@ class CloudProvider(Provider): @property def supported_options(self): """Return list of supported options like voice, emotion.""" - return [CONF_GENDER] + return [ATTR_GENDER, ATTR_AUDIO_OUTPUT] @property def default_options(self): """Return a dict include default options.""" - return {CONF_GENDER: self._gender} + return { + ATTR_GENDER: self._gender, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + } async def async_get_tts_audio(self, message, language, options=None): """Load TTS from NabuCasa Cloud.""" @@ -101,10 +109,10 @@ class CloudProvider(Provider): data = await self.cloud.voice.process_tts( message, language, - gender=options[CONF_GENDER], - output=AudioOutput.MP3, + gender=options[ATTR_GENDER], + output=options[ATTR_AUDIO_OUTPUT], ) except VoiceError: return (None, None) - return ("mp3", data) + return (str(options[ATTR_AUDIO_OUTPUT]), data) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index f459e415661..b6a2b8d83fa 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -137,8 +137,11 @@ class CommandSensor(SensorEntity): _LOGGER.warning("Unable to parse output as JSON: %s", value) else: _LOGGER.warning("Empty reply found when expecting JSON data") + if self._value_template is None: + self._attr_native_value = None + return - elif self._value_template is not None: + if self._value_template is not None: self._attr_native_value = ( self._value_template.async_render_with_possible_json_value( value, diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py deleted file mode 100644 index a3bc07ee0a1..00000000000 --- a/homeassistant/components/coronavirus/__init__.py +++ /dev/null @@ -1,88 +0,0 @@ -"""The Coronavirus integration.""" -from datetime import timedelta -import logging - -import async_timeout -import coronavirus - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - aiohttp_client, - entity_registry as er, - update_coordinator, -) -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR] - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Coronavirus component.""" - # Make sure coordinator is initialized. - await get_coordinator(hass) - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Coronavirus from a config entry.""" - if isinstance(entry.data["country"], int): - hass.config_entries.async_update_entry( - entry, data={**entry.data, "country": entry.title} - ) - - @callback - def _async_migrator(entity_entry: er.RegistryEntry): - """Migrate away from unstable ID.""" - country, info_type = entity_entry.unique_id.rsplit("-", 1) - if not country.isnumeric(): - return None - return {"new_unique_id": f"{entry.title}-{info_type}"} - - await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) - - if not entry.unique_id: - hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) - - coordinator = await get_coordinator(hass) - if not coordinator.last_update_success: - await coordinator.async_config_entry_first_refresh() - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def get_coordinator( - hass: HomeAssistant, -) -> update_coordinator.DataUpdateCoordinator: - """Get the data update coordinator.""" - if DOMAIN in hass.data: - return hass.data[DOMAIN] - - async def async_get_cases(): - async with async_timeout.timeout(10): - return { - case.country: case - for case in await coronavirus.get_cases( - aiohttp_client.async_get_clientsession(hass) - ) - } - - hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( - hass, - logging.getLogger(__name__), - name=DOMAIN, - update_method=async_get_cases, - update_interval=timedelta(hours=1), - ) - await hass.data[DOMAIN].async_refresh() - return hass.data[DOMAIN] diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py deleted file mode 100644 index 81e4f06f57f..00000000000 --- a/homeassistant/components/coronavirus/config_flow.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Config flow for Coronavirus integration.""" -from __future__ import annotations - -from typing import Any - -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult - -from . import get_coordinator -from .const import DOMAIN, OPTION_WORLDWIDE - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Coronavirus.""" - - VERSION = 1 - - _options: dict[str, Any] | None = None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - - if self._options is None: - coordinator = await get_coordinator(self.hass) - if not coordinator.last_update_success or coordinator.data is None: - return self.async_abort(reason="cannot_connect") - - self._options = {OPTION_WORLDWIDE: "Worldwide"} - for case in sorted( - coordinator.data.values(), key=lambda case: case.country - ): - self._options[case.country] = case.country - - if user_input is not None: - await self.async_set_unique_id(user_input["country"]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=self._options[user_input["country"]], data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required("country"): vol.In(self._options)}), - errors=errors, - ) diff --git a/homeassistant/components/coronavirus/const.py b/homeassistant/components/coronavirus/const.py deleted file mode 100644 index e1ffa64e88c..00000000000 --- a/homeassistant/components/coronavirus/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for the Coronavirus integration.""" -from coronavirus import DEFAULT_SOURCE - -DOMAIN = "coronavirus" -OPTION_WORLDWIDE = "__worldwide" -ATTRIBUTION = f"Data provided by {DEFAULT_SOURCE.NAME}" diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json deleted file mode 100644 index a053b4056c0..00000000000 --- a/homeassistant/components/coronavirus/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "coronavirus", - "name": "Coronavirus (COVID-19)", - "codeowners": ["@home-assistant/core"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/coronavirus", - "iot_class": "cloud_polling", - "loggers": ["coronavirus"], - "requirements": ["coronavirus==1.1.1"] -} diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py deleted file mode 100644 index 7fa7c5aed08..00000000000 --- a/homeassistant/components/coronavirus/sensor.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Sensor platform for the Corona virus.""" -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import get_coordinator -from .const import ATTRIBUTION, OPTION_WORLDWIDE - -SENSORS = { - "confirmed": "mdi:emoticon-neutral-outline", - "current": "mdi:emoticon-sad-outline", - "recovered": "mdi:emoticon-happy-outline", - "deaths": "mdi:emoticon-cry-outline", -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Defer sensor setup to the shared sensor module.""" - coordinator = await get_coordinator(hass) - - async_add_entities( - CoronavirusSensor(coordinator, config_entry.data["country"], info_type) - for info_type in SENSORS - ) - - -class CoronavirusSensor(CoordinatorEntity, SensorEntity): - """Sensor representing corona virus data.""" - - _attr_attribution = ATTRIBUTION - _attr_native_unit_of_measurement = "people" - - def __init__(self, coordinator, country, info_type): - """Initialize coronavirus sensor.""" - super().__init__(coordinator) - self._attr_icon = SENSORS[info_type] - self._attr_unique_id = f"{country}-{info_type}" - if country == OPTION_WORLDWIDE: - self._attr_name = f"Worldwide Coronavirus {info_type}" - else: - self._attr_name = ( - f"{coordinator.data[country].country} Coronavirus {info_type}" - ) - - self.country = country - self.info_type = info_type - - @property - def available(self) -> bool: - """Return if sensor is available.""" - return self.coordinator.last_update_success and ( - self.country in self.coordinator.data or self.country == OPTION_WORLDWIDE - ) - - @property - def native_value(self): - """State of the sensor.""" - if self.country == OPTION_WORLDWIDE: - sum_cases = 0 - for case in self.coordinator.data.values(): - if (value := getattr(case, self.info_type)) is None: - continue - sum_cases += value - - return sum_cases - - return getattr(self.coordinator.data[self.country], self.info_type) diff --git a/homeassistant/components/coronavirus/strings.json b/homeassistant/components/coronavirus/strings.json deleted file mode 100644 index e0b29d6c8db..00000000000 --- a/homeassistant/components/coronavirus/strings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Pick a country to monitor", - "data": { "country": "Country" } - } - }, - "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - } - } -} diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 30238073b16..db739f3f0db 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -106,7 +106,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = CounterStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -140,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class CounterStorageCollection(collection.StorageCollection): +class CounterStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -154,10 +153,10 @@ class CounterStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class Counter(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index d3852c3bcc3..02a5054dd1d 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -345,10 +345,13 @@ async def async_setup_entry( # noqa: C901 disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False - # Clear out the states so that we will always dispatch + # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects - for state_keys in entry_data.state.values(): - state_keys.clear() + entry_data.stale_state = { + (type(entity_state), key) + for state_dict in entry_data.state.values() + for key, entity_state in state_dict.items() + } if not hass.is_stopping: # Avoid marking every esphome entity as unavailable on shutdown # since it generates a lot of state changed events and database diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d7f25f319ac..7a6027f946b 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -70,6 +70,10 @@ class RuntimeEntryData: client: APIClient store: Store state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) + # When the disconnect callback is called, we mark all states + # as stale so we will always dispatch a state update when the + # device reconnects. This is the same format as state_subscriptions. + stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set) info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) # A second list of EntityInfo objects @@ -206,9 +210,11 @@ class RuntimeEntryData: """Distribute an update of state information to the target.""" key = state.key state_type = type(state) + stale_state = self.stale_state current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) - if current_state == state: + subscription_key = (state_type, key) + if current_state == state and subscription_key not in stale_state: _LOGGER.debug( "%s: ignoring duplicate update with and key %s: %s", self.name, @@ -222,8 +228,8 @@ class RuntimeEntryData: key, state, ) + stale_state.discard(subscription_key) current_state_by_type[key] = state - subscription_key = (state_type, key) if subscription_key in self.state_subscriptions: self.state_subscriptions[subscription_key]() diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8417870eb0a..b1fd062032f 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230405.0"] + "requirements": ["home-assistant-frontend==20230406.1"] } diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 91dd742e802..987a4317ba8 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -33,10 +33,12 @@ from homeassistant.helpers.service import ( from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType +from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .exposed_entities import ExposedEntities + ATTR_ENTRY_ID = "entry_id" _LOGGER = logging.getLogger(__name__) -DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" @@ -340,4 +342,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all ) + exposed_entities = ExposedEntities(hass) + await exposed_entities.async_initialize() + hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + return True diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py new file mode 100644 index 00000000000..f3bc95dd1ee --- /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 00000000000..9317f43ea75 --- /dev/null +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -0,0 +1,351 @@ +"""Control which entities are exposed to voice assistants.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import dataclasses +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.storage import Store + +from .const import DATA_EXPOSED_ENTITIES, DOMAIN + +KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant") + +STORAGE_KEY = f"{DOMAIN}.exposed_entities" +STORAGE_VERSION = 1 + +SAVE_DELAY = 10 + +DEFAULT_EXPOSED_DOMAINS = { + "climate", + "cover", + "fan", + "humidifier", + "light", + "lock", + "scene", + "script", + "switch", + "vacuum", + "water_heater", +} + +DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + + +@dataclasses.dataclass(frozen=True) +class AssistantPreferences: + """Preferences for an assistant.""" + + expose_new: bool + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return {"expose_new": self.expose_new} + + +class ExposedEntities: + """Control assistant settings.""" + + _assistants: dict[str, AssistantPreferences] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + self._hass = hass + self._listeners: dict[str, list[Callable[[], None]]] = {} + self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + + async def async_initialize(self) -> None: + """Finish initializing.""" + websocket_api.async_register_command(self._hass, ws_expose_entity) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) + await self.async_load() + + @callback + def async_listen_entity_updates( + self, assistant: str, listener: Callable[[], None] + ) -> None: + """Listen for updates to entity expose settings.""" + self._listeners.setdefault(assistant, []).append(listener) + + @callback + def async_expose_entity( + self, assistant: str, entity_id: str, should_expose: bool + ) -> None: + """Expose an entity to an assistant. + + Notify listeners if expose flag was changed. + """ + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError("Unknown entity") + + assistant_options: Mapping[str, Any] + if ( + assistant_options := registry_entry.options.get(assistant, {}) + ) and assistant_options.get("should_expose") == should_expose: + return + + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + for listener in self._listeners.get(assistant, []): + listener() + + @callback + def async_get_expose_new_entities(self, assistant: str) -> bool: + """Check if new entities are exposed to an assistant.""" + if prefs := self._assistants.get(assistant): + return prefs.expose_new + return False + + @callback + def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None: + """Enable an assistant to expose new entities.""" + self._assistants[assistant] = AssistantPreferences(expose_new=expose_new) + self._async_schedule_save() + + @callback + def async_get_assistant_settings( + self, assistant: str + ) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + entity_registry = er.async_get(self._hass) + result: dict[str, Mapping[str, Any]] = {} + + for entity_id, entry in entity_registry.entities.items(): + if options := entry.options.get(assistant): + result[entity_id] = options + + return result + + @callback + def async_should_expose(self, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + entity_registry = er.async_get(self._hass) + if not (registry_entry := entity_registry.async_get(entity_id)): + # Entities which are not in the entity registry are not exposed + return False + + if assistant in registry_entry.options: + if "should_expose" in registry_entry.options[assistant]: + should_expose = registry_entry.options[assistant]["should_expose"] + return should_expose + + if (prefs := self._assistants.get(assistant)) and prefs.expose_new: + should_expose = self._is_default_exposed(entity_id, registry_entry) + else: + should_expose = False + + assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {}) + assistant_options = assistant_options | {"should_expose": should_expose} + entity_registry.async_update_entity_options( + entity_id, assistant, assistant_options + ) + + return should_expose + + def _is_default_exposed( + self, entity_id: str, registry_entry: er.RegistryEntry + ) -> bool: + """Return True if an entity is exposed by default.""" + if ( + registry_entry.entity_category is not None + or registry_entry.hidden_by is not None + ): + return False + + domain = split_entity_id(entity_id)[0] + if domain in DEFAULT_EXPOSED_DOMAINS: + return True + + device_class = get_device_class(self._hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES: + return True + + return False + + async def async_load(self) -> None: + """Load from the store.""" + data = await self._store.async_load() + + assistants: dict[str, AssistantPreferences] = {} + + if data: + for domain, preferences in data["assistants"].items(): + assistants[domain] = AssistantPreferences(**preferences) + + self._assistants = assistants + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the preferences.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]: + """Return data to store in a file.""" + data = {} + + data["assistants"] = { + domain: preferences.to_json() + for domain, preferences in self._assistants.items() + } + + return data + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity", + vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)], + vol.Required("entity_ids"): [str], + vol.Required("should_expose"): bool, + } +) +def ws_expose_entity( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + entity_registry = er.async_get(hass) + entity_ids: str = msg["entity_ids"] + + if blocked := next( + ( + entity_id + for entity_id in entity_ids + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" + ) + return + + if unknown := next( + ( + entity_id + for entity_id in entity_ids + if entity_id not in entity_registry.entities + ), + None, + ): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'" + ) + return + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + for entity_id in entity_ids: + for assistant in msg["assistants"]: + exposed_entities.async_expose_entity( + assistant, entity_id, msg["should_expose"] + ) + connection.send_result(msg["id"]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/get", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + } +) +def ws_expose_new_entities_get( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Check if new entities are exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"]) + connection.send_result(msg["id"], {"expose_new": expose_new}) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_new_entities/set", + vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS), + vol.Required("expose_new"): bool, + } +) +def ws_expose_new_entities_set( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose new entities to an assistatant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) + connection.send_result(msg["id"]) + + +@callback +def async_listen_entity_updates( + hass: HomeAssistant, assistant: str, listener: Callable[[], None] +) -> None: + """Listen for updates to entity expose settings.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_listen_entity_updates(assistant, listener) + + +@callback +def async_get_assistant_settings( + hass: HomeAssistant, assistant: str +) -> dict[str, Mapping[str, Any]]: + """Get all entity expose settings for an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_get_assistant_settings(assistant) + + +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: + """Return True if an entity should be exposed to an assistant.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + return exposed_entities.async_should_expose(assistant, entity_id) diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 50f768915ed..452b23d27be 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -57,7 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ImageStorageCollection(collection.StorageCollection): +class ImageStorageCollection(collection.DictStorageCollection): """Image collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -67,7 +67,6 @@ class ImageStorageCollection(collection.StorageCollection): """Initialize media storage collection.""" super().__init__( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), ) self.async_add_listener(self._change_listener) self.image_dir = image_dir @@ -126,11 +125,11 @@ class ImageStorageCollection(collection.StorageCollection): async def _update_data( self, - data: dict[str, Any], + item: dict[str, Any], update_data: dict[str, Any], ) -> dict[str, Any]: """Return a new updated data object.""" - return {**data, **self.UPDATE_SCHEMA(update_data)} + return {**item, **self.UPDATE_SCHEMA(update_data)} async def _change_listener( self, diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a8b221e4939..49dcf731f7b 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -65,7 +65,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputBooleanStorageCollection(collection.StorageCollection): +class InputBooleanStorageCollection(collection.DictStorageCollection): """Input boolean collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -79,10 +79,10 @@ class InputBooleanStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data @bind_hass @@ -110,7 +110,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputBooleanStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index f8ff9164214..d9693a208c1 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -56,7 +56,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -class InputButtonStorageCollection(collection.StorageCollection): +class InputButtonStorageCollection(collection.DictStorageCollection): """Input button collection stored in storage.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -70,10 +70,10 @@ class InputButtonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputButtonStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 34ded40d583..c927b71c77e 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -148,7 +148,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = DateTimeStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -204,7 +203,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class DateTimeStorageCollection(collection.StorageCollection): +class DateTimeStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, has_date_or_time)) @@ -218,10 +217,10 @@ class DateTimeStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputDatetime(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 05d4a4f8b95..9f77bb0a828 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = NumberStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -171,7 +170,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class NumberStorageCollection(collection.StorageCollection): +class NumberStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_number)) @@ -185,7 +184,7 @@ class NumberStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused frontend to add initial value to all input numbers. @@ -201,10 +200,10 @@ class NumberStorageCollection(collection.StorageCollection): return data - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputNumber(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 9e4833954d6..b7a026352d0 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -156,7 +156,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: InputSelectStore( hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -232,7 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputSelectStorageCollection(collection.StorageCollection): +class InputSelectStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_select)) @@ -247,11 +246,11 @@ class InputSelectStorageCollection(collection.StorageCollection): return cast(str, info[CONF_NAME]) async def _update_data( - self, data: dict[str, Any], update_data: dict[str, Any] + self, item: dict[str, Any], update_data: dict[str, Any] ) -> dict[str, Any]: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 6ebfdcd70dc..f246779b64c 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = InputTextStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -165,7 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class InputTextStorageCollection(collection.StorageCollection): +class InputTextStorageCollection(collection.DictStorageCollection): """Input storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_text)) @@ -179,10 +178,10 @@ class InputTextStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.CREATE_UPDATE_SCHEMA(update_data) - return {CONF_ID: data[CONF_ID]} | update_data + return {CONF_ID: item[CONF_ID]} | update_data class InputText(collection.CollectionEntity, RestoreEntity): diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 4b6d9444fd8..6cfcaec61d0 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime, timedelta import logging from typing import Any @@ -186,14 +186,23 @@ def _parse_event(event: dict[str, Any]) -> Event: def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" + start: datetime | date + end: datetime | date + if isinstance(event.start, datetime) and isinstance(event.end, datetime): + start = dt_util.as_local(event.start) + end = dt_util.as_local(event.end) + if (end - start) <= timedelta(seconds=0): + end = start + timedelta(minutes=30) + else: + start = event.start + end = event.end + if (end - start) <= timedelta(days=0): + end = start + timedelta(days=1) + return CalendarEvent( summary=event.summary, - start=dt_util.as_local(event.start) - if isinstance(event.start, datetime) - else event.start, - end=dt_util.as_local(event.end) - if isinstance(event.end, datetime) - else event.end, + start=start, + end=end, description=event.description, uid=event.uid, rrule=event.rrule.as_rrule_str() if event.rrule else None, diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index ef47ea0b1fc..054aaf9b24c 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -6,7 +6,6 @@ import logging import os from pathlib import Path import time -from typing import cast import voluptuous as vol @@ -218,7 +217,7 @@ def _config_info(mode, config): } -class DashboardsCollection(collection.StorageCollection): +class DashboardsCollection(collection.DictStorageCollection): """Collection of dashboards.""" CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) @@ -228,13 +227,12 @@ class DashboardsCollection(collection.StorageCollection): """Initialize the dashboards collection.""" super().__init__( storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), - _LOGGER, ) - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" if (data := await self.store.async_load()) is None: - return cast(dict | None, data) + return data updated = False @@ -246,7 +244,7 @@ class DashboardsCollection(collection.StorageCollection): if updated: await self.store.async_save(data) - return cast(dict | None, data) + return data async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" @@ -263,10 +261,10 @@ class DashboardsCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_URL_PATH] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - updated = {**data, **update_data} + updated = {**item, **update_data} if CONF_ICON in updated and updated[CONF_ICON] is None: updated.pop(CONF_ICON) diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index e6c4acfdf69..b6d0c939fec 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any import uuid import voluptuous as vol @@ -45,7 +45,7 @@ class ResourceYAMLCollection: return self.data -class ResourceStorageCollection(collection.StorageCollection): +class ResourceStorageCollection(collection.DictStorageCollection): """Collection to store resources.""" loaded = False @@ -56,7 +56,6 @@ class ResourceStorageCollection(collection.StorageCollection): """Initialize the storage collection.""" super().__init__( storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY), - _LOGGER, ) self.ll_config = ll_config @@ -68,10 +67,10 @@ class ResourceStorageCollection(collection.StorageCollection): return {"resources": len(self.async_items() or [])} - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" - if (data := await self.store.async_load()) is not None: - return cast(dict | None, data) + if (store_data := await self.store.async_load()) is not None: + return store_data # Import it from config. try: @@ -83,20 +82,20 @@ class ResourceStorageCollection(collection.StorageCollection): return None # Remove it from config and save both resources + config - data = conf[CONF_RESOURCES] + resources: list[dict[str, Any]] = conf[CONF_RESOURCES] try: - vol.Schema([RESOURCE_SCHEMA])(data) + vol.Schema([RESOURCE_SCHEMA])(resources) except vol.Invalid as err: _LOGGER.warning("Resource import failed. Data invalid: %s", err) return None conf.pop(CONF_RESOURCES) - for item in data: + for item in resources: item[CONF_ID] = uuid.uuid4().hex - data = {"items": data} + data: collection.SerializedStorageCollection = {"items": resources} await self.store.async_save(data) await self.ll_config.async_save(conf) @@ -114,7 +113,7 @@ class ResourceStorageCollection(collection.StorageCollection): """Return unique ID.""" return uuid.uuid4().hex - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" if not self.loaded: await self.async_load() @@ -124,4 +123,4 @@ class ResourceStorageCollection(collection.StorageCollection): if CONF_RESOURCE_TYPE_WS in update_data: update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) - return {**data, **update_data} + return {**item, **update_data} diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index ee8cc4e7e97..ef168374bd8 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -31,7 +31,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.network import get_url +from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -152,9 +152,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) webhook_url = webhook.async_generate_path(entry.entry_id) - hass_url = get_url( - hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False - ) + + try: + hass_url = get_url( + hass, + allow_cloud=False, + allow_external=False, + allow_ip=True, + require_ssl=False, + ) + except NoURLAvailableError: + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady( + f"Error registering URL for webhook {entry.entry_id}: " + "HomeAssistant URL is not available" + ) from None + url = f"{hass_url}{webhook_url}" if hass_url.startswith("https"): diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index a5e56d00731..ba11250f83e 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -188,7 +188,7 @@ class PersonStore(Store): return {"items": old_data["persons"]} -class PersonStorageCollection(collection.StorageCollection): +class PersonStorageCollection(collection.DictStorageCollection): """Person collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -197,15 +197,14 @@ class PersonStorageCollection(collection.StorageCollection): def __init__( self, store: Store, - logger: logging.Logger, id_manager: collection.IDManager, yaml_collection: collection.YamlCollection, ) -> None: """Initialize a person storage collection.""" - super().__init__(store, logger, id_manager) + super().__init__(store, id_manager) self.yaml_collection = yaml_collection - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data. A past bug caused onboarding to create invalid person objects. @@ -271,16 +270,16 @@ class PersonStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) user_id = update_data.get(CONF_USER_ID) - if user_id is not None and user_id != data.get(CONF_USER_ID): + if user_id is not None and user_id != item.get(CONF_USER_ID): await self._validate_user_id(user_id) - return {**data, **update_data} + return {**item, **update_data} async def _validate_user_id(self, user_id): """Validate the used user_id.""" @@ -337,7 +336,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) storage_collection = PersonStorageCollection( PersonStore(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, yaml_collection, ) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 1a7649f367a..850aa110171 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -87,7 +87,7 @@ BINARY_SENSORS = ( icon="mdi:bell-ring-outline", icon_off="mdi:doorbell", value=lambda api, ch: api.visitor_detected(ch), - supported=lambda api, ch: api.is_doorbell_enabled(ch), + supported=lambda api, ch: api.is_doorbell(ch), ), ) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index b8de6cd8399..73318f12be1 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.9"] + "requirements": ["reolink-aio==0.5.10"] } diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index fb00d58c7ac..3e91e8ab86d 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, time, timedelta import itertools -import logging from typing import Any, Literal import voluptuous as vol @@ -21,8 +20,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.collection import ( CollectionEntity, + DictStorageCollection, IDManager, - StorageCollection, + SerializedStorageCollection, StorageCollectionWebsocket, YamlCollection, sync_entity_lifecycle, @@ -173,7 +173,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: version=STORAGE_VERSION, minor_version=STORAGE_VERSION_MINOR, ), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) sync_entity_lifecycle(hass, DOMAIN, DOMAIN, component, storage_collection, Schedule) @@ -210,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class ScheduleStorageCollection(StorageCollection): +class ScheduleStorageCollection(DictStorageCollection): """Schedules stored in storage.""" SCHEMA = vol.Schema(BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA) @@ -226,12 +225,12 @@ class ScheduleStorageCollection(StorageCollection): name: str = info[CONF_NAME] return name - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" self.SCHEMA(update_data) - return data | update_data + return item | update_data - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> SerializedStorageCollection | None: """Load the data.""" if data := await super()._async_load_data(): data["items"] = [STORAGE_SCHEMA(item) for item in data["items"]] diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 090835103f9..363c28cc3f8 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -59,7 +59,7 @@ class TagIDManager(collection.IDManager): return suggestion -class TagStorageCollection(collection.StorageCollection): +class TagStorageCollection(collection.DictStorageCollection): """Tag collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -80,9 +80,9 @@ class TagStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[TAG_ID] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {**data, **self.UPDATE_SCHEMA(update_data)} + data = {**item, **self.UPDATE_SCHEMA(update_data)} # make last_scanned JSON serializeable if LAST_SCANNED in update_data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() @@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: id_manager = TagIDManager() hass.data[DOMAIN][TAGS] = storage_collection = TagStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) await storage_collection.async_load() diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 62d962ee526..214d95c72e5 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -119,7 +119,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = TimerStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( @@ -163,7 +162,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class TimerStorageCollection(collection.StorageCollection): +class TimerStorageCollection(collection.DictStorageCollection): """Timer storage based collection.""" CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) @@ -180,9 +179,9 @@ class TimerStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info[CONF_NAME] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - data = {CONF_ID: data[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) + data = {CONF_ID: item[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data) # make duration JSON serializeable if CONF_DURATION in update_data: data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION]) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 119a013ebf6..c1a827c27bb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -59,6 +59,7 @@ ATTR_LANGUAGE = "language" ATTR_MESSAGE = "message" ATTR_OPTIONS = "options" ATTR_PLATFORM = "platform" +ATTR_AUDIO_OUTPUT = "audio_output" BASE_URL_KEY = "tts_base_url" @@ -134,6 +135,7 @@ class TTSCache(TypedDict): filename: str voice: bytes + pending: asyncio.Task | None @callback @@ -495,8 +497,11 @@ class SpeechManager: ) extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - data = self.mem_cache[cache_key]["voice"] - return extension, data + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return extension, cached["voice"] @callback def _generate_cache_key( @@ -527,30 +532,62 @@ class SpeechManager: This method is a coroutine. """ provider = self.providers[engine] - extension, data = await provider.async_get_tts_audio(message, language, options) - if data is None or extension is None: - raise HomeAssistantError(f"No TTS from {engine} for '{message}'") + if options is not None and ATTR_AUDIO_OUTPUT in options: + expected_extension = options[ATTR_AUDIO_OUTPUT] + else: + expected_extension = None - # Create file infos - filename = f"{cache_key}.{extension}".lower() - - # Validate filename - if not _RE_VOICE_FILE.match(filename): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine} is invalid!" + async def get_tts_data() -> str: + """Handle data available.""" + extension, data = await provider.async_get_tts_audio( + message, language, options ) - # Save to memory - if extension == "mp3": - data = self.write_tags(filename, data, provider, message, language, options) - self._async_store_to_memcache(cache_key, filename, data) + if data is None or extension is None: + raise HomeAssistantError(f"No TTS from {engine} for '{message}'") - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) + # Create file infos + filename = f"{cache_key}.{extension}".lower() + # Validate filename + if not _RE_VOICE_FILE.match(filename): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine} is invalid!" + ) + + # Save to memory + if extension == "mp3": + data = self.write_tags( + filename, data, provider, message, language, options + ) + self._async_store_to_memcache(cache_key, filename, data) + + if cache: + self.hass.async_create_task( + self._async_save_tts_audio(cache_key, filename, data) + ) + + return filename + + audio_task = self.hass.async_create_task(get_tts_data()) + + if expected_extension is None: + return await audio_task + + def handle_error(_future: asyncio.Future) -> None: + """Handle error.""" + if audio_task.exception(): + self.mem_cache.pop(cache_key, None) + + audio_task.add_done_callback(handle_error) + + filename = f"{cache_key}.{expected_extension}".lower() + self.mem_cache[cache_key] = { + "filename": filename, + "voice": b"", + "pending": audio_task, + } return filename async def _async_save_tts_audio( @@ -601,7 +638,11 @@ class SpeechManager: self, cache_key: str, filename: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" - self.mem_cache[cache_key] = {"filename": filename, "voice": data} + self.mem_cache[cache_key] = { + "filename": filename, + "voice": data, + "pending": None, + } @callback def async_remove_from_mem() -> None: @@ -628,7 +669,11 @@ class SpeechManager: await self._async_file_to_mem(cache_key) content, _ = mimetypes.guess_type(filename) - return content, self.mem_cache[cache_key]["voice"] + cached = self.mem_cache[cache_key] + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + return content, cached["voice"] @staticmethod def write_tags( diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py index 8a2c04d8301..4edeb1e6bcd 100644 --- a/homeassistant/components/voice_assistant/__init__.py +++ b/homeassistant/components/voice_assistant/__init__.py @@ -17,6 +17,7 @@ from .pipeline import ( PipelineRun, PipelineStage, async_get_pipeline, + async_setup_pipeline_store, ) from .websocket_api import async_register_websocket_api @@ -31,7 +32,7 @@ __all__ = ( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Voice Assistant integration.""" - hass.data[DOMAIN] = {} + await async_setup_pipeline_store(hass) async_register_websocket_api(hass) return True @@ -61,7 +62,7 @@ async def async_pipeline_from_audio_stream( if context is None: context = Context() - pipeline = async_get_pipeline( + pipeline = await async_get_pipeline( hass, pipeline_id=pipeline_id, language=language, diff --git a/homeassistant/components/voice_assistant/const.py b/homeassistant/components/voice_assistant/const.py index 86572fb459f..f3006c98169 100644 --- a/homeassistant/components/voice_assistant/const.py +++ b/homeassistant/components/voice_assistant/const.py @@ -1,3 +1,2 @@ """Constants for the Voice Assistant integration.""" DOMAIN = "voice_assistant" -DEFAULT_PIPELINE = "default" diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 7c909c32819..26cc2d2d27e 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -7,13 +7,20 @@ from dataclasses import asdict, dataclass, field import logging from typing import Any +import voluptuous as vol + from homeassistant.backports.enum import StrEnum from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.util.dt import utcnow +from homeassistant.helpers.collection import ( + StorageCollection, + StorageCollectionWebsocket, +) +from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util, ulid as ulid_util from .const import DOMAIN from .error import ( @@ -25,23 +32,39 @@ from .error import ( _LOGGER = logging.getLogger(__name__) +STORAGE_KEY = f"{DOMAIN}.pipelines" +STORAGE_VERSION = 1 -@callback -def async_get_pipeline( +STORAGE_FIELDS = { + vol.Required("conversation_engine"): str, + vol.Required("language"): str, + vol.Required("name"): str, + vol.Required("stt_engine"): str, + vol.Required("tts_engine"): str, +} + +SAVE_DELAY = 10 + + +async def async_get_pipeline( hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None ) -> Pipeline | None: """Get a pipeline by id or create one for a language.""" + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN] + if pipeline_id is not None: - return hass.data[DOMAIN].get(pipeline_id) + return pipeline_store.data.get(pipeline_id) # Construct a pipeline for the required/configured language language = language or hass.config.language - return Pipeline( - name=language, - language=language, - stt_engine=None, # first engine - conversation_engine=None, # first agent - tts_engine=None, # first engine + return await pipeline_store.async_create_item( + { + "name": language, + "language": language, + "stt_engine": None, # first engine + "conversation_engine": None, # first agent + "tts_engine": None, # first engine + } ) @@ -65,7 +88,7 @@ class PipelineEvent: type: PipelineEventType data: dict[str, Any] | None = None - timestamp: str = field(default_factory=lambda: utcnow().isoformat()) + timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat()) def as_dict(self) -> dict[str, Any]: """Return a dict representation of the event.""" @@ -79,16 +102,29 @@ class PipelineEvent: PipelineEventCallback = Callable[[PipelineEvent], None] -@dataclass +@dataclass(frozen=True) class Pipeline: """A voice assistant pipeline.""" - name: str - language: str | None - stt_engine: str | None conversation_engine: str | None + language: str | None + name: str + stt_engine: str | None tts_engine: str | None + id: str = field(default_factory=ulid_util.ulid) + + def to_json(self) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return { + "conversation_engine": self.conversation_engine, + "id": self.id, + "language": self.language, + "name": self.name, + "stt_engine": self.stt_engine, + "tts_engine": self.tts_engine, + } + class PipelineStage(StrEnum): """Stages of a pipeline.""" @@ -478,3 +514,47 @@ class PipelineInput: if prepare_tasks: await asyncio.gather(*prepare_tasks) + + +class PipelineStorageCollection(StorageCollection[Pipeline]): + """Pipeline storage collection.""" + + CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS) + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + # We don't need to validate, the WS API has already validated + return data + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + return ulid_util.ulid() + + async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline: + """Return a new updated item.""" + return Pipeline(id=item.id, **update_data) + + def _create_item(self, item_id: str, data: dict) -> Pipeline: + """Create an item from validated config.""" + return Pipeline(id=item_id, **data) + + def _deserialize_item(self, data: dict) -> Pipeline: + """Create an item from its serialized representation.""" + return Pipeline(**data) + + def _serialize_item(self, item_id: str, item: Pipeline) -> dict: + """Return the serialized representation of an item.""" + return item.to_json() + + +async def async_setup_pipeline_store(hass): + """Set up the pipeline storage collection.""" + pipeline_store = PipelineStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY) + ) + await pipeline_store.async_load() + StorageCollectionWebsocket( + pipeline_store, f"{DOMAIN}/pipeline", "pipeline", STORAGE_FIELDS, STORAGE_FIELDS + ).async_setup(hass) + hass.data[DOMAIN] = pipeline_store diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 0df13fc19ea..42c22bfbed5 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -61,7 +61,7 @@ async def websocket_run( language = "en-US" pipeline_id = msg.get("pipeline") - pipeline = async_get_pipeline( + pipeline = await async_get_pipeline( hass, pipeline_id=pipeline_id, language=language, diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 3ab2a35bf1e..cad92a2978c 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -163,7 +163,7 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS]) -class ZoneStorageCollection(collection.StorageCollection): +class ZoneStorageCollection(collection.DictStorageCollection): """Zone collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) @@ -178,10 +178,10 @@ class ZoneStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return cast(str, info[CONF_NAME]) - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - return {**data, **update_data} + return {**item, **update_data} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -198,7 +198,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: storage_collection = ZoneStorageCollection( storage.Store(hass, STORAGE_VERSION, STORAGE_KEY), - logging.getLogger(f"{__name__}.storage_collection"), id_manager, ) collection.sync_entity_lifecycle( diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0370c4249ee..08f43fa5b04 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -80,7 +80,6 @@ FLOWS = { "coinbase", "control4", "coolmaster", - "coronavirus", "cpuspeed", "crownstone", "daikin", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9517cba3486..3f5ee393800 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -881,12 +881,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "coronavirus": { - "name": "Coronavirus (COVID-19)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "cozytouch": { "name": "Atlantic Cozytouch", "integration_type": "virtual", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 10ed9fdd65d..48746b339dc 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -216,7 +216,7 @@ class AreaRegistry: if not new_values: return old - new = self.areas[area_id] = attr.evolve(old, **new_values) + new = self.areas[area_id] = attr.evolve(old, **new_values) # type: ignore[arg-type] if normalized_name is not None: self._normalized_name_area_idx[ normalized_name diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 9da6f84207a..4d5dc4012ee 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from itertools import groupby import logging from operator import attrgetter -from typing import Any, cast +from typing import Any, Generic, TypedDict, TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -32,6 +32,8 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" +_T = TypeVar("_T") + @dataclass class CollectionChangeSet: @@ -121,23 +123,20 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection(ABC): +class ObservableCollection(ABC, Generic[_T]): """Base collection type that can be observed.""" - def __init__( - self, logger: logging.Logger, id_manager: IDManager | None = None - ) -> None: + def __init__(self, id_manager: IDManager | None) -> None: """Initialize the base collection.""" - self.logger = logger self.id_manager = id_manager or IDManager() - self.data: dict[str, dict] = {} + self.data: dict[str, _T] = {} self.listeners: list[ChangeListener] = [] self.change_set_listeners: list[ChangeSetListener] = [] self.id_manager.add_collection(self.data) @callback - def async_items(self) -> list[dict]: + def async_items(self) -> list[_T]: """Return list of items in collection.""" return list(self.data.values()) @@ -172,9 +171,18 @@ class ObservableCollection(ABC): ) -class YamlCollection(ObservableCollection): +class YamlCollection(ObservableCollection[dict]): """Offer a collection based on static data.""" + def __init__( + self, + logger: logging.Logger, + id_manager: IDManager | None = None, + ) -> None: + """Initialize the storage collection.""" + super().__init__(id_manager) + self.logger = logger + @staticmethod def create_entity( entity_class: type[CollectionEntity], config: ConfigType @@ -212,17 +220,22 @@ class YamlCollection(ObservableCollection): await self.notify_changes(change_sets) -class StorageCollection(ObservableCollection, ABC): +class SerializedStorageCollection(TypedDict): + """Serialized storage collection.""" + + items: list[dict[str, Any]] + + +class StorageCollection(ObservableCollection[_T], ABC): """Offer a CRUD interface on top of JSON storage.""" def __init__( self, - store: Store, - logger: logging.Logger, + store: Store[SerializedStorageCollection], id_manager: IDManager | None = None, ) -> None: """Initialize the storage collection.""" - super().__init__(logger, id_manager) + super().__init__(id_manager) self.store = store @staticmethod @@ -237,9 +250,9 @@ class StorageCollection(ObservableCollection, ABC): """Home Assistant object.""" return self.store.hass - async def _async_load_data(self) -> dict | None: + async def _async_load_data(self) -> SerializedStorageCollection | None: """Load the data.""" - return cast(dict | None, await self.store.async_load()) + return await self.store.async_load() async def async_load(self) -> None: """Load the storage Manager.""" @@ -249,7 +262,7 @@ class StorageCollection(ObservableCollection, ABC): raw_storage = {"items": []} for item in raw_storage["items"]: - self.data[item[CONF_ID]] = item + self.data[item[CONF_ID]] = self._deserialize_item(item) await self.notify_changes( [ @@ -268,21 +281,35 @@ class StorageCollection(ObservableCollection, ABC): """Suggest an ID based on the config.""" @abstractmethod - async def _update_data(self, data: dict, update_data: dict) -> dict: - """Return a new updated data object.""" + async def _update_data(self, item: _T, update_data: dict) -> _T: + """Return a new updated item.""" - async def async_create_item(self, data: dict) -> dict: + @abstractmethod + def _create_item(self, item_id: str, data: dict) -> _T: + """Create an item from validated config.""" + + @abstractmethod + def _deserialize_item(self, data: dict) -> _T: + """Create an item from its serialized representation.""" + + @abstractmethod + def _serialize_item(self, item_id: str, item: _T) -> dict: + """Return the serialized representation of an item. + + The serialized representation must include the item_id in the "id" key. + """ + + async def async_create_item(self, data: dict) -> _T: """Create a new item.""" - item = await self._process_create_data(data) - item[CONF_ID] = self.id_manager.generate_id(self._get_suggested_id(item)) - self.data[item[CONF_ID]] = item + validated_data = await self._process_create_data(data) + item_id = self.id_manager.generate_id(self._get_suggested_id(validated_data)) + item = self._create_item(item_id, validated_data) + self.data[item_id] = item self._async_schedule_save() - await self.notify_changes( - [CollectionChangeSet(CHANGE_ADDED, item[CONF_ID], item)] - ) + await self.notify_changes([CollectionChangeSet(CHANGE_ADDED, item_id, item)]) return item - async def async_update_item(self, item_id: str, updates: dict) -> dict: + async def async_update_item(self, item_id: str, updates: dict) -> _T: """Update item.""" if item_id not in self.data: raise ItemNotFound(item_id) @@ -315,13 +342,34 @@ class StorageCollection(ObservableCollection, ABC): @callback def _async_schedule_save(self) -> None: - """Schedule saving the area registry.""" + """Schedule saving the collection.""" self.store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict: - """Return data of area registry to store in a file.""" - return {"items": list(self.data.values())} + def _data_to_save(self) -> SerializedStorageCollection: + """Return JSON-compatible date for storing to file.""" + return { + "items": [ + self._serialize_item(item_id, item) + for item_id, item in self.data.items() + ] + } + + +class DictStorageCollection(StorageCollection[dict]): + """A specialized StorageCollection where the items are untyped dicts.""" + + def _create_item(self, item_id: str, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return {CONF_ID: item_id} | data + + def _deserialize_item(self, data: dict) -> dict: + """Create an item from its validated, serialized representation.""" + return data + + def _serialize_item(self, item_id: str, item: dict) -> dict: + """Return the serialized representation of an item.""" + return item class IDLessCollection(YamlCollection): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6da995662f7..2de7bb2bb43 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230405.0 +home-assistant-frontend==20230406.1 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/mypy.ini b/mypy.ini index b3a4cafba36..389e639a1a4 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/requirements_all.txt b/requirements_all.txt index 167122c39c1..07e36a655cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -119,7 +119,7 @@ aioairq==0.2.4 aioairzone==0.5.2 # homeassistant.components.ambient_station -aioambient==2021.11.0 +aioambient==2022.10.0 # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -544,9 +544,6 @@ connect-box==0.2.8 # homeassistant.components.xiaomi_miio construct==2.10.56 -# homeassistant.components.coronavirus -coronavirus==1.1.1 - # homeassistant.components.utility_meter croniter==1.0.6 @@ -910,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230405.0 +home-assistant-frontend==20230406.1 # homeassistant.components.conversation home-assistant-intents==2023.3.29 @@ -2234,7 +2231,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.9 +reolink-aio==0.5.10 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test.txt b/requirements_test.txt index caf29fc558a..097cff38d59 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ codecov==2.1.12 coverage==7.2.1 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.1.1 +mypy==1.2.0 pre-commit==3.1.0 pydantic==1.10.7 pylint==2.17.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d489ded9173..58674d4ab0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioairq==0.2.4 aioairzone==0.5.2 # homeassistant.components.ambient_station -aioambient==2021.11.0 +aioambient==2022.10.0 # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -430,9 +430,6 @@ colorthief==0.2.1 # homeassistant.components.xiaomi_miio construct==2.10.56 -# homeassistant.components.coronavirus -coronavirus==1.1.1 - # homeassistant.components.utility_meter croniter==1.0.6 @@ -696,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230405.0 +home-assistant-frontend==20230406.1 # homeassistant.components.conversation home-assistant-intents==2023.3.29 @@ -1600,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.9 +reolink-aio==0.5.10 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 40809d2759c..7933d8639c1 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from homeassistant.components import cloud -from homeassistant.components.cloud import const +from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component @@ -18,9 +18,11 @@ async def mock_cloud(hass, config=None): def mock_cloud_prefs(hass, prefs={}): """Fixture for cloud component.""" prefs_to_set = { + const.PREF_ALEXA_SETTINGS_VERSION: cloud_prefs.ALEXA_SETTINGS_VERSION, const.PREF_ENABLE_ALEXA: True, const.PREF_ENABLE_GOOGLE: True, const.PREF_GOOGLE_SECURE_DEVICES_PIN: None, + const.PREF_GOOGLE_SETTINGS_VERSION: cloud_prefs.GOOGLE_SETTINGS_VERSION, } prefs_to_set.update(prefs) hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 73dd69db447..2cb363b0420 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -6,10 +6,22 @@ import pytest from homeassistant.components.alexa import errors from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config +from homeassistant.components.cloud.const import ( + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_SHOULD_EXPOSE, +) +from homeassistant.components.cloud.prefs import CloudPreferences +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.setup import async_setup_component from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -21,10 +33,23 @@ def cloud_stub(): return Mock(is_logged_in=True, subscription_expired=False) +def expose_new(hass, expose_new): + """Enable exposing new entities to Alexa.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new) + + +def expose_entity(hass, entity_id, should_expose): + """Expose an entity to Alexa.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose) + + async def test_alexa_config_expose_entity_prefs( hass: HomeAssistant, cloud_prefs, cloud_stub, entity_registry: er.EntityRegistry ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) entity_entry1 = entity_registry.async_get_or_create( "light", "test", @@ -53,54 +78,62 @@ async def test_alexa_config_expose_entity_prefs( suggested_object_id="hidden_user_light", hidden_by=er.RegistryEntryHider.USER, ) + entity_entry5 = entity_registry.async_get_or_create( + "light", + "test", + "light_basement_id", + suggested_object_id="basement", + ) + entity_entry6 = entity_registry.async_get_or_create( + "light", + "test", + "light_entrance_id", + suggested_object_id="entrance", + ) - entity_conf = {"should_expose": False} await cloud_prefs.async_update( - alexa_entity_configs={"light.kitchen": entity_conf}, - alexa_default_expose=["light"], alexa_enabled=True, alexa_report_state=False, ) + expose_new(hass, True) + expose_entity(hass, entity_entry5.entity_id, False) conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + # can't expose an entity which is not in the entity registry + with pytest.raises(HomeAssistantError): + expose_entity(hass, "light.kitchen", True) assert not conf.should_expose("light.kitchen") - assert not conf.should_expose(entity_entry1.entity_id) - assert not conf.should_expose(entity_entry2.entity_id) - assert not conf.should_expose(entity_entry3.entity_id) - assert not conf.should_expose(entity_entry4.entity_id) - - entity_conf["should_expose"] = True - assert conf.should_expose("light.kitchen") # categorized and hidden entities should not be exposed assert not conf.should_expose(entity_entry1.entity_id) assert not conf.should_expose(entity_entry2.entity_id) assert not conf.should_expose(entity_entry3.entity_id) assert not conf.should_expose(entity_entry4.entity_id) + # this has been hidden + assert not conf.should_expose(entity_entry5.entity_id) + # exposed by default + assert conf.should_expose(entity_entry6.entity_id) - entity_conf["should_expose"] = None - assert conf.should_expose("light.kitchen") - # categorized and hidden entities should not be exposed - assert not conf.should_expose(entity_entry1.entity_id) - assert not conf.should_expose(entity_entry2.entity_id) - assert not conf.should_expose(entity_entry3.entity_id) - assert not conf.should_expose(entity_entry4.entity_id) + expose_entity(hass, entity_entry5.entity_id, True) + assert conf.should_expose(entity_entry5.entity_id) + + expose_entity(hass, entity_entry5.entity_id, None) + assert not conf.should_expose(entity_entry5.entity_id) assert "alexa" not in hass.config.components - await cloud_prefs.async_update( - alexa_default_expose=["sensor"], - ) await hass.async_block_till_done() assert "alexa" in hass.config.components - assert not conf.should_expose("light.kitchen") + assert not conf.should_expose(entity_entry5.entity_id) async def test_alexa_config_report_state( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) + await cloud_prefs.async_update( alexa_report_state=False, ) @@ -134,6 +167,8 @@ async def test_alexa_config_invalidate_token( hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker ) -> None: """Test Alexa config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) + aioclient_mock.post( "https://example/access_token", json={ @@ -181,10 +216,18 @@ async def test_alexa_config_fail_refresh_token( hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, reject_reason, expected_exception, ) -> None: """Test Alexa config failing to refresh token.""" + assert await async_setup_component(hass, "homeassistant", {}) + # Enable exposing new entities to Alexa + expose_new(hass, True) + # Register a fan entity + entity_entry = entity_registry.async_get_or_create( + "fan", "test", "unique", suggested_object_id="test_fan" + ) aioclient_mock.post( "https://example/access_token", @@ -216,7 +259,7 @@ async def test_alexa_config_fail_refresh_token( assert conf.should_report_state is False assert conf.is_reporting_states is False - hass.states.async_set("fan.test_fan", "off") + hass.states.async_set(entity_entry.entity_id, "off") # Enable state reporting await cloud_prefs.async_update(alexa_report_state=True) @@ -227,7 +270,7 @@ async def test_alexa_config_fail_refresh_token( assert conf.is_reporting_states is True # Change states to trigger event listener - hass.states.async_set("fan.test_fan", "on") + hass.states.async_set(entity_entry.entity_id, "on") await hass.async_block_till_done() # Invalidate the token and try to fetch another @@ -240,7 +283,7 @@ async def test_alexa_config_fail_refresh_token( ) # Change states to trigger event listener - hass.states.async_set("fan.test_fan", "off") + hass.states.async_set(entity_entry.entity_id, "off") await hass.async_block_till_done() # Check state reporting is still wanted in cloud prefs, but disabled for Alexa @@ -292,16 +335,30 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync( - hass: HomeAssistant, cloud_prefs, cloud_stub + hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to updating exposed entities.""" - hass.states.async_set("binary_sensor.door", "on") + assert await async_setup_component(hass, "homeassistant", {}) + # Enable exposing new entities to Alexa + expose_new(hass, True) + # Register entities + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique", suggested_object_id="door" + ) + sensor_entry = entity_registry.async_get_or_create( + "sensor", "test", "unique", suggested_object_id="temp" + ) + light_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + + hass.states.async_set(binary_sensor_entry.entity_id, "on") hass.states.async_set( - "sensor.temp", + sensor_entry.entity_id, "23", {"device_class": "temperature", "unit_of_measurement": "°C"}, ) - hass.states.async_set("light.kitchen", "off") + hass.states.async_set(light_entry.entity_id, "off") await cloud_prefs.async_update( alexa_enabled=True, @@ -313,34 +370,26 @@ async def test_alexa_update_expose_trigger_sync( await conf.async_initialize() with patch_sync_helper() as (to_update, to_remove): - await cloud_prefs.async_update_alexa_entity_config( - entity_id="light.kitchen", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() assert conf._alexa_sync_unsub is None - assert to_update == ["light.kitchen"] + assert to_update == [light_entry.entity_id] assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): - await cloud_prefs.async_update_alexa_entity_config( - entity_id="light.kitchen", should_expose=False - ) - await cloud_prefs.async_update_alexa_entity_config( - entity_id="binary_sensor.door", should_expose=True - ) - await cloud_prefs.async_update_alexa_entity_config( - entity_id="sensor.temp", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() assert conf._alexa_sync_unsub is None - assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"] - assert to_remove == ["light.kitchen"] + assert sorted(to_update) == [binary_sensor_entry.entity_id, sensor_entry.entity_id] + assert to_remove == [light_entry.entity_id] with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update( @@ -350,56 +399,65 @@ async def test_alexa_update_expose_trigger_sync( assert conf._alexa_sync_unsub is None assert to_update == [] - assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"] + assert to_remove == [ + binary_sensor_entry.entity_id, + sensor_entry.entity_id, + light_entry.entity_id, + ] async def test_alexa_entity_registry_sync( - hass: HomeAssistant, mock_cloud_login, cloud_prefs + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_login, + cloud_prefs, ) -> None: """Test Alexa config responds to entity registry.""" + # Enable exposing new entities to Alexa + expose_new(hass, True) + await alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ).async_initialize() with patch_sync_helper() as (to_update, to_remove): - hass.bus.async_fire( - er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" ) await hass.async_block_till_done() - assert to_update == ["light.kitchen"] + assert to_update == [entry.entity_id] assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "remove", "entity_id": "light.kitchen"}, + {"action": "remove", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() assert to_update == [] - assert to_remove == ["light.kitchen"] + assert to_remove == [entry.entity_id] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, { "action": "update", - "entity_id": "light.kitchen", + "entity_id": entry.entity_id, "changes": ["entity_id"], "old_entity_id": "light.living_room", }, ) await hass.async_block_till_done() - assert to_update == ["light.kitchen"] + assert to_update == [entry.entity_id] assert to_remove == ["light.living_room"] with patch_sync_helper() as (to_update, to_remove): hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]}, + {"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]}, ) await hass.async_block_till_done() @@ -411,6 +469,7 @@ async def test_alexa_update_report_state( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to reporting state.""" + assert await async_setup_component(hass, "homeassistant", {}) await cloud_prefs.async_update( alexa_report_state=False, ) @@ -450,6 +509,7 @@ async def test_alexa_handle_logout( hass: HomeAssistant, cloud_prefs, cloud_stub ) -> None: """Test Alexa config responds to logging out.""" + assert await async_setup_component(hass, "homeassistant", {}) aconf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) @@ -475,3 +535,118 @@ async def test_alexa_handle_logout( await hass.async_block_till_done() assert len(mock_enable.return_value.mock_calls) == 1 + + +async def test_alexa_config_migrate_expose_entity_prefs( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_exposed", + suggested_object_id="exposed", + ) + + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + + entity_config = entity_registry.async_get_or_create( + "light", + "test", + "light_config", + suggested_object_id="config", + entity_category=EntityCategory.CONFIG, + ) + + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + entity_blocked = entity_registry.async_get_or_create( + "group", + "test", + "group_all_locks", + suggested_object_id="all_locks", + ) + assert entity_blocked.entity_id == "group.all_locks" + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + + entity_exposed = entity_registry.async_get(entity_exposed.entity_id) + assert entity_exposed.options == {"cloud.alexa": {"should_expose": True}} + + entity_migrated = entity_registry.async_get(entity_migrated.entity_id) + assert entity_migrated.options == {"cloud.alexa": {"should_expose": False}} + + entity_config = entity_registry.async_get(entity_config.entity_id) + assert entity_config.options == {"cloud.alexa": {"should_expose": False}} + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.alexa": {"should_expose": True}} + + entity_blocked = entity_registry.async_get(entity_blocked.entity_id) + assert entity_blocked.options == {"cloud.alexa": {"should_expose": False}} + + +async def test_alexa_config_migrate_expose_entity_prefs_default_none( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + + cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = None + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.alexa": {"should_expose": True}} diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index b7bfed53aac..1afe9956288 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -13,8 +13,13 @@ from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, ) +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -245,14 +250,25 @@ async def test_google_config_expose_entity( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config exposing entity method uses latest config.""" + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", True) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") gconf = await cloud_client.get_google_config() assert gconf.should_expose(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=False + exposed_entities.async_expose_entity( + "cloud.google_assistant", entity_entry.entity_id, False ) assert not gconf.should_expose(state) @@ -262,14 +278,21 @@ async def test_google_config_should_2fa( hass: HomeAssistant, mock_cloud_setup, mock_cloud_login ) -> None: """Test Google config disabling 2FA method uses latest config.""" + entity_registry = er.async_get(hass) + + # Register a light entity + entity_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + cloud_client = hass.data[DOMAIN].client gconf = await cloud_client.get_google_config() - state = State("light.kitchen", "on") + state = State(entity_entry.entity_id, "on") assert gconf.should_2fa(state) - await cloud_client.prefs.async_update_google_entity_config( - entity_id="light.kitchen", disable_2fa=True + entity_registry.async_update_entity_options( + entity_entry.entity_id, "cloud.google_assistant", {"disable_2fa": True} ) assert not gconf.should_2fa(state) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 6725fbea633..738b3fa7cd7 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -6,11 +6,24 @@ from freezegun import freeze_time import pytest from homeassistant.components.cloud import GACTIONS_SCHEMA +from homeassistant.components.cloud.const import ( + PREF_DISABLE_2FA, + PREF_GOOGLE_DEFAULT_EXPOSE, + PREF_GOOGLE_ENTITY_CONFIGS, + PREF_SHOULD_EXPOSE, +) from homeassistant.components.cloud.google_config import CloudGoogleConfig +from homeassistant.components.cloud.prefs import CloudPreferences from homeassistant.components.google_assistant import helpers as ga_helpers +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, +) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -28,10 +41,26 @@ def mock_conf(hass, cloud_prefs): ) +def expose_new(hass, expose_new): + """Enable exposing new entities to Google.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new) + + +def expose_entity(hass, entity_id, should_expose): + """Expose an entity to Google.""" + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_expose_entity( + "cloud.google_assistant", entity_id, should_expose + ) + + async def test_google_update_report_state( mock_conf, hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config responds to updating preference.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -51,6 +80,8 @@ async def test_google_update_report_state_subscription_expired( mock_conf, hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config not reporting state when subscription has expired.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -68,6 +99,8 @@ async def test_google_update_report_state_subscription_expired( async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> None: """Test sync devices.""" + assert await async_setup_component(hass, "homeassistant", {}) + await mock_conf.async_initialize() await mock_conf.async_connect_agent_user("mock-user-id") @@ -88,6 +121,22 @@ async def test_google_update_expose_trigger_sync( hass: HomeAssistant, cloud_prefs ) -> None: """Test Google config responds to updating exposed entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + # Register entities + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique", suggested_object_id="door" + ) + sensor_entry = entity_registry.async_get_or_create( + "sensor", "test", "unique", suggested_object_id="temp" + ) + light_entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + with freeze_time(utcnow()): config = CloudGoogleConfig( hass, @@ -102,9 +151,7 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await cloud_prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -114,15 +161,9 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await cloud_prefs.async_update_google_entity_config( - entity_id="light.kitchen", should_expose=False - ) - await cloud_prefs.async_update_google_entity_config( - entity_id="binary_sensor.door", should_expose=True - ) - await cloud_prefs.async_update_google_entity_config( - entity_id="sensor.temp", should_expose=True - ) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -134,6 +175,11 @@ async def test_google_entity_registry_sync( hass: HomeAssistant, mock_cloud_login, cloud_prefs ) -> None: """Test Google config responds to entity registry.""" + entity_registry = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + config = CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) @@ -146,9 +192,8 @@ async def test_google_entity_registry_sync( ga_helpers, "SYNC_DELAY", 0 ): # Created entity - hass.bus.async_fire( - er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" ) await hass.async_block_till_done() @@ -157,7 +202,7 @@ async def test_google_entity_registry_sync( # Removed entity hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "remove", "entity_id": "light.kitchen"}, + {"action": "remove", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() @@ -168,7 +213,7 @@ async def test_google_entity_registry_sync( er.EVENT_ENTITY_REGISTRY_UPDATED, { "action": "update", - "entity_id": "light.kitchen", + "entity_id": entry.entity_id, "changes": ["entity_id"], }, ) @@ -179,7 +224,7 @@ async def test_google_entity_registry_sync( # Entity registry updated with non-relevant changes hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]}, + {"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]}, ) await hass.async_block_till_done() @@ -189,7 +234,7 @@ async def test_google_entity_registry_sync( hass.state = CoreState.starting hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, - {"action": "create", "entity_id": "light.kitchen"}, + {"action": "create", "entity_id": entry.entity_id}, ) await hass.async_block_till_done() @@ -204,6 +249,10 @@ async def test_google_device_registry_sync( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) ent_reg = er.async_get(hass) + + # Enable exposing new entities to Google + expose_new(hass, True) + entity_entry = ent_reg.async_get_or_create("light", "hue", "1234", device_id="1234") entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD") @@ -293,6 +342,7 @@ async def test_google_config_expose_entity_prefs( hass: HomeAssistant, mock_conf, cloud_prefs, entity_registry: er.EntityRegistry ) -> None: """Test Google config should expose using prefs.""" + assert await async_setup_component(hass, "homeassistant", {}) entity_entry1 = entity_registry.async_get_or_create( "light", "test", @@ -321,45 +371,49 @@ async def test_google_config_expose_entity_prefs( suggested_object_id="hidden_user_light", hidden_by=er.RegistryEntryHider.USER, ) - - entity_conf = {"should_expose": False} - await cloud_prefs.async_update( - google_entity_configs={"light.kitchen": entity_conf}, - google_default_expose=["light"], + entity_entry5 = entity_registry.async_get_or_create( + "light", + "test", + "light_basement_id", + suggested_object_id="basement", ) + entity_entry6 = entity_registry.async_get_or_create( + "light", + "test", + "light_entrance_id", + suggested_object_id="entrance", + ) + + expose_new(hass, True) + expose_entity(hass, entity_entry5.entity_id, False) state = State("light.kitchen", "on") state_config = State(entity_entry1.entity_id, "on") state_diagnostic = State(entity_entry2.entity_id, "on") state_hidden_integration = State(entity_entry3.entity_id, "on") state_hidden_user = State(entity_entry4.entity_id, "on") + state_not_exposed = State(entity_entry5.entity_id, "on") + state_exposed_default = State(entity_entry6.entity_id, "on") + # can't expose an entity which is not in the entity registry + with pytest.raises(HomeAssistantError): + expose_entity(hass, "light.kitchen", True) assert not mock_conf.should_expose(state) - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) - - entity_conf["should_expose"] = True - assert mock_conf.should_expose(state) # categorized and hidden entities should not be exposed assert not mock_conf.should_expose(state_config) assert not mock_conf.should_expose(state_diagnostic) assert not mock_conf.should_expose(state_hidden_integration) assert not mock_conf.should_expose(state_hidden_user) + # this has been hidden + assert not mock_conf.should_expose(state_not_exposed) + # exposed by default + assert mock_conf.should_expose(state_exposed_default) - entity_conf["should_expose"] = None - assert mock_conf.should_expose(state) - # categorized and hidden entities should not be exposed - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) + expose_entity(hass, entity_entry5.entity_id, True) + assert mock_conf.should_expose(state_not_exposed) - await cloud_prefs.async_update( - google_default_expose=["sensor"], - ) - assert not mock_conf.should_expose(state) + expose_entity(hass, entity_entry5.entity_id, None) + assert not mock_conf.should_expose(state_not_exposed) def test_enabled_requires_valid_sub( @@ -379,6 +433,7 @@ def test_enabled_requires_valid_sub( async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None: """Test that we set up the integration if used.""" + assert await async_setup_component(hass, "homeassistant", {}) mock_conf._cloud.subscription_expired = False assert "google_assistant" not in hass.config.components @@ -423,3 +478,136 @@ async def test_google_handle_logout( await hass.async_block_till_done() assert len(mock_enable.return_value.mock_calls) == 1 + + +async def test_google_config_migrate_expose_entity_prefs( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_exposed", + suggested_object_id="exposed", + ) + + entity_no_2fa_exposed = entity_registry.async_get_or_create( + "light", + "test", + "light_no_2fa_exposed", + suggested_object_id="no_2fa_exposed", + ) + + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + + entity_config = entity_registry.async_get_or_create( + "light", + "test", + "light_config", + suggested_object_id="config", + entity_category=EntityCategory.CONFIG, + ) + + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + entity_blocked = entity_registry.async_get_or_create( + "group", + "test", + "group_all_locks", + suggested_object_id="all_locks", + ) + assert entity_blocked.entity_id == "group.all_locks" + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_no_2fa_exposed.entity_id] = { + PREF_SHOULD_EXPOSE: True, + PREF_DISABLE_2FA: True, + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + + entity_exposed = entity_registry.async_get(entity_exposed.entity_id) + assert entity_exposed.options == {"cloud.google_assistant": {"should_expose": True}} + + entity_migrated = entity_registry.async_get(entity_migrated.entity_id) + assert entity_migrated.options == { + "cloud.google_assistant": {"should_expose": False} + } + + entity_no_2fa_exposed = entity_registry.async_get(entity_no_2fa_exposed.entity_id) + assert entity_no_2fa_exposed.options == { + "cloud.google_assistant": {"disable_2fa": True, "should_expose": True} + } + + entity_config = entity_registry.async_get(entity_config.entity_id) + assert entity_config.options == {"cloud.google_assistant": {"should_expose": False}} + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}} + + entity_blocked = entity_registry.async_get(entity_blocked.entity_id) + assert entity_blocked.options == { + "cloud.google_assistant": {"should_expose": False} + } + + +async def test_google_config_migrate_expose_entity_prefs_default_none( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + entity_default = entity_registry.async_get_or_create( + "light", + "test", + "light_default", + suggested_object_id="default", + ) + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + + cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = None + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + + entity_default = entity_registry.async_get(entity_default.entity_id) + assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}} diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 92c0ca70a17..115e77f118e 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -15,6 +15,7 @@ from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.util.location import LocationInfo from . import mock_cloud, mock_cloud_prefs @@ -399,11 +400,9 @@ async def test_websocket_status( "alexa_enabled": True, "cloudhooks": {}, "google_enabled": True, - "google_entity_configs": {}, "google_secure_devices_pin": None, "google_default_expose": None, "alexa_default_expose": None, - "alexa_entity_configs": {}, "alexa_report_state": True, "google_report_state": True, "remote_enabled": False, @@ -520,8 +519,6 @@ async def test_websocket_update_preferences( "alexa_enabled": False, "google_enabled": False, "google_secure_devices_pin": "1234", - "google_default_expose": ["light", "switch"], - "alexa_default_expose": ["sensor", "media_player"], "tts_default_voice": ["en-GB", "male"], } ) @@ -531,8 +528,6 @@ async def test_websocket_update_preferences( assert not setup_api.google_enabled assert not setup_api.alexa_enabled assert setup_api.google_secure_devices_pin == "1234" - assert setup_api.google_default_expose == ["light", "switch"] - assert setup_api.alexa_default_expose == ["sensor", "media_player"] assert setup_api.tts_default_voice == ("en-GB", "male") @@ -683,7 +678,11 @@ async def test_enabling_remote( async def test_list_google_entities( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can list Google entities.""" client = await hass_ws_client(hass) @@ -699,9 +698,25 @@ async def test_list_google_entities( "homeassistant.components.google_assistant.helpers.async_get_entities", return_value=[entity, entity2], ): - await client.send_json({"id": 5, "type": "cloud/google_assistant/entities"}) + await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 0 + # Add the entities to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + entity_registry.async_get_or_create( + "cover", "test", "unique", suggested_object_id="garage" + ) + + with patch( + "homeassistant.components.google_assistant.helpers.async_get_entities", + return_value=[entity, entity2], + ): + await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) + response = await client.receive_json() assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -716,49 +731,118 @@ async def test_list_google_entities( } +async def test_get_google_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, +) -> None: + """Test that we can get a Google entity.""" + client = await hass_ws_client(hass) + + # Test getting an unknown entity + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "light.kitchen unknown or not in the entity registry", + } + + # Test getting a blocked entity + entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "group.all_locks not supported by Google assistant", + } + + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + entity_registry.async_get_or_create( + "cover", "test", "unique", suggested_object_id="garage" + ) + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("cover.garage", "open", {"device_class": "garage"}) + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "entity_id": "light.kitchen", + "might_2fa": False, + "traits": ["action.devices.traits.OnOff"], + } + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "entity_id": "cover.garage", + "might_2fa": True, + "traits": ["action.devices.traits.OpenClose"], + } + + async def test_update_google_entity( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can update config of a Google entity.""" + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, "type": "cloud/google_assistant/entities/update", "entity_id": "light.kitchen", - "should_expose": False, "disable_2fa": False, } ) response = await client.receive_json() - assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.google_entity_configs["light.kitchen"] == { - "should_expose": False, - "disable_2fa": False, - } - await client.send_json( + await client.send_json_auto_id( { - "id": 6, - "type": "cloud/google_assistant/entities/update", - "entity_id": "light.kitchen", - "should_expose": None, + "type": "homeassistant/expose_entity", + "assistants": ["cloud.google_assistant"], + "entity_ids": [entry.entity_id], + "should_expose": False, } ) response = await client.receive_json() - assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.google_entity_configs["light.kitchen"] == { - "should_expose": None, - "disable_2fa": False, - } + + assert entity_registry.async_get(entry.entity_id).options[ + "cloud.google_assistant" + ] == {"disable_2fa": False, "should_expose": False} async def test_list_alexa_entities( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can list Alexa entities.""" client = await hass_ws_client(hass) @@ -769,9 +853,22 @@ async def test_list_alexa_entities( "homeassistant.components.alexa.entities.async_get_entities", return_value=[entity], ): - await client.send_json({"id": 5, "type": "cloud/alexa/entities"}) + await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 0 + # Add the entity to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + + with patch( + "homeassistant.components.alexa.entities.async_get_entities", + return_value=[entity], + ): + await client.send_json_auto_id({"type": "cloud/alexa/entities"}) + response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -782,37 +879,31 @@ async def test_list_alexa_entities( async def test_update_alexa_entity( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, ) -> None: """Test that we can update config of an Alexa entity.""" + entry = entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, - "type": "cloud/alexa/entities/update", - "entity_id": "light.kitchen", + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry.entity_id], "should_expose": False, } ) response = await client.receive_json() assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False} - - await client.send_json( - { - "id": 6, - "type": "cloud/alexa/entities/update", - "entity_id": "light.kitchen", - "should_expose": None, - } - ) - response = await client.receive_json() - - assert response["success"] - prefs = hass.data[DOMAIN].client.prefs - assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None} + assert entity_registry.async_get(entry.entity_id).options["cloud.alexa"] == { + "should_expose": False + } async def test_sync_alexa_entities_timeout( diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index a17b0ae2f08..4d2ac35d56d 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -58,17 +58,17 @@ async def test_prefs_default_voice( ) assert provider_pref.default_language == "en-US" - assert provider_pref.default_options == {"gender": "female"} + assert provider_pref.default_options == {"gender": "female", "audio_output": "mp3"} assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female"} + assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} await cloud_prefs.async_update(tts_default_voice=("nl-NL", "male")) await hass.async_block_till_done() assert provider_pref.default_language == "nl-NL" - assert provider_pref.default_options == {"gender": "male"} + assert provider_pref.default_options == {"gender": "male", "audio_output": "mp3"} assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female"} + assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} async def test_provider_properties(cloud_with_prefs) -> None: @@ -76,7 +76,7 @@ async def test_provider_properties(cloud_with_prefs) -> None: provider = await tts.async_get_engine( Mock(data={const.DOMAIN: cloud_with_prefs}), None, {} ) - assert provider.supported_options == ["gender"] + assert provider.supported_options == ["gender", "audio_output"] assert "nl-NL" in provider.supported_languages @@ -85,5 +85,5 @@ async def test_get_tts_audio(cloud_with_prefs) -> None: provider = await tts.async_get_engine( Mock(data={const.DOMAIN: cloud_with_prefs}), None, {} ) - assert provider.supported_options == ["gender"] + assert provider.supported_options == ["gender", "audio_output"] assert "nl-NL" in provider.supported_languages diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 4643891691f..188f4aac062 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -169,6 +169,28 @@ async def test_update_with_json_attrs(hass: HomeAssistant) -> None: ) entity_state = hass.states.get("sensor.test") assert entity_state + assert entity_state.state == "unknown" + assert entity_state.attributes["key"] == "some_json_value" + assert entity_state.attributes["another_key"] == "another_json_value" + assert entity_state.attributes["key_three"] == "value_three" + + +async def test_update_with_json_attrs_and_value_template(hass: HomeAssistant) -> None: + """Test json_attributes can be used together with value_template.""" + await setup_test_entities( + hass, + { + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": ["key", "another_key", "key_three"], + "value_template": '{{ value_json["key"] }}', + }, + ) + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "some_json_value" assert entity_state.attributes["key"] == "some_json_value" assert entity_state.attributes["another_key"] == "another_json_value" assert entity_state.attributes["key_three"] == "value_three" diff --git a/tests/components/coronavirus/__init__.py b/tests/components/coronavirus/__init__.py deleted file mode 100644 index 2274a51506d..00000000000 --- a/tests/components/coronavirus/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Coronavirus integration.""" diff --git a/tests/components/coronavirus/conftest.py b/tests/components/coronavirus/conftest.py deleted file mode 100644 index 227d9fa2123..00000000000 --- a/tests/components/coronavirus/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Test helpers.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch - -import pytest - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.coronavirus.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture(autouse=True) -def mock_cases(): - """Mock coronavirus cases.""" - with patch( - "coronavirus.get_cases", - return_value=[ - Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1), - Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0), - Mock( - country="Sweden", - confirmed=None, - recovered=None, - deaths=None, - current=None, - ), - ], - ) as mock_get_cases: - yield mock_get_cases diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py deleted file mode 100644 index 2fe7ed370e8..00000000000 --- a/tests/components/coronavirus/test_config_flow.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Test the Coronavirus config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch - -from aiohttp import ClientError -import pytest - -from homeassistant import config_entries -from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE -from homeassistant.core import HomeAssistant - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"country": OPTION_WORLDWIDE}, - ) - assert result2["type"] == "create_entry" - assert result2["title"] == "Worldwide" - assert result2["result"].unique_id == OPTION_WORLDWIDE - assert result2["data"] == { - "country": OPTION_WORLDWIDE, - } - await hass.async_block_till_done() - mock_setup_entry.assert_called_once() - - -@patch( - "coronavirus.get_cases", - side_effect=ClientError, -) -async def test_abort_on_connection_error( - mock_get_cases: MagicMock, hass: HomeAssistant -) -> None: - """Test we abort on connection error.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert "type" in result - assert result["type"] == "abort" - assert "reason" in result - assert result["reason"] == "cannot_connect" diff --git a/tests/components/coronavirus/test_init.py b/tests/components/coronavirus/test_init.py deleted file mode 100644 index eeb91e77239..00000000000 --- a/tests/components/coronavirus/test_init.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Test init of Coronavirus integration.""" -from unittest.mock import MagicMock, patch - -from aiohttp import ClientError - -from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry, mock_registry - - -async def test_migration(hass: HomeAssistant) -> None: - """Test that we can migrate coronavirus to stable unique ID.""" - nl_entry = MockConfigEntry(domain=DOMAIN, title="Netherlands", data={"country": 34}) - nl_entry.add_to_hass(hass) - worldwide_entry = MockConfigEntry( - domain=DOMAIN, title="Worldwide", data={"country": OPTION_WORLDWIDE} - ) - worldwide_entry.add_to_hass(hass) - mock_registry( - hass, - { - "sensor.netherlands_confirmed": er.RegistryEntry( - entity_id="sensor.netherlands_confirmed", - unique_id="34-confirmed", - platform="coronavirus", - config_entry_id=nl_entry.entry_id, - ), - "sensor.worldwide_confirmed": er.RegistryEntry( - entity_id="sensor.worldwide_confirmed", - unique_id="__worldwide-confirmed", - platform="coronavirus", - config_entry_id=worldwide_entry.entry_id, - ), - }, - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - ent_reg = er.async_get(hass) - - sensor_nl = ent_reg.async_get("sensor.netherlands_confirmed") - assert sensor_nl.unique_id == "Netherlands-confirmed" - - sensor_worldwide = ent_reg.async_get("sensor.worldwide_confirmed") - assert sensor_worldwide.unique_id == "__worldwide-confirmed" - - assert hass.states.get("sensor.netherlands_confirmed").state == "10" - assert hass.states.get("sensor.worldwide_confirmed").state == "11" - - assert nl_entry.unique_id == "Netherlands" - assert worldwide_entry.unique_id == OPTION_WORLDWIDE - - -@patch( - "coronavirus.get_cases", - side_effect=ClientError, -) -async def test_config_entry_not_ready( - mock_get_cases: MagicMock, hass: HomeAssistant -) -> None: - """Test the configuration entry not ready.""" - entry = MockConfigEntry(domain=DOMAIN, title="Netherlands", data={"country": 34}) - entry.add_to_hass(hass) - - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py new file mode 100644 index 00000000000..1aa98ab423f --- /dev/null +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -0,0 +1,348 @@ +"""Test Home Assistant exposed entities helper.""" +import pytest + +from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ExposedEntities, + async_get_assistant_settings, + async_listen_entity_updates, + async_should_expose, +) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import flush_store +from tests.typing import WebSocketGenerator + + +async def test_load_preferences(hass: HomeAssistant) -> None: + """Make sure that we can load/save data correctly.""" + assert await async_setup_component(hass, "homeassistant", {}) + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + assert exposed_entities._assistants == {} + + exposed_entities.async_set_expose_new_entities("test1", True) + exposed_entities.async_set_expose_new_entities("test2", False) + + assert list(exposed_entities._assistants) == ["test1", "test2"] + + exposed_entities2 = ExposedEntities(hass) + await flush_store(exposed_entities._store) + await exposed_entities2.async_load() + + assert exposed_entities._assistants == exposed_entities2._assistants + + +async def test_expose_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("test", "test", "unique1") + entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry1.entity_id], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + entry1 = entity_registry.async_get(entry1.entity_id) + assert entry1.options == {"cloud.alexa": {"should_expose": True}} + entry2 = entity_registry.async_get(entry2.entity_id) + assert entry2.options == {} + + # Update options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry1.entity_id, entry2.entity_id], + "should_expose": False, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + entry1 = entity_registry.async_get(entry1.entity_id) + assert entry1.options == { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + entry2 = entity_registry.async_get(entry2.entity_id) + assert entry2.options == { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + + +async def test_expose_entity_unknown( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test behavior when exposing an unknown entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": ["test.test"], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "can't expose 'test.test'", + } + + with pytest.raises(HomeAssistantError): + exposed_entities.async_expose_entity("cloud.alexa", "test.test", True) + + +async def test_expose_entity_blocked( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test behavior when exposing a blocked entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Set options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": ["group.all_locks"], + "should_expose": True, + } + ) + + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_allowed", + "message": "can't expose 'group.all_locks'", + } + + +@pytest.mark.parametrize("expose_new", [True, False]) +async def test_expose_new_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + expose_new, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("climate", "test", "unique1") + entry2 = entity_registry.async_get_or_create("climate", "test", "unique2") + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/get", + "assistant": "cloud.alexa", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"expose_new": False} + + # Check if exposed - should be False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": expose_new, + } + ) + response = await ws_client.receive_json() + assert response["success"] + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/get", + "assistant": "cloud.alexa", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"expose_new": expose_new} + + # Check again if exposed - should still be False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + + # Check if exposed - should be True + assert async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new + + +async def test_listen_updates( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test listen to updates.""" + calls = [] + + def listener(): + calls.append(None) + + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + async_listen_entity_updates(hass, "cloud.alexa", listener) + + entry = entity_registry.async_get_or_create("climate", "test", "unique1") + + # Call for another assistant - listener not called + exposed_entities.async_expose_entity( + "cloud.google_assistant", entry.entity_id, True + ) + assert len(calls) == 0 + + # Call for our assistant - listener called + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + assert len(calls) == 1 + + # Settings not changed - listener not called + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + assert len(calls) == 1 + + # Settings changed - listener called + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False) + assert len(calls) == 2 + + +async def test_get_assistant_settings( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test get assistant settings.""" + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + + entry = entity_registry.async_get_or_create("climate", "test", "unique1") + + assert async_get_assistant_settings(hass, "cloud.alexa") == {} + + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + assert async_get_assistant_settings(hass, "cloud.alexa") == { + "climate.test_unique1": {"should_expose": True} + } + assert async_get_assistant_settings(hass, "cloud.google_assistant") == {} + + +async def test_should_expose( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # Unknown entity is not exposed + assert async_should_expose(hass, "test.test", "test.test") is False + + # Blocked entity is not exposed + entry_blocked = entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", entry_blocked.entity_id) is False + + # Lock is exposed + lock1 = entity_registry.async_get_or_create("lock", "test", "unique1") + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", lock1.entity_id) is True + + # Hidden entity is not exposed + lock2 = entity_registry.async_get_or_create( + "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", lock2.entity_id) is False + + # Entity with category is not exposed + lock3 = entity_registry.async_get_or_create( + "lock", "test", "unique3", entity_category=EntityCategory.CONFIG + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", lock3.entity_id) is False + + # Binary sensor without device class is not exposed + binarysensor1 = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique1" + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", binarysensor1.entity_id) is False + + # Binary sensor with certain device class is exposed + binarysensor2 = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "unique2", + original_device_class="door", + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", binarysensor2.entity_id) is True + + # Sensor without device class is not exposed + sensor1 = entity_registry.async_get_or_create("sensor", "test", "unique1") + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", sensor1.entity_id) is False + + # Sensor with certain device class is exposed + sensor2 = entity_registry.async_get_or_create( + "sensor", + "test", + "unique2", + original_device_class="temperature", + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + assert async_should_expose(hass, "cloud.alexa", sensor2.entity_id) is True diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index b083bbac78a..7dc294087bd 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -26,10 +26,10 @@ TEST_ENTITY = "calendar.light_schedule" class FakeStore(LocalCalendarStore): """Mock storage implementation.""" - def __init__(self, hass: HomeAssistant, path: Path) -> None: + def __init__(self, hass: HomeAssistant, path: Path, ics_content: str) -> None: """Initialize FakeStore.""" super().__init__(hass, path) - self._content = "" + self._content = ics_content def _load(self) -> str: """Read from calendar storage.""" @@ -40,15 +40,21 @@ class FakeStore(LocalCalendarStore): self._content = ics_content +@pytest.fixture(name="ics_content", autouse=True) +def mock_ics_content() -> str: + """Fixture to allow tests to set initial ics content for the calendar store.""" + return "" + + @pytest.fixture(name="store", autouse=True) -def mock_store() -> Generator[None, None, None]: +def mock_store(ics_content: str) -> Generator[None, None, None]: """Test cleanup, remove any media storage persisted during the test.""" stores: dict[Path, FakeStore] = {} def new_store(hass: HomeAssistant, path: Path) -> FakeStore: if path not in stores: - stores[path] = FakeStore(hass, path) + stores[path] = FakeStore(hass, path, ics_content) return stores[path] with patch( diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index a2f13ea289d..559a2af38b3 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1,6 +1,7 @@ """Tests for calendar platform of local calendar.""" import datetime +import textwrap import pytest @@ -940,3 +941,91 @@ async def test_create_event_service( "location": "Test Location", } ] + + +@pytest.mark.parametrize( + "ics_content", + [ + textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714 + DTEND:19970714 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714 + DTEND:19970710 + END:VEVENT + END:VCALENDAR + """ + ), + ], + ids=["no_duration", "negative"], +) +async def test_invalid_all_day_event( + ws_client: ClientFixture, + setup_integration: None, + get_events: GetEventsFn, +) -> None: + """Test all day events with invalid durations, which are coerced to be valid.""" + events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"date": "1997-07-14"}, + "end": {"date": "1997-07-15"}, + } + ] + + +@pytest.mark.parametrize( + "ics_content", + [ + textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T110000 + DTEND:19970714T110000 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T110000 + DTEND:19970710T100000 + END:VEVENT + END:VCALENDAR + """ + ), + ], + ids=["no_duration", "negative"], +) +async def test_invalid_event_duration( + ws_client: ClientFixture, + setup_integration: None, + get_events: GetEventsFn, +) -> None: + """Test events with invalid durations, which are coerced to be valid.""" + events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T11:30:00-06:00"}, + } + ] diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index b2203f5552f..e0cae290e2b 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -1,6 +1,8 @@ """The tests for the mailbox component.""" +from datetime import datetime from hashlib import sha1 from http import HTTPStatus +from typing import Any from aiohttp.test_utils import TestClient import pytest @@ -8,20 +10,111 @@ import pytest from homeassistant.bootstrap import async_setup_component import homeassistant.components.mailbox as mailbox from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component +from tests.common import MockModule, mock_integration, mock_platform from tests.typing import ClientSessionGenerator +MAILBOX_NAME = "TestMailbox" +MEDIA_DATA = b"3f67c4ea33b37d1710f" +MESSAGE_TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + +def _create_message(idx: int) -> dict[str, Any]: + """Create a sample message.""" + msgtime = dt_util.as_timestamp(datetime(2010, 12, idx + 1, 13, 17, 00)) + msgtxt = f"Message {idx + 1}. {MESSAGE_TEXT}" + msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() + return { + "info": { + "origtime": int(msgtime), + "callerid": "John Doe <212-555-1212>", + "duration": "10", + }, + "text": msgtxt, + "sha": msgsha, + } + + +class TestMailbox(mailbox.Mailbox): + """Test Mailbox, with 10 sample messages.""" + + def __init__(self, hass: HomeAssistant, name: str) -> None: + """Initialize Test mailbox.""" + super().__init__(hass, name) + self._messages: dict[str, dict[str, Any]] = {} + for idx in range(0, 10): + msg = _create_message(idx) + msgsha = msg["sha"] + self._messages[msgsha] = msg + + @property + def media_type(self) -> str: + """Return the supported media type.""" + return mailbox.CONTENT_TYPE_MPEG + + @property + def can_delete(self) -> bool: + """Return if messages can be deleted.""" + return True + + @property + def has_media(self) -> bool: + """Return if messages have attached media files.""" + return True + + async def async_get_media(self, msgid: str) -> bytes: + """Return the media blob for the msgid.""" + if msgid not in self._messages: + raise mailbox.StreamError("Message not found") + + return MEDIA_DATA + + async def async_get_messages(self) -> list[dict[str, Any]]: + """Return a list of the current messages.""" + return sorted( + self._messages.values(), + key=lambda item: item["info"]["origtime"], # type: ignore[no-any-return] + reverse=True, + ) + + async def async_delete(self, msgid: str) -> bool: + """Delete the specified messages.""" + if msgid in self._messages: + del self._messages[msgid] + self.async_update() + return True + + +class MockMailbox: + """A mock mailbox platform.""" + + async def async_get_handler( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> mailbox.Mailbox: + """Set up the Test mailbox.""" + return TestMailbox(hass, MAILBOX_NAME) + + +@pytest.fixture +def mock_mailbox(hass: HomeAssistant) -> None: + """Mock mailbox.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.mailbox", MockMailbox()) + @pytest.fixture async def mock_http_client( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_mailbox: None ) -> TestClient: """Start the Home Assistant HTTP component.""" - config = {mailbox.DOMAIN: {"platform": "demo"}} - with assert_setup_component(1, mailbox.DOMAIN): - await async_setup_component(hass, mailbox.DOMAIN, config) - await hass.async_block_till_done() + assert await async_setup_component( + hass, mailbox.DOMAIN, {mailbox.DOMAIN: {"platform": "test"}} + ) return await hass_client() @@ -33,12 +126,12 @@ async def test_get_platforms_from_mailbox(mock_http_client: TestClient) -> None: assert req.status == HTTPStatus.OK result = await req.json() assert len(result) == 1 - assert result[0].get("name") == "DemoMailbox" + assert result[0].get("name") == "TestMailbox" async def test_get_messages_from_mailbox(mock_http_client: TestClient) -> None: """Get messages from mailbox.""" - url = "/api/mailbox/messages/DemoMailbox" + url = "/api/mailbox/messages/TestMailbox" req = await mock_http_client.get(url) assert req.status == HTTPStatus.OK @@ -48,11 +141,11 @@ async def test_get_messages_from_mailbox(mock_http_client: TestClient) -> None: async def test_get_media_from_mailbox(mock_http_client: TestClient) -> None: """Get audio from mailbox.""" - mp3sha = "3f67c4ea33b37d1710f772a26dd3fb43bb159d50" + mp3sha = "7cad61312c7b66f619295be2da8c7ac73b4968f1" msgtxt = "Message 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit. " msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() - url = f"/api/mailbox/media/DemoMailbox/{msgsha}" + url = f"/api/mailbox/media/TestMailbox/{msgsha}" req = await mock_http_client.get(url) assert req.status == HTTPStatus.OK data = await req.read() @@ -67,11 +160,11 @@ async def test_delete_from_mailbox(mock_http_client: TestClient) -> None: msgsha2 = sha1(msgtxt2.encode("utf-8")).hexdigest() for msg in [msgsha1, msgsha2]: - url = f"/api/mailbox/delete/DemoMailbox/{msg}" + url = f"/api/mailbox/delete/TestMailbox/{msg}" req = await mock_http_client.delete(url) assert req.status == HTTPStatus.OK - url = "/api/mailbox/messages/DemoMailbox" + url = "/api/mailbox/messages/TestMailbox" req = await mock_http_client.get(url) assert req.status == HTTPStatus.OK result = await req.json() @@ -98,7 +191,7 @@ async def test_get_media_from_invalid_mailbox(mock_http_client: TestClient) -> N async def test_get_media_from_invalid_msgid(mock_http_client: TestClient) -> None: """Get messages from mailbox.""" msgsha = "0000000000000000000000000000000000000000" - url = f"/api/mailbox/media/DemoMailbox/{msgsha}" + url = f"/api/mailbox/media/TestMailbox/{msgsha}" req = await mock_http_client.get(url) assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 21260d85d18..d22de580c2a 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -35,7 +35,6 @@ def storage_collection(hass): id_manager = collection.IDManager() return person.PersonStorageCollection( person.PersonStore(hass, person.STORAGE_VERSION, person.STORAGE_KEY), - logging.getLogger(f"{person.__name__}.storage_collection"), id_manager, collection.YamlCollection( logging.getLogger(f"{person.__name__}.yaml_collection"), id_manager diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 694c9ff676c..b6004c13d46 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,4 +1,5 @@ """The tests for the TTS component.""" +import asyncio from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -996,3 +997,73 @@ async def test_support_options(hass: HomeAssistant, setup_tts) -> None: await tts.async_support_options(hass, "test", "en", {"invalid_option": "yo"}) is False ) + + +async def test_fetching_in_async(hass: HomeAssistant, hass_client) -> None: + """Test async fetching of data.""" + tts_audio = asyncio.Future() + + class ProviderWithAsyncFetching(MockProvider): + """Provider that supports audio output option.""" + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return [tts.ATTR_AUDIO_OUTPUT] + + @property + def default_options(self) -> dict[str, str]: + """Return a dict including the default options.""" + return {tts.ATTR_AUDIO_OUTPUT: "mp3"} + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + return ("mp3", await tts_audio) + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(ProviderWithAsyncFetching)) + assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) + + # Test async_get_media_source_audio + media_source_id = tts.generate_media_source_id( + hass, "test message", "test", "en", None, None + ) + + task = hass.async_create_task( + tts.async_get_media_source_audio(hass, media_source_id) + ) + task2 = hass.async_create_task( + tts.async_get_media_source_audio(hass, media_source_id) + ) + + url = await get_media_source_url(hass, media_source_id) + client = await hass_client() + client_get_task = hass.async_create_task(client.get(url)) + + # Make sure that tasks are waiting for our future to resolve + done, pending = await asyncio.wait((task, task2, client_get_task), timeout=0.1) + assert len(done) == 0 + assert len(pending) == 3 + + tts_audio.set_result(b"test") + + assert await task == ("mp3", b"test") + assert await task2 == ("mp3", b"test") + + req = await client_get_task + assert req.status == HTTPStatus.OK + assert await req.read() == b"test" + + # Test error is not cached + media_source_id = tts.generate_media_source_id( + hass, "test message 2", "test", "en", None, None + ) + tts_audio = asyncio.Future() + tts_audio.set_exception(HomeAssistantError("test error")) + with pytest.raises(HomeAssistantError): + assert await tts.async_get_media_source_audio(hass, media_source_id) + + tts_audio = asyncio.Future() + tts_audio.set_result(b"test 2") + await tts.async_get_media_source_audio(hass, media_source_id) == ("mp3", b"test 2") diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 33378d7ccde..2c12f9bc5f6 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -209,6 +209,29 @@ }), 'unit_of_measurement': None, }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fan_air_quality', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Air Quality', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Air Quality', + }), + 'entity_id': 'sensor.fan_air_quality', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), dict({ 'device_class': None, 'disabled': False, @@ -234,29 +257,6 @@ }), 'unit_of_measurement': '%', }), - dict({ - 'device_class': None, - 'disabled': False, - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.fan_air_quality', - 'icon': None, - 'name': None, - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Fan Air Quality', - 'state': dict({ - 'attributes': dict({ - 'friendly_name': 'Fan Air Quality', - }), - 'entity_id': 'sensor.fan_air_quality', - 'last_changed': str, - 'last_updated': str, - 'state': 'unavailable', - }), - 'unit_of_measurement': None, - }), ]), 'name': 'Fan', 'name_by_user': None, diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index eb802bb41b8..62365189064 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -85,6 +85,9 @@ async def test_async_get_device_diagnostics__single_fan( diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) assert isinstance(diag, dict) + diag["home_assistant"]["entities"] = sorted( + diag["home_assistant"]["entities"], key=lambda ent: ent["entity_id"] + ) assert diag == snapshot( matcher=path_type( { diff --git a/tests/components/voice_assistant/conftest.py b/tests/components/voice_assistant/conftest.py index 86da6334e09..b768c02ec44 100644 --- a/tests/components/voice_assistant/conftest.py +++ b/tests/components/voice_assistant/conftest.py @@ -117,7 +117,7 @@ async def mock_stt_provider(hass) -> MockSttProvider: return MockSttProvider(hass, _TRANSCRIPT) -@pytest.fixture(autouse=True) +@pytest.fixture async def init_components( hass: HomeAssistant, mock_stt_provider: MockSttProvider, diff --git a/tests/components/voice_assistant/test_init.py b/tests/components/voice_assistant/test_init.py index 1178f94c60c..c68aea9890c 100644 --- a/tests/components/voice_assistant/test_init.py +++ b/tests/components/voice_assistant/test_init.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant async def test_pipeline_from_audio_stream( - hass: HomeAssistant, mock_stt_provider, snapshot: SnapshotAssertion + hass: HomeAssistant, mock_stt_provider, init_components, snapshot: SnapshotAssertion ) -> None: """Test creating a pipeline from an audio stream.""" diff --git a/tests/components/voice_assistant/test_pipeline.py b/tests/components/voice_assistant/test_pipeline.py new file mode 100644 index 00000000000..db1f3629483 --- /dev/null +++ b/tests/components/voice_assistant/test_pipeline.py @@ -0,0 +1,104 @@ +"""Websocket tests for Voice Assistant integration.""" +from typing import Any + +from homeassistant.components.voice_assistant.const import DOMAIN +from homeassistant.components.voice_assistant.pipeline import ( + STORAGE_KEY, + STORAGE_VERSION, + PipelineStorageCollection, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.setup import async_setup_component + +from tests.common import flush_store + + +async def test_load_datasets(hass: HomeAssistant, init_components) -> None: + """Make sure that we can load/save data correctly.""" + + pipelines = [ + { + "conversation_engine": "conversation_engine_1", + "language": "language_1", + "name": "name_1", + "stt_engine": "stt_engine_1", + "tts_engine": "tts_engine_1", + }, + { + "conversation_engine": "conversation_engine_2", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "tts_engine": "tts_engine_2", + }, + { + "conversation_engine": "conversation_engine_3", + "language": "language_3", + "name": "name_3", + "stt_engine": "stt_engine_3", + "tts_engine": "tts_engine_3", + }, + ] + pipeline_ids = [] + + store1: PipelineStorageCollection = hass.data[DOMAIN] + for pipeline in pipelines: + pipeline_ids.append((await store1.async_create_item(pipeline)).id) + assert len(store1.data) == 3 + + await store1.async_delete_item(pipeline_ids[1]) + assert len(store1.data) == 2 + + store2 = PipelineStorageCollection(Store(hass, STORAGE_VERSION, STORAGE_KEY)) + await flush_store(store1.store) + await store2.async_load() + + assert len(store2.data) == 2 + + assert store1.data is not store2.data + assert store1.data == store2.data + + +async def test_loading_datasets_from_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test loading stored datasets on start.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "voice_assistant.pipelines", + "data": { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "name_1", + "stt_engine": "stt_engine_1", + "tts_engine": "tts_engine_1", + }, + { + "conversation_engine": "conversation_engine_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "tts_engine": "tts_engine_2", + }, + { + "conversation_engine": "conversation_engine_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": "stt_engine_3", + "tts_engine": "tts_engine_3", + }, + ] + }, + } + + assert await async_setup_component(hass, "voice_assistant", {}) + + store: PipelineStorageCollection = hass.data[DOMAIN] + assert len(store.data) == 3 diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index 08dadcdd99d..938184b607a 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -1,9 +1,14 @@ """Websocket tests for Voice Assistant integration.""" import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch from syrupy.assertion import SnapshotAssertion +from homeassistant.components.voice_assistant.const import DOMAIN +from homeassistant.components.voice_assistant.pipeline import ( + Pipeline, + PipelineStorageCollection, +) from homeassistant.core import HomeAssistant from tests.typing import WebSocketGenerator @@ -12,6 +17,7 @@ from tests.typing import WebSocketGenerator async def test_text_only_pipeline( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + init_components, snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with text input (no STT/TTS).""" @@ -51,7 +57,10 @@ async def test_text_only_pipeline( async def test_audio_pipeline( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with audio input/output.""" client = await hass_ws_client(hass) @@ -271,6 +280,7 @@ async def test_audio_pipeline_timeout( async def test_stt_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + init_components, snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" @@ -297,6 +307,7 @@ async def test_stt_provider_missing( async def test_stt_stream_failed( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + init_components, snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" @@ -398,3 +409,205 @@ async def test_invalid_stage_order( # result msg = await client.receive_json() assert not msg["success"] + + +async def test_add_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can add a pipeline.""" + client = await hass_ws_client(hass) + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN] + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/create", + "conversation_engine": "test_conversation_engine", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "tts_engine": "test_tts_engine", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "conversation_engine": "test_conversation_engine", + "id": ANY, + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "tts_engine": "test_tts_engine", + } + + assert len(pipeline_store.data) == 1 + pipeline = pipeline_store.data[msg["result"]["id"]] + assert pipeline == Pipeline( + conversation_engine="test_conversation_engine", + id=msg["result"]["id"], + language="test_language", + name="test_name", + stt_engine="test_stt_engine", + tts_engine="test_tts_engine", + ) + + +async def test_delete_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can delete a pipeline.""" + client = await hass_ws_client(hass) + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN] + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/create", + "conversation_engine": "test_conversation_engine", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "tts_engine": "test_tts_engine", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert len(pipeline_store.data) == 1 + + pipeline_id = msg["result"]["id"] + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/delete", + "pipeline_id": pipeline_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert len(pipeline_store.data) == 0 + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/delete", + "pipeline_id": pipeline_id, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": f"Unable to find pipeline_id {pipeline_id}", + } + + +async def test_list_pipelines( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can list pipelines.""" + client = await hass_ws_client(hass) + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN] + + await client.send_json_auto_id({"type": "voice_assistant/pipeline/list"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == [] + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/create", + "conversation_engine": "test_conversation_engine", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "tts_engine": "test_tts_engine", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert len(pipeline_store.data) == 1 + + await client.send_json_auto_id({"type": "voice_assistant/pipeline/list"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == [ + { + "conversation_engine": "test_conversation_engine", + "id": ANY, + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "tts_engine": "test_tts_engine", + } + ] + + +async def test_update_pipeline( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test we can list pipelines.""" + client = await hass_ws_client(hass) + pipeline_store: PipelineStorageCollection = hass.data[DOMAIN] + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/update", + "conversation_engine": "new_conversation_engine", + "language": "new_language", + "name": "new_name", + "pipeline_id": "no_such_pipeline", + "stt_engine": "new_stt_engine", + "tts_engine": "new_tts_engine", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_found", + "message": "Unable to find pipeline_id no_such_pipeline", + } + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/create", + "conversation_engine": "test_conversation_engine", + "language": "test_language", + "name": "test_name", + "stt_engine": "test_stt_engine", + "tts_engine": "test_tts_engine", + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + assert len(pipeline_store.data) == 1 + + await client.send_json_auto_id( + { + "type": "voice_assistant/pipeline/update", + "conversation_engine": "new_conversation_engine", + "language": "new_language", + "name": "new_name", + "pipeline_id": pipeline_id, + "stt_engine": "new_stt_engine", + "tts_engine": "new_tts_engine", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "conversation_engine": "new_conversation_engine", + "language": "new_language", + "name": "new_name", + "id": pipeline_id, + "stt_engine": "new_stt_engine", + "tts_engine": "new_tts_engine", + } + + assert len(pipeline_store.data) == 1 + pipeline = pipeline_store.data[pipeline_id] + assert pipeline == Pipeline( + conversation_engine="new_conversation_engine", + id=pipeline_id, + language="new_language", + name="new_name", + stt_engine="new_stt_engine", + tts_engine="new_tts_engine", + ) diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 64c83757a7b..52c7f899a6a 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -82,7 +82,7 @@ class MockObservableCollection(collection.ObservableCollection): return entity_class.from_storage(config) -class MockStorageCollection(collection.StorageCollection): +class MockStorageCollection(collection.DictStorageCollection): """Mock storage collection.""" async def _process_create_data(self, data: dict) -> dict: @@ -96,9 +96,9 @@ class MockStorageCollection(collection.StorageCollection): """Suggest an ID based on the config.""" return info["name"] - async def _update_data(self, data: dict, update_data: dict) -> dict: + async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - return {**data, **update_data} + return {**item, **update_data} def test_id_manager() -> None: @@ -116,7 +116,7 @@ def test_id_manager() -> None: async def test_observable_collection() -> None: """Test observerable collection.""" - coll = collection.ObservableCollection(_LOGGER) + coll = collection.ObservableCollection(None) assert coll.async_items() == [] coll.data["bla"] = 1 assert coll.async_items() == [1] @@ -202,7 +202,7 @@ async def test_storage_collection(hass: HomeAssistant) -> None: } ) id_manager = collection.IDManager() - coll = MockStorageCollection(store, _LOGGER, id_manager) + coll = MockStorageCollection(store, id_manager) changes = track_changes(coll) await coll.async_load() @@ -257,7 +257,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: """Test attaching collection to entity component.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) - coll = MockObservableCollection(_LOGGER) + coll = MockObservableCollection(None) collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, MockEntity) await coll.notify_changes( @@ -297,7 +297,7 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: """Test aborted entity adding is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) - coll = MockObservableCollection(_LOGGER) + coll = MockObservableCollection(None) async_update_config_calls = [] async_remove_calls = [] @@ -364,7 +364,7 @@ async def test_entity_component_collection_entity_removed(hass: HomeAssistant) - """Test entity removal is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) await ent_comp.async_setup({}) - coll = MockObservableCollection(_LOGGER) + coll = MockObservableCollection(None) async_update_config_calls = [] async_remove_calls = [] @@ -434,7 +434,7 @@ async def test_storage_collection_websocket( ) -> None: """Test exposing a storage collection via websockets.""" store = storage.Store(hass, 1, "test-data") - coll = MockStorageCollection(store, _LOGGER) + coll = MockStorageCollection(store) changes = track_changes(coll) collection.StorageCollectionWebsocket( coll,