mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313)
This commit is contained in:
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user