mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 19:13:18 +08:00
166 lines
5.7 KiB
Python
166 lines
5.7 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") 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
|