mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 10:06:03 +00:00
148 lines
5.8 KiB
Python
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)}")
|