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. 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 ## MCP Tools Reference
Once configured, Claude can use these tools to control BLE devices: 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 // Firmware Version
// ============================================ // ============================================
#define FIRMWARE_VERSION "1.0.0" #define FIRMWARE_VERSION "1.1.0"
// ============================================ // ============================================
// AP Mode Configuration // 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 #ifndef CONFIG_SERVICE_H
#define CONFIG_MANAGER_H #define CONFIG_SERVICE_H
#include <Arduino.h> #include <Arduino.h>
#include <Preferences.h> #include <Preferences.h>
// ============================================ // ============================================
// Configuration Structure // Config Service
// Manages persistent configuration in NVS
// ============================================ // ============================================
struct DeviceConfig { class ConfigService {
bool configured = false;
String wifiSsid = "";
String wifiPassword = "";
String mqttHost = "";
uint16_t mqttPort = 1883;
String deviceId = "";
};
// ============================================
// Configuration Manager Class
// ============================================
class ConfigManager {
public: public:
ConfigManager(); static ConfigService& getInstance();
// Prevent copying
ConfigService(const ConfigService&) = delete;
ConfigService& operator=(const ConfigService&) = delete;
// Load config from NVS // Load config from NVS
bool load(); bool load();
@@ -33,14 +26,14 @@ public:
void clear(); void clear();
// Check if configured // Check if configured
bool isConfigured() const { return config.configured; } bool isConfigured() const { return configured; }
// Getters // Getters
const String& getWifiSsid() const { return config.wifiSsid; } const String& getWifiSsid() const { return wifiSsid; }
const String& getWifiPassword() const { return config.wifiPassword; } const String& getWifiPassword() const { return wifiPassword; }
const String& getMqttHost() const { return config.mqttHost; } const String& getMqttHost() const { return mqttHost; }
uint16_t getMqttPort() const { return config.mqttPort; } uint16_t getMqttPort() const { return mqttPort; }
const String& getDeviceId() const { return config.deviceId; } const String& getDeviceId() const { return deviceId; }
// Setters // Setters
void setWifiCredentials(const String& ssid, const String& password); void setWifiCredentials(const String& ssid, const String& password);
@@ -54,11 +47,18 @@ public:
String getDefaultDeviceId() const; String getDefaultDeviceId() const;
private: private:
DeviceConfig config; ConfigService() = default;
bool configured = false;
String wifiSsid = "";
String wifiPassword = "";
String mqttHost = "";
uint16_t mqttPort = 1883;
String deviceId = "";
Preferences preferences; Preferences preferences;
}; };
// Global instance #define configService ConfigService::getInstance()
extern ConfigManager configManager;
#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) * Simulates BLE fitness devices (Heart Rate Monitor, Treadmill)
* Controlled via MQTT from pyBTMCP server * Controlled via MQTT from pyBTMCP server
* *
* Features: * Architecture:
* - AP mode for configuration when not connected to WiFi (192.168.4.1) * - device_state: Central state management with event callbacks
* - Connects to configured WiFi for MQTT control * - config_service: NVS persistence for configuration
* - Configuration stored in NVS (flash once, configure via web) * - 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 <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <NimBLEDevice.h>
#include "config.h" #include "config.h"
#include "config_manager.h" #include "device_state.h"
#include "web_portal.h" #include "services/config_service.h"
#include "ble_services.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() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(1000); delay(1000);
@@ -342,61 +33,28 @@ void setup() {
Serial.println("========================================\n"); Serial.println("========================================\n");
// Load configuration from NVS // Load configuration from NVS
bool hasConfig = configManager.load(); bool hasConfig = configService.load();
Serial.print("Device ID: "); Serial.print("Device ID: ");
Serial.println(configManager.getDeviceId()); Serial.println(configService.getDeviceId());
Serial.print("Configured: "); Serial.print("Configured: ");
Serial.println(hasConfig ? "Yes" : "No"); Serial.println(hasConfig ? "Yes" : "No");
// Start WiFi (AP always on, STA if configured) // Initialize all services
setupWiFi(); wifiService.setup();
webService.setup();
// Start web configuration portal mqttService.setup();
setupWebPortal(); bleService.setup();
// Initialize MQTT
setupMQTT();
// Initialize BLE (don't start advertising yet)
initBLE();
Serial.println("\nReady!"); Serial.println("\nReady!");
if (!configManager.isConfigured()) { if (!configService.isConfigured()) {
Serial.println("Configure at: http://192.168.4.1"); Serial.println("Configure at: http://192.168.4.1\n");
} else {
Serial.println("Connecting to WiFi...");
} }
Serial.println("Waiting for MQTT commands...\n");
} }
void loop() { void loop() {
// Handle web portal requests // Run all service loops
handleWebPortal(); wifiService.loop();
webService.loop();
// Maintain WiFi STA connection (if configured) mqttService.loop();
connectToWiFi(); bleService.loop();
// Maintain MQTT connection
connectToMQTT();
mqtt.loop();
// Update portal status display
updateStatus();
// Send BLE notifications
if (bleStarted && millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
lastNotify = millis();
if (currentDeviceType == DEVICE_HEART_RATE) {
notifyHeartRate(heartRate);
} else if (currentDeviceType == DEVICE_TREADMILL) {
notifyTreadmill(treadmillSpeed, treadmillIncline);
}
}
// Periodic status report to MQTT
if (mqttConnected && millis() - lastStatus >= STATUS_REPORT_INTERVAL) {
lastStatus = millis();
publishStatus();
}
} }

View File

@@ -0,0 +1,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 "services/web_service.h"
#include "config_manager.h" #include "services/config_service.h"
#include "services/wifi_service.h"
#include "device_state.h"
#include <WebServer.h> #include <WebServer.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
WebServer server(80); static WebServer server(80);
PortalStatus currentStatus = {};
// HTML template with embedded CSS and JS // HTML template with embedded CSS and JS
const char INDEX_HTML[] PROGMEM = R"rawliteral( const char INDEX_HTML[] PROGMEM = R"rawliteral(
@@ -78,6 +79,27 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
button:hover { background: #22c55e; } button:hover { background: #22c55e; }
.btn-danger { background: #f87171; } .btn-danger { background: #f87171; }
.btn-danger:hover { background: #ef4444; } .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 { padding: 10px; border-radius: 8px; margin-bottom: 15px; }
.msg-success { background: #064e3b; } .msg-success { background: #064e3b; }
.msg-error { background: #7f1d1d; } .msg-error { background: #7f1d1d; }
@@ -85,7 +107,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</head> </head>
<body> <body>
<h1>BLE Simulator</h1> <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"> <div class="card">
<h2>Status</h2> <h2>Status</h2>
@@ -107,6 +129,24 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</div> </div>
</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"> <div class="card">
<h2>WiFi Configuration</h2> <h2>WiFi Configuration</h2>
<div id="message"></div> <div id="message"></div>
@@ -137,7 +177,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
<script> <script>
let configLoaded = false; let configLoaded = false;
// Load config once on page load
async function loadConfig() { async function loadConfig() {
try { try {
const res = await fetch('/api/status'); 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() { async function updateStatus() {
try { try {
const res = await fetch('/api/status'); const res = await fetch('/api/status');
@@ -164,6 +202,15 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
updateStatusDots(data.status); updateStatusDots(data.status);
} catch (e) { } catch (e) {
console.error('Failed to update status:', 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('bleStatus').textContent = status.bleStarted ? status.deviceType : 'Not started';
document.getElementById('ipAddr').textContent = status.ipAddress || '-'; 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) => { 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 async function resetDistance() {
loadConfig(); try {
setInterval(updateStatus, 5000); 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> </script>
</body> </body>
</html> </html>
)rawliteral"; )rawliteral";
void handleRoot() { WebService& WebService::getInstance() {
static WebService instance;
return instance;
}
void WebService::handleRoot() {
server.send(200, "text/html", INDEX_HTML); server.send(200, "text/html", INDEX_HTML);
} }
void handleGetStatus() { void WebService::handleGetStatus() {
JsonDocument doc; JsonDocument doc;
doc["apName"] = configManager.getAPName(); doc["apName"] = configService.getAPName();
JsonObject config = doc["config"].to<JsonObject>(); JsonObject config = doc["config"].to<JsonObject>();
config["ssid"] = configManager.getWifiSsid(); config["ssid"] = configService.getWifiSsid();
config["mqttHost"] = configManager.getMqttHost(); config["mqttHost"] = configService.getMqttHost();
config["mqttPort"] = configManager.getMqttPort(); config["mqttPort"] = configService.getMqttPort();
config["deviceId"] = configManager.getDeviceId(); config["deviceId"] = configService.getDeviceId();
const auto& connState = deviceState.getConnectionState();
const auto& values = deviceState.getValues();
JsonObject status = doc["status"].to<JsonObject>(); JsonObject status = doc["status"].to<JsonObject>();
status["wifiConnected"] = currentStatus.wifiConnected; status["wifiConnected"] = connState.wifiConnected;
status["mqttConnected"] = currentStatus.mqttConnected; status["mqttConnected"] = connState.mqttConnected;
status["bleStarted"] = currentStatus.bleStarted; status["bleStarted"] = deviceState.isBleStarted();
status["deviceType"] = currentStatus.deviceType; status["deviceType"] = deviceState.getDeviceTypeString();
status["ipAddress"] = currentStatus.ipAddress; status["ipAddress"] = connState.ipAddress;
status["treadmillDistance"] = values.treadmillDistance;
status["batteryLevel"] = values.batteryLevel;
String response; String response;
serializeJson(doc, response); serializeJson(doc, response);
server.send(200, "application/json", response); server.send(200, "application/json", response);
} }
void handlePostConfig() { void WebService::handlePostConfig() {
if (!server.hasArg("plain")) { if (!server.hasArg("plain")) {
server.send(400, "application/json", "{\"error\":\"No body\"}"); server.send(400, "application/json", "{\"error\":\"No body\"}");
return; return;
@@ -272,26 +380,25 @@ void handlePostConfig() {
return; return;
} }
// Update configuration
String ssid = doc["ssid"] | ""; String ssid = doc["ssid"] | "";
String password = doc["password"] | ""; String password = doc["password"] | "";
String mqttHost = doc["mqtt_host"] | ""; String mqttHost = doc["mqtt_host"] | "";
uint16_t mqttPort = doc["mqtt_port"] | 1883; uint16_t mqttPort = doc["mqtt_port"] | 1883;
String deviceId = doc["device_id"] | ""; String deviceId = doc["device_id"] | "";
configManager.setWifiCredentials(ssid, password); configService.setWifiCredentials(ssid, password);
configManager.setMqttConfig(mqttHost, mqttPort); configService.setMqttConfig(mqttHost, mqttPort);
configManager.setDeviceId(deviceId); configService.setDeviceId(deviceId);
configManager.save(); configService.save();
server.send(200, "application/json", "{\"success\":true}"); server.send(200, "application/json", "{\"success\":true}");
// Trigger reconnect (will be handled in main loop)
Serial.println("Configuration updated, reconnecting..."); Serial.println("Configuration updated, reconnecting...");
wifiService.reconnect();
} }
void handleReset() { void WebService::handleReset() {
configManager.clear(); configService.clear();
server.send(200, "application/json", "{\"success\":true}"); server.send(200, "application/json", "{\"success\":true}");
Serial.println("Configuration reset, rebooting..."); Serial.println("Configuration reset, rebooting...");
@@ -299,20 +406,47 @@ void handleReset() {
ESP.restart(); ESP.restart();
} }
void setupWebPortal() { void WebService::handleResetDistance() {
server.on("/", HTTP_GET, handleRoot); deviceState.resetTreadmillDistance();
server.on("/api/status", HTTP_GET, handleGetStatus); server.send(200, "application/json", "{\"success\":true}");
server.on("/api/config", HTTP_POST, handlePostConfig); }
server.on("/api/reset", HTTP_POST, handleReset);
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(); server.begin();
Serial.println("Web portal started on http://192.168.4.1"); Serial.println("Web portal started on http://192.168.4.1");
} }
void handleWebPortal() { void WebService::loop() {
server.handleClient(); 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): def __init__(self):
self._devices: dict[str, dict[str, Any]] = {} 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() now = datetime.utcnow().isoformat()
if device_id not in self._devices: if device_id not in self._devices:
@@ -30,10 +39,16 @@ class DeviceRegistry:
if info: if info:
self._devices[device_id].update(info) self._devices[device_id].update(info)
def update_device(self, device_id: str, updates: dict): return True
"""Update device state."""
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: 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(): for key, value in updates.items():
if key == "values" and isinstance(value, dict): if key == "values" and isinstance(value, dict):
@@ -45,6 +60,7 @@ class DeviceRegistry:
self._devices[device_id][key] = value self._devices[device_id][key] = value
self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat() self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat()
return True
def mark_offline(self, device_id: str): def mark_offline(self, device_id: str):
"""Mark a device as offline.""" """Mark a device as offline."""
@@ -60,8 +76,22 @@ class DeviceRegistry:
return list(self._devices.values()) return list(self._devices.values())
def remove_device(self, device_id: str): 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) 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 # 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 from fastapi import FastAPI, WebSocket, WebSocketDisconnect
# Version info # Version info
__version__ = "1.0.0" __version__ = "1.4.0"
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -14,6 +14,7 @@ import os
from .routes import router from .routes import router
from .mqtt_client import mqtt_manager from .mqtt_client import mqtt_manager
from .device_registry import device_registry from .device_registry import device_registry
from .hr_variation import hr_variation_manager
class ConnectionManager: class ConnectionManager:
@@ -55,13 +56,17 @@ async def handle_device_status(topic: str, payload: str):
device_id = parts[1] device_id = parts[1]
try: try:
data = json.loads(payload) 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 = { update_data = {
"online": data.get("online", True), "online": data.get("online", True),
"type": data.get("type"), "type": data.get("type"),
"ble_started": data.get("ble_started", False), "ble_started": data.get("ble_started", False),
"ip": data.get("ip"), "ip": data.get("ip"),
"firmware_version": data.get("firmware_version"), "firmware_version": data.get("firmware_version"),
"bt_mac": data.get("bt_mac"),
} }
device_registry.update_device(device_id, update_data) device_registry.update_device(device_id, update_data)
print(f"Device {device_id} status updated: {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] device_id = parts[1]
try: try:
data = json.loads(payload) 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}") print(f"Device {device_id} values updated: {data}")
# Broadcast to WebSocket clients # Broadcast to WebSocket clients
await ws_manager.broadcast({ await ws_manager.broadcast({
@@ -97,9 +105,16 @@ async def handle_device_values(topic: str, payload: str):
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Manage application lifecycle - connect/disconnect MQTT.""" """Manage application lifecycle - connect/disconnect MQTT and HR variation."""
await mqtt_manager.connect() 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 yield
await hr_variation_manager.stop_all()
await mqtt_manager.disconnect() await mqtt_manager.disconnect()

View File

@@ -102,7 +102,7 @@ class MQTTManager:
return func return func
return decorator 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.""" """Publish a message to MQTT."""
if not self._connected or not self.client: if not self._connected or not self.client:
raise RuntimeError("MQTT not connected") raise RuntimeError("MQTT not connected")
@@ -110,7 +110,13 @@ class MQTTManager:
if isinstance(payload, dict): if isinstance(payload, dict):
payload = json.dumps(payload) 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): async def configure_device(self, device_id: str, device_type: str):
"""Send configuration command to a device.""" """Send configuration command to a device."""
@@ -126,6 +132,21 @@ class MQTTManager:
values 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 # Global MQTT manager instance
mqtt_manager = MQTTManager() mqtt_manager = MQTTManager()

View File

@@ -1,10 +1,11 @@
"""API routes for device control.""" """API routes for device control."""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from .mqtt_client import mqtt_manager from .mqtt_client import mqtt_manager
from .device_registry import device_registry from .device_registry import device_registry
from .hr_variation import hr_variation_manager
router = APIRouter() router = APIRouter()
@@ -16,11 +17,25 @@ class DeviceConfig(BaseModel):
class DeviceValues(BaseModel): class DeviceValues(BaseModel):
"""Device values update request.""" """Device values update request."""
heart_rate: int | None = None heart_rate: int | None = Field(None, ge=30, le=220) # Valid BPM range
speed: float | None = None # km/h speed: float | None = Field(None, ge=0, le=50) # km/h, max 50 km/h
incline: float | None = None # percent incline: float | None = Field(None, ge=-10, le=40) # percent
cadence: int | None = None # rpm cadence: int | None = Field(None, ge=0, le=300) # rpm
power: int | None = None # watts 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") @router.get("/devices")
@@ -40,6 +55,40 @@ async def get_device(device_id: str):
return device 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") @router.post("/devices/{device_id}/configure")
async def configure_device(device_id: str, config: DeviceConfig): async def configure_device(device_id: str, config: DeviceConfig):
"""Configure a device's type.""" """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}) device_registry.update_device(device_id, {"values": values_dict})
return {"status": "ok", "device_id": 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"], "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"] device_id = arguments["device_id"]
bpm = arguments["bpm"] 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( await mqtt_client.publish(
f"ble-sim/{device_id}/set", f"ble-sim/{device_id}/set",
json.dumps({"heart_rate": bpm}) 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" 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: else:
return [TextContent( return [TextContent(
type="text", type="text",

View File

@@ -258,6 +258,65 @@
color: var(--text-dim); 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 */ /* WebSocket connection status */
.connection-status { .connection-status {
display: flex; display: flex;
@@ -289,7 +348,7 @@
<div class="header"> <div class="header">
<div> <div>
<h1>pyBTMCP</h1> <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>
<div class="header-buttons"> <div class="header-buttons">
<div class="connection-status"> <div class="connection-status">
@@ -424,6 +483,40 @@
} else if (message.type === 'device_update') { } else if (message.type === 'device_update') {
// Single device updated - update UI efficiently // Single device updated - update UI efficiently
updateDeviceInUI(message.device_id, message.data); 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 (dot) dot.className = `status-dot ${deviceData.online ? '' : 'offline'}`;
if (text) text.textContent = deviceData.online ? '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'); const infoDiv = existingCard.querySelector('.device-info');
if (infoDiv) { if (infoDiv) {
let infoHtml = ''; let infoHtml = '';
if (deviceData.ip) { if (deviceData.ip) {
infoHtml += `<a href="http://${deviceData.ip}" target="_blank" class="device-link">Admin: ${deviceData.ip}</a>`; 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) { if (deviceData.firmware_version) {
infoHtml += `<span class="device-version">v${deviceData.firmware_version}</span>`; infoHtml += `<span class="device-version">v${deviceData.firmware_version}</span>`;
} }
infoDiv.innerHTML = infoHtml; 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) // Settings (persisted to localStorage)
@@ -469,11 +583,8 @@
hrVariation: true hrVariation: true
}; };
// HR variation state per device // Track active devices (for UI state only - business logic is server-side)
let hrVariationIntervals = {}; let activeDevices = new Set();
let currentHrTargets = {};
let currentHrValues = {};
let activeDevices = new Set(); // Track devices being actively controlled
// Treadmill presets (stored in metric - km/h) // Treadmill presets (stored in metric - km/h)
const treadmillPresets = [ const treadmillPresets = [
@@ -515,18 +626,25 @@
loadDevices(); // Re-render with new units loadDevices(); // Re-render with new units
} }
function toggleHrVariation() { async function toggleHrVariation() {
settings.hrVariation = document.getElementById('hrVariationToggle').checked; settings.hrVariation = document.getElementById('hrVariationToggle').checked;
saveSettings(); saveSettings();
updateSettingsDisplay(); updateSettingsDisplay();
// Stop or start variation for all HR devices // Update all HR devices via API
Object.keys(hrVariationIntervals).forEach(deviceId => { const devices = document.querySelectorAll('.device-card[data-type="heart_rate"]');
if (!settings.hrVariation) { for (const card of devices) {
clearInterval(hrVariationIntervals[deviceId]); const deviceId = card.dataset.id;
delete hrVariationIntervals[deviceId]; 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() { function updateSettingsDisplay() {
@@ -605,11 +723,13 @@
<div class="device-status"> <div class="device-status">
<span class="status-dot ${device.online ? '' : 'offline'}"></span> <span class="status-dot ${device.online ? '' : 'offline'}"></span>
<span class="status-text">${device.online ? '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> </div>
<div class="device-info"> <div class="device-info">
${device.ip ? `<a href="http://${device.ip}" target="_blank" class="device-link">Admin: ${device.ip}</a>` : ''} ${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>` : ''} ${device.firmware_version ? `<span class="device-version">v${device.firmware_version}</span>` : ''}
</div> </div>
@@ -623,15 +743,26 @@
</select> </select>
</div> </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)} ${renderControls(device)}
</div> </div>
`).join(''); `).join('');
// Setup HR variation for new HR devices // Enable HR variation for new HR devices if setting is on
devices.forEach(device => { devices.forEach(device => {
if (device.type === 'heart_rate' && settings.hrVariation && !hrVariationIntervals[device.id]) { if (device.type === 'heart_rate' && settings.hrVariation) {
const startHr = currentHrValues[device.id] || device.values?.heart_rate || 70; enableHrVariationForDevice(device.id, device.values?.heart_rate || 70);
setupHrVariation(device.id, startHr);
} }
}); });
} else { } else {
@@ -653,28 +784,30 @@
const values = device.values || {}; const values = device.values || {};
if (device.type === 'heart_rate') { if (device.type === 'heart_rate') {
const hr = currentHrValues[device.id] || values.heart_rate || 70; const hr = values.heart_rate || 70;
if (!currentHrTargets[device.id]) currentHrTargets[device.id] = hr;
if (!currentHrValues[device.id]) currentHrValues[device.id] = hr;
return ` return `
<div class="presets"> <div class="presets">
${hrPresets.map(p => ` ${hrPresets.map(p => `
<button class="preset-btn" <button class="preset-btn"
onclick="applyHrPresetGradual('${device.id}', ${p.hr})" onclick="setHrTarget('${device.id}', ${p.hr})">${p.name}</button>
ondblclick="applyHrPresetImmediate('${device.id}', ${p.hr})">${p.name}</button>
`).join('')} `).join('')}
</div> </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"> <div class="control-group">
<label>Heart Rate Target</label> <label>Heart Rate Target</label>
<div class="slider-container"> <div class="slider-container">
<input type="range" min="30" max="220" value="${currentHrTargets[device.id]}" <input type="range" min="30" max="220" value="${hr}"
oninput="updateHrTarget('${device.id}', this.value)"> oninput="setHrTargetDebounced('${device.id}', this.value)">
<span class="value-display" id="hr-display-${device.id}">${hr} BPM</span> <span class="value-display" id="hr-display-${device.id}">${hr} BPM</span>
</div> </div>
</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> <span class="value-display" id="incline-display-${device.id}">${incline}%</span>
</div> </div>
</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 ''; return '';
} }
// HR variation - smooth wandering around target // HR control functions - business logic is server-side
function setupHrVariation(deviceId, targetHr) { const hrTargetDebounceTimers = {};
if (hrVariationIntervals[deviceId]) {
clearInterval(hrVariationIntervals[deviceId]);
}
if (!settings.hrVariation) return;
currentHrTargets[deviceId] = targetHr; async function enableHrVariationForDevice(deviceId, startHr) {
// Use existing value if we have one, otherwise start at target
if (!currentHrValues[deviceId]) {
currentHrValues[deviceId] = targetHr;
}
let trend = 0; // -1, 0, or 1
hrVariationIntervals[deviceId] = setInterval(() => {
const target = currentHrTargets[deviceId];
let currentHr = currentHrValues[deviceId];
// Smoothly drift toward target with small random variation
const diff = target - currentHr;
// Change trend occasionally
if (Math.random() < 0.3) {
trend = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
}
// Apply small change (max ±1 BPM per interval)
let change = 0;
if (Math.abs(diff) > 3) {
// Too far from target, move toward it
change = diff > 0 ? 1 : -1;
} else {
// Near target, wander slightly
change = trend * (Math.random() < 0.5 ? 1 : 0);
}
currentHr = Math.round(Math.max(30, Math.min(220, currentHr + change)));
currentHrValues[deviceId] = currentHr;
// Send to device
sendHrValue(deviceId, currentHr);
// Update display
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) {
display.textContent = `${currentHr} BPM`;
}
}, 1000);
}
// Gradual transition - just change target, let variation do the work
function applyHrPresetGradual(deviceId, targetHr) {
currentHrTargets[deviceId] = targetHr;
// Update slider to show target
const card = document.querySelector(`[data-id="${deviceId}"]`);
const slider = card?.querySelector('input[type="range"]');
if (slider) slider.value = targetHr;
// Ensure variation is running
if (!hrVariationIntervals[deviceId] && settings.hrVariation) {
const startHr = currentHrValues[deviceId] || targetHr;
setupHrVariation(deviceId, startHr);
currentHrTargets[deviceId] = targetHr;
}
// If variation is off, just send the value
if (!settings.hrVariation) {
currentHrValues[deviceId] = targetHr;
sendHrValue(deviceId, targetHr);
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${targetHr} BPM`;
}
}
// Immediate jump - set both current and target immediately
function applyHrPresetImmediate(deviceId, targetHr) {
currentHrTargets[deviceId] = targetHr;
currentHrValues[deviceId] = targetHr;
// Update slider
const card = document.querySelector(`[data-id="${deviceId}"]`);
const slider = card?.querySelector('input[type="range"]');
if (slider) slider.value = targetHr;
// Update display
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${targetHr} BPM`;
// Send immediately
sendHrValue(deviceId, targetHr);
}
function updateHrTarget(deviceId, value) {
const hr = parseInt(value);
currentHrTargets[deviceId] = hr;
if (!settings.hrVariation) {
sendHrValue(deviceId, hr);
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) display.textContent = `${hr} BPM`;
}
}
async function sendHrValue(deviceId, hr) {
try { try {
await fetch(`${API_BASE}/devices/${deviceId}/values`, { await fetch(`${API_BASE}/devices/${deviceId}/hr-variation`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { } catch (error) {
console.error('Failed to send HR:', 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) { function updateSpeed(deviceId, displayValue) {
const metricSpeed = displayToMetric(parseFloat(displayValue)); const metricSpeed = displayToMetric(parseFloat(displayValue));
const display = document.getElementById(`speed-display-${deviceId}`); const display = document.getElementById(`speed-display-${deviceId}`);
@@ -856,6 +927,28 @@
updateValueDebounced(deviceId, 'speed', metricSpeed); 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) { function applyTreadmillPreset(deviceId, speedKmh, incline) {
const card = document.querySelector(`[data-id="${deviceId}"]`); 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 = {}; let updateTimeouts = {};
function updateValueDebounced(deviceId, key, value) { function updateValueDebounced(deviceId, key, value) {
const timeoutKey = `${deviceId}-${key}`; const timeoutKey = `${deviceId}-${key}`;