mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
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:
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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](
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user