Skip to content

Commit 7fd440c

Browse files
authored
Add coordinator to Duck DNS integration (home-assistant#158041)
1 parent 2a116a2 commit 7fd440c

File tree

7 files changed

+156
-188
lines changed

7 files changed

+156
-188
lines changed

homeassistant/components/duckdns/__init__.py

Lines changed: 10 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,31 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Callable, Coroutine, Sequence
6-
from datetime import datetime, timedelta
75
import logging
8-
from typing import Any, cast
96

10-
from aiohttp import ClientSession
117
import voluptuous as vol
128

13-
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
9+
from homeassistant.config_entries import SOURCE_IMPORT
1410
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
15-
from homeassistant.core import (
16-
CALLBACK_TYPE,
17-
HassJob,
18-
HomeAssistant,
19-
ServiceCall,
20-
callback,
21-
)
11+
from homeassistant.core import HomeAssistant, ServiceCall
2212
from homeassistant.exceptions import ServiceValidationError
2313
from homeassistant.helpers import config_validation as cv
2414
from homeassistant.helpers.aiohttp_client import async_get_clientsession
25-
from homeassistant.helpers.event import async_call_later
2615
from homeassistant.helpers.selector import ConfigEntrySelector
2716
from homeassistant.helpers.typing import ConfigType
28-
from homeassistant.loader import bind_hass
29-
from homeassistant.util import dt as dt_util
3017

3118
from .const import ATTR_CONFIG_ENTRY
19+
from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator
20+
from .helpers import update_duckdns
3221

3322
_LOGGER = logging.getLogger(__name__)
3423

3524
ATTR_TXT = "txt"
3625

3726
DOMAIN = "duckdns"
3827

39-
INTERVAL = timedelta(minutes=5)
40-
BACKOFF_INTERVALS = (
41-
INTERVAL,
42-
timedelta(minutes=1),
43-
timedelta(minutes=5),
44-
timedelta(minutes=15),
45-
timedelta(minutes=30),
46-
)
4728
SERVICE_SET_TXT = "set_txt"
4829

49-
UPDATE_URL = "https://www.duckdns.org/update"
5030

5131
CONFIG_SCHEMA = vol.Schema(
5232
{
@@ -71,8 +51,6 @@
7151
}
7252
)
7353

74-
type DuckDnsConfigEntry = ConfigEntry
75-
7654

7755
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
7856
"""Initialize the DuckDNS component."""
@@ -99,21 +77,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
9977
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
10078
"""Set up Duck DNS from a config entry."""
10179

102-
session = async_get_clientsession(hass)
80+
coordinator = DuckDnsUpdateCoordinator(hass, entry)
81+
await coordinator.async_config_entry_first_refresh()
82+
entry.runtime_data = coordinator
10383

104-
async def update_domain_interval(_now: datetime) -> bool:
105-
"""Update the DuckDNS entry."""
106-
return await _update_duckdns(
107-
session,
108-
entry.data[CONF_DOMAIN],
109-
entry.data[CONF_ACCESS_TOKEN],
110-
)
111-
112-
entry.async_on_unload(
113-
async_track_time_interval_backoff(
114-
hass, update_domain_interval, BACKOFF_INTERVALS
115-
)
116-
)
84+
# Add a dummy listener as we do not have regular entities
85+
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
11786

11887
return True
11988

@@ -153,7 +122,7 @@ async def update_domain_service(call: ServiceCall) -> None:
153122

154123
session = async_get_clientsession(call.hass)
155124

156-
await _update_duckdns(
125+
await update_duckdns(
157126
session,
158127
entry.data[CONF_DOMAIN],
159128
entry.data[CONF_ACCESS_TOKEN],
@@ -164,73 +133,3 @@ async def update_domain_service(call: ServiceCall) -> None:
164133
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
165134
"""Unload a config entry."""
166135
return True
167-
168-
169-
_SENTINEL = object()
170-
171-
172-
async def _update_duckdns(
173-
session: ClientSession,
174-
domain: str,
175-
token: str,
176-
*,
177-
txt: str | None | object = _SENTINEL,
178-
clear: bool = False,
179-
) -> bool:
180-
"""Update DuckDNS."""
181-
params = {"domains": domain, "token": token}
182-
183-
if txt is not _SENTINEL:
184-
if txt is None:
185-
# Pass in empty txt value to indicate it's clearing txt record
186-
params["txt"] = ""
187-
clear = True
188-
else:
189-
params["txt"] = cast(str, txt)
190-
191-
if clear:
192-
params["clear"] = "true"
193-
194-
resp = await session.get(UPDATE_URL, params=params)
195-
body = await resp.text()
196-
197-
if body != "OK":
198-
_LOGGER.warning("Updating DuckDNS domain failed: %s", domain)
199-
return False
200-
201-
return True
202-
203-
204-
@callback
205-
@bind_hass
206-
def async_track_time_interval_backoff(
207-
hass: HomeAssistant,
208-
action: Callable[[datetime], Coroutine[Any, Any, bool]],
209-
intervals: Sequence[timedelta],
210-
) -> CALLBACK_TYPE:
211-
"""Add a listener that fires repetitively at every timedelta interval."""
212-
remove: CALLBACK_TYPE | None = None
213-
failed = 0
214-
215-
async def interval_listener(now: datetime) -> None:
216-
"""Handle elapsed intervals with backoff."""
217-
nonlocal failed, remove
218-
try:
219-
failed += 1
220-
if await action(now):
221-
failed = 0
222-
finally:
223-
delay = intervals[failed] if failed < len(intervals) else intervals[-1]
224-
remove = async_call_later(
225-
hass, delay.total_seconds(), interval_listener_job
226-
)
227-
228-
interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True)
229-
hass.async_run_hass_job(interval_listener_job, dt_util.utcnow())
230-
231-
def remove_listener() -> None:
232-
"""Remove interval listener."""
233-
if remove:
234-
remove()
235-
236-
return remove_listener

homeassistant/components/duckdns/config_flow.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
TextSelectorType,
1717
)
1818

19-
from . import _update_duckdns
2019
from .const import DOMAIN
20+
from .helpers import update_duckdns
2121
from .issue import deprecate_yaml_issue
2222

2323
_LOGGER = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ async def async_step_user(
4646
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
4747
session = async_get_clientsession(self.hass)
4848
try:
49-
if not await _update_duckdns(
49+
if not await update_duckdns(
5050
session,
5151
user_input[CONF_DOMAIN],
5252
user_input[CONF_ACCESS_TOKEN],
@@ -93,7 +93,7 @@ async def async_step_reconfigure(
9393
if user_input is not None:
9494
session = async_get_clientsession(self.hass)
9595
try:
96-
if not await _update_duckdns(
96+
if not await update_duckdns(
9797
session,
9898
entry.data[CONF_DOMAIN],
9999
user_input[CONF_ACCESS_TOKEN],
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Coordinator for the Duck DNS integration."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
import logging
7+
8+
from aiohttp import ClientError
9+
10+
from homeassistant.config_entries import ConfigEntry
11+
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
14+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
15+
16+
from .const import DOMAIN
17+
from .helpers import update_duckdns
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
22+
type DuckDnsConfigEntry = ConfigEntry[DuckDnsUpdateCoordinator]
23+
24+
INTERVAL = timedelta(minutes=5)
25+
BACKOFF_INTERVALS = (
26+
INTERVAL,
27+
timedelta(minutes=1),
28+
timedelta(minutes=5),
29+
timedelta(minutes=15),
30+
timedelta(minutes=30),
31+
)
32+
33+
34+
class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
35+
"""Duck DNS update coordinator."""
36+
37+
config_entry: DuckDnsConfigEntry
38+
39+
def __init__(self, hass: HomeAssistant, config_entry: DuckDnsConfigEntry) -> None:
40+
"""Initialize the Duck DNS update coordinator."""
41+
super().__init__(
42+
hass,
43+
_LOGGER,
44+
config_entry=config_entry,
45+
name=DOMAIN,
46+
update_interval=INTERVAL,
47+
)
48+
self.session = async_get_clientsession(hass)
49+
self.failed = 0
50+
51+
async def _async_update_data(self) -> None:
52+
"""Update Duck DNS."""
53+
54+
retry_after = BACKOFF_INTERVALS[
55+
min(self.failed, len(BACKOFF_INTERVALS))
56+
].total_seconds()
57+
58+
try:
59+
if not await update_duckdns(
60+
self.session,
61+
self.config_entry.data[CONF_DOMAIN],
62+
self.config_entry.data[CONF_ACCESS_TOKEN],
63+
):
64+
self.failed += 1
65+
raise UpdateFailed(
66+
translation_domain=DOMAIN,
67+
translation_key="update_failed",
68+
translation_placeholders={
69+
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
70+
},
71+
retry_after=retry_after,
72+
)
73+
except ClientError as e:
74+
self.failed += 1
75+
raise UpdateFailed(
76+
translation_domain=DOMAIN,
77+
translation_key="connection_error",
78+
translation_placeholders={
79+
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
80+
},
81+
retry_after=retry_after,
82+
) from e
83+
self.failed = 0
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Helpers for Duck DNS integration."""
2+
3+
from aiohttp import ClientSession
4+
5+
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
6+
7+
UPDATE_URL = "https://www.duckdns.org/update"
8+
9+
10+
async def update_duckdns(
11+
session: ClientSession,
12+
domain: str,
13+
token: str,
14+
*,
15+
txt: str | None | UndefinedType = UNDEFINED,
16+
clear: bool = False,
17+
) -> bool:
18+
"""Update DuckDNS."""
19+
params = {"domains": domain, "token": token}
20+
21+
if txt is not UNDEFINED:
22+
if txt is None:
23+
# Pass in empty txt value to indicate it's clearing txt record
24+
params["txt"] = ""
25+
clear = True
26+
else:
27+
params["txt"] = txt
28+
29+
if clear:
30+
params["clear"] = "true"
31+
32+
resp = await session.get(UPDATE_URL, params=params)
33+
body = await resp.text()
34+
35+
return body == "OK"

homeassistant/components/duckdns/strings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@
3232
}
3333
},
3434
"exceptions": {
35+
"connection_error": {
36+
"message": "Updating Duck DNS domain {domain} failed due to a connection error"
37+
},
3538
"entry_not_found": {
3639
"message": "Duck DNS integration entry not found"
3740
},
3841
"entry_not_selected": {
3942
"message": "Duck DNS integration entry not selected"
43+
},
44+
"update_failed": {
45+
"message": "Updating Duck DNS domain {domain} failed"
4046
}
4147
},
4248
"issues": {

tests/components/duckdns/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def mock_update_duckdns() -> Generator[AsyncMock]:
4343
"""Mock _update_duckdns."""
4444

4545
with patch(
46-
"homeassistant.components.duckdns.config_flow._update_duckdns",
46+
"homeassistant.components.duckdns.config_flow.update_duckdns",
4747
return_value=True,
4848
) as mock:
4949
yield mock

0 commit comments

Comments
 (0)