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)
83 lines
2.3 KiB
Python
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)
|