Ryan Malloy 397b164eee Add ready probe to esp32_connect for reliable startup
The boot event fires early in app_main before the UART command
handler task is fully initialised. This means the first command
after connect can get lost, causing transient ping timeouts.

Now esp32_connect retries a ping (up to 5 attempts, 1s timeout
each) after the boot-event wait, so it only returns "connected"
when the firmware is actually responsive.
2026-02-02 19:45:01 -07:00

194 lines
6.5 KiB
Python

"""Connection management tools for the ESP32 test harness."""
from __future__ import annotations
import asyncio
from typing import Any
from fastmcp import FastMCP
from ..protocol import CMD_GET_INFO, CMD_GET_STATUS, CMD_PING, CMD_RESET, Status
from ..serial_client import CommandTimeout, NotConnected, get_client, init_client
def register_tools(mcp: FastMCP) -> None:
"""Register connection management tools with the MCP server."""
@mcp.tool()
async def esp32_connect(
port: str = "/dev/ttyUSB0",
baudrate: int = 115200,
) -> dict[str, Any]:
"""Connect to an ESP32 over serial UART.
Initialises the serial client (if needed) and opens the connection.
Waits briefly for the ESP32 boot event before returning status.
Args:
port: Serial device path (e.g. "/dev/ttyUSB0", "/dev/ttyACM0")
baudrate: UART baud rate (default 115200)
Returns:
Connection status including port, baudrate, and whether the
device responded with a boot event.
"""
from ..serial_client import get_client_or_none
client = get_client_or_none()
if client is None:
client = init_client(port=port, baudrate=baudrate)
try:
await client.connect()
except Exception as e:
return {"connected": False, "error": str(e), "port": port}
# Give the ESP32 a moment to send its boot event
boot_received = False
try:
event = await client.event_queue.wait_for(event_name="boot", timeout=2.0)
boot_received = event is not None
except TimeoutError:
pass
# Ready probe: retry ping until the firmware's command handler is up.
# The boot event fires early in app_main, before the UART command
# task is fully initialised, so the first command can get lost.
ready = False
for attempt in range(5):
try:
resp = await client.send_command(CMD_PING, timeout=1.0)
if resp.status == Status.OK:
ready = True
break
except (CommandTimeout, NotConnected):
await asyncio.sleep(0.3)
return {
"connected": True,
"port": port,
"baudrate": baudrate,
"boot_event": boot_received,
"ready": ready,
}
@mcp.tool()
async def esp32_disconnect() -> dict[str, Any]:
"""Disconnect from the ESP32 serial device.
Closes the UART connection. The client instance is retained so
a subsequent esp32_connect can reuse it.
Returns:
Disconnection status.
"""
try:
client = get_client()
except NotConnected:
return {"connected": False, "message": "Already disconnected"}
try:
await client.disconnect()
return {"connected": False, "message": "Disconnected"}
except Exception as e:
return {"connected": False, "error": str(e)}
@mcp.tool()
async def esp32_status() -> dict[str, Any]:
"""Query the ESP32 for its current status.
Sends a get_status command and returns the device's reported
state (Bluetooth mode, discoverable, connected peers, etc.).
Returns:
Status dict from the ESP32, or {"connected": false} if the
serial link is down.
"""
try:
client = get_client()
except NotConnected:
return {"connected": False}
try:
resp = await client.send_command(CMD_GET_STATUS)
if resp.status == Status.OK:
return {"connected": True, **resp.data}
return {"connected": True, "status": resp.status, "error": resp.data.get("error", "")}
except NotConnected:
return {"connected": False}
except Exception as e:
return {"connected": True, "error": str(e)}
@mcp.tool()
async def esp32_ping() -> dict[str, Any]:
"""Ping the ESP32 to verify the serial link is alive.
Sends a lightweight ping command and expects a pong response.
Returns:
Response dict (typically {"pong": true}) or an error dict
if the device is unreachable.
"""
try:
client = get_client()
resp = await client.send_command(CMD_PING)
if resp.status == Status.OK:
return {"pong": True, **resp.data}
return {"pong": False, "error": resp.data.get("error", "")}
except NotConnected:
return {"pong": False, "error": "Not connected to ESP32"}
except Exception as e:
return {"pong": False, "error": str(e)}
@mcp.tool()
async def esp32_reset() -> dict[str, Any]:
"""Reset (reboot) the ESP32.
Sends a reset command over UART. The ESP32 will reboot, which
drops the serial link. The client is disconnected automatically;
call esp32_connect again after the device restarts.
Returns:
Reset status. The serial connection will be closed.
"""
try:
client = get_client()
except NotConnected:
return {"reset": False, "error": "Not connected to ESP32"}
try:
resp = await client.send_command(CMD_RESET)
status = resp.status == Status.OK
except Exception:
# The reset itself may kill the link before the response arrives
status = True
# Connection will be lost after reboot, so disconnect cleanly
try:
await client.disconnect()
except Exception:
pass
return {"reset": status, "message": "ESP32 is rebooting — reconnect after restart"}
@mcp.tool()
async def esp32_get_info() -> dict[str, Any]:
"""Retrieve device information from the ESP32.
Returns firmware version, chip model, MAC addresses, and other
hardware details reported by the device.
Returns:
Device information dict, or an error dict on failure.
"""
try:
client = get_client()
resp = await client.send_command(CMD_GET_INFO)
if resp.status == Status.OK:
return resp.data
return {"error": resp.data.get("error", "Failed to get device info")}
except NotConnected:
return {"error": "Not connected to ESP32"}
except Exception as e:
return {"error": str(e)}