From 016a59ac94b1e732ca75dfc53021b5ff343deb79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Sep 2022 22:57:43 -0500 Subject: [PATCH] Add support for subscribing to config entry changes (#77803) --- .../components/config/config_entries.py | 50 +++ homeassistant/config_entries.py | 80 +++-- .../components/config/test_config_entries.py | 314 ++++++++++++++++++ 3 files changed, 416 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 54132080f08..fb96a99827d 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -20,6 +20,7 @@ from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, ) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.loader import ( Integration, IntegrationNotFound, @@ -43,6 +44,7 @@ async def async_setup(hass): websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) websocket_api.async_register_command(hass, config_entry_update) + websocket_api.async_register_command(hass, config_entries_subscribe) websocket_api.async_register_command(hass, config_entries_progress) websocket_api.async_register_command(hass, ignore_config_flow) @@ -408,6 +410,54 @@ async def config_entries_get( ) +@websocket_api.websocket_command( + { + vol.Required("type"): "config_entries/subscribe", + vol.Optional("type_filter"): str, + } +) +@websocket_api.async_response +async def config_entries_subscribe( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to config entry updates.""" + type_filter = msg.get("type_filter") + + async def async_forward_config_entry_changes( + change: config_entries.ConfigEntryChange, entry: config_entries.ConfigEntry + ) -> None: + """Forward config entry state events to websocket.""" + if type_filter: + integration = await async_get_integration(hass, entry.domain) + if integration.integration_type != type_filter: + return + + connection.send_message( + websocket_api.event_message( + msg["id"], + [ + { + "type": change, + "entry": entry_json(entry), + } + ], + ) + ) + + current_entries = await async_matching_config_entries(hass, type_filter, None) + connection.subscriptions[msg["id"]] = async_dispatcher_connect( + hass, + config_entries.SIGNAL_CONFIG_ENTRY_CHANGED, + async_forward_config_entry_changes, + ) + connection.send_result(msg["id"]) + connection.send_message( + websocket_api.event_message( + msg["id"], [{"type": None, "entry": entry} for entry in current_entries] + ) + ) + + async def async_matching_config_entries( hass: HomeAssistant, type_filter: str | None, domain: str | None ) -> list[dict[str, Any]]: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f37efb6c627..e7960646ecb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -19,6 +19,7 @@ from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platfo from .core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback from .exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError from .helpers import device_registry, entity_registry, storage +from .helpers.dispatcher import async_dispatcher_send from .helpers.event import async_call_later from .helpers.frame import report from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType @@ -136,6 +137,16 @@ RECONFIGURE_NOTIFICATION_ID = "config_entry_reconfigure" EVENT_FLOW_DISCOVERED = "config_entry_discovered" +SIGNAL_CONFIG_ENTRY_CHANGED = "config_entry_changed" + + +class ConfigEntryChange(StrEnum): + """What was changed in a config entry.""" + + ADDED = "added" + REMOVED = "removed" + UPDATED = "updated" + class ConfigEntryDisabler(StrEnum): """What disabled a config entry.""" @@ -310,7 +321,7 @@ class ConfigEntry: # Only store setup result as state if it was not forwarded. if self.domain == integration.domain: - self.state = ConfigEntryState.SETUP_IN_PROGRESS + self.async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) self.supports_unload = await support_entry_unload(hass, self.domain) self.supports_remove_device = await support_remove_from_device( @@ -327,8 +338,7 @@ class ConfigEntry: err, ) if self.domain == integration.domain: - self.state = ConfigEntryState.SETUP_ERROR - self.reason = "Import error" + self.async_set_state(hass, ConfigEntryState.SETUP_ERROR, "Import error") return if self.domain == integration.domain: @@ -341,14 +351,12 @@ class ConfigEntry: self.domain, err, ) - self.state = ConfigEntryState.SETUP_ERROR - self.reason = "Import error" + self.async_set_state(hass, ConfigEntryState.SETUP_ERROR, "Import error") return # Perform migration if not await self.async_migrate(hass): - self.state = ConfigEntryState.MIGRATION_ERROR - self.reason = None + self.async_set_state(hass, ConfigEntryState.MIGRATION_ERROR, None) return error_reason = None @@ -378,8 +386,7 @@ class ConfigEntry: self.async_start_reauth(hass) result = False except ConfigEntryNotReady as ex: - self.state = ConfigEntryState.SETUP_RETRY - self.reason = str(ex) or None + self.async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) wait_time = 2 ** min(tries, 4) * 5 tries += 1 message = str(ex) @@ -427,11 +434,9 @@ class ConfigEntry: return if result: - self.state = ConfigEntryState.LOADED - self.reason = None + self.async_set_state(hass, ConfigEntryState.LOADED, None) else: - self.state = ConfigEntryState.SETUP_ERROR - self.reason = error_reason + self.async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) async def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" @@ -452,8 +457,7 @@ class ConfigEntry: Returns if unload is possible and was successful. """ if self.source == SOURCE_IGNORE: - self.state = ConfigEntryState.NOT_LOADED - self.reason = None + self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) return True if self.state == ConfigEntryState.NOT_LOADED: @@ -467,8 +471,7 @@ class ConfigEntry: # that was uninstalled, or an integration # that has been renamed without removing the config # entry. - self.state = ConfigEntryState.NOT_LOADED - self.reason = None + self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) return True component = integration.get_component() @@ -479,17 +482,16 @@ class ConfigEntry: if self.state is not ConfigEntryState.LOADED: self.async_cancel_retry_setup() - - self.state = ConfigEntryState.NOT_LOADED - self.reason = None + self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) return True supports_unload = hasattr(component, "async_unload_entry") if not supports_unload: if integration.domain == self.domain: - self.state = ConfigEntryState.FAILED_UNLOAD - self.reason = "Unload not supported" + self.async_set_state( + hass, ConfigEntryState.FAILED_UNLOAD, "Unload not supported" + ) return False try: @@ -499,20 +501,20 @@ class ConfigEntry: # Only adjust state if we unloaded the component if result and integration.domain == self.domain: - self.state = ConfigEntryState.NOT_LOADED - self.reason = None + self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) await self._async_process_on_unload() # https://github.com/python/mypy/issues/11839 return result # type: ignore[no-any-return] - except Exception: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain ) if integration.domain == self.domain: - self.state = ConfigEntryState.FAILED_UNLOAD - self.reason = "Unknown error" + self.async_set_state( + hass, ConfigEntryState.FAILED_UNLOAD, str(ex) or "Unknown error" + ) return False async def async_remove(self, hass: HomeAssistant) -> None: @@ -541,6 +543,17 @@ class ConfigEntry: integration.domain, ) + @callback + def async_set_state( + self, hass: HomeAssistant, state: ConfigEntryState, reason: str | None + ) -> None: + """Set the state of the config entry.""" + self.state = state + self.reason = reason + async_dispatcher_send( + hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self + ) + async def async_migrate(self, hass: HomeAssistant) -> bool: """Migrate an entry. @@ -895,6 +908,7 @@ class ConfigEntries: ) self._entries[entry.entry_id] = entry self._domain_index.setdefault(entry.domain, []).append(entry.entry_id) + self._async_dispatch(ConfigEntryChange.ADDED, entry) await self.async_setup(entry.entry_id) self._async_schedule_save() @@ -950,6 +964,7 @@ class ConfigEntries: ) ) + self._async_dispatch(ConfigEntryChange.REMOVED, entry) return {"require_restart": not unload_success} async def _async_shutdown(self, event: Event) -> None: @@ -1161,9 +1176,18 @@ class ConfigEntries: self.hass.async_create_task(listener(self.hass, entry)) self._async_schedule_save() - + self._async_dispatch(ConfigEntryChange.UPDATED, entry) return True + @callback + def _async_dispatch( + self, change_type: ConfigEntryChange, entry: ConfigEntry + ) -> None: + """Dispatch a config entry change.""" + async_dispatcher_send( + self.hass, SIGNAL_CONFIG_ENTRY_CHANGED, change_type, entry + ) + @callback def async_setup_platforms( self, entry: ConfigEntry, platforms: Iterable[Platform | str] diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 0de4bf44401..d36f7282bdb 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1303,3 +1303,317 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): assert response["id"] == 8 assert response["success"] is False + + +async def test_subscribe_entries_ws(hass, hass_ws_client, clear_handlers): + """Test subscribe entries with the websocket api.""" + assert await async_setup_component(hass, "config", {}) + mock_integration(hass, MockModule("comp1")) + mock_integration( + hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) + ) + mock_integration(hass, MockModule("comp3")) + entry = MockConfigEntry( + domain="comp1", + title="Test 1", + source="bla", + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain="comp2", + title="Test 2", + source="bla2", + state=core_ce.ConfigEntryState.SETUP_ERROR, + reason="Unsupported API", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp3", + title="Test 3", + source="bla3", + disabled_by=core_ce.ConfigEntryDisabler.USER, + ).add_to_hass(hass) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/subscribe", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["result"] is None + assert response["success"] is True + assert response["type"] == "result" + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "type": None, + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + }, + { + "type": None, + "entry": { + "disabled_by": None, + "domain": "comp2", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", + "source": "bla2", + "state": "setup_error", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 2", + }, + }, + { + "type": None, + "entry": { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + }, + ] + assert hass.config_entries.async_update_entry(entry, title="changed") + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "changed", + }, + "type": "updated", + } + ] + await hass.config_entries.async_remove(entry.entry_id) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "changed", + }, + "type": "removed", + } + ] + await hass.config_entries.async_add(entry) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "changed", + }, + "type": "added", + } + ] + + +async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handlers): + """Test subscribe entries with the websocket api with a type filter.""" + assert await async_setup_component(hass, "config", {}) + mock_integration(hass, MockModule("comp1")) + mock_integration( + hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) + ) + mock_integration(hass, MockModule("comp3")) + entry = MockConfigEntry( + domain="comp1", + title="Test 1", + source="bla", + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain="comp2", + title="Test 2", + source="bla2", + state=core_ce.ConfigEntryState.SETUP_ERROR, + reason="Unsupported API", + ) + entry2.add_to_hass(hass) + MockConfigEntry( + domain="comp3", + title="Test 3", + source="bla3", + disabled_by=core_ce.ConfigEntryDisabler.USER, + ).add_to_hass(hass) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/subscribe", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["result"] is None + assert response["success"] is True + assert response["type"] == "result" + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "type": None, + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + }, + { + "type": None, + "entry": { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + }, + ] + assert hass.config_entries.async_update_entry(entry, title="changed") + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "changed", + }, + "type": "updated", + } + ] + await hass.config_entries.async_remove(entry.entry_id) + await hass.config_entries.async_remove(entry2.entry_id) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "changed", + }, + "type": "removed", + } + ] + await hass.config_entries.async_add(entry) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "changed", + }, + "type": "added", + } + ]