mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 16:41:52 +00:00
Initial release v1.0.0 - BLE Device Simulator with MCP Integration
Features: - MCP server with tools for AI-controlled device simulation - FastAPI backend with REST API and WebSocket support - Real-time device updates via WebSocket - Web UI for manual device control - ESP32 firmware for BLE device simulation - Docker containerization with MQTT broker Supported device types: - Heart Rate Monitor (BLE Heart Rate Service) - Treadmill (BLE Fitness Machine Service) - Bike Trainer (BLE Cycling Power Service) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
30
Dockerfile
Normal 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
306
README.md
Normal 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
17
config/mosquitto.conf
Normal 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
23
entrypoint.sh
Normal 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
5
firmware/esp32_ble_sim/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
59
firmware/esp32_ble_sim/include/ble_services.h
Normal file
59
firmware/esp32_ble_sim/include/ble_services.h
Normal 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
|
||||||
37
firmware/esp32_ble_sim/include/config.h
Normal file
37
firmware/esp32_ble_sim/include/config.h
Normal 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
|
||||||
64
firmware/esp32_ble_sim/include/config_manager.h
Normal file
64
firmware/esp32_ble_sim/include/config_manager.h
Normal 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
|
||||||
30
firmware/esp32_ble_sim/include/web_portal.h
Normal file
30
firmware/esp32_ble_sim/include/web_portal.h
Normal 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
|
||||||
27
firmware/esp32_ble_sim/platformio.ini
Normal file
27
firmware/esp32_ble_sim/platformio.ini
Normal 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-*
|
||||||
241
firmware/esp32_ble_sim/src/ble_services.cpp
Normal file
241
firmware/esp32_ble_sim/src/ble_services.cpp
Normal 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();
|
||||||
|
}
|
||||||
79
firmware/esp32_ble_sim/src/config_manager.cpp
Normal file
79
firmware/esp32_ble_sim/src/config_manager.cpp
Normal 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);
|
||||||
|
}
|
||||||
402
firmware/esp32_ble_sim/src/main.cpp
Normal file
402
firmware/esp32_ble_sim/src/main.cpp
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
318
firmware/esp32_ble_sim/src/web_portal.cpp
Normal file
318
firmware/esp32_ble_sim/src/web_portal.cpp
Normal 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
14
mcp-config.example.json
Normal 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
16
requirements.txt
Normal 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
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# pyBTMCP - BLE Device Simulator
|
||||||
1
src/api/__init__.py
Normal file
1
src/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# FastAPI REST API
|
||||||
68
src/api/device_registry.py
Normal file
68
src/api/device_registry.py
Normal 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
168
src/api/main.py
Normal 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
131
src/api/mqtt_client.py
Normal 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
69
src/api/routes.py
Normal 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
1
src/mcp/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# MCP Server
|
||||||
353
src/mcp/server.py
Normal file
353
src/mcp/server.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"""MCP Server for BLE device simulator control."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import aiomqtt
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import Tool, TextContent
|
||||||
|
|
||||||
|
|
||||||
|
# Device state (shared with API via MQTT)
|
||||||
|
devices: dict[str, dict] = {}
|
||||||
|
|
||||||
|
# MQTT client for this server
|
||||||
|
mqtt_client: aiomqtt.Client | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_mqtt_host() -> str:
|
||||||
|
return os.getenv("MQTT_HOST", "localhost")
|
||||||
|
|
||||||
|
|
||||||
|
def get_mqtt_port() -> int:
|
||||||
|
return int(os.getenv("MQTT_PORT", "1883"))
|
||||||
|
|
||||||
|
|
||||||
|
# Create MCP server
|
||||||
|
server = Server("ble-simulator")
|
||||||
|
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
"""List available MCP tools."""
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name="list_devices",
|
||||||
|
description="List all connected ESP32 BLE simulator devices",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="configure_device",
|
||||||
|
description="Configure an ESP32 to simulate a specific BLE device type",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ESP32 device ID",
|
||||||
|
},
|
||||||
|
"device_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["heart_rate", "treadmill", "bike"],
|
||||||
|
"description": "Type of BLE device to simulate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["device_id", "device_type"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="set_heart_rate",
|
||||||
|
description="Set the simulated heart rate value for a device configured as a heart rate monitor",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ESP32 device ID",
|
||||||
|
},
|
||||||
|
"bpm": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 30,
|
||||||
|
"maximum": 220,
|
||||||
|
"description": "Heart rate in beats per minute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["device_id", "bpm"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="set_treadmill_values",
|
||||||
|
description="Set simulated values for a device configured as a treadmill",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ESP32 device ID",
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 25,
|
||||||
|
"description": "Speed in km/h",
|
||||||
|
},
|
||||||
|
"incline": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -5,
|
||||||
|
"maximum": 30,
|
||||||
|
"description": "Incline percentage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["device_id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="set_bike_values",
|
||||||
|
description="Set simulated values for a device configured as a bike/cycling trainer",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ESP32 device ID",
|
||||||
|
},
|
||||||
|
"power": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 2000,
|
||||||
|
"description": "Power in watts",
|
||||||
|
},
|
||||||
|
"cadence": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 200,
|
||||||
|
"description": "Cadence in RPM",
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 80,
|
||||||
|
"description": "Speed in km/h",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["device_id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="get_device_status",
|
||||||
|
description="Get the current status and values of a specific device",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ESP32 device ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["device_id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
"""Handle tool calls."""
|
||||||
|
global mqtt_client
|
||||||
|
|
||||||
|
# Ensure MQTT is connected
|
||||||
|
if mqtt_client is None:
|
||||||
|
try:
|
||||||
|
mqtt_client = aiomqtt.Client(
|
||||||
|
hostname=get_mqtt_host(),
|
||||||
|
port=get_mqtt_port()
|
||||||
|
)
|
||||||
|
await mqtt_client.__aenter__()
|
||||||
|
except Exception as e:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Failed to connect to MQTT broker: {e}"
|
||||||
|
)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if name == "list_devices":
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps({
|
||||||
|
"devices": list(devices.values()),
|
||||||
|
"count": len(devices)
|
||||||
|
}, indent=2)
|
||||||
|
)]
|
||||||
|
|
||||||
|
elif name == "configure_device":
|
||||||
|
device_id = arguments["device_id"]
|
||||||
|
device_type = arguments["device_type"]
|
||||||
|
|
||||||
|
await mqtt_client.publish(
|
||||||
|
f"ble-sim/{device_id}/config",
|
||||||
|
json.dumps({"type": device_type})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update local state
|
||||||
|
if device_id not in devices:
|
||||||
|
devices[device_id] = {"id": device_id}
|
||||||
|
devices[device_id]["type"] = device_type
|
||||||
|
devices[device_id]["values"] = {}
|
||||||
|
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Configured {device_id} as {device_type}"
|
||||||
|
)]
|
||||||
|
|
||||||
|
elif name == "set_heart_rate":
|
||||||
|
device_id = arguments["device_id"]
|
||||||
|
bpm = arguments["bpm"]
|
||||||
|
|
||||||
|
await mqtt_client.publish(
|
||||||
|
f"ble-sim/{device_id}/set",
|
||||||
|
json.dumps({"heart_rate": bpm})
|
||||||
|
)
|
||||||
|
|
||||||
|
if device_id in devices:
|
||||||
|
devices[device_id].setdefault("values", {})["heart_rate"] = bpm
|
||||||
|
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Set heart rate to {bpm} BPM on {device_id}"
|
||||||
|
)]
|
||||||
|
|
||||||
|
elif name == "set_treadmill_values":
|
||||||
|
device_id = arguments["device_id"]
|
||||||
|
values = {}
|
||||||
|
if "speed" in arguments:
|
||||||
|
values["speed"] = arguments["speed"]
|
||||||
|
if "incline" in arguments:
|
||||||
|
values["incline"] = arguments["incline"]
|
||||||
|
|
||||||
|
await mqtt_client.publish(
|
||||||
|
f"ble-sim/{device_id}/set",
|
||||||
|
json.dumps(values)
|
||||||
|
)
|
||||||
|
|
||||||
|
if device_id in devices:
|
||||||
|
devices[device_id].setdefault("values", {}).update(values)
|
||||||
|
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Set treadmill values on {device_id}: {values}"
|
||||||
|
)]
|
||||||
|
|
||||||
|
elif name == "set_bike_values":
|
||||||
|
device_id = arguments["device_id"]
|
||||||
|
values = {}
|
||||||
|
if "power" in arguments:
|
||||||
|
values["power"] = arguments["power"]
|
||||||
|
if "cadence" in arguments:
|
||||||
|
values["cadence"] = arguments["cadence"]
|
||||||
|
if "speed" in arguments:
|
||||||
|
values["speed"] = arguments["speed"]
|
||||||
|
|
||||||
|
await mqtt_client.publish(
|
||||||
|
f"ble-sim/{device_id}/set",
|
||||||
|
json.dumps(values)
|
||||||
|
)
|
||||||
|
|
||||||
|
if device_id in devices:
|
||||||
|
devices[device_id].setdefault("values", {}).update(values)
|
||||||
|
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Set bike values on {device_id}: {values}"
|
||||||
|
)]
|
||||||
|
|
||||||
|
elif name == "get_device_status":
|
||||||
|
device_id = arguments["device_id"]
|
||||||
|
device = devices.get(device_id)
|
||||||
|
|
||||||
|
if device:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps(device, indent=2)
|
||||||
|
)]
|
||||||
|
else:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Device {device_id} not found"
|
||||||
|
)]
|
||||||
|
|
||||||
|
else:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Unknown tool: {name}"
|
||||||
|
)]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Error: {e}"
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_mqtt_messages():
|
||||||
|
"""Background task to handle incoming MQTT status messages."""
|
||||||
|
global mqtt_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = get_mqtt_host()
|
||||||
|
port = get_mqtt_port()
|
||||||
|
|
||||||
|
async with aiomqtt.Client(hostname=host, port=port) as client:
|
||||||
|
await client.subscribe("ble-sim/+/status")
|
||||||
|
await client.subscribe("ble-sim/+/values")
|
||||||
|
|
||||||
|
async for message in client.messages:
|
||||||
|
topic_parts = str(message.topic).split("/")
|
||||||
|
if len(topic_parts) >= 3:
|
||||||
|
device_id = topic_parts[1]
|
||||||
|
msg_type = topic_parts[2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(message.payload.decode())
|
||||||
|
|
||||||
|
if device_id not in devices:
|
||||||
|
devices[device_id] = {"id": device_id}
|
||||||
|
|
||||||
|
if msg_type == "status":
|
||||||
|
devices[device_id]["online"] = payload.get("online", True)
|
||||||
|
devices[device_id]["type"] = payload.get("type")
|
||||||
|
elif msg_type == "values":
|
||||||
|
devices[device_id].setdefault("values", {}).update(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"MQTT listener error: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run the MCP server."""
|
||||||
|
# Start MQTT listener in background
|
||||||
|
mqtt_task = asyncio.create_task(handle_mqtt_messages())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run MCP server on stdio
|
||||||
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
|
await server.run(
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
server.create_initialization_options()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
mqtt_task.cancel()
|
||||||
|
try:
|
||||||
|
await mqtt_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1
src/web/__init__.py
Normal file
1
src/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Web UI
|
||||||
958
src/web/static/index.html
Normal file
958
src/web/static/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user