DuckDNS setup backoff (#25899)

pull/26145/head
Johann Kellerman 2019-08-22 18:19:27 +02:00 committed by GitHub
parent 82b1b10c28
commit 2d432da14c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 153 additions and 31 deletions

View File

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

View File

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