[core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313)

This commit is contained in:
J. Nick Koston
2026-08-13 13:30:46 +12:00
committed by Jesse Hills
parent a14ea0e8fa
commit c1a326f32e
6 changed files with 982 additions and 96 deletions
+322 -1
View File
@@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None:
with (
patch.object(api_client, "async_run", mock_run),
patch.object(api_client, "APIClient") as mock_client,
patch.object(api_client, "APIClient", autospec=True) as mock_client,
patch.object(api_client, "safe_print", printed.append),
):
task = asyncio.get_running_loop().create_task(
@@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep(
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@pytest.mark.asyncio
async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None:
"""Addresses discovered via MQTT are fed into the running client."""
caplog.set_level("INFO", logger="esphome.api_client")
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
fed = asyncio.Event()
def resolver(stop_event):
return ["10.0.0.9", "10.0.0.10"]
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
patch.object(api_client, "APIClient", autospec=True) as mock_client,
):
mock_client.return_value.add_addresses.side_effect = lambda addrs: (
fed.set() or True
)
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
async with asyncio.timeout(1):
await fed.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_client.return_value.add_addresses.assert_called_once_with(
["10.0.0.9", "10.0.0.10"]
)
assert "Discovered address(es) via MQTT" in caplog.text
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None:
"""A resolver returning nothing (failed lookup) leaves the session running."""
import threading
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
resolver_ran = threading.Event()
def resolver(stop_event):
# The resolver owns failure handling; a failed lookup returns []
resolver_ran.set()
return []
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
patch.object(api_client, "APIClient", autospec=True) as mock_client,
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.to_thread(resolver_ran.wait, 1)
await asyncio.sleep(0)
assert not task.done()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_client.return_value.add_addresses.assert_not_called()
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None:
"""Teardown sets the resolver's stop event so the thread exits promptly."""
import threading
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
captured_event: threading.Event | None = None
resolver_started = threading.Event()
def resolver(stop_event):
nonlocal captured_event
captured_event = stop_event
resolver_started.set()
# Simulate a slow broker lookup that only ends via the stop event.
stop_event.wait(timeout=5)
return []
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
patch.object(api_client, "APIClient", autospec=True),
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.to_thread(resolver_started.wait, 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert captured_event is not None
assert captured_event.is_set()
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None:
"""A resolver raising unexpectedly must not skip stop() at teardown."""
import threading
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
resolver_ran = threading.Event()
def resolver(stop_event):
resolver_ran.set()
raise RuntimeError("resolver blew up")
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
patch.object(api_client, "APIClient", autospec=True),
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.to_thread(resolver_ran.wait, 1)
await asyncio.sleep(0.05)
assert not task.done()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert "MQTT address discovery failed" in caplog.text
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None:
"""A successful connection stops the in-flight broker lookup."""
import threading
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
captured_event: threading.Event | None = None
resolver_started = threading.Event()
def resolver(stop_event):
nonlocal captured_event
captured_event = stop_event
resolver_started.set()
stop_event.wait(timeout=5)
return []
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run,
patch.object(api_client, "APIClient", autospec=True) as mock_client,
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.to_thread(resolver_started.wait, 1)
# The runner reports a successful connection
on_connect = mock_run.call_args.kwargs["on_connect"]
on_connect()
await asyncio.sleep(0.05)
assert captured_event is not None
assert captured_event.is_set()
assert not task.done()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_client.return_value.add_addresses.assert_not_called()
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None:
"""A connection during async_run startup prevents the lookup from starting."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
resolver = Mock(name="resolver")
async def fake_async_run(*args, **kwargs):
# Connection succeeds before async_run even returns
kwargs["on_connect"]()
return stop
with (
patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)),
patch.object(api_client, "APIClient", autospec=True),
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.sleep(0.05)
assert not task.done()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
resolver.assert_not_called()
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None:
"""A discovery the client rejects as already known leaves a debug trace."""
import threading
caplog.set_level("DEBUG", logger="esphome.api_client")
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
fed = threading.Event()
def resolver(stop_event):
return ["1.2.3.4"]
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
patch.object(api_client, "APIClient", autospec=True) as mock_client,
):
mock_client.return_value.add_addresses.side_effect = lambda addrs: (
fed.set() or False
)
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.to_thread(fed.wait, 1)
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"])
assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text
assert "Discovered address(es) via MQTT" not in caplog.text
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None:
"""A BaseException escaping the worker is reported, and stop() still runs."""
import threading
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
resolver_ran = threading.Event()
class WorkerEscape(BaseException):
"""Not an Exception, so the task-level guard must not catch it."""
def resolver(stop_event):
resolver_ran.set()
raise WorkerEscape("worker bailed")
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
patch.object(api_client, "APIClient", autospec=True),
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.to_thread(resolver_ran.wait, 1)
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert "MQTT address discovery failed" in caplog.text
stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None:
"""A worker that ignores the stop event is cancelled after the grace period."""
import threading
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
stop = AsyncMock()
resolver_ran = threading.Event()
release = threading.Event()
def resolver(stop_event):
resolver_ran.set()
# Ignore stop_event entirely; only the test releases us
release.wait(timeout=10)
return []
with (
patch.object(api_client, "async_run", AsyncMock(return_value=stop)),
patch.object(api_client, "APIClient", autospec=True),
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver)
)
await asyncio.to_thread(resolver_ran.wait, 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
release.set()
stop.assert_awaited_once()
+133 -38
View File
@@ -25,6 +25,7 @@ from esphome.__main__ import (
_make_crystal_freq_callback,
_redact_with_legacy_fallback,
_resolve_network_devices,
_split_network_devices,
_unresolved_default_error,
_validate_bootloader_binary,
_validate_partition_table_binary,
@@ -2879,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution(
assert exit_code == 0
assert host == "192.168.1.100"
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
mock_mqtt_get_ip.assert_called_once_with(
config, "user", "pass", "client", stop_event=None
)
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
@@ -2926,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker(
assert exit_code == 0
assert host == "192.168.1.50"
# Verify MQTT was attempted but failed gracefully
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
mock_mqtt_get_ip.assert_called_once_with(
config, "user", "pass", "client", stop_event=None
)
# Verify we fell back to the IP address
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
@@ -3015,7 +3020,10 @@ def test_show_logs_api(
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True
CORE.config,
["192.168.1.100", "192.168.1.101"],
subscribe_states=True,
mqtt_resolver=None,
)
@@ -3042,7 +3050,7 @@ def test_show_logs_api_no_states(
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=False
CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None
)
@@ -3069,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled(
assert result == 0
# Should use the FQDN directly, not try MQTT lookup
mock_run_logs.assert_called_once_with(
CORE.config, ["device.example.com"], subscribe_states=True
CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None
)
@@ -3097,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback(
result = show_logs(CORE.config, args, devices)
assert result == 0
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
mock_mqtt_get_ip.assert_called_once_with(
CORE.config, "user", "pass", "client", stop_event=None
)
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.200"], subscribe_states=True
CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None
)
@patch("esphome.mqtt.show_logs")
def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs(
mock_mqtt_show_logs: Mock,
mock_mqtt_get_ip: Mock,
) -> None:
"""With no addresses at all after a failed MQTT lookup, MQTT logging is used."""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
platform=PLATFORM_ESP32,
)
mock_mqtt_show_logs.return_value = 0
mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT")
args = MockArgs(
topic="esphome/logs", username="user", password="pass", client_id="client"
)
devices = ["MQTT", "MQTTIP"]
result = show_logs(CORE.config, args, devices)
assert result == 0
mock_mqtt_get_ip.assert_called_once_with(
CORE.config, "user", "pass", "client", stop_event=None
)
mock_mqtt_show_logs.assert_called_once_with(
CORE.config, "esphome/logs", "user", "pass", "client"
)
@@ -3466,7 +3509,9 @@ def test_mqtt_get_ip() -> None:
result = mqtt_get_ip(config, "user", "pass", "client-id")
assert result == ["192.168.1.100", "192.168.1.101"]
mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id")
mock_get_ip.assert_called_once_with(
config, "user", "pass", "client-id", stop_event=None
)
def test_has_resolvable_address() -> None:
@@ -3847,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None:
assert result == ["unknown.local", "192.168.1.50"]
def test_split_network_devices_direct_only(tmp_path: Path) -> None:
"""Direct addresses pass through deduped, with no MQTT flag."""
setup_core(tmp_path=tmp_path)
assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == (
["192.168.1.50", "device.local"],
False,
)
def test_split_network_devices_mqtt_only(tmp_path: Path) -> None:
"""MQTT magic strings produce no direct addresses, only the flag."""
setup_core(tmp_path=tmp_path)
assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True)
def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None:
"""Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices."""
setup_core(tmp_path=tmp_path)
CORE.address_cache = AddressCache(
mdns_cache={
"device-abc123.local": ["10.0.0.1", "10.0.0.2"],
}
)
assert _split_network_devices(
["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"]
) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True)
def test_await_discovery_timeout_returns_empty(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -5022,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip(
assert host == "192.168.1.100"
# Verify MQTT was resolved
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
mock_mqtt_get_ip.assert_called_once_with(
config, "user", "pass", "client", stop_event=None
)
# Verify espota2.run_ota was called with both IPs
expected_firmware = (
@@ -5069,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once(
assert host == "192.168.2.50"
# Verify MQTT was only resolved once despite multiple MQTT magic strings
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
mock_mqtt_get_ip.assert_called_once_with(
config, "user", "pass", "client", stop_event=None
)
# Verify espota2.run_ota was called with all unique IPs
expected_firmware = (
@@ -5116,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication(
assert host == "192.168.1.100"
# Verify MQTT was resolved
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
mock_mqtt_get_ip.assert_called_once_with(
config, "user", "pass", "client", stop_event=None
)
# Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100)
# Note: Current implementation doesn't dedupe, so we'll get the IP twice
@@ -5136,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip(
This tests the scenario where a device has manual_ip (static IP) configured
and MQTT is also configured. The devices list contains both the static IP
and "MQTTIP" magic string.
and "MQTTIP" magic string. The MQTT lookup must not block startup; it is
handed to run_logs as a deferred resolver instead (issue #18311), while
still being reachable as a fallback for a stale static IP.
"""
setup_core(
config={
@@ -5157,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip(
assert result == 0
# Verify MQTT was resolved
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# The broker must not be contacted before run_logs starts
mock_mqtt_get_ip.assert_not_called()
# Verify run_logs was called with both IPs
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True
# run_logs gets the static IP immediately plus a deferred MQTT resolver
mock_run_logs.assert_called_once()
assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"])
assert mock_run_logs.call_args.kwargs["subscribe_states"] is True
resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"]
# Invoking the resolver performs the MQTT lookup (the #11260 fallback)
assert resolver(None) == ["192.168.2.50"]
mock_mqtt_get_ip.assert_called_once_with(
CORE.config, "user", "pass", "client", stop_event=None
)
@@ -5171,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
) -> None:
"""Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings."""
"""Test that multiple MQTT magic strings collapse into one deferred resolver."""
setup_core(
config={
"logger": {},
@@ -5191,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once(
assert result == 0
# Verify MQTT was only resolved once despite multiple MQTT magic strings
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# Note: "MQTT" is a different magic string from "MQTTIP", but both defer
# to the same single resolver; the broker is not contacted eagerly
mock_mqtt_get_ip.assert_not_called()
mock_run_logs.assert_called_once()
assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"])
# Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs)
# Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution
# The _resolve_network_devices helper filters out both after first resolution
mock_run_logs.assert_called_once_with(
CORE.config,
["192.168.2.50", "192.168.2.51", "192.168.1.100"],
subscribe_states=True,
resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"]
assert resolver(None) == ["192.168.2.50", "192.168.2.51"]
mock_mqtt_get_ip.assert_called_once_with(
CORE.config, "user", "pass", "client", stop_event=None
)
@@ -5238,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback(
assert host == "192.168.1.100"
# Verify MQTT was attempted
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
mock_mqtt_get_ip.assert_called_once_with(
config, "user", "pass", "client", stop_event=None
)
# Verify espota2.run_ota was called with only the static IP (MQTT failed)
expected_firmware = (
@@ -5254,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
) -> None:
"""Test show_logs falls back to other devices when MQTT times out."""
"""Test show_logs proceeds with the static IP when MQTT times out."""
setup_core(
config={
"logger": {},
@@ -5273,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback(
result = show_logs(CORE.config, args, devices)
# Should succeed using the static IP even though MQTT failed
# Logs start on the static IP without waiting for the broker
assert result == 0
mock_run_logs.assert_called_once()
assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"])
# Verify MQTT was attempted
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# Verify run_logs was called with only the static IP (MQTT failed)
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
# The deferred resolver owns the failure policy: it logs a warning and
# returns no addresses so the session keeps running on the known ones
resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"]
assert resolver(None) == []
mock_mqtt_get_ip.assert_called_once_with(
CORE.config, "user", "pass", "client", stop_event=None
)
@@ -6764,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs(
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=False
CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None
)
@@ -6805,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true(
assert result == 0
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None
)
+262
View File
@@ -2,6 +2,11 @@
from __future__ import annotations
import json
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME
@@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None:
match="Cannot discover IP via MQTT as the config does not include the device name:",
):
get_esphome_device_ip(config)
def _discovery_config() -> dict:
return {
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None:
"""Deliver a discovery answer as soon as the network loop starts."""
def deliver(*args, **kwargs):
msg = MagicMock()
msg.payload = payload
mock_prepare.call_args.args[2](client, None, msg)
client.loop_start.side_effect = deliver
def test_get_esphome_device_ip_success() -> None:
"""A device answer on the discovery topic returns its IPs."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare,
client,
json.dumps(
{"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"}
).encode(),
)
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5", "10.0.0.6"]
client.loop_stop.assert_called_once_with()
# Once from on_message on receiving the answer, once from the finally
assert client.disconnect.call_count == 2
def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None:
"""A stop event set before the call returns [] without touching the broker."""
stop_event = threading.Event()
stop_event.set()
with patch("esphome.mqtt.prepare") as mock_prepare:
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
assert result == []
mock_prepare.assert_not_called()
def test_get_esphome_device_ip_stop_event_aborts_wait() -> None:
"""A stop event set mid-wait exits quietly with no addresses."""
stop_event = threading.Event()
client = MagicMock()
# Simulate teardown starting right after the network loop spins up
client.loop_start.side_effect = stop_event.set
start = time.monotonic()
with patch("esphome.mqtt.prepare", return_value=client):
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
# An abort is not a failure and must be nowhere near the 25s timeout
assert result == []
assert time.monotonic() - start < 5
client.disconnect.assert_called_once_with()
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_timeout_raises() -> None:
"""No answer within the timeout raises EsphomeError (default stop event path)."""
client = MagicMock()
with (
patch("esphome.mqtt.prepare", return_value=client),
pytest.raises(EsphomeError, match="Failed to find IP via MQTT"),
):
get_esphome_device_ip(_discovery_config(), timeout=0.25)
client.disconnect.assert_called_once_with()
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None:
"""A stop event set while the broker connect is in flight still cleans up."""
stop_event = threading.Event()
client = MagicMock()
def prepare_and_stop(*args):
stop_event.set()
return client
with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop):
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
assert result == []
client.loop_start.assert_not_called()
client.disconnect.assert_called_once_with()
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_replaces_reconnect_handler(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The one-shot discovery client must not inherit the reconnect-forever
handler, which would make loop_stop() join the network thread forever;
its replacement still reports a broker-initiated disconnect."""
client = MagicMock()
prepare_handler = MagicMock()
client.on_disconnect = prepare_handler
with (
patch("esphome.mqtt.prepare", return_value=client),
pytest.raises(EsphomeError, match="Failed to find IP via MQTT"),
):
get_esphome_device_ip(_discovery_config(), timeout=0.25)
assert client.on_disconnect is not prepare_handler
client.on_disconnect(client, None, 0)
assert "Disconnected from MQTT broker" not in caplog.text
client.on_disconnect(client, None, 5)
assert "Disconnected from MQTT broker (5)" in caplog.text
def test_get_esphome_device_ip_answer_without_ip_fails_fast(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A device answer with no IP fields fails promptly, not at the timeout."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare, client, json.dumps({"name": "test-device"}).encode()
)
start = time.monotonic()
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
get_esphome_device_ip(_discovery_config(), timeout=5)
assert time.monotonic() - start < 1
assert "Device answer did not include an IP address" in caplog.text
@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"])
def test_get_esphome_device_ip_unparsable_payload_ignored(
caplog: pytest.LogCaptureFixture,
payload: bytes,
) -> None:
"""Garbage on the discovery topic must not kill paho's network thread."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(mock_prepare, client, payload)
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
get_esphome_device_ip(_discovery_config(), timeout=0)
assert "Ignoring unparsable discovery payload" in caplog.text
def test_get_esphome_device_ip_broker_disconnect_fails_fast(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A broker-initiated disconnect aborts the wait instead of timing out."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client):
def drop_connection(*args, **kwargs):
client.on_disconnect(client, None, 5)
client.loop_start.side_effect = drop_connection
start = time.monotonic()
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
get_esphome_device_ip(_discovery_config(), timeout=5)
assert time.monotonic() - start < 1
assert "Disconnected from MQTT broker (5)" in caplog.text
def test_get_esphome_device_ip_sends_discovery_ping() -> None:
"""Connecting publishes the discovery ping for the device."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
def connect_then_answer(*args, **kwargs):
on_connect = mock_prepare.call_args.args[3]
on_connect(client, None, None, 0)
msg = MagicMock()
msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode()
mock_prepare.call_args.args[2](client, None, msg)
client.loop_start.side_effect = connect_then_answer
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5"]
client.publish.assert_called_once_with(
"esphome/ping/test-device", None, retain=False
)
def test_get_esphome_device_ip_disconnect_error_does_not_mask_result(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A cleanup failure must not replace the discovery result."""
client = MagicMock()
# First disconnect (from on_message) succeeds; the finally's fails
client.disconnect.side_effect = [None, OSError("socket already closed")]
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare,
client,
json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(),
)
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5"]
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_invalid_address_values_skipped(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Non-string or non-printable ip values are skipped, valid ones kept."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare,
client,
json.dumps(
{
"name": "test-device",
"ip": 1234,
"ip1": "x\n[00:00:00][I][forged] fake line",
"ip2": " 10.0.0.5 ",
}
).encode(),
)
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5"]
assert caplog.text.count("Ignoring invalid address in discovery answer") == 2
assert "forged" not in "".join(
r.getMessage() for r in caplog.records if "Found IP" in r.getMessage()
)