Files
pyBTMCP/src/api/main.py
Matt Hills 1c6b9db903 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>
2026-01-19 11:50:10 -05:00

169 lines
5.1 KiB
Python

"""FastAPI application for BLE device simulator control."""
import json
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
# Version info
__version__ = "1.0.0"
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os
from .routes import router
from .mqtt_client import mqtt_manager
from .device_registry import device_registry
class ConnectionManager:
"""Manages WebSocket connections for broadcasting device updates."""
def __init__(self):
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
"""Send message to all connected clients."""
disconnected = []
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception:
disconnected.append(connection)
# Clean up disconnected clients
for conn in disconnected:
self.disconnect(conn)
ws_manager = ConnectionManager()
# Register MQTT message handlers
@mqtt_manager.on_message("ble-sim/+/status")
async def handle_device_status(topic: str, payload: str):
"""Handle device status updates from ESP32s."""
parts = topic.split("/")
if len(parts) >= 2:
device_id = parts[1]
try:
data = json.loads(payload)
device_registry.register_device(device_id)
update_data = {
"online": data.get("online", True),
"type": data.get("type"),
"ble_started": data.get("ble_started", False),
"ip": data.get("ip"),
"firmware_version": data.get("firmware_version"),
}
device_registry.update_device(device_id, update_data)
print(f"Device {device_id} status updated: {data}")
# Broadcast to WebSocket clients
await ws_manager.broadcast({
"type": "device_update",
"device_id": device_id,
"data": device_registry.get_device(device_id)
})
except json.JSONDecodeError:
pass
@mqtt_manager.on_message("ble-sim/+/values")
async def handle_device_values(topic: str, payload: str):
"""Handle device value updates from ESP32s."""
parts = topic.split("/")
if len(parts) >= 2:
device_id = parts[1]
try:
data = json.loads(payload)
device_registry.update_device(device_id, {"values": data})
print(f"Device {device_id} values updated: {data}")
# Broadcast to WebSocket clients
await ws_manager.broadcast({
"type": "device_update",
"device_id": device_id,
"data": device_registry.get_device(device_id)
})
except json.JSONDecodeError:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle - connect/disconnect MQTT."""
await mqtt_manager.connect()
yield
await mqtt_manager.disconnect()
app = FastAPI(
title="pyBTMCP",
description="BLE Device Simulator Control API",
version=__version__,
lifespan=lifespan,
)
# CORS for web UI
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routes
app.include_router(router, prefix="/api")
# Serve static web UI
static_path = os.path.join(os.path.dirname(__file__), "..", "web", "static")
if os.path.exists(static_path):
app.mount("/static", StaticFiles(directory=static_path), name="static")
@app.get("/")
async def root():
"""Serve web UI or redirect to API docs."""
index_path = os.path.join(static_path, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return {"message": "pyBTMCP API", "docs": "/docs"}
@app.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "healthy",
"mqtt_connected": mqtt_manager.is_connected,
"version": __version__,
}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time device updates."""
await ws_manager.connect(websocket)
try:
# Send current device state on connect
devices = device_registry.get_all_devices()
await websocket.send_json({
"type": "initial_state",
"devices": devices
})
# Keep connection alive and handle any incoming messages
while True:
# Wait for messages (or connection close)
await websocket.receive_text()
except WebSocketDisconnect:
ws_manager.disconnect(websocket)
except Exception:
ws_manager.disconnect(websocket)