diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index edf94268741..6f9b96b9dfa 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -390,4 +390,5 @@ def entry_json(entry: config_entries.ConfigEntry) -> dict: "supports_options": supports_options, "supports_unload": entry.supports_unload, "disabled_by": entry.disabled_by, + "reason": entry.reason, } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index bf9a45d06f0..5b35b7ef65c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -138,6 +138,7 @@ class ConfigEntry: "disabled_by", "_setup_lock", "update_listeners", + "reason", "_async_cancel_retry_setup", "_on_unload", ) @@ -202,6 +203,9 @@ class ConfigEntry: weakref.ReferenceType[UpdateListenerType] | weakref.WeakMethod ] = [] + # Reason why config entry is in a failed state + self.reason: str | None = None + # Function to cancel a scheduled retry self._async_cancel_retry_setup: Callable[[], Any] | None = None @@ -236,6 +240,7 @@ class ConfigEntry: ) if self.domain == integration.domain: self.state = ENTRY_STATE_SETUP_ERROR + self.reason = "Import error" return if self.domain == integration.domain: @@ -249,13 +254,17 @@ class ConfigEntry: err, ) self.state = ENTRY_STATE_SETUP_ERROR + self.reason = "Import error" return # Perform migration if not await self.async_migrate(hass): self.state = ENTRY_STATE_MIGRATION_ERROR + self.reason = None return + error_reason = None + try: result = await component.async_setup_entry(hass, self) # type: ignore @@ -267,6 +276,7 @@ class ConfigEntry: except ConfigEntryAuthFailed as ex: message = str(ex) auth_base_message = "could not authenticate" + error_reason = message or auth_base_message auth_message = ( f"{auth_base_message}: {message}" if message else auth_base_message ) @@ -281,6 +291,7 @@ class ConfigEntry: result = False except ConfigEntryNotReady as ex: self.state = ENTRY_STATE_SETUP_RETRY + self.reason = str(ex) or None wait_time = 2 ** min(tries, 4) * 5 tries += 1 message = str(ex) @@ -329,8 +340,10 @@ class ConfigEntry: if result: self.state = ENTRY_STATE_LOADED + self.reason = None else: self.state = ENTRY_STATE_SETUP_ERROR + self.reason = error_reason async def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" @@ -352,6 +365,7 @@ class ConfigEntry: """ if self.source == SOURCE_IGNORE: self.state = ENTRY_STATE_NOT_LOADED + self.reason = None return True if integration is None: @@ -363,6 +377,7 @@ class ConfigEntry: # that has been renamed without removing the config # entry. self.state = ENTRY_STATE_NOT_LOADED + self.reason = None return True component = integration.get_component() @@ -375,6 +390,7 @@ class ConfigEntry: self.async_cancel_retry_setup() self.state = ENTRY_STATE_NOT_LOADED + self.reason = None return True supports_unload = hasattr(component, "async_unload_entry") @@ -382,6 +398,7 @@ class ConfigEntry: if not supports_unload: if integration.domain == self.domain: self.state = ENTRY_STATE_FAILED_UNLOAD + self.reason = "Unload not supported" return False try: @@ -392,6 +409,7 @@ class ConfigEntry: # Only adjust state if we unloaded the component if result and integration.domain == self.domain: self.state = ENTRY_STATE_NOT_LOADED + self.reason = None self._async_process_on_unload() @@ -402,6 +420,7 @@ class ConfigEntry: ) if integration.domain == self.domain: self.state = ENTRY_STATE_FAILED_UNLOAD + self.reason = "Unknown error" return False async def async_remove(self, hass: HomeAssistant) -> None: diff --git a/tests/common.py b/tests/common.py index cc971ca4f13..d63e3859108 100644 --- a/tests/common.py +++ b/tests/common.py @@ -734,6 +734,7 @@ class MockConfigEntry(config_entries.ConfigEntry): connection_class=config_entries.CONN_CLASS_UNKNOWN, unique_id=None, disabled_by=None, + reason=None, ): """Initialize a mock config entry.""" kwargs = { @@ -753,6 +754,8 @@ class MockConfigEntry(config_entries.ConfigEntry): if state is not None: kwargs["state"] = state super().__init__(**kwargs) + if reason is not None: + self.reason = reason def add_to_hass(self, hass): """Test helper to add entry to hass.""" diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 128d0798b66..271333b092a 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -65,7 +65,8 @@ async def test_get_entries(hass, client): domain="comp2", title="Test 2", source="bla2", - state=core_ce.ENTRY_STATE_LOADED, + state=core_ce.ENTRY_STATE_SETUP_ERROR, + reason="Unsupported API", connection_class=core_ce.CONN_CLASS_ASSUMED, ).add_to_hass(hass) MockConfigEntry( @@ -90,16 +91,18 @@ async def test_get_entries(hass, client): "supports_options": True, "supports_unload": True, "disabled_by": None, + "reason": None, }, { "domain": "comp2", "title": "Test 2", "source": "bla2", - "state": "loaded", + "state": "setup_error", "connection_class": "assumed", "supports_options": False, "supports_unload": False, "disabled_by": None, + "reason": "Unsupported API", }, { "domain": "comp3", @@ -110,6 +113,7 @@ async def test_get_entries(hass, client): "supports_options": False, "supports_unload": False, "disabled_by": "user", + "reason": None, }, ] @@ -330,6 +334,7 @@ async def test_create_account(hass, client): "supports_options": False, "supports_unload": False, "title": "Test Entry", + "reason": None, }, "description": None, "description_placeholders": None, @@ -399,6 +404,7 @@ async def test_two_step_flow(hass, client): "supports_options": False, "supports_unload": False, "title": "user-title", + "reason": None, }, "description": None, "description_placeholders": None, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 20ab5e67fef..326c7ba19ca 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -865,12 +865,14 @@ async def test_setup_raise_not_ready(hass, caplog): assert p_hass is hass assert p_wait_time == 5 assert entry.state == config_entries.ENTRY_STATE_SETUP_RETRY + assert entry.reason == "The internet connection is offline" mock_setup_entry.side_effect = None mock_setup_entry.return_value = True await p_setup(None) assert entry.state == config_entries.ENTRY_STATE_LOADED + assert entry.reason is None async def test_setup_raise_not_ready_from_exception(hass, caplog): @@ -2555,6 +2557,7 @@ async def test_setup_raise_auth_failed(hass, caplog): assert "could not authenticate: The password is no longer valid" in caplog.text assert entry.state == config_entries.ENTRY_STATE_SETUP_ERROR + assert entry.reason == "The password is no longer valid" flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["context"]["entry_id"] == entry.entry_id @@ -2562,6 +2565,7 @@ async def test_setup_raise_auth_failed(hass, caplog): caplog.clear() entry.state = config_entries.ENTRY_STATE_NOT_LOADED + entry.reason = None await entry.async_setup(hass) await hass.async_block_till_done()