diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 30dfbbb8867..65deabadd17 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -168,6 +168,20 @@ class AlexaEntity: for prop in interface.serialize_properties(): yield prop + def serialize_discovery(self): + """Serialize the entity for discovery.""" + return { + 'displayCategories': self.display_categories(), + 'cookie': {}, + 'endpointId': self.alexa_id(), + 'friendlyName': self.friendly_name(), + 'description': self.description(), + 'manufacturerName': 'Home Assistant', + 'capabilities': [ + i.serialize_discovery() for i in self.interfaces() + ] + } + @callback def async_get_entities(hass, config) -> List[AlexaEntity]: diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 651ddc5b187..76ec92edf8d 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -12,8 +12,12 @@ class UnsupportedProperty(HomeAssistantError): """This entity does not support the requested Smart Home API property.""" +class NoTokenAvailable(HomeAssistantError): + """There is no access token available.""" + + class AlexaError(Exception): - """Base class for errors that can be serialized by the Alexa API. + """Base class for errors that can be serialized for the Alexa API. A handler can raise subclasses of this to return an error to the request. """ diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 98fb9259461..89cf171c83c 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -54,17 +54,7 @@ async def async_api_discovery(hass, config, directive, context): Async friendly. """ discovery_endpoints = [ - { - 'displayCategories': alexa_entity.display_categories(), - 'cookie': {}, - 'endpointId': alexa_entity.alexa_id(), - 'friendlyName': alexa_entity.friendly_name(), - 'description': alexa_entity.description(), - 'manufacturerName': 'Home Assistant', - 'capabilities': [ - i.serialize_discovery() for i in alexa_entity.interfaces() - ] - } + alexa_entity.serialize_discovery() for alexa_entity in async_get_entities(hass, config) if config.should_expose(alexa_entity.entity_id) ] diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py index 3dd72c11294..c1b0ac9c025 100644 --- a/homeassistant/components/alexa/messages.py +++ b/homeassistant/components/alexa/messages.py @@ -48,7 +48,7 @@ class AlexaDirective: self.entity_id = _endpoint_id.replace('#', '.') self.entity = hass.states.get(self.entity_id) - if not self.entity: + if not self.entity or not config.should_expose(self.entity_id): raise AlexaInvalidEndpointError(_endpoint_id) self.endpoint = ENTITY_ADAPTERS[self.entity.domain]( diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index f87e6bdee35..688828b20bd 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -16,41 +16,6 @@ _LOGGER = logging.getLogger(__name__) EVENT_ALEXA_SMART_HOME = 'alexa_smart_home' -# def _capability(interface, -# version=3, -# supports_deactivation=None, -# retrievable=None, -# properties_supported=None, -# cap_type='AlexaInterface'): -# """Return a Smart Home API capability object. - -# https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object - -# There are some additional fields allowed but not implemented here since -# we've no use case for them yet: - -# - proactively_reported - -# `supports_deactivation` applies only to scenes. -# """ -# result = { -# 'type': cap_type, -# 'interface': interface, -# 'version': version, -# } - -# if supports_deactivation is not None: -# result['supportsDeactivation'] = supports_deactivation - -# if retrievable is not None: -# result['retrievable'] = retrievable - -# if properties_supported is not None: -# result['properties'] = {'supported': properties_supported} - -# return result - - async def async_handle_message( hass, config, diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index cdb3a88ed22..4c11fb8c88c 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -21,10 +21,6 @@ async def async_enable_proactive_mode(hass, smart_home_config): Proactive mode makes this component report state changes to Alexa. """ - if await smart_home_config.async_get_access_token() is None: - # not ready yet - return - async def async_entity_state_listener(changed_entity, old_state, new_state): if not new_state: @@ -54,11 +50,11 @@ async def async_enable_proactive_mode(hass, smart_home_config): async def async_send_changereport_message(hass, config, alexa_entity): - """Send a ChangeReport message for an Alexa entity.""" + """Send a ChangeReport message for an Alexa entity. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events + """ token = await config.async_get_access_token() - if not token: - _LOGGER.error("Invalid access token.") - return headers = { "Authorization": "Bearer {}".format(token) @@ -83,9 +79,9 @@ async def async_send_changereport_message(hass, config, alexa_entity): message.set_endpoint_full(token, endpoint) message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() try: - session = hass.helpers.aiohttp_client.async_get_clientsession() with async_timeout.timeout(DEFAULT_TIMEOUT): response = await session.post(config.endpoint, headers=headers, @@ -106,3 +102,81 @@ async def async_send_changereport_message(hass, config, alexa_entity): _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s", response_json["payload"]["code"], response_json["payload"]["description"]) + + +async def async_send_add_or_update_message(hass, config, entity_ids): + """Send an AddOrUpdateReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report + """ + token = await config.async_get_access_token() + + headers = { + "Authorization": "Bearer {}".format(token) + } + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split('.', 1)[0] + alexa_entity = ENTITY_ADAPTERS[domain]( + hass, config, hass.states.get(entity_id) + ) + endpoints.append(alexa_entity.serialize_discovery()) + + payload = { + 'endpoints': endpoints, + 'scope': { + 'type': 'BearerToken', + 'token': token, + } + } + + message = AlexaResponse( + name='AddOrUpdateReport', namespace='Alexa.Discovery', payload=payload) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post(config.endpoint, headers=headers, + json=message_serialized, allow_redirects=True) + + +async def async_send_delete_message(hass, config, entity_ids): + """Send an DeleteReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event + """ + token = await config.async_get_access_token() + + headers = { + "Authorization": "Bearer {}".format(token) + } + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split('.', 1)[0] + alexa_entity = ENTITY_ADAPTERS[domain]( + hass, config, hass.states.get(entity_id) + ) + endpoints.append({ + 'endpointId': alexa_entity.alexa_id() + }) + + payload = { + 'endpoints': endpoints, + 'scope': { + 'type': 'BearerToken', + 'token': token, + } + } + + message = AlexaResponse(name='DeleteReport', namespace='Alexa.Discovery', + payload=payload) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post(config.endpoint, headers=headers, + json=message_serialized, allow_redirects=True) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e874e4213bc..bb539a270ac 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -21,7 +21,8 @@ from .const import ( CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG, CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL, CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL, - CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) + CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD, CONF_ALEXA_ACCESS_TOKEN_URL +) from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -72,6 +73,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(), vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, + vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): str, }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index e3c952898bd..92473974a9f 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -6,19 +6,25 @@ from datetime import timedelta import logging import aiohttp +import async_timeout from hass_nabucasa import cloud_api from hass_nabucasa.client import CloudClient as Interface from homeassistant.core import callback from homeassistant.components.alexa import ( config as alexa_config, + errors as alexa_errors, smart_home as alexa_sh, + entities as alexa_entities, + state_report as alexa_state_report, ) from homeassistant.components.google_assistant import ( helpers as ga_h, smart_home as ga) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry from homeassistant.util.aiohttp import MockRequest from homeassistant.util.dt import utcnow @@ -31,6 +37,9 @@ from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) +# Time to wait when entity preferences have changed before syncing it to +# the cloud. +SYNC_DELAY = 1 class AlexaConfig(alexa_config.AbstractConfig): @@ -44,7 +53,20 @@ class AlexaConfig(alexa_config.AbstractConfig): self._cloud = cloud self._token = None self._token_valid = None - prefs.async_listen_updates(self.async_prefs_updated) + self._cur_entity_prefs = prefs.alexa_entity_configs + self._alexa_sync_unsub = None + self._endpoint = None + + prefs.async_listen_updates(self._async_prefs_updated) + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated + ) + + @property + def enabled(self): + """Return if Alexa is enabled.""" + return self._prefs.alexa_enabled @property def supports_auth(self): @@ -59,7 +81,10 @@ class AlexaConfig(alexa_config.AbstractConfig): @property def endpoint(self): """Endpoint for report state.""" - return None + if self._endpoint is None: + raise ValueError("No endpoint available. Fetch access token first") + + return self._endpoint @property def entity_config(self): @@ -91,21 +116,143 @@ class AlexaConfig(alexa_config.AbstractConfig): if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'): raise RequireRelink - return None + return alexa_errors.NoTokenAvailable self._token = body['access_token'] + self._endpoint = body['event_endpoint'] 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): """Handle updated preferences.""" - if self.should_report_state == self.is_reporting_states: + if self.should_report_state != self.is_reporting_states: + if self.should_report_state: + await self.async_enable_proactive_mode() + else: + await self.async_disable_proactive_mode() + + # If entity prefs are the same or we have filter in config.yaml, + # don't sync. + if (self._cur_entity_prefs is prefs.alexa_entity_configs or + not self._config[CONF_FILTER].empty_filter): return - if self.should_report_state: - await self.async_enable_proactive_mode() - else: - await self.async_disable_proactive_mode() + 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 + + seen = set() + to_update = [] + to_remove = [] + + for entity_id, info in old_prefs.items(): + seen.add(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) + + if entity_id in new_prefs: + new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE) + else: + new_expose = None + + if old_expose == new_expose: + continue + + if new_expose: + to_update.append(entity_id) + else: + to_remove.append(entity_id) + + # Now all the ones that are in new prefs but never were in old prefs + for entity_id, info in new_prefs.items(): + if entity_id in seen: + continue + + new_expose = info.get(PREF_SHOULD_EXPOSE) + + if new_expose is None: + continue + + # Only test if we should expose. It can never be a remove action, + # as it didn't exist in old prefs object. + if new_expose: + to_update.append(entity_id) + + # We only set the prefs when update is successful, that way we will + # retry when next change comes in. + if await self._sync_helper(to_update, to_remove): + self._cur_entity_prefs = new_prefs + + async def async_sync_entities(self): + """Sync all entities to Alexa.""" + to_update = [] + to_remove = [] + + for entity in alexa_entities.async_get_entities(self.hass, self): + if self.should_expose(entity.entity_id): + to_update.append(entity.entity_id) + else: + to_remove.append(entity.entity_id) + + return await self._sync_helper(to_update, to_remove) + + async def _sync_helper(self, to_update, to_remove) -> bool: + """Sync entities to Alexa. + + Return boolean if it was successful. + """ + if not to_update and not to_remove: + return True + + tasks = [] + + if to_update: + tasks.append(alexa_state_report.async_send_add_or_update_message( + self.hass, self, to_update + )) + + if to_remove: + tasks.append(alexa_state_report.async_send_delete_message( + self.hass, self, to_remove + )) + + try: + with async_timeout.timeout(10): + await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + + return True + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout trying to sync entitites to Alexa") + return False + + except aiohttp.ClientError as err: + _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) + return False + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled: + return + + action = event.data['action'] + entity_id = event.data['entity_id'] + to_update = [] + to_remove = [] + + if action == 'create' and self.should_expose(entity_id): + to_update.append(entity_id) + elif action == 'remove' and self.should_expose(entity_id): + to_remove.append(entity_id) + + await self._sync_helper(to_update, to_remove) class CloudClient(Interface): diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 34324aca131..fdb36723fdb 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -32,6 +32,7 @@ CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' CONF_REMOTE_API_URL = 'remote_api_url' CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server' +CONF_ALEXA_ACCESS_TOKEN_URL = 'alexa_access_token_url' MODE_DEV = "development" MODE_PROD = "production" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 6eaa717f41c..d9c4ddcf1ce 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -13,6 +13,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components import websocket_api +from homeassistant.components.websocket_api import const as ws_const from homeassistant.components.alexa import entities as alexa_entities from homeassistant.components.google_assistant import helpers as google_helpers @@ -92,6 +93,7 @@ async def async_setup(hass): hass.components.websocket_api.async_register_command(alexa_list) hass.components.websocket_api.async_register_command(alexa_update) + hass.components.websocket_api.async_register_command(alexa_sync) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) @@ -560,3 +562,23 @@ async def alexa_update(hass, connection, msg): connection.send_result( msg['id'], cloud.client.prefs.alexa_entity_configs.get(msg['entity_id'])) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@websocket_api.websocket_command({ + 'type': 'cloud/alexa/sync', +}) +async def alexa_sync(hass, connection, msg): + """Sync with Alexa.""" + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(10): + success = await cloud.client.alexa_config.async_sync_entities() + + if success: + connection.send_result(msg['id']) + else: + connection.send_error( + msg['id'], ws_const.ERR_UNKNOWN_ERROR, 'Unknown error') diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index c5f95a96218..f954aa825bc 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -34,7 +34,63 @@ async def test_report_state(hass, aioclient_mock): call = aioclient_mock.mock_calls call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa" + assert call_json["event"]["header"]["name"] == "ChangeReport" assert call_json["event"]["payload"]["change"]["properties"][0]["value"] \ == "NOT_DETECTED" assert call_json["event"]["endpoint"]["endpointId"] \ == "binary_sensor#test_contact" + + +async def test_send_add_or_update_message(hass, aioclient_mock): + """Test sending an AddOrUpdateReport message.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_send_add_or_update_message( + hass, DEFAULT_CONFIG, ['binary_sensor.test_contact']) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery" + assert call_json["event"]["header"]["name"] == "AddOrUpdateReport" + assert len(call_json["event"]["payload"]["endpoints"]) == 1 + assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \ + == "binary_sensor#test_contact" + + +async def test_send_delete_message(hass, aioclient_mock): + """Test sending an AddOrUpdateReport message.""" + aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'}) + + hass.states.async_set( + 'binary_sensor.test_contact', + 'on', + { + 'friendly_name': "Test Contact Sensor", + 'device_class': 'door', + } + ) + + await state_report.async_send_delete_message( + hass, DEFAULT_CONFIG, ['binary_sensor.test_contact']) + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa.Discovery" + assert call_json["event"]["header"]["name"] == "DeleteReport" + assert len(call_json["event"]["payload"]["endpoints"]) == 1 + assert call_json["event"]["payload"]["endpoints"][0]["endpointId"] \ + == "binary_sensor#test_contact" diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 723e86f2f2d..7f4bf97086a 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -1,4 +1,5 @@ """Test the cloud.iot module.""" +import contextlib from unittest.mock import patch, MagicMock from aiohttp import web @@ -11,8 +12,10 @@ from homeassistant.components.cloud import ( DOMAIN, ALEXA_SCHEMA, client) from homeassistant.components.cloud.const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) +from homeassistant.util.dt import utcnow +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from tests.components.alexa import test_smart_home as test_alexa -from tests.common import mock_coro +from tests.common import mock_coro, async_fire_time_changed from . import mock_cloud_prefs @@ -292,3 +295,93 @@ async def test_alexa_config_report_state(hass, cloud_prefs): assert cloud_prefs.alexa_report_state is False assert conf.should_report_state is False assert conf.is_reporting_states is False + + +@contextlib.contextmanager +def patch_sync_helper(): + """Patch sync helper. + + In Py3.7 this would have been an async context manager. + """ + to_update = [] + to_remove = [] + + with patch( + 'homeassistant.components.cloud.client.SYNC_DELAY', 0 + ), patch( + 'homeassistant.components.cloud.client.AlexaConfig._sync_helper', + side_effect=mock_coro + ) as mock_helper: + yield to_update, to_remove + + actual_to_update, actual_to_remove = mock_helper.mock_calls[0][1] + to_update.extend(actual_to_update) + to_remove.extend(actual_to_remove) + + +async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs): + """Test Alexa config responds to updating exposed entities.""" + client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + 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()) + await hass.async_block_till_done() + + assert to_update == ['light.kitchen'] + 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 + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert sorted(to_update) == ['binary_sensor.door', 'sensor.temp'] + assert to_remove == ['light.kitchen'] + + +async def test_alexa_entity_registry_sync(hass, cloud_prefs): + """Test Alexa config responds to entity registry.""" + client.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None) + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'create', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == ['light.kitchen'] + assert to_remove == [] + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'remove', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == [] + assert to_remove == ['light.kitchen'] + + with patch_sync_helper() as (to_update, to_remove): + hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, { + 'action': 'update', + 'entity_id': 'light.kitchen', + }) + await hass.async_block_till_done() + + assert to_update == [] + assert to_remove == []