From fc4cd39cdd4c38ea00e1b343c7237df29d8671cc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 24 Sep 2017 15:48:45 -0700 Subject: [PATCH] Add DuckDNS component (#9556) * Add DuckDNS component * Address comments --- homeassistant/components/duckdns.py | 102 ++++++++++++++++++++++++++ tests/components/test_duckdns.py | 106 ++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 homeassistant/components/duckdns.py create mode 100644 tests/components/test_duckdns.py diff --git a/homeassistant/components/duckdns.py b/homeassistant/components/duckdns.py new file mode 100644 index 00000000000..0045b9421a2 --- /dev/null +++ b/homeassistant/components/duckdns.py @@ -0,0 +1,102 @@ +"""Integrate with DuckDNS.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DOMAIN = 'duckdns' +UPDATE_URL = 'https://www.duckdns.org/update' +INTERVAL = timedelta(minutes=5) +_LOGGER = logging.getLogger(__name__) +SERVICE_SET_TXT = 'set_txt' +ATTR_TXT = 'txt' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_TXT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TXT): vol.Any(None, cv.string) +}) + + +@bind_hass +@asyncio.coroutine +def async_set_txt(hass, txt): + """Set the txt record. Pass in None to remove it.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_TXT, { + ATTR_TXT: txt + }, blocking=True) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = yield from _update_duckdns(session, domain, token) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token) + + @asyncio.coroutine + def update_domain_service(call): + """Update the DuckDNS entry.""" + yield from _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 + + +_SENTINEL = object() + + +@asyncio.coroutine +def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): + """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'] = txt + + if clear: + params['clear'] = 'true' + + resp = yield from session.get(UPDATE_URL, params=params) + body = yield from resp.text() + + if body != 'OK': + _LOGGER.warning('Updating DuckDNS domain %s failed', domain) + return False + + return True diff --git a/tests/components/test_duckdns.py b/tests/components/test_duckdns.py new file mode 100644 index 00000000000..d64ffbca81f --- /dev/null +++ b/tests/components/test_duckdns.py @@ -0,0 +1,106 @@ +"""Test the DuckDNS component.""" +import asyncio +from datetime import timedelta + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import duckdns +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +DOMAIN = 'bla' +TOKEN = 'abcdefgh' + + +@pytest.fixture +def setup_duckdns(hass, aioclient_mock): + """Fixture that sets up DuckDNS.""" + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN + }, text='OK') + + hass.loop.run_until_complete(async_setup_component( + hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + })) + + +@asyncio.coroutine +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(hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_update_fails(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(hass, duckdns.DOMAIN, { + 'duckdns': { + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert not result + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_service_set_txt(hass, aioclient_mock, setup_duckdns): + """Test set txt service call.""" + # Empty the fixture mock requests + aioclient_mock.clear_requests() + + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN, + 'txt': 'some-txt', + }, text='OK') + + assert aioclient_mock.call_count == 0 + yield from hass.components.duckdns.async_set_txt('some-txt') + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_service_clear_txt(hass, aioclient_mock, setup_duckdns): + """Test clear txt service call.""" + # Empty the fixture mock requests + aioclient_mock.clear_requests() + + aioclient_mock.get(duckdns.UPDATE_URL, params={ + 'domains': DOMAIN, + 'token': TOKEN, + 'txt': '', + 'clear': 'true', + }, text='OK') + + assert aioclient_mock.call_count == 0 + yield from hass.components.duckdns.async_set_txt(None) + assert aioclient_mock.call_count == 1