From 8543f3f989b54fa071a4724a33ab3de4deb27d4c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:46:15 +0100 Subject: [PATCH] Add config flow to Namecheap DynamicDNS integration (#160841) --- CODEOWNERS | 2 + .../components/namecheapdns/__init__.py | 66 ++++++-- .../components/namecheapdns/config_flow.py | 91 +++++++++++ .../components/namecheapdns/const.py | 6 + .../components/namecheapdns/issue.py | 40 +++++ .../components/namecheapdns/manifest.json | 5 +- .../components/namecheapdns/strings.json | 41 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- tests/components/namecheapdns/conftest.py | 52 +++++++ .../namecheapdns/test_config_flow.py | 142 ++++++++++++++++++ tests/components/namecheapdns/test_init.py | 93 ++++++------ 12 files changed, 482 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/namecheapdns/config_flow.py create mode 100644 homeassistant/components/namecheapdns/const.py create mode 100644 homeassistant/components/namecheapdns/issue.py create mode 100644 homeassistant/components/namecheapdns/strings.json create mode 100644 tests/components/namecheapdns/conftest.py create mode 100644 tests/components/namecheapdns/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index b6459c82ac8..3483d0fe595 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1068,6 +1068,8 @@ build.json @home-assistant/supervisor /tests/components/myuplink/ @pajzo @astrandb /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu +/homeassistant/components/namecheapdns/ @tr4nt0r +/tests/components/namecheapdns/ @tr4nt0r /homeassistant/components/nanoleaf/ @milanmeu @joostlek /tests/components/nanoleaf/ @milanmeu @joostlek /homeassistant/components/nasweb/ @nasWebio diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py index 7fbd49d979b..8e280bfd24f 100644 --- a/homeassistant/components/namecheapdns/__init__.py +++ b/homeassistant/components/namecheapdns/__init__.py @@ -3,23 +3,26 @@ from datetime import timedelta import logging +from aiohttp import ClientError, ClientSession import defusedxml.ElementTree as ET import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN, UPDATE_URL + _LOGGER = logging.getLogger(__name__) -DOMAIN = "namecheapdns" INTERVAL = timedelta(minutes=5) -UPDATE_URL = "https://dynamicdns.park-your-domain.com/update" CONFIG_SCHEMA = vol.Schema( { @@ -34,30 +37,67 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type NamecheapConfigEntry = ConfigEntry[None] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the namecheap DNS component.""" - host = config[DOMAIN][CONF_HOST] - domain = config[DOMAIN][CONF_DOMAIN] - password = config[DOMAIN][CONF_PASSWORD] + + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool: + """Set up Namecheap DynamicDNS from a config entry.""" + host = entry.data[CONF_HOST] + domain = entry.data[CONF_DOMAIN] + password = entry.data[CONF_PASSWORD] session = async_get_clientsession(hass) - result = await _update_namecheapdns(session, host, domain, password) - - if not result: - return False + try: + if not await update_namecheapdns(session, host, domain, password): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={ + CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}" + }, + ) + except ClientError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={ + CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}" + }, + ) from e async def update_domain_interval(now): """Update the namecheap DNS entry.""" - await _update_namecheapdns(session, host, domain, password) + await update_namecheapdns(session, host, domain, password) - async_track_time_interval(hass, update_domain_interval, INTERVAL) + entry.async_on_unload( + async_track_time_interval(hass, update_domain_interval, INTERVAL) + ) - return result + return True -async def _update_namecheapdns(session, host, domain, password): +async def async_unload_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool: + """Unload a config entry.""" + return True + + +async def update_namecheapdns( + session: ClientSession, host: str, domain: str, password: str +): """Update namecheap DNS entry.""" params = {"host": host, "domain": domain, "password": password} diff --git a/homeassistant/components/namecheapdns/config_flow.py b/homeassistant/components/namecheapdns/config_flow.py new file mode 100644 index 00000000000..28af3029804 --- /dev/null +++ b/homeassistant/components/namecheapdns/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow for the Namecheap DynamicDNS integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from . import update_namecheapdns +from .const import DOMAIN +from .issue import deprecate_yaml_issue + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default="@"): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, autocomplete="current-password" + ) + ), + } +) + + +class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Namecheap DynamicDNS.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_DOMAIN: user_input[CONF_DOMAIN]} + ) + session = async_get_clientsession(self.hass) + try: + if not await update_namecheapdns(session, **user_input): + errors["base"] = "update_failed" + except ClientError: + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_HOST]}.{user_input[CONF_DOMAIN]}", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + description_placeholders={"account_panel": "https://ap.www.namecheap.com/"}, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import config from yaml.""" + + self._async_abort_entries_match( + {CONF_HOST: import_info[CONF_HOST], CONF_DOMAIN: import_info[CONF_DOMAIN]} + ) + result = await self.async_step_user(import_info) + if errors := result.get("errors"): + deprecate_yaml_issue(self.hass, import_success=False) + return self.async_abort(reason=errors["base"]) + + deprecate_yaml_issue(self.hass, import_success=True) + return result diff --git a/homeassistant/components/namecheapdns/const.py b/homeassistant/components/namecheapdns/const.py new file mode 100644 index 00000000000..84193fac90c --- /dev/null +++ b/homeassistant/components/namecheapdns/const.py @@ -0,0 +1,6 @@ +"""Constants for the Namecheap DynamicDNS integration.""" + +DOMAIN = "namecheapdns" + + +UPDATE_URL = "https://dynamicdns.park-your-domain.com/update" diff --git a/homeassistant/components/namecheapdns/issue.py b/homeassistant/components/namecheapdns/issue.py new file mode 100644 index 00000000000..e32e0db6c81 --- /dev/null +++ b/homeassistant/components/namecheapdns/issue.py @@ -0,0 +1,40 @@ +"""Issues for Namecheap DynamicDNS integration.""" + +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + + +@callback +def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None: + """Deprecate yaml issue.""" + if import_success: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.8.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Namecheap DynamicDNS", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_error", + breaks_in_ha_version="2026.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_error", + translation_placeholders={ + "url": f"/config/integrations/dashboard/add?domain={DOMAIN}" + }, + ) diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json index f50d6aed63e..3722975da1e 100644 --- a/homeassistant/components/namecheapdns/manifest.json +++ b/homeassistant/components/namecheapdns/manifest.json @@ -1,9 +1,10 @@ { "domain": "namecheapdns", "name": "Namecheap DynamicDNS", - "codeowners": [], + "codeowners": ["@tr4nt0r"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/namecheapdns", + "integration_type": "service", "iot_class": "cloud_push", - "quality_scale": "legacy", "requirements": ["defusedxml==0.7.1"] } diff --git a/homeassistant/components/namecheapdns/strings.json b/homeassistant/components/namecheapdns/strings.json new file mode 100644 index 00000000000..26c130fa289 --- /dev/null +++ b/homeassistant/components/namecheapdns/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "update_failed": "Updating DNS failed" + }, + "step": { + "user": { + "data": { + "domain": "[%key:common::config_flow::data::username%]", + "host": "[%key:common::config_flow::data::host%]", + "password": "Dynamic DNS password" + }, + "data_description": { + "domain": "The domain to update ('example.com')", + "host": "The host to update ('home' for home.example.com). Use '@' to update the root domain", + "password": "Dynamic DNS password for the domain" + }, + "description": "Enter your Namecheap DynamicDNS domain and password below to configure dynamic DNS updates. You can find the Dynamic DNS password in your [Namecheap account]({account_panel}) under Domain List > Manage > Advanced DNS > Dynamic DNS." + } + } + }, + "exceptions": { + "connection_error": { + "message": "Updating Namecheap DynamicDNS domain {domain} failed due to a connection error" + }, + "update_failed": { + "message": "Updating Namecheap DynamicDNS domain {domain} failed" + } + }, + "issues": { + "deprecated_yaml_import_issue_error": { + "description": "Configuring Namecheap DynamicDNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Namecheap DynamicDNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.", + "title": "The Namecheap DynamicDNS YAML configuration import failed" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2f0829b0756..af447760293 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -443,6 +443,7 @@ FLOWS = { "mystrom", "myuplink", "nam", + "namecheapdns", "nanoleaf", "nasweb", "neato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9ad1ad0e827..4c6c81db086 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4343,8 +4343,8 @@ }, "namecheapdns": { "name": "Namecheap DynamicDNS", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_push" }, "nanoleaf": { diff --git a/tests/components/namecheapdns/conftest.py b/tests/components/namecheapdns/conftest.py new file mode 100644 index 00000000000..0f17bad64b3 --- /dev/null +++ b/tests/components/namecheapdns/conftest.py @@ -0,0 +1,52 @@ +"""Common fixtures for the Namecheap DynamicDNS tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.namecheapdns.const import DOMAIN +from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD + +from tests.common import MockConfigEntry + +TEST_HOST = "home" +TEST_DOMAIN = "example.com" +TEST_PASSWORD = "test-password" + +TEST_USER_INPUT = { + CONF_HOST: TEST_HOST, + CONF_DOMAIN: TEST_DOMAIN, + CONF_PASSWORD: TEST_PASSWORD, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.namecheapdns.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="mock_namecheap") +def mock_update_namecheapdns() -> Generator[AsyncMock]: + """Mock update_namecheapdns.""" + + with patch( + "homeassistant.components.namecheapdns.config_flow.update_namecheapdns", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Namecheap Dynamic DNS configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=f"{TEST_HOST}.{TEST_DOMAIN}", + data=TEST_USER_INPUT, + entry_id="12345", + ) diff --git a/tests/components/namecheapdns/test_config_flow.py b/tests/components/namecheapdns/test_config_flow.py new file mode 100644 index 00000000000..a56ea944852 --- /dev/null +++ b/tests/components/namecheapdns/test_config_flow.py @@ -0,0 +1,142 @@ +"""Test the Namecheap DynamicDNS config flow.""" + +from unittest.mock import AsyncMock + +from aiohttp import ClientError +import pytest + +from homeassistant.components.namecheapdns.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from .conftest import TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_namecheap") +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": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home.example.com" + assert result["data"] == TEST_USER_INPUT + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "text_error"), + [ + (ValueError, "unknown"), + (False, "update_failed"), + (ClientError, "cannot_connect"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_namecheap: AsyncMock, + side_effect: Exception | bool, + text_error: str, +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_namecheap.side_effect = [side_effect] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_namecheap.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home.example.com" + assert result["data"] == TEST_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_namecheap") +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test import flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home.example.com" + assert result["data"] == TEST_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + assert issue_registry.async_get_issue( + domain=HOMEASSISTANT_DOMAIN, + issue_id=f"deprecated_yaml_{DOMAIN}", + ) + + +async def test_import_exception( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + issue_registry: ir.IssueRegistry, + mock_namecheap: AsyncMock, +) -> None: + """Test import flow failed.""" + mock_namecheap.side_effect = [False] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "update_failed" + + assert len(mock_setup_entry.mock_calls) == 0 + + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id="deprecated_yaml_import_issue_error", + ) + + +@pytest.mark.usefixtures("mock_namecheap") +async def test_init_import_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test yaml triggers import flow.""" + + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: TEST_USER_INPUT}, + ) + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py index b7c1fe732c0..0b47e7cc03b 100644 --- a/tests/components/namecheapdns/test_init.py +++ b/tests/components/namecheapdns/test_init.py @@ -2,74 +2,79 @@ from datetime import timedelta +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components import namecheapdns +from homeassistant.components.namecheapdns.const import UPDATE_URL +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from .conftest import TEST_USER_INPUT + +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -HOST = "test" -DOMAIN = "bla" -PASSWORD = "abcdefgh" - -@pytest.fixture -async def setup_namecheapdns( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.freeze_time +async def test_setup( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: - """Fixture that sets up NamecheapDNS.""" - aioclient_mock.get( - namecheapdns.UPDATE_URL, - params={"host": HOST, "domain": DOMAIN, "password": PASSWORD}, - text="0", - ) - - await async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) - - -async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test setup works if update passes.""" aioclient_mock.get( - namecheapdns.UPDATE_URL, - params={"host": HOST, "domain": DOMAIN, "password": PASSWORD}, + UPDATE_URL, + params=TEST_USER_INPUT, text="0", ) - result = await async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) - assert result + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED assert aioclient_mock.call_count == 1 - async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert aioclient_mock.call_count == 2 +@pytest.mark.freeze_time async def test_setup_fails_if_update_fails( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test setup fails if first update fails.""" aioclient_mock.get( - namecheapdns.UPDATE_URL, - params={"host": HOST, "domain": DOMAIN, "password": PASSWORD}, + UPDATE_URL, + params=TEST_USER_INPUT, text="1", ) - result = await async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) - assert not result + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + assert aioclient_mock.call_count == 1 + + aioclient_mock.clear_requests() + aioclient_mock.get( + UPDATE_URL, + params=TEST_USER_INPUT, + exc=ClientError, + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY assert aioclient_mock.call_count == 1