Skip to content

Commit 5916af1

Browse files
tr4nt0rfrenckCopilot
authored
Add config flow to Duck DNS integration (home-assistant#147693)
Co-authored-by: Franck Nijhof <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent f8bf7ec commit 5916af1

File tree

13 files changed

+664
-66
lines changed

13 files changed

+664
-66
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/components/duckdns/__init__.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from aiohttp import ClientSession
1111
import voluptuous as vol
1212

13+
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
1314
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
1415
from homeassistant.core import (
1516
CALLBACK_TYPE,
@@ -18,21 +19,31 @@
1819
ServiceCall,
1920
callback,
2021
)
22+
from homeassistant.exceptions import ServiceValidationError
2123
from homeassistant.helpers import config_validation as cv
2224
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2325
from homeassistant.helpers.event import async_call_later
26+
from homeassistant.helpers.selector import ConfigEntrySelector
2427
from homeassistant.helpers.typing import ConfigType
2528
from homeassistant.loader import bind_hass
2629
from homeassistant.util import dt as dt_util
2730

31+
from .const import ATTR_CONFIG_ENTRY
32+
2833
_LOGGER = logging.getLogger(__name__)
2934

3035
ATTR_TXT = "txt"
3136

3237
DOMAIN = "duckdns"
3338

3439
INTERVAL = timedelta(minutes=5)
35-
40+
BACKOFF_INTERVALS = (
41+
INTERVAL,
42+
timedelta(minutes=1),
43+
timedelta(minutes=5),
44+
timedelta(minutes=15),
45+
timedelta(minutes=30),
46+
)
3647
SERVICE_SET_TXT = "set_txt"
3748

3849
UPDATE_URL = "https://www.duckdns.org/update"
@@ -49,36 +60,109 @@
4960
extra=vol.ALLOW_EXTRA,
5061
)
5162

52-
SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string)})
63+
SERVICE_TXT_SCHEMA = vol.Schema(
64+
{
65+
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector(
66+
{
67+
"integration": DOMAIN,
68+
}
69+
),
70+
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
71+
}
72+
)
73+
74+
type DuckDnsConfigEntry = ConfigEntry
5375

5476

5577
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
5678
"""Initialize the DuckDNS component."""
57-
domain: str = config[DOMAIN][CONF_DOMAIN]
58-
token: str = config[DOMAIN][CONF_ACCESS_TOKEN]
79+
80+
hass.services.async_register(
81+
DOMAIN,
82+
SERVICE_SET_TXT,
83+
update_domain_service,
84+
schema=SERVICE_TXT_SCHEMA,
85+
)
86+
87+
if DOMAIN not in config:
88+
return True
89+
90+
hass.async_create_task(
91+
hass.config_entries.flow.async_init(
92+
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
93+
)
94+
)
95+
96+
return True
97+
98+
99+
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
100+
"""Set up Duck DNS from a config entry."""
101+
59102
session = async_get_clientsession(hass)
60103

61104
async def update_domain_interval(_now: datetime) -> bool:
62105
"""Update the DuckDNS entry."""
63-
return await _update_duckdns(session, domain, token)
64-
65-
intervals = (
66-
INTERVAL,
67-
timedelta(minutes=1),
68-
timedelta(minutes=5),
69-
timedelta(minutes=15),
70-
timedelta(minutes=30),
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+
)
71116
)
72-
async_track_time_interval_backoff(hass, update_domain_interval, intervals)
73117

74-
async def update_domain_service(call: ServiceCall) -> None:
75-
"""Update the DuckDNS entry."""
76-
await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT])
118+
return True
77119

78-
hass.services.async_register(
79-
DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA
120+
121+
def get_config_entry(
122+
hass: HomeAssistant, entry_id: str | None = None
123+
) -> DuckDnsConfigEntry:
124+
"""Return config entry or raise if not found or not loaded."""
125+
126+
if entry_id is None:
127+
if not (config_entries := hass.config_entries.async_entries(DOMAIN)):
128+
raise ServiceValidationError(
129+
translation_domain=DOMAIN,
130+
translation_key="entry_not_found",
131+
)
132+
133+
if len(config_entries) != 1:
134+
raise ServiceValidationError(
135+
translation_domain=DOMAIN,
136+
translation_key="entry_not_selected",
137+
)
138+
return config_entries[0]
139+
140+
if not (entry := hass.config_entries.async_get_entry(entry_id)):
141+
raise ServiceValidationError(
142+
translation_domain=DOMAIN,
143+
translation_key="entry_not_found",
144+
)
145+
146+
return entry
147+
148+
149+
async def update_domain_service(call: ServiceCall) -> None:
150+
"""Update the DuckDNS entry."""
151+
152+
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
153+
154+
session = async_get_clientsession(call.hass)
155+
156+
await _update_duckdns(
157+
session,
158+
entry.data[CONF_DOMAIN],
159+
entry.data[CONF_ACCESS_TOKEN],
160+
txt=call.data.get(ATTR_TXT),
80161
)
81162

163+
164+
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
165+
"""Unload a config entry."""
82166
return True
83167

84168

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Config flow for the Duck DNS integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
import voluptuous as vol
9+
10+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
11+
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
12+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
13+
from homeassistant.helpers.selector import (
14+
TextSelector,
15+
TextSelectorConfig,
16+
TextSelectorType,
17+
)
18+
19+
from . import _update_duckdns
20+
from .const import DOMAIN
21+
from .issue import deprecate_yaml_issue
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
STEP_USER_DATA_SCHEMA = vol.Schema(
26+
{
27+
vol.Required(CONF_DOMAIN): TextSelector(
28+
TextSelectorConfig(type=TextSelectorType.TEXT, suffix=".duckdns.org")
29+
),
30+
vol.Required(CONF_ACCESS_TOKEN): str,
31+
}
32+
)
33+
34+
35+
class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
36+
"""Handle a config flow for Duck DNS."""
37+
38+
async def async_step_user(
39+
self, user_input: dict[str, Any] | None = None
40+
) -> ConfigFlowResult:
41+
"""Handle the initial step."""
42+
errors: dict[str, str] = {}
43+
if user_input is not None:
44+
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
45+
session = async_get_clientsession(self.hass)
46+
try:
47+
if not await _update_duckdns(
48+
session,
49+
user_input[CONF_DOMAIN],
50+
user_input[CONF_ACCESS_TOKEN],
51+
):
52+
errors["base"] = "update_failed"
53+
except Exception:
54+
_LOGGER.exception("Unexpected exception")
55+
errors["base"] = "unknown"
56+
57+
if not errors:
58+
return self.async_create_entry(
59+
title=f"{user_input[CONF_DOMAIN]}.duckdns.org", data=user_input
60+
)
61+
62+
return self.async_show_form(
63+
step_id="user",
64+
data_schema=self.add_suggested_values_to_schema(
65+
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
66+
),
67+
errors=errors,
68+
description_placeholders={"url": "https://www.duckdns.org/"},
69+
)
70+
71+
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
72+
"""Import config from yaml."""
73+
74+
self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]})
75+
result = await self.async_step_user(import_info)
76+
if errors := result.get("errors"):
77+
deprecate_yaml_issue(self.hass, import_success=False)
78+
return self.async_abort(reason=errors["base"])
79+
80+
deprecate_yaml_issue(self.hass, import_success=True)
81+
return result
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Constants for the Duck DNS integration."""
2+
3+
from typing import Final
4+
5+
DOMAIN = "duckdns"
6+
7+
ATTR_CONFIG_ENTRY: Final = "config_entry_id"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Issues for Duck DNS integration."""
2+
3+
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
4+
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
5+
6+
from .const import DOMAIN
7+
8+
9+
@callback
10+
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
11+
"""Deprecate yaml issue."""
12+
if import_success:
13+
async_create_issue(
14+
hass,
15+
HOMEASSISTANT_DOMAIN,
16+
f"deprecated_yaml_{DOMAIN}",
17+
is_fixable=False,
18+
issue_domain=DOMAIN,
19+
breaks_in_ha_version="2026.6.0",
20+
severity=IssueSeverity.WARNING,
21+
translation_key="deprecated_yaml",
22+
translation_placeholders={
23+
"domain": DOMAIN,
24+
"integration_title": "Duck DNS",
25+
},
26+
)
27+
else:
28+
async_create_issue(
29+
hass,
30+
DOMAIN,
31+
"deprecated_yaml_import_issue_error",
32+
breaks_in_ha_version="2026.6.0",
33+
is_fixable=False,
34+
issue_domain=DOMAIN,
35+
severity=IssueSeverity.WARNING,
36+
translation_key="deprecated_yaml_import_issue_error",
37+
translation_placeholders={
38+
"url": "/config/integrations/dashboard/add?domain=duckdns"
39+
},
40+
)
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"domain": "duckdns",
33
"name": "Duck DNS",
4-
"codeowners": [],
4+
"codeowners": ["@tr4nt0r"],
5+
"config_flow": true,
56
"documentation": "https://www.home-assistant.io/integrations/duckdns",
6-
"iot_class": "cloud_polling",
7-
"quality_scale": "legacy"
7+
"iot_class": "cloud_polling"
88
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
set_txt:
22
fields:
3+
config_entry_id:
4+
selector:
5+
config_entry:
6+
integration: duckdns
37
txt:
4-
required: true
58
example: "This domain name is reserved for use in documentation"
69
selector:
710
text:

homeassistant/components/duckdns/strings.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,48 @@
11
{
2+
"config": {
3+
"abort": {
4+
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
5+
},
6+
"error": {
7+
"unknown": "[%key:common::config_flow::error::unknown%]",
8+
"update_failed": "Updating DuckDNS failed"
9+
},
10+
"step": {
11+
"user": {
12+
"data": {
13+
"access_token": "Token",
14+
"domain": "Subdomain"
15+
},
16+
"data_description": {
17+
"access_token": "Your Duck DNS account token",
18+
"domain": "The Duck DNS subdomain to update"
19+
},
20+
"description": "Enter your Duck DNS subdomain and token below to configure dynamic DNS updates. You can find your token on the [Duck DNS]({url}) homepage after logging into your account."
21+
}
22+
}
23+
},
24+
"exceptions": {
25+
"entry_not_found": {
26+
"message": "Duck DNS integration entry not found"
27+
},
28+
"entry_not_selected": {
29+
"message": "Duck DNS integration entry not selected"
30+
}
31+
},
32+
"issues": {
33+
"deprecated_yaml_import_issue_error": {
34+
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
35+
"title": "The Duck DNS YAML configuration import failed"
36+
}
37+
},
238
"services": {
339
"set_txt": {
440
"description": "Sets the TXT record of your DuckDNS subdomain.",
541
"fields": {
42+
"config_entry_id": {
43+
"description": "The Duck DNS integration ID.",
44+
"name": "Integration ID"
45+
},
646
"txt": {
747
"description": "Payload for the TXT record.",
848
"name": "TXT"

homeassistant/generated/config_flows.py

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/generated/integrations.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1467,7 +1467,7 @@
14671467
"duckdns": {
14681468
"name": "Duck DNS",
14691469
"integration_type": "hub",
1470-
"config_flow": false,
1470+
"config_flow": true,
14711471
"iot_class": "cloud_polling"
14721472
},
14731473
"duke_energy": {

0 commit comments

Comments
 (0)