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:
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
83
homeassistant/components/duckdns/coordinator.py
Normal file
83
homeassistant/components/duckdns/coordinator.py
Normal 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
|
||||
35
homeassistant/components/duckdns/helpers.py
Normal file
35
homeassistant/components/duckdns/helpers.py
Normal 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"
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user