DuckDNS setup backoff (#25899)
parent
82b1b10c28
commit
2d432da14c
|
@ -1,13 +1,17 @@
|
|||
"""Integrate with DuckDNS."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from asyncio import iscoroutinefunction
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import callback, CALLBACK_TYPE
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -42,25 +46,28 @@ async def async_setup(hass, config):
|
|||
token = config[DOMAIN][CONF_ACCESS_TOKEN]
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
result = await _update_duckdns(session, domain, token)
|
||||
|
||||
if not result:
|
||||
return False
|
||||
|
||||
async def update_domain_interval(now):
|
||||
async def update_domain_interval(_now):
|
||||
"""Update the DuckDNS entry."""
|
||||
await _update_duckdns(session, domain, token)
|
||||
return await _update_duckdns(session, domain, token)
|
||||
|
||||
intervals = (
|
||||
INTERVAL,
|
||||
timedelta(minutes=1),
|
||||
timedelta(minutes=5),
|
||||
timedelta(minutes=15),
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
async_track_time_interval_backoff(hass, update_domain_interval, intervals)
|
||||
|
||||
async def update_domain_service(call):
|
||||
"""Update the DuckDNS entry."""
|
||||
await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT])
|
||||
|
||||
async_track_time_interval(hass, update_domain_interval, INTERVAL)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA
|
||||
)
|
||||
|
||||
return result
|
||||
return True
|
||||
|
||||
|
||||
_SENTINEL = object()
|
||||
|
@ -89,3 +96,37 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False)
|
|||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE:
|
||||
"""Add a listener that fires repetitively at every timedelta interval."""
|
||||
if not iscoroutinefunction:
|
||||
_LOGGER.error("action needs to be a coroutine and return True/False")
|
||||
return
|
||||
|
||||
if not isinstance(intervals, (list, tuple)):
|
||||
intervals = (intervals,)
|
||||
remove = None
|
||||
failed = 0
|
||||
|
||||
async def interval_listener(now):
|
||||
"""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_track_point_in_utc_time(hass, interval_listener, now + delay)
|
||||
|
||||
hass.async_run_job(interval_listener, dt_util.utcnow())
|
||||
|
||||
def remove_listener():
|
||||
"""Remove interval listener."""
|
||||
if remove:
|
||||
remove() # pylint: disable=not-callable
|
||||
|
||||
return remove_listener
|
||||
|
|
|
@ -1,28 +1,29 @@
|
|||
"""Test the DuckDNS component."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
import logging
|
||||
import pytest
|
||||
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components import duckdns
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.components.duckdns import async_track_time_interval_backoff
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
DOMAIN = "bla"
|
||||
TOKEN = "abcdefgh"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
INTERVAL = duckdns.INTERVAL
|
||||
|
||||
|
||||
@bind_hass
|
||||
@asyncio.coroutine
|
||||
def async_set_txt(hass, txt):
|
||||
async def async_set_txt(hass, txt):
|
||||
"""Set the txt record. Pass in None to remove it.
|
||||
|
||||
This is a legacy helper method. Do not use it for new tests.
|
||||
"""
|
||||
yield from hass.services.async_call(
|
||||
await hass.services.async_call(
|
||||
duckdns.DOMAIN, duckdns.SERVICE_SET_TXT, {duckdns.ATTR_TXT: txt}, blocking=True
|
||||
)
|
||||
|
||||
|
@ -41,40 +42,60 @@ def setup_duckdns(hass, aioclient_mock):
|
|||
)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_setup(hass, aioclient_mock):
|
||||
async def test_setup(hass, aioclient_mock):
|
||||
"""Test setup works if update passes."""
|
||||
aioclient_mock.get(
|
||||
duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK"
|
||||
)
|
||||
|
||||
result = yield from async_setup_component(
|
||||
result = await async_setup_component(
|
||||
hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}}
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
|
||||
yield from hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
assert aioclient_mock.call_count == 2
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_setup_fails_if_update_fails(hass, aioclient_mock):
|
||||
async def test_setup_backoff(hass, aioclient_mock):
|
||||
"""Test setup fails if first update fails."""
|
||||
aioclient_mock.get(
|
||||
duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="KO"
|
||||
)
|
||||
|
||||
result = yield from async_setup_component(
|
||||
result = await async_setup_component(
|
||||
hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}}
|
||||
)
|
||||
assert not result
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
# Copy of the DuckDNS intervals from duckdns/__init__.py
|
||||
intervals = (
|
||||
INTERVAL,
|
||||
timedelta(minutes=1),
|
||||
timedelta(minutes=5),
|
||||
timedelta(minutes=15),
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
tme = utcnow()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_service_set_txt(hass, aioclient_mock, setup_duckdns):
|
||||
_LOGGER.debug("Backoff...")
|
||||
for idx in range(1, len(intervals)):
|
||||
tme += intervals[idx]
|
||||
async_fire_time_changed(hass, tme)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert aioclient_mock.call_count == idx + 1
|
||||
|
||||
|
||||
async def test_service_set_txt(hass, aioclient_mock, setup_duckdns):
|
||||
"""Test set txt service call."""
|
||||
# Empty the fixture mock requests
|
||||
aioclient_mock.clear_requests()
|
||||
|
@ -86,12 +107,11 @@ def test_service_set_txt(hass, aioclient_mock, setup_duckdns):
|
|||
)
|
||||
|
||||
assert aioclient_mock.call_count == 0
|
||||
yield from async_set_txt(hass, "some-txt")
|
||||
await async_set_txt(hass, "some-txt")
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def test_service_clear_txt(hass, aioclient_mock, setup_duckdns):
|
||||
async def test_service_clear_txt(hass, aioclient_mock, setup_duckdns):
|
||||
"""Test clear txt service call."""
|
||||
# Empty the fixture mock requests
|
||||
aioclient_mock.clear_requests()
|
||||
|
@ -103,5 +123,66 @@ def test_service_clear_txt(hass, aioclient_mock, setup_duckdns):
|
|||
)
|
||||
|
||||
assert aioclient_mock.call_count == 0
|
||||
yield from async_set_txt(hass, None)
|
||||
await async_set_txt(hass, None)
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
|
||||
async def test_async_track_time_interval_backoff(hass):
|
||||
"""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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert call_count == _idx
|
||||
|
|
Loading…
Reference in New Issue