Initial release v1.0.0 - BLE Device Simulator with MCP Integration

Features:
- MCP server with tools for AI-controlled device simulation
- FastAPI backend with REST API and WebSocket support
- Real-time device updates via WebSocket
- Web UI for manual device control
- ESP32 firmware for BLE device simulation
- Docker containerization with MQTT broker

Supported device types:
- Heart Rate Monitor (BLE Heart Rate Service)
- Treadmill (BLE Fitness Machine Service)
- Bike Trainer (BLE Cycling Power Service)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 11:50:10 -05:00
commit 1c6b9db903
27 changed files with 3455 additions and 0 deletions

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

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

View File

@@ -0,0 +1,59 @@
#ifndef BLE_SERVICES_H
#define BLE_SERVICES_H
#include <Arduino.h>
// ============================================
// BLE Service UUIDs (Bluetooth SIG standard)
// ============================================
// Heart Rate Service
#define HEART_RATE_SERVICE_UUID "180D"
#define HEART_RATE_MEASUREMENT_UUID "2A37"
#define BODY_SENSOR_LOCATION_UUID "2A38"
// Fitness Machine Service
#define FITNESS_MACHINE_SERVICE_UUID "1826"
#define TREADMILL_DATA_UUID "2ACD"
#define FITNESS_MACHINE_FEATURE_UUID "2ACC"
// ============================================
// Function Prototypes
// ============================================
/**
* Initialize NimBLE stack (call once at startup)
*/
void initBLE();
/**
* Stop BLE advertising and deinit
*/
void stopBLE();
/**
* Setup BLE as Heart Rate Monitor
* Creates Heart Rate Service with measurement characteristic
*/
void setupBLE_HeartRate();
/**
* Setup BLE as Treadmill (Fitness Machine)
* Creates Fitness Machine Service with treadmill data
*/
void setupBLE_Treadmill();
/**
* Send heart rate notification
* @param bpm Heart rate in beats per minute
*/
void notifyHeartRate(uint8_t bpm);
/**
* Send treadmill data notification
* @param speed Speed in 0.01 km/h units
* @param incline Incline in 0.1% units
*/
void notifyTreadmill(uint16_t speed, int16_t incline);
#endif // BLE_SERVICES_H

View File

@@ -0,0 +1,37 @@
#ifndef CONFIG_H
#define CONFIG_H
// ============================================
// Firmware Version
// ============================================
#define FIRMWARE_VERSION "1.0.0"
// ============================================
// AP Mode Configuration
// ============================================
#define AP_SSID_PREFIX "BLE-Sim-" // Will append chip ID for uniqueness
#define AP_PASSWORD "" // Open network (or set a password)
#define AP_IP IPAddress(192, 168, 4, 1)
#define AP_GATEWAY IPAddress(192, 168, 4, 1)
#define AP_SUBNET IPAddress(255, 255, 255, 0)
// ============================================
// Default Values (used until configured)
// ============================================
#define DEFAULT_MQTT_PORT 1883
#define DEFAULT_DEVICE_ID_PREFIX "esp32-"
// ============================================
// Timing Configuration
// ============================================
#define BLE_NOTIFY_INTERVAL 1000 // BLE notification interval (ms)
#define MQTT_RECONNECT_INTERVAL 5000 // MQTT reconnect attempt interval (ms)
#define STATUS_REPORT_INTERVAL 10000 // Status report to MQTT (ms)
#define WIFI_CONNECT_TIMEOUT 15000 // WiFi connection timeout (ms)
// ============================================
// NVS Configuration Keys
// ============================================
#define NVS_NAMESPACE "ble-sim"
#endif // CONFIG_H

View File

@@ -0,0 +1,64 @@
#ifndef CONFIG_MANAGER_H
#define CONFIG_MANAGER_H
#include <Arduino.h>
#include <Preferences.h>
// ============================================
// Configuration Structure
// ============================================
struct DeviceConfig {
bool configured = false;
String wifiSsid = "";
String wifiPassword = "";
String mqttHost = "";
uint16_t mqttPort = 1883;
String deviceId = "";
};
// ============================================
// Configuration Manager Class
// ============================================
class ConfigManager {
public:
ConfigManager();
// Load config from NVS
bool load();
// Save config to NVS
void save();
// Clear all config
void clear();
// Check if configured
bool isConfigured() const { return config.configured; }
// Getters
const String& getWifiSsid() const { return config.wifiSsid; }
const String& getWifiPassword() const { return config.wifiPassword; }
const String& getMqttHost() const { return config.mqttHost; }
uint16_t getMqttPort() const { return config.mqttPort; }
const String& getDeviceId() const { return config.deviceId; }
// Setters
void setWifiCredentials(const String& ssid, const String& password);
void setMqttConfig(const String& host, uint16_t port);
void setDeviceId(const String& id);
// Get unique AP name based on chip ID
String getAPName() const;
// Get unique default device ID based on chip ID
String getDefaultDeviceId() const;
private:
DeviceConfig config;
Preferences preferences;
};
// Global instance
extern ConfigManager configManager;
#endif // CONFIG_MANAGER_H

View File

@@ -0,0 +1,30 @@
#ifndef WEB_PORTAL_H
#define WEB_PORTAL_H
#include <Arduino.h>
/**
* Initialize and start the web portal
* Runs on AP interface at 192.168.4.1
*/
void setupWebPortal();
/**
* Handle web portal requests (call in loop)
*/
void handleWebPortal();
/**
* Get status info for web portal display
*/
struct PortalStatus {
bool wifiConnected;
bool mqttConnected;
String ipAddress;
String deviceType;
bool bleStarted;
};
void updatePortalStatus(const PortalStatus& status);
#endif // WEB_PORTAL_H

View File

@@ -0,0 +1,27 @@
; PlatformIO Project Configuration File
;
; BLE Device Simulator for ESP32
; Supports: Heart Rate Monitor, Treadmill (Fitness Machine)
[env:esp32]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
; Use NimBLE for smaller footprint and better BLE support
lib_deps =
h2zero/NimBLE-Arduino@^1.4.0
knolleary/PubSubClient@^2.8
bblanchon/ArduinoJson@^7.0.0
; Build flags
build_flags =
-D CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=1
-D CONFIG_BT_NIMBLE_ROLE_CENTRAL=0
-D CONFIG_BT_NIMBLE_ROLE_OBSERVER=0
-D CONFIG_BT_NIMBLE_ROLE_BROADCASTER=1
; Upload settings (adjust port as needed)
; upload_port = /dev/cu.usbserial-*
; monitor_port = /dev/cu.usbserial-*

View File

@@ -0,0 +1,241 @@
/**
* BLE Services Implementation
*
* Implements standard Bluetooth SIG GATT services:
* - Heart Rate Service (0x180D)
* - Fitness Machine Service (0x1826) - Treadmill
*/
#include "ble_services.h"
#include "config.h"
#include "config_manager.h"
#include <NimBLEDevice.h>
// ============================================
// BLE Server and Characteristics
// ============================================
static NimBLEServer* pServer = nullptr;
static NimBLEAdvertising* pAdvertising = nullptr;
// Heart Rate
static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
// Treadmill
static NimBLECharacteristic* pTreadmillData = nullptr;
static bool deviceConnected = false;
static bool oldDeviceConnected = false;
// ============================================
// Server Callbacks
// ============================================
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* pServer) override {
deviceConnected = true;
Serial.println("BLE client connected");
}
void onDisconnect(NimBLEServer* pServer) override {
deviceConnected = false;
Serial.println("BLE client disconnected");
// Restart advertising
NimBLEDevice::startAdvertising();
}
};
// ============================================
// BLE Initialization
// ============================================
void initBLE() {
NimBLEDevice::init("BLE Simulator");
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
pServer = NimBLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
pAdvertising = NimBLEDevice::getAdvertising();
Serial.println("BLE initialized");
}
void stopBLE() {
if (pAdvertising) {
pAdvertising->stop();
}
// Clear services (will be recreated on next setup)
pHeartRateMeasurement = nullptr;
pTreadmillData = nullptr;
Serial.println("BLE stopped");
}
// ============================================
// Heart Rate Service Setup
// ============================================
void setupBLE_HeartRate() {
Serial.println("Setting up Heart Rate Service...");
// Stop any current advertising
if (pAdvertising) {
pAdvertising->stop();
}
// Create Heart Rate Service
NimBLEService* pService = pServer->createService(HEART_RATE_SERVICE_UUID);
// Heart Rate Measurement Characteristic
// Flags: Notify
pHeartRateMeasurement = pService->createCharacteristic(
HEART_RATE_MEASUREMENT_UUID,
NIMBLE_PROPERTY::NOTIFY
);
// Body Sensor Location Characteristic
// Flags: Read
// Value: 1 = Chest
NimBLECharacteristic* pBodySensorLocation = pService->createCharacteristic(
BODY_SENSOR_LOCATION_UUID,
NIMBLE_PROPERTY::READ
);
uint8_t sensorLocation = 1; // Chest
pBodySensorLocation->setValue(&sensorLocation, 1);
// Start the service
pService->start();
// Configure advertising
pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06);
pAdvertising->setMaxPreferred(0x12);
// Update device name
NimBLEDevice::setDeviceName("HR Simulator");
// Start advertising
NimBLEDevice::startAdvertising();
Serial.println("Heart Rate Service started, advertising...");
}
// ============================================
// Treadmill (Fitness Machine) Service Setup
// ============================================
void setupBLE_Treadmill() {
Serial.println("Setting up Fitness Machine Service (Treadmill)...");
// Stop any current advertising
if (pAdvertising) {
pAdvertising->stop();
}
// Create Fitness Machine Service
NimBLEService* pService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID);
// Fitness Machine Feature Characteristic
// Flags: Read
NimBLECharacteristic* pFeature = pService->createCharacteristic(
FITNESS_MACHINE_FEATURE_UUID,
NIMBLE_PROPERTY::READ
);
// Feature flags for treadmill:
// Byte 0-3: Fitness Machine Features
// Bit 0: Average Speed Supported
// Bit 1: Cadence Supported
// Bit 2: Total Distance Supported
// Bit 3: Inclination Supported
// Bit 13: Elapsed Time Supported
// Byte 4-7: Target Setting Features
uint8_t featureData[8] = {
0x0B, 0x20, 0x00, 0x00, // Features: Speed, Cadence, Distance, Inclination, Elapsed Time
0x00, 0x00, 0x00, 0x00 // Target settings (none)
};
pFeature->setValue(featureData, 8);
// Treadmill Data Characteristic
// Flags: Notify
pTreadmillData = pService->createCharacteristic(
TREADMILL_DATA_UUID,
NIMBLE_PROPERTY::NOTIFY
);
// Start the service
pService->start();
// Configure advertising
pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06);
pAdvertising->setMaxPreferred(0x12);
// Update device name
NimBLEDevice::setDeviceName("Treadmill Sim");
// Start advertising
NimBLEDevice::startAdvertising();
Serial.println("Fitness Machine Service (Treadmill) started, advertising...");
}
// ============================================
// Heart Rate Notification
// ============================================
void notifyHeartRate(uint8_t bpm) {
if (!pHeartRateMeasurement || !deviceConnected) return;
// Heart Rate Measurement format:
// Byte 0: Flags
// Bit 0: Heart Rate Value Format (0 = UINT8, 1 = UINT16)
// Bit 1-2: Sensor Contact Status
// Bit 3: Energy Expended Status
// Bit 4: RR-Interval
// Byte 1: Heart Rate Value (UINT8)
uint8_t hrData[2];
hrData[0] = 0x00; // Flags: UINT8 format, no contact detection
hrData[1] = bpm;
pHeartRateMeasurement->setValue(hrData, 2);
pHeartRateMeasurement->notify();
}
// ============================================
// Treadmill Data Notification
// ============================================
void notifyTreadmill(uint16_t speed, int16_t incline) {
if (!pTreadmillData || !deviceConnected) return;
// Treadmill Data format (per Bluetooth FTMS spec):
// Byte 0-1: Flags
// Bit 0: More Data (0 = Instantaneous Speed present)
// Bit 1: Average Speed present
// Bit 2: Total Distance present
// Bit 3: Inclination and Ramp Angle present
// Following bytes: Data fields based on flags
// We'll include: Instantaneous Speed + Inclination + Ramp Angle
uint8_t data[8];
// Flags: Inclination and Ramp Angle present (bit 3)
uint16_t flags = 0x0008;
data[0] = flags & 0xFF;
data[1] = (flags >> 8) & 0xFF;
// Instantaneous Speed (always present when More Data=0, uint16, 0.01 km/h resolution)
data[2] = speed & 0xFF;
data[3] = (speed >> 8) & 0xFF;
// Inclination (sint16, 0.1% resolution)
data[4] = incline & 0xFF;
data[5] = (incline >> 8) & 0xFF;
// Ramp Angle Setting (sint16, 0.1 degree resolution) - set to 0
int16_t rampAngle = 0;
data[6] = rampAngle & 0xFF;
data[7] = (rampAngle >> 8) & 0xFF;
pTreadmillData->setValue(data, 8);
pTreadmillData->notify();
}

View File

@@ -0,0 +1,79 @@
#include "config_manager.h"
#include "config.h"
// Global instance
ConfigManager configManager;
ConfigManager::ConfigManager() {}
bool ConfigManager::load() {
preferences.begin(NVS_NAMESPACE, true); // Read-only
config.configured = preferences.getBool("configured", false);
config.wifiSsid = preferences.getString("wifi_ssid", "");
config.wifiPassword = preferences.getString("wifi_pass", "");
config.mqttHost = preferences.getString("mqtt_host", "");
config.mqttPort = preferences.getUShort("mqtt_port", DEFAULT_MQTT_PORT);
config.deviceId = preferences.getString("device_id", getDefaultDeviceId());
preferences.end();
return config.configured;
}
void ConfigManager::save() {
preferences.begin(NVS_NAMESPACE, false); // Read-write
preferences.putBool("configured", config.configured);
preferences.putString("wifi_ssid", config.wifiSsid);
preferences.putString("wifi_pass", config.wifiPassword);
preferences.putString("mqtt_host", config.mqttHost);
preferences.putUShort("mqtt_port", config.mqttPort);
preferences.putString("device_id", config.deviceId);
preferences.end();
Serial.println("Configuration saved to NVS");
}
void ConfigManager::clear() {
preferences.begin(NVS_NAMESPACE, false);
preferences.clear();
preferences.end();
config = DeviceConfig();
Serial.println("Configuration cleared");
}
void ConfigManager::setWifiCredentials(const String& ssid, const String& password) {
config.wifiSsid = ssid;
config.wifiPassword = password;
if (ssid.length() > 0) {
config.configured = true;
}
}
void ConfigManager::setMqttConfig(const String& host, uint16_t port) {
config.mqttHost = host;
config.mqttPort = port;
}
void ConfigManager::setDeviceId(const String& id) {
config.deviceId = id.length() > 0 ? id : getDefaultDeviceId();
}
String ConfigManager::getAPName() const {
uint32_t chipId = 0;
for (int i = 0; i < 17; i += 8) {
chipId |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i;
}
return String(AP_SSID_PREFIX) + String(chipId, HEX);
}
String ConfigManager::getDefaultDeviceId() const {
uint32_t chipId = 0;
for (int i = 0; i < 17; i += 8) {
chipId |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i;
}
return String(DEFAULT_DEVICE_ID_PREFIX) + String(chipId, HEX);
}

View File

@@ -0,0 +1,402 @@
/**
* ESP32 BLE Device Simulator
*
* Simulates BLE fitness devices (Heart Rate Monitor, Treadmill)
* Controlled via MQTT from pyBTMCP server
*
* Features:
* - AP mode for configuration when not connected to WiFi (192.168.4.1)
* - Connects to configured WiFi for MQTT control
* - Configuration stored in NVS (flash once, configure via web)
*/
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <NimBLEDevice.h>
#include "config.h"
#include "config_manager.h"
#include "web_portal.h"
#include "ble_services.h"
// ============================================
// Global State
// ============================================
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
// Current device configuration
enum DeviceType {
DEVICE_NONE,
DEVICE_HEART_RATE,
DEVICE_TREADMILL
};
DeviceType currentDeviceType = DEVICE_NONE;
bool bleStarted = false;
bool wifiConnected = false;
bool mqttConnected = false;
bool apModeActive = false;
// Simulated values
uint8_t heartRate = 70;
uint16_t treadmillSpeed = 0; // 0.01 km/h resolution
int16_t treadmillIncline = 0; // 0.1% resolution
// Timing
unsigned long lastNotify = 0;
unsigned long lastStatus = 0;
unsigned long lastWifiAttempt = 0;
unsigned long lastMqttAttempt = 0;
// ============================================
// WiFi Functions
// ============================================
void startAPMode() {
if (apModeActive) return;
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(AP_IP, AP_GATEWAY, AP_SUBNET);
String apName = configManager.getAPName();
WiFi.softAP(apName.c_str(), AP_PASSWORD);
apModeActive = true;
Serial.println("\n========================================");
Serial.println("Access Point Started");
Serial.print(" SSID: ");
Serial.println(apName);
Serial.print(" Config URL: http://");
Serial.println(WiFi.softAPIP());
Serial.println("========================================\n");
}
void stopAPMode() {
if (!apModeActive) return;
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
apModeActive = false;
Serial.println("Access Point stopped (WiFi connected)");
}
void setupWiFi() {
if (configManager.isConfigured()) {
// Start in STA mode, try to connect
WiFi.mode(WIFI_STA);
Serial.println("Starting in STA mode (configured)");
} else {
// No config - start AP for setup
startAPMode();
}
}
void connectToWiFi() {
if (!configManager.isConfigured()) {
// Not configured - ensure AP is running
if (!apModeActive) {
startAPMode();
}
return;
}
if (WiFi.status() == WL_CONNECTED) {
if (!wifiConnected) {
wifiConnected = true;
Serial.print("WiFi connected! IP: ");
Serial.println(WiFi.localIP());
// Stop AP mode when connected
stopAPMode();
}
return;
}
// WiFi not connected
if (wifiConnected) {
// Was connected, now disconnected - start AP
wifiConnected = false;
mqttConnected = false;
Serial.println("WiFi disconnected!");
startAPMode();
}
// Don't spam connection attempts
if (millis() - lastWifiAttempt < WIFI_CONNECT_TIMEOUT) {
return;
}
lastWifiAttempt = millis();
// If AP is active, switch to AP+STA to allow connection attempts
if (apModeActive) {
WiFi.mode(WIFI_AP_STA);
}
Serial.print("Connecting to WiFi: ");
Serial.println(configManager.getWifiSsid());
WiFi.begin(
configManager.getWifiSsid().c_str(),
configManager.getWifiPassword().c_str()
);
}
// ============================================
// MQTT Functions
// ============================================
void publishStatus() {
if (!mqtt.connected()) return;
JsonDocument doc;
doc["online"] = true;
doc["firmware_version"] = FIRMWARE_VERSION;
const char* typeStr = "none";
if (currentDeviceType == DEVICE_HEART_RATE) typeStr = "heart_rate";
else if (currentDeviceType == DEVICE_TREADMILL) typeStr = "treadmill";
doc["type"] = typeStr;
doc["ble_started"] = bleStarted;
doc["ip"] = WiFi.localIP().toString();
String payload;
serializeJson(doc, payload);
String topic = String("ble-sim/") + configManager.getDeviceId() + "/status";
mqtt.publish(topic.c_str(), payload.c_str());
}
void publishValues() {
if (!mqtt.connected()) return;
JsonDocument doc;
if (currentDeviceType == DEVICE_HEART_RATE) {
doc["heart_rate"] = heartRate;
} else if (currentDeviceType == DEVICE_TREADMILL) {
doc["speed"] = treadmillSpeed / 100.0;
doc["incline"] = treadmillIncline / 10.0;
}
String payload;
serializeJson(doc, payload);
String topic = String("ble-sim/") + configManager.getDeviceId() + "/values";
mqtt.publish(topic.c_str(), payload.c_str());
}
void handleMqttMessage(char* topic, byte* payload, unsigned int length) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload, length);
if (error) {
Serial.print("JSON parse error: ");
Serial.println(error.c_str());
return;
}
String topicStr = String(topic);
String baseTopic = String("ble-sim/") + configManager.getDeviceId();
// Handle configuration
if (topicStr == baseTopic + "/config") {
String type = doc["type"] | "";
Serial.print("Configuring as: ");
Serial.println(type);
if (type == "heart_rate") {
currentDeviceType = DEVICE_HEART_RATE;
setupBLE_HeartRate();
bleStarted = true;
} else if (type == "treadmill") {
currentDeviceType = DEVICE_TREADMILL;
setupBLE_Treadmill();
bleStarted = true;
} else {
currentDeviceType = DEVICE_NONE;
stopBLE();
bleStarted = false;
}
publishStatus();
}
// Handle value updates
else if (topicStr == baseTopic + "/set") {
if (doc["heart_rate"].is<int>()) {
heartRate = doc["heart_rate"];
Serial.print("Heart rate set to: ");
Serial.println(heartRate);
}
if (doc["speed"].is<float>()) {
float speed = doc["speed"];
treadmillSpeed = (uint16_t)(speed * 100);
Serial.print("Speed set to: ");
Serial.println(speed);
}
if (doc["incline"].is<float>()) {
float incline = doc["incline"];
treadmillIncline = (int16_t)(incline * 10);
Serial.print("Incline set to: ");
Serial.println(incline);
}
publishValues();
}
}
void setupMQTT() {
mqtt.setCallback(handleMqttMessage);
mqtt.setBufferSize(512);
}
void connectToMQTT() {
if (!configManager.isConfigured() || !wifiConnected) {
return;
}
if (mqtt.connected()) {
if (!mqttConnected) {
mqttConnected = true;
}
return;
}
// Don't spam connection attempts
if (millis() - lastMqttAttempt < MQTT_RECONNECT_INTERVAL) {
return;
}
lastMqttAttempt = millis();
mqttConnected = false;
// Update MQTT server config
mqtt.setServer(
configManager.getMqttHost().c_str(),
configManager.getMqttPort()
);
Serial.print("Connecting to MQTT at ");
Serial.print(configManager.getMqttHost());
Serial.print(":");
Serial.println(configManager.getMqttPort());
String clientId = String("esp32-") + String(random(0xffff), HEX);
if (mqtt.connect(clientId.c_str())) {
mqttConnected = true;
Serial.println("MQTT connected!");
// Subscribe to control topics
String configTopic = String("ble-sim/") + configManager.getDeviceId() + "/config";
String setTopic = String("ble-sim/") + configManager.getDeviceId() + "/set";
mqtt.subscribe(configTopic.c_str());
mqtt.subscribe(setTopic.c_str());
Serial.print("Subscribed to: ");
Serial.println(configTopic);
// Publish initial status
publishStatus();
} else {
Serial.print("MQTT connection failed, rc=");
Serial.println(mqtt.state());
}
}
// ============================================
// Update Portal Status
// ============================================
void updateStatus() {
PortalStatus status;
status.wifiConnected = wifiConnected;
status.mqttConnected = mqttConnected;
status.bleStarted = bleStarted;
status.ipAddress = wifiConnected ? WiFi.localIP().toString() : "";
if (currentDeviceType == DEVICE_HEART_RATE) {
status.deviceType = "Heart Rate";
} else if (currentDeviceType == DEVICE_TREADMILL) {
status.deviceType = "Treadmill";
} else {
status.deviceType = "Not configured";
}
updatePortalStatus(status);
}
// ============================================
// Main Setup & Loop
// ============================================
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n========================================");
Serial.println(" ESP32 BLE Device Simulator");
Serial.print(" Firmware: v");
Serial.println(FIRMWARE_VERSION);
Serial.println("========================================\n");
// Load configuration from NVS
bool hasConfig = configManager.load();
Serial.print("Device ID: ");
Serial.println(configManager.getDeviceId());
Serial.print("Configured: ");
Serial.println(hasConfig ? "Yes" : "No");
// Start WiFi (AP always on, STA if configured)
setupWiFi();
// Start web configuration portal
setupWebPortal();
// Initialize MQTT
setupMQTT();
// Initialize BLE (don't start advertising yet)
initBLE();
Serial.println("\nReady!");
if (!configManager.isConfigured()) {
Serial.println("Configure at: http://192.168.4.1");
} else {
Serial.println("Connecting to WiFi...");
}
Serial.println("Waiting for MQTT commands...\n");
}
void loop() {
// Handle web portal requests
handleWebPortal();
// Maintain WiFi STA connection (if configured)
connectToWiFi();
// Maintain MQTT connection
connectToMQTT();
mqtt.loop();
// Update portal status display
updateStatus();
// Send BLE notifications
if (bleStarted && millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
lastNotify = millis();
if (currentDeviceType == DEVICE_HEART_RATE) {
notifyHeartRate(heartRate);
} else if (currentDeviceType == DEVICE_TREADMILL) {
notifyTreadmill(treadmillSpeed, treadmillIncline);
}
}
// Periodic status report to MQTT
if (mqttConnected && millis() - lastStatus >= STATUS_REPORT_INTERVAL) {
lastStatus = millis();
publishStatus();
}
}

View File

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