Ryan Malloy 6398a5223a ESP32 Bluetooth test harness MCP server
UART-controlled ESP32 peripheral for automated E2E Bluetooth testing.
Dual-mode (Classic BT + BLE) via Bluedroid on original ESP32.

Firmware (ESP-IDF v5.x, 2511 lines C):
- NDJSON protocol over UART1 (115200 baud)
- System commands: ping, reset, get_info, get_status
- Classic BT: GAP, SPP, all 4 SSP pairing modes
- BLE: GATTS, advertising, GATT service/characteristic management
- 6 device personas: headset, speaker, keyboard, sensor, phone, bare
- Event reporter: thread-safe async event queue to host

Python MCP server (FastMCP, 1626 lines):
- Async serial client with command/response correlation
- Event queue with wait_for pattern matching
- Tools: connection, configure, classic, ble, persona, events
- MCP resources: esp32://status, esp32://events, esp32://personas

Tests: 74 unit tests passing, 5 integration test stubs (skip without hardware)
2026-02-02 15:12:28 -07:00

83 lines
2.3 KiB
Python

"""MCP resources -- read-only state queries exposed as esp32:// URIs."""
from __future__ import annotations
import json
from fastmcp import FastMCP
from .serial_client import NotConnected, get_client
# Static persona catalog. Mirrors the presets defined in the ESP32 firmware.
_PERSONAS: dict[str, dict] = {
"headset": {
"name": "ESP32-Headset",
"io_cap": "no_io",
"transport": "classic",
"services": ["handsfree", "a2dp_sink"],
},
"speaker": {
"name": "ESP32-Speaker",
"io_cap": "no_io",
"transport": "classic",
"services": ["a2dp_sink"],
},
"keyboard": {
"name": "ESP32-Keyboard",
"io_cap": "keyboard_display",
"transport": "ble",
"services": ["hid"],
},
"sensor": {
"name": "ESP32-Sensor",
"io_cap": "no_io",
"transport": "ble",
"services": ["battery", "environmental_sensing"],
},
"phone": {
"name": "ESP32-Phone",
"io_cap": "display_yesno",
"transport": "both",
"services": ["gap", "gatt"],
},
"bare": {
"name": "ESP32-BT",
"io_cap": "no_io",
"transport": "both",
"services": [],
},
}
def _event_to_dict(e) -> dict:
return {"event": e.event, "data": e.data, "ts": e.ts}
def register_resources(mcp: FastMCP) -> None:
@mcp.resource("esp32://status")
async def device_status() -> str:
"""Device connection status and Bluetooth state."""
try:
client = get_client()
resp = await client.send_command("get_status")
data = {"connected": True, **resp.data}
except NotConnected:
data = {"connected": False}
return json.dumps(data)
@mcp.resource("esp32://events")
async def recent_events() -> str:
"""Recent event history (last 50 events)."""
try:
client = get_client()
events = client.event_queue.get_events(limit=50)
data = [_event_to_dict(e) for e in events]
except NotConnected:
data = []
return json.dumps(data)
@mcp.resource("esp32://personas")
async def available_personas() -> str:
"""Available device personas and their configurations."""
return json.dumps(_PERSONAS)