mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 13:11:53 +00:00
Initial release v1.0.0 - BLE Device Simulator with MCP Integration
Features: - MCP server with tools for AI-controlled device simulation - FastAPI backend with REST API and WebSocket support - Real-time device updates via WebSocket - Web UI for manual device control - ESP32 firmware for BLE device simulation - Docker containerization with MQTT broker Supported device types: - Heart Rate Monitor (BLE Heart Rate Service) - Treadmill (BLE Fitness Machine Service) - Bike Trainer (BLE Cycling Power Service) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
353
src/mcp/server.py
Normal file
353
src/mcp/server.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@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"]
|
||||
|
||||
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"
|
||||
)]
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user