1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +00:00

Add coordinator to Duck DNS integration (#158041)

This commit is contained in:
Manu
2025-12-05 15:49:48 +01:00
committed by GitHub
parent 2a116a2a11
commit 7fd440c4a0
7 changed files with 156 additions and 188 deletions

View File

@@ -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

View File

@@ -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],

View File

@@ -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

View File

@@ -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"

View File

@@ -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": {

View File

@@ -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

View File

@@ -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,