From 6eeb1855ff531cd7c2aff5ad16e90624d38266da Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Jun 2022 04:32:50 -0400 Subject: [PATCH] Remove entities from Alexa when disabling Alexa (#73999) Co-authored-by: Martin Hjelmare --- .../components/cloud/alexa_config.py | 69 +++++++++++++------ homeassistant/components/cloud/prefs.py | 4 ++ tests/components/cloud/test_alexa_config.py | 36 +++++++--- 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index cf52b458a28..1e59c9a6512 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -1,5 +1,8 @@ """Alexa configuration for Home Assistant Cloud.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable from contextlib import suppress from datetime import timedelta from http import HTTPStatus @@ -24,7 +27,15 @@ from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .const import CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE +from .const import ( + CONF_ENTITY_CONFIG, + CONF_FILTER, + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + PREF_SHOULD_EXPOSE, +) from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -54,8 +65,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._token = None self._token_valid = None self._cur_entity_prefs = prefs.alexa_entity_configs - self._cur_default_expose = prefs.alexa_default_expose - self._alexa_sync_unsub = None + self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @property @@ -75,7 +85,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): @property def should_report_state(self): """Return if states should be proactively reported.""" - return self._prefs.alexa_report_state and self.authorized + return ( + self._prefs.alexa_enabled + and self._prefs.alexa_report_state + and self.authorized + ) @property def endpoint(self): @@ -179,7 +193,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) return self._token - async def _async_prefs_updated(self, prefs): + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" if not self._cloud.is_logged_in: if self.is_reporting_states: @@ -190,6 +204,8 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._alexa_sync_unsub = None return + updated_prefs = prefs.last_updated + if ( ALEXA_DOMAIN not in self.hass.config.components and self.enabled @@ -211,28 +227,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): await self.async_sync_entities() return - # If user has filter in config.yaml, don't sync. - if not self._config[CONF_FILTER].empty_filter: - return - - # If entity prefs are the same, don't sync. - if ( - self._cur_entity_prefs is prefs.alexa_entity_configs - and self._cur_default_expose is prefs.alexa_default_expose + # Nothing to do if no Alexa related things have changed + 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 self._alexa_sync_unsub: - self._alexa_sync_unsub() - self._alexa_sync_unsub = None + # 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() - if self._cur_default_expose is not prefs.alexa_default_expose: - await self.async_sync_entities() + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) return - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) + await self.async_sync_entities() async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" @@ -243,9 +261,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): seen = set() to_update = [] to_remove = [] + is_enabled = self.enabled for entity_id, info in old_prefs.items(): seen.add(entity_id) + + if not is_enabled: + to_remove.append(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) if entity_id in new_prefs: @@ -291,8 +314,10 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): to_update = [] to_remove = [] + is_enabled = self.enabled + for entity in alexa_entities.async_get_entities(self.hass, self): - if self.should_expose(entity.entity_id): + if is_enabled and self.should_expose(entity.entity_id): to_update.append(entity.entity_id) else: to_remove.append(entity.entity_id) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 275c2a56326..17ec00026bc 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -50,6 +50,7 @@ class CloudPreferences: self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) self._prefs = None self._listeners = [] + self.last_updated: set[str] = set() async def async_initialize(self): """Finish initializing the preferences.""" @@ -308,6 +309,9 @@ class CloudPreferences: async def _save_prefs(self, prefs): """Save preferences to disk.""" + self.last_updated = { + key for key, value in prefs.items() if value != self._prefs.get(key) + } self._prefs = prefs await self._store.async_save(self._prefs) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 465ff7dd3d4..4e0df3c8ee3 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -9,7 +9,6 @@ from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import EntityCategory -from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed, mock_registry @@ -270,10 +269,7 @@ async def test_alexa_config_fail_refresh_token( @contextlib.contextmanager def patch_sync_helper(): - """Patch sync helper. - - In Py3.7 this would have been an async context manager. - """ + """Patch sync helper.""" to_update = [] to_remove = [] @@ -291,21 +287,32 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to updating exposed entities.""" + hass.states.async_set("binary_sensor.door", "on") + hass.states.async_set( + "sensor.temp", + "23", + {"device_class": "temperature", "unit_of_measurement": "°C"}, + ) + hass.states.async_set("light.kitchen", "off") + await cloud_prefs.async_update( + alexa_enabled=True, alexa_report_state=False, ) - await alexa_config.CloudAlexaConfig( + conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ).async_initialize() + ) + 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 ) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow()) + 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_remove == [] @@ -320,12 +327,23 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): entity_id="sensor.temp", should_expose=True ) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow()) + 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"] + with patch_sync_helper() as (to_update, to_remove): + await cloud_prefs.async_update( + alexa_enabled=False, + ) + await hass.async_block_till_done() + + assert conf._alexa_sync_unsub is None + assert to_update == [] + assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"] + async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry."""