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:
2026-01-19 11:50:10 -05:00
commit 1c6b9db903
27 changed files with 3455 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -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/

30
Dockerfile Normal file
View File

@@ -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"]

306
README.md Normal file
View File

@@ -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

17
config/mosquitto.conf Normal file
View File

@@ -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

23
entrypoint.sh Normal file
View File

@@ -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

5
firmware/esp32_ble_sim/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View File

@@ -0,0 +1,59 @@
#ifndef BLE_SERVICES_H
#define BLE_SERVICES_H
#include <Arduino.h>
// ============================================
// 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

View File

@@ -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

View File

@@ -0,0 +1,64 @@
#ifndef CONFIG_MANAGER_H
#define CONFIG_MANAGER_H
#include <Arduino.h>
#include <Preferences.h>
// ============================================
// 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

View File

@@ -0,0 +1,30 @@
#ifndef WEB_PORTAL_H
#define WEB_PORTAL_H
#include <Arduino.h>
/**
* 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

View File

@@ -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-*

View File

@@ -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 <NimBLEDevice.h>
// ============================================
// 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();
}

View File

@@ -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);
}

View File

@@ -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 <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <NimBLEDevice.h>
#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<int>()) {
heartRate = doc["heart_rate"];
Serial.print("Heart rate set to: ");
Serial.println(heartRate);
}
if (doc["speed"].is<float>()) {
float speed = doc["speed"];
treadmillSpeed = (uint16_t)(speed * 100);
Serial.print("Speed set to: ");
Serial.println(speed);
}
if (doc["incline"].is<float>()) {
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();
}
}

View File

@@ -0,0 +1,318 @@
#include "web_portal.h"
#include "config_manager.h"
#include <WebServer.h>
#include <ArduinoJson.h>
WebServer server(80);
PortalStatus currentStatus = {};
// HTML template with embedded CSS and JS
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BLE Simulator Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, sans-serif;
background: #1a1a2e;
color: #e4e4e4;
padding: 20px;
max-width: 500px;
margin: 0 auto;
}
h1 { margin-bottom: 10px; }
.subtitle { color: #888; margin-bottom: 20px; }
.card {
background: #16213e;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.card h2 {
font-size: 14px;
color: #888;
text-transform: uppercase;
margin-bottom: 15px;
}
.status-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #0f3460;
}
.status-row:last-child { border: none; }
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.online { background: #4ade80; }
.offline { background: #f87171; }
label { display: block; margin-bottom: 5px; color: #888; font-size: 14px; }
input, select {
width: 100%;
padding: 12px;
margin-bottom: 15px;
background: #0f3460;
border: none;
border-radius: 8px;
color: #e4e4e4;
font-size: 16px;
}
input:focus { outline: 2px solid #4ade80; }
button {
width: 100%;
padding: 15px;
background: #4ade80;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
button:hover { background: #22c55e; }
.btn-danger { background: #f87171; }
.btn-danger:hover { background: #ef4444; }
.msg { padding: 10px; border-radius: 8px; margin-bottom: 15px; }
.msg-success { background: #064e3b; }
.msg-error { background: #7f1d1d; }
</style>
</head>
<body>
<h1>BLE Simulator</h1>
<p class="subtitle" id="apName">Loading...</p>
<div class="card">
<h2>Status</h2>
<div class="status-row">
<span>WiFi</span>
<span><span class="status-dot" id="wifiDot"></span><span id="wifiStatus">-</span></span>
</div>
<div class="status-row">
<span>MQTT</span>
<span><span class="status-dot" id="mqttDot"></span><span id="mqttStatus">-</span></span>
</div>
<div class="status-row">
<span>BLE</span>
<span><span class="status-dot" id="bleDot"></span><span id="bleStatus">-</span></span>
</div>
<div class="status-row">
<span>IP Address</span>
<span id="ipAddr">-</span>
</div>
</div>
<div class="card">
<h2>WiFi Configuration</h2>
<div id="message"></div>
<form id="configForm">
<label>WiFi Network Name (SSID)</label>
<input type="text" name="ssid" id="ssid" required>
<label>WiFi Password</label>
<input type="password" name="password" id="password">
<label>MQTT Server IP</label>
<input type="text" name="mqtt_host" id="mqtt_host" placeholder="192.168.1.100" required>
<label>MQTT Port</label>
<input type="number" name="mqtt_port" id="mqtt_port" value="1883">
<label>Device ID</label>
<input type="text" name="device_id" id="device_id" placeholder="esp32-01">
<button type="submit">Save & Connect</button>
</form>
</div>
<div class="card">
<button class="btn-danger" onclick="resetConfig()">Reset Configuration</button>
</div>
<script>
let configLoaded = false;
// Load config once on page load
async function loadConfig() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('apName').textContent = data.apName;
document.getElementById('ssid').value = data.config.ssid || '';
document.getElementById('mqtt_host').value = data.config.mqttHost || '';
document.getElementById('mqtt_port').value = data.config.mqttPort || 1883;
document.getElementById('device_id').value = data.config.deviceId || '';
updateStatusDots(data.status);
configLoaded = true;
} catch (e) {
console.error('Failed to load config:', e);
}
}
// Update only status indicators (not form fields)
async function updateStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
updateStatusDots(data.status);
} catch (e) {
console.error('Failed to update status:', e);
}
}
function updateStatusDots(status) {
document.getElementById('wifiDot').className = 'status-dot ' + (status.wifiConnected ? 'online' : 'offline');
document.getElementById('wifiStatus').textContent = status.wifiConnected ? 'Connected' : 'Disconnected';
document.getElementById('mqttDot').className = 'status-dot ' + (status.mqttConnected ? 'online' : 'offline');
document.getElementById('mqttStatus').textContent = status.mqttConnected ? 'Connected' : 'Disconnected';
document.getElementById('bleDot').className = 'status-dot ' + (status.bleStarted ? 'online' : 'offline');
document.getElementById('bleStatus').textContent = status.bleStarted ? status.deviceType : 'Not started';
document.getElementById('ipAddr').textContent = status.ipAddress || '-';
}
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = {
ssid: form.ssid.value,
password: form.password.value,
mqtt_host: form.mqtt_host.value,
mqtt_port: parseInt(form.mqtt_port.value),
device_id: form.device_id.value
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
document.getElementById('message').innerHTML =
'<div class="msg msg-success">Configuration saved! Reconnecting...</div>';
setTimeout(updateStatus, 3000);
} catch (e) {
document.getElementById('message').innerHTML =
'<div class="msg msg-error">Failed to save configuration</div>';
}
});
async function resetConfig() {
if (!confirm('Reset all configuration?')) return;
try {
await fetch('/api/reset', { method: 'POST' });
document.getElementById('message').innerHTML =
'<div class="msg msg-success">Configuration reset! Rebooting...</div>';
setTimeout(() => location.reload(), 3000);
} catch (e) {
document.getElementById('message').innerHTML =
'<div class="msg msg-error">Failed to reset</div>';
}
}
// Load config once, then only update status
loadConfig();
setInterval(updateStatus, 5000);
</script>
</body>
</html>
)rawliteral";
void handleRoot() {
server.send(200, "text/html", INDEX_HTML);
}
void handleGetStatus() {
JsonDocument doc;
doc["apName"] = configManager.getAPName();
JsonObject config = doc["config"].to<JsonObject>();
config["ssid"] = configManager.getWifiSsid();
config["mqttHost"] = configManager.getMqttHost();
config["mqttPort"] = configManager.getMqttPort();
config["deviceId"] = configManager.getDeviceId();
JsonObject status = doc["status"].to<JsonObject>();
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;
}

14
mcp-config.example.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"ble-simulator": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-p", "1883:1883",
"-p", "8000:8000",
"--name", "pybtmcp",
"pybtmcp:latest"
]
}
}
}

16
requirements.txt Normal file
View File

@@ -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

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
# pyBTMCP - BLE Device Simulator

1
src/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
# FastAPI REST API

View File

@@ -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()

168
src/api/main.py Normal file
View File

@@ -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)

131
src/api/mqtt_client.py Normal file
View File

@@ -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()

69
src/api/routes.py Normal file
View File

@@ -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}

1
src/mcp/__init__.py Normal file
View File

@@ -0,0 +1 @@
# MCP Server

353
src/mcp/server.py Normal file
View 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())

1
src/web/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Web UI

958
src/web/static/index.html Normal file
View File

@@ -0,0 +1,958 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pyBTMCP - BLE Simulator</title>
<style>
:root {
--bg: #1a1a2e;
--card-bg: #16213e;
--accent: #0f3460;
--text: #e4e4e4;
--text-dim: #888;
--success: #4ade80;
--warning: #fbbf24;
--error: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem;
}
h1 { margin-bottom: 0.5rem; }
.subtitle { color: var(--text-dim); margin-bottom: 2rem; }
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.header-buttons {
display: flex;
gap: 0.5rem;
}
.btn {
background: var(--accent);
border: none;
color: var(--text);
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
}
.btn:hover { background: #1a4a7a; }
/* Settings Panel */
.settings-bar {
background: var(--card-bg);
border-radius: 12px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 2rem;
flex-wrap: wrap;
}
.setting-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.setting-item label {
color: var(--text-dim);
font-size: 0.875rem;
}
.toggle {
position: relative;
width: 50px;
height: 26px;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--accent);
border-radius: 26px;
transition: 0.3s;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: var(--text);
border-radius: 50%;
transition: 0.3s;
}
.toggle input:checked + .toggle-slider { background: var(--success); }
.toggle input:checked + .toggle-slider:before { transform: translateX(24px); }
.unit-label {
font-size: 0.75rem;
color: var(--text-dim);
min-width: 60px;
}
/* Devices */
.devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.device-card {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--accent);
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.device-id { font-weight: 600; font-size: 1.1rem; }
.device-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
}
.status-dot.offline { background: var(--error); }
.control-group { margin-bottom: 1rem; }
.control-group label {
display: block;
color: var(--text-dim);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.slider-container {
display: flex;
align-items: center;
gap: 1rem;
}
input[type="range"] {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--accent);
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--success);
cursor: pointer;
}
.value-display {
min-width: 80px;
text-align: right;
font-weight: 600;
font-family: monospace;
}
select {
width: 100%;
padding: 0.75rem;
background: var(--accent);
border: none;
border-radius: 8px;
color: var(--text);
font-size: 1rem;
}
/* Presets */
.presets {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.preset-btn {
padding: 0.5rem 1rem;
background: var(--accent);
border: none;
border-radius: 20px;
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.preset-btn:hover { background: #1a4a7a; }
.preset-btn.active { background: var(--success); color: var(--bg); }
.no-devices {
text-align: center;
color: var(--text-dim);
padding: 4rem 2rem;
background: var(--card-bg);
border-radius: 12px;
}
/* HR Variation indicator */
.variation-indicator {
font-size: 0.75rem;
color: var(--text-dim);
margin-left: 0.5rem;
}
/* Device info row */
.device-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--accent);
font-size: 0.8rem;
}
.device-link {
color: var(--success);
text-decoration: none;
}
.device-link:hover {
text-decoration: underline;
}
.device-version {
color: var(--text-dim);
}
/* WebSocket connection status */
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
padding: 0.5rem 1rem;
background: var(--card-bg);
border-radius: 8px;
}
.connection-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
transition: background 0.3s;
}
.connection-dot.connected { background: var(--success); }
.connection-dot.connecting { background: var(--warning); animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>pyBTMCP</h1>
<p class="subtitle">BLE Device Simulator <span id="backendVersion"></span></p>
</div>
<div class="header-buttons">
<div class="connection-status">
<span class="connection-dot" id="connectionDot"></span>
<span id="connectionText">Disconnected</span>
</div>
<button class="btn" onclick="loadDevices()">Refresh</button>
</div>
</div>
<div class="settings-bar">
<div class="setting-item">
<label>Units:</label>
<span class="unit-label" id="unitLabel">Metric</span>
<label class="toggle">
<input type="checkbox" id="imperialToggle" onchange="toggleUnits()">
<span class="toggle-slider"></span>
</label>
<span class="unit-label">Imperial</span>
</div>
<div class="setting-item">
<label>HR Variation:</label>
<label class="toggle">
<input type="checkbox" id="hrVariationToggle" onchange="toggleHrVariation()" checked>
<span class="toggle-slider"></span>
</label>
<span class="unit-label" id="hrVariationLabel">On (±3 BPM)</span>
</div>
</div>
<div id="devices" class="devices">
<div class="no-devices">
<p>No devices connected</p>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
Connect ESP32 devices to see them here
</p>
</div>
</div>
<script>
const API_BASE = '/api';
// WebSocket connection
let ws = null;
let wsReconnectTimeout = null;
let wsReconnectAttempts = 0;
const WS_RECONNECT_DELAY_BASE = 1000;
const WS_RECONNECT_MAX_DELAY = 30000;
function getWebSocketUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws`;
}
function updateConnectionStatus(status) {
const dot = document.getElementById('connectionDot');
const text = document.getElementById('connectionText');
dot.className = 'connection-dot';
if (status === 'connected') {
dot.classList.add('connected');
text.textContent = 'Live';
} else if (status === 'connecting') {
dot.classList.add('connecting');
text.textContent = 'Connecting...';
} else {
text.textContent = 'Disconnected';
}
}
function connectWebSocket() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
updateConnectionStatus('connecting');
try {
ws = new WebSocket(getWebSocketUrl());
ws.onopen = () => {
console.log('WebSocket connected');
updateConnectionStatus('connected');
wsReconnectAttempts = 0;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
updateConnectionStatus('disconnected');
scheduleReconnect();
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus('disconnected');
};
} catch (e) {
console.error('Failed to create WebSocket:', e);
updateConnectionStatus('disconnected');
scheduleReconnect();
}
}
function scheduleReconnect() {
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
}
// Exponential backoff with max delay
const delay = Math.min(
WS_RECONNECT_DELAY_BASE * Math.pow(2, wsReconnectAttempts),
WS_RECONNECT_MAX_DELAY
);
wsReconnectAttempts++;
console.log(`Reconnecting WebSocket in ${delay}ms (attempt ${wsReconnectAttempts})`);
wsReconnectTimeout = setTimeout(connectWebSocket, delay);
}
function handleWebSocketMessage(message) {
console.log('WebSocket message:', message.type);
if (message.type === 'initial_state') {
// Full device list received on connect
renderDevices(message.devices);
} else if (message.type === 'device_update') {
// Single device updated - update UI efficiently
updateDeviceInUI(message.device_id, message.data);
}
}
function updateDeviceInUI(deviceId, deviceData) {
const container = document.getElementById('devices');
const existingCard = container.querySelector(`[data-id="${deviceId}"]`);
if (!existingCard) {
// New device - reload all devices
loadDevices();
return;
}
// Check if device type changed (need full card re-render)
if (existingCard.dataset.type !== (deviceData.type || '')) {
loadDevices();
return;
}
// Update online/offline status
const dot = existingCard.querySelector('.status-dot');
const text = existingCard.querySelector('.status-text');
if (dot) dot.className = `status-dot ${deviceData.online ? '' : 'offline'}`;
if (text) text.textContent = deviceData.online ? 'Online' : 'Offline';
// Update IP and version if present
const infoDiv = existingCard.querySelector('.device-info');
if (infoDiv) {
let infoHtml = '';
if (deviceData.ip) {
infoHtml += `<a href="http://${deviceData.ip}" target="_blank" class="device-link">Admin: ${deviceData.ip}</a>`;
}
if (deviceData.firmware_version) {
infoHtml += `<span class="device-version">v${deviceData.firmware_version}</span>`;
}
infoDiv.innerHTML = infoHtml;
}
}
// Settings (persisted to localStorage)
let settings = {
imperial: false,
hrVariation: true
};
// HR variation state per device
let hrVariationIntervals = {};
let currentHrTargets = {};
let currentHrValues = {};
let activeDevices = new Set(); // Track devices being actively controlled
// Treadmill presets (stored in metric - km/h)
const treadmillPresets = [
{ name: 'Walk', speed: 5.0, incline: 1 },
{ name: 'Jog', speed: 8.0, incline: 1 },
{ name: 'Run', speed: 11.0, incline: 1.5 },
{ name: 'Sprint', speed: 16.0, incline: 0.5 },
{ name: 'Hill', speed: 6.5, incline: 8 }
];
// HR presets
const hrPresets = [
{ name: 'Rest', hr: 65 },
{ name: 'Warm Up', hr: 100 },
{ name: 'Fat Burn', hr: 130 },
{ name: 'Cardio', hr: 150 },
{ name: 'Peak', hr: 175 }
];
// Load settings from localStorage
function loadSettings() {
const saved = localStorage.getItem('pybtmcp_settings');
if (saved) {
settings = { ...settings, ...JSON.parse(saved) };
}
document.getElementById('imperialToggle').checked = settings.imperial;
document.getElementById('hrVariationToggle').checked = settings.hrVariation;
updateSettingsDisplay();
}
function saveSettings() {
localStorage.setItem('pybtmcp_settings', JSON.stringify(settings));
}
function toggleUnits() {
settings.imperial = document.getElementById('imperialToggle').checked;
saveSettings();
updateSettingsDisplay();
loadDevices(); // Re-render with new units
}
function toggleHrVariation() {
settings.hrVariation = document.getElementById('hrVariationToggle').checked;
saveSettings();
updateSettingsDisplay();
// Stop or start variation for all HR devices
Object.keys(hrVariationIntervals).forEach(deviceId => {
if (!settings.hrVariation) {
clearInterval(hrVariationIntervals[deviceId]);
delete hrVariationIntervals[deviceId];
}
});
}
function updateSettingsDisplay() {
document.getElementById('hrVariationLabel').textContent =
settings.hrVariation ? 'On (±3 BPM)' : 'Off';
}
// Unit conversion helpers (display only)
function kmhToMph(kmh) { return kmh * 0.621371; }
function formatSpeed(kmh) {
if (settings.imperial) {
return `${kmhToMph(kmh).toFixed(1)} mph`;
}
return `${parseFloat(kmh).toFixed(1)} km/h`;
}
function getSpeedMax() { return settings.imperial ? 15.5 : 25; }
function getSpeedStep() { return settings.imperial ? 0.1 : 0.1; }
function displayToMetric(displaySpeed) {
return settings.imperial ? displaySpeed / 0.621371 : displaySpeed;
}
function metricToDisplay(metricSpeed) {
return settings.imperial ? metricSpeed * 0.621371 : metricSpeed;
}
async function loadDevices() {
try {
const response = await fetch(`${API_BASE}/devices`);
const data = await response.json();
renderDevices(data.devices);
} catch (error) {
console.error('Failed to load devices:', error);
}
}
function renderDevices(devices) {
const container = document.getElementById('devices');
if (!devices || devices.length === 0) {
container.innerHTML = `
<div class="no-devices">
<p>No devices connected</p>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
Connect ESP32 devices to see them here
</p>
</div>
`;
return;
}
// Check if we need full re-render or just status update
const existingCards = container.querySelectorAll('.device-card');
const existingIds = new Set([...existingCards].map(c => c.dataset.id));
const newIds = new Set(devices.map(d => d.id));
// Check if device types changed (need full re-render for controls)
let typesChanged = false;
devices.forEach(device => {
const card = container.querySelector(`[data-id="${device.id}"]`);
if (card && card.dataset.type !== (device.type || '')) {
typesChanged = true;
}
});
// Full re-render if device list or types changed
const needsFullRender = typesChanged ||
existingIds.size !== newIds.size ||
[...existingIds].some(id => !newIds.has(id)) ||
[...newIds].some(id => !existingIds.has(id));
if (needsFullRender) {
container.innerHTML = devices.map(device => `
<div class="device-card" data-id="${device.id}" data-type="${device.type || ''}">
<div class="device-header">
<span class="device-id">${device.id}</span>
<div class="device-status">
<span class="status-dot ${device.online ? '' : 'offline'}"></span>
<span class="status-text">${device.online ? 'Online' : 'Offline'}</span>
</div>
</div>
<div class="device-info">
${device.ip ? `<a href="http://${device.ip}" target="_blank" class="device-link">Admin: ${device.ip}</a>` : ''}
${device.firmware_version ? `<span class="device-version">v${device.firmware_version}</span>` : ''}
</div>
<div class="control-group">
<label>Device Type</label>
<select onchange="configureDevice('${device.id}', this.value)">
<option value="" ${!device.type ? 'selected' : ''}>Select type...</option>
<option value="heart_rate" ${device.type === 'heart_rate' ? 'selected' : ''}>Heart Rate Monitor</option>
<option value="treadmill" ${device.type === 'treadmill' ? 'selected' : ''}>Treadmill</option>
<option value="bike" ${device.type === 'bike' ? 'selected' : ''}>Bike / Trainer</option>
</select>
</div>
${renderControls(device)}
</div>
`).join('');
// Setup HR variation for new HR devices
devices.forEach(device => {
if (device.type === 'heart_rate' && settings.hrVariation && !hrVariationIntervals[device.id]) {
const startHr = currentHrValues[device.id] || device.values?.heart_rate || 70;
setupHrVariation(device.id, startHr);
}
});
} else {
// Just update online/offline status
devices.forEach(device => {
const card = container.querySelector(`[data-id="${device.id}"]`);
if (card) {
const dot = card.querySelector('.status-dot');
const text = card.querySelector('.status-text');
if (dot) dot.className = `status-dot ${device.online ? '' : 'offline'}`;
if (text) text.textContent = device.online ? 'Online' : 'Offline';
}
});
}
}
function renderControls(device) {
if (!device.type) return '';
const values = device.values || {};
if (device.type === 'heart_rate') {
const hr = currentHrValues[device.id] || values.heart_rate || 70;
if (!currentHrTargets[device.id]) currentHrTargets[device.id] = hr;
if (!currentHrValues[device.id]) currentHrValues[device.id] = hr;
return `
<div class="presets">
${hrPresets.map(p => `
<button class="preset-btn"
onclick="applyHrPresetGradual('${device.id}', ${p.hr})"
ondblclick="applyHrPresetImmediate('${device.id}', ${p.hr})">${p.name}</button>
`).join('')}
</div>
<p style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 1rem;">
Click = gradual transition | Double-click = instant
</p>
<div class="control-group">
<label>Heart Rate Target</label>
<div class="slider-container">
<input type="range" min="30" max="220" value="${currentHrTargets[device.id]}"
oninput="updateHrTarget('${device.id}', this.value)">
<span class="value-display" id="hr-display-${device.id}">${hr} BPM</span>
</div>
</div>
`;
}
if (device.type === 'treadmill') {
const speed = values.speed || 0;
const incline = values.incline || 0;
const displaySpeed = metricToDisplay(speed);
return `
<div class="presets">
${treadmillPresets.map(p => `
<button class="preset-btn" onclick="applyTreadmillPreset('${device.id}', ${p.speed}, ${p.incline})">${p.name}</button>
`).join('')}
</div>
<div class="control-group">
<label>Speed</label>
<div class="slider-container">
<input type="range" min="0" max="${getSpeedMax()}" step="${getSpeedStep()}"
value="${displaySpeed.toFixed(1)}"
oninput="updateSpeed('${device.id}', this.value)">
<span class="value-display" id="speed-display-${device.id}">${formatSpeed(speed)}</span>
</div>
</div>
<div class="control-group">
<label>Incline</label>
<div class="slider-container">
<input type="range" min="-5" max="30" step="0.5" value="${incline}"
oninput="updateValue('${device.id}', 'incline', this.value)">
<span class="value-display" id="incline-display-${device.id}">${incline}%</span>
</div>
</div>
`;
}
if (device.type === 'bike') {
return `
<div class="control-group">
<label>Power</label>
<div class="slider-container">
<input type="range" min="0" max="500" value="${values.power || 0}"
oninput="updateValue('${device.id}', 'power', this.value)">
<span class="value-display">${values.power || 0} W</span>
</div>
</div>
<div class="control-group">
<label>Cadence</label>
<div class="slider-container">
<input type="range" min="0" max="150" value="${values.cadence || 0}"
oninput="updateValue('${device.id}', 'cadence', this.value)">
<span class="value-display">${values.cadence || 0} RPM</span>
</div>
</div>
`;
}
return '';
}
// HR variation - smooth wandering around target
function setupHrVariation(deviceId, targetHr) {
if (hrVariationIntervals[deviceId]) {
clearInterval(hrVariationIntervals[deviceId]);
}
if (!settings.hrVariation) return;
currentHrTargets[deviceId] = targetHr;
// Use existing value if we have one, otherwise start at target
if (!currentHrValues[deviceId]) {
currentHrValues[deviceId] = targetHr;
}
let trend = 0; // -1, 0, or 1
hrVariationIntervals[deviceId] = setInterval(() => {
const target = currentHrTargets[deviceId];
let currentHr = currentHrValues[deviceId];
// Smoothly drift toward target with small random variation
const diff = target - currentHr;
// Change trend occasionally
if (Math.random() < 0.3) {
trend = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
}
// Apply small change (max ±1 BPM per interval)
let change = 0;
if (Math.abs(diff) > 3) {
// Too far from target, move toward it
change = diff > 0 ? 1 : -1;
} else {
// Near target, wander slightly
change = trend * (Math.random() < 0.5 ? 1 : 0);
}
currentHr = Math.round(Math.max(30, Math.min(220, currentHr + change)));
currentHrValues[deviceId] = currentHr;
// Send to device
sendHrValue(deviceId, currentHr);
// Update display
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) {
display.textContent = `${currentHr} BPM`;
}
}, 1000);
}
// Gradual transition - just change target, let variation do the work
function applyHrPresetGradual(deviceId, targetHr) {
currentHrTargets[deviceId] = targetHr;
// Update slider to show target
const card = document.querySelector(`[data-id="${deviceId}"]`);
const slider = card?.querySelector('input[type="range"]');
if (slider) slider.value = targetHr;
// Ensure variation is running
if (!hrVariationIntervals[deviceId] && settings.hrVariation) {
const startHr = currentHrValues[deviceId] || targetHr;
setupHrVariation(deviceId, startHr);
currentHrTargets[deviceId] = targetHr;
}
// If variation is off, just send the value
if (!settings.hrVariation) {
currentHrValues[deviceId] = targetHr;
sendHrValue(deviceId, targetHr);
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${targetHr} BPM`;
}
}
// Immediate jump - set both current and target immediately
function applyHrPresetImmediate(deviceId, targetHr) {
currentHrTargets[deviceId] = targetHr;
currentHrValues[deviceId] = targetHr;
// Update slider
const card = document.querySelector(`[data-id="${deviceId}"]`);
const slider = card?.querySelector('input[type="range"]');
if (slider) slider.value = targetHr;
// Update display
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${targetHr} BPM`;
// Send immediately
sendHrValue(deviceId, targetHr);
}
function updateHrTarget(deviceId, value) {
const hr = parseInt(value);
currentHrTargets[deviceId] = hr;
if (!settings.hrVariation) {
sendHrValue(deviceId, hr);
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${hr} BPM`;
}
}
async function sendHrValue(deviceId, hr) {
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ heart_rate: hr })
});
} catch (error) {
console.error('Failed to send HR:', error);
}
}
function updateSpeed(deviceId, displayValue) {
const metricSpeed = displayToMetric(parseFloat(displayValue));
const display = document.getElementById(`speed-display-${deviceId}`);
if (display) display.textContent = formatSpeed(metricSpeed);
updateValueDebounced(deviceId, 'speed', metricSpeed);
}
function applyTreadmillPreset(deviceId, speedKmh, incline) {
const card = document.querySelector(`[data-id="${deviceId}"]`);
// Update sliders
const speedSlider = card.querySelector('input[oninput*="updateSpeed"]');
const inclineSlider = card.querySelector('input[oninput*="incline"]');
if (speedSlider) speedSlider.value = metricToDisplay(speedKmh).toFixed(1);
if (inclineSlider) inclineSlider.value = incline;
// Update displays
const speedDisplay = document.getElementById(`speed-display-${deviceId}`);
const inclineDisplay = document.getElementById(`incline-display-${deviceId}`);
if (speedDisplay) speedDisplay.textContent = formatSpeed(speedKmh);
if (inclineDisplay) inclineDisplay.textContent = `${incline}%`;
// Send values
sendTreadmillValues(deviceId, speedKmh, incline);
}
async function sendTreadmillValues(deviceId, speed, incline) {
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ speed, incline })
});
} catch (error) {
console.error('Failed to send treadmill values:', error);
}
}
async function configureDevice(deviceId, deviceType) {
if (!deviceType) return;
try {
await fetch(`${API_BASE}/devices/${deviceId}/configure`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_type: deviceType })
});
loadDevices();
} catch (error) {
console.error('Failed to configure device:', error);
}
}
let updateTimeouts = {};
function updateValueDebounced(deviceId, key, value) {
const timeoutKey = `${deviceId}-${key}`;
clearTimeout(updateTimeouts[timeoutKey]);
updateTimeouts[timeoutKey] = setTimeout(async () => {
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value })
});
} catch (error) {
console.error('Failed to update value:', error);
}
}, 100);
}
function updateValue(deviceId, key, value) {
const numValue = parseFloat(value);
// Update display
const display = document.getElementById(`${key}-display-${deviceId}`);
if (display) {
const units = { incline: '%', power: ' W', cadence: ' RPM' };
display.textContent = `${numValue}${units[key] || ''}`;
}
updateValueDebounced(deviceId, key, numValue);
}
// Load backend version
async function loadBackendVersion() {
try {
const response = await fetch('/health');
const data = await response.json();
const versionEl = document.getElementById('backendVersion');
if (versionEl && data.version) {
versionEl.textContent = `v${data.version}`;
}
} catch (error) {
console.error('Failed to load backend version:', error);
}
}
// Initialize
loadSettings();
loadBackendVersion();
connectWebSocket();
loadDevices();
// Keep polling as fallback (reduced frequency since WebSocket handles most updates)
setInterval(loadDevices, 5000);
</script>
</body>
</html>