Add config flow to Namecheap DynamicDNS integration (#160841)

This commit is contained in:
Manu
2026-01-13 15:46:15 +01:00
committed by GitHub
parent 52a8a66a91
commit 8543f3f989
12 changed files with 482 additions and 61 deletions

2
CODEOWNERS generated
View File

@@ -1068,6 +1068,8 @@ build.json @home-assistant/supervisor
/tests/components/myuplink/ @pajzo @astrandb
/homeassistant/components/nam/ @bieniu
/tests/components/nam/ @bieniu
/homeassistant/components/namecheapdns/ @tr4nt0r
/tests/components/namecheapdns/ @tr4nt0r
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio

View File

@@ -3,23 +3,26 @@
from datetime import timedelta
import logging
from aiohttp import ClientError, ClientSession
import defusedxml.ElementTree as ET
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, UPDATE_URL
_LOGGER = logging.getLogger(__name__)
DOMAIN = "namecheapdns"
INTERVAL = timedelta(minutes=5)
UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"
CONFIG_SCHEMA = vol.Schema(
{
@@ -34,30 +37,67 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
type NamecheapConfigEntry = ConfigEntry[None]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the namecheap DNS component."""
host = config[DOMAIN][CONF_HOST]
domain = config[DOMAIN][CONF_DOMAIN]
password = config[DOMAIN][CONF_PASSWORD]
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Set up Namecheap DynamicDNS from a config entry."""
host = entry.data[CONF_HOST]
domain = entry.data[CONF_DOMAIN]
password = entry.data[CONF_PASSWORD]
session = async_get_clientsession(hass)
result = await _update_namecheapdns(session, host, domain, password)
if not result:
return False
try:
if not await update_namecheapdns(session, host, domain, password):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
)
except ClientError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
) from e
async def update_domain_interval(now):
"""Update the namecheap DNS entry."""
await _update_namecheapdns(session, host, domain, password)
await update_namecheapdns(session, host, domain, password)
async_track_time_interval(hass, update_domain_interval, INTERVAL)
entry.async_on_unload(
async_track_time_interval(hass, update_domain_interval, INTERVAL)
)
return result
return True
async def _update_namecheapdns(session, host, domain, password):
async def async_unload_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def update_namecheapdns(
session: ClientSession, host: str, domain: str, password: str
):
"""Update namecheap DNS entry."""
params = {"host": host, "domain": domain, "password": password}

View File

@@ -0,0 +1,91 @@
"""Config flow for the Namecheap DynamicDNS integration."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from . import update_namecheapdns
from .const import DOMAIN
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default="@"): cv.string,
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="current-password"
)
),
}
)
class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Namecheap DynamicDNS."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_DOMAIN: user_input[CONF_DOMAIN]}
)
session = async_get_clientsession(self.hass)
try:
if not await update_namecheapdns(session, **user_input):
errors["base"] = "update_failed"
except ClientError:
_LOGGER.debug("Cannot connect", exc_info=True)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title=f"{user_input[CONF_HOST]}.{user_input[CONF_DOMAIN]}",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders={"account_panel": "https://ap.www.namecheap.com/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match(
{CONF_HOST: import_info[CONF_HOST], CONF_DOMAIN: import_info[CONF_DOMAIN]}
)
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result

View File

@@ -0,0 +1,6 @@
"""Constants for the Namecheap DynamicDNS integration."""
DOMAIN = "namecheapdns"
UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"

View File

@@ -0,0 +1,40 @@
"""Issues for Namecheap DynamicDNS integration."""
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.8.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Namecheap DynamicDNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": f"/config/integrations/dashboard/add?domain={DOMAIN}"
},
)

View File

@@ -1,9 +1,10 @@
{
"domain": "namecheapdns",
"name": "Namecheap DynamicDNS",
"codeowners": [],
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
"integration_type": "service",
"iot_class": "cloud_push",
"quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1"]
}

View File

@@ -0,0 +1,41 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"update_failed": "Updating DNS failed"
},
"step": {
"user": {
"data": {
"domain": "[%key:common::config_flow::data::username%]",
"host": "[%key:common::config_flow::data::host%]",
"password": "Dynamic DNS password"
},
"data_description": {
"domain": "The domain to update ('example.com')",
"host": "The host to update ('home' for home.example.com). Use '@' to update the root domain",
"password": "Dynamic DNS password for the domain"
},
"description": "Enter your Namecheap DynamicDNS domain and password below to configure dynamic DNS updates. You can find the Dynamic DNS password in your [Namecheap account]({account_panel}) under Domain List > Manage > Advanced DNS > Dynamic DNS."
}
}
},
"exceptions": {
"connection_error": {
"message": "Updating Namecheap DynamicDNS domain {domain} failed due to a connection error"
},
"update_failed": {
"message": "Updating Namecheap DynamicDNS domain {domain} failed"
}
},
"issues": {
"deprecated_yaml_import_issue_error": {
"description": "Configuring Namecheap DynamicDNS 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 Namecheap DynamicDNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Namecheap DynamicDNS YAML configuration import failed"
}
}
}

View File

@@ -443,6 +443,7 @@ FLOWS = {
"mystrom",
"myuplink",
"nam",
"namecheapdns",
"nanoleaf",
"nasweb",
"neato",

View File

@@ -4343,8 +4343,8 @@
},
"namecheapdns": {
"name": "Namecheap DynamicDNS",
"integration_type": "hub",
"config_flow": false,
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_push"
},
"nanoleaf": {

View File

@@ -0,0 +1,52 @@
"""Common fixtures for the Namecheap DynamicDNS tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.namecheapdns.const import DOMAIN
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from tests.common import MockConfigEntry
TEST_HOST = "home"
TEST_DOMAIN = "example.com"
TEST_PASSWORD = "test-password"
TEST_USER_INPUT = {
CONF_HOST: TEST_HOST,
CONF_DOMAIN: TEST_DOMAIN,
CONF_PASSWORD: TEST_PASSWORD,
}
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.namecheapdns.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="mock_namecheap")
def mock_update_namecheapdns() -> Generator[AsyncMock]:
"""Mock update_namecheapdns."""
with patch(
"homeassistant.components.namecheapdns.config_flow.update_namecheapdns",
return_value=True,
) as mock:
yield mock
@pytest.fixture(name="config_entry")
def mock_config_entry() -> MockConfigEntry:
"""Mock Namecheap Dynamic DNS configuration entry."""
return MockConfigEntry(
domain=DOMAIN,
title=f"{TEST_HOST}.{TEST_DOMAIN}",
data=TEST_USER_INPUT,
entry_id="12345",
)

View File

@@ -0,0 +1,142 @@
"""Test the Namecheap DynamicDNS config flow."""
from unittest.mock import AsyncMock
from aiohttp import ClientError
import pytest
from homeassistant.components.namecheapdns.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from .conftest import TEST_USER_INPUT
@pytest.mark.usefixtures("mock_namecheap")
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "home.example.com"
assert result["data"] == TEST_USER_INPUT
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("side_effect", "text_error"),
[
(ValueError, "unknown"),
(False, "update_failed"),
(ClientError, "cannot_connect"),
],
)
async def test_form_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_namecheap: AsyncMock,
side_effect: Exception | bool,
text_error: str,
) -> None:
"""Test we handle errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_namecheap.side_effect = [side_effect]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": text_error}
mock_namecheap.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "home.example.com"
assert result["data"] == TEST_USER_INPUT
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_namecheap")
async def test_import(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test import flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "home.example.com"
assert result["data"] == TEST_USER_INPUT
assert len(mock_setup_entry.mock_calls) == 1
assert issue_registry.async_get_issue(
domain=HOMEASSISTANT_DOMAIN,
issue_id=f"deprecated_yaml_{DOMAIN}",
)
async def test_import_exception(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
issue_registry: ir.IssueRegistry,
mock_namecheap: AsyncMock,
) -> None:
"""Test import flow failed."""
mock_namecheap.side_effect = [False]
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "update_failed"
assert len(mock_setup_entry.mock_calls) == 0
assert issue_registry.async_get_issue(
domain=DOMAIN,
issue_id="deprecated_yaml_import_issue_error",
)
@pytest.mark.usefixtures("mock_namecheap")
async def test_init_import_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test yaml triggers import flow."""
await async_setup_component(
hass,
DOMAIN,
{DOMAIN: TEST_USER_INPUT},
)
assert len(mock_setup_entry.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1

View File

@@ -2,74 +2,79 @@
from datetime import timedelta
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components import namecheapdns
from homeassistant.components.namecheapdns.const import UPDATE_URL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from tests.common import async_fire_time_changed
from .conftest import TEST_USER_INPUT
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
HOST = "test"
DOMAIN = "bla"
PASSWORD = "abcdefgh"
@pytest.fixture
async def setup_namecheapdns(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
@pytest.mark.freeze_time
async def test_setup(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Fixture that sets up NamecheapDNS."""
aioclient_mock.get(
namecheapdns.UPDATE_URL,
params={"host": HOST, "domain": DOMAIN, "password": PASSWORD},
text="<interface-response><ErrCount>0</ErrCount></interface-response>",
)
await async_setup_component(
hass,
namecheapdns.DOMAIN,
{"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}},
)
async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None:
"""Test setup works if update passes."""
aioclient_mock.get(
namecheapdns.UPDATE_URL,
params={"host": HOST, "domain": DOMAIN, "password": PASSWORD},
UPDATE_URL,
params=TEST_USER_INPUT,
text="<interface-response><ErrCount>0</ErrCount></interface-response>",
)
result = await async_setup_component(
hass,
namecheapdns.DOMAIN,
{"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}},
)
assert result
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert aioclient_mock.call_count == 1
async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert aioclient_mock.call_count == 2
@pytest.mark.freeze_time
async def test_setup_fails_if_update_fails(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test setup fails if first update fails."""
aioclient_mock.get(
namecheapdns.UPDATE_URL,
params={"host": HOST, "domain": DOMAIN, "password": PASSWORD},
UPDATE_URL,
params=TEST_USER_INPUT,
text="<interface-response><ErrCount>1</ErrCount></interface-response>",
)
result = await async_setup_component(
hass,
namecheapdns.DOMAIN,
{"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}},
)
assert not result
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
assert aioclient_mock.call_count == 1
aioclient_mock.clear_requests()
aioclient_mock.get(
UPDATE_URL,
params=TEST_USER_INPUT,
exc=ClientError,
)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
assert aioclient_mock.call_count == 1