diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index ec5a9f033d2485..5d15f4730f87ee 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -1,23 +1,83 @@ """The DNS IP integration.""" +import asyncio +from dataclasses import dataclass + +import aiodns +from aiodns.error import DNSError + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + CONF_HOSTNAME, + CONF_IPV4, + CONF_IPV6, + CONF_PORT_IPV6, + CONF_RESOLVER, + CONF_RESOLVER_IPV6, + DEFAULT_PORT, + PLATFORMS, +) + -from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS +@dataclass +class DnsIPRuntimeData: + """Runtime data for DNS IP integration.""" + resolver_ipv4: aiodns.DNSResolver + resolver_ipv6: aiodns.DNSResolver -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool: """Set up DNS IP from a config entry.""" + nameserver_ipv4 = entry.options[CONF_RESOLVER] + nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6] + port_ipv4 = entry.options[CONF_PORT] + port_ipv6 = entry.options[CONF_PORT_IPV6] + + resolver_ipv4 = aiodns.DNSResolver( + nameservers=[nameserver_ipv4], tcp_port=port_ipv4, udp_port=port_ipv4 + ) + resolver_ipv6 = aiodns.DNSResolver( + nameservers=[nameserver_ipv6], tcp_port=port_ipv6, udp_port=port_ipv6 + ) + + hostname = entry.data[CONF_HOSTNAME] + try: + async with asyncio.timeout(10): + if entry.data[CONF_IPV4]: + await resolver_ipv4.query(hostname, "A") + elif entry.data[CONF_IPV6]: + await resolver_ipv6.query(hostname, "AAAA") + except (TimeoutError, DNSError) as err: + await resolver_ipv4.close() + await resolver_ipv6.close() + raise ConfigEntryNotReady(f"DNS lookup failed for {hostname}: {err}") from err + + entry.runtime_data = DnsIPRuntimeData( + resolver_ipv4=resolver_ipv4, + resolver_ipv6=resolver_ipv6, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool: """Unload DNS IP config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.resolver_ipv4.close() + await entry.runtime_data.resolver_ipv6.close() + return unload_ok async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index d700c89ab6f737..b5403c5929fdf6 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/dnsip", "integration_type": "service", "iot_class": "cloud_polling", + "quality_scale": "bronze", "requirements": ["aiodns==4.0.0"] } diff --git a/homeassistant/components/dnsip/quality_scale.yaml b/homeassistant/components/dnsip/quality_scale.yaml new file mode 100644 index 00000000000000..d50eaf18ca90fd --- /dev/null +++ b/homeassistant/components/dnsip/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not subscribe to events; the sensor polls via async_update. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: DNS resolution does not require authentication. + test-coverage: todo + devices: todo + diagnostics: todo + discovery-update-info: + status: exempt + comment: DNS resolvers do not support discovery. + discovery: + status: exempt + comment: DNS resolvers do not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: Integration does not represent physical devices. + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Integration does not represent physical devices. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Integration does not represent physical devices. + async-dependency: done + inject-websession: + status: exempt + comment: Integration uses aiodns directly; no shared HTTP websession applies. + strict-typing: todo diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index dd5a4f38aab370..b3816b9ba4a600 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -10,17 +10,16 @@ from aiodns.error import DNSError from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import DnsIPConfigEntry from .const import ( CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, - CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, DOMAIN, @@ -46,7 +45,7 @@ def sort_ips(ips: list, querytype: Literal["A", "AAAA"]) -> list: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DnsIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the dnsip sensor entry.""" @@ -54,16 +53,27 @@ async def async_setup_entry( hostname = entry.data[CONF_HOSTNAME] name = entry.data[CONF_NAME] - nameserver_ipv4 = entry.options[CONF_RESOLVER] - nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6] - port_ipv4 = entry.options[CONF_PORT] - port_ipv6 = entry.options[CONF_PORT_IPV6] - entities = [] if entry.data[CONF_IPV4]: - entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4)) + entities.append( + WanIpSensor( + name, + hostname, + entry.options[CONF_RESOLVER], + False, + entry.runtime_data.resolver_ipv4, + ) + ) if entry.data[CONF_IPV6]: - entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6)) + entities.append( + WanIpSensor( + name, + hostname, + entry.options[CONF_RESOLVER_IPV6], + True, + entry.runtime_data.resolver_ipv6, + ) + ) async_add_entities(entities, update_before_add=True) @@ -75,22 +85,19 @@ class WanIpSensor(SensorEntity): _attr_translation_key = "dnsip" _unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"}) - resolver: aiodns.DNSResolver - def __init__( self, name: str, hostname: str, nameserver: str, ipv6: bool, - port: int, + resolver: aiodns.DNSResolver, ) -> None: """Initialize the DNS IP sensor.""" self._attr_name = "IPv6" if ipv6 else None self._attr_unique_id = f"{hostname}_{ipv6}" self.hostname = hostname - self.port = port - self.nameserver = nameserver + self.resolver = resolver self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { @@ -104,28 +111,17 @@ def __init__( model=aiodns.__version__, name=name, ) - self.create_dns_resolver() - - def create_dns_resolver(self) -> None: - """Create the DNS resolver.""" - self.resolver = aiodns.DNSResolver( - nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port - ) async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" - if self.resolver._closed: # noqa: SLF001 - self.create_dns_resolver() response = None try: async with asyncio.timeout(10): response = await self.resolver.query(self.hostname, self.querytype) except TimeoutError as err: _LOGGER.debug("Timeout while resolving host: %s", err) - await self.resolver.close() except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) - await self.resolver.close() if response: sorted_ips = sort_ips( diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index a841cdffcde0b0..24da52f2182b41 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -9,11 +9,18 @@ "step": { "user": { "data": { - "hostname": "The hostname for which to perform the DNS query", - "port": "Port for IPV4 lookup", - "port_ipv6": "Port for IPV6 lookup", - "resolver": "Resolver for IPV4 lookup", - "resolver_ipv6": "Resolver for IPV6 lookup" + "hostname": "Hostname", + "port": "IPv4 port", + "port_ipv6": "IPv6 port", + "resolver": "IPv4 resolver", + "resolver_ipv6": "IPv6 resolver" + }, + "data_description": { + "hostname": "The hostname for which to perform the DNS query.", + "port": "Port used for the IPv4 lookup.", + "port_ipv6": "Port used for the IPv6 lookup.", + "resolver": "Resolver used for the IPv4 lookup.", + "resolver_ipv6": "Resolver used for the IPv6 lookup." } } } @@ -50,6 +57,12 @@ "port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]", "resolver": "[%key:component::dnsip::config::step::user::data::resolver%]", "resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]" + }, + "data_description": { + "port": "[%key:component::dnsip::config::step::user::data_description::port%]", + "port_ipv6": "[%key:component::dnsip::config::step::user::data_description::port_ipv6%]", + "resolver": "[%key:component::dnsip::config::step::user::data_description::resolver%]", + "resolver_ipv6": "[%key:component::dnsip::config::step::user::data_description::resolver_ipv6%]" } } } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index dd88ac6703520f..3e7e32aef2d65c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -282,7 +282,6 @@ class Rule: "dlink", "dlna_dmr", "dlna_dms", - "dnsip", "dominos", "doods", "doorbird", @@ -1253,7 +1252,6 @@ class Rule: "dlink", "dlna_dmr", "dlna_dms", - "dnsip", "dominos", "doods", "doorbird", diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 1181c391ca2f52..bce9da3521dad7 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import patch +from aiodns.error import DNSError + from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, CONF_IPV4, @@ -44,7 +46,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -82,7 +84,7 @@ async def test_port_migration( entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -123,7 +125,7 @@ async def test_remove_unique_id_migration( entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -160,7 +162,7 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -168,3 +170,69 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: entry = hass.config_entries.async_get_entry(entry.entry_id) assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_setup_dns_error(hass: HomeAssistant) -> None: + """Test setup raises ConfigEntryNotReady when DNS lookup fails.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + entry_id="1", + unique_id="home-assistant.io", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.aiodns.DNSResolver", + return_value=RetrieveDNS(error=DNSError()), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_ipv6_only(hass: HomeAssistant) -> None: + """Test setup with only IPv6 enabled exercises the IPv6 lookup branch.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: False, + CONF_IPV6: True, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + entry_id="1", + unique_id="home-assistant.io", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index b7dae634765112..34b1d7859766d3 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -48,7 +48,7 @@ async def test_sensor(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -91,7 +91,7 @@ async def test_legacy_sensor(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -137,7 +137,7 @@ async def test_sensor_no_response( dns_mock = RetrieveDNS() with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=dns_mock, ): await hass.config_entries.async_setup(entry.entry_id) @@ -149,7 +149,7 @@ async def test_sensor_no_response( dns_mock.error = DNSError() with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=dns_mock, ): freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) @@ -197,7 +197,7 @@ async def test_sensor_timeout( dns_mock = RetrieveDNS() with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=dns_mock, ): await hass.config_entries.async_setup(entry.entry_id) @@ -209,7 +209,7 @@ async def test_sensor_timeout( with ( patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=dns_mock, ), patch(