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>
This commit is contained in:
2026-01-19 16:49:54 -05:00
parent 9b740ebdd0
commit c95cd33343
22 changed files with 1402 additions and 956 deletions

View File

@@ -1,70 +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"
// Battery Service
#define BATTERY_SERVICE_UUID "180F"
#define BATTERY_LEVEL_UUID "2A19"
// 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);
/**
* Update battery level
* @param level Battery level 0-100%
*/
void updateBatteryLevel(uint8_t level);
/**
* Send treadmill data notification
* @param speed Speed in 0.01 km/h units
* @param incline Incline in 0.1% units
* @param distance Total distance in meters
*/
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance);
#endif // BLE_SERVICES_H

View File

@@ -4,7 +4,7 @@
// ============================================ // ============================================
// Firmware Version // Firmware Version
// ============================================ // ============================================
#define FIRMWARE_VERSION "1.0.5" #define FIRMWARE_VERSION "1.0.6"
// ============================================ // ============================================
// 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,58 @@
#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; }
private:
BleService() = default;
bool deviceConnected = false;
unsigned long lastNotify = 0;
void initBLE();
};
#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,40 +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;
uint32_t treadmillDistance;
uint8_t batteryLevel;
};
// Callback for resetting treadmill distance
typedef void (*ResetDistanceCallback)();
void setResetDistanceCallback(ResetDistanceCallback callback);
// Callback for setting battery level
typedef void (*SetBatteryCallback)(uint8_t level);
void setSetBatteryCallback(SetBatteryCallback callback);
void updatePortalStatus(const PortalStatus& status);
#endif // WEB_PORTAL_H

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 "Not configured";
}
}
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,377 +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;
uint8_t batteryLevel = 100; // Battery level 0-100%
uint16_t treadmillSpeed = 0; // 0.01 km/h resolution
int16_t treadmillIncline = 0; // 0.1% resolution
uint32_t treadmillDistance = 0; // Total distance in meters
float distanceAccumulator = 0.0; // Fractional distance accumulator
// 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(), true); // retained
}
void publishValues() {
if (!mqtt.connected()) return;
JsonDocument doc;
if (currentDeviceType == DEVICE_HEART_RATE) {
doc["heart_rate"] = heartRate;
doc["battery"] = batteryLevel;
} else if (currentDeviceType == DEVICE_TREADMILL) {
doc["speed"] = treadmillSpeed / 100.0;
doc["incline"] = treadmillIncline / 10.0;
doc["distance"] = treadmillDistance;
}
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["battery"].is<int>()) {
batteryLevel = doc["battery"];
if (batteryLevel > 100) batteryLevel = 100;
updateBatteryLevel(batteryLevel);
Serial.print("Battery level set to: ");
Serial.println(batteryLevel);
}
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);
}
if (doc["distance"].is<int>()) {
treadmillDistance = doc["distance"];
distanceAccumulator = (float)treadmillDistance; // Sync accumulator
Serial.print("Distance set to: ");
Serial.println(treadmillDistance);
}
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);
// Set up Last Will and Testament (LWT) for disconnect detection
String statusTopic = String("ble-sim/") + configManager.getDeviceId() + "/status";
String willMessage = "{\"online\":false}";
// Connect with LWT: topic, QoS 1, retain true, message
if (mqtt.connect(clientId.c_str(), statusTopic.c_str(), 1, true, willMessage.c_str())) {
mqttConnected = true;
Serial.println("MQTT connected with LWT!");
// 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 (with retain so new subscribers see current state)
publishStatus();
} else {
Serial.print("MQTT connection failed, rc=");
Serial.println(mqtt.state());
}
}
// ============================================
// Reset Treadmill Distance
// ============================================
void resetTreadmillDistance() {
treadmillDistance = 0;
distanceAccumulator = 0.0;
Serial.println("Treadmill distance reset to 0");
}
// ============================================
// Set Battery Level (from web UI)
// ============================================
void setBatteryLevelCallback(uint8_t level) {
batteryLevel = level;
updateBatteryLevel(batteryLevel);
Serial.print("Battery level set via web UI: ");
Serial.println(batteryLevel);
}
// ============================================
// Update Portal Status
// ============================================
void updateStatus() {
PortalStatus status;
status.wifiConnected = wifiConnected;
status.mqttConnected = mqttConnected;
status.bleStarted = bleStarted;
status.ipAddress = wifiConnected ? WiFi.localIP().toString() : "";
status.treadmillDistance = treadmillDistance;
status.batteryLevel = batteryLevel;
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);
@@ -386,28 +33,20 @@ 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();
setResetDistanceCallback(resetTreadmillDistance);
setSetBatteryCallback(setBatteryLevelCallback);
// 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");
} else { } else {
Serial.println("Connecting to WiFi..."); Serial.println("Connecting to WiFi...");
@@ -416,39 +55,9 @@ void setup() {
} }
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) {
// Accumulate distance based on speed
// Speed is in 0.01 km/h units, interval is 1 second
// meters per second = (speed/100) * 1000 / 3600 = speed / 360
distanceAccumulator += treadmillSpeed / 360.0;
treadmillDistance = (uint32_t)distanceAccumulator;
notifyTreadmill(treadmillSpeed, treadmillIncline, treadmillDistance);
}
}
// Periodic status report to MQTT
if (mqttConnected && millis() - lastStatus >= STATUS_REPORT_INTERVAL) {
lastStatus = millis();
publishStatus();
}
} }

View File

@@ -1,14 +1,6 @@
/** #include "services/ble_service.h"
* BLE Services Implementation #include "device_state.h"
*
* Implements standard Bluetooth SIG GATT services:
* - Heart Rate Service (0x180D)
* - Fitness Machine Service (0x1826) - Treadmill
*/
#include "ble_services.h"
#include "config.h" #include "config.h"
#include "config_manager.h"
#include <NimBLEDevice.h> #include <NimBLEDevice.h>
// ============================================ // ============================================
@@ -16,41 +8,80 @@
// ============================================ // ============================================
static NimBLEServer* pServer = nullptr; static NimBLEServer* pServer = nullptr;
static NimBLEAdvertising* pAdvertising = nullptr; static NimBLEAdvertising* pAdvertising = nullptr;
// Heart Rate
static NimBLECharacteristic* pHeartRateMeasurement = nullptr; static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
// Battery
static NimBLECharacteristic* pBatteryLevel = nullptr; static NimBLECharacteristic* pBatteryLevel = nullptr;
// Treadmill
static NimBLECharacteristic* pTreadmillData = nullptr; static NimBLECharacteristic* pTreadmillData = nullptr;
static bool deviceConnected = false; static bool bleInitialized = false;
static bool oldDeviceConnected = false;
// Forward declare for callback
static void onBleConnect();
static void onBleDisconnect();
// ============================================ // ============================================
// Server Callbacks // Server Callbacks
// ============================================ // ============================================
class ServerCallbacks : public NimBLEServerCallbacks { class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* pServer) override { void onConnect(NimBLEServer* pServer) override {
deviceConnected = true; onBleConnect();
Serial.println("BLE client connected");
} }
void onDisconnect(NimBLEServer* pServer) override { void onDisconnect(NimBLEServer* pServer) override {
deviceConnected = false; onBleDisconnect();
Serial.println("BLE client disconnected");
// Restart advertising
NimBLEDevice::startAdvertising();
} }
}; };
static void onBleConnect() {
deviceState.setBleClientConnected(true);
Serial.println("BLE client connected");
}
static void onBleDisconnect() {
deviceState.setBleClientConnected(false);
Serial.println("BLE client disconnected");
NimBLEDevice::startAdvertising();
}
// ============================================ // ============================================
// BLE Initialization // Singleton
// ============================================ // ============================================
void initBLE() { BleService& BleService::getInstance() {
static BleService instance;
return instance;
}
// ============================================
// Lifecycle
// ============================================
void BleService::setup() {
initBLE();
}
void BleService::loop() {
if (!deviceState.isBleStarted()) return;
// 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::init("BLE Simulator");
NimBLEDevice::setPower(ESP_PWR_LVL_P9); NimBLEDevice::setPower(ESP_PWR_LVL_P9);
@@ -59,15 +90,15 @@ void initBLE() {
pAdvertising = NimBLEDevice::getAdvertising(); pAdvertising = NimBLEDevice::getAdvertising();
bleInitialized = true;
Serial.println("BLE initialized"); Serial.println("BLE initialized");
} }
void stopBLE() { void BleService::stop() {
if (pAdvertising) { if (pAdvertising) {
pAdvertising->stop(); pAdvertising->stop();
} }
// Clear services (will be recreated on next setup)
pHeartRateMeasurement = nullptr; pHeartRateMeasurement = nullptr;
pBatteryLevel = nullptr; pBatteryLevel = nullptr;
pTreadmillData = nullptr; pTreadmillData = nullptr;
@@ -78,10 +109,9 @@ void stopBLE() {
// ============================================ // ============================================
// Heart Rate Service Setup // Heart Rate Service Setup
// ============================================ // ============================================
void setupBLE_HeartRate() { void BleService::setupHeartRate() {
Serial.println("Setting up Heart Rate Service..."); Serial.println("Setting up Heart Rate Service...");
// Stop any current advertising
if (pAdvertising) { if (pAdvertising) {
pAdvertising->stop(); pAdvertising->stop();
} }
@@ -89,16 +119,11 @@ void setupBLE_HeartRate() {
// Create Heart Rate Service // Create Heart Rate Service
NimBLEService* pHRService = pServer->createService(HEART_RATE_SERVICE_UUID); NimBLEService* pHRService = pServer->createService(HEART_RATE_SERVICE_UUID);
// Heart Rate Measurement Characteristic
// Flags: Notify
pHeartRateMeasurement = pHRService->createCharacteristic( pHeartRateMeasurement = pHRService->createCharacteristic(
HEART_RATE_MEASUREMENT_UUID, HEART_RATE_MEASUREMENT_UUID,
NIMBLE_PROPERTY::NOTIFY NIMBLE_PROPERTY::NOTIFY
); );
// Body Sensor Location Characteristic
// Flags: Read
// Value: 1 = Chest
NimBLECharacteristic* pBodySensorLocation = pHRService->createCharacteristic( NimBLECharacteristic* pBodySensorLocation = pHRService->createCharacteristic(
BODY_SENSOR_LOCATION_UUID, BODY_SENSOR_LOCATION_UUID,
NIMBLE_PROPERTY::READ NIMBLE_PROPERTY::READ
@@ -106,22 +131,18 @@ void setupBLE_HeartRate() {
uint8_t sensorLocation = 1; // Chest uint8_t sensorLocation = 1; // Chest
pBodySensorLocation->setValue(&sensorLocation, 1); pBodySensorLocation->setValue(&sensorLocation, 1);
// Start Heart Rate service
pHRService->start(); pHRService->start();
// Create Battery Service // Create Battery Service
NimBLEService* pBatteryService = pServer->createService(BATTERY_SERVICE_UUID); NimBLEService* pBatteryService = pServer->createService(BATTERY_SERVICE_UUID);
// Battery Level Characteristic
// Flags: Read, Notify
pBatteryLevel = pBatteryService->createCharacteristic( pBatteryLevel = pBatteryService->createCharacteristic(
BATTERY_LEVEL_UUID, BATTERY_LEVEL_UUID,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
); );
uint8_t initialBattery = 100; uint8_t initialBattery = deviceState.getValues().batteryLevel;
pBatteryLevel->setValue(&initialBattery, 1); pBatteryLevel->setValue(&initialBattery, 1);
// Start Battery service
pBatteryService->start(); pBatteryService->start();
// Configure advertising // Configure advertising
@@ -131,145 +152,94 @@ void setupBLE_HeartRate() {
pAdvertising->setMinPreferred(0x06); pAdvertising->setMinPreferred(0x06);
pAdvertising->setMaxPreferred(0x12); pAdvertising->setMaxPreferred(0x12);
// Update device name
NimBLEDevice::setDeviceName("HR Simulator"); NimBLEDevice::setDeviceName("HR Simulator");
// Start advertising
NimBLEDevice::startAdvertising(); NimBLEDevice::startAdvertising();
Serial.println("Heart Rate + Battery Services started, advertising..."); Serial.println("Heart Rate + Battery Services started, advertising...");
} }
// ============================================ // ============================================
// Treadmill (Fitness Machine) Service Setup // Treadmill Service Setup
// ============================================ // ============================================
void setupBLE_Treadmill() { void BleService::setupTreadmill() {
Serial.println("Setting up Fitness Machine Service (Treadmill)..."); Serial.println("Setting up Fitness Machine Service (Treadmill)...");
// Stop any current advertising
if (pAdvertising) { if (pAdvertising) {
pAdvertising->stop(); pAdvertising->stop();
} }
// Create Fitness Machine Service
NimBLEService* pService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID); NimBLEService* pService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID);
// Fitness Machine Feature Characteristic
// Flags: Read
NimBLECharacteristic* pFeature = pService->createCharacteristic( NimBLECharacteristic* pFeature = pService->createCharacteristic(
FITNESS_MACHINE_FEATURE_UUID, FITNESS_MACHINE_FEATURE_UUID,
NIMBLE_PROPERTY::READ 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] = { uint8_t featureData[8] = {
0x0B, 0x20, 0x00, 0x00, // Features: Speed, Cadence, Distance, Inclination, Elapsed Time 0x0B, 0x20, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00 // Target settings (none) 0x00, 0x00, 0x00, 0x00
}; };
pFeature->setValue(featureData, 8); pFeature->setValue(featureData, 8);
// Treadmill Data Characteristic
// Flags: Notify
pTreadmillData = pService->createCharacteristic( pTreadmillData = pService->createCharacteristic(
TREADMILL_DATA_UUID, TREADMILL_DATA_UUID,
NIMBLE_PROPERTY::NOTIFY NIMBLE_PROPERTY::NOTIFY
); );
// Start the service
pService->start(); pService->start();
// Configure advertising
pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID); pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID);
pAdvertising->setScanResponse(true); pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06); pAdvertising->setMinPreferred(0x06);
pAdvertising->setMaxPreferred(0x12); pAdvertising->setMaxPreferred(0x12);
// Update device name
NimBLEDevice::setDeviceName("Treadmill Sim"); NimBLEDevice::setDeviceName("Treadmill Sim");
// Start advertising
NimBLEDevice::startAdvertising(); NimBLEDevice::startAdvertising();
Serial.println("Fitness Machine Service (Treadmill) started, advertising..."); Serial.println("Fitness Machine Service (Treadmill) started, advertising...");
} }
// ============================================ // ============================================
// Heart Rate Notification // Notifications
// ============================================ // ============================================
void notifyHeartRate(uint8_t bpm) { void BleService::notifyHeartRate(uint8_t bpm) {
if (!pHeartRateMeasurement || !deviceConnected) return; if (!pHeartRateMeasurement || !deviceState.getConnectionState().bleClientConnected) 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]; uint8_t hrData[2];
hrData[0] = 0x00; // Flags: UINT8 format, no contact detection hrData[0] = 0x00; // Flags
hrData[1] = bpm; hrData[1] = bpm;
pHeartRateMeasurement->setValue(hrData, 2); pHeartRateMeasurement->setValue(hrData, 2);
pHeartRateMeasurement->notify(); pHeartRateMeasurement->notify();
} }
// ============================================ void BleService::updateBattery(uint8_t level) {
// Battery Level Update
// ============================================
void updateBatteryLevel(uint8_t level) {
if (!pBatteryLevel) return; if (!pBatteryLevel) return;
// Clamp to 0-100
if (level > 100) level = 100; if (level > 100) level = 100;
pBatteryLevel->setValue(&level, 1); pBatteryLevel->setValue(&level, 1);
pBatteryLevel->notify(); pBatteryLevel->notify();
} }
// ============================================ void BleService::notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance) {
// Treadmill Data Notification if (!pTreadmillData || !deviceState.getConnectionState().bleClientConnected) return;
// ============================================
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance) {
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 (in order of flag bits)
// We'll include: Instantaneous Speed + Total Distance + Inclination + Ramp Angle
uint8_t data[11]; uint8_t data[11];
// Flags: Total Distance present (bit 2) + Inclination and Ramp Angle present (bit 3)
uint16_t flags = 0x000C; uint16_t flags = 0x000C;
data[0] = flags & 0xFF; data[0] = flags & 0xFF;
data[1] = (flags >> 8) & 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[2] = speed & 0xFF;
data[3] = (speed >> 8) & 0xFF; data[3] = (speed >> 8) & 0xFF;
// Total Distance (uint24, meters) - 3 bytes, little endian
data[4] = distance & 0xFF; data[4] = distance & 0xFF;
data[5] = (distance >> 8) & 0xFF; data[5] = (distance >> 8) & 0xFF;
data[6] = (distance >> 16) & 0xFF; data[6] = (distance >> 16) & 0xFF;
// Inclination (sint16, 0.1% resolution)
data[7] = incline & 0xFF; data[7] = incline & 0xFF;
data[8] = (incline >> 8) & 0xFF; data[8] = (incline >> 8) & 0xFF;
// Ramp Angle Setting (sint16, 0.1 degree resolution) - set to 0
int16_t rampAngle = 0; int16_t rampAngle = 0;
data[9] = rampAngle & 0xFF; data[9] = rampAngle & 0xFF;
data[10] = (rampAngle >> 8) & 0xFF; data[10] = (rampAngle >> 8) & 0xFF;

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,203 @@
#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 report
if (mqttConnected && millis() - lastStatusReport >= STATUS_REPORT_INTERVAL) {
lastStatusReport = millis();
publishStatus();
}
}
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";
mqtt.subscribe(configTopic.c_str());
mqtt.subscribe(setTopic.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();
}
}
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,12 +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 = {};
ResetDistanceCallback resetDistanceCallback = nullptr;
SetBatteryCallback setBatteryCallback = nullptr;
// 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(
@@ -108,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"><span id="apName">Loading...</span> &bull; UI v1.1.0</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>
@@ -178,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');
@@ -197,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');
@@ -205,7 +202,6 @@ 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);
// Show disconnected state when device is unreachable
updateStatusDots({ updateStatusDots({
wifiConnected: false, wifiConnected: false,
mqttConnected: false, mqttConnected: false,
@@ -230,7 +226,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
document.getElementById('ipAddr').textContent = status.ipAddress || '-'; document.getElementById('ipAddr').textContent = status.ipAddress || '-';
// Show heart rate card only when configured as heart rate
const heartRateCard = document.getElementById('heartRateCard'); const heartRateCard = document.getElementById('heartRateCard');
if (status.deviceType === 'Heart Rate') { if (status.deviceType === 'Heart Rate') {
heartRateCard.classList.remove('hidden'); heartRateCard.classList.remove('hidden');
@@ -240,7 +235,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
heartRateCard.classList.add('hidden'); heartRateCard.classList.add('hidden');
} }
// Show treadmill card only when configured as treadmill
const treadmillCard = document.getElementById('treadmillCard'); const treadmillCard = document.getElementById('treadmillCard');
if (status.deviceType === 'Treadmill') { if (status.deviceType === 'Treadmill') {
treadmillCard.classList.remove('hidden'); treadmillCard.classList.remove('hidden');
@@ -302,7 +296,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
} }
} }
// Battery slider handler
document.getElementById('batterySlider').addEventListener('input', async (e) => { document.getElementById('batterySlider').addEventListener('input', async (e) => {
const level = e.target.value; const level = e.target.value;
document.getElementById('batteryValue').textContent = level + '%'; document.getElementById('batteryValue').textContent = level + '%';
@@ -317,7 +310,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
} }
}); });
// Load config with retry, then update status periodically
async function init() { async function init() {
let retries = 3; let retries = 3;
while (retries > 0) { while (retries > 0) {
@@ -337,36 +329,44 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</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"] = currentStatus.treadmillDistance; status["treadmillDistance"] = values.treadmillDistance;
status["batteryLevel"] = currentStatus.batteryLevel; 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;
@@ -380,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...");
@@ -407,19 +406,12 @@ void handleReset() {
ESP.restart(); ESP.restart();
} }
void handleResetDistance() { void WebService::handleResetDistance() {
if (resetDistanceCallback) { deviceState.resetTreadmillDistance();
resetDistanceCallback();
Serial.println("Treadmill distance reset");
}
server.send(200, "application/json", "{\"success\":true}"); server.send(200, "application/json", "{\"success\":true}");
} }
void setResetDistanceCallback(ResetDistanceCallback callback) { void WebService::handleSetBattery() {
resetDistanceCallback = callback;
}
void handleSetBattery() {
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;
@@ -436,35 +428,25 @@ void handleSetBattery() {
uint8_t level = doc["level"] | 100; uint8_t level = doc["level"] | 100;
if (level > 100) level = 100; if (level > 100) level = 100;
if (setBatteryCallback) { deviceState.setBatteryLevel(level);
setBatteryCallback(level); Serial.print("Battery level set via web UI: ");
Serial.print("Battery level set to: "); Serial.println(level);
Serial.println(level);
}
server.send(200, "application/json", "{\"success\":true}"); server.send(200, "application/json", "{\"success\":true}");
} }
void setSetBatteryCallback(SetBatteryCallback callback) { void WebService::setup() {
setBatteryCallback = callback; server.on("/", HTTP_GET, []() { webService.handleRoot(); });
} server.on("/api/status", HTTP_GET, []() { webService.handleGetStatus(); });
server.on("/api/config", HTTP_POST, []() { webService.handlePostConfig(); });
void setupWebPortal() { server.on("/api/reset", HTTP_POST, []() { webService.handleReset(); });
server.on("/", HTTP_GET, handleRoot); server.on("/api/reset-distance", HTTP_POST, []() { webService.handleResetDistance(); });
server.on("/api/status", HTTP_GET, handleGetStatus); server.on("/api/set-battery", HTTP_POST, []() { webService.handleSetBattery(); });
server.on("/api/config", HTTP_POST, handlePostConfig);
server.on("/api/reset", HTTP_POST, handleReset);
server.on("/api/reset-distance", HTTP_POST, handleResetDistance);
server.on("/api/set-battery", HTTP_POST, 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()
);
}

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

@@ -0,0 +1,159 @@
"""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 (30% toward ideal each tick)
state.float_hr += (ideal_hr - state.float_hr) * 0.3
# Clamp to ±3 of target
state.float_hr = max(
state.target - 3,
min(state.target + 3, state.float_hr)
)
# 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(1)
except asyncio.CancelledError:
pass
async def _send_hr(self, device_id: str, hr: int):
"""Send HR value to device via MQTT and update registry."""
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.1.2" __version__ = "1.3.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:
@@ -62,6 +63,7 @@ async def handle_device_status(topic: str, payload: str):
"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}")
@@ -97,9 +99,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."""

View File

@@ -5,6 +5,7 @@ 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()
@@ -21,6 +22,14 @@ class DeviceValues(BaseModel):
incline: float | None = Field(None, ge=-10, le=40) # percent incline: float | None = Field(None, ge=-10, le=40) # percent
cadence: int | None = Field(None, ge=0, le=300) # rpm cadence: int | None = Field(None, ge=0, le=300) # rpm
power: int | None = Field(None, ge=0, le=2000) # 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)
@router.get("/devices") @router.get("/devices")
@@ -40,6 +49,29 @@ 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:
pass # MQTT might not be connected
# Remove from registry
device_registry.remove_device(device_id)
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 +99,43 @@ 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.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

@@ -258,6 +258,28 @@
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;
}
/* WebSocket connection status */ /* WebSocket connection status */
.connection-status { .connection-status {
display: flex; display: flex;
@@ -289,7 +311,7 @@
<div class="header"> <div class="header">
<div> <div>
<h1>pyBTMCP</h1> <h1>pyBTMCP</h1>
<p class="subtitle">BLE Device Simulator &bull; UI v1.2.3 &bull; API <span id="backendVersion"></span></p> <p class="subtitle">BLE Device Simulator &bull; UI v1.4.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 +446,16 @@
} 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 === 'hr_update') {
// HR value update from server-side variation
updateHrDisplay(message.device_id, message.heart_rate);
}
}
function updateHrDisplay(deviceId, hr) {
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) {
display.textContent = `${hr} BPM`;
} }
} }
@@ -449,18 +481,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 +522,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 +565,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 +662,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>
@@ -627,11 +686,10 @@
</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,25 +711,19 @@
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>
@@ -747,135 +799,53 @@
return ''; return '';
} }
// HR variation - smooth realistic human heart rate simulation // HR control functions - business logic is server-side
const hrVariationState = {}; // Store per-device state const hrTargetDebounceTimers = {};
function setupHrVariation(deviceId, startHr) { async function enableHrVariationForDevice(deviceId, startHr) {
if (hrVariationIntervals[deviceId]) {
clearInterval(hrVariationIntervals[deviceId]);
}
if (!settings.hrVariation) return;
// Only set target if not already set (don't overwrite user's target)
if (!currentHrTargets[deviceId]) {
currentHrTargets[deviceId] = startHr;
}
// Use existing value if we have one, otherwise start at startHr
if (!currentHrValues[deviceId]) {
currentHrValues[deviceId] = startHr;
}
// Initialize smooth variation state
if (!hrVariationState[deviceId]) {
hrVariationState[deviceId] = {
phase: Math.random() * Math.PI * 2, // Random start phase
floatHr: currentHrValues[deviceId] // Floating point HR for smooth transitions
};
}
hrVariationIntervals[deviceId] = setInterval(() => {
const target = currentHrTargets[deviceId];
const state = hrVariationState[deviceId];
// Slowly advance phase (completes cycle in ~30 seconds)
state.phase += 0.2;
// Smooth sinusoidal base variation (±2 BPM)
const sineVariation = Math.sin(state.phase) * 2;
// Small random walk component (±0.3 per tick, smooths out)
const randomWalk = (Math.random() - 0.5) * 0.6;
// Calculate ideal HR with smooth variation
const idealHr = target + sineVariation + randomWalk;
// Smooth transition toward ideal (move 30% of the way each tick)
state.floatHr += (idealHr - state.floatHr) * 0.3;
// Clamp to ±3 of target
state.floatHr = Math.max(target - 3, Math.min(target + 3, state.floatHr));
// Round for display/transmission
const currentHr = Math.round(Math.max(30, Math.min(220, state.floatHr)));
// Only send if value changed
if (currentHr !== currentHrValues[deviceId]) {
currentHrValues[deviceId] = currentHr;
sendHrValue(deviceId, currentHr);
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);
}
// 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}`);
@@ -952,6 +922,18 @@
} }
} }
async function deleteDevice(deviceId) {
if (!confirm(`Remove device ${deviceId} from the list?`)) return;
try {
await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'DELETE'
});
loadDevices();
} catch (error) {
console.error('Failed to delete device:', error);
}
}
let updateTimeouts = {}; let updateTimeouts = {};
function updateValueDebounced(deviceId, key, value) { function updateValueDebounced(deviceId, key, value) {
const timeoutKey = `${deviceId}-${key}`; const timeoutKey = `${deviceId}-${key}`;