Migrate NextDNS integration to use subentries (#175067)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Maciej Bieniek
2026-07-21 16:22:08 +02:00
committed by GitHub
parent 06693f03b7
commit ca4fd1d232
16 changed files with 939 additions and 230 deletions
+156 -14
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from types import MappingProxyType
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from nextdns import ( from nextdns import (
@@ -18,11 +19,17 @@ from nextdns import (
) )
from tenacity import RetryError from tenacity import RetryError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
ATTR_CONNECTION, ATTR_CONNECTION,
@@ -34,6 +41,7 @@ from .const import (
ATTR_STATUS, ATTR_STATUS,
CONF_PROFILE_ID, CONF_PROFILE_ID,
DOMAIN, DOMAIN,
SUBENTRY_TYPE_PROFILE,
) )
from .coordinator import ( from .coordinator import (
NextDnsConnectionUpdateCoordinator, NextDnsConnectionUpdateCoordinator,
@@ -50,8 +58,8 @@ type NextDnsConfigEntry = ConfigEntry[NextDnsData]
@dataclass @dataclass
class NextDnsData: class NextDnsCoordinators:
"""Data for the NextDNS integration.""" """Coordinators for a NextDNS profile."""
connection: NextDnsUpdateCoordinator[ConnectionStatus] connection: NextDnsUpdateCoordinator[ConnectionStatus]
dnssec: NextDnsUpdateCoordinator[AnalyticsDnssec] dnssec: NextDnsUpdateCoordinator[AnalyticsDnssec]
@@ -62,6 +70,15 @@ class NextDnsData:
status: NextDnsUpdateCoordinator[AnalyticsStatus] status: NextDnsUpdateCoordinator[AnalyticsStatus]
@dataclass
class NextDnsData:
"""Runtime data for the NextDNS integration."""
client: NextDns
profiles: dict[str, NextDnsCoordinators]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [ COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [
(ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator), (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator),
@@ -74,10 +91,117 @@ COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [
] ]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up NextDNS."""
await async_migrate_integration(hass)
return True
async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure."""
# Make sure we get enabled config entries first
entries = sorted(
hass.config_entries.async_entries(DOMAIN),
key=lambda e: e.disabled_by is not None,
)
if not any(entry.version == 1 for entry in entries):
return
api_keys_entries: dict[str, tuple[NextDnsConfigEntry, bool]] = {}
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
for entry in entries:
profile_id = entry.data[CONF_PROFILE_ID]
profile_name = entry.title
subentry = ConfigSubentry(
data=MappingProxyType({CONF_PROFILE_ID: profile_id}),
subentry_type=SUBENTRY_TYPE_PROFILE,
title=profile_name,
unique_id=profile_id,
)
if entry.data[CONF_API_KEY] not in api_keys_entries:
all_disabled = all(
e.disabled_by is not None
for e in entries
if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
)
api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
hass.config_entries.async_add_subentry(parent_entry, subentry)
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
device = device_registry.async_get_device(identifiers={(DOMAIN, profile_id)})
for entity_entry in entities:
entity_disabled_by = entity_entry.disabled_by
if (
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
# Device and entity registries don't update the disabled_by flag
# when moving a device or entity from one config entry to another,
# so we need to do it manually.
entity_disabled_by = (
er.RegistryEntryDisabler.DEVICE
if device
else er.RegistryEntryDisabler.USER
)
entity_registry.async_update_entity(
entity_entry.entity_id,
config_entry_id=parent_entry.entry_id,
config_subentry_id=subentry.subentry_id,
disabled_by=entity_disabled_by,
)
if device is not None:
# Device and entity registries don't update the disabled_by flag when
# moving a device or entity from one config entry to another, so we
# need to do it manually.
device_disabled_by = device.disabled_by
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
device_disabled_by = dr.DeviceEntryDisabler.USER
device_registry.async_update_device(
device.id,
disabled_by=device_disabled_by,
new_identifiers={(DOMAIN, profile_id)},
add_config_subentry_id=subentry.subentry_id,
add_config_entry_id=parent_entry.entry_id,
)
if parent_entry.entry_id != entry.entry_id:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if parent_entry.entry_id != entry.entry_id:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
data={CONF_API_KEY: entry.data[CONF_API_KEY]},
title="NextDNS",
version=2,
unique_id=None,
)
async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool:
"""Set up NextDNS as config entry.""" """Set up NextDNS as config entry."""
api_key = entry.data[CONF_API_KEY] api_key = entry.data[CONF_API_KEY]
profile_id = entry.data[CONF_PROFILE_ID]
websession = async_get_clientsession(hass) websession = async_get_clientsession(hass)
try: try:
@@ -98,25 +222,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b
translation_placeholders={"entry": entry.title}, translation_placeholders={"entry": entry.title},
) from err ) from err
tasks = [] profiles: dict[str, NextDnsCoordinators] = {}
coordinators = {}
# Independent DataUpdateCoordinator is used for each API endpoint to avoid for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE):
# unnecessary requests when entities using this endpoint are disabled. subentry_id = subentry.subentry_id
for coordinator_name, coordinator_class in COORDINATORS: profile_id = subentry.data[CONF_PROFILE_ID]
coordinator = coordinator_class(hass, entry, nextdns, profile_id) tasks = []
tasks.append(coordinator.async_config_entry_first_refresh()) coordinators = {}
coordinators[coordinator_name] = coordinator
await asyncio.gather(*tasks) # Independent DataUpdateCoordinator is used for each API endpoint to avoid
# unnecessary requests when entities using this endpoint are disabled.
for coordinator_name, coordinator_class in COORDINATORS:
coordinator = coordinator_class(
hass, entry, nextdns, profile_id, subentry_id
)
tasks.append(coordinator.async_config_entry_first_refresh())
coordinators[coordinator_name] = coordinator
entry.runtime_data = NextDnsData(**coordinators) await asyncio.gather(*tasks)
profiles[subentry_id] = NextDnsCoordinators(**coordinators)
entry.runtime_data = NextDnsData(client=nextdns, profiles=profiles)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def _async_update_listener(
hass: HomeAssistant, entry: NextDnsConfigEntry
) -> None:
"""Reload the config entry when subentries change."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -52,11 +52,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Add NextDNS entities from a config_entry.""" """Add NextDNS entities from a config_entry."""
coordinator = entry.runtime_data.connection for subentry_id, profile_data in entry.runtime_data.profiles.items():
coordinator = profile_data.connection
async_add_entities( async_add_entities(
NextDnsBinarySensor(coordinator, description) for description in SENSORS (NextDnsBinarySensor(coordinator, description) for description in SENSORS),
) config_subentry_id=subentry_id,
)
class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity): class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity):
+7 -4
View File
@@ -31,10 +31,13 @@ async def async_setup_entry(
entry: NextDnsConfigEntry, entry: NextDnsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Add aNextDNS entities from a config_entry.""" """Add NextDNS entities from a config_entry."""
coordinator = entry.runtime_data.status for subentry_id, profile_data in entry.runtime_data.profiles.items():
coordinator = profile_data.status
async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)]) async_add_entities(
[NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)],
config_subentry_id=subentry_id,
)
class NextDnsButton(NextDnsEntity, ButtonEntity): class NextDnsButton(NextDnsEntity, ButtonEntity):
+141 -34
View File
@@ -9,51 +9,58 @@ from nextdns import ApiError, InvalidApiKeyError, NextDns
from tenacity import RetryError from tenacity import RetryError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import (
from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME ConfigEntry,
from homeassistant.core import HomeAssistant ConfigEntryState,
from homeassistant.exceptions import HomeAssistantError ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_PROFILE_ID, DOMAIN from .const import CONF_PROFILE_ID, DOMAIN, SUBENTRY_TYPE_PROFILE
AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_init_nextdns( async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns:
hass: HomeAssistant, api_key: str, profile_id: str | None = None """Check if credentials are valid."""
) -> NextDns:
"""Check if credentials and profile_id are valid."""
websession = async_get_clientsession(hass) websession = async_get_clientsession(hass)
nextdns = await NextDns.create(websession, api_key) return await NextDns.create(websession, api_key)
if profile_id:
if not any(profile.id == profile_id for profile in nextdns.profiles):
raise ProfileNotAvailable
return nextdns
async def async_validate_new_api_key( async def async_validate_new_api_key(
hass: HomeAssistant, user_input: dict[str, Any], profile_id: str hass: HomeAssistant, user_input: dict[str, Any], profile_ids: list[str]
) -> dict[str, str]: ) -> dict[str, str]:
"""Validate the new API key during reconfiguration or reauth.""" """Validate the new API key during reconfiguration or reauth."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
try: try:
await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id) nextdns = await async_init_nextdns(hass, user_input[CONF_API_KEY])
except InvalidApiKeyError: except InvalidApiKeyError:
errors["base"] = "invalid_api_key" errors["base"] = "invalid_api_key"
except ApiError, ClientConnectorError, RetryError, TimeoutError: except ApiError, ClientConnectorError, RetryError, TimeoutError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except ProfileNotAvailable:
errors["base"] = "profile_not_available"
except Exception: except Exception:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else:
for profile_id in profile_ids:
if not any(profile.id == profile_id for profile in nextdns.profiles):
errors["base"] = "profile_not_available"
break
return errors return errors
@@ -61,7 +68,7 @@ async def async_validate_new_api_key(
class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for NextDNS.""" """Config flow for NextDNS."""
VERSION = 1 VERSION = 2
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
@@ -77,6 +84,9 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
self.api_key = user_input[CONF_API_KEY] self.api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match({CONF_API_KEY: self.api_key})
try: try:
self.nextdns = await async_init_nextdns(self.hass, self.api_key) self.nextdns = await async_init_nextdns(self.hass, self.api_key)
except InvalidApiKeyError: except InvalidApiKeyError:
@@ -102,23 +112,36 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
profile_name = user_input[CONF_PROFILE_NAME] profile_id = user_input[CONF_PROFILE_ID]
profile_id = self.nextdns.get_profile_id(profile_name)
await self.async_set_unique_id(profile_id)
self._abort_if_unique_id_configured()
return self.async_create_entry( return self.async_create_entry(
title=profile_name, title="NextDNS",
data={CONF_PROFILE_ID: profile_id, CONF_API_KEY: self.api_key}, data={CONF_API_KEY: self.api_key},
subentries=[
{
"subentry_type": SUBENTRY_TYPE_PROFILE,
"data": {CONF_PROFILE_ID: profile_id},
"title": self.nextdns.get_profile_name(profile_id),
"unique_id": profile_id,
},
],
) )
return self.async_show_form( return self.async_show_form(
step_id="profiles", step_id="profiles",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_PROFILE_NAME): vol.In( vol.Required(CONF_PROFILE_ID): SelectSelector(
[profile.name for profile in self.nextdns.profiles] SelectSelectorConfig(
options=[
SelectOptionDict(
value=profile.id,
label=profile.name,
)
for profile in self.nextdns.profiles
],
mode=SelectSelectorMode.LIST,
)
) )
} }
), ),
@@ -139,8 +162,12 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
entry = self._get_reauth_entry() entry = self._get_reauth_entry()
if user_input is not None: if user_input is not None:
profile_ids = [
subentry.data[CONF_PROFILE_ID]
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE)
]
errors = await async_validate_new_api_key( errors = await async_validate_new_api_key(
self.hass, user_input, entry.data[CONF_PROFILE_ID] self.hass, user_input, profile_ids
) )
if errors.get("base") == "profile_not_available": if errors.get("base") == "profile_not_available":
return self.async_abort(reason="profile_not_available") return self.async_abort(reason="profile_not_available")
@@ -165,8 +192,12 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
entry = self._get_reconfigure_entry() entry = self._get_reconfigure_entry()
if user_input is not None: if user_input is not None:
profile_ids = [
subentry.data[CONF_PROFILE_ID]
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE)
]
errors = await async_validate_new_api_key( errors = await async_validate_new_api_key(
self.hass, user_input, entry.data[CONF_PROFILE_ID] self.hass, user_input, profile_ids
) )
if errors.get("base") == "profile_not_available": if errors.get("base") == "profile_not_available":
return self.async_abort(reason="profile_not_available") return self.async_abort(reason="profile_not_available")
@@ -183,6 +214,82 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
@classmethod
@callback
@override
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {SUBENTRY_TYPE_PROFILE: ProfileSubentryFlowHandler}
class ProfileNotAvailable(HomeAssistantError):
"""Error to indicate that the profile is not available after reconfig/reauth.""" class ProfileSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for profile."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.nextdns: NextDns
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> SubentryFlowResult:
"""Handle the profile step."""
entry = self._get_entry()
if entry.state is not ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
self.nextdns = entry.runtime_data.client
if user_input is not None:
profile_id = user_input[CONF_PROFILE_ID]
if any(
subentry.unique_id == profile_id
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE)
):
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=self.nextdns.get_profile_name(profile_id),
data={CONF_PROFILE_ID: profile_id},
unique_id=profile_id,
)
# Filter out already configured profiles
configured_profiles = {
subentry.data[CONF_PROFILE_ID]
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PROFILE)
}
available_profiles = [
profile
for profile in self.nextdns.profiles
if profile.id not in configured_profiles
]
if not available_profiles:
return self.async_abort(reason="all_profiles_configured")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_PROFILE_ID): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
value=profile.id,
label=profile.name,
)
for profile in available_profiles
],
mode=SelectSelectorMode.LIST,
)
)
}
),
errors=errors,
)
@@ -12,6 +12,8 @@ ATTR_STATUS = "status"
CONF_PROFILE_ID = "profile_id" CONF_PROFILE_ID = "profile_id"
SUBENTRY_TYPE_PROFILE = "profile"
UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5) UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5)
UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10) UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10)
UPDATE_INTERVAL_SETTINGS = timedelta(minutes=1) UPDATE_INTERVAL_SETTINGS = timedelta(minutes=1)
@@ -51,16 +51,18 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData](
config_entry: NextDnsConfigEntry, config_entry: NextDnsConfigEntry,
nextdns: NextDns, nextdns: NextDns,
profile_id: str, profile_id: str,
subentry_id: str,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
self.nextdns = nextdns self.nextdns = nextdns
self.profile_id = profile_id self.profile_id = profile_id
self.subentry_id = subentry_id
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry, config_entry=config_entry,
name=DOMAIN, name=f"{DOMAIN}_{subentry_id}",
update_interval=self._update_interval, update_interval=self._update_interval,
) )
+15 -12
View File
@@ -17,19 +17,22 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: NextDnsConfigEntry hass: HomeAssistant, config_entry: NextDnsConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
dnssec_coordinator = config_entry.runtime_data.dnssec profiles_data: list[dict[str, Any]] = []
encryption_coordinator = config_entry.runtime_data.encryption for subentry_id, profile_data in config_entry.runtime_data.profiles.items():
ip_versions_coordinator = config_entry.runtime_data.ip_versions subentry = config_entry.subentries[subentry_id]
protocols_coordinator = config_entry.runtime_data.protocols profiles_data.append(
settings_coordinator = config_entry.runtime_data.settings {
status_coordinator = config_entry.runtime_data.status "subentry_title": subentry.title,
"dnssec_coordinator_data": asdict(profile_data.dnssec.data),
"encryption_coordinator_data": asdict(profile_data.encryption.data),
"ip_versions_coordinator_data": asdict(profile_data.ip_versions.data),
"protocols_coordinator_data": asdict(profile_data.protocols.data),
"settings_coordinator_data": asdict(profile_data.settings.data),
"status_coordinator_data": asdict(profile_data.status.data),
}
)
return { return {
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"dnssec_coordinator_data": asdict(dnssec_coordinator.data), "profiles": profiles_data,
"encryption_coordinator_data": asdict(encryption_coordinator.data),
"ip_versions_coordinator_data": asdict(ip_versions_coordinator.data),
"protocols_coordinator_data": asdict(protocols_coordinator.data),
"settings_coordinator_data": asdict(settings_coordinator.data),
"status_coordinator_data": asdict(status_coordinator.data),
} }
+3 -2
View File
@@ -24,12 +24,13 @@ class NextDnsEntity[CoordinatorDataT: NextDnsData](
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
super().__init__(coordinator) super().__init__(coordinator)
subentry = coordinator.config_entry.subentries[coordinator.subentry_id]
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup", configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(coordinator.profile_id))}, identifiers={(DOMAIN, coordinator.profile_id)},
manufacturer="NextDNS Inc.", manufacturer="NextDNS Inc.",
name=coordinator.nextdns.get_profile_name(coordinator.profile_id), name=subentry.title,
) )
self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" self._attr_unique_id = f"{coordinator.profile_id}_{description.key}"
self.entity_description = description self.entity_description = description
+9 -5
View File
@@ -287,12 +287,16 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Add a NextDNS entities from a config_entry.""" """Add a NextDNS entities from a config_entry."""
async_add_entities( for subentry_id, profile_data in entry.runtime_data.profiles.items():
NextDnsSensor( async_add_entities(
getattr(entry.runtime_data, description.coordinator_type), description (
NextDnsSensor(
getattr(profile_data, description.coordinator_type), description
)
for description in SENSORS
),
config_subentry_id=subentry_id,
) )
for description in SENSORS
)
class NextDnsSensor[CoordinatorDataT: NextDnsData]( class NextDnsSensor[CoordinatorDataT: NextDnsData](
+29 -3
View File
@@ -1,7 +1,8 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "This NextDNS profile is already configured.", "all_profiles_configured": "All NextDNS profiles are already configured.",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.", "profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
@@ -14,10 +15,10 @@
"step": { "step": {
"profiles": { "profiles": {
"data": { "data": {
"profile_name": "Profile" "profile_id": "Profile"
}, },
"data_description": { "data_description": {
"profile_name": "The NextDNS configuration profile you want to integrate" "profile_id": "The NextDNS configuration profile you want to integrate"
} }
}, },
"reauth_confirm": { "reauth_confirm": {
@@ -46,6 +47,31 @@
} }
} }
}, },
"config_subentries": {
"profile": {
"abort": {
"all_profiles_configured": "[%key:component::nextdns::config::abort::all_profiles_configured%]",
"already_configured": "This NextDNS profile is already configured.",
"entry_not_loaded": "The NextDNS configuration entry is not loaded. Please ensure it is set up correctly before adding a profile."
},
"entry_type": "NextDNS profile",
"initiate_flow": {
"user": "Add profile"
},
"step": {
"user": {
"data": {
"profile_id": "[%key:component::nextdns::config::step::profiles::data::profile_id%]"
},
"data_description": {
"profile_id": "[%key:component::nextdns::config::step::profiles::data_description::profile_id%]"
},
"description": "Select the NextDNS profile you want to add.",
"title": "Add NextDNS profile"
}
}
}
},
"entity": { "entity": {
"binary_sensor": { "binary_sensor": {
"device_connection_status": { "device_connection_status": {
+6 -5
View File
@@ -533,11 +533,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Add NextDNS entities from a config_entry.""" """Add NextDNS entities from a config_entry."""
coordinator = entry.runtime_data.settings for subentry_id, profile_data in entry.runtime_data.profiles.items():
coordinator = profile_data.settings
async_add_entities( async_add_entities(
NextDnsSwitch(coordinator, description) for description in SWITCHES (NextDnsSwitch(coordinator, description) for description in SWITCHES),
) config_subentry_id=subentry_id,
)
class NextDnsSwitch(NextDnsEntity, SwitchEntity): class NextDnsSwitch(NextDnsEntity, SwitchEntity):
+39 -3
View File
@@ -15,7 +15,12 @@ from nextdns import (
) )
import pytest import pytest
from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN from homeassistant.components.nextdns.const import (
CONF_PROFILE_ID,
DOMAIN,
SUBENTRY_TYPE_PROFILE,
)
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from tests.common import ( from tests.common import (
@@ -47,14 +52,41 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture @pytest.fixture
def mock_config_entry() -> MockConfigEntry: def mock_subentries() -> list[ConfigSubentryData]:
"""Return a list of mock subentries."""
return [
ConfigSubentryData(
data={CONF_PROFILE_ID: "xyz12"},
subentry_type=SUBENTRY_TYPE_PROFILE,
title="Fake Profile",
unique_id="xyz12",
)
]
@pytest.fixture
def mock_config_entry(mock_subentries: list[ConfigSubentryData]) -> MockConfigEntry:
"""Return the default mocked config entry.""" """Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="NextDNS",
data={CONF_API_KEY: "fake_api_key"},
entry_id="d9aa37407ddac7b964a99e86312288d6",
version=2,
subentries_data=mock_subentries,
)
@pytest.fixture
def mock_config_entry_v1() -> MockConfigEntry:
"""Return a v1 mocked config entry for migration testing."""
return MockConfigEntry( return MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
title="Fake Profile", title="Fake Profile",
unique_id="xyz12", unique_id="xyz12",
data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"},
entry_id="d9aa37407ddac7b964a99e86312288d6", entry_id="d9aa37407ddac7b964a99e86312288d6",
version=1,
) )
@@ -80,7 +112,11 @@ def mock_nextdns_client(mock_nextdns: AsyncMock) -> AsyncMock:
client.get_analytics_protocols.return_value = ANALYTICS_PROTOCOLS client.get_analytics_protocols.return_value = ANALYTICS_PROTOCOLS
client.get_analytics_status.return_value = ANALYTICS_STATUS client.get_analytics_status.return_value = ANALYTICS_STATUS
client.get_profile_id = Mock(return_value="xyz12") client.get_profile_id = Mock(return_value="xyz12")
client.get_profile_name = Mock(return_value="Fake Profile") client.get_profile_name = Mock(
side_effect=lambda profile_id: next(
profile.name for profile in client.profiles if profile.id == profile_id
)
)
client.get_profiles.return_value = PROFILES client.get_profiles.return_value = PROFILES
client.get_settings.return_value = SETTINGS client.get_settings.return_value = SETTINGS
client.set_setting.return_value = True client.set_setting.return_value = True
@@ -4,13 +4,11 @@
'config_entry': dict({ 'config_entry': dict({
'data': dict({ 'data': dict({
'api_key': '**REDACTED**', 'api_key': '**REDACTED**',
'profile_id': '**REDACTED**',
}), }),
'disabled_by': None, 'disabled_by': None,
'discovery_keys': dict({ 'discovery_keys': dict({
}), }),
'domain': 'nextdns', 'domain': 'nextdns',
'entry_id': 'd9aa37407ddac7b964a99e86312288d6',
'minor_version': 1, 'minor_version': 1,
'options': dict({ 'options': dict({
}), }),
@@ -18,125 +16,138 @@
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'subentries': list([ 'subentries': list([
dict({
'data': dict({
'profile_id': '**REDACTED**',
}),
'subentry_type': 'profile',
'title': 'Fake Profile',
'unique_id': '**REDACTED**',
}),
]), ]),
'title': 'Fake Profile', 'title': 'NextDNS',
'unique_id': '**REDACTED**', 'unique_id': None,
'version': 1, 'version': 2,
}),
'dnssec_coordinator_data': dict({
'not_validated_queries': 25,
'validated_queries': 75,
'validated_queries_ratio': 75.0,
}),
'encryption_coordinator_data': dict({
'encrypted_queries': 60,
'encrypted_queries_ratio': 60.0,
'unencrypted_queries': 40,
}),
'ip_versions_coordinator_data': dict({
'ipv4_queries': 90,
'ipv6_queries': 10,
'ipv6_queries_ratio': 10.0,
}),
'protocols_coordinator_data': dict({
'doh3_queries': 15,
'doh3_queries_ratio': 13.0,
'doh_queries': 20,
'doh_queries_ratio': 17.4,
'doq_queries': 10,
'doq_queries_ratio': 8.7,
'dot_queries': 30,
'dot_queries_ratio': 26.1,
'tcp_queries': 0,
'tcp_queries_ratio': 0.0,
'udp_queries': 40,
'udp_queries_ratio': 34.8,
}),
'settings_coordinator_data': dict({
'ai_threat_detection': True,
'allow_affiliate': True,
'anonymized_ecs': True,
'bav': True,
'block_9gag': True,
'block_amazon': True,
'block_bereal': True,
'block_blizzard': True,
'block_bypass_methods': True,
'block_chatgpt': True,
'block_csam': True,
'block_dailymotion': True,
'block_dating': True,
'block_ddns': True,
'block_discord': True,
'block_disguised_trackers': True,
'block_disneyplus': True,
'block_ebay': True,
'block_facebook': True,
'block_fortnite': True,
'block_gambling': True,
'block_google_chat': True,
'block_hbomax': True,
'block_hulu': True,
'block_imgur': True,
'block_instagram': True,
'block_leagueoflegends': True,
'block_mastodon': True,
'block_messenger': True,
'block_minecraft': True,
'block_netflix': True,
'block_nrd': True,
'block_online_gaming': True,
'block_page': False,
'block_parked_domains': True,
'block_pinterest': True,
'block_piracy': True,
'block_playstation_network': True,
'block_porn': True,
'block_primevideo': True,
'block_reddit': True,
'block_roblox': True,
'block_signal': True,
'block_skype': True,
'block_snapchat': True,
'block_social_networks': True,
'block_spotify': True,
'block_steam': True,
'block_telegram': True,
'block_tiktok': True,
'block_tinder': True,
'block_tumblr': True,
'block_twitch': True,
'block_twitter': True,
'block_video_streaming': True,
'block_vimeo': True,
'block_vk': True,
'block_whatsapp': True,
'block_xboxlive': True,
'block_youtube': True,
'block_zoom': True,
'cache_boost': True,
'cname_flattening': True,
'cryptojacking_protection': True,
'dga_protection': True,
'dns_rebinding_protection': True,
'google_safe_browsing': False,
'idn_homograph_attacks_protection': True,
'logs': True,
'logs_location': 'ch',
'logs_retention': 720,
'safesearch': False,
'threat_intelligence_feeds': True,
'typosquatting_protection': True,
'web3': True,
'youtube_restricted_mode': False,
}),
'status_coordinator_data': dict({
'all_queries': 100,
'allowed_queries': 30,
'blocked_queries': 20,
'blocked_queries_ratio': 20.0,
'default_queries': 40,
'relayed_queries': 10,
}), }),
'profiles': list([
dict({
'dnssec_coordinator_data': dict({
'not_validated_queries': 25,
'validated_queries': 75,
'validated_queries_ratio': 75.0,
}),
'encryption_coordinator_data': dict({
'encrypted_queries': 60,
'encrypted_queries_ratio': 60.0,
'unencrypted_queries': 40,
}),
'ip_versions_coordinator_data': dict({
'ipv4_queries': 90,
'ipv6_queries': 10,
'ipv6_queries_ratio': 10.0,
}),
'protocols_coordinator_data': dict({
'doh3_queries': 15,
'doh3_queries_ratio': 13.0,
'doh_queries': 20,
'doh_queries_ratio': 17.4,
'doq_queries': 10,
'doq_queries_ratio': 8.7,
'dot_queries': 30,
'dot_queries_ratio': 26.1,
'tcp_queries': 0,
'tcp_queries_ratio': 0.0,
'udp_queries': 40,
'udp_queries_ratio': 34.8,
}),
'settings_coordinator_data': dict({
'ai_threat_detection': True,
'allow_affiliate': True,
'anonymized_ecs': True,
'bav': True,
'block_9gag': True,
'block_amazon': True,
'block_bereal': True,
'block_blizzard': True,
'block_bypass_methods': True,
'block_chatgpt': True,
'block_csam': True,
'block_dailymotion': True,
'block_dating': True,
'block_ddns': True,
'block_discord': True,
'block_disguised_trackers': True,
'block_disneyplus': True,
'block_ebay': True,
'block_facebook': True,
'block_fortnite': True,
'block_gambling': True,
'block_google_chat': True,
'block_hbomax': True,
'block_hulu': True,
'block_imgur': True,
'block_instagram': True,
'block_leagueoflegends': True,
'block_mastodon': True,
'block_messenger': True,
'block_minecraft': True,
'block_netflix': True,
'block_nrd': True,
'block_online_gaming': True,
'block_page': False,
'block_parked_domains': True,
'block_pinterest': True,
'block_piracy': True,
'block_playstation_network': True,
'block_porn': True,
'block_primevideo': True,
'block_reddit': True,
'block_roblox': True,
'block_signal': True,
'block_skype': True,
'block_snapchat': True,
'block_social_networks': True,
'block_spotify': True,
'block_steam': True,
'block_telegram': True,
'block_tiktok': True,
'block_tinder': True,
'block_tumblr': True,
'block_twitch': True,
'block_twitter': True,
'block_video_streaming': True,
'block_vimeo': True,
'block_vk': True,
'block_whatsapp': True,
'block_xboxlive': True,
'block_youtube': True,
'block_zoom': True,
'cache_boost': True,
'cname_flattening': True,
'cryptojacking_protection': True,
'dga_protection': True,
'dns_rebinding_protection': True,
'google_safe_browsing': False,
'idn_homograph_attacks_protection': True,
'logs': True,
'logs_location': 'ch',
'logs_retention': 720,
'safesearch': False,
'threat_intelligence_feeds': True,
'typosquatting_protection': True,
'web3': True,
'youtube_restricted_mode': False,
}),
'status_coordinator_data': dict({
'all_queries': 100,
'allowed_queries': 30,
'blocked_queries': 20,
'blocked_queries_ratio': 20.0,
'default_queries': 40,
'relayed_queries': 10,
}),
'subentry_title': 'Fake Profile',
}),
]),
}) })
# --- # ---
+142 -17
View File
@@ -1,14 +1,19 @@
"""Define tests for the NextDNS config flow.""" """Define tests for the NextDNS config flow."""
from types import MappingProxyType
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from nextdns import ApiError, InvalidApiKeyError, ProfileInfo from nextdns import ApiError, InvalidApiKeyError, ProfileInfo
import pytest import pytest
from tenacity import RetryError from tenacity import RetryError
from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN from homeassistant.components.nextdns.const import (
from homeassistant.config_entries import SOURCE_USER CONF_PROFILE_ID,
from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME DOMAIN,
SUBENTRY_TYPE_PROFILE,
)
from homeassistant.config_entries import SOURCE_USER, ConfigSubentry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@@ -40,14 +45,17 @@ async def test_form_create_entry(
assert result["step_id"] == "profiles" assert result["step_id"] == "profiles"
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} result["flow_id"], {CONF_PROFILE_ID: "xyz12"}
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Fake Profile" assert result["title"] == "NextDNS"
assert result["data"][CONF_API_KEY] == "fake_api_key" assert result["data"][CONF_API_KEY] == "fake_api_key"
assert result["data"][CONF_PROFILE_ID] == "xyz12" assert len(result["subentries"]) == 1
assert result["result"].unique_id == "xyz12" subentry = result["subentries"][0]
assert subentry["subentry_type"] == SUBENTRY_TYPE_PROFILE
assert subentry["title"] == "Fake Profile"
assert subentry["data"][CONF_PROFILE_ID] == "xyz12"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@@ -97,14 +105,13 @@ async def test_form_errors(
assert result["step_id"] == "profiles" assert result["step_id"] == "profiles"
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} result["flow_id"], {CONF_PROFILE_ID: "xyz12"}
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Fake Profile" assert result["title"] == "NextDNS"
assert result["data"][CONF_API_KEY] == "fake_api_key" assert result["data"][CONF_API_KEY] == "fake_api_key"
assert result["data"][CONF_PROFILE_ID] == "xyz12" assert len(result["subentries"]) == 1
assert result["result"].unique_id == "xyz12"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@@ -114,22 +121,20 @@ async def test_form_already_configured(
mock_nextdns_client: AsyncMock, mock_nextdns_client: AsyncMock,
mock_nextdns: AsyncMock, mock_nextdns: AsyncMock,
) -> None: ) -> None:
"""Test that errors are shown when duplicates are added.""" """Test that the flow aborts when API key is already configured."""
await init_integration(hass, mock_config_entry) await init_integration(hass, mock_config_entry)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{CONF_API_KEY: "fake_api_key"}, {CONF_API_KEY: "fake_api_key"},
) )
result = await hass.config_entries.flow.async_configure( # When a config entry with the same API key exists, the flow aborts
result["flow_id"], {CONF_PROFILE_NAME: "Fake Profile"} # Users should add profiles via the subentry flow
)
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
@@ -324,3 +329,123 @@ async def test_reconfigure_flow_no_profile(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "profile_not_available" assert result["reason"] == "profile_not_available"
async def test_subentry_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nextdns_client: AsyncMock,
) -> None:
"""Test creating a profile subentry."""
# Add a second profile to the client
mock_nextdns_client.profiles = [
ProfileInfo(id="xyz12", fingerprint="xyz12", name="Fake Profile"),
ProfileInfo(id="abc34", fingerprint="abc34", name="Second Profile"),
]
await init_integration(hass, mock_config_entry)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE),
context={"source": "user"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_PROFILE_ID: "abc34"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Second Profile"
assert result["data"][CONF_PROFILE_ID] == "abc34"
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert len(entry.subentries) == 2
async def test_subentry_flow_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nextdns_client: AsyncMock,
) -> None:
"""Test subentry flow when the profile gets configured between form display and submit."""
# Add a second and third profile so the flow doesn't abort immediately
second_profile = ProfileInfo(
id="abc34", fingerprint="xyz789", name="Second Profile"
)
third_profile = ProfileInfo(id="def56", fingerprint="uvw456", name="Third Profile")
mock_nextdns_client.profiles = [
*mock_nextdns_client.profiles,
second_profile,
third_profile,
]
await init_integration(hass, mock_config_entry)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE),
context={"source": "user"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# Simulate a race condition where the second profile gets configured
# between showing the form and submitting it
hass.config_entries.async_add_subentry(
mock_config_entry,
ConfigSubentry(
data=MappingProxyType({CONF_PROFILE_ID: "abc34"}),
subentry_type=SUBENTRY_TYPE_PROFILE,
title="Second Profile",
unique_id="abc34",
),
)
await hass.async_block_till_done()
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_PROFILE_ID: "abc34"},
)
# Abort flow when a profile is already configured between form display and submit
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_subentry_flow_all_profiles_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nextdns_client: AsyncMock,
) -> None:
"""Test subentry flow when all profiles are already configured."""
await init_integration(hass, mock_config_entry)
# Only one profile available and it's already configured
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE),
context={"source": "user"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "all_profiles_configured"
async def test_subentry_flow_entry_not_loaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test subentry flow when the entry is not loaded."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, SUBENTRY_TYPE_PROFILE),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "entry_not_loaded"
+1 -1
View File
@@ -26,4 +26,4 @@ async def test_entry_diagnostics(
assert await get_diagnostics_for_config_entry( assert await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry hass, hass_client, mock_config_entry
) == snapshot(exclude=props("created_at", "modified_at")) ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id"))
+249 -4
View File
@@ -6,10 +6,19 @@ from nextdns import ApiError, InvalidApiKeyError
import pytest import pytest
from tenacity import RetryError from tenacity import RetryError
from homeassistant.components.nextdns.const import DOMAIN from homeassistant.components.nextdns.const import (
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState CONF_PROFILE_ID,
from homeassistant.const import STATE_UNAVAILABLE DOMAIN,
SUBENTRY_TYPE_PROFILE,
)
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntryDisabler,
ConfigEntryState,
)
from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration from . import init_integration
@@ -63,7 +72,6 @@ async def test_unload_entry(
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
async def test_config_auth_failed( async def test_config_auth_failed(
@@ -88,3 +96,240 @@ async def test_config_auth_failed(
assert "context" in flow assert "context" in flow
assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == mock_config_entry.entry_id assert flow["context"].get("entry_id") == mock_config_entry.entry_id
async def test_migrate_entry_v1_to_v2(
hass: HomeAssistant,
mock_config_entry_v1: MockConfigEntry,
mock_nextdns_client: AsyncMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 1 to version 2."""
# Create old device and entity with old-style identifiers before migration
mock_config_entry_v1.add_to_hass(hass)
device_registry.async_get_or_create(
config_entry_id=mock_config_entry_v1.entry_id,
identifiers={(DOMAIN, "xyz12")},
manufacturer="NextDNS Inc.",
name="Fake Profile",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"sensor",
DOMAIN,
"xyz12_dns_queries",
config_entry=mock_config_entry_v1,
)
await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
await hass.async_block_till_done()
# Verify migration was successful
assert mock_config_entry_v1.version == 2
assert mock_config_entry_v1.title == "NextDNS"
assert mock_config_entry_v1.state is ConfigEntryState.LOADED
# Verify data was migrated correctly
assert CONF_PROFILE_ID not in mock_config_entry_v1.data
assert mock_config_entry_v1.data[CONF_API_KEY] == "fake_api_key"
# Verify subentry was created
assert len(mock_config_entry_v1.subentries) == 1
subentry = list(mock_config_entry_v1.subentries.values())[0]
assert subentry.subentry_type == SUBENTRY_TYPE_PROFILE
assert subentry.title == "Fake Profile"
assert subentry.data[CONF_PROFILE_ID] == "xyz12"
assert subentry.unique_id == "xyz12"
# Verify device was migrated and linked to subentry
device = device_registry.async_get_device(identifiers={(DOMAIN, "xyz12")})
assert device is not None
assert device.config_entries_subentries == {
mock_config_entry_v1.entry_id: {subentry.subentry_id}
}
# Verify entity was migrated and linked to subentry
entity_entry = entity_registry.async_get("sensor.nextdns_xyz12_dns_queries")
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry_v1.entry_id
assert entity_entry.config_subentry_id == subentry.subentry_id
async def test_migrate_entry_v1_to_v2_merge_same_api_key(
hass: HomeAssistant,
mock_nextdns_client: AsyncMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration merges v1 entries with the same API key."""
entry1 = MockConfigEntry(
domain=DOMAIN,
title="Profile One",
unique_id="abc11",
data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "abc11"},
entry_id="entry1_id",
version=1,
)
entry2 = MockConfigEntry(
domain=DOMAIN,
title="Profile Two",
unique_id="def22",
data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "def22"},
entry_id="entry2_id",
version=1,
)
entry1.add_to_hass(hass)
entry2.add_to_hass(hass)
# Create old devices with old-style identifiers
device_registry.async_get_or_create(
config_entry_id=entry1.entry_id,
identifiers={(DOMAIN, "abc11")},
manufacturer="NextDNS Inc.",
name="Profile One",
entry_type=dr.DeviceEntryType.SERVICE,
)
device_registry.async_get_or_create(
config_entry_id=entry2.entry_id,
identifiers={(DOMAIN, "def22")},
manufacturer="NextDNS Inc.",
name="Profile Two",
entry_type=dr.DeviceEntryType.SERVICE,
)
# Create old entities for both entries to verify they are migrated
entity_registry.async_get_or_create(
"sensor",
DOMAIN,
"profile_one_dns_queries",
config_entry=entry1,
)
entity_registry.async_get_or_create(
"sensor",
DOMAIN,
"profile_two_dns_queries",
config_entry=entry2,
)
await hass.config_entries.async_setup(entry1.entry_id)
await hass.async_block_till_done()
# Verify entry1 was migrated and is loaded
assert entry1.version == 2
assert entry1.title == "NextDNS"
assert entry1.state is ConfigEntryState.LOADED
assert CONF_PROFILE_ID not in entry1.data
assert entry1.data[CONF_API_KEY] == "fake_api_key"
# Verify entry2 was removed
assert hass.config_entries.async_get_entry(entry2.entry_id) is None
# Verify entry1 has two subentries (both profiles merged)
assert len(entry1.subentries) == 2
subentries = list(entry1.subentries.values())
profile_ids = {s.data[CONF_PROFILE_ID] for s in subentries}
assert profile_ids == {"abc11", "def22"}
titles = {s.title for s in subentries}
assert titles == {"Profile One", "Profile Two"}
# Verify devices were migrated to entry1 with existing identifiers
device_abc = device_registry.async_get_device(identifiers={(DOMAIN, "abc11")})
assert device_abc is not None
assert entry1.entry_id in device_abc.config_entries
device_def = device_registry.async_get_device(identifiers={(DOMAIN, "def22")})
assert device_def is not None
assert entry1.entry_id in device_def.config_entries
# Verify entities from both entries were migrated to entry1
entity_entry_1 = entity_registry.async_get("sensor.nextdns_profile_one_dns_queries")
assert entity_entry_1 is not None
assert entity_entry_1.config_entry_id == entry1.entry_id
assert entity_entry_1.config_subentry_id is not None
entity_entry_2 = entity_registry.async_get("sensor.nextdns_profile_two_dns_queries")
assert entity_entry_2 is not None
assert entity_entry_2.config_entry_id == entry1.entry_id
assert entity_entry_2.config_subentry_id is not None
async def test_migrate_entry_v1_to_v2_disabled_entry(
hass: HomeAssistant,
mock_nextdns_client: AsyncMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration updates disabled_by when merging disabled and enabled entries."""
entry1 = MockConfigEntry(
domain=DOMAIN,
title="Profile One",
unique_id="abc11",
data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "abc11"},
entry_id="entry1_id",
version=1,
)
entry2 = MockConfigEntry(
domain=DOMAIN,
title="Profile Two",
unique_id="def22",
data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "def22"},
entry_id="entry2_id",
version=1,
disabled_by=ConfigEntryDisabler.USER,
)
entry1.add_to_hass(hass)
entry2.add_to_hass(hass)
# Create device and entity for disabled entry2 with CONFIG_ENTRY disabled_by
device_registry.async_get_or_create(
config_entry_id=entry1.entry_id,
identifiers={(DOMAIN, "abc11")},
manufacturer="NextDNS Inc.",
name="Profile One",
entry_type=dr.DeviceEntryType.SERVICE,
)
device2 = device_registry.async_get_or_create(
config_entry_id=entry2.entry_id,
identifiers={(DOMAIN, "def22")},
manufacturer="NextDNS Inc.",
name="Profile Two",
entry_type=dr.DeviceEntryType.SERVICE,
disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY,
)
entity_registry.async_get_or_create(
domain="sensor",
platform=DOMAIN,
unique_id="def22_all_queries",
suggested_object_id="profile_two_dns_queries",
config_entry=entry2,
device_id=device2.id,
disabled_by=er.RegistryEntryDisabler.CONFIG_ENTRY,
)
await hass.config_entries.async_setup(entry1.entry_id)
await hass.async_block_till_done()
# Verify entry1 was migrated and entry2 was removed
assert entry1.version == 2
assert entry1.state is ConfigEntryState.LOADED
assert hass.config_entries.async_get_entry(entry2.entry_id) is None
# Find the subentry for the disabled profile
subentry2 = next(
s for s in entry1.subentries.values() if s.data[CONF_PROFILE_ID] == "def22"
)
# Verify device disabled_by was changed from CONFIG_ENTRY to USER
device = device_registry.async_get_device(identifiers={(DOMAIN, "def22")})
assert device is not None
assert device.disabled_by is dr.DeviceEntryDisabler.USER
# Verify entity disabled_by was changed from CONFIG_ENTRY to DEVICE
entity_entry = entity_registry.async_get("sensor.profile_two_dns_queries")
assert entity_entry is not None
assert entity_entry.config_entry_id == entry1.entry_id
assert entity_entry.config_subentry_id == subentry2.subentry_id
assert entity_entry.disabled_by is er.RegistryEntryDisabler.DEVICE