From 89fc5ebc978325698052579a49c5af780f758b1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Feb 2026 06:18:03 +0100 Subject: [PATCH] Fix bare hostname ping fallback in dashboard (#13760) --- esphome/dashboard/dns.py | 21 +++++++- tests/dashboard/status/test_dns.py | 82 +++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index 58867f7bc1..eb4a87dbfb 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -3,11 +3,16 @@ from __future__ import annotations import asyncio from contextlib import suppress from ipaddress import ip_address +import logging from icmplib import NameLookupError, async_resolve RESOLVE_TIMEOUT = 3.0 +_LOGGER = logging.getLogger(__name__) + +_RESOLVE_EXCEPTIONS = (TimeoutError, NameLookupError, UnicodeError) + async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception: """Wrap the icmplib async_resolve function.""" @@ -16,7 +21,21 @@ async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception: try: async with asyncio.timeout(RESOLVE_TIMEOUT): return await async_resolve(hostname) - except (TimeoutError, NameLookupError, UnicodeError) as ex: + except _RESOLVE_EXCEPTIONS as ex: + # If the hostname ends with .local and resolution failed, + # try the bare hostname as a fallback since mDNS may not be + # working on the system but unicast DNS might resolve it + if hostname.endswith(".local"): + bare_hostname = hostname[:-6] # Remove ".local" + try: + async with asyncio.timeout(RESOLVE_TIMEOUT): + result = await async_resolve(bare_hostname) + _LOGGER.debug( + "Bare hostname %s resolved to %s", bare_hostname, result + ) + return result + except _RESOLVE_EXCEPTIONS: + _LOGGER.debug("Bare hostname %s also failed to resolve", bare_hostname) return ex diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py index 9ca48ba2d8..f7c4992079 100644 --- a/tests/dashboard/status/test_dns.py +++ b/tests/dashboard/status/test_dns.py @@ -3,11 +3,12 @@ from __future__ import annotations import time -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from icmplib import NameLookupError import pytest -from esphome.dashboard.dns import DNSCache +from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper @pytest.fixture @@ -119,3 +120,80 @@ def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: result = dns_cache_fixture.get_cached_addresses("valid.com", now) assert result == ["192.168.1.10"] mock_resolve.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_resolve_wrapper_ip_address() -> None: + """Test _async_resolve_wrapper returns IP address directly.""" + result = await _async_resolve_wrapper("192.168.1.10") + assert result == ["192.168.1.10"] + + result = await _async_resolve_wrapper("2001:db8::1") + assert result == ["2001:db8::1"] + + +@pytest.mark.asyncio +async def test_async_resolve_wrapper_local_fallback_success() -> None: + """Test _async_resolve_wrapper falls back to bare hostname for .local.""" + mock_resolve = AsyncMock() + # First call (device.local) fails, second call (device) succeeds + mock_resolve.side_effect = [ + NameLookupError("device.local"), + ["192.168.1.50"], + ] + + with patch("esphome.dashboard.dns.async_resolve", mock_resolve): + result = await _async_resolve_wrapper("device.local") + + assert result == ["192.168.1.50"] + assert mock_resolve.call_count == 2 + mock_resolve.assert_any_call("device.local") + mock_resolve.assert_any_call("device") + + +@pytest.mark.asyncio +async def test_async_resolve_wrapper_local_fallback_both_fail() -> None: + """Test _async_resolve_wrapper returns exception when both fail.""" + mock_resolve = AsyncMock() + original_exception = NameLookupError("device.local") + mock_resolve.side_effect = [ + original_exception, + NameLookupError("device"), + ] + + with patch("esphome.dashboard.dns.async_resolve", mock_resolve): + result = await _async_resolve_wrapper("device.local") + + # Should return the original exception, not the fallback exception + assert result is original_exception + assert mock_resolve.call_count == 2 + + +@pytest.mark.asyncio +async def test_async_resolve_wrapper_non_local_no_fallback() -> None: + """Test _async_resolve_wrapper doesn't fallback for non-.local hostnames.""" + mock_resolve = AsyncMock() + original_exception = NameLookupError("device.example.com") + mock_resolve.side_effect = original_exception + + with patch("esphome.dashboard.dns.async_resolve", mock_resolve): + result = await _async_resolve_wrapper("device.example.com") + + assert result is original_exception + # Should only try the original hostname, no fallback + assert mock_resolve.call_count == 1 + mock_resolve.assert_called_once_with("device.example.com") + + +@pytest.mark.asyncio +async def test_async_resolve_wrapper_local_success_no_fallback() -> None: + """Test _async_resolve_wrapper doesn't fallback when .local succeeds.""" + mock_resolve = AsyncMock(return_value=["192.168.1.50"]) + + with patch("esphome.dashboard.dns.async_resolve", mock_resolve): + result = await _async_resolve_wrapper("device.local") + + assert result == ["192.168.1.50"] + # Should only try once since it succeeded + assert mock_resolve.call_count == 1 + mock_resolve.assert_called_once_with("device.local")