mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 10:41:54 +00:00
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>
419 lines
14 KiB
Python
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())
|