mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 10:52:56 +08:00
487 lines
17 KiB
Python
487 lines
17 KiB
Python
"""Tests for esphome.api_client."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from esphome import api_client
|
|
from esphome.const import (
|
|
CONF_ENCRYPTION,
|
|
CONF_KEY,
|
|
CONF_PORT,
|
|
KEY_CORE,
|
|
KEY_TARGET_PLATFORM,
|
|
)
|
|
from esphome.core import CORE
|
|
|
|
|
|
def test_component_shim_reexports_runtime_client() -> None:
|
|
"""The old import paths must keep working for external code."""
|
|
from esphome.components import api
|
|
from esphome.components.api import client as shim
|
|
|
|
assert shim.run_logs is api_client.run_logs
|
|
assert shim.async_run_logs is api_client.async_run_logs
|
|
assert api.CONF_ENCRYPTION is CONF_ENCRYPTION
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_run_logs_full_flow(caplog) -> None:
|
|
"""Drive async_run_logs end to end with a fake connection.
|
|
|
|
Covers the encryption key extraction, the multi-address banner, the
|
|
registry-miss unavailable notice at session start, the on_log
|
|
handler, and the stop() cleanup in the finally block.
|
|
"""
|
|
caplog.set_level("INFO", logger="esphome.api_client")
|
|
caplog.set_level("INFO", logger="esphome.platform_hooks")
|
|
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "host"}
|
|
config = {
|
|
"esphome": {"name": "test"},
|
|
"api": {CONF_PORT: 6053, CONF_ENCRYPTION: {CONF_KEY: "psk123"}},
|
|
}
|
|
|
|
stop = AsyncMock()
|
|
run_started = asyncio.Event()
|
|
|
|
async def fake_async_run(*args, **kwargs):
|
|
run_started.set()
|
|
return stop
|
|
|
|
mock_run = AsyncMock(side_effect=fake_async_run)
|
|
printed: list[str] = []
|
|
|
|
with (
|
|
patch.object(api_client, "async_run", mock_run),
|
|
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(
|
|
api_client.async_run_logs(config, ["1.2.3.4", "5.6.7.8"])
|
|
)
|
|
# Let the task run up to the forever-wait; the timeout fails the
|
|
# test instead of hanging it if the task dies early.
|
|
async with asyncio.timeout(1):
|
|
await run_started.wait()
|
|
on_log = mock_run.call_args.args[1]
|
|
on_log(Mock(message=b"[I][main:001] hello world\nPC: 0x40104960"))
|
|
# Cancellation is the real termination path; stop() must still run.
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
# Both addresses reach APIClient, along with the noise key.
|
|
assert mock_client.call_args.kwargs["noise_psk"] == "psk123"
|
|
assert mock_client.call_args.kwargs["addresses"] == ["1.2.3.4", "5.6.7.8"]
|
|
assert "1.2.3.4 or 5.6.7.8" in caplog.text
|
|
# host has no stacktrace analyzer; the notice fires at session start.
|
|
assert "Stacktrace analysis is unavailable" in caplog.text
|
|
# The log message was printed with a timestamp prefix.
|
|
assert any("hello world" in line for line in printed)
|
|
# stop() ran in the finally block despite the cancellation.
|
|
stop.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_run_logs_never_resolves_without_crash_lines() -> None:
|
|
"""The headline claim: an ordinary session imports no platform code."""
|
|
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
|
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}}
|
|
|
|
stop = AsyncMock()
|
|
run_started = asyncio.Event()
|
|
|
|
async def fake_async_run(*args, **kwargs):
|
|
run_started.set()
|
|
return stop
|
|
|
|
mock_run = AsyncMock(side_effect=fake_async_run)
|
|
|
|
with (
|
|
patch.object(api_client, "async_run", mock_run),
|
|
patch.object(api_client, "APIClient"),
|
|
patch.object(api_client, "safe_print"),
|
|
patch("esphome.platform_hooks.get_stacktrace_handler") as mock_resolve,
|
|
):
|
|
task = asyncio.get_running_loop().create_task(
|
|
api_client.async_run_logs(config, ["1.2.3.4"])
|
|
)
|
|
async with asyncio.timeout(1):
|
|
await run_started.wait()
|
|
on_log = mock_run.call_args.args[1]
|
|
on_log(Mock(message=b"[I][app:100] hello\n[C][wifi:200] connected"))
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
mock_resolve.assert_not_called()
|
|
|
|
|
|
def test_run_logs_suppresses_keyboard_interrupt() -> None:
|
|
"""Ctrl-C during log streaming exits cleanly instead of tracebacking."""
|
|
with patch.object(
|
|
api_client,
|
|
"async_run_logs",
|
|
AsyncMock(side_effect=KeyboardInterrupt),
|
|
) as mock_run:
|
|
api_client.run_logs(
|
|
{"esphome": {"name": "test"}}, ["1.2.3.4"], subscribe_states=False
|
|
)
|
|
|
|
assert mock_run.call_args.kwargs["subscribe_states"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("extra_config", "expected_deep_sleep"),
|
|
[({"deep_sleep": {}}, True), ({}, False)],
|
|
)
|
|
async def test_async_run_logs_passes_deep_sleep(
|
|
extra_config: dict, expected_deep_sleep: bool
|
|
) -> None:
|
|
"""async_run_logs tells async_run whether the device deep sleeps.
|
|
|
|
That flag is the only thing capping reconnect backoff for a device
|
|
that is only briefly awake; dropping it means missed wake windows.
|
|
"""
|
|
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
|
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
|
|
# async_run blocks forever after connecting; raise to unwind
|
|
# async_run_logs once we have captured how it was called.
|
|
sentinel = RuntimeError("stop the wait")
|
|
|
|
with (
|
|
patch.object(
|
|
api_client, "async_run", AsyncMock(side_effect=sentinel)
|
|
) as mock_run,
|
|
patch.object(api_client, "APIClient"),
|
|
pytest.raises(RuntimeError, match="stop the wait"),
|
|
):
|
|
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()
|