From 7fd440c4a06777bc4cfd90a3c176ded80c87a8fd Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:49:48 +0100 Subject: [PATCH] Add coordinator to Duck DNS integration (#158041) --- homeassistant/components/duckdns/__init__.py | 121 ++---------------- .../components/duckdns/config_flow.py | 6 +- .../components/duckdns/coordinator.py | 83 ++++++++++++ homeassistant/components/duckdns/helpers.py | 35 +++++ homeassistant/components/duckdns/strings.json | 6 + tests/components/duckdns/conftest.py | 2 +- tests/components/duckdns/test_init.py | 91 +++---------- 7 files changed, 156 insertions(+), 188 deletions(-) create mode 100644 homeassistant/components/duckdns/coordinator.py create mode 100644 homeassistant/components/duckdns/helpers.py diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 4022413ab60..d50a7161b12 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -2,33 +2,22 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine, Sequence -from datetime import datetime, timedelta import logging -from typing import Any, cast -from aiohttp import ClientSession import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN -from homeassistant.core import ( - CALLBACK_TYPE, - HassJob, - HomeAssistant, - ServiceCall, - callback, -) +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.selector import ConfigEntrySelector from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util from .const import ATTR_CONFIG_ENTRY +from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator +from .helpers import update_duckdns _LOGGER = logging.getLogger(__name__) @@ -36,17 +25,8 @@ ATTR_TXT = "txt" DOMAIN = "duckdns" -INTERVAL = timedelta(minutes=5) -BACKOFF_INTERVALS = ( - INTERVAL, - timedelta(minutes=1), - timedelta(minutes=5), - timedelta(minutes=15), - timedelta(minutes=30), -) SERVICE_SET_TXT = "set_txt" -UPDATE_URL = "https://www.duckdns.org/update" CONFIG_SCHEMA = vol.Schema( { @@ -71,8 +51,6 @@ SERVICE_TXT_SCHEMA = vol.Schema( } ) -type DuckDnsConfigEntry = ConfigEntry - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the DuckDNS component.""" @@ -99,21 +77,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool: """Set up Duck DNS from a config entry.""" - session = async_get_clientsession(hass) + coordinator = DuckDnsUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator - async def update_domain_interval(_now: datetime) -> bool: - """Update the DuckDNS entry.""" - return await _update_duckdns( - session, - entry.data[CONF_DOMAIN], - entry.data[CONF_ACCESS_TOKEN], - ) - - entry.async_on_unload( - async_track_time_interval_backoff( - hass, update_domain_interval, BACKOFF_INTERVALS - ) - ) + # Add a dummy listener as we do not have regular entities + entry.async_on_unload(coordinator.async_add_listener(lambda: None)) return True @@ -153,7 +122,7 @@ async def update_domain_service(call: ServiceCall) -> None: session = async_get_clientsession(call.hass) - await _update_duckdns( + await update_duckdns( session, entry.data[CONF_DOMAIN], entry.data[CONF_ACCESS_TOKEN], @@ -164,73 +133,3 @@ async def update_domain_service(call: ServiceCall) -> None: async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool: """Unload a config entry.""" return True - - -_SENTINEL = object() - - -async def _update_duckdns( - session: ClientSession, - domain: str, - token: str, - *, - txt: str | None | object = _SENTINEL, - clear: bool = False, -) -> bool: - """Update DuckDNS.""" - params = {"domains": domain, "token": token} - - if txt is not _SENTINEL: - if txt is None: - # Pass in empty txt value to indicate it's clearing txt record - params["txt"] = "" - clear = True - else: - params["txt"] = cast(str, txt) - - if clear: - params["clear"] = "true" - - resp = await session.get(UPDATE_URL, params=params) - body = await resp.text() - - if body != "OK": - _LOGGER.warning("Updating DuckDNS domain failed: %s", domain) - return False - - return True - - -@callback -@bind_hass -def async_track_time_interval_backoff( - hass: HomeAssistant, - action: Callable[[datetime], Coroutine[Any, Any, bool]], - intervals: Sequence[timedelta], -) -> CALLBACK_TYPE: - """Add a listener that fires repetitively at every timedelta interval.""" - remove: CALLBACK_TYPE | None = None - failed = 0 - - async def interval_listener(now: datetime) -> None: - """Handle elapsed intervals with backoff.""" - nonlocal failed, remove - try: - failed += 1 - if await action(now): - failed = 0 - finally: - delay = intervals[failed] if failed < len(intervals) else intervals[-1] - remove = async_call_later( - hass, delay.total_seconds(), interval_listener_job - ) - - interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True) - hass.async_run_hass_job(interval_listener_job, dt_util.utcnow()) - - def remove_listener() -> None: - """Remove interval listener.""" - if remove: - remove() - - return remove_listener diff --git a/homeassistant/components/duckdns/config_flow.py b/homeassistant/components/duckdns/config_flow.py index 0376e4241a8..0a2ad9bdc19 100644 --- a/homeassistant/components/duckdns/config_flow.py +++ b/homeassistant/components/duckdns/config_flow.py @@ -16,8 +16,8 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from . import _update_duckdns from .const import DOMAIN +from .helpers import update_duckdns from .issue import deprecate_yaml_issue _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,7 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]}) session = async_get_clientsession(self.hass) try: - if not await _update_duckdns( + if not await update_duckdns( session, user_input[CONF_DOMAIN], user_input[CONF_ACCESS_TOKEN], @@ -93,7 +93,7 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: session = async_get_clientsession(self.hass) try: - if not await _update_duckdns( + if not await update_duckdns( session, entry.data[CONF_DOMAIN], user_input[CONF_ACCESS_TOKEN], diff --git a/homeassistant/components/duckdns/coordinator.py b/homeassistant/components/duckdns/coordinator.py new file mode 100644 index 00000000000..9c972b4fa11 --- /dev/null +++ b/homeassistant/components/duckdns/coordinator.py @@ -0,0 +1,83 @@ +"""Coordinator for the Duck DNS integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiohttp import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .helpers import update_duckdns + +_LOGGER = logging.getLogger(__name__) + + +type DuckDnsConfigEntry = ConfigEntry[DuckDnsUpdateCoordinator] + +INTERVAL = timedelta(minutes=5) +BACKOFF_INTERVALS = ( + INTERVAL, + timedelta(minutes=1), + timedelta(minutes=5), + timedelta(minutes=15), + timedelta(minutes=30), +) + + +class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]): + """Duck DNS update coordinator.""" + + config_entry: DuckDnsConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: DuckDnsConfigEntry) -> None: + """Initialize the Duck DNS update coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=INTERVAL, + ) + self.session = async_get_clientsession(hass) + self.failed = 0 + + async def _async_update_data(self) -> None: + """Update Duck DNS.""" + + retry_after = BACKOFF_INTERVALS[ + min(self.failed, len(BACKOFF_INTERVALS)) + ].total_seconds() + + try: + if not await update_duckdns( + self.session, + self.config_entry.data[CONF_DOMAIN], + self.config_entry.data[CONF_ACCESS_TOKEN], + ): + self.failed += 1 + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={ + CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN], + }, + retry_after=retry_after, + ) + except ClientError as e: + self.failed += 1 + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={ + CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN], + }, + retry_after=retry_after, + ) from e + self.failed = 0 diff --git a/homeassistant/components/duckdns/helpers.py b/homeassistant/components/duckdns/helpers.py new file mode 100644 index 00000000000..e7a76093c8a --- /dev/null +++ b/homeassistant/components/duckdns/helpers.py @@ -0,0 +1,35 @@ +"""Helpers for Duck DNS integration.""" + +from aiohttp import ClientSession + +from homeassistant.helpers.typing import UNDEFINED, UndefinedType + +UPDATE_URL = "https://www.duckdns.org/update" + + +async def update_duckdns( + session: ClientSession, + domain: str, + token: str, + *, + txt: str | None | UndefinedType = UNDEFINED, + clear: bool = False, +) -> bool: + """Update DuckDNS.""" + params = {"domains": domain, "token": token} + + if txt is not UNDEFINED: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params["txt"] = "" + clear = True + else: + params["txt"] = txt + + if clear: + params["clear"] = "true" + + resp = await session.get(UPDATE_URL, params=params) + body = await resp.text() + + return body == "OK" diff --git a/homeassistant/components/duckdns/strings.json b/homeassistant/components/duckdns/strings.json index 3f7e989e759..64625c9ac86 100644 --- a/homeassistant/components/duckdns/strings.json +++ b/homeassistant/components/duckdns/strings.json @@ -32,11 +32,17 @@ } }, "exceptions": { + "connection_error": { + "message": "Updating Duck DNS domain {domain} failed due to a connection error" + }, "entry_not_found": { "message": "Duck DNS integration entry not found" }, "entry_not_selected": { "message": "Duck DNS integration entry not selected" + }, + "update_failed": { + "message": "Updating Duck DNS domain {domain} failed" } }, "issues": { diff --git a/tests/components/duckdns/conftest.py b/tests/components/duckdns/conftest.py index e5ec1623a97..8a39f3f7a71 100644 --- a/tests/components/duckdns/conftest.py +++ b/tests/components/duckdns/conftest.py @@ -43,7 +43,7 @@ def mock_update_duckdns() -> Generator[AsyncMock]: """Mock _update_duckdns.""" with patch( - "homeassistant.components.duckdns.config_flow._update_duckdns", + "homeassistant.components.duckdns.config_flow.update_duckdns", return_value=True, ) as mock: yield mock diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index 72eb86b5dac..37e54e7b033 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -5,15 +5,9 @@ import logging import pytest -from homeassistant.components.duckdns import ( - ATTR_TXT, - BACKOFF_INTERVALS, - DOMAIN, - INTERVAL, - SERVICE_SET_TXT, - UPDATE_URL, - async_track_time_interval_backoff, -) +from homeassistant.components.duckdns import ATTR_TXT, DOMAIN, SERVICE_SET_TXT +from homeassistant.components.duckdns.coordinator import BACKOFF_INTERVALS +from homeassistant.components.duckdns.helpers import UPDATE_URL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -73,12 +67,13 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - assert aioclient_mock.call_count == 2 +@pytest.mark.freeze_time async def test_setup_backoff( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, ) -> None: - """Test setup fails if first update fails.""" + """Test update fails with backoffs and recovers.""" aioclient_mock.get( UPDATE_URL, params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN}, @@ -86,10 +81,10 @@ async def test_setup_backoff( ) config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.SETUP_RETRY assert aioclient_mock.call_count == 1 tme = utcnow() @@ -103,6 +98,17 @@ async def test_setup_backoff( assert aioclient_mock.call_count == idx + 1 + aioclient_mock.clear_requests() + aioclient_mock.get( + UPDATE_URL, + params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN}, + text="OK", + ) + + async_fire_time_changed(hass, tme) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + @pytest.mark.usefixtures("setup_duckdns") async def test_service_set_txt( @@ -147,67 +153,6 @@ async def test_service_clear_txt( assert aioclient_mock.call_count == 1 -async def test_async_track_time_interval_backoff(hass: HomeAssistant) -> None: - """Test setup fails if first update fails.""" - ret_val = False - call_count = 0 - tme = None - - async def _return(now): - nonlocal call_count, ret_val, tme - if tme is None: - tme = now - call_count += 1 - return ret_val - - intervals = ( - INTERVAL, - INTERVAL * 2, - INTERVAL * 5, - INTERVAL * 9, - INTERVAL * 10, - INTERVAL * 11, - INTERVAL * 12, - ) - - async_track_time_interval_backoff(hass, _return, intervals) - await hass.async_block_till_done() - - assert call_count == 1 - - _LOGGER.debug("Backoff") - for idx in range(1, len(intervals)): - tme += intervals[idx] - async_fire_time_changed(hass, tme + timedelta(seconds=0.1)) - await hass.async_block_till_done() - - assert call_count == idx + 1 - - _LOGGER.debug("Max backoff reached - intervals[-1]") - for _idx in range(1, 10): - tme += intervals[-1] - async_fire_time_changed(hass, tme + timedelta(seconds=0.1)) - await hass.async_block_till_done() - - assert call_count == idx + 1 + _idx - - _LOGGER.debug("Reset backoff") - call_count = 0 - ret_val = True - tme += intervals[-1] - async_fire_time_changed(hass, tme + timedelta(seconds=0.1)) - await hass.async_block_till_done() - assert call_count == 1 - - _LOGGER.debug("No backoff - intervals[0]") - for _idx in range(2, 10): - tme += intervals[0] - async_fire_time_changed(hass, tme + timedelta(seconds=0.1)) - await hass.async_block_till_done() - - assert call_count == _idx - - async def test_load_unload( hass: HomeAssistant, config_entry: MockConfigEntry,