Files
pyBTMCP/src/mcp/server.py
Matt Hills aab2526f99 v1.4.0: BLE disconnect simulation with teardown support
Features:
- Add BLE disconnect simulation to test client reconnection behavior
- Support immediate disconnect, timed advertising pause, and full BLE teardown
- Add preset test buttons in admin UI (Quick Drop, 5s Pause, BLE Restart, 10s Restart)
- Add simulate_ble_disconnect MCP tool with duration_ms and teardown params
- Add POST /devices/{id}/disconnect API endpoint

Fixes:
- Fix treadmill distance not updating in admin UI (publish values periodically)
- Fix HR variation sending to non-heart_rate devices
- Fix BLE connection ID 0 being treated as "not connected"
- Clean up misleading startup log messages

Firmware: 1.1.0, API: 1.4.0, UI: 1.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 18:35:03 -05:00

419 lines
14 KiB
Python

"""MCP Server for BLE device simulator control."""
import asyncio
import json
import os
import aiomqtt
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
# Device state (shared with API via MQTT)
devices: dict[str, dict] = {}
# MQTT client for this server
mqtt_client: aiomqtt.Client | None = None
def get_mqtt_host() -> str:
return os.getenv("MQTT_HOST", "localhost")
def get_mqtt_port() -> int:
return int(os.getenv("MQTT_PORT", "1883"))
# Create MCP server
server = Server("ble-simulator")
@server.list_tools()
async def list_tools() -> list[Tool]:
"""List available MCP tools."""
return [
Tool(
name="list_devices",
description="List all connected ESP32 BLE simulator devices",
inputSchema={
"type": "object",
"properties": {},
"required": [],
},
),
Tool(
name="configure_device",
description="Configure an ESP32 to simulate a specific BLE device type",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
"device_type": {
"type": "string",
"enum": ["heart_rate", "treadmill", "bike"],
"description": "Type of BLE device to simulate",
},
},
"required": ["device_id", "device_type"],
},
),
Tool(
name="set_heart_rate",
description="Set the simulated heart rate value for a device configured as a heart rate monitor",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
"bpm": {
"type": "integer",
"minimum": 30,
"maximum": 220,
"description": "Heart rate in beats per minute",
},
},
"required": ["device_id", "bpm"],
},
),
Tool(
name="set_treadmill_values",
description="Set simulated values for a device configured as a treadmill",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
"speed": {
"type": "number",
"minimum": 0,
"maximum": 25,
"description": "Speed in km/h",
},
"incline": {
"type": "number",
"minimum": -5,
"maximum": 30,
"description": "Incline percentage",
},
},
"required": ["device_id"],
},
),
Tool(
name="set_bike_values",
description="Set simulated values for a device configured as a bike/cycling trainer",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
"power": {
"type": "integer",
"minimum": 0,
"maximum": 2000,
"description": "Power in watts",
},
"cadence": {
"type": "integer",
"minimum": 0,
"maximum": 200,
"description": "Cadence in RPM",
},
"speed": {
"type": "number",
"minimum": 0,
"maximum": 80,
"description": "Speed in km/h",
},
},
"required": ["device_id"],
},
),
Tool(
name="get_device_status",
description="Get the current status and values of a specific device",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
},
"required": ["device_id"],
},
),
Tool(
name="simulate_ble_disconnect",
description="Simulate a BLE client disconnection to test reconnection behavior",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
"duration_ms": {
"type": "integer",
"minimum": 0,
"maximum": 60000,
"default": 0,
"description": "How long to pause advertising after disconnect (0 = immediate resume)",
},
"teardown": {
"type": "boolean",
"default": False,
"description": "Full BLE stack teardown - device completely disappears from scans",
},
},
"required": ["device_id"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
"""Handle tool calls."""
global mqtt_client
# Ensure MQTT is connected
if mqtt_client is None:
try:
mqtt_client = aiomqtt.Client(
hostname=get_mqtt_host(),
port=get_mqtt_port()
)
await mqtt_client.__aenter__()
except Exception as e:
return [TextContent(
type="text",
text=f"Failed to connect to MQTT broker: {e}"
)]
try:
if name == "list_devices":
return [TextContent(
type="text",
text=json.dumps({
"devices": list(devices.values()),
"count": len(devices)
}, indent=2)
)]
elif name == "configure_device":
device_id = arguments["device_id"]
device_type = arguments["device_type"]
await mqtt_client.publish(
f"ble-sim/{device_id}/config",
json.dumps({"type": device_type})
)
# Update local state
if device_id not in devices:
devices[device_id] = {"id": device_id}
devices[device_id]["type"] = device_type
devices[device_id]["values"] = {}
return [TextContent(
type="text",
text=f"Configured {device_id} as {device_type}"
)]
elif name == "set_heart_rate":
device_id = arguments["device_id"]
bpm = arguments["bpm"]
# Validate BPM range
if not (30 <= bpm <= 220):
return [TextContent(
type="text",
text=f"Error: BPM must be between 30 and 220, got {bpm}"
)]
await mqtt_client.publish(
f"ble-sim/{device_id}/set",
json.dumps({"heart_rate": bpm})
)
if device_id in devices:
devices[device_id].setdefault("values", {})["heart_rate"] = bpm
return [TextContent(
type="text",
text=f"Set heart rate to {bpm} BPM on {device_id}"
)]
elif name == "set_treadmill_values":
device_id = arguments["device_id"]
values = {}
if "speed" in arguments:
values["speed"] = arguments["speed"]
if "incline" in arguments:
values["incline"] = arguments["incline"]
await mqtt_client.publish(
f"ble-sim/{device_id}/set",
json.dumps(values)
)
if device_id in devices:
devices[device_id].setdefault("values", {}).update(values)
return [TextContent(
type="text",
text=f"Set treadmill values on {device_id}: {values}"
)]
elif name == "set_bike_values":
device_id = arguments["device_id"]
values = {}
if "power" in arguments:
values["power"] = arguments["power"]
if "cadence" in arguments:
values["cadence"] = arguments["cadence"]
if "speed" in arguments:
values["speed"] = arguments["speed"]
await mqtt_client.publish(
f"ble-sim/{device_id}/set",
json.dumps(values)
)
if device_id in devices:
devices[device_id].setdefault("values", {}).update(values)
return [TextContent(
type="text",
text=f"Set bike values on {device_id}: {values}"
)]
elif name == "get_device_status":
device_id = arguments["device_id"]
device = devices.get(device_id)
if device:
return [TextContent(
type="text",
text=json.dumps(device, indent=2)
)]
else:
return [TextContent(
type="text",
text=f"Device {device_id} not found"
)]
elif name == "simulate_ble_disconnect":
device_id = arguments["device_id"]
duration_ms = arguments.get("duration_ms", 0)
teardown = arguments.get("teardown", False)
payload = {}
if duration_ms > 0:
payload["duration_ms"] = duration_ms
if teardown:
payload["teardown"] = True
await mqtt_client.publish(
f"ble-sim/{device_id}/disconnect",
json.dumps(payload)
)
if teardown:
return [TextContent(
type="text",
text=f"BLE stack teardown on {device_id}, will reinit in {duration_ms if duration_ms > 0 else 3000}ms"
)]
elif duration_ms > 0:
return [TextContent(
type="text",
text=f"Disconnected BLE on {device_id}, advertising paused for {duration_ms}ms"
)]
else:
return [TextContent(
type="text",
text=f"Disconnected BLE on {device_id}, advertising resumed immediately"
)]
else:
return [TextContent(
type="text",
text=f"Unknown tool: {name}"
)]
except Exception as e:
return [TextContent(
type="text",
text=f"Error: {e}"
)]
async def handle_mqtt_messages():
"""Background task to handle incoming MQTT status messages."""
global mqtt_client
try:
host = get_mqtt_host()
port = get_mqtt_port()
async with aiomqtt.Client(hostname=host, port=port) as client:
await client.subscribe("ble-sim/+/status")
await client.subscribe("ble-sim/+/values")
async for message in client.messages:
topic_parts = str(message.topic).split("/")
if len(topic_parts) >= 3:
device_id = topic_parts[1]
msg_type = topic_parts[2]
try:
payload = json.loads(message.payload.decode())
if device_id not in devices:
devices[device_id] = {"id": device_id}
if msg_type == "status":
devices[device_id]["online"] = payload.get("online", True)
devices[device_id]["type"] = payload.get("type")
elif msg_type == "values":
devices[device_id].setdefault("values", {}).update(payload)
except json.JSONDecodeError:
pass
except Exception as e:
print(f"MQTT listener error: {e}", flush=True)
async def main():
"""Run the MCP server."""
# Start MQTT listener in background
mqtt_task = asyncio.create_task(handle_mqtt_messages())
try:
# Run MCP server on stdio
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
finally:
mqtt_task.cancel()
try:
await mqtt_task
except asyncio.CancelledError:
pass
if __name__ == "__main__":
asyncio.run(main())