Files
esphome/tests/integration/test_improv_serial_uart.py
T

148 lines
5.8 KiB
Python

"""Integration test for improv_serial over a mocked UART bus.
Drives the improv serial protocol end to end on the host platform:
the fixture wires improv_serial to a uart_mock bus and shadows the wifi
component with a host stub. The test injects improv frames through an API
action and asserts on the framed responses that uart_mock logs as TX lines.
Covered:
1. Get Current State reports AUTHORIZED
2. Get Device Info returns the firmware/device info RPC response
3. Get Wi-Fi Networks returns deduplicated scan results and a terminator
4. Wi-Fi Settings provisions: saves credentials and reports PROVISIONED
"""
from __future__ import annotations
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
# Improv serial framing (improv_serial_component.h)
IMPROV_HEADER = b"IMPROV"
IMPROV_VERSION = 1
TYPE_CURRENT_STATE = 0x01
TYPE_RPC = 0x03
TYPE_RPC_RESPONSE = 0x04
# improv::Command values
CMD_GET_CURRENT_STATE = 0x02
CMD_GET_DEVICE_INFO = 0x03
CMD_GET_WIFI_NETWORKS = 0x04
CMD_WIFI_SETTINGS = 0x01
def build_rpc_frame(command: int, data: bytes = b"") -> list[int]:
"""Build a full improv serial frame carrying one RPC command."""
payload = bytes([command, len(data)]) + data
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC, len(payload)]) + payload
checksum = sum(frame) & 0xFF
return list(frame + bytes([checksum]) + b"\n")
def state_frame_hex(state: int) -> str:
"""Full 12 byte current-state frame as hex, checksum and newline included."""
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_CURRENT_STATE, 1, state])
checksum = sum(frame) & 0xFF
return ":".join(f"{b:02X}" for b in frame + bytes([checksum]) + b"\n")
def rpc_footer_hex(payload: bytes) -> str:
"""Checksum and newline footer written after an RPC response payload."""
header = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC_RESPONSE, len(payload)])
checksum = (sum(header) + sum(payload)) & 0xFF
return f"{checksum:02X}:0A"
def wifi_settings_data(ssid: str, password: str) -> bytes:
ssid_b = ssid.encode()
pass_b = password.encode()
return bytes([len(ssid_b)]) + ssid_b + bytes([len(pass_b)]) + pass_b
def hex_of(text: str) -> str:
"""Colon separated uppercase hex as logged by format_hex_pretty."""
return ":".join(f"{b:02X}" for b in text.encode())
@pytest.mark.asyncio
async def test_improv_serial_uart(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
_entities, services = await client.list_entities_services()
inject = next(s for s in services if s.name == "uart_inject")
# 1. Get Current State: expect the complete current-state frame reporting
# AUTHORIZED (0x02), checksum and newline included
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_CURRENT_STATE)}
)
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x02)}")
# 2. Get Device Info: the always logged 9 byte response header, then the
# payload with the firmware name (must stay under uart_mock's 64 byte
# hex dump cap or the payload line reads "too large to log")
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_DEVICE_INFO)}
)
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04")
await waiter.wait_for("uart_mock", "TX ", hex_of("ESPHome"))
# 3. Get Wi-Fi Networks: stub scan has TestNet twice (dedup keeps the
# stronger), OpenNet, and a hidden entry (filtered). Expect one response
# per visible network plus the empty terminator.
await client.execute_service(
inject, {"payload": build_rpc_frame(CMD_GET_WIFI_NETWORKS)}
)
await waiter.wait_for("uart_mock", hex_of("TestNet"))
await waiter.wait_for("uart_mock", hex_of("OpenNet"))
# Terminator: all three writes of the response frame; 9 byte header,
# payload [0x04, 0x00, 0x00], then the checksum and newline footer
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04:03")
await waiter.wait_for("uart_mock", "TX 3 bytes: 04:00:00")
await waiter.wait_for(
"uart_mock", f"TX 2 bytes: {rpc_footer_hex(bytes([0x04, 0x00, 0x00]))}"
)
testnet_count = sum(
1
for line in waiter.lines
if "uart_mock" in line and "TX " in line and hex_of("TestNet") in line
)
assert testnet_count == 1, (
f"Duplicate scan entry not deduplicated: {testnet_count} TestNet responses"
)
# 4. Wi-Fi Settings: stub connects immediately; expect the credentials
# saved, the PROVISIONED state frame (0x04), and the settings response
await client.execute_service(
inject,
{
"payload": build_rpc_frame(
CMD_WIFI_SETTINGS, wifi_settings_data("NewNet", "secret123")
)
},
)
await waiter.wait_for("save_wifi_sta ssid=NewNet")
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x04)}")
# Settings RPC response carries the formatted next_url and its footer
next_url = b"https://example.com/?device=improv-uart"
payload = (
bytes([CMD_WIFI_SETTINGS, len(next_url) + 1, len(next_url)])
+ next_url
+ b"\x00"
)
await waiter.wait_for(
"uart_mock",
f"TX {len(payload)} bytes: " + ":".join(f"{b:02X}" for b in payload),
)
await waiter.wait_for("uart_mock", f"TX 2 bytes: {rpc_footer_hex(payload)}")