Compare commits

5 Commits
v1.0.0 ... main

Author SHA1 Message Date
aab2526f99 v1.4.0: BLE disconnect simulation with teardown support
Features:
- Add BLE disconnect simulation to test client reconnection behavior
- Support immediate disconnect, timed advertising pause, and full BLE teardown
- Add preset test buttons in admin UI (Quick Drop, 5s Pause, BLE Restart, 10s Restart)
- Add simulate_ble_disconnect MCP tool with duration_ms and teardown params
- Add POST /devices/{id}/disconnect API endpoint

Fixes:
- Fix treadmill distance not updating in admin UI (publish values periodically)
- Fix HR variation sending to non-heart_rate devices
- Fix BLE connection ID 0 being treated as "not connected"
- Clean up misleading startup log messages

Firmware: 1.1.0, API: 1.4.0, UI: 1.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 18:35:03 -05:00
0a48a2928e v1.3.2: Fix HR variation jumping instead of smooth transitions
Remove hard clamp that instantly snapped HR to ±3 of target, causing
jumps when target changed. Now uses rate-limited transitions (1 BPM
per tick at 0.5s intervals) for smooth ramping.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:28:24 -05:00
0d34cae441 v1.3.1: Fix stale devices in admin UI and firmware device type persistence
Admin UI fixes:
- Track deleted devices to prevent MQTT re-registration from stale messages
- Broadcast device_deleted event via WebSocket to sync all clients
- Add removeDeviceFromUI() to clean up device cards and pending timers
- Improve delete error handling with user feedback

Firmware fixes (v1.0.7):
- Fix device type strings to match API expectations (heart_rate vs Heart Rate)
- Clean up BLE services when switching device types (prevents service accumulation)
- Call stop() before setupHeartRate()/setupTreadmill() to reset NimBLE state
- Track service pointers for proper cleanup with pServer->removeService()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:18:57 -05:00
c95cd33343 v1.2.0: Firmware refactor to service-oriented architecture + WiFi fixes
Firmware:
- Fixed WiFi reconnection bug after power cycle by adding WiFi.persistent(false)
  and disabling auto-connect/auto-reconnect
- Fixed AP mode not broadcasting by properly handling WIFI_AP_STA mode transitions
- Refactored to service-oriented architecture:
  - device_state: Central state management with event callbacks
  - config_service: NVS persistence (renamed from config_manager)
  - wifi_service: WiFi STA/AP management (extracted from main.cpp)
  - mqtt_service: MQTT client and message routing (extracted from main.cpp)
  - ble_service: BLE GATT services (renamed from ble_services)
  - web_service: HTTP configuration portal (renamed from web_portal)
- main.cpp reduced from ~470 lines to ~60 lines (thin orchestrator)
- Each service is self-contained with setup()/loop() pattern

Backend:
- Added heart rate variation endpoint
- UI improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:49:54 -05:00
9b740ebdd0 v1.1.2: Smooth HR variation, validation, and UI improvements
- Fix HR variation to use smooth sinusoidal algorithm instead of erratic jumps
- Clamp HR values to ±3 of target (was exceeding bounds)
- Add Pydantic Field validation for device values in API
- Add runtime BPM validation in MCP server
- Add battery slider control for HR monitor
- Add distance display and reset button for treadmill
- Add UI/API version display in header
- Firmware: web portal improvements, battery/distance controls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:40:51 -05:00
26 changed files with 2206 additions and 999 deletions

View File

@@ -92,6 +92,24 @@ To use pyBTMCP with Claude Desktop, add it to your MCP configuration:
After saving the configuration, restart Claude Desktop. You should see the "ble-simulator" MCP server available.
## MCP Integration (Claude Code CLI)
To add pyBTMCP to Claude Code, run:
```bash
claude mcp add ble-simulator \
-s user \
-- docker run -i --rm -p 1883:1883 -p 8000:8000 --name pybtmcp pybtmcp:latest
```
This adds the MCP server to your user configuration. Use `-s project` instead to add it to the current project only.
To verify the server was added:
```bash
claude mcp list
```
## MCP Tools Reference
Once configured, Claude can use these tools to control BLE devices:

View File

@@ -1,59 +0,0 @@
#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

@@ -4,7 +4,7 @@
// ============================================
// Firmware Version
// ============================================
#define FIRMWARE_VERSION "1.0.0"
#define FIRMWARE_VERSION "1.1.0"
// ============================================
// AP Mode Configuration

View File

@@ -0,0 +1,105 @@
#ifndef DEVICE_STATE_H
#define DEVICE_STATE_H
#include <Arduino.h>
#include <functional>
// ============================================
// Device Types
// ============================================
enum class DeviceType {
NONE,
HEART_RATE,
TREADMILL
};
// ============================================
// Connection State
// ============================================
struct ConnectionState {
bool wifiConnected = false;
bool mqttConnected = false;
bool bleClientConnected = false;
String ipAddress = "";
};
// ============================================
// Simulated Device Values
// ============================================
struct SimulatedValues {
// Heart Rate Monitor
uint8_t heartRate = 70;
uint8_t batteryLevel = 100;
// Treadmill
uint16_t treadmillSpeed = 0; // 0.01 km/h resolution
int16_t treadmillIncline = 0; // 0.1% resolution
uint32_t treadmillDistance = 0; // meters
float distanceAccumulator = 0.0; // fractional distance
};
// ============================================
// Event Callbacks
// ============================================
using DeviceTypeChangedCallback = std::function<void(DeviceType newType)>;
using ValuesChangedCallback = std::function<void(const SimulatedValues& values)>;
using ConnectionChangedCallback = std::function<void(const ConnectionState& state)>;
// ============================================
// Device State Manager (Singleton)
// ============================================
class DeviceState {
public:
static DeviceState& getInstance();
// Prevent copying
DeviceState(const DeviceState&) = delete;
DeviceState& operator=(const DeviceState&) = delete;
// Device type
DeviceType getDeviceType() const { return deviceType; }
void setDeviceType(DeviceType type);
bool isBleStarted() const { return deviceType != DeviceType::NONE; }
const char* getDeviceTypeString() const;
// Simulated values
const SimulatedValues& getValues() const { return values; }
void setHeartRate(uint8_t bpm);
void setBatteryLevel(uint8_t level);
void setTreadmillSpeed(float speedKmh);
void setTreadmillIncline(float inclinePercent);
void setTreadmillDistance(uint32_t meters);
void resetTreadmillDistance();
void accumulateTreadmillDistance(float deltaSeconds);
// Connection state
const ConnectionState& getConnectionState() const { return connectionState; }
void setWifiConnected(bool connected, const String& ip = "");
void setMqttConnected(bool connected);
void setBleClientConnected(bool connected);
// Event registration
void onDeviceTypeChanged(DeviceTypeChangedCallback callback);
void onValuesChanged(ValuesChangedCallback callback);
void onConnectionChanged(ConnectionChangedCallback callback);
private:
DeviceState() = default;
DeviceType deviceType = DeviceType::NONE;
SimulatedValues values;
ConnectionState connectionState;
// Callbacks
DeviceTypeChangedCallback deviceTypeCallback = nullptr;
ValuesChangedCallback valuesCallback = nullptr;
ConnectionChangedCallback connectionCallback = nullptr;
void notifyValuesChanged();
void notifyConnectionChanged();
};
// Convenience macro for accessing singleton
#define deviceState DeviceState::getInstance()
#endif // DEVICE_STATE_H

View File

@@ -0,0 +1,68 @@
#ifndef BLE_SERVICE_H
#define BLE_SERVICE_H
#include <Arduino.h>
// ============================================
// BLE Service UUIDs (Bluetooth SIG standard)
// ============================================
#define HEART_RATE_SERVICE_UUID "180D"
#define HEART_RATE_MEASUREMENT_UUID "2A37"
#define BODY_SENSOR_LOCATION_UUID "2A38"
#define BATTERY_SERVICE_UUID "180F"
#define BATTERY_LEVEL_UUID "2A19"
#define FITNESS_MACHINE_SERVICE_UUID "1826"
#define TREADMILL_DATA_UUID "2ACD"
#define FITNESS_MACHINE_FEATURE_UUID "2ACC"
// ============================================
// BLE Service
// Manages BLE advertising and GATT services
// ============================================
class BleService {
public:
static BleService& getInstance();
// Prevent copying
BleService(const BleService&) = delete;
BleService& operator=(const BleService&) = delete;
// Lifecycle
void setup();
void loop();
// Configuration
void setupHeartRate();
void setupTreadmill();
void stop();
// Notifications
void notifyHeartRate(uint8_t bpm);
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance);
void updateBattery(uint8_t level);
// State
bool isClientConnected() const { return deviceConnected; }
// Disconnect simulation
void disconnectClient(); // Force disconnect + immediate re-advertise
void disconnectClientForDuration(int ms); // Disconnect + pause advertising for duration
void teardownForDuration(int ms); // Full BLE deinit + reinit after duration
private:
BleService() = default;
bool deviceConnected = false;
unsigned long lastNotify = 0;
unsigned long advertisingResumeTime = 0; // When to resume advertising (0 = not paused)
bool advertisingPaused = false;
unsigned long teardownResumeTime = 0; // When to reinit BLE after teardown
bool teardownPending = false;
void initBLE();
void reinitBLE(); // Reinit BLE and restore services
};
#define bleService BleService::getInstance()
#endif // BLE_SERVICE_H

View File

@@ -1,27 +1,20 @@
#ifndef CONFIG_MANAGER_H
#define CONFIG_MANAGER_H
#ifndef CONFIG_SERVICE_H
#define CONFIG_SERVICE_H
#include <Arduino.h>
#include <Preferences.h>
// ============================================
// Configuration Structure
// Config Service
// Manages persistent configuration in NVS
// ============================================
struct DeviceConfig {
bool configured = false;
String wifiSsid = "";
String wifiPassword = "";
String mqttHost = "";
uint16_t mqttPort = 1883;
String deviceId = "";
};
// ============================================
// Configuration Manager Class
// ============================================
class ConfigManager {
class ConfigService {
public:
ConfigManager();
static ConfigService& getInstance();
// Prevent copying
ConfigService(const ConfigService&) = delete;
ConfigService& operator=(const ConfigService&) = delete;
// Load config from NVS
bool load();
@@ -33,14 +26,14 @@ public:
void clear();
// Check if configured
bool isConfigured() const { return config.configured; }
bool isConfigured() const { return 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; }
const String& getWifiSsid() const { return wifiSsid; }
const String& getWifiPassword() const { return wifiPassword; }
const String& getMqttHost() const { return mqttHost; }
uint16_t getMqttPort() const { return mqttPort; }
const String& getDeviceId() const { return deviceId; }
// Setters
void setWifiCredentials(const String& ssid, const String& password);
@@ -54,11 +47,18 @@ public:
String getDefaultDeviceId() const;
private:
DeviceConfig config;
ConfigService() = default;
bool configured = false;
String wifiSsid = "";
String wifiPassword = "";
String mqttHost = "";
uint16_t mqttPort = 1883;
String deviceId = "";
Preferences preferences;
};
// Global instance
extern ConfigManager configManager;
#define configService ConfigService::getInstance()
#endif // CONFIG_MANAGER_H
#endif // CONFIG_SERVICE_H

View File

@@ -0,0 +1,45 @@
#ifndef MQTT_SERVICE_H
#define MQTT_SERVICE_H
#include <Arduino.h>
// ============================================
// MQTT Service
// Manages MQTT connection and message routing
// ============================================
class MqttService {
public:
static MqttService& getInstance();
// Prevent copying
MqttService(const MqttService&) = delete;
MqttService& operator=(const MqttService&) = delete;
// Lifecycle
void setup();
void loop();
// State
bool isConnected() const { return mqttConnected; }
// Publishing
void publishStatus();
void publishValues();
private:
MqttService() = default;
bool mqttConnected = false;
unsigned long lastMqttAttempt = 0;
unsigned long lastStatusReport = 0;
void connectToMQTT();
void handleMessage(char* topic, byte* payload, unsigned int length);
// Static callback wrapper for PubSubClient
static void messageCallback(char* topic, byte* payload, unsigned int length);
};
#define mqttService MqttService::getInstance()
#endif // MQTT_SERVICE_H

View File

@@ -0,0 +1,35 @@
#ifndef WEB_SERVICE_H
#define WEB_SERVICE_H
#include <Arduino.h>
// ============================================
// Web Service
// Manages HTTP server for configuration portal
// ============================================
class WebService {
public:
static WebService& getInstance();
// Prevent copying
WebService(const WebService&) = delete;
WebService& operator=(const WebService&) = delete;
// Lifecycle
void setup();
void loop();
private:
WebService() = default;
void handleRoot();
void handleGetStatus();
void handlePostConfig();
void handleReset();
void handleResetDistance();
void handleSetBattery();
};
#define webService WebService::getInstance()
#endif // WEB_SERVICE_H

View File

@@ -0,0 +1,49 @@
#ifndef WIFI_SERVICE_H
#define WIFI_SERVICE_H
#include <Arduino.h>
// ============================================
// WiFi Service
// Manages WiFi STA and AP modes
// ============================================
class WiFiService {
public:
static WiFiService& getInstance();
// Prevent copying
WiFiService(const WiFiService&) = delete;
WiFiService& operator=(const WiFiService&) = delete;
// Lifecycle
void setup();
void loop();
// State queries
bool isConnected() const { return wifiConnected; }
bool isApActive() const { return apModeActive; }
String getIP() const;
// Actions
void startAP();
void stopAP();
void reconnect(); // Trigger reconnection attempt
private:
WiFiService() = default;
bool wifiConnected = false;
bool apModeActive = false;
unsigned long lastWifiAttempt = 0;
int wifiRetryCount = 0;
static const int MAX_WIFI_RETRIES = 5;
void connectToWiFi();
void startAPMode();
void stopAPMode();
};
#define wifiService WiFiService::getInstance()
#endif // WIFI_SERVICE_H

View File

@@ -1,30 +0,0 @@
#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

@@ -1,241 +0,0 @@
/**
* 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

@@ -1,79 +0,0 @@
#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,104 @@
#include "device_state.h"
DeviceState& DeviceState::getInstance() {
static DeviceState instance;
return instance;
}
const char* DeviceState::getDeviceTypeString() const {
switch (deviceType) {
case DeviceType::HEART_RATE: return "heart_rate";
case DeviceType::TREADMILL: return "treadmill";
default: return "";
}
}
void DeviceState::setDeviceType(DeviceType type) {
if (deviceType != type) {
deviceType = type;
if (deviceTypeCallback) {
deviceTypeCallback(type);
}
}
}
void DeviceState::setHeartRate(uint8_t bpm) {
values.heartRate = bpm;
notifyValuesChanged();
}
void DeviceState::setBatteryLevel(uint8_t level) {
if (level > 100) level = 100;
values.batteryLevel = level;
notifyValuesChanged();
}
void DeviceState::setTreadmillSpeed(float speedKmh) {
values.treadmillSpeed = (uint16_t)(speedKmh * 100);
notifyValuesChanged();
}
void DeviceState::setTreadmillIncline(float inclinePercent) {
values.treadmillIncline = (int16_t)(inclinePercent * 10);
notifyValuesChanged();
}
void DeviceState::setTreadmillDistance(uint32_t meters) {
values.treadmillDistance = meters;
values.distanceAccumulator = (float)meters;
notifyValuesChanged();
}
void DeviceState::resetTreadmillDistance() {
values.treadmillDistance = 0;
values.distanceAccumulator = 0.0;
Serial.println("Treadmill distance reset to 0");
notifyValuesChanged();
}
void DeviceState::accumulateTreadmillDistance(float deltaSeconds) {
// Speed is in 0.01 km/h units
// meters per second = (speed/100) * 1000 / 3600 = speed / 360
values.distanceAccumulator += (values.treadmillSpeed / 360.0) * deltaSeconds;
values.treadmillDistance = (uint32_t)values.distanceAccumulator;
}
void DeviceState::setWifiConnected(bool connected, const String& ip) {
connectionState.wifiConnected = connected;
connectionState.ipAddress = connected ? ip : "";
notifyConnectionChanged();
}
void DeviceState::setMqttConnected(bool connected) {
connectionState.mqttConnected = connected;
notifyConnectionChanged();
}
void DeviceState::setBleClientConnected(bool connected) {
connectionState.bleClientConnected = connected;
notifyConnectionChanged();
}
void DeviceState::onDeviceTypeChanged(DeviceTypeChangedCallback callback) {
deviceTypeCallback = callback;
}
void DeviceState::onValuesChanged(ValuesChangedCallback callback) {
valuesCallback = callback;
}
void DeviceState::onConnectionChanged(ConnectionChangedCallback callback) {
connectionCallback = callback;
}
void DeviceState::notifyValuesChanged() {
if (valuesCallback) {
valuesCallback(values);
}
}
void DeviceState::notifyConnectionChanged() {
if (connectionCallback) {
connectionCallback(connectionState);
}
}

View File

@@ -4,333 +4,24 @@
* 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)
* Architecture:
* - device_state: Central state management with event callbacks
* - config_service: NVS persistence for configuration
* - wifi_service: WiFi STA/AP management
* - mqtt_service: MQTT client and message routing
* - ble_service: BLE GATT services and notifications
* - web_service: HTTP configuration portal
*/
#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"
#include "device_state.h"
#include "services/config_service.h"
#include "services/wifi_service.h"
#include "services/mqtt_service.h"
#include "services/ble_service.h"
#include "services/web_service.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);
@@ -342,61 +33,28 @@ void setup() {
Serial.println("========================================\n");
// Load configuration from NVS
bool hasConfig = configManager.load();
bool hasConfig = configService.load();
Serial.print("Device ID: ");
Serial.println(configManager.getDeviceId());
Serial.println(configService.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();
// Initialize all services
wifiService.setup();
webService.setup();
mqttService.setup();
bleService.setup();
Serial.println("\nReady!");
if (!configManager.isConfigured()) {
Serial.println("Configure at: http://192.168.4.1");
} else {
Serial.println("Connecting to WiFi...");
if (!configService.isConfigured()) {
Serial.println("Configure at: http://192.168.4.1\n");
}
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();
}
// Run all service loops
wifiService.loop();
webService.loop();
mqttService.loop();
bleService.loop();
}

View File

@@ -0,0 +1,381 @@
#include "services/ble_service.h"
#include "device_state.h"
#include "config.h"
#include <NimBLEDevice.h>
// ============================================
// BLE Server and Characteristics
// ============================================
static NimBLEServer* pServer = nullptr;
static NimBLEAdvertising* pAdvertising = nullptr;
static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
static NimBLECharacteristic* pBatteryLevel = nullptr;
static NimBLECharacteristic* pTreadmillData = nullptr;
// Track created services for cleanup
static NimBLEService* pHeartRateService = nullptr;
static NimBLEService* pBatteryService = nullptr;
static NimBLEService* pFitnessMachineService = nullptr;
static bool bleInitialized = false;
static uint16_t currentConnId = 0; // Track connection ID for disconnect
static bool advertisingPausedFlag = false; // Shared with callbacks
// Forward declare for callback
static void onBleConnect(NimBLEServer* server);
static void onBleDisconnect();
// ============================================
// Server Callbacks
// ============================================
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* pServer) override {
onBleConnect(pServer);
}
void onDisconnect(NimBLEServer* pServer) override {
onBleDisconnect();
}
};
static void onBleConnect(NimBLEServer* server) {
// Track connection ID for forced disconnect
// getPeerInfo(0) gets the first (most recent) connected peer
NimBLEConnInfo peerInfo = server->getPeerInfo(0);
currentConnId = peerInfo.getConnHandle();
deviceState.setBleClientConnected(true);
Serial.print("BLE client connected (connId: ");
Serial.print(currentConnId);
Serial.println(")");
}
static void onBleDisconnect() {
currentConnId = 0;
deviceState.setBleClientConnected(false);
Serial.println("BLE client disconnected");
// Only auto-resume advertising if not paused
if (!advertisingPausedFlag) {
NimBLEDevice::startAdvertising();
}
}
// ============================================
// Singleton
// ============================================
BleService& BleService::getInstance() {
static BleService instance;
return instance;
}
// ============================================
// Lifecycle
// ============================================
void BleService::setup() {
initBLE();
}
void BleService::loop() {
// Check if BLE should be reinitialized after teardown
if (teardownPending && teardownResumeTime > 0 && millis() >= teardownResumeTime) {
teardownResumeTime = 0;
teardownPending = false;
reinitBLE();
}
if (!deviceState.isBleStarted()) return;
// Check if advertising should be resumed after timed pause
if (advertisingPaused && advertisingResumeTime > 0 && millis() >= advertisingResumeTime) {
advertisingResumeTime = 0;
advertisingPaused = false;
advertisingPausedFlag = false;
NimBLEDevice::startAdvertising();
Serial.println("Advertising resumed after timed pause");
}
// Send notifications at regular intervals
if (millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
lastNotify = millis();
const auto& values = deviceState.getValues();
if (deviceState.getDeviceType() == DeviceType::HEART_RATE) {
notifyHeartRate(values.heartRate);
} else if (deviceState.getDeviceType() == DeviceType::TREADMILL) {
// Accumulate distance
deviceState.accumulateTreadmillDistance(BLE_NOTIFY_INTERVAL / 1000.0);
const auto& updatedValues = deviceState.getValues();
notifyTreadmill(updatedValues.treadmillSpeed,
updatedValues.treadmillIncline,
updatedValues.treadmillDistance);
}
}
}
void BleService::initBLE() {
if (bleInitialized) return;
NimBLEDevice::init("BLE Simulator");
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
pServer = NimBLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
pAdvertising = NimBLEDevice::getAdvertising();
bleInitialized = true;
Serial.println("BLE initialized");
}
void BleService::stop() {
if (pAdvertising) {
pAdvertising->stop();
// Clear all service UUIDs from advertising
pAdvertising->reset();
}
// Remove existing services from the server
if (pServer) {
if (pHeartRateService) {
pServer->removeService(pHeartRateService);
pHeartRateService = nullptr;
}
if (pBatteryService) {
pServer->removeService(pBatteryService);
pBatteryService = nullptr;
}
if (pFitnessMachineService) {
pServer->removeService(pFitnessMachineService);
pFitnessMachineService = nullptr;
}
}
// Clear characteristic pointers
pHeartRateMeasurement = nullptr;
pBatteryLevel = nullptr;
pTreadmillData = nullptr;
Serial.println("BLE stopped and services cleaned up");
}
// ============================================
// Heart Rate Service Setup
// ============================================
void BleService::setupHeartRate() {
Serial.println("Setting up Heart Rate Service...");
// Clean up any existing services first
stop();
// Create Heart Rate Service
pHeartRateService = pServer->createService(HEART_RATE_SERVICE_UUID);
pHeartRateMeasurement = pHeartRateService->createCharacteristic(
HEART_RATE_MEASUREMENT_UUID,
NIMBLE_PROPERTY::NOTIFY
);
NimBLECharacteristic* pBodySensorLocation = pHeartRateService->createCharacteristic(
BODY_SENSOR_LOCATION_UUID,
NIMBLE_PROPERTY::READ
);
uint8_t sensorLocation = 1; // Chest
pBodySensorLocation->setValue(&sensorLocation, 1);
pHeartRateService->start();
// Create Battery Service
pBatteryService = pServer->createService(BATTERY_SERVICE_UUID);
pBatteryLevel = pBatteryService->createCharacteristic(
BATTERY_LEVEL_UUID,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
);
uint8_t initialBattery = deviceState.getValues().batteryLevel;
pBatteryLevel->setValue(&initialBattery, 1);
pBatteryService->start();
// Configure advertising
pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID);
pAdvertising->addServiceUUID(BATTERY_SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06);
pAdvertising->setMaxPreferred(0x12);
NimBLEDevice::setDeviceName("HR Simulator");
NimBLEDevice::startAdvertising();
Serial.println("Heart Rate + Battery Services started, advertising...");
}
// ============================================
// Treadmill Service Setup
// ============================================
void BleService::setupTreadmill() {
Serial.println("Setting up Fitness Machine Service (Treadmill)...");
// Clean up any existing services first
stop();
pFitnessMachineService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID);
NimBLECharacteristic* pFeature = pFitnessMachineService->createCharacteristic(
FITNESS_MACHINE_FEATURE_UUID,
NIMBLE_PROPERTY::READ
);
uint8_t featureData[8] = {
0x0B, 0x20, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};
pFeature->setValue(featureData, 8);
pTreadmillData = pFitnessMachineService->createCharacteristic(
TREADMILL_DATA_UUID,
NIMBLE_PROPERTY::NOTIFY
);
pFitnessMachineService->start();
pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06);
pAdvertising->setMaxPreferred(0x12);
NimBLEDevice::setDeviceName("Treadmill Sim");
NimBLEDevice::startAdvertising();
Serial.println("Fitness Machine Service (Treadmill) started, advertising...");
}
// ============================================
// Notifications
// ============================================
void BleService::notifyHeartRate(uint8_t bpm) {
if (!pHeartRateMeasurement || !deviceState.getConnectionState().bleClientConnected) return;
uint8_t hrData[2];
hrData[0] = 0x00; // Flags
hrData[1] = bpm;
pHeartRateMeasurement->setValue(hrData, 2);
pHeartRateMeasurement->notify();
}
void BleService::updateBattery(uint8_t level) {
if (!pBatteryLevel) return;
if (level > 100) level = 100;
pBatteryLevel->setValue(&level, 1);
pBatteryLevel->notify();
}
void BleService::notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance) {
if (!pTreadmillData || !deviceState.getConnectionState().bleClientConnected) return;
uint8_t data[11];
uint16_t flags = 0x000C;
data[0] = flags & 0xFF;
data[1] = (flags >> 8) & 0xFF;
data[2] = speed & 0xFF;
data[3] = (speed >> 8) & 0xFF;
data[4] = distance & 0xFF;
data[5] = (distance >> 8) & 0xFF;
data[6] = (distance >> 16) & 0xFF;
data[7] = incline & 0xFF;
data[8] = (incline >> 8) & 0xFF;
int16_t rampAngle = 0;
data[9] = rampAngle & 0xFF;
data[10] = (rampAngle >> 8) & 0xFF;
pTreadmillData->setValue(data, 11);
pTreadmillData->notify();
}
// ============================================
// Disconnect Simulation
// ============================================
void BleService::disconnectClient() {
if (!pServer || !deviceState.getConnectionState().bleClientConnected) {
Serial.println("No BLE client connected to disconnect");
return;
}
Serial.println("Forcing BLE client disconnect (immediate re-advertise)");
pServer->disconnect(currentConnId);
// onBleDisconnect callback will handle re-advertising
}
void BleService::disconnectClientForDuration(int ms) {
if (!pServer || !deviceState.getConnectionState().bleClientConnected) {
Serial.println("No BLE client connected to disconnect");
return;
}
Serial.print("Forcing BLE client disconnect, pausing advertising for ");
Serial.print(ms);
Serial.println("ms");
// Set flags before disconnect so callback knows not to auto-resume
advertisingPaused = true;
advertisingPausedFlag = true;
advertisingResumeTime = millis() + ms;
pServer->disconnect(currentConnId);
// onBleDisconnect callback will NOT resume advertising due to flag
}
void BleService::teardownForDuration(int ms) {
Serial.print("Tearing down BLE stack, will reinit in ");
Serial.print(ms);
Serial.println("ms");
// Clear all service/characteristic pointers
pHeartRateMeasurement = nullptr;
pBatteryLevel = nullptr;
pTreadmillData = nullptr;
pHeartRateService = nullptr;
pBatteryService = nullptr;
pFitnessMachineService = nullptr;
pServer = nullptr;
pAdvertising = nullptr;
// Full BLE deinit
NimBLEDevice::deinit(true);
bleInitialized = false;
currentConnId = 0;
deviceConnected = false;
deviceState.setBleClientConnected(false);
// Schedule reinit
teardownPending = true;
teardownResumeTime = millis() + ms;
Serial.println("BLE stack torn down - device will disappear from scans");
}
void BleService::reinitBLE() {
Serial.println("Reinitializing BLE stack after teardown...");
// Reinit the BLE stack
initBLE();
// Restore the previous device type configuration
DeviceType currentType = deviceState.getDeviceType();
if (currentType == DeviceType::HEART_RATE) {
setupHeartRate();
Serial.println("Restored Heart Rate service");
} else if (currentType == DeviceType::TREADMILL) {
setupTreadmill();
Serial.println("Restored Treadmill service");
}
Serial.println("BLE stack reinitialized - device visible again");
}

View File

@@ -0,0 +1,85 @@
#include "services/config_service.h"
#include "config.h"
ConfigService& ConfigService::getInstance() {
static ConfigService instance;
return instance;
}
bool ConfigService::load() {
preferences.begin(NVS_NAMESPACE, true); // Read-only
configured = preferences.getBool("configured", false);
wifiSsid = preferences.getString("wifi_ssid", "");
wifiPassword = preferences.getString("wifi_pass", "");
mqttHost = preferences.getString("mqtt_host", "");
mqttPort = preferences.getUShort("mqtt_port", DEFAULT_MQTT_PORT);
deviceId = preferences.getString("device_id", getDefaultDeviceId());
preferences.end();
return configured;
}
void ConfigService::save() {
preferences.begin(NVS_NAMESPACE, false); // Read-write
preferences.putBool("configured", configured);
preferences.putString("wifi_ssid", wifiSsid);
preferences.putString("wifi_pass", wifiPassword);
preferences.putString("mqtt_host", mqttHost);
preferences.putUShort("mqtt_port", mqttPort);
preferences.putString("device_id", deviceId);
preferences.end();
Serial.println("Configuration saved to NVS");
}
void ConfigService::clear() {
preferences.begin(NVS_NAMESPACE, false);
preferences.clear();
preferences.end();
configured = false;
wifiSsid = "";
wifiPassword = "";
mqttHost = "";
mqttPort = DEFAULT_MQTT_PORT;
deviceId = "";
Serial.println("Configuration cleared");
}
void ConfigService::setWifiCredentials(const String& ssid, const String& password) {
wifiSsid = ssid;
wifiPassword = password;
if (ssid.length() > 0) {
configured = true;
}
}
void ConfigService::setMqttConfig(const String& host, uint16_t port) {
mqttHost = host;
mqttPort = port;
}
void ConfigService::setDeviceId(const String& id) {
deviceId = id.length() > 0 ? id : getDefaultDeviceId();
}
String ConfigService::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 ConfigService::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,223 @@
#include "services/mqtt_service.h"
#include "services/config_service.h"
#include "services/wifi_service.h"
#include "services/ble_service.h"
#include "device_state.h"
#include "config.h"
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
// MQTT client (needs WiFiClient)
static WiFiClient wifiClient;
static PubSubClient mqtt(wifiClient);
MqttService& MqttService::getInstance() {
static MqttService instance;
return instance;
}
void MqttService::messageCallback(char* topic, byte* payload, unsigned int length) {
getInstance().handleMessage(topic, payload, length);
}
void MqttService::setup() {
mqtt.setCallback(messageCallback);
mqtt.setBufferSize(512);
}
void MqttService::loop() {
connectToMQTT();
mqtt.loop();
// Periodic status and values report
if (mqttConnected && millis() - lastStatusReport >= STATUS_REPORT_INTERVAL) {
lastStatusReport = millis();
publishStatus();
publishValues(); // Include current values (e.g., accumulated distance)
}
}
void MqttService::connectToMQTT() {
if (!configService.isConfigured() || !wifiService.isConnected()) {
return;
}
if (mqtt.connected()) {
if (!mqttConnected) {
mqttConnected = true;
deviceState.setMqttConnected(true);
}
return;
}
// Don't spam connection attempts
if (millis() - lastMqttAttempt < MQTT_RECONNECT_INTERVAL) {
return;
}
lastMqttAttempt = millis();
mqttConnected = false;
deviceState.setMqttConnected(false);
mqtt.setServer(
configService.getMqttHost().c_str(),
configService.getMqttPort()
);
Serial.print("Connecting to MQTT at ");
Serial.print(configService.getMqttHost());
Serial.print(":");
Serial.println(configService.getMqttPort());
String clientId = String("esp32-") + String(random(0xffff), HEX);
// LWT for disconnect detection
String statusTopic = String("ble-sim/") + configService.getDeviceId() + "/status";
String willMessage = "{\"online\":false}";
if (mqtt.connect(clientId.c_str(), statusTopic.c_str(), 1, true, willMessage.c_str())) {
mqttConnected = true;
deviceState.setMqttConnected(true);
Serial.println("MQTT connected with LWT!");
// Subscribe to control topics
String configTopic = String("ble-sim/") + configService.getDeviceId() + "/config";
String setTopic = String("ble-sim/") + configService.getDeviceId() + "/set";
String disconnectTopic = String("ble-sim/") + configService.getDeviceId() + "/disconnect";
mqtt.subscribe(configTopic.c_str());
mqtt.subscribe(setTopic.c_str());
mqtt.subscribe(disconnectTopic.c_str());
Serial.print("Subscribed to: ");
Serial.println(configTopic);
publishStatus();
} else {
Serial.print("MQTT connection failed, rc=");
Serial.println(mqtt.state());
}
}
void MqttService::handleMessage(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/") + configService.getDeviceId();
// Handle device type configuration
if (topicStr == baseTopic + "/config") {
String type = doc["type"] | "";
Serial.print("Configuring as: ");
Serial.println(type);
if (type == "heart_rate") {
deviceState.setDeviceType(DeviceType::HEART_RATE);
bleService.setupHeartRate();
} else if (type == "treadmill") {
deviceState.setDeviceType(DeviceType::TREADMILL);
bleService.setupTreadmill();
} else {
deviceState.setDeviceType(DeviceType::NONE);
bleService.stop();
}
publishStatus();
}
// Handle value updates
else if (topicStr == baseTopic + "/set") {
if (doc["heart_rate"].is<int>()) {
deviceState.setHeartRate(doc["heart_rate"]);
Serial.print("Heart rate set to: ");
Serial.println(deviceState.getValues().heartRate);
}
if (doc["battery"].is<int>()) {
deviceState.setBatteryLevel(doc["battery"]);
bleService.updateBattery(deviceState.getValues().batteryLevel);
Serial.print("Battery level set to: ");
Serial.println(deviceState.getValues().batteryLevel);
}
if (doc["speed"].is<float>()) {
deviceState.setTreadmillSpeed(doc["speed"]);
Serial.print("Speed set to: ");
Serial.println(doc["speed"].as<float>());
}
if (doc["incline"].is<float>()) {
deviceState.setTreadmillIncline(doc["incline"]);
Serial.print("Incline set to: ");
Serial.println(doc["incline"].as<float>());
}
if (doc["distance"].is<int>()) {
deviceState.setTreadmillDistance(doc["distance"]);
Serial.print("Distance set to: ");
Serial.println(deviceState.getValues().treadmillDistance);
}
publishValues();
}
// Handle BLE disconnect command
else if (topicStr == baseTopic + "/disconnect") {
int duration = doc["duration_ms"] | 0; // 0 = immediate resume
bool teardown = doc["teardown"] | false; // Full BLE stack teardown
if (teardown) {
// Full teardown - device disappears from scans
int teardownDuration = duration > 0 ? duration : 3000; // Default 3s for teardown
bleService.teardownForDuration(teardownDuration);
} else if (duration > 0) {
// Just disconnect and pause advertising
bleService.disconnectClientForDuration(duration);
} else {
// Just disconnect, immediate re-advertise
bleService.disconnectClient();
}
}
}
void MqttService::publishStatus() {
if (!mqtt.connected()) return;
JsonDocument doc;
doc["online"] = true;
doc["firmware_version"] = FIRMWARE_VERSION;
doc["type"] = deviceState.getDeviceTypeString();
doc["ble_started"] = deviceState.isBleStarted();
doc["ip"] = wifiService.getIP();
String payload;
serializeJson(doc, payload);
String topic = String("ble-sim/") + configService.getDeviceId() + "/status";
mqtt.publish(topic.c_str(), payload.c_str(), true);
}
void MqttService::publishValues() {
if (!mqtt.connected()) return;
JsonDocument doc;
const auto& values = deviceState.getValues();
if (deviceState.getDeviceType() == DeviceType::HEART_RATE) {
doc["heart_rate"] = values.heartRate;
doc["battery"] = values.batteryLevel;
} else if (deviceState.getDeviceType() == DeviceType::TREADMILL) {
doc["speed"] = values.treadmillSpeed / 100.0;
doc["incline"] = values.treadmillIncline / 10.0;
doc["distance"] = values.treadmillDistance;
}
String payload;
serializeJson(doc, payload);
String topic = String("ble-sim/") + configService.getDeviceId() + "/values";
mqtt.publish(topic.c_str(), payload.c_str());
}

View File

@@ -1,10 +1,11 @@
#include "web_portal.h"
#include "config_manager.h"
#include "services/web_service.h"
#include "services/config_service.h"
#include "services/wifi_service.h"
#include "device_state.h"
#include <WebServer.h>
#include <ArduinoJson.h>
WebServer server(80);
PortalStatus currentStatus = {};
static WebServer server(80);
// HTML template with embedded CSS and JS
const char INDEX_HTML[] PROGMEM = R"rawliteral(
@@ -78,6 +79,27 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
button:hover { background: #22c55e; }
.btn-danger { background: #f87171; }
.btn-danger:hover { background: #ef4444; }
.btn-secondary { background: #6366f1; margin-top: 10px; }
.btn-secondary:hover { background: #4f46e5; }
.distance-value { font-size: 24px; font-weight: bold; color: #4ade80; }
.battery-value { font-size: 24px; font-weight: bold; color: #4ade80; }
.hidden { display: none; }
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 8px;
border-radius: 4px;
background: #0f3460;
margin: 10px 0;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #4ade80;
cursor: pointer;
}
.msg { padding: 10px; border-radius: 8px; margin-bottom: 15px; }
.msg-success { background: #064e3b; }
.msg-error { background: #7f1d1d; }
@@ -85,7 +107,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</head>
<body>
<h1>BLE Simulator</h1>
<p class="subtitle" id="apName">Loading...</p>
<p class="subtitle"><span id="apName">Loading...</span> &bull; UI v1.2.0</p>
<div class="card">
<h2>Status</h2>
@@ -107,6 +129,24 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</div>
</div>
<div class="card hidden" id="heartRateCard">
<h2>Heart Rate Monitor</h2>
<div class="status-row">
<span>Battery Level</span>
<span class="battery-value" id="batteryValue">100%</span>
</div>
<input type="range" id="batterySlider" min="0" max="100" value="100">
</div>
<div class="card hidden" id="treadmillCard">
<h2>Treadmill</h2>
<div class="status-row">
<span>Distance</span>
<span class="distance-value" id="distance">0 m</span>
</div>
<button class="btn-secondary" onclick="resetDistance()">Reset Distance</button>
</div>
<div class="card">
<h2>WiFi Configuration</h2>
<div id="message"></div>
@@ -137,7 +177,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
<script>
let configLoaded = false;
// Load config once on page load
async function loadConfig() {
try {
const res = await fetch('/api/status');
@@ -156,7 +195,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
}
}
// Update only status indicators (not form fields)
async function updateStatus() {
try {
const res = await fetch('/api/status');
@@ -164,6 +202,15 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
updateStatusDots(data.status);
} catch (e) {
console.error('Failed to update status:', e);
updateStatusDots({
wifiConnected: false,
mqttConnected: false,
bleStarted: false,
deviceType: 'Not configured',
ipAddress: '',
treadmillDistance: 0,
batteryLevel: 0
});
}
}
@@ -178,6 +225,23 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
document.getElementById('bleStatus').textContent = status.bleStarted ? status.deviceType : 'Not started';
document.getElementById('ipAddr').textContent = status.ipAddress || '-';
const heartRateCard = document.getElementById('heartRateCard');
if (status.deviceType === 'Heart Rate') {
heartRateCard.classList.remove('hidden');
document.getElementById('batteryValue').textContent = status.batteryLevel + '%';
document.getElementById('batterySlider').value = status.batteryLevel;
} else {
heartRateCard.classList.add('hidden');
}
const treadmillCard = document.getElementById('treadmillCard');
if (status.deviceType === 'Treadmill') {
treadmillCard.classList.remove('hidden');
document.getElementById('distance').textContent = status.treadmillDistance + ' m';
} else {
treadmillCard.classList.add('hidden');
}
}
document.getElementById('configForm').addEventListener('submit', async (e) => {
@@ -223,42 +287,86 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
}
}
// Load config once, then only update status
loadConfig();
setInterval(updateStatus, 5000);
async function resetDistance() {
try {
await fetch('/api/reset-distance', { method: 'POST' });
document.getElementById('distance').textContent = '0 m';
} catch (e) {
console.error('Failed to reset distance:', e);
}
}
document.getElementById('batterySlider').addEventListener('input', async (e) => {
const level = e.target.value;
document.getElementById('batteryValue').textContent = level + '%';
try {
await fetch('/api/set-battery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level: parseInt(level) })
});
} catch (err) {
console.error('Failed to set battery:', err);
}
});
async function init() {
let retries = 3;
while (retries > 0) {
try {
await loadConfig();
break;
} catch (e) {
retries--;
if (retries > 0) await new Promise(r => setTimeout(r, 1000));
}
}
}
init();
setInterval(updateStatus, 3000);
</script>
</body>
</html>
)rawliteral";
void handleRoot() {
WebService& WebService::getInstance() {
static WebService instance;
return instance;
}
void WebService::handleRoot() {
server.send(200, "text/html", INDEX_HTML);
}
void handleGetStatus() {
void WebService::handleGetStatus() {
JsonDocument doc;
doc["apName"] = configManager.getAPName();
doc["apName"] = configService.getAPName();
JsonObject config = doc["config"].to<JsonObject>();
config["ssid"] = configManager.getWifiSsid();
config["mqttHost"] = configManager.getMqttHost();
config["mqttPort"] = configManager.getMqttPort();
config["deviceId"] = configManager.getDeviceId();
config["ssid"] = configService.getWifiSsid();
config["mqttHost"] = configService.getMqttHost();
config["mqttPort"] = configService.getMqttPort();
config["deviceId"] = configService.getDeviceId();
const auto& connState = deviceState.getConnectionState();
const auto& values = deviceState.getValues();
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;
status["wifiConnected"] = connState.wifiConnected;
status["mqttConnected"] = connState.mqttConnected;
status["bleStarted"] = deviceState.isBleStarted();
status["deviceType"] = deviceState.getDeviceTypeString();
status["ipAddress"] = connState.ipAddress;
status["treadmillDistance"] = values.treadmillDistance;
status["batteryLevel"] = values.batteryLevel;
String response;
serializeJson(doc, response);
server.send(200, "application/json", response);
}
void handlePostConfig() {
void WebService::handlePostConfig() {
if (!server.hasArg("plain")) {
server.send(400, "application/json", "{\"error\":\"No body\"}");
return;
@@ -272,26 +380,25 @@ void handlePostConfig() {
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();
configService.setWifiCredentials(ssid, password);
configService.setMqttConfig(mqttHost, mqttPort);
configService.setDeviceId(deviceId);
configService.save();
server.send(200, "application/json", "{\"success\":true}");
// Trigger reconnect (will be handled in main loop)
Serial.println("Configuration updated, reconnecting...");
wifiService.reconnect();
}
void handleReset() {
configManager.clear();
void WebService::handleReset() {
configService.clear();
server.send(200, "application/json", "{\"success\":true}");
Serial.println("Configuration reset, rebooting...");
@@ -299,20 +406,47 @@ void handleReset() {
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);
void WebService::handleResetDistance() {
deviceState.resetTreadmillDistance();
server.send(200, "application/json", "{\"success\":true}");
}
void WebService::handleSetBattery() {
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;
}
uint8_t level = doc["level"] | 100;
if (level > 100) level = 100;
deviceState.setBatteryLevel(level);
Serial.print("Battery level set via web UI: ");
Serial.println(level);
server.send(200, "application/json", "{\"success\":true}");
}
void WebService::setup() {
server.on("/", HTTP_GET, []() { webService.handleRoot(); });
server.on("/api/status", HTTP_GET, []() { webService.handleGetStatus(); });
server.on("/api/config", HTTP_POST, []() { webService.handlePostConfig(); });
server.on("/api/reset", HTTP_POST, []() { webService.handleReset(); });
server.on("/api/reset-distance", HTTP_POST, []() { webService.handleResetDistance(); });
server.on("/api/set-battery", HTTP_POST, []() { webService.handleSetBattery(); });
server.begin();
Serial.println("Web portal started on http://192.168.4.1");
}
void handleWebPortal() {
void WebService::loop() {
server.handleClient();
}
void updatePortalStatus(const PortalStatus& status) {
currentStatus = status;
}

View File

@@ -0,0 +1,162 @@
#include "services/wifi_service.h"
#include "services/config_service.h"
#include "device_state.h"
#include "config.h"
#include <WiFi.h>
WiFiService& WiFiService::getInstance() {
static WiFiService instance;
return instance;
}
String WiFiService::getIP() const {
if (wifiConnected) {
return WiFi.localIP().toString();
}
return "";
}
void WiFiService::setup() {
// Disable ESP32's automatic WiFi persistence - we manage our own config
WiFi.persistent(false);
WiFi.setAutoConnect(false);
WiFi.setAutoReconnect(false);
// Force clean state on every boot
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
delay(500);
if (configService.isConfigured()) {
WiFi.mode(WIFI_STA);
Serial.println("Starting in STA mode (configured)");
} else {
startAPMode();
}
}
void WiFiService::loop() {
connectToWiFi();
}
void WiFiService::reconnect() {
wifiRetryCount = 0;
lastWifiAttempt = 0;
}
void WiFiService::startAP() {
startAPMode();
}
void WiFiService::stopAP() {
stopAPMode();
}
void WiFiService::startAPMode() {
if (apModeActive) return;
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(AP_IP, AP_GATEWAY, AP_SUBNET);
String apName = configService.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 WiFiService::stopAPMode() {
if (!apModeActive) return;
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
apModeActive = false;
Serial.println("Access Point stopped (WiFi connected)");
}
void WiFiService::connectToWiFi() {
if (!configService.isConfigured()) {
if (!apModeActive) {
startAPMode();
}
return;
}
if (WiFi.status() == WL_CONNECTED) {
if (!wifiConnected) {
wifiConnected = true;
wifiRetryCount = 0;
Serial.print("WiFi connected! IP: ");
Serial.println(WiFi.localIP());
deviceState.setWifiConnected(true, WiFi.localIP().toString());
stopAPMode();
}
return;
}
// WiFi not connected
if (wifiConnected) {
wifiConnected = false;
deviceState.setWifiConnected(false);
wifiRetryCount = 0;
Serial.println("WiFi disconnected!");
startAPMode();
}
// Don't spam connection attempts
if (millis() - lastWifiAttempt < WIFI_CONNECT_TIMEOUT) {
return;
}
lastWifiAttempt = millis();
// Cap retry count
if (wifiRetryCount < MAX_WIFI_RETRIES) {
wifiRetryCount++;
}
// Start AP mode after too many failed attempts
if (wifiRetryCount >= MAX_WIFI_RETRIES && !apModeActive) {
Serial.println("WiFi connection failed after multiple attempts");
Serial.println("Starting AP mode for reconfiguration...");
WiFi.mode(WIFI_AP_STA);
WiFi.softAPConfig(AP_IP, AP_GATEWAY, AP_SUBNET);
String apName = configService.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");
return;
}
Serial.print("Connecting to WiFi (attempt ");
Serial.print(wifiRetryCount);
Serial.print("/");
Serial.print(MAX_WIFI_RETRIES);
Serial.print("): ");
Serial.println(configService.getWifiSsid());
if (!apModeActive) {
WiFi.disconnect(false);
delay(100);
}
WiFi.begin(
configService.getWifiSsid().c_str(),
configService.getWifiPassword().c_str()
);
}

View File

@@ -9,9 +9,18 @@ class DeviceRegistry:
def __init__(self):
self._devices: dict[str, dict[str, Any]] = {}
# Track recently deleted devices to prevent MQTT re-registration
self._deleted_devices: set[str] = set()
def register_device(self, device_id: str, info: dict | None = None) -> bool:
"""Register a new device or update existing.
Returns False if device was recently deleted (won't be registered).
"""
# Skip recently deleted devices - prevents stale MQTT messages from re-registering
if device_id in self._deleted_devices:
return False
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:
@@ -30,10 +39,16 @@ class DeviceRegistry:
if info:
self._devices[device_id].update(info)
def update_device(self, device_id: str, updates: dict):
"""Update device state."""
return True
def update_device(self, device_id: str, updates: dict) -> bool:
"""Update device state.
Returns False if device was recently deleted (won't be updated).
"""
if device_id not in self._devices:
self.register_device(device_id)
if not self.register_device(device_id):
return False # Device was deleted, don't update
for key, value in updates.items():
if key == "values" and isinstance(value, dict):
@@ -45,6 +60,7 @@ class DeviceRegistry:
self._devices[device_id][key] = value
self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat()
return True
def mark_offline(self, device_id: str):
"""Mark a device as offline."""
@@ -60,8 +76,22 @@ class DeviceRegistry:
return list(self._devices.values())
def remove_device(self, device_id: str):
"""Remove a device from the registry."""
"""Remove a device from the registry and mark as deleted."""
self._devices.pop(device_id, None)
# Track as deleted to prevent MQTT re-registration
self._deleted_devices.add(device_id)
def is_deleted(self, device_id: str) -> bool:
"""Check if a device was recently deleted."""
return device_id in self._deleted_devices
def clear_deleted(self, device_id: str):
"""Allow a deleted device to be re-registered (for manual re-add)."""
self._deleted_devices.discard(device_id)
def clear_all_deleted(self):
"""Clear all deleted device tracking (e.g., on server restart)."""
self._deleted_devices.clear()
# Global registry instance

170
src/api/hr_variation.py Normal file
View File

@@ -0,0 +1,170 @@
"""Heart rate variation manager - smooth realistic HR simulation."""
import asyncio
import math
import random
from dataclasses import dataclass, field
@dataclass
class HRDeviceState:
"""State for a single device's HR variation."""
target: int = 70
float_hr: float = 70.0
phase: float = field(default_factory=lambda: random.random() * math.pi * 2)
enabled: bool = False
task: asyncio.Task | None = field(default=None, repr=False)
class HRVariationManager:
"""Manages smooth HR variation for multiple devices."""
def __init__(self):
self._devices: dict[str, HRDeviceState] = {}
self._mqtt_manager = None
self._device_registry = None
self._ws_broadcast = None
def set_dependencies(self, mqtt_manager, device_registry, ws_broadcast=None):
"""Set required dependencies after initialization."""
self._mqtt_manager = mqtt_manager
self._device_registry = device_registry
self._ws_broadcast = ws_broadcast
def get_state(self, device_id: str) -> dict:
"""Get current HR variation state for a device."""
if device_id not in self._devices:
return {"enabled": False, "target": 70, "current": 70}
state = self._devices[device_id]
return {
"enabled": state.enabled,
"target": state.target,
"current": round(state.float_hr),
}
async def set_target(self, device_id: str, target: int):
"""Set HR target for a device."""
if device_id not in self._devices:
self._devices[device_id] = HRDeviceState(target=target, float_hr=float(target))
else:
self._devices[device_id].target = target
# If variation not enabled, send value directly
if not self._devices[device_id].enabled:
await self._send_hr(device_id, target)
async def enable(self, device_id: str, target: int | None = None):
"""Enable HR variation for a device."""
if device_id not in self._devices:
initial_target = target or 70
self._devices[device_id] = HRDeviceState(
target=initial_target,
float_hr=float(initial_target)
)
elif target is not None:
self._devices[device_id].target = target
state = self._devices[device_id]
if state.enabled and state.task and not state.task.done():
return # Already running
state.enabled = True
state.task = asyncio.create_task(self._variation_loop(device_id))
async def disable(self, device_id: str):
"""Disable HR variation for a device."""
if device_id not in self._devices:
return
state = self._devices[device_id]
state.enabled = False
if state.task and not state.task.done():
state.task.cancel()
try:
await state.task
except asyncio.CancelledError:
pass
state.task = None
async def stop_all(self):
"""Stop all variation tasks (for shutdown)."""
for device_id in list(self._devices.keys()):
await self.disable(device_id)
async def _variation_loop(self, device_id: str):
"""Run smooth HR variation for a device."""
state = self._devices[device_id]
last_sent = None
try:
while state.enabled:
# Advance phase (completes cycle in ~30 seconds)
state.phase += 0.2
# Smooth sinusoidal base variation (±2 BPM)
sine_variation = math.sin(state.phase) * 2
# Small random walk component
random_walk = (random.random() - 0.5) * 0.6
# Calculate ideal HR
ideal_hr = state.target + sine_variation + random_walk
# Smooth transition toward ideal
# Use rate-limited step when far from target for smooth ramp
distance = abs(ideal_hr - state.float_hr)
if distance > 3:
# Far from target: move max 1 BPM per tick (2 BPM/sec at 0.5s ticks)
step = 1.0 if ideal_hr > state.float_hr else -1.0
state.float_hr += step
else:
# Close to target: use proportional smoothing
state.float_hr += (ideal_hr - state.float_hr) * 0.2
# Round for transmission
current_hr = round(max(30, min(220, state.float_hr)))
# Only send if changed
if current_hr != last_sent:
last_sent = current_hr
await self._send_hr(device_id, current_hr)
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
async def _send_hr(self, device_id: str, hr: int):
"""Send HR value to device via MQTT and update registry."""
# Only send HR to devices configured as heart_rate type
if self._device_registry:
device = self._device_registry.get_device(device_id)
if device and device.get("type") != "heart_rate":
# Device is not a heart rate monitor, disable variation
if device_id in self._devices and self._devices[device_id].enabled:
self._devices[device_id].enabled = False
return
if self._mqtt_manager and self._mqtt_manager.is_connected:
await self._mqtt_manager.publish(
f"ble-sim/{device_id}/set",
{"heart_rate": hr}
)
if self._device_registry:
self._device_registry.update_device(device_id, {"values": {"heart_rate": hr}})
# Broadcast to WebSocket clients
if self._ws_broadcast:
await self._ws_broadcast({
"type": "hr_update",
"device_id": device_id,
"heart_rate": hr
})
# Global instance
hr_variation_manager = HRVariationManager()

View File

@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
# Version info
__version__ = "1.0.0"
__version__ = "1.4.0"
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@@ -14,6 +14,7 @@ import os
from .routes import router
from .mqtt_client import mqtt_manager
from .device_registry import device_registry
from .hr_variation import hr_variation_manager
class ConnectionManager:
@@ -55,13 +56,17 @@ async def handle_device_status(topic: str, payload: str):
device_id = parts[1]
try:
data = json.loads(payload)
device_registry.register_device(device_id)
# Skip if device was recently deleted (prevents stale MQTT messages from re-registering)
if not device_registry.register_device(device_id):
print(f"Ignoring status from deleted device: {device_id}")
return
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"),
"bt_mac": data.get("bt_mac"),
}
device_registry.update_device(device_id, update_data)
print(f"Device {device_id} status updated: {data}")
@@ -83,7 +88,10 @@ async def handle_device_values(topic: str, payload: str):
device_id = parts[1]
try:
data = json.loads(payload)
device_registry.update_device(device_id, {"values": data})
# Skip if device was recently deleted
if not device_registry.update_device(device_id, {"values": data}):
print(f"Ignoring values from deleted device: {device_id}")
return
print(f"Device {device_id} values updated: {data}")
# Broadcast to WebSocket clients
await ws_manager.broadcast({
@@ -97,9 +105,16 @@ async def handle_device_values(topic: str, payload: str):
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle - connect/disconnect MQTT."""
"""Manage application lifecycle - connect/disconnect MQTT and HR variation."""
await mqtt_manager.connect()
# Wire up HR variation manager dependencies
hr_variation_manager.set_dependencies(
mqtt_manager=mqtt_manager,
device_registry=device_registry,
ws_broadcast=ws_manager.broadcast
)
yield
await hr_variation_manager.stop_all()
await mqtt_manager.disconnect()

View File

@@ -102,7 +102,7 @@ class MQTTManager:
return func
return decorator
async def publish(self, topic: str, payload: dict | str):
async def publish(self, topic: str, payload: dict | str, retain: bool = False):
"""Publish a message to MQTT."""
if not self._connected or not self.client:
raise RuntimeError("MQTT not connected")
@@ -110,7 +110,13 @@ class MQTTManager:
if isinstance(payload, dict):
payload = json.dumps(payload)
await self.client.publish(topic, payload)
await self.client.publish(topic, payload, retain=retain)
async def clear_retained(self, topic: str):
"""Clear a retained message by publishing empty payload with retain flag."""
if not self._connected or not self.client:
return
await self.client.publish(topic, "", retain=True)
async def configure_device(self, device_id: str, device_type: str):
"""Send configuration command to a device."""
@@ -126,6 +132,21 @@ class MQTTManager:
values
)
async def disconnect_ble(self, device_id: str, duration_ms: int = 0, teardown: bool = False):
"""Trigger BLE disconnect on a device.
Args:
device_id: The ESP32 device ID
duration_ms: How long to pause advertising after disconnect (0 = immediate resume)
teardown: If True, fully tear down BLE stack (device disappears from scans)
"""
payload = {}
if duration_ms > 0:
payload["duration_ms"] = duration_ms
if teardown:
payload["teardown"] = True
await self.publish(f"ble-sim/{device_id}/disconnect", payload)
# Global MQTT manager instance
mqtt_manager = MQTTManager()

View File

@@ -1,10 +1,11 @@
"""API routes for device control."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from .mqtt_client import mqtt_manager
from .device_registry import device_registry
from .hr_variation import hr_variation_manager
router = APIRouter()
@@ -16,11 +17,25 @@ class DeviceConfig(BaseModel):
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
heart_rate: int | None = Field(None, ge=30, le=220) # Valid BPM range
speed: float | None = Field(None, ge=0, le=50) # km/h, max 50 km/h
incline: float | None = Field(None, ge=-10, le=40) # percent
cadence: int | None = Field(None, ge=0, le=300) # rpm
power: int | None = Field(None, ge=0, le=2000) # watts
battery: int | None = Field(None, ge=0, le=100) # battery percentage
distance: int | None = Field(None, ge=0) # distance in meters
class HRVariationConfig(BaseModel):
"""HR variation configuration."""
enabled: bool
target: int | None = Field(None, ge=30, le=220)
class BLEDisconnectRequest(BaseModel):
"""BLE disconnect request."""
duration_ms: int = Field(0, ge=0, le=60000) # Max 60 seconds
teardown: bool = Field(False, description="Full BLE stack teardown (device disappears from scans)")
@router.get("/devices")
@@ -40,6 +55,40 @@ async def get_device(device_id: str):
return device
@router.delete("/devices/{device_id}")
async def delete_device(device_id: str):
"""Remove a device from the registry and clear its retained MQTT messages."""
device = device_registry.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
# Stop any HR variation for this device
await hr_variation_manager.disable(device_id)
# Clear retained MQTT messages so device doesn't reappear on restart
try:
await mqtt_manager.clear_retained(f"ble-sim/{device_id}/status")
await mqtt_manager.clear_retained(f"ble-sim/{device_id}/values")
except Exception as e:
# Log the error but continue with deletion
print(f"Warning: Failed to clear MQTT retained messages for {device_id}: {e}")
# Remove from registry (also marks as deleted to block re-registration)
device_registry.remove_device(device_id)
# Broadcast deletion to all WebSocket clients so they remove the device card
try:
from .main import ws_manager
await ws_manager.broadcast({
"type": "device_deleted",
"device_id": device_id
})
except Exception as e:
print(f"Warning: Failed to broadcast device deletion: {e}")
return {"status": "ok", "device_id": device_id, "message": "Device removed"}
@router.post("/devices/{device_id}/configure")
async def configure_device(device_id: str, config: DeviceConfig):
"""Configure a device's type."""
@@ -67,3 +116,64 @@ async def set_device_values(device_id: str, values: DeviceValues):
device_registry.update_device(device_id, {"values": values_dict})
return {"status": "ok", "device_id": device_id, "values": values_dict}
@router.post("/devices/{device_id}/disconnect")
async def disconnect_ble(device_id: str, request: BLEDisconnectRequest = BLEDisconnectRequest()):
"""Simulate a BLE disconnect.
Args:
device_id: The ESP32 device ID
request: Optional duration_ms and teardown parameters
"""
if not mqtt_manager.is_connected:
raise HTTPException(status_code=503, detail="MQTT not connected")
await mqtt_manager.disconnect_ble(device_id, request.duration_ms, request.teardown)
return {
"status": "ok",
"device_id": device_id,
"duration_ms": request.duration_ms,
"teardown": request.teardown
}
@router.get("/devices/{device_id}/hr-variation")
async def get_hr_variation(device_id: str):
"""Get HR variation state for a device."""
return hr_variation_manager.get_state(device_id)
@router.post("/devices/{device_id}/hr-variation")
async def set_hr_variation(device_id: str, config: HRVariationConfig):
"""Enable or disable HR variation for a device."""
if config.enabled:
await hr_variation_manager.enable(device_id, config.target)
else:
await hr_variation_manager.disable(device_id)
# If target provided, set it directly
if config.target is not None:
await hr_variation_manager.set_target(device_id, config.target)
return {
"status": "ok",
"device_id": device_id,
**hr_variation_manager.get_state(device_id)
}
class HRTargetRequest(BaseModel):
"""HR target request."""
target: int = Field(ge=30, le=220)
@router.post("/devices/{device_id}/hr-target")
async def set_hr_target(device_id: str, request: HRTargetRequest):
"""Set HR target (works with or without variation enabled)."""
await hr_variation_manager.set_target(device_id, request.target)
return {
"status": "ok",
"device_id": device_id,
**hr_variation_manager.get_state(device_id)
}

View File

@@ -152,6 +152,32 @@ async def list_tools() -> list[Tool]:
"required": ["device_id"],
},
),
Tool(
name="simulate_ble_disconnect",
description="Simulate a BLE client disconnection to test reconnection behavior",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
"duration_ms": {
"type": "integer",
"minimum": 0,
"maximum": 60000,
"default": 0,
"description": "How long to pause advertising after disconnect (0 = immediate resume)",
},
"teardown": {
"type": "boolean",
"default": False,
"description": "Full BLE stack teardown - device completely disappears from scans",
},
},
"required": ["device_id"],
},
),
]
@@ -208,6 +234,13 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
device_id = arguments["device_id"]
bpm = arguments["bpm"]
# Validate BPM range
if not (30 <= bpm <= 220):
return [TextContent(
type="text",
text=f"Error: BPM must be between 30 and 220, got {bpm}"
)]
await mqtt_client.publish(
f"ble-sim/{device_id}/set",
json.dumps({"heart_rate": bpm})
@@ -280,6 +313,38 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
text=f"Device {device_id} not found"
)]
elif name == "simulate_ble_disconnect":
device_id = arguments["device_id"]
duration_ms = arguments.get("duration_ms", 0)
teardown = arguments.get("teardown", False)
payload = {}
if duration_ms > 0:
payload["duration_ms"] = duration_ms
if teardown:
payload["teardown"] = True
await mqtt_client.publish(
f"ble-sim/{device_id}/disconnect",
json.dumps(payload)
)
if teardown:
return [TextContent(
type="text",
text=f"BLE stack teardown on {device_id}, will reinit in {duration_ms if duration_ms > 0 else 3000}ms"
)]
elif duration_ms > 0:
return [TextContent(
type="text",
text=f"Disconnected BLE on {device_id}, advertising paused for {duration_ms}ms"
)]
else:
return [TextContent(
type="text",
text=f"Disconnected BLE on {device_id}, advertising resumed immediately"
)]
else:
return [TextContent(
type="text",

View File

@@ -258,6 +258,65 @@
color: var(--text-dim);
}
.device-mac {
color: var(--text-dim);
font-family: monospace;
font-size: 0.75rem;
}
.delete-btn {
background: var(--error);
color: white;
border: none;
border-radius: 4px;
width: 20px;
height: 20px;
font-size: 14px;
cursor: pointer;
margin-left: 0.5rem;
line-height: 1;
}
.delete-btn:hover {
opacity: 0.8;
}
/* BLE Test controls */
.ble-tests {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--accent);
}
.ble-tests label {
display: block;
color: var(--text-dim);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.ble-test-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.ble-test-btn {
background: var(--accent);
color: var(--text);
border: none;
border-radius: 8px;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.2s;
}
.ble-test-btn:hover { background: #1a4a7a; }
.ble-test-btn.warning {
background: var(--warning);
color: var(--bg);
}
.ble-test-btn.warning:hover { opacity: 0.9; }
/* WebSocket connection status */
.connection-status {
display: flex;
@@ -289,7 +348,7 @@
<div class="header">
<div>
<h1>pyBTMCP</h1>
<p class="subtitle">BLE Device Simulator <span id="backendVersion"></span></p>
<p class="subtitle">BLE Device Simulator &bull; UI v1.5.0 &bull; API <span id="backendVersion"></span></p>
</div>
<div class="header-buttons">
<div class="connection-status">
@@ -424,6 +483,40 @@
} else if (message.type === 'device_update') {
// Single device updated - update UI efficiently
updateDeviceInUI(message.device_id, message.data);
} else if (message.type === 'device_deleted') {
// Device was deleted - remove from UI
removeDeviceFromUI(message.device_id);
} else if (message.type === 'hr_update') {
// HR value update from server-side variation
updateHrDisplay(message.device_id, message.heart_rate);
}
}
function removeDeviceFromUI(deviceId) {
const container = document.getElementById('devices');
const card = container.querySelector(`[data-id="${deviceId}"]`);
if (card) {
card.remove();
console.log(`Removed deleted device card: ${deviceId}`);
}
// Clean up any pending timers for this device
if (hrTargetDebounceTimers[deviceId]) {
clearTimeout(hrTargetDebounceTimers[deviceId]);
delete hrTargetDebounceTimers[deviceId];
}
// Clean up any pending value update timers
Object.keys(updateTimeouts).forEach(key => {
if (key.startsWith(deviceId + '-')) {
clearTimeout(updateTimeouts[key]);
delete updateTimeouts[key];
}
});
}
function updateHrDisplay(deviceId, hr) {
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) {
display.textContent = `${hr} BPM`;
}
}
@@ -449,18 +542,39 @@
if (dot) dot.className = `status-dot ${deviceData.online ? '' : 'offline'}`;
if (text) text.textContent = deviceData.online ? 'Online' : 'Offline';
// Update IP and version if present
// Update IP, MAC, 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.bt_mac) {
infoHtml += `<span class="device-mac">BT: ${deviceData.bt_mac}</span>`;
}
if (deviceData.firmware_version) {
infoHtml += `<span class="device-version">v${deviceData.firmware_version}</span>`;
}
infoDiv.innerHTML = infoHtml;
}
// Update values if present
const values = deviceData.values || {};
if (values.heart_rate !== undefined) {
updateHrDisplay(deviceId, values.heart_rate);
}
if (values.speed !== undefined) {
const speedDisplay = document.getElementById(`speed-display-${deviceId}`);
if (speedDisplay) speedDisplay.textContent = formatSpeed(values.speed);
}
if (values.distance !== undefined) {
const distDisplay = document.getElementById(`distance-display-${deviceId}`);
if (distDisplay) distDisplay.textContent = `${values.distance} m`;
}
if (values.battery !== undefined) {
const battDisplay = document.getElementById(`battery-display-${deviceId}`);
if (battDisplay) battDisplay.textContent = `${values.battery}%`;
}
}
// Settings (persisted to localStorage)
@@ -469,11 +583,8 @@
hrVariation: true
};
// HR variation state per device
let hrVariationIntervals = {};
let currentHrTargets = {};
let currentHrValues = {};
let activeDevices = new Set(); // Track devices being actively controlled
// Track active devices (for UI state only - business logic is server-side)
let activeDevices = new Set();
// Treadmill presets (stored in metric - km/h)
const treadmillPresets = [
@@ -515,18 +626,25 @@
loadDevices(); // Re-render with new units
}
function toggleHrVariation() {
async 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];
}
// Update all HR devices via API
const devices = document.querySelectorAll('.device-card[data-type="heart_rate"]');
for (const card of devices) {
const deviceId = card.dataset.id;
try {
await fetch(`${API_BASE}/devices/${deviceId}/hr-variation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: settings.hrVariation })
});
} catch (error) {
console.error('Failed to toggle HR variation:', error);
}
}
}
function updateSettingsDisplay() {
@@ -605,11 +723,13 @@
<div class="device-status">
<span class="status-dot ${device.online ? '' : 'offline'}"></span>
<span class="status-text">${device.online ? 'Online' : 'Offline'}</span>
<button class="delete-btn" onclick="deleteDevice('${device.id}')" title="Remove device">×</button>
</div>
</div>
<div class="device-info">
${device.ip ? `<a href="http://${device.ip}" target="_blank" class="device-link">Admin: ${device.ip}</a>` : ''}
${device.bt_mac ? `<span class="device-mac">BT: ${device.bt_mac}</span>` : ''}
${device.firmware_version ? `<span class="device-version">v${device.firmware_version}</span>` : ''}
</div>
@@ -623,15 +743,26 @@
</select>
</div>
${device.type ? `
<div class="ble-tests">
<label>BLE Connection Tests</label>
<div class="ble-test-buttons">
<button class="ble-test-btn" onclick="disconnectBle('${device.id}', 0, false)" title="Disconnect client, immediately resume advertising">Quick Drop</button>
<button class="ble-test-btn" onclick="disconnectBle('${device.id}', 5000, false)" title="Disconnect and pause advertising for 5 seconds">5s Pause</button>
<button class="ble-test-btn warning" onclick="disconnectBle('${device.id}', 3000, true)" title="Full BLE teardown - device disappears from scans for 3 seconds">BLE Restart</button>
<button class="ble-test-btn warning" onclick="disconnectBle('${device.id}', 10000, true)" title="Full BLE teardown - device disappears from scans for 10 seconds">10s Restart</button>
</div>
</div>
` : ''}
${renderControls(device)}
</div>
`).join('');
// Setup HR variation for new HR devices
// Enable HR variation for new HR devices if setting is on
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);
if (device.type === 'heart_rate' && settings.hrVariation) {
enableHrVariationForDevice(device.id, device.values?.heart_rate || 70);
}
});
} else {
@@ -653,28 +784,30 @@
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;
const hr = values.heart_rate || 70;
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>
onclick="setHrTarget('${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)">
<input type="range" min="30" max="220" value="${hr}"
oninput="setHrTargetDebounced('${device.id}', this.value)">
<span class="value-display" id="hr-display-${device.id}">${hr} BPM</span>
</div>
</div>
<div class="control-group">
<label>Battery Level</label>
<div class="slider-container">
<input type="range" min="0" max="100" value="${values.battery || 100}"
oninput="updateBattery('${device.id}', this.value)">
<span class="value-display" id="battery-display-${device.id}">${values.battery || 100}%</span>
</div>
</div>
`;
}
@@ -705,6 +838,13 @@
<span class="value-display" id="incline-display-${device.id}">${incline}%</span>
</div>
</div>
<div class="control-group">
<label>Distance</label>
<div class="slider-container" style="justify-content: space-between;">
<span class="value-display" id="distance-display-${device.id}">${values.distance || 0} m</span>
<button class="preset-btn" onclick="resetDistance('${device.id}')" style="margin: 0; padding: 0.25rem 0.75rem;">Reset</button>
</div>
</div>
`;
}
@@ -732,122 +872,53 @@
return '';
}
// HR variation - smooth wandering around target
function setupHrVariation(deviceId, targetHr) {
if (hrVariationIntervals[deviceId]) {
clearInterval(hrVariationIntervals[deviceId]);
}
if (!settings.hrVariation) return;
// HR control functions - business logic is server-side
const hrTargetDebounceTimers = {};
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) {
async function enableHrVariationForDevice(deviceId, startHr) {
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
await fetch(`${API_BASE}/devices/${deviceId}/hr-variation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ heart_rate: hr })
body: JSON.stringify({ enabled: true, target: startHr })
});
} catch (error) {
console.error('Failed to enable HR variation:', error);
}
}
async function setHrTarget(deviceId, targetHr) {
// Update slider UI immediately
const card = document.querySelector(`[data-id="${deviceId}"]`);
const slider = card?.querySelector('input[type="range"]');
if (slider) slider.value = targetHr;
try {
await fetch(`${API_BASE}/devices/${deviceId}/hr-target`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: targetHr })
});
} catch (error) {
console.error('Failed to send HR:', error);
}
}
function setHrTargetDebounced(deviceId, value) {
const hr = parseInt(value);
// Update display immediately
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${hr} BPM`;
// Debounce the API call
if (hrTargetDebounceTimers[deviceId]) {
clearTimeout(hrTargetDebounceTimers[deviceId]);
}
hrTargetDebounceTimers[deviceId] = setTimeout(() => {
setHrTarget(deviceId, hr);
}, 100);
}
function updateSpeed(deviceId, displayValue) {
const metricSpeed = displayToMetric(parseFloat(displayValue));
const display = document.getElementById(`speed-display-${deviceId}`);
@@ -856,6 +927,28 @@
updateValueDebounced(deviceId, 'speed', metricSpeed);
}
function updateBattery(deviceId, value) {
const display = document.getElementById(`battery-display-${deviceId}`);
if (display) display.textContent = `${value}%`;
updateValueDebounced(deviceId, 'battery', parseInt(value));
}
async function resetDistance(deviceId) {
const display = document.getElementById(`distance-display-${deviceId}`);
if (display) display.textContent = '0 m';
try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ distance: 0 })
});
} catch (error) {
console.error('Failed to reset distance:', error);
}
}
function applyTreadmillPreset(deviceId, speedKmh, incline) {
const card = document.querySelector(`[data-id="${deviceId}"]`);
@@ -902,6 +995,51 @@
}
}
async function deleteDevice(deviceId) {
if (!confirm(`Remove device ${deviceId} from the list?`)) return;
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.error('Failed to delete device:', error.detail || response.statusText);
alert(`Failed to delete device: ${error.detail || response.statusText}`);
return;
}
// WebSocket broadcast will handle removing the device from UI
// But also remove locally in case WebSocket is not connected
removeDeviceFromUI(deviceId);
} catch (error) {
console.error('Failed to delete device:', error);
alert('Failed to delete device. Check console for details.');
}
}
async function disconnectBle(deviceId, durationMs = 0, teardown = false) {
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/disconnect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration_ms: durationMs, teardown: teardown })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.error('Failed to disconnect BLE:', error.detail || response.statusText);
return;
}
if (teardown) {
console.log(`BLE teardown on ${deviceId}, reinit in ${durationMs || 3000}ms`);
} else if (durationMs > 0) {
console.log(`BLE disconnected on ${deviceId}, pausing ${durationMs}ms`);
} else {
console.log(`BLE disconnected on ${deviceId}, immediate re-advertise`);
}
} catch (error) {
console.error('Failed to disconnect BLE:', error);
}
}
let updateTimeouts = {};
function updateValueDebounced(deviceId, key, value) {
const timeoutKey = `${deviceId}-${key}`;