mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 13:11:53 +00:00
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:
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
105
firmware/esp32_ble_sim/include/device_state.h
Normal file
105
firmware/esp32_ble_sim/include/device_state.h
Normal 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
|
||||||
58
firmware/esp32_ble_sim/include/services/ble_service.h
Normal file
58
firmware/esp32_ble_sim/include/services/ble_service.h
Normal 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
|
||||||
@@ -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
|
||||||
45
firmware/esp32_ble_sim/include/services/mqtt_service.h
Normal file
45
firmware/esp32_ble_sim/include/services/mqtt_service.h
Normal 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
|
||||||
35
firmware/esp32_ble_sim/include/services/web_service.h
Normal file
35
firmware/esp32_ble_sim/include/services/web_service.h
Normal 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
|
||||||
49
firmware/esp32_ble_sim/include/services/wifi_service.h
Normal file
49
firmware/esp32_ble_sim/include/services/wifi_service.h
Normal 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
|
||||||
@@ -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
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
104
firmware/esp32_ble_sim/src/device_state.cpp
Normal file
104
firmware/esp32_ble_sim/src/device_state.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
85
firmware/esp32_ble_sim/src/services/config_service.cpp
Normal file
85
firmware/esp32_ble_sim/src/services/config_service.cpp
Normal 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);
|
||||||
|
}
|
||||||
203
firmware/esp32_ble_sim/src/services/mqtt_service.cpp
Normal file
203
firmware/esp32_ble_sim/src/services/mqtt_service.cpp
Normal 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());
|
||||||
|
}
|
||||||
@@ -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> • UI v1.1.0</p>
|
<p class="subtitle"><span id="apName">Loading...</span> • 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;
|
|
||||||
}
|
|
||||||
162
firmware/esp32_ble_sim/src/services/wifi_service.cpp
Normal file
162
firmware/esp32_ble_sim/src/services/wifi_service.cpp
Normal 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
159
src/api/hr_variation.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 • UI v1.2.3 • API <span id="backendVersion"></span></p>
|
<p class="subtitle">BLE Device Simulator • UI v1.4.0 • 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}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user