From 1c6b9db9031aeb82162973a61be1f1249b52b1c8 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 19 Jan 2026 11:50:10 -0500 Subject: [PATCH] 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 --- .gitignore | 36 + Dockerfile | 30 + README.md | 306 ++++++ config/mosquitto.conf | 17 + entrypoint.sh | 23 + firmware/esp32_ble_sim/.gitignore | 5 + firmware/esp32_ble_sim/include/ble_services.h | 59 ++ firmware/esp32_ble_sim/include/config.h | 37 + .../esp32_ble_sim/include/config_manager.h | 64 ++ firmware/esp32_ble_sim/include/web_portal.h | 30 + firmware/esp32_ble_sim/platformio.ini | 27 + firmware/esp32_ble_sim/src/ble_services.cpp | 241 +++++ firmware/esp32_ble_sim/src/config_manager.cpp | 79 ++ firmware/esp32_ble_sim/src/main.cpp | 402 ++++++++ firmware/esp32_ble_sim/src/web_portal.cpp | 318 ++++++ mcp-config.example.json | 14 + requirements.txt | 16 + src/__init__.py | 1 + src/api/__init__.py | 1 + src/api/device_registry.py | 68 ++ src/api/main.py | 168 +++ src/api/mqtt_client.py | 131 +++ src/api/routes.py | 69 ++ src/mcp/__init__.py | 1 + src/mcp/server.py | 353 +++++++ src/web/__init__.py | 1 + src/web/static/index.html | 958 ++++++++++++++++++ 27 files changed, 3455 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config/mosquitto.conf create mode 100644 entrypoint.sh create mode 100644 firmware/esp32_ble_sim/.gitignore create mode 100644 firmware/esp32_ble_sim/include/ble_services.h create mode 100644 firmware/esp32_ble_sim/include/config.h create mode 100644 firmware/esp32_ble_sim/include/config_manager.h create mode 100644 firmware/esp32_ble_sim/include/web_portal.h create mode 100644 firmware/esp32_ble_sim/platformio.ini create mode 100644 firmware/esp32_ble_sim/src/ble_services.cpp create mode 100644 firmware/esp32_ble_sim/src/config_manager.cpp create mode 100644 firmware/esp32_ble_sim/src/main.cpp create mode 100644 firmware/esp32_ble_sim/src/web_portal.cpp create mode 100644 mcp-config.example.json create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/api/__init__.py create mode 100644 src/api/device_registry.py create mode 100644 src/api/main.py create mode 100644 src/api/mqtt_client.py create mode 100644 src/api/routes.py create mode 100644 src/mcp/__init__.py create mode 100644 src/mcp/server.py create mode 100644 src/web/__init__.py create mode 100644 src/web/static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..260bec2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +env/ +.env +*.egg-info/ +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Docker +.docker/ + +# ESP32 / PlatformIO +firmware/esp32_ble_sim/.pio/ +firmware/esp32_ble_sim/.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Claude Code +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d5e09c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + mosquitto \ + mosquitto-clients \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy requirements first for layer caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY src/ ./src/ +COPY config/ ./config/ +COPY entrypoint.sh . + +RUN chmod +x entrypoint.sh + +# Expose ports +# 1883 - MQTT broker (for ESP32 connections) +# 8000 - Web UI / REST API +EXPOSE 1883 8000 + +# MCP server runs as main process via entrypoint +# Mosquitto and API run as background processes +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe43944 --- /dev/null +++ b/README.md @@ -0,0 +1,306 @@ +# pyBTMCP - BLE Device Simulator with MCP Integration + +A comprehensive BLE (Bluetooth Low Energy) fitness device simulator that enables AI agents like Claude to control simulated heart rate monitors, treadmills, and cycling trainers via the Model Context Protocol (MCP). + +## Overview + +pyBTMCP consists of three integrated components: + +1. **ESP32 Firmware** - Runs on ESP32 microcontrollers to simulate BLE fitness devices +2. **Python Backend** - FastAPI server with MQTT broker for device communication +3. **MCP Server** - Enables Claude and other AI agents to control devices + +``` +┌─────────────────┐ ┌──────────────────────────────────────────┐ +│ Claude/AI │ │ Docker Container │ +│ (MCP Client) │◄───►│ ┌─────────┐ ┌───────┐ ┌──────────┐ │ +└─────────────────┘ │ │ MCP │ │ MQTT │ │ FastAPI │ │ + │ │ Server │◄►│Broker │◄►│ + Web │ │ + │ └─────────┘ └───┬───┘ └──────────┘ │ + └──────────────────│───────────────────────┘ + │ + ┌──────────────────▼───────────────────────┐ + │ ESP32 Devices │ + │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ + │ │ HR │ │Treadmill│ │ Bike │ │ + │ │ Monitor │ │ │ │ Trainer │ │ + │ └─────────┘ └─────────┘ └─────────┘ │ + └──────────────────────────────────────────┘ +``` + +## Quick Start + +### Prerequisites + +- Docker +- (Optional) ESP32 development board for hardware simulation + +### 1. Build the Docker Image + +```bash +docker build -t pybtmcp:latest . +``` + +### 2. Run the Container + +```bash +docker run -it --rm \ + -p 1883:1883 \ + -p 8000:8000 \ + --name pybtmcp \ + pybtmcp:latest +``` + +This starts: +- **MQTT Broker** on port 1883 (for ESP32 device connections) +- **Web UI / API** on port 8000 (browser interface) +- **MCP Server** on stdio (for Claude integration) + +### 3. Access the Web UI + +Open http://localhost:8000 in your browser to see connected devices and control them manually. + +## MCP Integration (Claude Desktop) + +To use pyBTMCP with Claude Desktop, add it to your MCP configuration: + +### Configuration File Location + +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +### Add the Server Configuration + +```json +{ + "mcpServers": { + "ble-simulator": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-p", "1883:1883", + "-p", "8000:8000", + "--name", "pybtmcp", + "pybtmcp:latest" + ] + } + } +} +``` + +### Restart Claude Desktop + +After saving the configuration, restart Claude Desktop. You should see the "ble-simulator" MCP server available. + +## MCP Tools Reference + +Once configured, Claude can use these tools to control BLE devices: + +### `list_devices` +List all connected ESP32 BLE simulator devices. + +``` +No parameters required +``` + +### `configure_device` +Configure an ESP32 to simulate a specific BLE device type. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| device_id | string | Yes | The ESP32 device ID | +| device_type | string | Yes | One of: `heart_rate`, `treadmill`, `bike` | + +### `set_heart_rate` +Set the simulated heart rate for a heart rate monitor device. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| device_id | string | Yes | The ESP32 device ID | +| bpm | integer | Yes | Heart rate (30-220 BPM) | + +### `set_treadmill_values` +Set simulated values for a treadmill device. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| device_id | string | Yes | The ESP32 device ID | +| speed | number | No | Speed in km/h (0-25) | +| incline | number | No | Incline percentage (-5 to 30) | + +### `set_bike_values` +Set simulated values for a bike/cycling trainer device. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| device_id | string | Yes | The ESP32 device ID | +| power | integer | No | Power in watts (0-2000) | +| cadence | integer | No | Cadence in RPM (0-200) | +| speed | number | No | Speed in km/h (0-80) | + +### `get_device_status` +Get the current status and values of a specific device. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| device_id | string | Yes | The ESP32 device ID | + +## Web UI Features + +The web interface at http://localhost:8000 provides: + +- **Real-time device status** via WebSocket (live updates) +- **Device type configuration** (Heart Rate, Treadmill, Bike) +- **Preset values** for quick testing (Rest, Warm Up, Cardio, etc.) +- **Manual sliders** for fine-grained control +- **Unit switching** (Metric/Imperial) +- **HR variation** toggle for realistic heart rate simulation + +### Connection Status Indicator + +The UI shows WebSocket connection status: +- 🟢 **Live** - Connected and receiving real-time updates +- 🟡 **Connecting...** - Attempting to connect +- 🔴 **Disconnected** - Using polling fallback + +## REST API + +The FastAPI backend provides these endpoints: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/devices` | List all devices | +| GET | `/api/devices/{id}` | Get device by ID | +| POST | `/api/devices/{id}/configure` | Configure device type | +| POST | `/api/devices/{id}/values` | Set device values | +| GET | `/health` | Health check | +| WS | `/ws` | WebSocket for real-time updates | + +API documentation is available at http://localhost:8000/docs (Swagger UI). + +## ESP32 Firmware Setup + +### Hardware Requirements + +- ESP32 development board (ESP32-DevKitC, ESP32-WROOM, etc.) +- USB cable for programming + +### Building the Firmware + +1. Install [PlatformIO](https://platformio.org/) +2. Navigate to the firmware directory: + ```bash + cd firmware/esp32_ble_sim + ``` +3. Build and upload: + ```bash + pio run -t upload + ``` + +### Initial Configuration + +1. On first boot, the ESP32 creates a WiFi access point: `BLE-Sim-XXXX` +2. Connect to the AP and open http://192.168.4.1 +3. Enter your WiFi credentials and MQTT broker address +4. The device will restart and connect to your network + +### MQTT Topics + +The ESP32 devices communicate via MQTT: + +| Topic | Direction | Description | +|-------|-----------|-------------| +| `ble-sim/{id}/status` | Device → Server | Device online status | +| `ble-sim/{id}/values` | Device → Server | Current sensor values | +| `ble-sim/{id}/config` | Server → Device | Device type configuration | +| `ble-sim/{id}/set` | Server → Device | Set sensor values | + +## Development + +### Running Locally (without Docker) + +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Start an MQTT broker (e.g., Mosquitto): + ```bash + mosquitto -c config/mosquitto.conf + ``` + +3. Start the API server: + ```bash + uvicorn src.api.main:app --reload --port 8000 + ``` + +4. Run the MCP server (for testing): + ```bash + python -m src.mcp.server + ``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| MQTT_HOST | localhost | MQTT broker hostname | +| MQTT_PORT | 1883 | MQTT broker port | + +## Project Structure + +``` +pyBTMCP/ +├── src/ +│ ├── mcp/ +│ │ └── server.py # MCP server implementation +│ ├── api/ +│ │ ├── main.py # FastAPI app + WebSocket +│ │ ├── routes.py # REST API routes +│ │ ├── mqtt_client.py # MQTT connection manager +│ │ └── device_registry.py # Device state tracking +│ └── web/ +│ └── static/ +│ └── index.html # Web UI +├── firmware/ +│ └── esp32_ble_sim/ # PlatformIO ESP32 project +├── config/ +│ └── mosquitto.conf # MQTT broker config +├── Dockerfile +├── entrypoint.sh +├── requirements.txt +└── mcp-config.example.json # Example Claude Desktop config +``` + +## Supported BLE Services + +| Device Type | BLE Service | Characteristics | +|-------------|-------------|-----------------| +| Heart Rate Monitor | Heart Rate Service (0x180D) | Heart Rate Measurement | +| Treadmill | Fitness Machine Service (0x1826) | Treadmill Data | +| Bike Trainer | Cycling Power Service (0x1818) | Cycling Power Measurement | + +## Troubleshooting + +### MCP server not appearing in Claude Desktop + +1. Ensure Docker is running +2. Check the config file path is correct for your OS +3. Restart Claude Desktop after saving config +4. Check Docker logs: `docker logs pybtmcp` + +### ESP32 not connecting + +1. Verify WiFi credentials are correct +2. Ensure MQTT broker is reachable from ESP32's network +3. Check MQTT broker is listening on port 1883 +4. Use `mosquitto_sub -t "ble-sim/#" -v` to monitor MQTT traffic + +### Web UI not updating + +1. Check browser console for WebSocket errors +2. Verify the container is running: `docker ps` +3. The UI falls back to 5-second polling if WebSocket disconnects + +## License + +MIT License diff --git a/config/mosquitto.conf b/config/mosquitto.conf new file mode 100644 index 0000000..03cb70c --- /dev/null +++ b/config/mosquitto.conf @@ -0,0 +1,17 @@ +# Mosquitto MQTT Broker Configuration + +# Listen on all interfaces +listener 1883 0.0.0.0 + +# Allow anonymous connections (for local dev) +allow_anonymous true + +# Logging +log_type all +log_dest stderr + +# Persistence (disabled for container) +persistence false + +# Connection settings +max_connections -1 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..caa712f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +# Start Mosquitto MQTT broker in background +/usr/sbin/mosquitto -c /app/config/mosquitto.conf & +MOSQUITTO_PID=$! + +# Wait for Mosquitto to be ready +sleep 1 + +# Start FastAPI/Web UI in background +python -m uvicorn src.api.main:app --host 0.0.0.0 --port 8000 & +UVICORN_PID=$! + +# Trap to cleanup background processes on exit +cleanup() { + kill $UVICORN_PID 2>/dev/null || true + kill $MOSQUITTO_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Run MCP server as main process (stdio) +exec python -m src.mcp.server diff --git a/firmware/esp32_ble_sim/.gitignore b/firmware/esp32_ble_sim/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/firmware/esp32_ble_sim/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/firmware/esp32_ble_sim/include/ble_services.h b/firmware/esp32_ble_sim/include/ble_services.h new file mode 100644 index 0000000..483694e --- /dev/null +++ b/firmware/esp32_ble_sim/include/ble_services.h @@ -0,0 +1,59 @@ +#ifndef BLE_SERVICES_H +#define BLE_SERVICES_H + +#include + +// ============================================ +// BLE Service UUIDs (Bluetooth SIG standard) +// ============================================ + +// Heart Rate Service +#define HEART_RATE_SERVICE_UUID "180D" +#define HEART_RATE_MEASUREMENT_UUID "2A37" +#define BODY_SENSOR_LOCATION_UUID "2A38" + +// Fitness Machine Service +#define FITNESS_MACHINE_SERVICE_UUID "1826" +#define TREADMILL_DATA_UUID "2ACD" +#define FITNESS_MACHINE_FEATURE_UUID "2ACC" + +// ============================================ +// Function Prototypes +// ============================================ + +/** + * Initialize NimBLE stack (call once at startup) + */ +void initBLE(); + +/** + * Stop BLE advertising and deinit + */ +void stopBLE(); + +/** + * Setup BLE as Heart Rate Monitor + * Creates Heart Rate Service with measurement characteristic + */ +void setupBLE_HeartRate(); + +/** + * Setup BLE as Treadmill (Fitness Machine) + * Creates Fitness Machine Service with treadmill data + */ +void setupBLE_Treadmill(); + +/** + * Send heart rate notification + * @param bpm Heart rate in beats per minute + */ +void notifyHeartRate(uint8_t bpm); + +/** + * Send treadmill data notification + * @param speed Speed in 0.01 km/h units + * @param incline Incline in 0.1% units + */ +void notifyTreadmill(uint16_t speed, int16_t incline); + +#endif // BLE_SERVICES_H diff --git a/firmware/esp32_ble_sim/include/config.h b/firmware/esp32_ble_sim/include/config.h new file mode 100644 index 0000000..dd9cd4b --- /dev/null +++ b/firmware/esp32_ble_sim/include/config.h @@ -0,0 +1,37 @@ +#ifndef CONFIG_H +#define CONFIG_H + +// ============================================ +// Firmware Version +// ============================================ +#define FIRMWARE_VERSION "1.0.0" + +// ============================================ +// AP Mode Configuration +// ============================================ +#define AP_SSID_PREFIX "BLE-Sim-" // Will append chip ID for uniqueness +#define AP_PASSWORD "" // Open network (or set a password) +#define AP_IP IPAddress(192, 168, 4, 1) +#define AP_GATEWAY IPAddress(192, 168, 4, 1) +#define AP_SUBNET IPAddress(255, 255, 255, 0) + +// ============================================ +// Default Values (used until configured) +// ============================================ +#define DEFAULT_MQTT_PORT 1883 +#define DEFAULT_DEVICE_ID_PREFIX "esp32-" + +// ============================================ +// Timing Configuration +// ============================================ +#define BLE_NOTIFY_INTERVAL 1000 // BLE notification interval (ms) +#define MQTT_RECONNECT_INTERVAL 5000 // MQTT reconnect attempt interval (ms) +#define STATUS_REPORT_INTERVAL 10000 // Status report to MQTT (ms) +#define WIFI_CONNECT_TIMEOUT 15000 // WiFi connection timeout (ms) + +// ============================================ +// NVS Configuration Keys +// ============================================ +#define NVS_NAMESPACE "ble-sim" + +#endif // CONFIG_H diff --git a/firmware/esp32_ble_sim/include/config_manager.h b/firmware/esp32_ble_sim/include/config_manager.h new file mode 100644 index 0000000..6fcd990 --- /dev/null +++ b/firmware/esp32_ble_sim/include/config_manager.h @@ -0,0 +1,64 @@ +#ifndef CONFIG_MANAGER_H +#define CONFIG_MANAGER_H + +#include +#include + +// ============================================ +// Configuration Structure +// ============================================ +struct DeviceConfig { + bool configured = false; + String wifiSsid = ""; + String wifiPassword = ""; + String mqttHost = ""; + uint16_t mqttPort = 1883; + String deviceId = ""; +}; + +// ============================================ +// Configuration Manager Class +// ============================================ +class ConfigManager { +public: + ConfigManager(); + + // Load config from NVS + bool load(); + + // Save config to NVS + void save(); + + // Clear all config + void clear(); + + // Check if configured + bool isConfigured() const { return config.configured; } + + // Getters + const String& getWifiSsid() const { return config.wifiSsid; } + const String& getWifiPassword() const { return config.wifiPassword; } + const String& getMqttHost() const { return config.mqttHost; } + uint16_t getMqttPort() const { return config.mqttPort; } + const String& getDeviceId() const { return config.deviceId; } + + // Setters + void setWifiCredentials(const String& ssid, const String& password); + void setMqttConfig(const String& host, uint16_t port); + void setDeviceId(const String& id); + + // Get unique AP name based on chip ID + String getAPName() const; + + // Get unique default device ID based on chip ID + String getDefaultDeviceId() const; + +private: + DeviceConfig config; + Preferences preferences; +}; + +// Global instance +extern ConfigManager configManager; + +#endif // CONFIG_MANAGER_H diff --git a/firmware/esp32_ble_sim/include/web_portal.h b/firmware/esp32_ble_sim/include/web_portal.h new file mode 100644 index 0000000..a824422 --- /dev/null +++ b/firmware/esp32_ble_sim/include/web_portal.h @@ -0,0 +1,30 @@ +#ifndef WEB_PORTAL_H +#define WEB_PORTAL_H + +#include + +/** + * Initialize and start the web portal + * Runs on AP interface at 192.168.4.1 + */ +void setupWebPortal(); + +/** + * Handle web portal requests (call in loop) + */ +void handleWebPortal(); + +/** + * Get status info for web portal display + */ +struct PortalStatus { + bool wifiConnected; + bool mqttConnected; + String ipAddress; + String deviceType; + bool bleStarted; +}; + +void updatePortalStatus(const PortalStatus& status); + +#endif // WEB_PORTAL_H diff --git a/firmware/esp32_ble_sim/platformio.ini b/firmware/esp32_ble_sim/platformio.ini new file mode 100644 index 0000000..e3b83b9 --- /dev/null +++ b/firmware/esp32_ble_sim/platformio.ini @@ -0,0 +1,27 @@ +; PlatformIO Project Configuration File +; +; BLE Device Simulator for ESP32 +; Supports: Heart Rate Monitor, Treadmill (Fitness Machine) + +[env:esp32] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 + +; Use NimBLE for smaller footprint and better BLE support +lib_deps = + h2zero/NimBLE-Arduino@^1.4.0 + knolleary/PubSubClient@^2.8 + bblanchon/ArduinoJson@^7.0.0 + +; Build flags +build_flags = + -D CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=1 + -D CONFIG_BT_NIMBLE_ROLE_CENTRAL=0 + -D CONFIG_BT_NIMBLE_ROLE_OBSERVER=0 + -D CONFIG_BT_NIMBLE_ROLE_BROADCASTER=1 + +; Upload settings (adjust port as needed) +; upload_port = /dev/cu.usbserial-* +; monitor_port = /dev/cu.usbserial-* diff --git a/firmware/esp32_ble_sim/src/ble_services.cpp b/firmware/esp32_ble_sim/src/ble_services.cpp new file mode 100644 index 0000000..5649a2a --- /dev/null +++ b/firmware/esp32_ble_sim/src/ble_services.cpp @@ -0,0 +1,241 @@ +/** + * BLE Services Implementation + * + * Implements standard Bluetooth SIG GATT services: + * - Heart Rate Service (0x180D) + * - Fitness Machine Service (0x1826) - Treadmill + */ + +#include "ble_services.h" +#include "config.h" +#include "config_manager.h" +#include + +// ============================================ +// BLE Server and Characteristics +// ============================================ +static NimBLEServer* pServer = nullptr; +static NimBLEAdvertising* pAdvertising = nullptr; + +// Heart Rate +static NimBLECharacteristic* pHeartRateMeasurement = nullptr; + +// Treadmill +static NimBLECharacteristic* pTreadmillData = nullptr; + +static bool deviceConnected = false; +static bool oldDeviceConnected = false; + +// ============================================ +// Server Callbacks +// ============================================ +class ServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer* pServer) override { + deviceConnected = true; + Serial.println("BLE client connected"); + } + + void onDisconnect(NimBLEServer* pServer) override { + deviceConnected = false; + Serial.println("BLE client disconnected"); + + // Restart advertising + NimBLEDevice::startAdvertising(); + } +}; + +// ============================================ +// BLE Initialization +// ============================================ +void initBLE() { + NimBLEDevice::init("BLE Simulator"); + NimBLEDevice::setPower(ESP_PWR_LVL_P9); + + pServer = NimBLEDevice::createServer(); + pServer->setCallbacks(new ServerCallbacks()); + + pAdvertising = NimBLEDevice::getAdvertising(); + + Serial.println("BLE initialized"); +} + +void stopBLE() { + if (pAdvertising) { + pAdvertising->stop(); + } + + // Clear services (will be recreated on next setup) + pHeartRateMeasurement = nullptr; + pTreadmillData = nullptr; + + Serial.println("BLE stopped"); +} + +// ============================================ +// Heart Rate Service Setup +// ============================================ +void setupBLE_HeartRate() { + Serial.println("Setting up Heart Rate Service..."); + + // Stop any current advertising + if (pAdvertising) { + pAdvertising->stop(); + } + + // Create Heart Rate Service + NimBLEService* pService = pServer->createService(HEART_RATE_SERVICE_UUID); + + // Heart Rate Measurement Characteristic + // Flags: Notify + pHeartRateMeasurement = pService->createCharacteristic( + HEART_RATE_MEASUREMENT_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + + // Body Sensor Location Characteristic + // Flags: Read + // Value: 1 = Chest + NimBLECharacteristic* pBodySensorLocation = pService->createCharacteristic( + BODY_SENSOR_LOCATION_UUID, + NIMBLE_PROPERTY::READ + ); + uint8_t sensorLocation = 1; // Chest + pBodySensorLocation->setValue(&sensorLocation, 1); + + // Start the service + pService->start(); + + // Configure advertising + pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID); + pAdvertising->setScanResponse(true); + pAdvertising->setMinPreferred(0x06); + pAdvertising->setMaxPreferred(0x12); + + // Update device name + NimBLEDevice::setDeviceName("HR Simulator"); + + // Start advertising + NimBLEDevice::startAdvertising(); + + Serial.println("Heart Rate Service started, advertising..."); +} + +// ============================================ +// Treadmill (Fitness Machine) Service Setup +// ============================================ +void setupBLE_Treadmill() { + Serial.println("Setting up Fitness Machine Service (Treadmill)..."); + + // Stop any current advertising + if (pAdvertising) { + pAdvertising->stop(); + } + + // Create Fitness Machine Service + NimBLEService* pService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID); + + // Fitness Machine Feature Characteristic + // Flags: Read + NimBLECharacteristic* pFeature = pService->createCharacteristic( + FITNESS_MACHINE_FEATURE_UUID, + NIMBLE_PROPERTY::READ + ); + + // Feature flags for treadmill: + // Byte 0-3: Fitness Machine Features + // Bit 0: Average Speed Supported + // Bit 1: Cadence Supported + // Bit 2: Total Distance Supported + // Bit 3: Inclination Supported + // Bit 13: Elapsed Time Supported + // Byte 4-7: Target Setting Features + uint8_t featureData[8] = { + 0x0B, 0x20, 0x00, 0x00, // Features: Speed, Cadence, Distance, Inclination, Elapsed Time + 0x00, 0x00, 0x00, 0x00 // Target settings (none) + }; + pFeature->setValue(featureData, 8); + + // Treadmill Data Characteristic + // Flags: Notify + pTreadmillData = pService->createCharacteristic( + TREADMILL_DATA_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + + // Start the service + pService->start(); + + // Configure advertising + pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID); + pAdvertising->setScanResponse(true); + pAdvertising->setMinPreferred(0x06); + pAdvertising->setMaxPreferred(0x12); + + // Update device name + NimBLEDevice::setDeviceName("Treadmill Sim"); + + // Start advertising + NimBLEDevice::startAdvertising(); + + Serial.println("Fitness Machine Service (Treadmill) started, advertising..."); +} + +// ============================================ +// Heart Rate Notification +// ============================================ +void notifyHeartRate(uint8_t bpm) { + if (!pHeartRateMeasurement || !deviceConnected) return; + + // Heart Rate Measurement format: + // Byte 0: Flags + // Bit 0: Heart Rate Value Format (0 = UINT8, 1 = UINT16) + // Bit 1-2: Sensor Contact Status + // Bit 3: Energy Expended Status + // Bit 4: RR-Interval + // Byte 1: Heart Rate Value (UINT8) + uint8_t hrData[2]; + hrData[0] = 0x00; // Flags: UINT8 format, no contact detection + hrData[1] = bpm; + + pHeartRateMeasurement->setValue(hrData, 2); + pHeartRateMeasurement->notify(); +} + +// ============================================ +// Treadmill Data Notification +// ============================================ +void notifyTreadmill(uint16_t speed, int16_t incline) { + if (!pTreadmillData || !deviceConnected) return; + + // Treadmill Data format (per Bluetooth FTMS spec): + // Byte 0-1: Flags + // Bit 0: More Data (0 = Instantaneous Speed present) + // Bit 1: Average Speed present + // Bit 2: Total Distance present + // Bit 3: Inclination and Ramp Angle present + // Following bytes: Data fields based on flags + + // We'll include: Instantaneous Speed + Inclination + Ramp Angle + uint8_t data[8]; + + // Flags: Inclination and Ramp Angle present (bit 3) + uint16_t flags = 0x0008; + data[0] = flags & 0xFF; + data[1] = (flags >> 8) & 0xFF; + + // Instantaneous Speed (always present when More Data=0, uint16, 0.01 km/h resolution) + data[2] = speed & 0xFF; + data[3] = (speed >> 8) & 0xFF; + + // Inclination (sint16, 0.1% resolution) + data[4] = incline & 0xFF; + data[5] = (incline >> 8) & 0xFF; + + // Ramp Angle Setting (sint16, 0.1 degree resolution) - set to 0 + int16_t rampAngle = 0; + data[6] = rampAngle & 0xFF; + data[7] = (rampAngle >> 8) & 0xFF; + + pTreadmillData->setValue(data, 8); + pTreadmillData->notify(); +} diff --git a/firmware/esp32_ble_sim/src/config_manager.cpp b/firmware/esp32_ble_sim/src/config_manager.cpp new file mode 100644 index 0000000..3df4392 --- /dev/null +++ b/firmware/esp32_ble_sim/src/config_manager.cpp @@ -0,0 +1,79 @@ +#include "config_manager.h" +#include "config.h" + +// Global instance +ConfigManager configManager; + +ConfigManager::ConfigManager() {} + +bool ConfigManager::load() { + preferences.begin(NVS_NAMESPACE, true); // Read-only + + config.configured = preferences.getBool("configured", false); + config.wifiSsid = preferences.getString("wifi_ssid", ""); + config.wifiPassword = preferences.getString("wifi_pass", ""); + config.mqttHost = preferences.getString("mqtt_host", ""); + config.mqttPort = preferences.getUShort("mqtt_port", DEFAULT_MQTT_PORT); + config.deviceId = preferences.getString("device_id", getDefaultDeviceId()); + + preferences.end(); + + return config.configured; +} + +void ConfigManager::save() { + preferences.begin(NVS_NAMESPACE, false); // Read-write + + preferences.putBool("configured", config.configured); + preferences.putString("wifi_ssid", config.wifiSsid); + preferences.putString("wifi_pass", config.wifiPassword); + preferences.putString("mqtt_host", config.mqttHost); + preferences.putUShort("mqtt_port", config.mqttPort); + preferences.putString("device_id", config.deviceId); + + preferences.end(); + + Serial.println("Configuration saved to NVS"); +} + +void ConfigManager::clear() { + preferences.begin(NVS_NAMESPACE, false); + preferences.clear(); + preferences.end(); + + config = DeviceConfig(); + Serial.println("Configuration cleared"); +} + +void ConfigManager::setWifiCredentials(const String& ssid, const String& password) { + config.wifiSsid = ssid; + config.wifiPassword = password; + if (ssid.length() > 0) { + config.configured = true; + } +} + +void ConfigManager::setMqttConfig(const String& host, uint16_t port) { + config.mqttHost = host; + config.mqttPort = port; +} + +void ConfigManager::setDeviceId(const String& id) { + config.deviceId = id.length() > 0 ? id : getDefaultDeviceId(); +} + +String ConfigManager::getAPName() const { + uint32_t chipId = 0; + for (int i = 0; i < 17; i += 8) { + chipId |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i; + } + return String(AP_SSID_PREFIX) + String(chipId, HEX); +} + +String ConfigManager::getDefaultDeviceId() const { + uint32_t chipId = 0; + for (int i = 0; i < 17; i += 8) { + chipId |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i; + } + return String(DEFAULT_DEVICE_ID_PREFIX) + String(chipId, HEX); +} diff --git a/firmware/esp32_ble_sim/src/main.cpp b/firmware/esp32_ble_sim/src/main.cpp new file mode 100644 index 0000000..81ae607 --- /dev/null +++ b/firmware/esp32_ble_sim/src/main.cpp @@ -0,0 +1,402 @@ +/** + * ESP32 BLE Device Simulator + * + * Simulates BLE fitness devices (Heart Rate Monitor, Treadmill) + * Controlled via MQTT from pyBTMCP server + * + * Features: + * - AP mode for configuration when not connected to WiFi (192.168.4.1) + * - Connects to configured WiFi for MQTT control + * - Configuration stored in NVS (flash once, configure via web) + */ + +#include +#include +#include +#include +#include + +#include "config.h" +#include "config_manager.h" +#include "web_portal.h" +#include "ble_services.h" + +// ============================================ +// Global State +// ============================================ +WiFiClient wifiClient; +PubSubClient mqtt(wifiClient); + +// Current device configuration +enum DeviceType { + DEVICE_NONE, + DEVICE_HEART_RATE, + DEVICE_TREADMILL +}; + +DeviceType currentDeviceType = DEVICE_NONE; +bool bleStarted = false; +bool wifiConnected = false; +bool mqttConnected = false; +bool apModeActive = false; + +// Simulated values +uint8_t heartRate = 70; +uint16_t treadmillSpeed = 0; // 0.01 km/h resolution +int16_t treadmillIncline = 0; // 0.1% resolution + +// Timing +unsigned long lastNotify = 0; +unsigned long lastStatus = 0; +unsigned long lastWifiAttempt = 0; +unsigned long lastMqttAttempt = 0; + +// ============================================ +// WiFi Functions +// ============================================ +void startAPMode() { + if (apModeActive) return; + + WiFi.mode(WIFI_AP); + WiFi.softAPConfig(AP_IP, AP_GATEWAY, AP_SUBNET); + String apName = configManager.getAPName(); + WiFi.softAP(apName.c_str(), AP_PASSWORD); + apModeActive = true; + + Serial.println("\n========================================"); + Serial.println("Access Point Started"); + Serial.print(" SSID: "); + Serial.println(apName); + Serial.print(" Config URL: http://"); + Serial.println(WiFi.softAPIP()); + Serial.println("========================================\n"); +} + +void stopAPMode() { + if (!apModeActive) return; + + WiFi.softAPdisconnect(true); + WiFi.mode(WIFI_STA); + apModeActive = false; + + Serial.println("Access Point stopped (WiFi connected)"); +} + +void setupWiFi() { + if (configManager.isConfigured()) { + // Start in STA mode, try to connect + WiFi.mode(WIFI_STA); + Serial.println("Starting in STA mode (configured)"); + } else { + // No config - start AP for setup + startAPMode(); + } +} + +void connectToWiFi() { + if (!configManager.isConfigured()) { + // Not configured - ensure AP is running + if (!apModeActive) { + startAPMode(); + } + return; + } + + if (WiFi.status() == WL_CONNECTED) { + if (!wifiConnected) { + wifiConnected = true; + Serial.print("WiFi connected! IP: "); + Serial.println(WiFi.localIP()); + + // Stop AP mode when connected + stopAPMode(); + } + return; + } + + // WiFi not connected + if (wifiConnected) { + // Was connected, now disconnected - start AP + wifiConnected = false; + mqttConnected = false; + Serial.println("WiFi disconnected!"); + startAPMode(); + } + + // Don't spam connection attempts + if (millis() - lastWifiAttempt < WIFI_CONNECT_TIMEOUT) { + return; + } + lastWifiAttempt = millis(); + + // If AP is active, switch to AP+STA to allow connection attempts + if (apModeActive) { + WiFi.mode(WIFI_AP_STA); + } + + Serial.print("Connecting to WiFi: "); + Serial.println(configManager.getWifiSsid()); + + WiFi.begin( + configManager.getWifiSsid().c_str(), + configManager.getWifiPassword().c_str() + ); +} + +// ============================================ +// MQTT Functions +// ============================================ +void publishStatus() { + if (!mqtt.connected()) return; + + JsonDocument doc; + doc["online"] = true; + doc["firmware_version"] = FIRMWARE_VERSION; + + const char* typeStr = "none"; + if (currentDeviceType == DEVICE_HEART_RATE) typeStr = "heart_rate"; + else if (currentDeviceType == DEVICE_TREADMILL) typeStr = "treadmill"; + doc["type"] = typeStr; + + doc["ble_started"] = bleStarted; + doc["ip"] = WiFi.localIP().toString(); + + String payload; + serializeJson(doc, payload); + + String topic = String("ble-sim/") + configManager.getDeviceId() + "/status"; + mqtt.publish(topic.c_str(), payload.c_str()); +} + +void publishValues() { + if (!mqtt.connected()) return; + + JsonDocument doc; + + if (currentDeviceType == DEVICE_HEART_RATE) { + doc["heart_rate"] = heartRate; + } else if (currentDeviceType == DEVICE_TREADMILL) { + doc["speed"] = treadmillSpeed / 100.0; + doc["incline"] = treadmillIncline / 10.0; + } + + String payload; + serializeJson(doc, payload); + + String topic = String("ble-sim/") + configManager.getDeviceId() + "/values"; + mqtt.publish(topic.c_str(), payload.c_str()); +} + +void handleMqttMessage(char* topic, byte* payload, unsigned int length) { + JsonDocument doc; + DeserializationError error = deserializeJson(doc, payload, length); + + if (error) { + Serial.print("JSON parse error: "); + Serial.println(error.c_str()); + return; + } + + String topicStr = String(topic); + String baseTopic = String("ble-sim/") + configManager.getDeviceId(); + + // Handle configuration + if (topicStr == baseTopic + "/config") { + String type = doc["type"] | ""; + + Serial.print("Configuring as: "); + Serial.println(type); + + if (type == "heart_rate") { + currentDeviceType = DEVICE_HEART_RATE; + setupBLE_HeartRate(); + bleStarted = true; + } else if (type == "treadmill") { + currentDeviceType = DEVICE_TREADMILL; + setupBLE_Treadmill(); + bleStarted = true; + } else { + currentDeviceType = DEVICE_NONE; + stopBLE(); + bleStarted = false; + } + + publishStatus(); + } + // Handle value updates + else if (topicStr == baseTopic + "/set") { + if (doc["heart_rate"].is()) { + heartRate = doc["heart_rate"]; + Serial.print("Heart rate set to: "); + Serial.println(heartRate); + } + if (doc["speed"].is()) { + float speed = doc["speed"]; + treadmillSpeed = (uint16_t)(speed * 100); + Serial.print("Speed set to: "); + Serial.println(speed); + } + if (doc["incline"].is()) { + float incline = doc["incline"]; + treadmillIncline = (int16_t)(incline * 10); + Serial.print("Incline set to: "); + Serial.println(incline); + } + + publishValues(); + } +} + +void setupMQTT() { + mqtt.setCallback(handleMqttMessage); + mqtt.setBufferSize(512); +} + +void connectToMQTT() { + if (!configManager.isConfigured() || !wifiConnected) { + return; + } + + if (mqtt.connected()) { + if (!mqttConnected) { + mqttConnected = true; + } + return; + } + + // Don't spam connection attempts + if (millis() - lastMqttAttempt < MQTT_RECONNECT_INTERVAL) { + return; + } + lastMqttAttempt = millis(); + + mqttConnected = false; + + // Update MQTT server config + mqtt.setServer( + configManager.getMqttHost().c_str(), + configManager.getMqttPort() + ); + + Serial.print("Connecting to MQTT at "); + Serial.print(configManager.getMqttHost()); + Serial.print(":"); + Serial.println(configManager.getMqttPort()); + + String clientId = String("esp32-") + String(random(0xffff), HEX); + + if (mqtt.connect(clientId.c_str())) { + mqttConnected = true; + Serial.println("MQTT connected!"); + + // Subscribe to control topics + String configTopic = String("ble-sim/") + configManager.getDeviceId() + "/config"; + String setTopic = String("ble-sim/") + configManager.getDeviceId() + "/set"; + + mqtt.subscribe(configTopic.c_str()); + mqtt.subscribe(setTopic.c_str()); + + Serial.print("Subscribed to: "); + Serial.println(configTopic); + + // Publish initial status + publishStatus(); + } else { + Serial.print("MQTT connection failed, rc="); + Serial.println(mqtt.state()); + } +} + +// ============================================ +// Update Portal Status +// ============================================ +void updateStatus() { + PortalStatus status; + status.wifiConnected = wifiConnected; + status.mqttConnected = mqttConnected; + status.bleStarted = bleStarted; + status.ipAddress = wifiConnected ? WiFi.localIP().toString() : ""; + + if (currentDeviceType == DEVICE_HEART_RATE) { + status.deviceType = "Heart Rate"; + } else if (currentDeviceType == DEVICE_TREADMILL) { + status.deviceType = "Treadmill"; + } else { + status.deviceType = "Not configured"; + } + + updatePortalStatus(status); +} + +// ============================================ +// Main Setup & Loop +// ============================================ +void setup() { + Serial.begin(115200); + delay(1000); + + Serial.println("\n========================================"); + Serial.println(" ESP32 BLE Device Simulator"); + Serial.print(" Firmware: v"); + Serial.println(FIRMWARE_VERSION); + Serial.println("========================================\n"); + + // Load configuration from NVS + bool hasConfig = configManager.load(); + Serial.print("Device ID: "); + Serial.println(configManager.getDeviceId()); + Serial.print("Configured: "); + Serial.println(hasConfig ? "Yes" : "No"); + + // Start WiFi (AP always on, STA if configured) + setupWiFi(); + + // Start web configuration portal + setupWebPortal(); + + // Initialize MQTT + setupMQTT(); + + // Initialize BLE (don't start advertising yet) + initBLE(); + + Serial.println("\nReady!"); + if (!configManager.isConfigured()) { + Serial.println("Configure at: http://192.168.4.1"); + } else { + Serial.println("Connecting to WiFi..."); + } + Serial.println("Waiting for MQTT commands...\n"); +} + +void loop() { + // Handle web portal requests + handleWebPortal(); + + // Maintain WiFi STA connection (if configured) + connectToWiFi(); + + // Maintain MQTT connection + connectToMQTT(); + mqtt.loop(); + + // Update portal status display + updateStatus(); + + // Send BLE notifications + if (bleStarted && millis() - lastNotify >= BLE_NOTIFY_INTERVAL) { + lastNotify = millis(); + + if (currentDeviceType == DEVICE_HEART_RATE) { + notifyHeartRate(heartRate); + } else if (currentDeviceType == DEVICE_TREADMILL) { + notifyTreadmill(treadmillSpeed, treadmillIncline); + } + } + + // Periodic status report to MQTT + if (mqttConnected && millis() - lastStatus >= STATUS_REPORT_INTERVAL) { + lastStatus = millis(); + publishStatus(); + } +} diff --git a/firmware/esp32_ble_sim/src/web_portal.cpp b/firmware/esp32_ble_sim/src/web_portal.cpp new file mode 100644 index 0000000..a92dff3 --- /dev/null +++ b/firmware/esp32_ble_sim/src/web_portal.cpp @@ -0,0 +1,318 @@ +#include "web_portal.h" +#include "config_manager.h" +#include +#include + +WebServer server(80); +PortalStatus currentStatus = {}; + +// HTML template with embedded CSS and JS +const char INDEX_HTML[] PROGMEM = R"rawliteral( + + + + + BLE Simulator Setup + + + +

BLE Simulator

+

Loading...

+ +
+

Status

+
+ WiFi + - +
+
+ MQTT + - +
+
+ BLE + - +
+
+ IP Address + - +
+
+ +
+

WiFi Configuration

+
+
+ + + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + + +)rawliteral"; + +void handleRoot() { + server.send(200, "text/html", INDEX_HTML); +} + +void handleGetStatus() { + JsonDocument doc; + + doc["apName"] = configManager.getAPName(); + + JsonObject config = doc["config"].to(); + config["ssid"] = configManager.getWifiSsid(); + config["mqttHost"] = configManager.getMqttHost(); + config["mqttPort"] = configManager.getMqttPort(); + config["deviceId"] = configManager.getDeviceId(); + + JsonObject status = doc["status"].to(); + status["wifiConnected"] = currentStatus.wifiConnected; + status["mqttConnected"] = currentStatus.mqttConnected; + status["bleStarted"] = currentStatus.bleStarted; + status["deviceType"] = currentStatus.deviceType; + status["ipAddress"] = currentStatus.ipAddress; + + String response; + serializeJson(doc, response); + server.send(200, "application/json", response); +} + +void handlePostConfig() { + if (!server.hasArg("plain")) { + server.send(400, "application/json", "{\"error\":\"No body\"}"); + return; + } + + JsonDocument doc; + DeserializationError error = deserializeJson(doc, server.arg("plain")); + + if (error) { + server.send(400, "application/json", "{\"error\":\"Invalid JSON\"}"); + return; + } + + // Update configuration + String ssid = doc["ssid"] | ""; + String password = doc["password"] | ""; + String mqttHost = doc["mqtt_host"] | ""; + uint16_t mqttPort = doc["mqtt_port"] | 1883; + String deviceId = doc["device_id"] | ""; + + configManager.setWifiCredentials(ssid, password); + configManager.setMqttConfig(mqttHost, mqttPort); + configManager.setDeviceId(deviceId); + configManager.save(); + + server.send(200, "application/json", "{\"success\":true}"); + + // Trigger reconnect (will be handled in main loop) + Serial.println("Configuration updated, reconnecting..."); +} + +void handleReset() { + configManager.clear(); + server.send(200, "application/json", "{\"success\":true}"); + + Serial.println("Configuration reset, rebooting..."); + delay(1000); + ESP.restart(); +} + +void setupWebPortal() { + server.on("/", HTTP_GET, handleRoot); + server.on("/api/status", HTTP_GET, handleGetStatus); + server.on("/api/config", HTTP_POST, handlePostConfig); + server.on("/api/reset", HTTP_POST, handleReset); + + server.begin(); + Serial.println("Web portal started on http://192.168.4.1"); +} + +void handleWebPortal() { + server.handleClient(); +} + +void updatePortalStatus(const PortalStatus& status) { + currentStatus = status; +} diff --git a/mcp-config.example.json b/mcp-config.example.json new file mode 100644 index 0000000..bc3f601 --- /dev/null +++ b/mcp-config.example.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "ble-simulator": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-p", "1883:1883", + "-p", "8000:8000", + "--name", "pybtmcp", + "pybtmcp:latest" + ] + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc42547 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +# FastAPI and web server +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 + +# MQTT client +aiomqtt>=2.0.0 + +# MCP server +mcp>=1.0.0 + +# Data validation +pydantic>=2.5.0 +pydantic-settings>=2.1.0 + +# Utilities +python-dotenv>=1.0.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..464a48b --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# pyBTMCP - BLE Device Simulator diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..bb62f99 --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1 @@ +# FastAPI REST API diff --git a/src/api/device_registry.py b/src/api/device_registry.py new file mode 100644 index 0000000..f5fc593 --- /dev/null +++ b/src/api/device_registry.py @@ -0,0 +1,68 @@ +"""In-memory device registry for tracking ESP32 devices.""" + +from datetime import datetime +from typing import Any + + +class DeviceRegistry: + """Tracks connected ESP32 devices and their state.""" + + def __init__(self): + self._devices: dict[str, dict[str, Any]] = {} + + def register_device(self, device_id: str, info: dict | None = None): + """Register a new device or update existing.""" + now = datetime.utcnow().isoformat() + + if device_id not in self._devices: + self._devices[device_id] = { + "id": device_id, + "type": None, + "values": {}, + "online": True, + "first_seen": now, + "last_seen": now, + } + else: + self._devices[device_id]["last_seen"] = now + self._devices[device_id]["online"] = True + + if info: + self._devices[device_id].update(info) + + def update_device(self, device_id: str, updates: dict): + """Update device state.""" + if device_id not in self._devices: + self.register_device(device_id) + + for key, value in updates.items(): + if key == "values" and isinstance(value, dict): + # Merge values - ensure values dict exists + if not self._devices[device_id].get("values"): + self._devices[device_id]["values"] = {} + self._devices[device_id]["values"].update(value) + else: + self._devices[device_id][key] = value + + self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat() + + def mark_offline(self, device_id: str): + """Mark a device as offline.""" + if device_id in self._devices: + self._devices[device_id]["online"] = False + + def get_device(self, device_id: str) -> dict | None: + """Get a device by ID.""" + return self._devices.get(device_id) + + def get_all_devices(self) -> list[dict]: + """Get all devices.""" + return list(self._devices.values()) + + def remove_device(self, device_id: str): + """Remove a device from the registry.""" + self._devices.pop(device_id, None) + + +# Global registry instance +device_registry = DeviceRegistry() diff --git a/src/api/main.py b/src/api/main.py new file mode 100644 index 0000000..caf5b66 --- /dev/null +++ b/src/api/main.py @@ -0,0 +1,168 @@ +"""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) diff --git a/src/api/mqtt_client.py b/src/api/mqtt_client.py new file mode 100644 index 0000000..cc7ee70 --- /dev/null +++ b/src/api/mqtt_client.py @@ -0,0 +1,131 @@ +"""MQTT client manager for communicating with ESP32 devices.""" + +import asyncio +import json +import os +from typing import Callable +import aiomqtt + + +class MQTTManager: + """Manages MQTT connection and message handling.""" + + def __init__(self): + self.client: aiomqtt.Client | None = None + self._connected = False + self._message_handlers: dict[str, list[Callable]] = {} + self._listen_task: asyncio.Task | None = None + + @property + def is_connected(self) -> bool: + return self._connected + + async def connect(self): + """Connect to MQTT broker.""" + host = os.getenv("MQTT_HOST", "localhost") + port = int(os.getenv("MQTT_PORT", "1883")) + + try: + self.client = aiomqtt.Client(hostname=host, port=port) + await self.client.__aenter__() + self._connected = True + + # Subscribe to device status topics + await self.client.subscribe("ble-sim/+/status") + await self.client.subscribe("ble-sim/+/values") + + # Start listening for messages + self._listen_task = asyncio.create_task(self._listen()) + + print(f"MQTT connected to {host}:{port}") + except Exception as e: + print(f"MQTT connection failed: {e}") + self._connected = False + + async def disconnect(self): + """Disconnect from MQTT broker.""" + if self._listen_task: + self._listen_task.cancel() + try: + await self._listen_task + except asyncio.CancelledError: + pass + + if self.client: + await self.client.__aexit__(None, None, None) + self._connected = False + + async def _listen(self): + """Listen for incoming MQTT messages.""" + try: + async for message in self.client.messages: + topic = str(message.topic) + payload = message.payload.decode() + + # Notify handlers + for pattern, handlers in self._message_handlers.items(): + if self._topic_matches(pattern, topic): + for handler in handlers: + try: + await handler(topic, payload) + except Exception as e: + print(f"Handler error: {e}") + except asyncio.CancelledError: + pass + except aiomqtt.MqttError: + # Expected during shutdown when client disconnects + pass + + def _topic_matches(self, pattern: str, topic: str) -> bool: + """Check if topic matches pattern (supports + and # wildcards).""" + pattern_parts = pattern.split("/") + topic_parts = topic.split("/") + + if len(pattern_parts) != len(topic_parts): + if "#" not in pattern_parts: + return False + + for p, t in zip(pattern_parts, topic_parts): + if p == "#": + return True + if p != "+" and p != t: + return False + + return True + + def on_message(self, topic_pattern: str): + """Decorator to register a message handler.""" + def decorator(func: Callable): + if topic_pattern not in self._message_handlers: + self._message_handlers[topic_pattern] = [] + self._message_handlers[topic_pattern].append(func) + return func + return decorator + + async def publish(self, topic: str, payload: dict | str): + """Publish a message to MQTT.""" + if not self._connected or not self.client: + raise RuntimeError("MQTT not connected") + + if isinstance(payload, dict): + payload = json.dumps(payload) + + await self.client.publish(topic, payload) + + async def configure_device(self, device_id: str, device_type: str): + """Send configuration command to a device.""" + await self.publish( + f"ble-sim/{device_id}/config", + {"type": device_type} + ) + + async def set_device_values(self, device_id: str, values: dict): + """Send value update to a device.""" + await self.publish( + f"ble-sim/{device_id}/set", + values + ) + + +# Global MQTT manager instance +mqtt_manager = MQTTManager() diff --git a/src/api/routes.py b/src/api/routes.py new file mode 100644 index 0000000..e9f1c4d --- /dev/null +++ b/src/api/routes.py @@ -0,0 +1,69 @@ +"""API routes for device control.""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from .mqtt_client import mqtt_manager +from .device_registry import device_registry + +router = APIRouter() + + +class DeviceConfig(BaseModel): + """Device configuration request.""" + device_type: str # "heart_rate", "treadmill", "bike" + + +class DeviceValues(BaseModel): + """Device values update request.""" + heart_rate: int | None = None + speed: float | None = None # km/h + incline: float | None = None # percent + cadence: int | None = None # rpm + power: int | None = None # watts + + +@router.get("/devices") +async def list_devices(): + """List all connected devices.""" + return { + "devices": device_registry.get_all_devices() + } + + +@router.get("/devices/{device_id}") +async def get_device(device_id: str): + """Get a specific device's status.""" + device = device_registry.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail="Device not found") + return device + + +@router.post("/devices/{device_id}/configure") +async def configure_device(device_id: str, config: DeviceConfig): + """Configure a device's type.""" + if not mqtt_manager.is_connected: + raise HTTPException(status_code=503, detail="MQTT not connected") + + await mqtt_manager.configure_device(device_id, config.device_type) + + # Update local registry + device_registry.update_device(device_id, {"type": config.device_type}) + + return {"status": "ok", "device_id": device_id, "type": config.device_type} + + +@router.post("/devices/{device_id}/values") +async def set_device_values(device_id: str, values: DeviceValues): + """Update a device's simulated values.""" + if not mqtt_manager.is_connected: + raise HTTPException(status_code=503, detail="MQTT not connected") + + values_dict = values.model_dump(exclude_none=True) + await mqtt_manager.set_device_values(device_id, values_dict) + + # Update local registry + device_registry.update_device(device_id, {"values": values_dict}) + + return {"status": "ok", "device_id": device_id, "values": values_dict} diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py new file mode 100644 index 0000000..1238e7d --- /dev/null +++ b/src/mcp/__init__.py @@ -0,0 +1 @@ +# MCP Server diff --git a/src/mcp/server.py b/src/mcp/server.py new file mode 100644 index 0000000..bc8d4e2 --- /dev/null +++ b/src/mcp/server.py @@ -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()) diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..e97628f --- /dev/null +++ b/src/web/__init__.py @@ -0,0 +1 @@ +# Web UI diff --git a/src/web/static/index.html b/src/web/static/index.html new file mode 100644 index 0000000..e0a5aee --- /dev/null +++ b/src/web/static/index.html @@ -0,0 +1,958 @@ + + + + + + pyBTMCP - BLE Simulator + + + +
+
+

pyBTMCP

+

BLE Device Simulator

+
+
+
+ + Disconnected +
+ +
+
+ +
+
+ + Metric + + Imperial +
+
+ + + On (±3 BPM) +
+
+ +
+
+

No devices connected

+

+ Connect ESP32 devices to see them here +

+
+
+ + + +