From 4d3405340d4abfd4e3e0341c668aef8a3278d332 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:21:01 -0500 Subject: [PATCH 01/26] Fix dashboard dns lookup delay --- esphome/__main__.py | 22 ++++++ esphome/address_cache.py | 131 +++++++++++++++++++++++++++++++ esphome/core/__init__.py | 3 + esphome/dashboard/dns.py | 11 +++ esphome/dashboard/status/mdns.py | 16 ++++ esphome/dashboard/web_server.py | 70 ++++++++++------- esphome/espota2.py | 6 +- esphome/helpers.py | 88 ++++++++++++++------- tests/unit_tests/test_helpers.py | 87 ++++++++++++++++++++ tests/unit_tests/test_main.py | 71 +++++++++++++++++ 10 files changed, 448 insertions(+), 57 deletions(-) create mode 100644 esphome/address_cache.py diff --git a/esphome/__main__.py b/esphome/__main__.py index bba254436e..15c29e6cdf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -889,6 +889,18 @@ def parse_args(argv): help="Add a substitution", metavar=("key", "value"), ) + options_parser.add_argument( + "--mdns-lookup-cache", + help="mDNS lookup cache mapping in format 'hostname=ip1,ip2'", + action="append", + default=[], + ) + options_parser.add_argument( + "--dns-lookup-cache", + help="DNS lookup cache mapping in format 'hostname=ip1,ip2'", + action="append", + default=[], + ) parser = argparse.ArgumentParser( description=f"ESPHome {const.__version__}", parents=[options_parser] @@ -1136,9 +1148,19 @@ def parse_args(argv): def run_esphome(argv): + from esphome.address_cache import AddressCache + args = parse_args(argv) CORE.dashboard = args.dashboard + # Create address cache from command-line arguments + address_cache = AddressCache.from_cli_args( + args.mdns_lookup_cache, args.dns_lookup_cache + ) + + # Store cache in CORE for access throughout the application + CORE.address_cache = address_cache + # Override log level if verbose is set if args.verbose: args.log_level = "DEBUG" diff --git a/esphome/address_cache.py b/esphome/address_cache.py new file mode 100644 index 0000000000..6e5881716d --- /dev/null +++ b/esphome/address_cache.py @@ -0,0 +1,131 @@ +"""Address cache for DNS and mDNS lookups.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +_LOGGER = logging.getLogger(__name__) + + +def normalize_hostname(hostname: str) -> str: + """Normalize hostname for cache lookups. + + Removes trailing dots and converts to lowercase. + """ + return hostname.rstrip(".").lower() + + +class AddressCache: + """Cache for DNS and mDNS address lookups. + + This cache stores pre-resolved addresses from command-line arguments + to avoid slow DNS/mDNS lookups during builds. + """ + + def __init__( + self, + mdns_cache: dict[str, list[str]] | None = None, + dns_cache: dict[str, list[str]] | None = None, + ) -> None: + """Initialize the address cache. + + Args: + mdns_cache: Pre-populated mDNS addresses (hostname -> IPs) + dns_cache: Pre-populated DNS addresses (hostname -> IPs) + """ + self.mdns_cache = mdns_cache or {} + self.dns_cache = dns_cache or {} + + def get_mdns_addresses(self, hostname: str) -> list[str] | None: + """Get cached mDNS addresses for a hostname. + + Args: + hostname: The hostname to look up (should end with .local) + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if addresses := self.mdns_cache.get(normalized): + _LOGGER.debug("Using mDNS cache for %s: %s", hostname, addresses) + return addresses + return None + + def get_dns_addresses(self, hostname: str) -> list[str] | None: + """Get cached DNS addresses for a hostname. + + Args: + hostname: The hostname to look up + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if addresses := self.dns_cache.get(normalized): + _LOGGER.debug("Using DNS cache for %s: %s", hostname, addresses) + return addresses + return None + + def get_addresses(self, hostname: str) -> list[str] | None: + """Get cached addresses for a hostname. + + Checks mDNS cache for .local domains, DNS cache otherwise. + + Args: + hostname: The hostname to look up + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if normalized.endswith(".local"): + return self.get_mdns_addresses(hostname) + return self.get_dns_addresses(hostname) + + def has_cache(self) -> bool: + """Check if any cache entries exist.""" + return bool(self.mdns_cache or self.dns_cache) + + @classmethod + def from_cli_args( + cls, mdns_args: Iterable[str], dns_args: Iterable[str] + ) -> AddressCache: + """Create cache from command-line arguments. + + Args: + mdns_args: List of mDNS cache entries like ['host=ip1,ip2'] + dns_args: List of DNS cache entries like ['host=ip1,ip2'] + + Returns: + Configured AddressCache instance + """ + mdns_cache = cls._parse_cache_args(mdns_args) + dns_cache = cls._parse_cache_args(dns_args) + return cls(mdns_cache=mdns_cache, dns_cache=dns_cache) + + @staticmethod + def _parse_cache_args(cache_args: Iterable[str]) -> dict[str, list[str]]: + """Parse cache arguments into a dictionary. + + Args: + cache_args: List of cache mappings like ['host1=ip1,ip2', 'host2=ip3'] + + Returns: + Dictionary mapping normalized hostnames to list of IP addresses + """ + cache: dict[str, list[str]] = {} + for arg in cache_args: + if "=" not in arg: + _LOGGER.warning( + "Invalid cache format: %s (expected 'hostname=ip1,ip2')", arg + ) + continue + hostname, ips = arg.split("=", 1) + # Normalize hostname for consistent lookups + normalized = normalize_hostname(hostname) + cache[normalized] = [ip.strip() for ip in ips.split(",")] + return cache diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 89e3eff7d8..0d4ddf56d4 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -583,6 +583,8 @@ class EsphomeCore: self.id_classes = {} # The current component being processed during validation self.current_component: str | None = None + # Address cache for DNS and mDNS lookups from command line arguments + self.address_cache: object | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -610,6 +612,7 @@ class EsphomeCore: self.platform_counts = defaultdict(int) self.unique_ids = {} self.current_component = None + self.address_cache = None PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index 98134062f4..4f1ef71dd0 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -28,6 +28,17 @@ class DNSCache: self._cache: dict[str, tuple[float, list[str] | Exception]] = {} self._ttl = ttl + def get_cached(self, hostname: str, now_monotonic: float) -> list[str] | None: + """Get cached address without triggering resolution. + + Returns None if not in cache, list of addresses if found. + """ + if expire_time_addresses := self._cache.get(hostname): + expire_time, addresses = expire_time_addresses + if expire_time > now_monotonic and not isinstance(addresses, Exception): + return addresses + return None + async def async_resolve( self, hostname: str, now_monotonic: float ) -> list[str] | Exception: diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index f9ac7b4289..0977a89c3a 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -50,6 +50,22 @@ class MDNSStatus: return await aiozc.async_resolve_host(host_name) return None + def get_cached_addresses(self, host_name: str) -> list[str] | None: + """Get cached addresses for a host without triggering resolution. + + Returns None if not in cache or no zeroconf available. + """ + if not self.aiozc: + return None + + from zeroconf import AddressResolver, IPVersion + + # Try to load from zeroconf cache without triggering resolution + info = AddressResolver(f"{host_name.partition('.')[0]}.local.") + if info.load_from_cache(self.aiozc.zeroconf): + return info.parsed_scoped_addresses(IPVersion.All) + return None + async def async_refresh_hosts(self) -> None: """Refresh the hosts to track.""" dashboard = self.dashboard diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 294a180794..767144fd19 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -326,52 +326,64 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] + + # Only get cached addresses - no async resolution addresses: list[str] = [] + cache_args: list[str] = [] + if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - # First priority: entry.address AKA use_address - if ( - (use_address := entry.address) - and ( - address_list := await dashboard.dns_cache.async_resolve( - use_address, time.monotonic() - ) - ) - and not isinstance(address_list, Exception) - ): - addresses.extend(sort_ip_addresses(address_list)) + now = time.monotonic() - # Second priority: mDNS - if ( - (mdns := dashboard.mdns_status) - and (address_list := await mdns.async_resolve_host(entry.name)) - and ( - new_addresses := [ - addr for addr in address_list if addr not in addresses - ] - ) + # Collect all cached addresses for this device + dns_cache_entries: dict[str, set[str]] = {} + mdns_cache_entries: dict[str, set[str]] = {} + + # First priority: entry.address AKA use_address (from DNS cache only) + if (use_address := entry.address) and ( + cached := dashboard.dns_cache.get_cached(use_address, now) ): - # Use the IP address if available but only - # if the API is loaded and the device is online - # since MQTT logging will not work otherwise - addresses.extend(sort_ip_addresses(new_addresses)) + addresses.extend(sort_ip_addresses(cached)) + dns_cache_entries[use_address] = set(cached) + + # Second priority: mDNS cache for device name + if entry.name and not addresses: # Only if we don't have addresses yet + if entry.name.endswith(".local"): + # Check mDNS cache (zeroconf) + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(entry.name) + ): + addresses.extend(sort_ip_addresses(cached)) + mdns_cache_entries[entry.name] = set(cached) + # Check DNS cache for non-.local names + elif cached := dashboard.dns_cache.get_cached(entry.name, now): + addresses.extend(sort_ip_addresses(cached)) + dns_cache_entries[entry.name] = set(cached) + + # Build cache arguments to pass to CLI + for hostname, addrs in dns_cache_entries.items(): + cache_args.extend( + ["--dns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ) + for hostname, addrs in mdns_cache_entries.items(): + cache_args.extend( + ["--mdns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ) if not addresses: - # If no address was found, use the port directly - # as otherwise they will get the chooser which - # does not work with the dashboard as there is no - # interactive way to get keyboard input + # If no cached address was found, use the port directly + # The CLI will do the resolution with the cache hints we provide addresses = [port] device_args: list[str] = [ arg for address in addresses for arg in ("--device", address) ] - return [*DASHBOARD_COMMAND, *args, config_file, *device_args] + return [*DASHBOARD_COMMAND, *args, config_file, *device_args, *cache_args] class EsphomeLogsHandler(EsphomePortCommandWebSocket): diff --git a/esphome/espota2.py b/esphome/espota2.py index 3d25af985b..f808d558d7 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -311,10 +311,14 @@ def perform_ota( def run_ota_impl_( remote_host: str | list[str], remote_port: int, password: str, filename: str ) -> tuple[int, str | None]: + from esphome.core import CORE + # Handle both single host and list of hosts try: # Resolve all hosts at once for parallel DNS resolution - res = resolve_ip_address(remote_host, remote_port) + res = resolve_ip_address( + remote_host, remote_port, address_cache=getattr(CORE, "address_cache", None) + ) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", diff --git a/esphome/helpers.py b/esphome/helpers.py index 6beaa24a96..f4b321b26f 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -173,7 +173,9 @@ def addr_preference_(res: AddrInfo) -> int: return 1 -def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: +def resolve_ip_address( + host: str | list[str], port: int, address_cache: object | None = None +) -> list[AddrInfo]: import socket # There are five cases here. The host argument could be one of: @@ -194,8 +196,9 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: hosts = [host] res: list[AddrInfo] = [] + + # Fast path: if all hosts are already IP addresses if all(is_ip_address(h) for h in hosts): - # Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST for addr in hosts: try: res += socket.getaddrinfo( @@ -207,34 +210,65 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: res.sort(key=addr_preference_) return res - from esphome.resolver import AsyncResolver + # Check if we have cached addresses for these hosts + cached_hosts: list[str] = [] + uncached_hosts: list[str] = [] - resolver = AsyncResolver(hosts, port) - addr_infos = resolver.resolve() - # Convert aioesphomeapi AddrInfo to our format - for addr_info in addr_infos: - sockaddr = addr_info.sockaddr - if addr_info.family == socket.AF_INET6: - # IPv6 - sockaddr_tuple = ( - sockaddr.address, - sockaddr.port, - sockaddr.flowinfo, - sockaddr.scope_id, - ) - else: - # IPv4 - sockaddr_tuple = (sockaddr.address, sockaddr.port) + for h in hosts: + # Check if it's already an IP address + if is_ip_address(h): + cached_hosts.append(h) + continue - res.append( - ( - addr_info.family, - addr_info.type, - addr_info.proto, - "", # canonname - sockaddr_tuple, + # Check cache if provided + if address_cache and (cached_addresses := address_cache.get_addresses(h)): + cached_hosts.extend(cached_addresses) + continue + + # Not in cache, need to resolve + if address_cache and address_cache.has_cache(): + _LOGGER.info("Host %s not in cache, will need to resolve", h) + uncached_hosts.append(h) + + # Process cached addresses (all should be IP addresses) + for addr in cached_hosts: + try: + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + + # If we have uncached hosts, resolve them + if uncached_hosts: + from esphome.resolver import AsyncResolver + + resolver = AsyncResolver(uncached_hosts, port) + addr_infos = resolver.resolve() + # Convert aioesphomeapi AddrInfo to our format + for addr_info in addr_infos: + sockaddr = addr_info.sockaddr + if addr_info.family == socket.AF_INET6: + # IPv6 + sockaddr_tuple = ( + sockaddr.address, + sockaddr.port, + sockaddr.flowinfo, + sockaddr.scope_id, + ) + else: + # IPv4 + sockaddr_tuple = (sockaddr.address, sockaddr.port) + + res.append( + ( + addr_info.family, + addr_info.type, + addr_info.proto, + "", # canonname + sockaddr_tuple, + ) ) - ) # Sort by preference res.sort(key=addr_preference_) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 9f51206ff9..631d6a878e 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -594,3 +594,90 @@ def test_resolve_ip_address_sorting() -> None: assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1) assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2) assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3) + + +def test_resolve_ip_address_with_cache() -> None: + """Test that the cache is used when provided.""" + from esphome.address_cache import AddressCache + + cache = AddressCache( + mdns_cache={"test.local": ["192.168.1.100", "192.168.1.101"]}, + dns_cache={ + "example.com": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] + }, + ) + + # Test mDNS cache hit + result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache) + + # Should return cached addresses without calling resolver + assert len(result) == 2 + assert result[0][4][0] == "192.168.1.100" + assert result[1][4][0] == "192.168.1.101" + + # Test DNS cache hit + result = helpers.resolve_ip_address("example.com", 6053, address_cache=cache) + + # Should return cached addresses with IPv6 first due to preference + assert len(result) == 2 + assert result[0][4][0] == "2606:2800:220:1:248:1893:25c8:1946" # IPv6 first + assert result[1][4][0] == "93.184.216.34" # IPv4 second + + +def test_resolve_ip_address_cache_miss() -> None: + """Test that resolver is called when not in cache.""" + from esphome.address_cache import AddressCache + + cache = AddressCache(mdns_cache={"other.local": ["192.168.1.200"]}) + + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache) + + # Should call resolver since test.local is not in cache + MockResolver.assert_called_once_with(["test.local"], 6053) + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.100" + + +def test_resolve_ip_address_mixed_cached_uncached() -> None: + """Test resolution with mix of cached and uncached hosts.""" + from esphome.address_cache import AddressCache + + cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]}) + + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + # Pass a list with cached IP, cached hostname, and uncached hostname + result = helpers.resolve_ip_address( + ["192.168.1.10", "cached.local", "uncached.local"], + 6053, + address_cache=cache, + ) + + # Should only resolve uncached.local + MockResolver.assert_called_once_with(["uncached.local"], 6053) + + # Results should include all addresses + addresses = [r[4][0] for r in result] + assert "192.168.1.10" in addresses # Direct IP + assert "192.168.1.50" in addresses # From cache + assert "192.168.1.100" in addresses # From resolver diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2c7236c7f8..ce19f18a1f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -10,6 +10,7 @@ from unittest.mock import Mock, patch import pytest from esphome.__main__ import choose_upload_log_host +from esphome.address_cache import AddressCache from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI from esphome.core import CORE @@ -510,3 +511,73 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: show_api=False, ) assert result == [] + + +def test_address_cache_from_cli_args() -> None: + """Test parsing address cache from CLI arguments.""" + # Test empty lists + cache = AddressCache.from_cli_args([], []) + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + + # Test single entry with single IP + cache = AddressCache.from_cli_args( + ["host.local=192.168.1.1"], ["example.com=10.0.0.1"] + ) + assert cache.mdns_cache == {"host.local": ["192.168.1.1"]} + assert cache.dns_cache == {"example.com": ["10.0.0.1"]} + + # Test multiple IPs + cache = AddressCache.from_cli_args(["host.local=192.168.1.1,192.168.1.2"], []) + assert cache.mdns_cache == {"host.local": ["192.168.1.1", "192.168.1.2"]} + + # Test multiple entries + cache = AddressCache.from_cli_args( + ["host1.local=192.168.1.1", "host2.local=192.168.1.2"], + ["example.com=10.0.0.1", "test.org=10.0.0.2,10.0.0.3"], + ) + assert cache.mdns_cache == { + "host1.local": ["192.168.1.1"], + "host2.local": ["192.168.1.2"], + } + assert cache.dns_cache == { + "example.com": ["10.0.0.1"], + "test.org": ["10.0.0.2", "10.0.0.3"], + } + + # Test with IPv6 + cache = AddressCache.from_cli_args(["host.local=2001:db8::1,fe80::1"], []) + assert cache.mdns_cache == {"host.local": ["2001:db8::1", "fe80::1"]} + + # Test invalid format (should be skipped with warning) + with patch("esphome.address_cache._LOGGER") as mock_logger: + cache = AddressCache.from_cli_args(["invalid_format"], []) + assert cache.mdns_cache == {} + mock_logger.warning.assert_called_once() + + +def test_address_cache_get_methods() -> None: + """Test the AddressCache get methods.""" + cache = AddressCache( + mdns_cache={"test.local": ["192.168.1.1"]}, + dns_cache={"example.com": ["10.0.0.1"]}, + ) + + # Test mDNS lookup + assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] + assert cache.get_mdns_addresses("other.local") is None + + # Test DNS lookup + assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] + assert cache.get_dns_addresses("other.com") is None + + # Test automatic selection based on domain + assert cache.get_addresses("test.local") == ["192.168.1.1"] + assert cache.get_addresses("example.com") == ["10.0.0.1"] + assert cache.get_addresses("unknown.local") is None + assert cache.get_addresses("unknown.com") is None + + # Test has_cache + assert cache.has_cache() is True + empty_cache = AddressCache() + assert empty_cache.has_cache() is False From 519bc5ef9e77f608defb8993b1da2543fe6ba234 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:22:16 -0500 Subject: [PATCH 02/26] Fix dashboard dns lookup delay --- esphome/dashboard/dns.py | 4 +++- esphome/dashboard/status/mdns.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index 4f1ef71dd0..b94d816c74 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -33,7 +33,9 @@ class DNSCache: Returns None if not in cache, list of addresses if found. """ - if expire_time_addresses := self._cache.get(hostname): + # Normalize hostname for consistent lookups + normalized = hostname.rstrip(".").lower() + if expire_time_addresses := self._cache.get(normalized): expire_time, addresses = expire_time_addresses if expire_time > now_monotonic and not isinstance(addresses, Exception): return addresses diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 0977a89c3a..a5ce69f30c 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -4,6 +4,8 @@ import asyncio import logging import typing +from zeroconf import AddressResolver, IPVersion + from esphome.zeroconf import ( ESPHOME_SERVICE_TYPE, AsyncEsphomeZeroconf, @@ -58,10 +60,12 @@ class MDNSStatus: if not self.aiozc: return None - from zeroconf import AddressResolver, IPVersion + # Normalize hostname: remove trailing dots and get the base name + normalized = host_name.rstrip(".").lower() + base_name = normalized.partition(".")[0] # Try to load from zeroconf cache without triggering resolution - info = AddressResolver(f"{host_name.partition('.')[0]}.local.") + info = AddressResolver(f"{base_name}.local.") if info.load_from_cache(self.aiozc.zeroconf): return info.parsed_scoped_addresses(IPVersion.All) return None From bc9d16289e8115c8b60c807ba7d0ceedae8b5b87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:25:06 -0500 Subject: [PATCH 03/26] Fix dashboard dns lookup delay --- esphome/core/__init__.py | 5 +++- esphome/dashboard/web_server.py | 8 +++--- tests/unit_tests/test_main.py | 44 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 0d4ddf56d4..476ff1c618 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -6,6 +6,9 @@ import os import re from typing import TYPE_CHECKING +if TYPE_CHECKING: + from esphome.address_cache import AddressCache + from esphome.const import ( CONF_COMMENT, CONF_ESPHOME, @@ -584,7 +587,7 @@ class EsphomeCore: # The current component being processed during validation self.current_component: str | None = None # Address cache for DNS and mDNS lookups from command line arguments - self.address_cache: object | None = None + self.address_cache: AddressCache | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 767144fd19..ff92fea958 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -364,14 +364,16 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): addresses.extend(sort_ip_addresses(cached)) dns_cache_entries[entry.name] = set(cached) - # Build cache arguments to pass to CLI + # Build cache arguments to pass to CLI (normalize hostnames) for hostname, addrs in dns_cache_entries.items(): + normalized = hostname.rstrip(".").lower() cache_args.extend( - ["--dns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ["--dns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] ) for hostname, addrs in mdns_cache_entries.items(): + normalized = hostname.rstrip(".").lower() cache_args.extend( - ["--mdns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ["--mdns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] ) if not addresses: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ce19f18a1f..a00d8ce43a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -581,3 +581,47 @@ def test_address_cache_get_methods() -> None: assert cache.has_cache() is True empty_cache = AddressCache() assert empty_cache.has_cache() is False + + +def test_address_cache_hostname_normalization() -> None: + """Test that hostnames are normalized for cache lookups.""" + from esphome.address_cache import normalize_hostname + + # Test normalize_hostname function + assert normalize_hostname("test.local") == "test.local" + assert normalize_hostname("test.local.") == "test.local" + assert normalize_hostname("TEST.LOCAL") == "test.local" + assert normalize_hostname("TeSt.LoCaL.") == "test.local" + assert normalize_hostname("example.com.") == "example.com" + + # Test cache with normalized lookups + cache = AddressCache( + mdns_cache={"test.local": ["192.168.1.1"]}, + dns_cache={"example.com": ["10.0.0.1"]}, + ) + + # Should find with different case and trailing dots + assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] + assert cache.get_mdns_addresses("TEST.LOCAL") == ["192.168.1.1"] + assert cache.get_mdns_addresses("test.local.") == ["192.168.1.1"] + assert cache.get_mdns_addresses("TEST.LOCAL.") == ["192.168.1.1"] + + assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] + assert cache.get_dns_addresses("EXAMPLE.COM") == ["10.0.0.1"] + assert cache.get_dns_addresses("example.com.") == ["10.0.0.1"] + assert cache.get_dns_addresses("EXAMPLE.COM.") == ["10.0.0.1"] + + # Test from_cli_args also normalizes + cache = AddressCache.from_cli_args( + ["TEST.LOCAL.=192.168.1.1"], ["EXAMPLE.COM.=10.0.0.1"] + ) + + # Should store as normalized + assert "test.local" in cache.mdns_cache + assert "example.com" in cache.dns_cache + + # Should find with any variation + assert cache.get_addresses("test.local") == ["192.168.1.1"] + assert cache.get_addresses("TEST.LOCAL.") == ["192.168.1.1"] + assert cache.get_addresses("example.com") == ["10.0.0.1"] + assert cache.get_addresses("EXAMPLE.COM.") == ["10.0.0.1"] From 29525febe1da6c9c03aca62fb275e98513d670d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:28:28 -0500 Subject: [PATCH 04/26] cleanup --- esphome/address_cache.py | 31 +++++++++++++++++--------- esphome/dashboard/status/mdns.py | 5 +++-- esphome/helpers.py | 38 +++++++++++++++++--------------- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/esphome/address_cache.py b/esphome/address_cache.py index 6e5881716d..7c20be90f0 100644 --- a/esphome/address_cache.py +++ b/esphome/address_cache.py @@ -40,6 +40,25 @@ class AddressCache: self.mdns_cache = mdns_cache or {} self.dns_cache = dns_cache or {} + def _get_cached_addresses( + self, hostname: str, cache: dict[str, list[str]], cache_type: str + ) -> list[str] | None: + """Get cached addresses from a specific cache. + + Args: + hostname: The hostname to look up + cache: The cache dictionary to check + cache_type: Type of cache for logging ("mDNS" or "DNS") + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if addresses := cache.get(normalized): + _LOGGER.debug("Using %s cache for %s: %s", cache_type, hostname, addresses) + return addresses + return None + def get_mdns_addresses(self, hostname: str) -> list[str] | None: """Get cached mDNS addresses for a hostname. @@ -49,11 +68,7 @@ class AddressCache: Returns: List of IP addresses if found in cache, None otherwise """ - normalized = normalize_hostname(hostname) - if addresses := self.mdns_cache.get(normalized): - _LOGGER.debug("Using mDNS cache for %s: %s", hostname, addresses) - return addresses - return None + return self._get_cached_addresses(hostname, self.mdns_cache, "mDNS") def get_dns_addresses(self, hostname: str) -> list[str] | None: """Get cached DNS addresses for a hostname. @@ -64,11 +79,7 @@ class AddressCache: Returns: List of IP addresses if found in cache, None otherwise """ - normalized = normalize_hostname(hostname) - if addresses := self.dns_cache.get(normalized): - _LOGGER.debug("Using DNS cache for %s: %s", hostname, addresses) - return addresses - return None + return self._get_cached_addresses(hostname, self.dns_cache, "DNS") def get_addresses(self, hostname: str) -> list[str] | None: """Get cached addresses for a hostname. diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index a5ce69f30c..576bade7cd 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -6,6 +6,7 @@ import typing from zeroconf import AddressResolver, IPVersion +from esphome.address_cache import normalize_hostname from esphome.zeroconf import ( ESPHOME_SERVICE_TYPE, AsyncEsphomeZeroconf, @@ -60,8 +61,8 @@ class MDNSStatus: if not self.aiozc: return None - # Normalize hostname: remove trailing dots and get the base name - normalized = host_name.rstrip(".").lower() + # Normalize hostname and get the base name + normalized = normalize_hostname(host_name) base_name = normalized.partition(".")[0] # Try to load from zeroconf cache without triggering resolution diff --git a/esphome/helpers.py b/esphome/helpers.py index f4b321b26f..7eb560646b 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -210,28 +210,30 @@ def resolve_ip_address( res.sort(key=addr_preference_) return res - # Check if we have cached addresses for these hosts - cached_hosts: list[str] = [] + # Process hosts + cached_addresses: list[str] = [] uncached_hosts: list[str] = [] + has_cache = address_cache is not None for h in hosts: - # Check if it's already an IP address if is_ip_address(h): - cached_hosts.append(h) - continue + if has_cache: + # If we have a cache, treat IPs as cached + cached_addresses.append(h) + else: + # If no cache, pass IPs through to resolver with hostnames + uncached_hosts.append(h) + elif address_cache and (cached := address_cache.get_addresses(h)): + # Found in cache + cached_addresses.extend(cached) + else: + # Not cached, need to resolve + if address_cache and address_cache.has_cache(): + _LOGGER.info("Host %s not in cache, will need to resolve", h) + uncached_hosts.append(h) - # Check cache if provided - if address_cache and (cached_addresses := address_cache.get_addresses(h)): - cached_hosts.extend(cached_addresses) - continue - - # Not in cache, need to resolve - if address_cache and address_cache.has_cache(): - _LOGGER.info("Host %s not in cache, will need to resolve", h) - uncached_hosts.append(h) - - # Process cached addresses (all should be IP addresses) - for addr in cached_hosts: + # Process cached addresses (includes direct IPs and cached lookups) + for addr in cached_addresses: try: res += socket.getaddrinfo( addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST @@ -239,7 +241,7 @@ def resolve_ip_address( except OSError: _LOGGER.debug("Failed to parse IP address '%s'", addr) - # If we have uncached hosts, resolve them + # If we have uncached hosts (only non-IP hostnames), resolve them if uncached_hosts: from esphome.resolver import AsyncResolver From 80240437c53967dfbf5a3cb00a1ea604bd26e263 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:35:51 -0500 Subject: [PATCH 05/26] cleanup --- esphome/dashboard/web_server.py | 65 ++++++++++++++------------------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index ff92fea958..71b10aaef1 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -327,8 +327,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): config_file = settings.rel_path(configuration) port = json_message["port"] - # Only get cached addresses - no async resolution - addresses: list[str] = [] + # Build cache arguments to pass to CLI cache_args: list[str] = [] if ( @@ -339,53 +338,43 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ): now = time.monotonic() - # Collect all cached addresses for this device - dns_cache_entries: dict[str, set[str]] = {} - mdns_cache_entries: dict[str, set[str]] = {} - - # First priority: entry.address AKA use_address (from DNS cache only) + # Build cache entries for any cached addresses we have + # First check entry.address (use_address) if (use_address := entry.address) and ( cached := dashboard.dns_cache.get_cached(use_address, now) ): - addresses.extend(sort_ip_addresses(cached)) - dns_cache_entries[use_address] = set(cached) + normalized = use_address.rstrip(".").lower() + cache_args.extend( + [ + "--dns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) - # Second priority: mDNS cache for device name - if entry.name and not addresses: # Only if we don't have addresses yet + # Also check entry.name for cache entries + if entry.name: if entry.name.endswith(".local"): # Check mDNS cache (zeroconf) if (mdns := dashboard.mdns_status) and ( cached := mdns.get_cached_addresses(entry.name) ): - addresses.extend(sort_ip_addresses(cached)) - mdns_cache_entries[entry.name] = set(cached) - # Check DNS cache for non-.local names + normalized = entry.name.rstrip(".").lower() + cache_args.extend( + [ + "--mdns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) elif cached := dashboard.dns_cache.get_cached(entry.name, now): - addresses.extend(sort_ip_addresses(cached)) - dns_cache_entries[entry.name] = set(cached) + normalized = entry.name.rstrip(".").lower() + cache_args.extend( + [ + "--dns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) - # Build cache arguments to pass to CLI (normalize hostnames) - for hostname, addrs in dns_cache_entries.items(): - normalized = hostname.rstrip(".").lower() - cache_args.extend( - ["--dns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] - ) - for hostname, addrs in mdns_cache_entries.items(): - normalized = hostname.rstrip(".").lower() - cache_args.extend( - ["--mdns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] - ) - - if not addresses: - # If no cached address was found, use the port directly - # The CLI will do the resolution with the cache hints we provide - addresses = [port] - - device_args: list[str] = [ - arg for address in addresses for arg in ("--device", address) - ] - - return [*DASHBOARD_COMMAND, *args, config_file, *device_args, *cache_args] + return [*DASHBOARD_COMMAND, *args, config_file, "--device", port, *cache_args] class EsphomeLogsHandler(EsphomePortCommandWebSocket): From 7fb8c84d6a3913d7a881f8d4322b104422ce4f83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:47:09 -0500 Subject: [PATCH 06/26] cleanup --- esphome/__main__.py | 9 +++- esphome/dashboard/web_server.py | 82 ++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 15c29e6cdf..4d2da21e7c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -136,7 +136,14 @@ def choose_upload_log_host( (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config) ): - resolved.append(CORE.address) + # Check if we have cached addresses for CORE.address + if CORE.address_cache and ( + cached := CORE.address_cache.get_addresses(CORE.address) + ): + _LOGGER.debug("Using cached addresses for OTA: %s", cached) + resolved.extend(cached) + else: + resolved.append(CORE.address) elif show_mqtt and has_mqtt_logging(): resolved.append("MQTT") else: diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 71b10aaef1..9637bc6b88 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -337,44 +337,74 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and "api" in entry.loaded_integrations ): now = time.monotonic() + _LOGGER.debug( + "Building cache for %s (address=%s, name=%s)", + configuration, + entry.address, + entry.name, + ) # Build cache entries for any cached addresses we have # First check entry.address (use_address) - if (use_address := entry.address) and ( - cached := dashboard.dns_cache.get_cached(use_address, now) - ): - normalized = use_address.rstrip(".").lower() - cache_args.extend( - [ - "--dns-lookup-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) + if use_address := entry.address: + if use_address.endswith(".local"): + # Check mDNS cache for .local addresses + if mdns := dashboard.mdns_status: + cached = mdns.get_cached_addresses(use_address) + _LOGGER.debug( + "mDNS cache lookup for address %s: %s", use_address, cached + ) + if cached: + normalized = use_address.rstrip(".").lower() + cache_args.extend( + [ + "--mdns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) + else: + # Check DNS cache for non-.local addresses + cached = dashboard.dns_cache.get_cached(use_address, now) + _LOGGER.debug( + "DNS cache lookup for address %s: %s", use_address, cached + ) + if cached: + normalized = use_address.rstrip(".").lower() + cache_args.extend( + [ + "--dns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) # Also check entry.name for cache entries - if entry.name: - if entry.name.endswith(".local"): - # Check mDNS cache (zeroconf) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(entry.name) - ): - normalized = entry.name.rstrip(".").lower() + # For mDNS devices, entry.name typically doesn't have .local suffix + # but we should check both with and without .local + if ( + entry.name and not use_address + ): # Only if we didn't already check address + # Try mDNS cache with .local suffix + mdns_name = ( + f"{entry.name}.local" + if not entry.name.endswith(".local") + else entry.name + ) + if mdns := dashboard.mdns_status: + cached = mdns.get_cached_addresses(mdns_name) + _LOGGER.debug("mDNS cache lookup for %s: %s", mdns_name, cached) + if cached: + normalized = mdns_name.rstrip(".").lower() cache_args.extend( [ "--mdns-lookup-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) - elif cached := dashboard.dns_cache.get_cached(entry.name, now): - normalized = entry.name.rstrip(".").lower() - cache_args.extend( - [ - "--dns-lookup-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) - return [*DASHBOARD_COMMAND, *args, config_file, "--device", port, *cache_args] + # Cache arguments must come before the subcommand + cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeLogsHandler(EsphomePortCommandWebSocket): From 817dba3d5348a4c8ccbd768853bfbdae441affef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:50:28 -0500 Subject: [PATCH 07/26] preen --- esphome/espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index f808d558d7..99c91d94e2 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -317,7 +317,7 @@ def run_ota_impl_( try: # Resolve all hosts at once for parallel DNS resolution res = resolve_ip_address( - remote_host, remote_port, address_cache=getattr(CORE, "address_cache", None) + remote_host, remote_port, address_cache=CORE.address_cache ) except EsphomeError as err: _LOGGER.error( From 158236f819d1655ff794111e11aa42a8c65bfac6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:51:44 -0500 Subject: [PATCH 08/26] preen --- tests/unit_tests/test_address_cache.py | 260 +++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 tests/unit_tests/test_address_cache.py diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py new file mode 100644 index 0000000000..f02c129256 --- /dev/null +++ b/tests/unit_tests/test_address_cache.py @@ -0,0 +1,260 @@ +"""Tests for the address_cache module.""" + +from esphome.address_cache import AddressCache, normalize_hostname + + +class TestNormalizeHostname: + """Test the normalize_hostname function.""" + + def test_normalize_simple_hostname(self): + """Test normalizing a simple hostname.""" + assert normalize_hostname("device") == "device" + assert normalize_hostname("device.local") == "device.local" + assert normalize_hostname("server.example.com") == "server.example.com" + + def test_normalize_removes_trailing_dots(self): + """Test that trailing dots are removed.""" + assert normalize_hostname("device.") == "device" + assert normalize_hostname("device.local.") == "device.local" + assert normalize_hostname("server.example.com.") == "server.example.com" + assert normalize_hostname("device...") == "device" + + def test_normalize_converts_to_lowercase(self): + """Test that hostnames are converted to lowercase.""" + assert normalize_hostname("DEVICE") == "device" + assert normalize_hostname("Device.Local") == "device.local" + assert normalize_hostname("Server.Example.COM") == "server.example.com" + + def test_normalize_combined(self): + """Test combination of trailing dots and case conversion.""" + assert normalize_hostname("DEVICE.LOCAL.") == "device.local" + assert normalize_hostname("Server.Example.COM...") == "server.example.com" + + +class TestAddressCache: + """Test the AddressCache class.""" + + def test_init_empty(self): + """Test initialization with empty caches.""" + cache = AddressCache() + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + assert not cache.has_cache() + + def test_init_with_caches(self): + """Test initialization with provided caches.""" + mdns_cache = {"device.local": ["192.168.1.10"]} + dns_cache = {"server.com": ["10.0.0.1"]} + cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache) + assert cache.mdns_cache == mdns_cache + assert cache.dns_cache == dns_cache + assert cache.has_cache() + + def test_get_mdns_addresses(self): + """Test getting mDNS addresses.""" + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]} + ) + + # Direct lookup + assert cache.get_mdns_addresses("device.local") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # Case insensitive lookup + assert cache.get_mdns_addresses("Device.Local") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # With trailing dot + assert cache.get_mdns_addresses("device.local.") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # Not found + assert cache.get_mdns_addresses("unknown.local") is None + + def test_get_dns_addresses(self): + """Test getting DNS addresses.""" + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]}) + + # Direct lookup + assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"] + + # Case insensitive lookup + assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"] + + # With trailing dot + assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"] + + # Not found + assert cache.get_dns_addresses("unknown.com") is None + + def test_get_addresses_auto_detection(self): + """Test automatic cache selection based on hostname.""" + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Should use mDNS cache for .local domains + assert cache.get_addresses("device.local") == ["192.168.1.10"] + assert cache.get_addresses("device.local.") == ["192.168.1.10"] + assert cache.get_addresses("Device.Local") == ["192.168.1.10"] + + # Should use DNS cache for non-.local domains + assert cache.get_addresses("server.com") == ["10.0.0.1"] + assert cache.get_addresses("server.com.") == ["10.0.0.1"] + assert cache.get_addresses("Server.COM") == ["10.0.0.1"] + + # Not found + assert cache.get_addresses("unknown.local") is None + assert cache.get_addresses("unknown.com") is None + + def test_has_cache(self): + """Test checking if cache has entries.""" + # Empty cache + cache = AddressCache() + assert not cache.has_cache() + + # Only mDNS cache + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]}) + assert cache.has_cache() + + # Only DNS cache + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]}) + assert cache.has_cache() + + # Both caches + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + assert cache.has_cache() + + def test_from_cli_args_empty(self): + """Test creating cache from empty CLI arguments.""" + cache = AddressCache.from_cli_args([], []) + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + + def test_from_cli_args_single_entry(self): + """Test creating cache from single CLI argument.""" + mdns_args = ["device.local=192.168.1.10"] + dns_args = ["server.com=10.0.0.1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + def test_from_cli_args_multiple_ips(self): + """Test creating cache with multiple IPs per host.""" + mdns_args = ["device.local=192.168.1.10,192.168.1.11"] + dns_args = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]} + + def test_from_cli_args_multiple_entries(self): + """Test creating cache with multiple host entries.""" + mdns_args = [ + "device1.local=192.168.1.10", + "device2.local=192.168.1.20,192.168.1.21", + ] + dns_args = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20", "192.168.1.21"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } + + def test_from_cli_args_normalization(self): + """Test that CLI arguments are normalized.""" + mdns_args = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"] + dns_args = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Hostnames should be normalized (lowercase, no trailing dots) + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } + + def test_from_cli_args_whitespace_handling(self): + """Test that whitespace in IPs is handled.""" + mdns_args = ["device.local= 192.168.1.10 , 192.168.1.11 "] + dns_args = ["server.com= 10.0.0.1 , 10.0.0.2 "] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]} + + def test_from_cli_args_invalid_format(self, caplog): + """Test handling of invalid argument format.""" + mdns_args = ["invalid_format", "device.local=192.168.1.10"] + dns_args = ["server.com=10.0.0.1", "also_invalid"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Valid entries should still be processed + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + # Check that warnings were logged for invalid entries + assert "Invalid cache format: invalid_format" in caplog.text + assert "Invalid cache format: also_invalid" in caplog.text + + def test_from_cli_args_ipv6(self): + """Test handling of IPv6 addresses.""" + mdns_args = ["device.local=fe80::1,2001:db8::1"] + dns_args = ["server.com=2001:db8::2,::1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]} + assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]} + + def test_logging_output(self, caplog): + """Test that appropriate debug logging occurs.""" + import logging + + caplog.set_level(logging.DEBUG) + + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Test successful lookups log at debug level + result = cache.get_mdns_addresses("device.local") + assert result == ["192.168.1.10"] + assert "Using mDNS cache for device.local" in caplog.text + + caplog.clear() + result = cache.get_dns_addresses("server.com") + assert result == ["10.0.0.1"] + assert "Using DNS cache for server.com" in caplog.text + + # Test that failed lookups don't log + caplog.clear() + result = cache.get_mdns_addresses("unknown.local") + assert result is None + assert "Using mDNS cache" not in caplog.text From 23d82f8368f38999577d7e8bb586a8f6aa0c6f02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:54:06 -0500 Subject: [PATCH 09/26] preen --- esphome/dashboard/dns.py | 6 ++++-- esphome/dashboard/status/mdns.py | 5 ++++- esphome/dashboard/web_server.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index b94d816c74..58867f7bc1 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -28,8 +28,10 @@ class DNSCache: self._cache: dict[str, tuple[float, list[str] | Exception]] = {} self._ttl = ttl - def get_cached(self, hostname: str, now_monotonic: float) -> list[str] | None: - """Get cached address without triggering resolution. + def get_cached_addresses( + self, hostname: str, now_monotonic: float + ) -> list[str] | None: + """Get cached addresses without triggering resolution. Returns None if not in cache, list of addresses if found. """ diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 576bade7cd..c1bf1ce21f 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import logging +import time import typing from zeroconf import AddressResolver, IPVersion @@ -67,7 +68,9 @@ class MDNSStatus: # Try to load from zeroconf cache without triggering resolution info = AddressResolver(f"{base_name}.local.") - if info.load_from_cache(self.aiozc.zeroconf): + # Pass current time in milliseconds for cache expiry checking + now = time.time() * 1000 + if info.load_from_cache(self.aiozc.zeroconf, now): return info.parsed_scoped_addresses(IPVersion.All) return None diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 9637bc6b88..90a7cab3b5 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -364,7 +364,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ) else: # Check DNS cache for non-.local addresses - cached = dashboard.dns_cache.get_cached(use_address, now) + cached = dashboard.dns_cache.get_cached_addresses(use_address, now) _LOGGER.debug( "DNS cache lookup for address %s: %s", use_address, cached ) From b9bf81fffc4c0fe3b14e1ec7c74485848461cda6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:57:35 -0500 Subject: [PATCH 10/26] fixes --- esphome/__main__.py | 6 ++++++ esphome/dashboard/status/mdns.py | 15 +++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 4d2da21e7c..7d32d1f119 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1167,6 +1167,12 @@ def run_esphome(argv): # Store cache in CORE for access throughout the application CORE.address_cache = address_cache + if address_cache.has_cache(): + _LOGGER.debug( + "Address cache initialized with %d mDNS and %d DNS entries", + len(address_cache.mdns_cache), + len(address_cache.dns_cache), + ) # Override log level if verbose is set if args.verbose: diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index c1bf1ce21f..989517e1c3 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import logging -import time import typing from zeroconf import AddressResolver, IPVersion @@ -60,6 +59,7 @@ class MDNSStatus: Returns None if not in cache or no zeroconf available. """ if not self.aiozc: + _LOGGER.debug("No zeroconf instance available for %s", host_name) return None # Normalize hostname and get the base name @@ -67,11 +67,14 @@ class MDNSStatus: base_name = normalized.partition(".")[0] # Try to load from zeroconf cache without triggering resolution - info = AddressResolver(f"{base_name}.local.") - # Pass current time in milliseconds for cache expiry checking - now = time.time() * 1000 - if info.load_from_cache(self.aiozc.zeroconf, now): - return info.parsed_scoped_addresses(IPVersion.All) + resolver_name = f"{base_name}.local." + info = AddressResolver(resolver_name) + # Let zeroconf use its own current time for cache checking + if info.load_from_cache(self.aiozc.zeroconf): + addresses = info.parsed_scoped_addresses(IPVersion.All) + _LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses) + return addresses + _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) return None async def async_refresh_hosts(self) -> None: From 7dcedbae093317cd1b37d9f3291bd17c7e68f645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:00:31 -0500 Subject: [PATCH 11/26] fixes --- tests/unit_tests/test_address_cache.py | 453 ++++++++++++++----------- 1 file changed, 249 insertions(+), 204 deletions(-) diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py index f02c129256..de43830d53 100644 --- a/tests/unit_tests/test_address_cache.py +++ b/tests/unit_tests/test_address_cache.py @@ -1,260 +1,305 @@ """Tests for the address_cache module.""" +from __future__ import annotations + +import logging + +import pytest +from pytest import LogCaptureFixture + from esphome.address_cache import AddressCache, normalize_hostname -class TestNormalizeHostname: - """Test the normalize_hostname function.""" - - def test_normalize_simple_hostname(self): - """Test normalizing a simple hostname.""" - assert normalize_hostname("device") == "device" - assert normalize_hostname("device.local") == "device.local" - assert normalize_hostname("server.example.com") == "server.example.com" - - def test_normalize_removes_trailing_dots(self): - """Test that trailing dots are removed.""" - assert normalize_hostname("device.") == "device" - assert normalize_hostname("device.local.") == "device.local" - assert normalize_hostname("server.example.com.") == "server.example.com" - assert normalize_hostname("device...") == "device" - - def test_normalize_converts_to_lowercase(self): - """Test that hostnames are converted to lowercase.""" - assert normalize_hostname("DEVICE") == "device" - assert normalize_hostname("Device.Local") == "device.local" - assert normalize_hostname("Server.Example.COM") == "server.example.com" - - def test_normalize_combined(self): - """Test combination of trailing dots and case conversion.""" - assert normalize_hostname("DEVICE.LOCAL.") == "device.local" - assert normalize_hostname("Server.Example.COM...") == "server.example.com" +def test_normalize_simple_hostname() -> None: + """Test normalizing a simple hostname.""" + assert normalize_hostname("device") == "device" + assert normalize_hostname("device.local") == "device.local" + assert normalize_hostname("server.example.com") == "server.example.com" -class TestAddressCache: - """Test the AddressCache class.""" +def test_normalize_removes_trailing_dots() -> None: + """Test that trailing dots are removed.""" + assert normalize_hostname("device.") == "device" + assert normalize_hostname("device.local.") == "device.local" + assert normalize_hostname("server.example.com.") == "server.example.com" + assert normalize_hostname("device...") == "device" - def test_init_empty(self): - """Test initialization with empty caches.""" - cache = AddressCache() - assert cache.mdns_cache == {} - assert cache.dns_cache == {} - assert not cache.has_cache() - def test_init_with_caches(self): - """Test initialization with provided caches.""" - mdns_cache = {"device.local": ["192.168.1.10"]} - dns_cache = {"server.com": ["10.0.0.1"]} - cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache) - assert cache.mdns_cache == mdns_cache - assert cache.dns_cache == dns_cache - assert cache.has_cache() +def test_normalize_converts_to_lowercase() -> None: + """Test that hostnames are converted to lowercase.""" + assert normalize_hostname("DEVICE") == "device" + assert normalize_hostname("Device.Local") == "device.local" + assert normalize_hostname("Server.Example.COM") == "server.example.com" - def test_get_mdns_addresses(self): - """Test getting mDNS addresses.""" - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]} - ) - # Direct lookup - assert cache.get_mdns_addresses("device.local") == [ - "192.168.1.10", - "192.168.1.11", - ] +def test_normalize_combined() -> None: + """Test combination of trailing dots and case conversion.""" + assert normalize_hostname("DEVICE.LOCAL.") == "device.local" + assert normalize_hostname("Server.Example.COM...") == "server.example.com" - # Case insensitive lookup - assert cache.get_mdns_addresses("Device.Local") == [ - "192.168.1.10", - "192.168.1.11", - ] - # With trailing dot - assert cache.get_mdns_addresses("device.local.") == [ - "192.168.1.10", - "192.168.1.11", - ] +def test_init_empty() -> None: + """Test initialization with empty caches.""" + cache = AddressCache() + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + assert not cache.has_cache() - # Not found - assert cache.get_mdns_addresses("unknown.local") is None - def test_get_dns_addresses(self): - """Test getting DNS addresses.""" - cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]}) +def test_init_with_caches() -> None: + """Test initialization with provided caches.""" + mdns_cache: dict[str, list[str]] = {"device.local": ["192.168.1.10"]} + dns_cache: dict[str, list[str]] = {"server.com": ["10.0.0.1"]} + cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache) + assert cache.mdns_cache == mdns_cache + assert cache.dns_cache == dns_cache + assert cache.has_cache() - # Direct lookup - assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"] - # Case insensitive lookup - assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"] +def test_get_mdns_addresses() -> None: + """Test getting mDNS addresses.""" + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]}) - # With trailing dot - assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"] + # Direct lookup + assert cache.get_mdns_addresses("device.local") == [ + "192.168.1.10", + "192.168.1.11", + ] - # Not found - assert cache.get_dns_addresses("unknown.com") is None + # Case insensitive lookup + assert cache.get_mdns_addresses("Device.Local") == [ + "192.168.1.10", + "192.168.1.11", + ] - def test_get_addresses_auto_detection(self): - """Test automatic cache selection based on hostname.""" - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10"]}, - dns_cache={"server.com": ["10.0.0.1"]}, - ) + # With trailing dot + assert cache.get_mdns_addresses("device.local.") == [ + "192.168.1.10", + "192.168.1.11", + ] - # Should use mDNS cache for .local domains - assert cache.get_addresses("device.local") == ["192.168.1.10"] - assert cache.get_addresses("device.local.") == ["192.168.1.10"] - assert cache.get_addresses("Device.Local") == ["192.168.1.10"] + # Not found + assert cache.get_mdns_addresses("unknown.local") is None - # Should use DNS cache for non-.local domains - assert cache.get_addresses("server.com") == ["10.0.0.1"] - assert cache.get_addresses("server.com.") == ["10.0.0.1"] - assert cache.get_addresses("Server.COM") == ["10.0.0.1"] - # Not found - assert cache.get_addresses("unknown.local") is None - assert cache.get_addresses("unknown.com") is None +def test_get_dns_addresses() -> None: + """Test getting DNS addresses.""" + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]}) - def test_has_cache(self): - """Test checking if cache has entries.""" - # Empty cache - cache = AddressCache() - assert not cache.has_cache() + # Direct lookup + assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"] - # Only mDNS cache - cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]}) - assert cache.has_cache() + # Case insensitive lookup + assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"] - # Only DNS cache - cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]}) - assert cache.has_cache() + # With trailing dot + assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"] - # Both caches - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10"]}, - dns_cache={"server.com": ["10.0.0.1"]}, - ) - assert cache.has_cache() + # Not found + assert cache.get_dns_addresses("unknown.com") is None - def test_from_cli_args_empty(self): - """Test creating cache from empty CLI arguments.""" - cache = AddressCache.from_cli_args([], []) - assert cache.mdns_cache == {} - assert cache.dns_cache == {} - def test_from_cli_args_single_entry(self): - """Test creating cache from single CLI argument.""" - mdns_args = ["device.local=192.168.1.10"] - dns_args = ["server.com=10.0.0.1"] +def test_get_addresses_auto_detection() -> None: + """Test automatic cache selection based on hostname.""" + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) - cache = AddressCache.from_cli_args(mdns_args, dns_args) + # Should use mDNS cache for .local domains + assert cache.get_addresses("device.local") == ["192.168.1.10"] + assert cache.get_addresses("device.local.") == ["192.168.1.10"] + assert cache.get_addresses("Device.Local") == ["192.168.1.10"] - assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + # Should use DNS cache for non-.local domains + assert cache.get_addresses("server.com") == ["10.0.0.1"] + assert cache.get_addresses("server.com.") == ["10.0.0.1"] + assert cache.get_addresses("Server.COM") == ["10.0.0.1"] - def test_from_cli_args_multiple_ips(self): - """Test creating cache with multiple IPs per host.""" - mdns_args = ["device.local=192.168.1.10,192.168.1.11"] - dns_args = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"] + # Not found + assert cache.get_addresses("unknown.local") is None + assert cache.get_addresses("unknown.com") is None - cache = AddressCache.from_cli_args(mdns_args, dns_args) - assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]} +def test_has_cache() -> None: + """Test checking if cache has entries.""" + # Empty cache + cache = AddressCache() + assert not cache.has_cache() - def test_from_cli_args_multiple_entries(self): - """Test creating cache with multiple host entries.""" - mdns_args = [ - "device1.local=192.168.1.10", - "device2.local=192.168.1.20,192.168.1.21", - ] - dns_args = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"] + # Only mDNS cache + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]}) + assert cache.has_cache() - cache = AddressCache.from_cli_args(mdns_args, dns_args) + # Only DNS cache + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]}) + assert cache.has_cache() - assert cache.mdns_cache == { - "device1.local": ["192.168.1.10"], - "device2.local": ["192.168.1.20", "192.168.1.21"], - } - assert cache.dns_cache == { - "server1.com": ["10.0.0.1"], - "server2.com": ["10.0.0.2"], - } + # Both caches + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + assert cache.has_cache() - def test_from_cli_args_normalization(self): - """Test that CLI arguments are normalized.""" - mdns_args = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"] - dns_args = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) +def test_from_cli_args_empty() -> None: + """Test creating cache from empty CLI arguments.""" + cache = AddressCache.from_cli_args([], []) + assert cache.mdns_cache == {} + assert cache.dns_cache == {} - # Hostnames should be normalized (lowercase, no trailing dots) - assert cache.mdns_cache == { - "device1.local": ["192.168.1.10"], - "device2.local": ["192.168.1.20"], - } - assert cache.dns_cache == { - "server1.com": ["10.0.0.1"], - "server2.com": ["10.0.0.2"], - } - def test_from_cli_args_whitespace_handling(self): - """Test that whitespace in IPs is handled.""" - mdns_args = ["device.local= 192.168.1.10 , 192.168.1.11 "] - dns_args = ["server.com= 10.0.0.1 , 10.0.0.2 "] +def test_from_cli_args_single_entry() -> None: + """Test creating cache from single CLI argument.""" + mdns_args: list[str] = ["device.local=192.168.1.10"] + dns_args: list[str] = ["server.com=10.0.0.1"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) + cache = AddressCache.from_cli_args(mdns_args, dns_args) - assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]} + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} - def test_from_cli_args_invalid_format(self, caplog): - """Test handling of invalid argument format.""" - mdns_args = ["invalid_format", "device.local=192.168.1.10"] - dns_args = ["server.com=10.0.0.1", "also_invalid"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) +def test_from_cli_args_multiple_ips() -> None: + """Test creating cache with multiple IPs per host.""" + mdns_args: list[str] = ["device.local=192.168.1.10,192.168.1.11"] + dns_args: list[str] = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"] - # Valid entries should still be processed - assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + cache = AddressCache.from_cli_args(mdns_args, dns_args) - # Check that warnings were logged for invalid entries - assert "Invalid cache format: invalid_format" in caplog.text - assert "Invalid cache format: also_invalid" in caplog.text + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]} - def test_from_cli_args_ipv6(self): - """Test handling of IPv6 addresses.""" - mdns_args = ["device.local=fe80::1,2001:db8::1"] - dns_args = ["server.com=2001:db8::2,::1"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) +def test_from_cli_args_multiple_entries() -> None: + """Test creating cache with multiple host entries.""" + mdns_args: list[str] = [ + "device1.local=192.168.1.10", + "device2.local=192.168.1.20,192.168.1.21", + ] + dns_args: list[str] = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"] - assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]} - assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]} + cache = AddressCache.from_cli_args(mdns_args, dns_args) - def test_logging_output(self, caplog): - """Test that appropriate debug logging occurs.""" - import logging + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20", "192.168.1.21"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } - caplog.set_level(logging.DEBUG) - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10"]}, - dns_cache={"server.com": ["10.0.0.1"]}, - ) +def test_from_cli_args_normalization() -> None: + """Test that CLI arguments are normalized.""" + mdns_args: list[str] = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"] + dns_args: list[str] = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"] - # Test successful lookups log at debug level - result = cache.get_mdns_addresses("device.local") - assert result == ["192.168.1.10"] - assert "Using mDNS cache for device.local" in caplog.text + cache = AddressCache.from_cli_args(mdns_args, dns_args) - caplog.clear() - result = cache.get_dns_addresses("server.com") - assert result == ["10.0.0.1"] - assert "Using DNS cache for server.com" in caplog.text + # Hostnames should be normalized (lowercase, no trailing dots) + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } - # Test that failed lookups don't log - caplog.clear() - result = cache.get_mdns_addresses("unknown.local") - assert result is None - assert "Using mDNS cache" not in caplog.text + +def test_from_cli_args_whitespace_handling() -> None: + """Test that whitespace in IPs is handled.""" + mdns_args: list[str] = ["device.local= 192.168.1.10 , 192.168.1.11 "] + dns_args: list[str] = ["server.com= 10.0.0.1 , 10.0.0.2 "] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]} + + +def test_from_cli_args_invalid_format(caplog: LogCaptureFixture) -> None: + """Test handling of invalid argument format.""" + mdns_args: list[str] = ["invalid_format", "device.local=192.168.1.10"] + dns_args: list[str] = ["server.com=10.0.0.1", "also_invalid"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Valid entries should still be processed + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + # Check that warnings were logged for invalid entries + assert "Invalid cache format: invalid_format" in caplog.text + assert "Invalid cache format: also_invalid" in caplog.text + + +def test_from_cli_args_ipv6() -> None: + """Test handling of IPv6 addresses.""" + mdns_args: list[str] = ["device.local=fe80::1,2001:db8::1"] + dns_args: list[str] = ["server.com=2001:db8::2,::1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]} + assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]} + + +def test_logging_output(caplog: LogCaptureFixture) -> None: + """Test that appropriate debug logging occurs.""" + caplog.set_level(logging.DEBUG) + + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Test successful lookups log at debug level + result: list[str] | None = cache.get_mdns_addresses("device.local") + assert result == ["192.168.1.10"] + assert "Using mDNS cache for device.local" in caplog.text + + caplog.clear() + result = cache.get_dns_addresses("server.com") + assert result == ["10.0.0.1"] + assert "Using DNS cache for server.com" in caplog.text + + # Test that failed lookups don't log + caplog.clear() + result = cache.get_mdns_addresses("unknown.local") + assert result is None + assert "Using mDNS cache" not in caplog.text + + +@pytest.mark.parametrize( + "hostname,expected", + [ + ("test.local", "test.local"), + ("Test.Local.", "test.local"), + ("TEST.LOCAL...", "test.local"), + ("example.com", "example.com"), + ("EXAMPLE.COM.", "example.com"), + ], +) +def test_normalize_hostname_parametrized(hostname: str, expected: str) -> None: + """Test hostname normalization with various inputs.""" + assert normalize_hostname(hostname) == expected + + +@pytest.mark.parametrize( + "mdns_arg,expected", + [ + ("host=1.2.3.4", {"host": ["1.2.3.4"]}), + ("Host.Local=1.2.3.4,5.6.7.8", {"host.local": ["1.2.3.4", "5.6.7.8"]}), + ("HOST.LOCAL.=::1", {"host.local": ["::1"]}), + ], +) +def test_parse_cache_args_parametrized( + mdns_arg: str, expected: dict[str, list[str]] +) -> None: + """Test parsing of cache arguments with various formats.""" + cache = AddressCache.from_cli_args([mdns_arg], []) + assert cache.mdns_cache == expected From fd9df3a62901668db1496ade0c0e33ee5c1b47e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:02:33 -0500 Subject: [PATCH 12/26] fixes --- tests/unit_tests/test_main.py | 44 ----------------------------------- 1 file changed, 44 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a00d8ce43a..ce19f18a1f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -581,47 +581,3 @@ def test_address_cache_get_methods() -> None: assert cache.has_cache() is True empty_cache = AddressCache() assert empty_cache.has_cache() is False - - -def test_address_cache_hostname_normalization() -> None: - """Test that hostnames are normalized for cache lookups.""" - from esphome.address_cache import normalize_hostname - - # Test normalize_hostname function - assert normalize_hostname("test.local") == "test.local" - assert normalize_hostname("test.local.") == "test.local" - assert normalize_hostname("TEST.LOCAL") == "test.local" - assert normalize_hostname("TeSt.LoCaL.") == "test.local" - assert normalize_hostname("example.com.") == "example.com" - - # Test cache with normalized lookups - cache = AddressCache( - mdns_cache={"test.local": ["192.168.1.1"]}, - dns_cache={"example.com": ["10.0.0.1"]}, - ) - - # Should find with different case and trailing dots - assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] - assert cache.get_mdns_addresses("TEST.LOCAL") == ["192.168.1.1"] - assert cache.get_mdns_addresses("test.local.") == ["192.168.1.1"] - assert cache.get_mdns_addresses("TEST.LOCAL.") == ["192.168.1.1"] - - assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] - assert cache.get_dns_addresses("EXAMPLE.COM") == ["10.0.0.1"] - assert cache.get_dns_addresses("example.com.") == ["10.0.0.1"] - assert cache.get_dns_addresses("EXAMPLE.COM.") == ["10.0.0.1"] - - # Test from_cli_args also normalizes - cache = AddressCache.from_cli_args( - ["TEST.LOCAL.=192.168.1.1"], ["EXAMPLE.COM.=10.0.0.1"] - ) - - # Should store as normalized - assert "test.local" in cache.mdns_cache - assert "example.com" in cache.dns_cache - - # Should find with any variation - assert cache.get_addresses("test.local") == ["192.168.1.1"] - assert cache.get_addresses("TEST.LOCAL.") == ["192.168.1.1"] - assert cache.get_addresses("example.com") == ["10.0.0.1"] - assert cache.get_addresses("EXAMPLE.COM.") == ["10.0.0.1"] From b416f7c1fb48d381544f294622f45046d56a096d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:05:31 -0500 Subject: [PATCH 13/26] fixes --- tests/unit_tests/test_helpers.py | 7 +--- tests/unit_tests/test_main.py | 71 -------------------------------- 2 files changed, 1 insertion(+), 77 deletions(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 631d6a878e..0dc782e87e 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -8,6 +8,7 @@ from hypothesis.strategies import ip_addresses import pytest from esphome import helpers +from esphome.address_cache import AddressCache from esphome.core import EsphomeError @@ -598,8 +599,6 @@ def test_resolve_ip_address_sorting() -> None: def test_resolve_ip_address_with_cache() -> None: """Test that the cache is used when provided.""" - from esphome.address_cache import AddressCache - cache = AddressCache( mdns_cache={"test.local": ["192.168.1.100", "192.168.1.101"]}, dns_cache={ @@ -626,8 +625,6 @@ def test_resolve_ip_address_with_cache() -> None: def test_resolve_ip_address_cache_miss() -> None: """Test that resolver is called when not in cache.""" - from esphome.address_cache import AddressCache - cache = AddressCache(mdns_cache={"other.local": ["192.168.1.200"]}) mock_addr_info = AddrInfo( @@ -651,8 +648,6 @@ def test_resolve_ip_address_cache_miss() -> None: def test_resolve_ip_address_mixed_cached_uncached() -> None: """Test resolution with mix of cached and uncached hosts.""" - from esphome.address_cache import AddressCache - cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]}) mock_addr_info = AddrInfo( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ce19f18a1f..2c7236c7f8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -10,7 +10,6 @@ from unittest.mock import Mock, patch import pytest from esphome.__main__ import choose_upload_log_host -from esphome.address_cache import AddressCache from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI from esphome.core import CORE @@ -511,73 +510,3 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: show_api=False, ) assert result == [] - - -def test_address_cache_from_cli_args() -> None: - """Test parsing address cache from CLI arguments.""" - # Test empty lists - cache = AddressCache.from_cli_args([], []) - assert cache.mdns_cache == {} - assert cache.dns_cache == {} - - # Test single entry with single IP - cache = AddressCache.from_cli_args( - ["host.local=192.168.1.1"], ["example.com=10.0.0.1"] - ) - assert cache.mdns_cache == {"host.local": ["192.168.1.1"]} - assert cache.dns_cache == {"example.com": ["10.0.0.1"]} - - # Test multiple IPs - cache = AddressCache.from_cli_args(["host.local=192.168.1.1,192.168.1.2"], []) - assert cache.mdns_cache == {"host.local": ["192.168.1.1", "192.168.1.2"]} - - # Test multiple entries - cache = AddressCache.from_cli_args( - ["host1.local=192.168.1.1", "host2.local=192.168.1.2"], - ["example.com=10.0.0.1", "test.org=10.0.0.2,10.0.0.3"], - ) - assert cache.mdns_cache == { - "host1.local": ["192.168.1.1"], - "host2.local": ["192.168.1.2"], - } - assert cache.dns_cache == { - "example.com": ["10.0.0.1"], - "test.org": ["10.0.0.2", "10.0.0.3"], - } - - # Test with IPv6 - cache = AddressCache.from_cli_args(["host.local=2001:db8::1,fe80::1"], []) - assert cache.mdns_cache == {"host.local": ["2001:db8::1", "fe80::1"]} - - # Test invalid format (should be skipped with warning) - with patch("esphome.address_cache._LOGGER") as mock_logger: - cache = AddressCache.from_cli_args(["invalid_format"], []) - assert cache.mdns_cache == {} - mock_logger.warning.assert_called_once() - - -def test_address_cache_get_methods() -> None: - """Test the AddressCache get methods.""" - cache = AddressCache( - mdns_cache={"test.local": ["192.168.1.1"]}, - dns_cache={"example.com": ["10.0.0.1"]}, - ) - - # Test mDNS lookup - assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] - assert cache.get_mdns_addresses("other.local") is None - - # Test DNS lookup - assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] - assert cache.get_dns_addresses("other.com") is None - - # Test automatic selection based on domain - assert cache.get_addresses("test.local") == ["192.168.1.1"] - assert cache.get_addresses("example.com") == ["10.0.0.1"] - assert cache.get_addresses("unknown.local") is None - assert cache.get_addresses("unknown.com") is None - - # Test has_cache - assert cache.has_cache() is True - empty_cache = AddressCache() - assert empty_cache.has_cache() is False From 99403c5a36781f6cf194f4fafd39dd19b6f95cc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:08:43 -0500 Subject: [PATCH 14/26] wip --- tests/dashboard/status/__init__.py | 0 tests/dashboard/status/test_mdns.py | 172 ++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tests/dashboard/status/__init__.py create mode 100644 tests/dashboard/status/test_mdns.py diff --git a/tests/dashboard/status/__init__.py b/tests/dashboard/status/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py new file mode 100644 index 0000000000..1d6b096b7b --- /dev/null +++ b/tests/dashboard/status/test_mdns.py @@ -0,0 +1,172 @@ +"""Unit tests for esphome.dashboard.status.mdns module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import Mock, patch + +import pytest +from zeroconf import AddressResolver, IPVersion + +from esphome.dashboard.status.mdns import MDNSStatus + +if TYPE_CHECKING: + from esphome.dashboard.core import ESPHomeDashboard + + +@pytest.fixture +def mock_dashboard() -> Mock: + """Create a mock dashboard.""" + dashboard = Mock(spec=ESPHomeDashboard) + dashboard.entries = Mock() + dashboard.entries.async_all.return_value = [] + dashboard.stop_event = Mock() + dashboard.stop_event.is_set.return_value = True + dashboard.ping_request = Mock() + return dashboard + + +@pytest.fixture +def mdns_status(mock_dashboard: Mock) -> MDNSStatus: + """Create an MDNSStatus instance.""" + return MDNSStatus(mock_dashboard) + + +def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when no zeroconf instance is available.""" + mdns_status.aiozc = None + result = mdns_status.get_cached_addresses("device.local") + assert result is None + + +def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when address is not in cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = False + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result is None + mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) + + +def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when address is found in cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == ["192.168.1.10", "fe80::1"] + mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) + mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) + + +def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with hostname having trailing dot.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local.") + assert result == ["192.168.1.10"] + # Should normalize to device.local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with uppercase hostname.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("DEVICE.LOCAL") + assert result == ["192.168.1.10"] + # Should normalize to device.local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with simple hostname (no domain).""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device") + assert result == ["192.168.1.10"] + # Should append .local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses returning only IPv6 addresses.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == ["fe80::1", "2001:db8::1"] + + +def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses returning empty list from cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = [] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == [] + + +def test_async_setup_success(mock_dashboard: Mock) -> None: + """Test successful async_setup.""" + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.return_value = Mock() + result = mdns_status.async_setup() + assert result is True + assert mdns_status.aiozc is not None + + +def test_async_setup_failure(mock_dashboard: Mock) -> None: + """Test async_setup with OSError.""" + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.side_effect = OSError("Network error") + result = mdns_status.async_setup() + assert result is False + assert mdns_status.aiozc is None From 5dbe56849a101fc8a10a79ffd5b5afc58eb671f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:10:58 -0500 Subject: [PATCH 15/26] wip --- esphome/__main__.py | 10 +- tests/dashboard/status/test_dns.py | 202 ++++++++++++++++++++++++++++ tests/dashboard/status/test_mdns.py | 9 +- 3 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 tests/dashboard/status/test_dns.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 7d32d1f119..3223371c96 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -897,14 +897,14 @@ def parse_args(argv): metavar=("key", "value"), ) options_parser.add_argument( - "--mdns-lookup-cache", - help="mDNS lookup cache mapping in format 'hostname=ip1,ip2'", + "--mdns-address-cache", + help="mDNS address cache mapping in format 'hostname=ip1,ip2'", action="append", default=[], ) options_parser.add_argument( - "--dns-lookup-cache", - help="DNS lookup cache mapping in format 'hostname=ip1,ip2'", + "--dns-address-cache", + help="DNS address cache mapping in format 'hostname=ip1,ip2'", action="append", default=[], ) @@ -1162,7 +1162,7 @@ def run_esphome(argv): # Create address cache from command-line arguments address_cache = AddressCache.from_cli_args( - args.mdns_lookup_cache, args.dns_lookup_cache + args.mdns_address_cache, args.dns_address_cache ) # Store cache in CORE for access throughout the application diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py new file mode 100644 index 0000000000..519defcbe1 --- /dev/null +++ b/tests/dashboard/status/test_dns.py @@ -0,0 +1,202 @@ +"""Unit tests for esphome.dashboard.dns module.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest + +from esphome.dashboard.dns import DNSCache + + +@pytest.fixture +def dns_cache() -> DNSCache: + """Create a DNSCache instance.""" + return DNSCache() + + +def test_get_cached_addresses_not_in_cache(dns_cache: DNSCache) -> None: + """Test get_cached_addresses when hostname is not in cache.""" + now = time.monotonic() + result = dns_cache.get_cached_addresses("unknown.example.com", now) + assert result is None + + +def test_get_cached_addresses_expired(dns_cache: DNSCache) -> None: + """Test get_cached_addresses when cache entry is expired.""" + now = time.monotonic() + # Add entry that's already expired + dns_cache.cache["example.com"] = (["192.168.1.10"], now - 1) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result is None + # Expired entry should be removed + assert "example.com" not in dns_cache.cache + + +def test_get_cached_addresses_valid(dns_cache: DNSCache) -> None: + """Test get_cached_addresses with valid cache entry.""" + now = time.monotonic() + # Add entry that expires in 60 seconds + dns_cache.cache["example.com"] = (["192.168.1.10", "192.168.1.11"], now + 60) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result == ["192.168.1.10", "192.168.1.11"] + # Entry should still be in cache + assert "example.com" in dns_cache.cache + + +def test_get_cached_addresses_hostname_normalization(dns_cache: DNSCache) -> None: + """Test get_cached_addresses normalizes hostname.""" + now = time.monotonic() + # Add entry with lowercase hostname + dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + + # Test with various forms + assert dns_cache.get_cached_addresses("EXAMPLE.COM", now) == ["192.168.1.10"] + assert dns_cache.get_cached_addresses("example.com.", now) == ["192.168.1.10"] + assert dns_cache.get_cached_addresses("EXAMPLE.COM.", now) == ["192.168.1.10"] + + +def test_get_cached_addresses_ipv6(dns_cache: DNSCache) -> None: + """Test get_cached_addresses with IPv6 addresses.""" + now = time.monotonic() + dns_cache.cache["example.com"] = (["2001:db8::1", "fe80::1"], now + 60) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result == ["2001:db8::1", "fe80::1"] + + +def test_get_cached_addresses_empty_list(dns_cache: DNSCache) -> None: + """Test get_cached_addresses with empty address list.""" + now = time.monotonic() + dns_cache.cache["example.com"] = ([], now + 60) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result == [] + + +def test_resolve_addresses_already_cached(dns_cache: DNSCache) -> None: + """Test resolve_addresses when hostname is already cached.""" + now = time.monotonic() + dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + + with patch("socket.getaddrinfo") as mock_getaddrinfo: + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + # Should not call getaddrinfo for cached entry + mock_getaddrinfo.assert_not_called() + + +def test_resolve_addresses_not_cached(dns_cache: DNSCache) -> None: + """Test resolve_addresses when hostname needs resolution.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + (None, None, None, None, ("192.168.1.11", 0)), + ] + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10", "192.168.1.11"] + mock_getaddrinfo.assert_called_once_with("example.com", 0) + + # Should be cached now + assert "example.com" in dns_cache.cache + + +def test_resolve_addresses_multiple_hostnames(dns_cache: DNSCache) -> None: + """Test resolve_addresses with multiple hostnames.""" + now = time.monotonic() + dns_cache.cache["cached.com"] = (["192.168.1.10"], now + 60) + + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("10.0.0.1", 0)), + ] + + result = dns_cache.resolve_addresses( + "primary.com", ["cached.com", "primary.com", "fallback.com"] + ) + # Should return cached result for first match + assert result == ["192.168.1.10"] + mock_getaddrinfo.assert_not_called() + + +def test_resolve_addresses_resolution_error(dns_cache: DNSCache) -> None: + """Test resolve_addresses when resolution fails.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.side_effect = OSError("Name resolution failed") + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == [] + # Failed resolution should not be cached + assert "example.com" not in dns_cache.cache + + +def test_resolve_addresses_ipv6_resolution(dns_cache: DNSCache) -> None: + """Test resolve_addresses with IPv6 results.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("2001:db8::1", 0, 0, 0)), + (None, None, None, None, ("fe80::1", 0, 0, 0)), + ] + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["2001:db8::1", "fe80::1"] + + +def test_resolve_addresses_duplicate_removal(dns_cache: DNSCache) -> None: + """Test resolve_addresses removes duplicate addresses.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + (None, None, None, None, ("192.168.1.10", 0)), # Duplicate + (None, None, None, None, ("192.168.1.11", 0)), + ] + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10", "192.168.1.11"] + + +def test_resolve_addresses_hostname_normalization(dns_cache: DNSCache) -> None: + """Test resolve_addresses normalizes hostnames.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + ] + + # Resolve with uppercase and trailing dot + result = dns_cache.resolve_addresses("EXAMPLE.COM.", ["EXAMPLE.COM."]) + assert result == ["192.168.1.10"] + + # Should be cached with normalized name + assert "example.com" in dns_cache.cache + + # Should use cached result for different forms + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + # Only called once due to caching + mock_getaddrinfo.assert_called_once() + + +def test_cache_expiration_ttl(dns_cache: DNSCache) -> None: + """Test that cache entries expire after TTL.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + ] + + # First resolution + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + assert mock_getaddrinfo.call_count == 1 + + # Simulate time passing beyond TTL + with patch("time.monotonic") as mock_time: + mock_time.return_value = time.monotonic() + 301 # TTL is 300 seconds + + # Should trigger new resolution + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + assert mock_getaddrinfo.call_count == 2 diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index 1d6b096b7b..b451157922 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -2,17 +2,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING from unittest.mock import Mock, patch import pytest from zeroconf import AddressResolver, IPVersion +from esphome.dashboard.core import ESPHomeDashboard from esphome.dashboard.status.mdns import MDNSStatus -if TYPE_CHECKING: - from esphome.dashboard.core import ESPHomeDashboard - @pytest.fixture def mock_dashboard() -> Mock: @@ -29,7 +26,9 @@ def mock_dashboard() -> Mock: @pytest.fixture def mdns_status(mock_dashboard: Mock) -> MDNSStatus: """Create an MDNSStatus instance.""" - return MDNSStatus(mock_dashboard) + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value = Mock() + return MDNSStatus(mock_dashboard) def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: From 305b4504de66ee14157a17ed16ac21977de6726d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:14:03 -0500 Subject: [PATCH 16/26] wip --- esphome/dashboard/web_server.py | 6 +- tests/dashboard/status/test_dns.py | 195 ++++++++-------------------- tests/dashboard/status/test_mdns.py | 28 ++-- 3 files changed, 76 insertions(+), 153 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 90a7cab3b5..2ab449b8da 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -358,7 +358,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): normalized = use_address.rstrip(".").lower() cache_args.extend( [ - "--mdns-lookup-cache", + "--mdns-address-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) @@ -372,7 +372,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): normalized = use_address.rstrip(".").lower() cache_args.extend( [ - "--dns-lookup-cache", + "--dns-address-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) @@ -396,7 +396,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): normalized = mdns_name.rstrip(".").lower() cache_args.extend( [ - "--mdns-lookup-cache", + "--mdns-address-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py index 519defcbe1..9ca48ba2d8 100644 --- a/tests/dashboard/status/test_dns.py +++ b/tests/dashboard/status/test_dns.py @@ -11,192 +11,111 @@ from esphome.dashboard.dns import DNSCache @pytest.fixture -def dns_cache() -> DNSCache: +def dns_cache_fixture() -> DNSCache: """Create a DNSCache instance.""" return DNSCache() -def test_get_cached_addresses_not_in_cache(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses when hostname is not in cache.""" now = time.monotonic() - result = dns_cache.get_cached_addresses("unknown.example.com", now) + result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now) assert result is None -def test_get_cached_addresses_expired(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses when cache entry is expired.""" now = time.monotonic() # Add entry that's already expired - dns_cache.cache["example.com"] = (["192.168.1.10"], now - 1) + dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"]) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result is None - # Expired entry should be removed - assert "example.com" not in dns_cache.cache + # Expired entry should still be in cache (not removed by get_cached_addresses) + assert "example.com" in dns_cache_fixture._cache -def test_get_cached_addresses_valid(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses with valid cache entry.""" now = time.monotonic() # Add entry that expires in 60 seconds - dns_cache.cache["example.com"] = (["192.168.1.10", "192.168.1.11"], now + 60) + dns_cache_fixture._cache["example.com"] = ( + now + 60, + ["192.168.1.10", "192.168.1.11"], + ) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result == ["192.168.1.10", "192.168.1.11"] # Entry should still be in cache - assert "example.com" in dns_cache.cache + assert "example.com" in dns_cache_fixture._cache -def test_get_cached_addresses_hostname_normalization(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_hostname_normalization( + dns_cache_fixture: DNSCache, +) -> None: """Test get_cached_addresses normalizes hostname.""" now = time.monotonic() # Add entry with lowercase hostname - dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"]) # Test with various forms - assert dns_cache.get_cached_addresses("EXAMPLE.COM", now) == ["192.168.1.10"] - assert dns_cache.get_cached_addresses("example.com.", now) == ["192.168.1.10"] - assert dns_cache.get_cached_addresses("EXAMPLE.COM.", now) == ["192.168.1.10"] + assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [ + "192.168.1.10" + ] + assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [ + "192.168.1.10" + ] + assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [ + "192.168.1.10" + ] -def test_get_cached_addresses_ipv6(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses with IPv6 addresses.""" now = time.monotonic() - dns_cache.cache["example.com"] = (["2001:db8::1", "fe80::1"], now + 60) + dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"]) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result == ["2001:db8::1", "fe80::1"] -def test_get_cached_addresses_empty_list(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses with empty address list.""" now = time.monotonic() - dns_cache.cache["example.com"] = ([], now + 60) + dns_cache_fixture._cache["example.com"] = (now + 60, []) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result == [] -def test_resolve_addresses_already_cached(dns_cache: DNSCache) -> None: - """Test resolve_addresses when hostname is already cached.""" +def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses when cache contains an exception.""" now = time.monotonic() - dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + # Store an exception (from failed resolution) + dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed")) - with patch("socket.getaddrinfo") as mock_getaddrinfo: - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - # Should not call getaddrinfo for cached entry - mock_getaddrinfo.assert_not_called() + result = dns_cache_fixture.get_cached_addresses("example.com", now) + assert result is None # Should return None for exceptions -def test_resolve_addresses_not_cached(dns_cache: DNSCache) -> None: - """Test resolve_addresses when hostname needs resolution.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - (None, None, None, None, ("192.168.1.11", 0)), - ] - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10", "192.168.1.11"] - mock_getaddrinfo.assert_called_once_with("example.com", 0) - - # Should be cached now - assert "example.com" in dns_cache.cache - - -def test_resolve_addresses_multiple_hostnames(dns_cache: DNSCache) -> None: - """Test resolve_addresses with multiple hostnames.""" +def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: + """Test that get_cached_addresses never calls async_resolve.""" now = time.monotonic() - dns_cache.cache["cached.com"] = (["192.168.1.10"], now + 60) - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("10.0.0.1", 0)), - ] + with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve: + # Test non-cached + result = dns_cache_fixture.get_cached_addresses("uncached.com", now) + assert result is None + mock_resolve.assert_not_called() - result = dns_cache.resolve_addresses( - "primary.com", ["cached.com", "primary.com", "fallback.com"] - ) - # Should return cached result for first match + # Test expired + dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"]) + result = dns_cache_fixture.get_cached_addresses("expired.com", now) + assert result is None + mock_resolve.assert_not_called() + + # Test valid + dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"]) + result = dns_cache_fixture.get_cached_addresses("valid.com", now) assert result == ["192.168.1.10"] - mock_getaddrinfo.assert_not_called() - - -def test_resolve_addresses_resolution_error(dns_cache: DNSCache) -> None: - """Test resolve_addresses when resolution fails.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.side_effect = OSError("Name resolution failed") - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == [] - # Failed resolution should not be cached - assert "example.com" not in dns_cache.cache - - -def test_resolve_addresses_ipv6_resolution(dns_cache: DNSCache) -> None: - """Test resolve_addresses with IPv6 results.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("2001:db8::1", 0, 0, 0)), - (None, None, None, None, ("fe80::1", 0, 0, 0)), - ] - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["2001:db8::1", "fe80::1"] - - -def test_resolve_addresses_duplicate_removal(dns_cache: DNSCache) -> None: - """Test resolve_addresses removes duplicate addresses.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - (None, None, None, None, ("192.168.1.10", 0)), # Duplicate - (None, None, None, None, ("192.168.1.11", 0)), - ] - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10", "192.168.1.11"] - - -def test_resolve_addresses_hostname_normalization(dns_cache: DNSCache) -> None: - """Test resolve_addresses normalizes hostnames.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - ] - - # Resolve with uppercase and trailing dot - result = dns_cache.resolve_addresses("EXAMPLE.COM.", ["EXAMPLE.COM."]) - assert result == ["192.168.1.10"] - - # Should be cached with normalized name - assert "example.com" in dns_cache.cache - - # Should use cached result for different forms - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - # Only called once due to caching - mock_getaddrinfo.assert_called_once() - - -def test_cache_expiration_ttl(dns_cache: DNSCache) -> None: - """Test that cache entries expire after TTL.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - ] - - # First resolution - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - assert mock_getaddrinfo.call_count == 1 - - # Simulate time passing beyond TTL - with patch("time.monotonic") as mock_time: - mock_time.return_value = time.monotonic() + 301 # TTL is 300 seconds - - # Should trigger new resolution - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - assert mock_getaddrinfo.call_count == 2 + mock_resolve.assert_not_called() diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index b451157922..b20ba6699d 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -153,19 +153,23 @@ def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: def test_async_setup_success(mock_dashboard: Mock) -> None: """Test successful async_setup.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value = Mock() + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.return_value = Mock() + result = mdns_status.async_setup() + assert result is True + assert mdns_status.aiozc is not None def test_async_setup_failure(mock_dashboard: Mock) -> None: """Test async_setup with OSError.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value = Mock() + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.side_effect = OSError("Network error") + result = mdns_status.async_setup() + assert result is False + assert mdns_status.aiozc is None From 384ded539dfb68a150f51ce7816bb32d126887e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:14:51 -0500 Subject: [PATCH 17/26] wip --- tests/dashboard/status/test_mdns.py | 70 ++++++++++++++++------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index b20ba6699d..ad66eed2c2 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import Mock, patch import pytest +import pytest_asyncio from zeroconf import AddressResolver, IPVersion from esphome.dashboard.core import ESPHomeDashboard @@ -23,22 +24,23 @@ def mock_dashboard() -> Mock: return dashboard -@pytest.fixture -def mdns_status(mock_dashboard: Mock) -> MDNSStatus: - """Create an MDNSStatus instance.""" - with patch("asyncio.get_running_loop") as mock_loop: - mock_loop.return_value = Mock() - return MDNSStatus(mock_dashboard) +@pytest_asyncio.fixture +async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: + """Create an MDNSStatus instance in async context.""" + # We're in an async context so get_running_loop will work + return MDNSStatus(mock_dashboard) -def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses when no zeroconf instance is available.""" mdns_status.aiozc = None result = mdns_status.get_cached_addresses("device.local") assert result is None -def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses when address is not in cache.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -53,7 +55,8 @@ def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) -def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses when address is found in cache.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -70,7 +73,8 @@ def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) -def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses with hostname having trailing dot.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -87,7 +91,8 @@ def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None mock_resolver.assert_called_once_with("device.local.") -def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses with uppercase hostname.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -104,7 +109,8 @@ def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> Non mock_resolver.assert_called_once_with("device.local.") -def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses with simple hostname (no domain).""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -121,7 +127,8 @@ def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: mock_resolver.assert_called_once_with("device.local.") -def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses returning only IPv6 addresses.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -136,7 +143,8 @@ def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: assert result == ["fe80::1", "2001:db8::1"] -def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses returning empty list from cache.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -151,25 +159,23 @@ def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: assert result == [] -def test_async_setup_success(mock_dashboard: Mock) -> None: +@pytest.mark.asyncio +async def test_async_setup_success(mock_dashboard: Mock) -> None: """Test successful async_setup.""" - with patch("asyncio.get_running_loop") as mock_loop: - mock_loop.return_value = Mock() - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.return_value = Mock() + result = mdns_status.async_setup() + assert result is True + assert mdns_status.aiozc is not None -def test_async_setup_failure(mock_dashboard: Mock) -> None: +@pytest.mark.asyncio +async def test_async_setup_failure(mock_dashboard: Mock) -> None: """Test async_setup with OSError.""" - with patch("asyncio.get_running_loop") as mock_loop: - mock_loop.return_value = Mock() - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.side_effect = OSError("Network error") + result = mdns_status.async_setup() + assert result is False + assert mdns_status.aiozc is None From 854a4158050aac61b2d8649334cf5fde470c3aca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:17:54 -0500 Subject: [PATCH 18/26] wip --- esphome/dashboard/web_server.py | 81 ++++++++++++----------------- tests/dashboard/conftest.py | 21 ++++++++ tests/dashboard/status/test_mdns.py | 13 ----- 3 files changed, 55 insertions(+), 60 deletions(-) create mode 100644 tests/dashboard/conftest.py diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 2ab449b8da..9524611d76 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -344,62 +344,49 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): entry.name, ) - # Build cache entries for any cached addresses we have - # First check entry.address (use_address) + def add_cache_entry( + hostname: str, addresses: list[str], cache_type: str + ) -> None: + """Add a cache entry to the command arguments.""" + if not addresses: + return + normalized = hostname.rstrip(".").lower() + cache_args.extend( + [ + f"--{cache_type}-address-cache", + f"{normalized}={','.join(sort_ip_addresses(addresses))}", + ] + ) + + # Check entry.address for cached addresses if use_address := entry.address: if use_address.endswith(".local"): - # Check mDNS cache for .local addresses - if mdns := dashboard.mdns_status: - cached = mdns.get_cached_addresses(use_address) - _LOGGER.debug( - "mDNS cache lookup for address %s: %s", use_address, cached - ) - if cached: - normalized = use_address.rstrip(".").lower() - cache_args.extend( - [ - "--mdns-address-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) - else: - # Check DNS cache for non-.local addresses - cached = dashboard.dns_cache.get_cached_addresses(use_address, now) - _LOGGER.debug( - "DNS cache lookup for address %s: %s", use_address, cached - ) - if cached: - normalized = use_address.rstrip(".").lower() - cache_args.extend( - [ - "--dns-address-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) + # mDNS cache for .local addresses + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(use_address) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "mdns") + # DNS cache for non-.local addresses + elif cached := dashboard.dns_cache.get_cached_addresses( + use_address, now + ): + _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "dns") - # Also check entry.name for cache entries + # Check entry.name if we haven't already cached via address # For mDNS devices, entry.name typically doesn't have .local suffix - # but we should check both with and without .local - if ( - entry.name and not use_address - ): # Only if we didn't already check address - # Try mDNS cache with .local suffix + if entry.name and not use_address: mdns_name = ( f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name ) - if mdns := dashboard.mdns_status: - cached = mdns.get_cached_addresses(mdns_name) - _LOGGER.debug("mDNS cache lookup for %s: %s", mdns_name, cached) - if cached: - normalized = mdns_name.rstrip(".").lower() - cache_args.extend( - [ - "--mdns-address-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(mdns_name) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) + add_cache_entry(mdns_name, cached, "mdns") # Cache arguments must come before the subcommand cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py new file mode 100644 index 0000000000..358be1bf5d --- /dev/null +++ b/tests/dashboard/conftest.py @@ -0,0 +1,21 @@ +"""Common fixtures for dashboard tests.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from esphome.dashboard.core import ESPHomeDashboard + + +@pytest.fixture +def mock_dashboard() -> Mock: + """Create a mock dashboard.""" + dashboard = Mock(spec=ESPHomeDashboard) + dashboard.entries = Mock() + dashboard.entries.async_all.return_value = [] + dashboard.stop_event = Mock() + dashboard.stop_event.is_set.return_value = True + dashboard.ping_request = Mock() + return dashboard diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index ad66eed2c2..7130c2c73a 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -8,22 +8,9 @@ import pytest import pytest_asyncio from zeroconf import AddressResolver, IPVersion -from esphome.dashboard.core import ESPHomeDashboard from esphome.dashboard.status.mdns import MDNSStatus -@pytest.fixture -def mock_dashboard() -> Mock: - """Create a mock dashboard.""" - dashboard = Mock(spec=ESPHomeDashboard) - dashboard.entries = Mock() - dashboard.entries.async_all.return_value = [] - dashboard.stop_event = Mock() - dashboard.stop_event.is_set.return_value = True - dashboard.ping_request = Mock() - return dashboard - - @pytest_asyncio.fixture async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: """Create an MDNSStatus instance in async context.""" From 89259661198269f5c92e7d5291e53bc00e4d0442 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:26:18 -0500 Subject: [PATCH 19/26] reorder --- esphome/core/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 476ff1c618..242a6854df 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -6,9 +6,6 @@ import os import re from typing import TYPE_CHECKING -if TYPE_CHECKING: - from esphome.address_cache import AddressCache - from esphome.const import ( CONF_COMMENT, CONF_ESPHOME, @@ -42,6 +39,8 @@ from esphome.helpers import ensure_unique_string, get_str_env, is_ha_addon from esphome.util import OrderedDict if TYPE_CHECKING: + from esphome.address_cache import AddressCache + from ..cpp_generator import MockObj, MockObjClass, Statement from ..types import ConfigType, EntityMetadata From 46c83c8824437da727842afcebc8d5869485dc01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:28:54 -0500 Subject: [PATCH 20/26] fix type --- esphome/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 7eb560646b..d37f549658 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -9,10 +9,14 @@ from pathlib import Path import platform import re import tempfile +from typing import TYPE_CHECKING from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION +if TYPE_CHECKING: + from esphome.address_cache import AddressCache + # Type aliases for socket address information AddrInfo = tuple[ int, # family (AF_INET, AF_INET6, etc.) @@ -174,7 +178,7 @@ def addr_preference_(res: AddrInfo) -> int: def resolve_ip_address( - host: str | list[str], port: int, address_cache: object | None = None + host: str | list[str], port: int, address_cache: AddressCache | None = None ) -> list[AddrInfo]: import socket From a86f35dbb6bc4e541d874ef0010d4f84094e8c15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:34:06 -0500 Subject: [PATCH 21/26] break it up --- esphome/dashboard/web_server.py | 123 ++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 53 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 9524611d76..30b792676a 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -50,8 +50,8 @@ from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader from .const import DASHBOARD_COMMAND -from .core import DASHBOARD -from .entries import UNKNOWN_STATE, entry_state_to_bool +from .core import DASHBOARD, ESPHomeDashboard +from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool from .util.file import write_file from .util.subprocess import async_run_system_command from .util.text import friendly_name_slugify @@ -314,6 +314,73 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): raise NotImplementedError +def build_cache_arguments( + entry: DashboardEntry | None, + dashboard: ESPHomeDashboard, + now: float, +) -> list[str]: + """Build cache arguments for passing to CLI. + + Args: + entry: Dashboard entry for the configuration + dashboard: Dashboard instance with cache access + now: Current monotonic time for DNS cache expiry checks + + Returns: + List of cache arguments to pass to CLI + """ + cache_args: list[str] = [] + + if not entry: + return cache_args + + _LOGGER.debug( + "Building cache for entry (address=%s, name=%s)", + entry.address, + entry.name, + ) + + def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None: + """Add a cache entry to the command arguments.""" + if not addresses: + return + normalized = hostname.rstrip(".").lower() + cache_args.extend( + [ + f"--{cache_type}-address-cache", + f"{normalized}={','.join(sort_ip_addresses(addresses))}", + ] + ) + + # Check entry.address for cached addresses + if use_address := entry.address: + if use_address.endswith(".local"): + # mDNS cache for .local addresses + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(use_address) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "mdns") + # DNS cache for non-.local addresses + elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now): + _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "dns") + + # Check entry.name if we haven't already cached via address + # For mDNS devices, entry.name typically doesn't have .local suffix + if entry.name and not use_address: + mdns_name = ( + f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name + ) + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(mdns_name) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) + add_cache_entry(mdns_name, cached, "mdns") + + return cache_args + + class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): """Base class for commands that require a port.""" @@ -336,57 +403,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and entry.loaded_integrations and "api" in entry.loaded_integrations ): - now = time.monotonic() - _LOGGER.debug( - "Building cache for %s (address=%s, name=%s)", - configuration, - entry.address, - entry.name, - ) - - def add_cache_entry( - hostname: str, addresses: list[str], cache_type: str - ) -> None: - """Add a cache entry to the command arguments.""" - if not addresses: - return - normalized = hostname.rstrip(".").lower() - cache_args.extend( - [ - f"--{cache_type}-address-cache", - f"{normalized}={','.join(sort_ip_addresses(addresses))}", - ] - ) - - # Check entry.address for cached addresses - if use_address := entry.address: - if use_address.endswith(".local"): - # mDNS cache for .local addresses - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(use_address) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "mdns") - # DNS cache for non-.local addresses - elif cached := dashboard.dns_cache.get_cached_addresses( - use_address, now - ): - _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "dns") - - # Check entry.name if we haven't already cached via address - # For mDNS devices, entry.name typically doesn't have .local suffix - if entry.name and not use_address: - mdns_name = ( - f"{entry.name}.local" - if not entry.name.endswith(".local") - else entry.name - ) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(mdns_name) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) - add_cache_entry(mdns_name, cached, "mdns") + cache_args = build_cache_arguments(entry, dashboard, time.monotonic()) # Cache arguments must come before the subcommand cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] From aaeb541bd02981ff245d1cc4aa68032a69b1085a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:38:04 -0500 Subject: [PATCH 22/26] break it out --- tests/dashboard/test_web_server.py | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index a22f4a8b2a..e481a1b273 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -579,3 +579,86 @@ def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: mock_server_class.assert_called_once_with(app) mock_bind.assert_called_once_with(str(socket_path), mode=0o666) server.add_socket.assert_called_once() + + +# Tests for build_cache_arguments function + + +def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: + """Test with no entry returns empty list.""" + result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) + assert result == [] + + +def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None: + """Test with entry but no address or name.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = None + entry.name = None + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + assert result == [] + + +def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None: + """Test with .local address that has cached mDNS results.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = "device.local" + entry.name = None + mock_dashboard.mdns_status = Mock() + mock_dashboard.mdns_status.get_cached_addresses.return_value = [ + "192.168.1.10", + "fe80::1", + ] + + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + + assert result == [ + "--mdns-address-cache", + "device.local=192.168.1.10,fe80::1", + ] + mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( + "device.local" + ) + + +def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None: + """Test with non-.local address that has cached DNS results.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = "example.com" + entry.name = None + mock_dashboard.dns_cache = Mock() + mock_dashboard.dns_cache.get_cached_addresses.return_value = [ + "93.184.216.34", + "2606:2800:220:1:248:1893:25c8:1946", + ] + + now = 100.0 + result = web_server.build_cache_arguments(entry, mock_dashboard, now) + + # IPv6 addresses are sorted before IPv4 + assert result == [ + "--dns-address-cache", + "example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34", + ] + mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with( + "example.com", now + ) + + +def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None: + """Test with name but no address - should check mDNS with .local suffix.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.name = "my-device" + entry.address = None + mock_dashboard.mdns_status = Mock() + mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"] + + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + + assert result == [ + "--mdns-address-cache", + "my-device.local=192.168.1.20", + ] + mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( + "my-device.local" + ) From 674415643487f73deb3e6951da059183f8e426e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:38:18 -0500 Subject: [PATCH 23/26] break it out --- tests/dashboard/test_web_server.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index e481a1b273..891c91a55f 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -581,9 +581,6 @@ def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: server.add_socket.assert_called_once() -# Tests for build_cache_arguments function - - def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: """Test with no entry returns empty list.""" result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) From 0be3387d377e89fd6d0f33596ab55e0312c51ff6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:39:36 -0500 Subject: [PATCH 24/26] break it out --- esphome/__main__.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 3223371c96..137043f626 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1161,19 +1161,9 @@ def run_esphome(argv): CORE.dashboard = args.dashboard # Create address cache from command-line arguments - address_cache = AddressCache.from_cli_args( + CORE.address_cache = AddressCache.from_cli_args( args.mdns_address_cache, args.dns_address_cache ) - - # Store cache in CORE for access throughout the application - CORE.address_cache = address_cache - if address_cache.has_cache(): - _LOGGER.debug( - "Address cache initialized with %d mDNS and %d DNS entries", - len(address_cache.mdns_cache), - len(address_cache.dns_cache), - ) - # Override log level if verbose is set if args.verbose: args.log_level = "DEBUG" From 801c15a1e0a918ca6fb380871b70f0c2cba814ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:43:08 -0500 Subject: [PATCH 25/26] dry --- esphome/helpers.py | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index d37f549658..2b7221355c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -177,6 +177,21 @@ def addr_preference_(res: AddrInfo) -> int: return 1 +def _add_ip_addresses_to_addrinfo( + addresses: list[str], port: int, res: list[AddrInfo] +) -> None: + """Helper to add IP addresses to addrinfo results with error handling.""" + import socket + + for addr in addresses: + try: + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + + def resolve_ip_address( host: str | list[str], port: int, address_cache: AddressCache | None = None ) -> list[AddrInfo]: @@ -203,13 +218,7 @@ def resolve_ip_address( # Fast path: if all hosts are already IP addresses if all(is_ip_address(h) for h in hosts): - for addr in hosts: - try: - res += socket.getaddrinfo( - addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.debug("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(hosts, port, res) # Sort by preference res.sort(key=addr_preference_) return res @@ -237,13 +246,7 @@ def resolve_ip_address( uncached_hosts.append(h) # Process cached addresses (includes direct IPs and cached lookups) - for addr in cached_addresses: - try: - res += socket.getaddrinfo( - addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.debug("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(cached_addresses, port, res) # If we have uncached hosts (only non-IP hostnames), resolve them if uncached_hosts: @@ -296,14 +299,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: # First "resolve" all the IP addresses to getaddrinfo() tuples of the form # (family, type, proto, canonname, sockaddr) res: list[AddrInfo] = [] - for addr in address_list: - # This should always work as these are supposed to be IP addresses - try: - res += socket.getaddrinfo( - addr, 0, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.info("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(address_list, 0, res) # Now use that information to sort them. res.sort(key=addr_preference_) From 4b15421d428d038583d6b8cb48c92096d2becc7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 16:36:57 -0500 Subject: [PATCH 26/26] dry --- esphome/__main__.py | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 29ddabcd19..b04b74c15b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -114,6 +114,14 @@ class Purpose(StrEnum): LOGGING = "logging" +def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: + """Resolve an address using cache if available, otherwise return the address itself.""" + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(address)): + _LOGGER.debug("Using cached addresses for %s: %s", purpose.value, cached) + return cached + return [address] + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -142,14 +150,7 @@ def choose_upload_log_host( (purpose == Purpose.LOGGING and has_api()) or (purpose == Purpose.UPLOADING and has_ota()) ): - # Check if we have cached addresses for CORE.address - if CORE.address_cache and ( - cached := CORE.address_cache.get_addresses(CORE.address) - ): - _LOGGER.debug("Using cached addresses for OTA: %s", cached) - resolved.extend(cached) - else: - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) if purpose == Purpose.LOGGING: if has_api() and has_mqtt_ip_lookup(): @@ -159,32 +160,14 @@ def choose_upload_log_host( resolved.append("MQTT") if has_api() and has_non_ip_address(): - # Check if we have cached addresses for CORE.address - if CORE.address_cache and ( - cached := CORE.address_cache.get_addresses(CORE.address) - ): - _LOGGER.debug( - "Using cached addresses for logging: %s", cached - ) - resolved.extend(cached) - else: - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) elif purpose == Purpose.UPLOADING: if has_ota() and has_mqtt_ip_lookup(): resolved.append("MQTTIP") if has_ota() and has_non_ip_address(): - # Check if we have cached addresses for CORE.address - if CORE.address_cache and ( - cached := CORE.address_cache.get_addresses(CORE.address) - ): - _LOGGER.debug( - "Using cached addresses for uploading: %s", cached - ) - resolved.extend(cached) - else: - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) else: resolved.append(device) if not resolved: