mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 14:21:52 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aab2526f99 | |||
| 0a48a2928e | |||
| 0d34cae441 | |||
| c95cd33343 | |||
| 9b740ebdd0 |
18
README.md
18
README.md
@@ -92,6 +92,24 @@ To use pyBTMCP with Claude Desktop, add it to your MCP configuration:
|
|||||||
|
|
||||||
After saving the configuration, restart Claude Desktop. You should see the "ble-simulator" MCP server available.
|
After saving the configuration, restart Claude Desktop. You should see the "ble-simulator" MCP server available.
|
||||||
|
|
||||||
|
## MCP Integration (Claude Code CLI)
|
||||||
|
|
||||||
|
To add pyBTMCP to Claude Code, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add ble-simulator \
|
||||||
|
-s user \
|
||||||
|
-- docker run -i --rm -p 1883:1883 -p 8000:8000 --name pybtmcp pybtmcp:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds the MCP server to your user configuration. Use `-s project` instead to add it to the current project only.
|
||||||
|
|
||||||
|
To verify the server was added:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp list
|
||||||
|
```
|
||||||
|
|
||||||
## MCP Tools Reference
|
## MCP Tools Reference
|
||||||
|
|
||||||
Once configured, Claude can use these tools to control BLE devices:
|
Once configured, Claude can use these tools to control BLE devices:
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
#ifndef BLE_SERVICES_H
|
|
||||||
#define BLE_SERVICES_H
|
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// BLE Service UUIDs (Bluetooth SIG standard)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// Heart Rate Service
|
|
||||||
#define HEART_RATE_SERVICE_UUID "180D"
|
|
||||||
#define HEART_RATE_MEASUREMENT_UUID "2A37"
|
|
||||||
#define BODY_SENSOR_LOCATION_UUID "2A38"
|
|
||||||
|
|
||||||
// Fitness Machine Service
|
|
||||||
#define FITNESS_MACHINE_SERVICE_UUID "1826"
|
|
||||||
#define TREADMILL_DATA_UUID "2ACD"
|
|
||||||
#define FITNESS_MACHINE_FEATURE_UUID "2ACC"
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Function Prototypes
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize NimBLE stack (call once at startup)
|
|
||||||
*/
|
|
||||||
void initBLE();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop BLE advertising and deinit
|
|
||||||
*/
|
|
||||||
void stopBLE();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup BLE as Heart Rate Monitor
|
|
||||||
* Creates Heart Rate Service with measurement characteristic
|
|
||||||
*/
|
|
||||||
void setupBLE_HeartRate();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup BLE as Treadmill (Fitness Machine)
|
|
||||||
* Creates Fitness Machine Service with treadmill data
|
|
||||||
*/
|
|
||||||
void setupBLE_Treadmill();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send heart rate notification
|
|
||||||
* @param bpm Heart rate in beats per minute
|
|
||||||
*/
|
|
||||||
void notifyHeartRate(uint8_t bpm);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send treadmill data notification
|
|
||||||
* @param speed Speed in 0.01 km/h units
|
|
||||||
* @param incline Incline in 0.1% units
|
|
||||||
*/
|
|
||||||
void notifyTreadmill(uint16_t speed, int16_t incline);
|
|
||||||
|
|
||||||
#endif // BLE_SERVICES_H
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// Firmware Version
|
// Firmware Version
|
||||||
// ============================================
|
// ============================================
|
||||||
#define FIRMWARE_VERSION "1.0.0"
|
#define FIRMWARE_VERSION "1.1.0"
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// AP Mode Configuration
|
// AP Mode Configuration
|
||||||
|
|||||||
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
|
||||||
68
firmware/esp32_ble_sim/include/services/ble_service.h
Normal file
68
firmware/esp32_ble_sim/include/services/ble_service.h
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#ifndef BLE_SERVICE_H
|
||||||
|
#define BLE_SERVICE_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BLE Service UUIDs (Bluetooth SIG standard)
|
||||||
|
// ============================================
|
||||||
|
#define HEART_RATE_SERVICE_UUID "180D"
|
||||||
|
#define HEART_RATE_MEASUREMENT_UUID "2A37"
|
||||||
|
#define BODY_SENSOR_LOCATION_UUID "2A38"
|
||||||
|
#define BATTERY_SERVICE_UUID "180F"
|
||||||
|
#define BATTERY_LEVEL_UUID "2A19"
|
||||||
|
#define FITNESS_MACHINE_SERVICE_UUID "1826"
|
||||||
|
#define TREADMILL_DATA_UUID "2ACD"
|
||||||
|
#define FITNESS_MACHINE_FEATURE_UUID "2ACC"
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BLE Service
|
||||||
|
// Manages BLE advertising and GATT services
|
||||||
|
// ============================================
|
||||||
|
class BleService {
|
||||||
|
public:
|
||||||
|
static BleService& getInstance();
|
||||||
|
|
||||||
|
// Prevent copying
|
||||||
|
BleService(const BleService&) = delete;
|
||||||
|
BleService& operator=(const BleService&) = delete;
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
void setup();
|
||||||
|
void loop();
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
void setupHeartRate();
|
||||||
|
void setupTreadmill();
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
void notifyHeartRate(uint8_t bpm);
|
||||||
|
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance);
|
||||||
|
void updateBattery(uint8_t level);
|
||||||
|
|
||||||
|
// State
|
||||||
|
bool isClientConnected() const { return deviceConnected; }
|
||||||
|
|
||||||
|
// Disconnect simulation
|
||||||
|
void disconnectClient(); // Force disconnect + immediate re-advertise
|
||||||
|
void disconnectClientForDuration(int ms); // Disconnect + pause advertising for duration
|
||||||
|
void teardownForDuration(int ms); // Full BLE deinit + reinit after duration
|
||||||
|
|
||||||
|
private:
|
||||||
|
BleService() = default;
|
||||||
|
|
||||||
|
bool deviceConnected = false;
|
||||||
|
unsigned long lastNotify = 0;
|
||||||
|
unsigned long advertisingResumeTime = 0; // When to resume advertising (0 = not paused)
|
||||||
|
bool advertisingPaused = false;
|
||||||
|
unsigned long teardownResumeTime = 0; // When to reinit BLE after teardown
|
||||||
|
bool teardownPending = false;
|
||||||
|
|
||||||
|
void initBLE();
|
||||||
|
void reinitBLE(); // Reinit BLE and restore services
|
||||||
|
};
|
||||||
|
|
||||||
|
#define bleService BleService::getInstance()
|
||||||
|
|
||||||
|
#endif // BLE_SERVICE_H
|
||||||
@@ -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,30 +0,0 @@
|
|||||||
#ifndef WEB_PORTAL_H
|
|
||||||
#define WEB_PORTAL_H
|
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize and start the web portal
|
|
||||||
* Runs on AP interface at 192.168.4.1
|
|
||||||
*/
|
|
||||||
void setupWebPortal();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle web portal requests (call in loop)
|
|
||||||
*/
|
|
||||||
void handleWebPortal();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status info for web portal display
|
|
||||||
*/
|
|
||||||
struct PortalStatus {
|
|
||||||
bool wifiConnected;
|
|
||||||
bool mqttConnected;
|
|
||||||
String ipAddress;
|
|
||||||
String deviceType;
|
|
||||||
bool bleStarted;
|
|
||||||
};
|
|
||||||
|
|
||||||
void updatePortalStatus(const PortalStatus& status);
|
|
||||||
|
|
||||||
#endif // WEB_PORTAL_H
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
/**
|
|
||||||
* BLE Services Implementation
|
|
||||||
*
|
|
||||||
* Implements standard Bluetooth SIG GATT services:
|
|
||||||
* - Heart Rate Service (0x180D)
|
|
||||||
* - Fitness Machine Service (0x1826) - Treadmill
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ble_services.h"
|
|
||||||
#include "config.h"
|
|
||||||
#include "config_manager.h"
|
|
||||||
#include <NimBLEDevice.h>
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// BLE Server and Characteristics
|
|
||||||
// ============================================
|
|
||||||
static NimBLEServer* pServer = nullptr;
|
|
||||||
static NimBLEAdvertising* pAdvertising = nullptr;
|
|
||||||
|
|
||||||
// Heart Rate
|
|
||||||
static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
|
|
||||||
|
|
||||||
// Treadmill
|
|
||||||
static NimBLECharacteristic* pTreadmillData = nullptr;
|
|
||||||
|
|
||||||
static bool deviceConnected = false;
|
|
||||||
static bool oldDeviceConnected = false;
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Server Callbacks
|
|
||||||
// ============================================
|
|
||||||
class ServerCallbacks : public NimBLEServerCallbacks {
|
|
||||||
void onConnect(NimBLEServer* pServer) override {
|
|
||||||
deviceConnected = true;
|
|
||||||
Serial.println("BLE client connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
void onDisconnect(NimBLEServer* pServer) override {
|
|
||||||
deviceConnected = false;
|
|
||||||
Serial.println("BLE client disconnected");
|
|
||||||
|
|
||||||
// Restart advertising
|
|
||||||
NimBLEDevice::startAdvertising();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// BLE Initialization
|
|
||||||
// ============================================
|
|
||||||
void initBLE() {
|
|
||||||
NimBLEDevice::init("BLE Simulator");
|
|
||||||
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
|
|
||||||
|
|
||||||
pServer = NimBLEDevice::createServer();
|
|
||||||
pServer->setCallbacks(new ServerCallbacks());
|
|
||||||
|
|
||||||
pAdvertising = NimBLEDevice::getAdvertising();
|
|
||||||
|
|
||||||
Serial.println("BLE initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
void stopBLE() {
|
|
||||||
if (pAdvertising) {
|
|
||||||
pAdvertising->stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear services (will be recreated on next setup)
|
|
||||||
pHeartRateMeasurement = nullptr;
|
|
||||||
pTreadmillData = nullptr;
|
|
||||||
|
|
||||||
Serial.println("BLE stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Heart Rate Service Setup
|
|
||||||
// ============================================
|
|
||||||
void setupBLE_HeartRate() {
|
|
||||||
Serial.println("Setting up Heart Rate Service...");
|
|
||||||
|
|
||||||
// Stop any current advertising
|
|
||||||
if (pAdvertising) {
|
|
||||||
pAdvertising->stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Heart Rate Service
|
|
||||||
NimBLEService* pService = pServer->createService(HEART_RATE_SERVICE_UUID);
|
|
||||||
|
|
||||||
// Heart Rate Measurement Characteristic
|
|
||||||
// Flags: Notify
|
|
||||||
pHeartRateMeasurement = pService->createCharacteristic(
|
|
||||||
HEART_RATE_MEASUREMENT_UUID,
|
|
||||||
NIMBLE_PROPERTY::NOTIFY
|
|
||||||
);
|
|
||||||
|
|
||||||
// Body Sensor Location Characteristic
|
|
||||||
// Flags: Read
|
|
||||||
// Value: 1 = Chest
|
|
||||||
NimBLECharacteristic* pBodySensorLocation = pService->createCharacteristic(
|
|
||||||
BODY_SENSOR_LOCATION_UUID,
|
|
||||||
NIMBLE_PROPERTY::READ
|
|
||||||
);
|
|
||||||
uint8_t sensorLocation = 1; // Chest
|
|
||||||
pBodySensorLocation->setValue(&sensorLocation, 1);
|
|
||||||
|
|
||||||
// Start the service
|
|
||||||
pService->start();
|
|
||||||
|
|
||||||
// Configure advertising
|
|
||||||
pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID);
|
|
||||||
pAdvertising->setScanResponse(true);
|
|
||||||
pAdvertising->setMinPreferred(0x06);
|
|
||||||
pAdvertising->setMaxPreferred(0x12);
|
|
||||||
|
|
||||||
// Update device name
|
|
||||||
NimBLEDevice::setDeviceName("HR Simulator");
|
|
||||||
|
|
||||||
// Start advertising
|
|
||||||
NimBLEDevice::startAdvertising();
|
|
||||||
|
|
||||||
Serial.println("Heart Rate Service started, advertising...");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Treadmill (Fitness Machine) Service Setup
|
|
||||||
// ============================================
|
|
||||||
void setupBLE_Treadmill() {
|
|
||||||
Serial.println("Setting up Fitness Machine Service (Treadmill)...");
|
|
||||||
|
|
||||||
// Stop any current advertising
|
|
||||||
if (pAdvertising) {
|
|
||||||
pAdvertising->stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Fitness Machine Service
|
|
||||||
NimBLEService* pService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID);
|
|
||||||
|
|
||||||
// Fitness Machine Feature Characteristic
|
|
||||||
// Flags: Read
|
|
||||||
NimBLECharacteristic* pFeature = pService->createCharacteristic(
|
|
||||||
FITNESS_MACHINE_FEATURE_UUID,
|
|
||||||
NIMBLE_PROPERTY::READ
|
|
||||||
);
|
|
||||||
|
|
||||||
// Feature flags for treadmill:
|
|
||||||
// Byte 0-3: Fitness Machine Features
|
|
||||||
// Bit 0: Average Speed Supported
|
|
||||||
// Bit 1: Cadence Supported
|
|
||||||
// Bit 2: Total Distance Supported
|
|
||||||
// Bit 3: Inclination Supported
|
|
||||||
// Bit 13: Elapsed Time Supported
|
|
||||||
// Byte 4-7: Target Setting Features
|
|
||||||
uint8_t featureData[8] = {
|
|
||||||
0x0B, 0x20, 0x00, 0x00, // Features: Speed, Cadence, Distance, Inclination, Elapsed Time
|
|
||||||
0x00, 0x00, 0x00, 0x00 // Target settings (none)
|
|
||||||
};
|
|
||||||
pFeature->setValue(featureData, 8);
|
|
||||||
|
|
||||||
// Treadmill Data Characteristic
|
|
||||||
// Flags: Notify
|
|
||||||
pTreadmillData = pService->createCharacteristic(
|
|
||||||
TREADMILL_DATA_UUID,
|
|
||||||
NIMBLE_PROPERTY::NOTIFY
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start the service
|
|
||||||
pService->start();
|
|
||||||
|
|
||||||
// Configure advertising
|
|
||||||
pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID);
|
|
||||||
pAdvertising->setScanResponse(true);
|
|
||||||
pAdvertising->setMinPreferred(0x06);
|
|
||||||
pAdvertising->setMaxPreferred(0x12);
|
|
||||||
|
|
||||||
// Update device name
|
|
||||||
NimBLEDevice::setDeviceName("Treadmill Sim");
|
|
||||||
|
|
||||||
// Start advertising
|
|
||||||
NimBLEDevice::startAdvertising();
|
|
||||||
|
|
||||||
Serial.println("Fitness Machine Service (Treadmill) started, advertising...");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Heart Rate Notification
|
|
||||||
// ============================================
|
|
||||||
void notifyHeartRate(uint8_t bpm) {
|
|
||||||
if (!pHeartRateMeasurement || !deviceConnected) return;
|
|
||||||
|
|
||||||
// Heart Rate Measurement format:
|
|
||||||
// Byte 0: Flags
|
|
||||||
// Bit 0: Heart Rate Value Format (0 = UINT8, 1 = UINT16)
|
|
||||||
// Bit 1-2: Sensor Contact Status
|
|
||||||
// Bit 3: Energy Expended Status
|
|
||||||
// Bit 4: RR-Interval
|
|
||||||
// Byte 1: Heart Rate Value (UINT8)
|
|
||||||
uint8_t hrData[2];
|
|
||||||
hrData[0] = 0x00; // Flags: UINT8 format, no contact detection
|
|
||||||
hrData[1] = bpm;
|
|
||||||
|
|
||||||
pHeartRateMeasurement->setValue(hrData, 2);
|
|
||||||
pHeartRateMeasurement->notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Treadmill Data Notification
|
|
||||||
// ============================================
|
|
||||||
void notifyTreadmill(uint16_t speed, int16_t incline) {
|
|
||||||
if (!pTreadmillData || !deviceConnected) return;
|
|
||||||
|
|
||||||
// Treadmill Data format (per Bluetooth FTMS spec):
|
|
||||||
// Byte 0-1: Flags
|
|
||||||
// Bit 0: More Data (0 = Instantaneous Speed present)
|
|
||||||
// Bit 1: Average Speed present
|
|
||||||
// Bit 2: Total Distance present
|
|
||||||
// Bit 3: Inclination and Ramp Angle present
|
|
||||||
// Following bytes: Data fields based on flags
|
|
||||||
|
|
||||||
// We'll include: Instantaneous Speed + Inclination + Ramp Angle
|
|
||||||
uint8_t data[8];
|
|
||||||
|
|
||||||
// Flags: Inclination and Ramp Angle present (bit 3)
|
|
||||||
uint16_t flags = 0x0008;
|
|
||||||
data[0] = flags & 0xFF;
|
|
||||||
data[1] = (flags >> 8) & 0xFF;
|
|
||||||
|
|
||||||
// Instantaneous Speed (always present when More Data=0, uint16, 0.01 km/h resolution)
|
|
||||||
data[2] = speed & 0xFF;
|
|
||||||
data[3] = (speed >> 8) & 0xFF;
|
|
||||||
|
|
||||||
// Inclination (sint16, 0.1% resolution)
|
|
||||||
data[4] = incline & 0xFF;
|
|
||||||
data[5] = (incline >> 8) & 0xFF;
|
|
||||||
|
|
||||||
// Ramp Angle Setting (sint16, 0.1 degree resolution) - set to 0
|
|
||||||
int16_t rampAngle = 0;
|
|
||||||
data[6] = rampAngle & 0xFF;
|
|
||||||
data[7] = (rampAngle >> 8) & 0xFF;
|
|
||||||
|
|
||||||
pTreadmillData->setValue(data, 8);
|
|
||||||
pTreadmillData->notify();
|
|
||||||
}
|
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,333 +4,24 @@
|
|||||||
* Simulates BLE fitness devices (Heart Rate Monitor, Treadmill)
|
* Simulates BLE fitness devices (Heart Rate Monitor, Treadmill)
|
||||||
* Controlled via MQTT from pyBTMCP server
|
* Controlled via MQTT from pyBTMCP server
|
||||||
*
|
*
|
||||||
* Features:
|
* Architecture:
|
||||||
* - AP mode for configuration when not connected to WiFi (192.168.4.1)
|
* - device_state: Central state management with event callbacks
|
||||||
* - Connects to configured WiFi for MQTT control
|
* - config_service: NVS persistence for configuration
|
||||||
* - Configuration stored in NVS (flash once, configure via web)
|
* - wifi_service: WiFi STA/AP management
|
||||||
|
* - mqtt_service: MQTT client and message routing
|
||||||
|
* - ble_service: BLE GATT services and notifications
|
||||||
|
* - web_service: HTTP configuration portal
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <WiFi.h>
|
|
||||||
#include <PubSubClient.h>
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <NimBLEDevice.h>
|
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "config_manager.h"
|
#include "device_state.h"
|
||||||
#include "web_portal.h"
|
#include "services/config_service.h"
|
||||||
#include "ble_services.h"
|
#include "services/wifi_service.h"
|
||||||
|
#include "services/mqtt_service.h"
|
||||||
|
#include "services/ble_service.h"
|
||||||
|
#include "services/web_service.h"
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Global State
|
|
||||||
// ============================================
|
|
||||||
WiFiClient wifiClient;
|
|
||||||
PubSubClient mqtt(wifiClient);
|
|
||||||
|
|
||||||
// Current device configuration
|
|
||||||
enum DeviceType {
|
|
||||||
DEVICE_NONE,
|
|
||||||
DEVICE_HEART_RATE,
|
|
||||||
DEVICE_TREADMILL
|
|
||||||
};
|
|
||||||
|
|
||||||
DeviceType currentDeviceType = DEVICE_NONE;
|
|
||||||
bool bleStarted = false;
|
|
||||||
bool wifiConnected = false;
|
|
||||||
bool mqttConnected = false;
|
|
||||||
bool apModeActive = false;
|
|
||||||
|
|
||||||
// Simulated values
|
|
||||||
uint8_t heartRate = 70;
|
|
||||||
uint16_t treadmillSpeed = 0; // 0.01 km/h resolution
|
|
||||||
int16_t treadmillIncline = 0; // 0.1% resolution
|
|
||||||
|
|
||||||
// Timing
|
|
||||||
unsigned long lastNotify = 0;
|
|
||||||
unsigned long lastStatus = 0;
|
|
||||||
unsigned long lastWifiAttempt = 0;
|
|
||||||
unsigned long lastMqttAttempt = 0;
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// WiFi Functions
|
|
||||||
// ============================================
|
|
||||||
void startAPMode() {
|
|
||||||
if (apModeActive) return;
|
|
||||||
|
|
||||||
WiFi.mode(WIFI_AP);
|
|
||||||
WiFi.softAPConfig(AP_IP, AP_GATEWAY, AP_SUBNET);
|
|
||||||
String apName = configManager.getAPName();
|
|
||||||
WiFi.softAP(apName.c_str(), AP_PASSWORD);
|
|
||||||
apModeActive = true;
|
|
||||||
|
|
||||||
Serial.println("\n========================================");
|
|
||||||
Serial.println("Access Point Started");
|
|
||||||
Serial.print(" SSID: ");
|
|
||||||
Serial.println(apName);
|
|
||||||
Serial.print(" Config URL: http://");
|
|
||||||
Serial.println(WiFi.softAPIP());
|
|
||||||
Serial.println("========================================\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
void stopAPMode() {
|
|
||||||
if (!apModeActive) return;
|
|
||||||
|
|
||||||
WiFi.softAPdisconnect(true);
|
|
||||||
WiFi.mode(WIFI_STA);
|
|
||||||
apModeActive = false;
|
|
||||||
|
|
||||||
Serial.println("Access Point stopped (WiFi connected)");
|
|
||||||
}
|
|
||||||
|
|
||||||
void setupWiFi() {
|
|
||||||
if (configManager.isConfigured()) {
|
|
||||||
// Start in STA mode, try to connect
|
|
||||||
WiFi.mode(WIFI_STA);
|
|
||||||
Serial.println("Starting in STA mode (configured)");
|
|
||||||
} else {
|
|
||||||
// No config - start AP for setup
|
|
||||||
startAPMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void connectToWiFi() {
|
|
||||||
if (!configManager.isConfigured()) {
|
|
||||||
// Not configured - ensure AP is running
|
|
||||||
if (!apModeActive) {
|
|
||||||
startAPMode();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
if (!wifiConnected) {
|
|
||||||
wifiConnected = true;
|
|
||||||
Serial.print("WiFi connected! IP: ");
|
|
||||||
Serial.println(WiFi.localIP());
|
|
||||||
|
|
||||||
// Stop AP mode when connected
|
|
||||||
stopAPMode();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// WiFi not connected
|
|
||||||
if (wifiConnected) {
|
|
||||||
// Was connected, now disconnected - start AP
|
|
||||||
wifiConnected = false;
|
|
||||||
mqttConnected = false;
|
|
||||||
Serial.println("WiFi disconnected!");
|
|
||||||
startAPMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't spam connection attempts
|
|
||||||
if (millis() - lastWifiAttempt < WIFI_CONNECT_TIMEOUT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastWifiAttempt = millis();
|
|
||||||
|
|
||||||
// If AP is active, switch to AP+STA to allow connection attempts
|
|
||||||
if (apModeActive) {
|
|
||||||
WiFi.mode(WIFI_AP_STA);
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.print("Connecting to WiFi: ");
|
|
||||||
Serial.println(configManager.getWifiSsid());
|
|
||||||
|
|
||||||
WiFi.begin(
|
|
||||||
configManager.getWifiSsid().c_str(),
|
|
||||||
configManager.getWifiPassword().c_str()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// MQTT Functions
|
|
||||||
// ============================================
|
|
||||||
void publishStatus() {
|
|
||||||
if (!mqtt.connected()) return;
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["online"] = true;
|
|
||||||
doc["firmware_version"] = FIRMWARE_VERSION;
|
|
||||||
|
|
||||||
const char* typeStr = "none";
|
|
||||||
if (currentDeviceType == DEVICE_HEART_RATE) typeStr = "heart_rate";
|
|
||||||
else if (currentDeviceType == DEVICE_TREADMILL) typeStr = "treadmill";
|
|
||||||
doc["type"] = typeStr;
|
|
||||||
|
|
||||||
doc["ble_started"] = bleStarted;
|
|
||||||
doc["ip"] = WiFi.localIP().toString();
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
String topic = String("ble-sim/") + configManager.getDeviceId() + "/status";
|
|
||||||
mqtt.publish(topic.c_str(), payload.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
void publishValues() {
|
|
||||||
if (!mqtt.connected()) return;
|
|
||||||
|
|
||||||
JsonDocument doc;
|
|
||||||
|
|
||||||
if (currentDeviceType == DEVICE_HEART_RATE) {
|
|
||||||
doc["heart_rate"] = heartRate;
|
|
||||||
} else if (currentDeviceType == DEVICE_TREADMILL) {
|
|
||||||
doc["speed"] = treadmillSpeed / 100.0;
|
|
||||||
doc["incline"] = treadmillIncline / 10.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
String payload;
|
|
||||||
serializeJson(doc, payload);
|
|
||||||
|
|
||||||
String topic = String("ble-sim/") + configManager.getDeviceId() + "/values";
|
|
||||||
mqtt.publish(topic.c_str(), payload.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleMqttMessage(char* topic, byte* payload, unsigned int length) {
|
|
||||||
JsonDocument doc;
|
|
||||||
DeserializationError error = deserializeJson(doc, payload, length);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
Serial.print("JSON parse error: ");
|
|
||||||
Serial.println(error.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String topicStr = String(topic);
|
|
||||||
String baseTopic = String("ble-sim/") + configManager.getDeviceId();
|
|
||||||
|
|
||||||
// Handle configuration
|
|
||||||
if (topicStr == baseTopic + "/config") {
|
|
||||||
String type = doc["type"] | "";
|
|
||||||
|
|
||||||
Serial.print("Configuring as: ");
|
|
||||||
Serial.println(type);
|
|
||||||
|
|
||||||
if (type == "heart_rate") {
|
|
||||||
currentDeviceType = DEVICE_HEART_RATE;
|
|
||||||
setupBLE_HeartRate();
|
|
||||||
bleStarted = true;
|
|
||||||
} else if (type == "treadmill") {
|
|
||||||
currentDeviceType = DEVICE_TREADMILL;
|
|
||||||
setupBLE_Treadmill();
|
|
||||||
bleStarted = true;
|
|
||||||
} else {
|
|
||||||
currentDeviceType = DEVICE_NONE;
|
|
||||||
stopBLE();
|
|
||||||
bleStarted = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
publishStatus();
|
|
||||||
}
|
|
||||||
// Handle value updates
|
|
||||||
else if (topicStr == baseTopic + "/set") {
|
|
||||||
if (doc["heart_rate"].is<int>()) {
|
|
||||||
heartRate = doc["heart_rate"];
|
|
||||||
Serial.print("Heart rate set to: ");
|
|
||||||
Serial.println(heartRate);
|
|
||||||
}
|
|
||||||
if (doc["speed"].is<float>()) {
|
|
||||||
float speed = doc["speed"];
|
|
||||||
treadmillSpeed = (uint16_t)(speed * 100);
|
|
||||||
Serial.print("Speed set to: ");
|
|
||||||
Serial.println(speed);
|
|
||||||
}
|
|
||||||
if (doc["incline"].is<float>()) {
|
|
||||||
float incline = doc["incline"];
|
|
||||||
treadmillIncline = (int16_t)(incline * 10);
|
|
||||||
Serial.print("Incline set to: ");
|
|
||||||
Serial.println(incline);
|
|
||||||
}
|
|
||||||
|
|
||||||
publishValues();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setupMQTT() {
|
|
||||||
mqtt.setCallback(handleMqttMessage);
|
|
||||||
mqtt.setBufferSize(512);
|
|
||||||
}
|
|
||||||
|
|
||||||
void connectToMQTT() {
|
|
||||||
if (!configManager.isConfigured() || !wifiConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mqtt.connected()) {
|
|
||||||
if (!mqttConnected) {
|
|
||||||
mqttConnected = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't spam connection attempts
|
|
||||||
if (millis() - lastMqttAttempt < MQTT_RECONNECT_INTERVAL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastMqttAttempt = millis();
|
|
||||||
|
|
||||||
mqttConnected = false;
|
|
||||||
|
|
||||||
// Update MQTT server config
|
|
||||||
mqtt.setServer(
|
|
||||||
configManager.getMqttHost().c_str(),
|
|
||||||
configManager.getMqttPort()
|
|
||||||
);
|
|
||||||
|
|
||||||
Serial.print("Connecting to MQTT at ");
|
|
||||||
Serial.print(configManager.getMqttHost());
|
|
||||||
Serial.print(":");
|
|
||||||
Serial.println(configManager.getMqttPort());
|
|
||||||
|
|
||||||
String clientId = String("esp32-") + String(random(0xffff), HEX);
|
|
||||||
|
|
||||||
if (mqtt.connect(clientId.c_str())) {
|
|
||||||
mqttConnected = true;
|
|
||||||
Serial.println("MQTT connected!");
|
|
||||||
|
|
||||||
// Subscribe to control topics
|
|
||||||
String configTopic = String("ble-sim/") + configManager.getDeviceId() + "/config";
|
|
||||||
String setTopic = String("ble-sim/") + configManager.getDeviceId() + "/set";
|
|
||||||
|
|
||||||
mqtt.subscribe(configTopic.c_str());
|
|
||||||
mqtt.subscribe(setTopic.c_str());
|
|
||||||
|
|
||||||
Serial.print("Subscribed to: ");
|
|
||||||
Serial.println(configTopic);
|
|
||||||
|
|
||||||
// Publish initial status
|
|
||||||
publishStatus();
|
|
||||||
} else {
|
|
||||||
Serial.print("MQTT connection failed, rc=");
|
|
||||||
Serial.println(mqtt.state());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Update Portal Status
|
|
||||||
// ============================================
|
|
||||||
void updateStatus() {
|
|
||||||
PortalStatus status;
|
|
||||||
status.wifiConnected = wifiConnected;
|
|
||||||
status.mqttConnected = mqttConnected;
|
|
||||||
status.bleStarted = bleStarted;
|
|
||||||
status.ipAddress = wifiConnected ? WiFi.localIP().toString() : "";
|
|
||||||
|
|
||||||
if (currentDeviceType == DEVICE_HEART_RATE) {
|
|
||||||
status.deviceType = "Heart Rate";
|
|
||||||
} else if (currentDeviceType == DEVICE_TREADMILL) {
|
|
||||||
status.deviceType = "Treadmill";
|
|
||||||
} else {
|
|
||||||
status.deviceType = "Not configured";
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePortalStatus(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Main Setup & Loop
|
|
||||||
// ============================================
|
|
||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(1000);
|
delay(1000);
|
||||||
@@ -342,61 +33,28 @@ void setup() {
|
|||||||
Serial.println("========================================\n");
|
Serial.println("========================================\n");
|
||||||
|
|
||||||
// Load configuration from NVS
|
// Load configuration from NVS
|
||||||
bool hasConfig = configManager.load();
|
bool hasConfig = configService.load();
|
||||||
Serial.print("Device ID: ");
|
Serial.print("Device ID: ");
|
||||||
Serial.println(configManager.getDeviceId());
|
Serial.println(configService.getDeviceId());
|
||||||
Serial.print("Configured: ");
|
Serial.print("Configured: ");
|
||||||
Serial.println(hasConfig ? "Yes" : "No");
|
Serial.println(hasConfig ? "Yes" : "No");
|
||||||
|
|
||||||
// Start WiFi (AP always on, STA if configured)
|
// Initialize all services
|
||||||
setupWiFi();
|
wifiService.setup();
|
||||||
|
webService.setup();
|
||||||
// Start web configuration portal
|
mqttService.setup();
|
||||||
setupWebPortal();
|
bleService.setup();
|
||||||
|
|
||||||
// Initialize MQTT
|
|
||||||
setupMQTT();
|
|
||||||
|
|
||||||
// Initialize BLE (don't start advertising yet)
|
|
||||||
initBLE();
|
|
||||||
|
|
||||||
Serial.println("\nReady!");
|
Serial.println("\nReady!");
|
||||||
if (!configManager.isConfigured()) {
|
if (!configService.isConfigured()) {
|
||||||
Serial.println("Configure at: http://192.168.4.1");
|
Serial.println("Configure at: http://192.168.4.1\n");
|
||||||
} else {
|
|
||||||
Serial.println("Connecting to WiFi...");
|
|
||||||
}
|
}
|
||||||
Serial.println("Waiting for MQTT commands...\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
// Handle web portal requests
|
// Run all service loops
|
||||||
handleWebPortal();
|
wifiService.loop();
|
||||||
|
webService.loop();
|
||||||
// Maintain WiFi STA connection (if configured)
|
mqttService.loop();
|
||||||
connectToWiFi();
|
bleService.loop();
|
||||||
|
|
||||||
// Maintain MQTT connection
|
|
||||||
connectToMQTT();
|
|
||||||
mqtt.loop();
|
|
||||||
|
|
||||||
// Update portal status display
|
|
||||||
updateStatus();
|
|
||||||
|
|
||||||
// Send BLE notifications
|
|
||||||
if (bleStarted && millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
|
|
||||||
lastNotify = millis();
|
|
||||||
|
|
||||||
if (currentDeviceType == DEVICE_HEART_RATE) {
|
|
||||||
notifyHeartRate(heartRate);
|
|
||||||
} else if (currentDeviceType == DEVICE_TREADMILL) {
|
|
||||||
notifyTreadmill(treadmillSpeed, treadmillIncline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Periodic status report to MQTT
|
|
||||||
if (mqttConnected && millis() - lastStatus >= STATUS_REPORT_INTERVAL) {
|
|
||||||
lastStatus = millis();
|
|
||||||
publishStatus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
381
firmware/esp32_ble_sim/src/services/ble_service.cpp
Normal file
381
firmware/esp32_ble_sim/src/services/ble_service.cpp
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
#include "services/ble_service.h"
|
||||||
|
#include "device_state.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include <NimBLEDevice.h>
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BLE Server and Characteristics
|
||||||
|
// ============================================
|
||||||
|
static NimBLEServer* pServer = nullptr;
|
||||||
|
static NimBLEAdvertising* pAdvertising = nullptr;
|
||||||
|
static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
|
||||||
|
static NimBLECharacteristic* pBatteryLevel = nullptr;
|
||||||
|
static NimBLECharacteristic* pTreadmillData = nullptr;
|
||||||
|
|
||||||
|
// Track created services for cleanup
|
||||||
|
static NimBLEService* pHeartRateService = nullptr;
|
||||||
|
static NimBLEService* pBatteryService = nullptr;
|
||||||
|
static NimBLEService* pFitnessMachineService = nullptr;
|
||||||
|
|
||||||
|
static bool bleInitialized = false;
|
||||||
|
static uint16_t currentConnId = 0; // Track connection ID for disconnect
|
||||||
|
static bool advertisingPausedFlag = false; // Shared with callbacks
|
||||||
|
|
||||||
|
// Forward declare for callback
|
||||||
|
static void onBleConnect(NimBLEServer* server);
|
||||||
|
static void onBleDisconnect();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Server Callbacks
|
||||||
|
// ============================================
|
||||||
|
class ServerCallbacks : public NimBLEServerCallbacks {
|
||||||
|
void onConnect(NimBLEServer* pServer) override {
|
||||||
|
onBleConnect(pServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDisconnect(NimBLEServer* pServer) override {
|
||||||
|
onBleDisconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
static void onBleConnect(NimBLEServer* server) {
|
||||||
|
// Track connection ID for forced disconnect
|
||||||
|
// getPeerInfo(0) gets the first (most recent) connected peer
|
||||||
|
NimBLEConnInfo peerInfo = server->getPeerInfo(0);
|
||||||
|
currentConnId = peerInfo.getConnHandle();
|
||||||
|
deviceState.setBleClientConnected(true);
|
||||||
|
Serial.print("BLE client connected (connId: ");
|
||||||
|
Serial.print(currentConnId);
|
||||||
|
Serial.println(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onBleDisconnect() {
|
||||||
|
currentConnId = 0;
|
||||||
|
deviceState.setBleClientConnected(false);
|
||||||
|
Serial.println("BLE client disconnected");
|
||||||
|
|
||||||
|
// Only auto-resume advertising if not paused
|
||||||
|
if (!advertisingPausedFlag) {
|
||||||
|
NimBLEDevice::startAdvertising();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Singleton
|
||||||
|
// ============================================
|
||||||
|
BleService& BleService::getInstance() {
|
||||||
|
static BleService instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Lifecycle
|
||||||
|
// ============================================
|
||||||
|
void BleService::setup() {
|
||||||
|
initBLE();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::loop() {
|
||||||
|
// Check if BLE should be reinitialized after teardown
|
||||||
|
if (teardownPending && teardownResumeTime > 0 && millis() >= teardownResumeTime) {
|
||||||
|
teardownResumeTime = 0;
|
||||||
|
teardownPending = false;
|
||||||
|
reinitBLE();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceState.isBleStarted()) return;
|
||||||
|
|
||||||
|
// Check if advertising should be resumed after timed pause
|
||||||
|
if (advertisingPaused && advertisingResumeTime > 0 && millis() >= advertisingResumeTime) {
|
||||||
|
advertisingResumeTime = 0;
|
||||||
|
advertisingPaused = false;
|
||||||
|
advertisingPausedFlag = false;
|
||||||
|
NimBLEDevice::startAdvertising();
|
||||||
|
Serial.println("Advertising resumed after timed pause");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notifications at regular intervals
|
||||||
|
if (millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
|
||||||
|
lastNotify = millis();
|
||||||
|
|
||||||
|
const auto& values = deviceState.getValues();
|
||||||
|
|
||||||
|
if (deviceState.getDeviceType() == DeviceType::HEART_RATE) {
|
||||||
|
notifyHeartRate(values.heartRate);
|
||||||
|
} else if (deviceState.getDeviceType() == DeviceType::TREADMILL) {
|
||||||
|
// Accumulate distance
|
||||||
|
deviceState.accumulateTreadmillDistance(BLE_NOTIFY_INTERVAL / 1000.0);
|
||||||
|
const auto& updatedValues = deviceState.getValues();
|
||||||
|
notifyTreadmill(updatedValues.treadmillSpeed,
|
||||||
|
updatedValues.treadmillIncline,
|
||||||
|
updatedValues.treadmillDistance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::initBLE() {
|
||||||
|
if (bleInitialized) return;
|
||||||
|
|
||||||
|
NimBLEDevice::init("BLE Simulator");
|
||||||
|
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
|
||||||
|
|
||||||
|
pServer = NimBLEDevice::createServer();
|
||||||
|
pServer->setCallbacks(new ServerCallbacks());
|
||||||
|
|
||||||
|
pAdvertising = NimBLEDevice::getAdvertising();
|
||||||
|
|
||||||
|
bleInitialized = true;
|
||||||
|
Serial.println("BLE initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::stop() {
|
||||||
|
if (pAdvertising) {
|
||||||
|
pAdvertising->stop();
|
||||||
|
// Clear all service UUIDs from advertising
|
||||||
|
pAdvertising->reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing services from the server
|
||||||
|
if (pServer) {
|
||||||
|
if (pHeartRateService) {
|
||||||
|
pServer->removeService(pHeartRateService);
|
||||||
|
pHeartRateService = nullptr;
|
||||||
|
}
|
||||||
|
if (pBatteryService) {
|
||||||
|
pServer->removeService(pBatteryService);
|
||||||
|
pBatteryService = nullptr;
|
||||||
|
}
|
||||||
|
if (pFitnessMachineService) {
|
||||||
|
pServer->removeService(pFitnessMachineService);
|
||||||
|
pFitnessMachineService = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear characteristic pointers
|
||||||
|
pHeartRateMeasurement = nullptr;
|
||||||
|
pBatteryLevel = nullptr;
|
||||||
|
pTreadmillData = nullptr;
|
||||||
|
|
||||||
|
Serial.println("BLE stopped and services cleaned up");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Heart Rate Service Setup
|
||||||
|
// ============================================
|
||||||
|
void BleService::setupHeartRate() {
|
||||||
|
Serial.println("Setting up Heart Rate Service...");
|
||||||
|
|
||||||
|
// Clean up any existing services first
|
||||||
|
stop();
|
||||||
|
|
||||||
|
// Create Heart Rate Service
|
||||||
|
pHeartRateService = pServer->createService(HEART_RATE_SERVICE_UUID);
|
||||||
|
|
||||||
|
pHeartRateMeasurement = pHeartRateService->createCharacteristic(
|
||||||
|
HEART_RATE_MEASUREMENT_UUID,
|
||||||
|
NIMBLE_PROPERTY::NOTIFY
|
||||||
|
);
|
||||||
|
|
||||||
|
NimBLECharacteristic* pBodySensorLocation = pHeartRateService->createCharacteristic(
|
||||||
|
BODY_SENSOR_LOCATION_UUID,
|
||||||
|
NIMBLE_PROPERTY::READ
|
||||||
|
);
|
||||||
|
uint8_t sensorLocation = 1; // Chest
|
||||||
|
pBodySensorLocation->setValue(&sensorLocation, 1);
|
||||||
|
|
||||||
|
pHeartRateService->start();
|
||||||
|
|
||||||
|
// Create Battery Service
|
||||||
|
pBatteryService = pServer->createService(BATTERY_SERVICE_UUID);
|
||||||
|
|
||||||
|
pBatteryLevel = pBatteryService->createCharacteristic(
|
||||||
|
BATTERY_LEVEL_UUID,
|
||||||
|
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
|
||||||
|
);
|
||||||
|
uint8_t initialBattery = deviceState.getValues().batteryLevel;
|
||||||
|
pBatteryLevel->setValue(&initialBattery, 1);
|
||||||
|
|
||||||
|
pBatteryService->start();
|
||||||
|
|
||||||
|
// Configure advertising
|
||||||
|
pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID);
|
||||||
|
pAdvertising->addServiceUUID(BATTERY_SERVICE_UUID);
|
||||||
|
pAdvertising->setScanResponse(true);
|
||||||
|
pAdvertising->setMinPreferred(0x06);
|
||||||
|
pAdvertising->setMaxPreferred(0x12);
|
||||||
|
|
||||||
|
NimBLEDevice::setDeviceName("HR Simulator");
|
||||||
|
NimBLEDevice::startAdvertising();
|
||||||
|
|
||||||
|
Serial.println("Heart Rate + Battery Services started, advertising...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Treadmill Service Setup
|
||||||
|
// ============================================
|
||||||
|
void BleService::setupTreadmill() {
|
||||||
|
Serial.println("Setting up Fitness Machine Service (Treadmill)...");
|
||||||
|
|
||||||
|
// Clean up any existing services first
|
||||||
|
stop();
|
||||||
|
|
||||||
|
pFitnessMachineService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID);
|
||||||
|
|
||||||
|
NimBLECharacteristic* pFeature = pFitnessMachineService->createCharacteristic(
|
||||||
|
FITNESS_MACHINE_FEATURE_UUID,
|
||||||
|
NIMBLE_PROPERTY::READ
|
||||||
|
);
|
||||||
|
|
||||||
|
uint8_t featureData[8] = {
|
||||||
|
0x0B, 0x20, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00
|
||||||
|
};
|
||||||
|
pFeature->setValue(featureData, 8);
|
||||||
|
|
||||||
|
pTreadmillData = pFitnessMachineService->createCharacteristic(
|
||||||
|
TREADMILL_DATA_UUID,
|
||||||
|
NIMBLE_PROPERTY::NOTIFY
|
||||||
|
);
|
||||||
|
|
||||||
|
pFitnessMachineService->start();
|
||||||
|
|
||||||
|
pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID);
|
||||||
|
pAdvertising->setScanResponse(true);
|
||||||
|
pAdvertising->setMinPreferred(0x06);
|
||||||
|
pAdvertising->setMaxPreferred(0x12);
|
||||||
|
|
||||||
|
NimBLEDevice::setDeviceName("Treadmill Sim");
|
||||||
|
NimBLEDevice::startAdvertising();
|
||||||
|
|
||||||
|
Serial.println("Fitness Machine Service (Treadmill) started, advertising...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Notifications
|
||||||
|
// ============================================
|
||||||
|
void BleService::notifyHeartRate(uint8_t bpm) {
|
||||||
|
if (!pHeartRateMeasurement || !deviceState.getConnectionState().bleClientConnected) return;
|
||||||
|
|
||||||
|
uint8_t hrData[2];
|
||||||
|
hrData[0] = 0x00; // Flags
|
||||||
|
hrData[1] = bpm;
|
||||||
|
|
||||||
|
pHeartRateMeasurement->setValue(hrData, 2);
|
||||||
|
pHeartRateMeasurement->notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::updateBattery(uint8_t level) {
|
||||||
|
if (!pBatteryLevel) return;
|
||||||
|
|
||||||
|
if (level > 100) level = 100;
|
||||||
|
pBatteryLevel->setValue(&level, 1);
|
||||||
|
pBatteryLevel->notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance) {
|
||||||
|
if (!pTreadmillData || !deviceState.getConnectionState().bleClientConnected) return;
|
||||||
|
|
||||||
|
uint8_t data[11];
|
||||||
|
|
||||||
|
uint16_t flags = 0x000C;
|
||||||
|
data[0] = flags & 0xFF;
|
||||||
|
data[1] = (flags >> 8) & 0xFF;
|
||||||
|
|
||||||
|
data[2] = speed & 0xFF;
|
||||||
|
data[3] = (speed >> 8) & 0xFF;
|
||||||
|
|
||||||
|
data[4] = distance & 0xFF;
|
||||||
|
data[5] = (distance >> 8) & 0xFF;
|
||||||
|
data[6] = (distance >> 16) & 0xFF;
|
||||||
|
|
||||||
|
data[7] = incline & 0xFF;
|
||||||
|
data[8] = (incline >> 8) & 0xFF;
|
||||||
|
|
||||||
|
int16_t rampAngle = 0;
|
||||||
|
data[9] = rampAngle & 0xFF;
|
||||||
|
data[10] = (rampAngle >> 8) & 0xFF;
|
||||||
|
|
||||||
|
pTreadmillData->setValue(data, 11);
|
||||||
|
pTreadmillData->notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Disconnect Simulation
|
||||||
|
// ============================================
|
||||||
|
void BleService::disconnectClient() {
|
||||||
|
if (!pServer || !deviceState.getConnectionState().bleClientConnected) {
|
||||||
|
Serial.println("No BLE client connected to disconnect");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("Forcing BLE client disconnect (immediate re-advertise)");
|
||||||
|
pServer->disconnect(currentConnId);
|
||||||
|
// onBleDisconnect callback will handle re-advertising
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::disconnectClientForDuration(int ms) {
|
||||||
|
if (!pServer || !deviceState.getConnectionState().bleClientConnected) {
|
||||||
|
Serial.println("No BLE client connected to disconnect");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.print("Forcing BLE client disconnect, pausing advertising for ");
|
||||||
|
Serial.print(ms);
|
||||||
|
Serial.println("ms");
|
||||||
|
|
||||||
|
// Set flags before disconnect so callback knows not to auto-resume
|
||||||
|
advertisingPaused = true;
|
||||||
|
advertisingPausedFlag = true;
|
||||||
|
advertisingResumeTime = millis() + ms;
|
||||||
|
|
||||||
|
pServer->disconnect(currentConnId);
|
||||||
|
// onBleDisconnect callback will NOT resume advertising due to flag
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::teardownForDuration(int ms) {
|
||||||
|
Serial.print("Tearing down BLE stack, will reinit in ");
|
||||||
|
Serial.print(ms);
|
||||||
|
Serial.println("ms");
|
||||||
|
|
||||||
|
// Clear all service/characteristic pointers
|
||||||
|
pHeartRateMeasurement = nullptr;
|
||||||
|
pBatteryLevel = nullptr;
|
||||||
|
pTreadmillData = nullptr;
|
||||||
|
pHeartRateService = nullptr;
|
||||||
|
pBatteryService = nullptr;
|
||||||
|
pFitnessMachineService = nullptr;
|
||||||
|
pServer = nullptr;
|
||||||
|
pAdvertising = nullptr;
|
||||||
|
|
||||||
|
// Full BLE deinit
|
||||||
|
NimBLEDevice::deinit(true);
|
||||||
|
bleInitialized = false;
|
||||||
|
currentConnId = 0;
|
||||||
|
deviceConnected = false;
|
||||||
|
deviceState.setBleClientConnected(false);
|
||||||
|
|
||||||
|
// Schedule reinit
|
||||||
|
teardownPending = true;
|
||||||
|
teardownResumeTime = millis() + ms;
|
||||||
|
|
||||||
|
Serial.println("BLE stack torn down - device will disappear from scans");
|
||||||
|
}
|
||||||
|
|
||||||
|
void BleService::reinitBLE() {
|
||||||
|
Serial.println("Reinitializing BLE stack after teardown...");
|
||||||
|
|
||||||
|
// Reinit the BLE stack
|
||||||
|
initBLE();
|
||||||
|
|
||||||
|
// Restore the previous device type configuration
|
||||||
|
DeviceType currentType = deviceState.getDeviceType();
|
||||||
|
if (currentType == DeviceType::HEART_RATE) {
|
||||||
|
setupHeartRate();
|
||||||
|
Serial.println("Restored Heart Rate service");
|
||||||
|
} else if (currentType == DeviceType::TREADMILL) {
|
||||||
|
setupTreadmill();
|
||||||
|
Serial.println("Restored Treadmill service");
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("BLE stack reinitialized - device visible again");
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
223
firmware/esp32_ble_sim/src/services/mqtt_service.cpp
Normal file
223
firmware/esp32_ble_sim/src/services/mqtt_service.cpp
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
#include "services/mqtt_service.h"
|
||||||
|
#include "services/config_service.h"
|
||||||
|
#include "services/wifi_service.h"
|
||||||
|
#include "services/ble_service.h"
|
||||||
|
#include "device_state.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <PubSubClient.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
// MQTT client (needs WiFiClient)
|
||||||
|
static WiFiClient wifiClient;
|
||||||
|
static PubSubClient mqtt(wifiClient);
|
||||||
|
|
||||||
|
MqttService& MqttService::getInstance() {
|
||||||
|
static MqttService instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttService::messageCallback(char* topic, byte* payload, unsigned int length) {
|
||||||
|
getInstance().handleMessage(topic, payload, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttService::setup() {
|
||||||
|
mqtt.setCallback(messageCallback);
|
||||||
|
mqtt.setBufferSize(512);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttService::loop() {
|
||||||
|
connectToMQTT();
|
||||||
|
mqtt.loop();
|
||||||
|
|
||||||
|
// Periodic status and values report
|
||||||
|
if (mqttConnected && millis() - lastStatusReport >= STATUS_REPORT_INTERVAL) {
|
||||||
|
lastStatusReport = millis();
|
||||||
|
publishStatus();
|
||||||
|
publishValues(); // Include current values (e.g., accumulated distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttService::connectToMQTT() {
|
||||||
|
if (!configService.isConfigured() || !wifiService.isConnected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mqtt.connected()) {
|
||||||
|
if (!mqttConnected) {
|
||||||
|
mqttConnected = true;
|
||||||
|
deviceState.setMqttConnected(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't spam connection attempts
|
||||||
|
if (millis() - lastMqttAttempt < MQTT_RECONNECT_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMqttAttempt = millis();
|
||||||
|
|
||||||
|
mqttConnected = false;
|
||||||
|
deviceState.setMqttConnected(false);
|
||||||
|
|
||||||
|
mqtt.setServer(
|
||||||
|
configService.getMqttHost().c_str(),
|
||||||
|
configService.getMqttPort()
|
||||||
|
);
|
||||||
|
|
||||||
|
Serial.print("Connecting to MQTT at ");
|
||||||
|
Serial.print(configService.getMqttHost());
|
||||||
|
Serial.print(":");
|
||||||
|
Serial.println(configService.getMqttPort());
|
||||||
|
|
||||||
|
String clientId = String("esp32-") + String(random(0xffff), HEX);
|
||||||
|
|
||||||
|
// LWT for disconnect detection
|
||||||
|
String statusTopic = String("ble-sim/") + configService.getDeviceId() + "/status";
|
||||||
|
String willMessage = "{\"online\":false}";
|
||||||
|
|
||||||
|
if (mqtt.connect(clientId.c_str(), statusTopic.c_str(), 1, true, willMessage.c_str())) {
|
||||||
|
mqttConnected = true;
|
||||||
|
deviceState.setMqttConnected(true);
|
||||||
|
Serial.println("MQTT connected with LWT!");
|
||||||
|
|
||||||
|
// Subscribe to control topics
|
||||||
|
String configTopic = String("ble-sim/") + configService.getDeviceId() + "/config";
|
||||||
|
String setTopic = String("ble-sim/") + configService.getDeviceId() + "/set";
|
||||||
|
String disconnectTopic = String("ble-sim/") + configService.getDeviceId() + "/disconnect";
|
||||||
|
|
||||||
|
mqtt.subscribe(configTopic.c_str());
|
||||||
|
mqtt.subscribe(setTopic.c_str());
|
||||||
|
mqtt.subscribe(disconnectTopic.c_str());
|
||||||
|
|
||||||
|
Serial.print("Subscribed to: ");
|
||||||
|
Serial.println(configTopic);
|
||||||
|
|
||||||
|
publishStatus();
|
||||||
|
} else {
|
||||||
|
Serial.print("MQTT connection failed, rc=");
|
||||||
|
Serial.println(mqtt.state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttService::handleMessage(char* topic, byte* payload, unsigned int length) {
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, payload, length);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Serial.print("JSON parse error: ");
|
||||||
|
Serial.println(error.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String topicStr = String(topic);
|
||||||
|
String baseTopic = String("ble-sim/") + configService.getDeviceId();
|
||||||
|
|
||||||
|
// Handle device type configuration
|
||||||
|
if (topicStr == baseTopic + "/config") {
|
||||||
|
String type = doc["type"] | "";
|
||||||
|
|
||||||
|
Serial.print("Configuring as: ");
|
||||||
|
Serial.println(type);
|
||||||
|
|
||||||
|
if (type == "heart_rate") {
|
||||||
|
deviceState.setDeviceType(DeviceType::HEART_RATE);
|
||||||
|
bleService.setupHeartRate();
|
||||||
|
} else if (type == "treadmill") {
|
||||||
|
deviceState.setDeviceType(DeviceType::TREADMILL);
|
||||||
|
bleService.setupTreadmill();
|
||||||
|
} else {
|
||||||
|
deviceState.setDeviceType(DeviceType::NONE);
|
||||||
|
bleService.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
publishStatus();
|
||||||
|
}
|
||||||
|
// Handle value updates
|
||||||
|
else if (topicStr == baseTopic + "/set") {
|
||||||
|
if (doc["heart_rate"].is<int>()) {
|
||||||
|
deviceState.setHeartRate(doc["heart_rate"]);
|
||||||
|
Serial.print("Heart rate set to: ");
|
||||||
|
Serial.println(deviceState.getValues().heartRate);
|
||||||
|
}
|
||||||
|
if (doc["battery"].is<int>()) {
|
||||||
|
deviceState.setBatteryLevel(doc["battery"]);
|
||||||
|
bleService.updateBattery(deviceState.getValues().batteryLevel);
|
||||||
|
Serial.print("Battery level set to: ");
|
||||||
|
Serial.println(deviceState.getValues().batteryLevel);
|
||||||
|
}
|
||||||
|
if (doc["speed"].is<float>()) {
|
||||||
|
deviceState.setTreadmillSpeed(doc["speed"]);
|
||||||
|
Serial.print("Speed set to: ");
|
||||||
|
Serial.println(doc["speed"].as<float>());
|
||||||
|
}
|
||||||
|
if (doc["incline"].is<float>()) {
|
||||||
|
deviceState.setTreadmillIncline(doc["incline"]);
|
||||||
|
Serial.print("Incline set to: ");
|
||||||
|
Serial.println(doc["incline"].as<float>());
|
||||||
|
}
|
||||||
|
if (doc["distance"].is<int>()) {
|
||||||
|
deviceState.setTreadmillDistance(doc["distance"]);
|
||||||
|
Serial.print("Distance set to: ");
|
||||||
|
Serial.println(deviceState.getValues().treadmillDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
publishValues();
|
||||||
|
}
|
||||||
|
// Handle BLE disconnect command
|
||||||
|
else if (topicStr == baseTopic + "/disconnect") {
|
||||||
|
int duration = doc["duration_ms"] | 0; // 0 = immediate resume
|
||||||
|
bool teardown = doc["teardown"] | false; // Full BLE stack teardown
|
||||||
|
|
||||||
|
if (teardown) {
|
||||||
|
// Full teardown - device disappears from scans
|
||||||
|
int teardownDuration = duration > 0 ? duration : 3000; // Default 3s for teardown
|
||||||
|
bleService.teardownForDuration(teardownDuration);
|
||||||
|
} else if (duration > 0) {
|
||||||
|
// Just disconnect and pause advertising
|
||||||
|
bleService.disconnectClientForDuration(duration);
|
||||||
|
} else {
|
||||||
|
// Just disconnect, immediate re-advertise
|
||||||
|
bleService.disconnectClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttService::publishStatus() {
|
||||||
|
if (!mqtt.connected()) return;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["online"] = true;
|
||||||
|
doc["firmware_version"] = FIRMWARE_VERSION;
|
||||||
|
doc["type"] = deviceState.getDeviceTypeString();
|
||||||
|
doc["ble_started"] = deviceState.isBleStarted();
|
||||||
|
doc["ip"] = wifiService.getIP();
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
serializeJson(doc, payload);
|
||||||
|
|
||||||
|
String topic = String("ble-sim/") + configService.getDeviceId() + "/status";
|
||||||
|
mqtt.publish(topic.c_str(), payload.c_str(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttService::publishValues() {
|
||||||
|
if (!mqtt.connected()) return;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
const auto& values = deviceState.getValues();
|
||||||
|
|
||||||
|
if (deviceState.getDeviceType() == DeviceType::HEART_RATE) {
|
||||||
|
doc["heart_rate"] = values.heartRate;
|
||||||
|
doc["battery"] = values.batteryLevel;
|
||||||
|
} else if (deviceState.getDeviceType() == DeviceType::TREADMILL) {
|
||||||
|
doc["speed"] = values.treadmillSpeed / 100.0;
|
||||||
|
doc["incline"] = values.treadmillIncline / 10.0;
|
||||||
|
doc["distance"] = values.treadmillDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
serializeJson(doc, payload);
|
||||||
|
|
||||||
|
String topic = String("ble-sim/") + configService.getDeviceId() + "/values";
|
||||||
|
mqtt.publish(topic.c_str(), payload.c_str());
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
#include "web_portal.h"
|
#include "services/web_service.h"
|
||||||
#include "config_manager.h"
|
#include "services/config_service.h"
|
||||||
|
#include "services/wifi_service.h"
|
||||||
|
#include "device_state.h"
|
||||||
#include <WebServer.h>
|
#include <WebServer.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
WebServer server(80);
|
static WebServer server(80);
|
||||||
PortalStatus currentStatus = {};
|
|
||||||
|
|
||||||
// HTML template with embedded CSS and JS
|
// HTML template with embedded CSS and JS
|
||||||
const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
||||||
@@ -78,6 +79,27 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
button:hover { background: #22c55e; }
|
button:hover { background: #22c55e; }
|
||||||
.btn-danger { background: #f87171; }
|
.btn-danger { background: #f87171; }
|
||||||
.btn-danger:hover { background: #ef4444; }
|
.btn-danger:hover { background: #ef4444; }
|
||||||
|
.btn-secondary { background: #6366f1; margin-top: 10px; }
|
||||||
|
.btn-secondary:hover { background: #4f46e5; }
|
||||||
|
.distance-value { font-size: 24px; font-weight: bold; color: #4ade80; }
|
||||||
|
.battery-value { font-size: 24px; font-weight: bold; color: #4ade80; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0f3460;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.msg { padding: 10px; border-radius: 8px; margin-bottom: 15px; }
|
.msg { padding: 10px; border-radius: 8px; margin-bottom: 15px; }
|
||||||
.msg-success { background: #064e3b; }
|
.msg-success { background: #064e3b; }
|
||||||
.msg-error { background: #7f1d1d; }
|
.msg-error { background: #7f1d1d; }
|
||||||
@@ -85,7 +107,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>BLE Simulator</h1>
|
<h1>BLE Simulator</h1>
|
||||||
<p class="subtitle" id="apName">Loading...</p>
|
<p class="subtitle"><span id="apName">Loading...</span> • UI v1.2.0</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Status</h2>
|
<h2>Status</h2>
|
||||||
@@ -107,6 +129,24 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card hidden" id="heartRateCard">
|
||||||
|
<h2>Heart Rate Monitor</h2>
|
||||||
|
<div class="status-row">
|
||||||
|
<span>Battery Level</span>
|
||||||
|
<span class="battery-value" id="batteryValue">100%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="batterySlider" min="0" max="100" value="100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card hidden" id="treadmillCard">
|
||||||
|
<h2>Treadmill</h2>
|
||||||
|
<div class="status-row">
|
||||||
|
<span>Distance</span>
|
||||||
|
<span class="distance-value" id="distance">0 m</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary" onclick="resetDistance()">Reset Distance</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>WiFi Configuration</h2>
|
<h2>WiFi Configuration</h2>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
@@ -137,7 +177,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
<script>
|
<script>
|
||||||
let configLoaded = false;
|
let configLoaded = false;
|
||||||
|
|
||||||
// Load config once on page load
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/status');
|
const res = await fetch('/api/status');
|
||||||
@@ -156,7 +195,6 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update only status indicators (not form fields)
|
|
||||||
async function updateStatus() {
|
async function updateStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/status');
|
const res = await fetch('/api/status');
|
||||||
@@ -164,6 +202,15 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
updateStatusDots(data.status);
|
updateStatusDots(data.status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to update status:', e);
|
console.error('Failed to update status:', e);
|
||||||
|
updateStatusDots({
|
||||||
|
wifiConnected: false,
|
||||||
|
mqttConnected: false,
|
||||||
|
bleStarted: false,
|
||||||
|
deviceType: 'Not configured',
|
||||||
|
ipAddress: '',
|
||||||
|
treadmillDistance: 0,
|
||||||
|
batteryLevel: 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +225,23 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
document.getElementById('bleStatus').textContent = status.bleStarted ? status.deviceType : 'Not started';
|
document.getElementById('bleStatus').textContent = status.bleStarted ? status.deviceType : 'Not started';
|
||||||
|
|
||||||
document.getElementById('ipAddr').textContent = status.ipAddress || '-';
|
document.getElementById('ipAddr').textContent = status.ipAddress || '-';
|
||||||
|
|
||||||
|
const heartRateCard = document.getElementById('heartRateCard');
|
||||||
|
if (status.deviceType === 'Heart Rate') {
|
||||||
|
heartRateCard.classList.remove('hidden');
|
||||||
|
document.getElementById('batteryValue').textContent = status.batteryLevel + '%';
|
||||||
|
document.getElementById('batterySlider').value = status.batteryLevel;
|
||||||
|
} else {
|
||||||
|
heartRateCard.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const treadmillCard = document.getElementById('treadmillCard');
|
||||||
|
if (status.deviceType === 'Treadmill') {
|
||||||
|
treadmillCard.classList.remove('hidden');
|
||||||
|
document.getElementById('distance').textContent = status.treadmillDistance + ' m';
|
||||||
|
} else {
|
||||||
|
treadmillCard.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
||||||
@@ -223,42 +287,86 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load config once, then only update status
|
async function resetDistance() {
|
||||||
loadConfig();
|
try {
|
||||||
setInterval(updateStatus, 5000);
|
await fetch('/api/reset-distance', { method: 'POST' });
|
||||||
|
document.getElementById('distance').textContent = '0 m';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to reset distance:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('batterySlider').addEventListener('input', async (e) => {
|
||||||
|
const level = e.target.value;
|
||||||
|
document.getElementById('batteryValue').textContent = level + '%';
|
||||||
|
try {
|
||||||
|
await fetch('/api/set-battery', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ level: parseInt(level) })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set battery:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
let retries = 3;
|
||||||
|
while (retries > 0) {
|
||||||
|
try {
|
||||||
|
await loadConfig();
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
retries--;
|
||||||
|
if (retries > 0) await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
setInterval(updateStatus, 3000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)rawliteral";
|
)rawliteral";
|
||||||
|
|
||||||
void handleRoot() {
|
WebService& WebService::getInstance() {
|
||||||
|
static WebService instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebService::handleRoot() {
|
||||||
server.send(200, "text/html", INDEX_HTML);
|
server.send(200, "text/html", INDEX_HTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleGetStatus() {
|
void WebService::handleGetStatus() {
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
|
|
||||||
doc["apName"] = configManager.getAPName();
|
doc["apName"] = configService.getAPName();
|
||||||
|
|
||||||
JsonObject config = doc["config"].to<JsonObject>();
|
JsonObject config = doc["config"].to<JsonObject>();
|
||||||
config["ssid"] = configManager.getWifiSsid();
|
config["ssid"] = configService.getWifiSsid();
|
||||||
config["mqttHost"] = configManager.getMqttHost();
|
config["mqttHost"] = configService.getMqttHost();
|
||||||
config["mqttPort"] = configManager.getMqttPort();
|
config["mqttPort"] = configService.getMqttPort();
|
||||||
config["deviceId"] = configManager.getDeviceId();
|
config["deviceId"] = configService.getDeviceId();
|
||||||
|
|
||||||
|
const auto& connState = deviceState.getConnectionState();
|
||||||
|
const auto& values = deviceState.getValues();
|
||||||
|
|
||||||
JsonObject status = doc["status"].to<JsonObject>();
|
JsonObject status = doc["status"].to<JsonObject>();
|
||||||
status["wifiConnected"] = currentStatus.wifiConnected;
|
status["wifiConnected"] = connState.wifiConnected;
|
||||||
status["mqttConnected"] = currentStatus.mqttConnected;
|
status["mqttConnected"] = connState.mqttConnected;
|
||||||
status["bleStarted"] = currentStatus.bleStarted;
|
status["bleStarted"] = deviceState.isBleStarted();
|
||||||
status["deviceType"] = currentStatus.deviceType;
|
status["deviceType"] = deviceState.getDeviceTypeString();
|
||||||
status["ipAddress"] = currentStatus.ipAddress;
|
status["ipAddress"] = connState.ipAddress;
|
||||||
|
status["treadmillDistance"] = values.treadmillDistance;
|
||||||
|
status["batteryLevel"] = values.batteryLevel;
|
||||||
|
|
||||||
String response;
|
String response;
|
||||||
serializeJson(doc, response);
|
serializeJson(doc, response);
|
||||||
server.send(200, "application/json", response);
|
server.send(200, "application/json", response);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handlePostConfig() {
|
void WebService::handlePostConfig() {
|
||||||
if (!server.hasArg("plain")) {
|
if (!server.hasArg("plain")) {
|
||||||
server.send(400, "application/json", "{\"error\":\"No body\"}");
|
server.send(400, "application/json", "{\"error\":\"No body\"}");
|
||||||
return;
|
return;
|
||||||
@@ -272,26 +380,25 @@ void handlePostConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
|
||||||
String ssid = doc["ssid"] | "";
|
String ssid = doc["ssid"] | "";
|
||||||
String password = doc["password"] | "";
|
String password = doc["password"] | "";
|
||||||
String mqttHost = doc["mqtt_host"] | "";
|
String mqttHost = doc["mqtt_host"] | "";
|
||||||
uint16_t mqttPort = doc["mqtt_port"] | 1883;
|
uint16_t mqttPort = doc["mqtt_port"] | 1883;
|
||||||
String deviceId = doc["device_id"] | "";
|
String deviceId = doc["device_id"] | "";
|
||||||
|
|
||||||
configManager.setWifiCredentials(ssid, password);
|
configService.setWifiCredentials(ssid, password);
|
||||||
configManager.setMqttConfig(mqttHost, mqttPort);
|
configService.setMqttConfig(mqttHost, mqttPort);
|
||||||
configManager.setDeviceId(deviceId);
|
configService.setDeviceId(deviceId);
|
||||||
configManager.save();
|
configService.save();
|
||||||
|
|
||||||
server.send(200, "application/json", "{\"success\":true}");
|
server.send(200, "application/json", "{\"success\":true}");
|
||||||
|
|
||||||
// Trigger reconnect (will be handled in main loop)
|
|
||||||
Serial.println("Configuration updated, reconnecting...");
|
Serial.println("Configuration updated, reconnecting...");
|
||||||
|
wifiService.reconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleReset() {
|
void WebService::handleReset() {
|
||||||
configManager.clear();
|
configService.clear();
|
||||||
server.send(200, "application/json", "{\"success\":true}");
|
server.send(200, "application/json", "{\"success\":true}");
|
||||||
|
|
||||||
Serial.println("Configuration reset, rebooting...");
|
Serial.println("Configuration reset, rebooting...");
|
||||||
@@ -299,20 +406,47 @@ void handleReset() {
|
|||||||
ESP.restart();
|
ESP.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupWebPortal() {
|
void WebService::handleResetDistance() {
|
||||||
server.on("/", HTTP_GET, handleRoot);
|
deviceState.resetTreadmillDistance();
|
||||||
server.on("/api/status", HTTP_GET, handleGetStatus);
|
server.send(200, "application/json", "{\"success\":true}");
|
||||||
server.on("/api/config", HTTP_POST, handlePostConfig);
|
}
|
||||||
server.on("/api/reset", HTTP_POST, handleReset);
|
|
||||||
|
void WebService::handleSetBattery() {
|
||||||
|
if (!server.hasArg("plain")) {
|
||||||
|
server.send(400, "application/json", "{\"error\":\"No body\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, server.arg("plain"));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
server.send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t level = doc["level"] | 100;
|
||||||
|
if (level > 100) level = 100;
|
||||||
|
|
||||||
|
deviceState.setBatteryLevel(level);
|
||||||
|
Serial.print("Battery level set via web UI: ");
|
||||||
|
Serial.println(level);
|
||||||
|
|
||||||
|
server.send(200, "application/json", "{\"success\":true}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebService::setup() {
|
||||||
|
server.on("/", HTTP_GET, []() { webService.handleRoot(); });
|
||||||
|
server.on("/api/status", HTTP_GET, []() { webService.handleGetStatus(); });
|
||||||
|
server.on("/api/config", HTTP_POST, []() { webService.handlePostConfig(); });
|
||||||
|
server.on("/api/reset", HTTP_POST, []() { webService.handleReset(); });
|
||||||
|
server.on("/api/reset-distance", HTTP_POST, []() { webService.handleResetDistance(); });
|
||||||
|
server.on("/api/set-battery", HTTP_POST, []() { webService.handleSetBattery(); });
|
||||||
|
|
||||||
server.begin();
|
server.begin();
|
||||||
Serial.println("Web portal started on http://192.168.4.1");
|
Serial.println("Web portal started on http://192.168.4.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleWebPortal() {
|
void WebService::loop() {
|
||||||
server.handleClient();
|
server.handleClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updatePortalStatus(const PortalStatus& status) {
|
|
||||||
currentStatus = status;
|
|
||||||
}
|
|
||||||
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,18 @@ class DeviceRegistry:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._devices: dict[str, dict[str, Any]] = {}
|
self._devices: dict[str, dict[str, Any]] = {}
|
||||||
|
# Track recently deleted devices to prevent MQTT re-registration
|
||||||
|
self._deleted_devices: set[str] = set()
|
||||||
|
|
||||||
|
def register_device(self, device_id: str, info: dict | None = None) -> bool:
|
||||||
|
"""Register a new device or update existing.
|
||||||
|
|
||||||
|
Returns False if device was recently deleted (won't be registered).
|
||||||
|
"""
|
||||||
|
# Skip recently deleted devices - prevents stale MQTT messages from re-registering
|
||||||
|
if device_id in self._deleted_devices:
|
||||||
|
return False
|
||||||
|
|
||||||
def register_device(self, device_id: str, info: dict | None = None):
|
|
||||||
"""Register a new device or update existing."""
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
if device_id not in self._devices:
|
if device_id not in self._devices:
|
||||||
@@ -30,10 +39,16 @@ class DeviceRegistry:
|
|||||||
if info:
|
if info:
|
||||||
self._devices[device_id].update(info)
|
self._devices[device_id].update(info)
|
||||||
|
|
||||||
def update_device(self, device_id: str, updates: dict):
|
return True
|
||||||
"""Update device state."""
|
|
||||||
|
def update_device(self, device_id: str, updates: dict) -> bool:
|
||||||
|
"""Update device state.
|
||||||
|
|
||||||
|
Returns False if device was recently deleted (won't be updated).
|
||||||
|
"""
|
||||||
if device_id not in self._devices:
|
if device_id not in self._devices:
|
||||||
self.register_device(device_id)
|
if not self.register_device(device_id):
|
||||||
|
return False # Device was deleted, don't update
|
||||||
|
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
if key == "values" and isinstance(value, dict):
|
if key == "values" and isinstance(value, dict):
|
||||||
@@ -45,6 +60,7 @@ class DeviceRegistry:
|
|||||||
self._devices[device_id][key] = value
|
self._devices[device_id][key] = value
|
||||||
|
|
||||||
self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat()
|
self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat()
|
||||||
|
return True
|
||||||
|
|
||||||
def mark_offline(self, device_id: str):
|
def mark_offline(self, device_id: str):
|
||||||
"""Mark a device as offline."""
|
"""Mark a device as offline."""
|
||||||
@@ -60,8 +76,22 @@ class DeviceRegistry:
|
|||||||
return list(self._devices.values())
|
return list(self._devices.values())
|
||||||
|
|
||||||
def remove_device(self, device_id: str):
|
def remove_device(self, device_id: str):
|
||||||
"""Remove a device from the registry."""
|
"""Remove a device from the registry and mark as deleted."""
|
||||||
self._devices.pop(device_id, None)
|
self._devices.pop(device_id, None)
|
||||||
|
# Track as deleted to prevent MQTT re-registration
|
||||||
|
self._deleted_devices.add(device_id)
|
||||||
|
|
||||||
|
def is_deleted(self, device_id: str) -> bool:
|
||||||
|
"""Check if a device was recently deleted."""
|
||||||
|
return device_id in self._deleted_devices
|
||||||
|
|
||||||
|
def clear_deleted(self, device_id: str):
|
||||||
|
"""Allow a deleted device to be re-registered (for manual re-add)."""
|
||||||
|
self._deleted_devices.discard(device_id)
|
||||||
|
|
||||||
|
def clear_all_deleted(self):
|
||||||
|
"""Clear all deleted device tracking (e.g., on server restart)."""
|
||||||
|
self._deleted_devices.clear()
|
||||||
|
|
||||||
|
|
||||||
# Global registry instance
|
# Global registry instance
|
||||||
|
|||||||
170
src/api/hr_variation.py
Normal file
170
src/api/hr_variation.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Heart rate variation manager - smooth realistic HR simulation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HRDeviceState:
|
||||||
|
"""State for a single device's HR variation."""
|
||||||
|
target: int = 70
|
||||||
|
float_hr: float = 70.0
|
||||||
|
phase: float = field(default_factory=lambda: random.random() * math.pi * 2)
|
||||||
|
enabled: bool = False
|
||||||
|
task: asyncio.Task | None = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
class HRVariationManager:
|
||||||
|
"""Manages smooth HR variation for multiple devices."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._devices: dict[str, HRDeviceState] = {}
|
||||||
|
self._mqtt_manager = None
|
||||||
|
self._device_registry = None
|
||||||
|
self._ws_broadcast = None
|
||||||
|
|
||||||
|
def set_dependencies(self, mqtt_manager, device_registry, ws_broadcast=None):
|
||||||
|
"""Set required dependencies after initialization."""
|
||||||
|
self._mqtt_manager = mqtt_manager
|
||||||
|
self._device_registry = device_registry
|
||||||
|
self._ws_broadcast = ws_broadcast
|
||||||
|
|
||||||
|
def get_state(self, device_id: str) -> dict:
|
||||||
|
"""Get current HR variation state for a device."""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
return {"enabled": False, "target": 70, "current": 70}
|
||||||
|
|
||||||
|
state = self._devices[device_id]
|
||||||
|
return {
|
||||||
|
"enabled": state.enabled,
|
||||||
|
"target": state.target,
|
||||||
|
"current": round(state.float_hr),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def set_target(self, device_id: str, target: int):
|
||||||
|
"""Set HR target for a device."""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
self._devices[device_id] = HRDeviceState(target=target, float_hr=float(target))
|
||||||
|
else:
|
||||||
|
self._devices[device_id].target = target
|
||||||
|
|
||||||
|
# If variation not enabled, send value directly
|
||||||
|
if not self._devices[device_id].enabled:
|
||||||
|
await self._send_hr(device_id, target)
|
||||||
|
|
||||||
|
async def enable(self, device_id: str, target: int | None = None):
|
||||||
|
"""Enable HR variation for a device."""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
initial_target = target or 70
|
||||||
|
self._devices[device_id] = HRDeviceState(
|
||||||
|
target=initial_target,
|
||||||
|
float_hr=float(initial_target)
|
||||||
|
)
|
||||||
|
elif target is not None:
|
||||||
|
self._devices[device_id].target = target
|
||||||
|
|
||||||
|
state = self._devices[device_id]
|
||||||
|
|
||||||
|
if state.enabled and state.task and not state.task.done():
|
||||||
|
return # Already running
|
||||||
|
|
||||||
|
state.enabled = True
|
||||||
|
state.task = asyncio.create_task(self._variation_loop(device_id))
|
||||||
|
|
||||||
|
async def disable(self, device_id: str):
|
||||||
|
"""Disable HR variation for a device."""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
return
|
||||||
|
|
||||||
|
state = self._devices[device_id]
|
||||||
|
state.enabled = False
|
||||||
|
|
||||||
|
if state.task and not state.task.done():
|
||||||
|
state.task.cancel()
|
||||||
|
try:
|
||||||
|
await state.task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
state.task = None
|
||||||
|
|
||||||
|
async def stop_all(self):
|
||||||
|
"""Stop all variation tasks (for shutdown)."""
|
||||||
|
for device_id in list(self._devices.keys()):
|
||||||
|
await self.disable(device_id)
|
||||||
|
|
||||||
|
async def _variation_loop(self, device_id: str):
|
||||||
|
"""Run smooth HR variation for a device."""
|
||||||
|
state = self._devices[device_id]
|
||||||
|
last_sent = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while state.enabled:
|
||||||
|
# Advance phase (completes cycle in ~30 seconds)
|
||||||
|
state.phase += 0.2
|
||||||
|
|
||||||
|
# Smooth sinusoidal base variation (±2 BPM)
|
||||||
|
sine_variation = math.sin(state.phase) * 2
|
||||||
|
|
||||||
|
# Small random walk component
|
||||||
|
random_walk = (random.random() - 0.5) * 0.6
|
||||||
|
|
||||||
|
# Calculate ideal HR
|
||||||
|
ideal_hr = state.target + sine_variation + random_walk
|
||||||
|
|
||||||
|
# Smooth transition toward ideal
|
||||||
|
# Use rate-limited step when far from target for smooth ramp
|
||||||
|
distance = abs(ideal_hr - state.float_hr)
|
||||||
|
if distance > 3:
|
||||||
|
# Far from target: move max 1 BPM per tick (2 BPM/sec at 0.5s ticks)
|
||||||
|
step = 1.0 if ideal_hr > state.float_hr else -1.0
|
||||||
|
state.float_hr += step
|
||||||
|
else:
|
||||||
|
# Close to target: use proportional smoothing
|
||||||
|
state.float_hr += (ideal_hr - state.float_hr) * 0.2
|
||||||
|
|
||||||
|
# Round for transmission
|
||||||
|
current_hr = round(max(30, min(220, state.float_hr)))
|
||||||
|
|
||||||
|
# Only send if changed
|
||||||
|
if current_hr != last_sent:
|
||||||
|
last_sent = current_hr
|
||||||
|
await self._send_hr(device_id, current_hr)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _send_hr(self, device_id: str, hr: int):
|
||||||
|
"""Send HR value to device via MQTT and update registry."""
|
||||||
|
# Only send HR to devices configured as heart_rate type
|
||||||
|
if self._device_registry:
|
||||||
|
device = self._device_registry.get_device(device_id)
|
||||||
|
if device and device.get("type") != "heart_rate":
|
||||||
|
# Device is not a heart rate monitor, disable variation
|
||||||
|
if device_id in self._devices and self._devices[device_id].enabled:
|
||||||
|
self._devices[device_id].enabled = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._mqtt_manager and self._mqtt_manager.is_connected:
|
||||||
|
await self._mqtt_manager.publish(
|
||||||
|
f"ble-sim/{device_id}/set",
|
||||||
|
{"heart_rate": hr}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._device_registry:
|
||||||
|
self._device_registry.update_device(device_id, {"values": {"heart_rate": hr}})
|
||||||
|
|
||||||
|
# Broadcast to WebSocket clients
|
||||||
|
if self._ws_broadcast:
|
||||||
|
await self._ws_broadcast({
|
||||||
|
"type": "hr_update",
|
||||||
|
"device_id": device_id,
|
||||||
|
"heart_rate": hr
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
hr_variation_manager = HRVariationManager()
|
||||||
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
# Version info
|
# Version info
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.4.0"
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -14,6 +14,7 @@ import os
|
|||||||
from .routes import router
|
from .routes import router
|
||||||
from .mqtt_client import mqtt_manager
|
from .mqtt_client import mqtt_manager
|
||||||
from .device_registry import device_registry
|
from .device_registry import device_registry
|
||||||
|
from .hr_variation import hr_variation_manager
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
@@ -55,13 +56,17 @@ async def handle_device_status(topic: str, payload: str):
|
|||||||
device_id = parts[1]
|
device_id = parts[1]
|
||||||
try:
|
try:
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
device_registry.register_device(device_id)
|
# Skip if device was recently deleted (prevents stale MQTT messages from re-registering)
|
||||||
|
if not device_registry.register_device(device_id):
|
||||||
|
print(f"Ignoring status from deleted device: {device_id}")
|
||||||
|
return
|
||||||
update_data = {
|
update_data = {
|
||||||
"online": data.get("online", True),
|
"online": data.get("online", True),
|
||||||
"type": data.get("type"),
|
"type": data.get("type"),
|
||||||
"ble_started": data.get("ble_started", False),
|
"ble_started": data.get("ble_started", False),
|
||||||
"ip": data.get("ip"),
|
"ip": data.get("ip"),
|
||||||
"firmware_version": data.get("firmware_version"),
|
"firmware_version": data.get("firmware_version"),
|
||||||
|
"bt_mac": data.get("bt_mac"),
|
||||||
}
|
}
|
||||||
device_registry.update_device(device_id, update_data)
|
device_registry.update_device(device_id, update_data)
|
||||||
print(f"Device {device_id} status updated: {data}")
|
print(f"Device {device_id} status updated: {data}")
|
||||||
@@ -83,7 +88,10 @@ async def handle_device_values(topic: str, payload: str):
|
|||||||
device_id = parts[1]
|
device_id = parts[1]
|
||||||
try:
|
try:
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
device_registry.update_device(device_id, {"values": data})
|
# Skip if device was recently deleted
|
||||||
|
if not device_registry.update_device(device_id, {"values": data}):
|
||||||
|
print(f"Ignoring values from deleted device: {device_id}")
|
||||||
|
return
|
||||||
print(f"Device {device_id} values updated: {data}")
|
print(f"Device {device_id} values updated: {data}")
|
||||||
# Broadcast to WebSocket clients
|
# Broadcast to WebSocket clients
|
||||||
await ws_manager.broadcast({
|
await ws_manager.broadcast({
|
||||||
@@ -97,9 +105,16 @@ async def handle_device_values(topic: str, payload: str):
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Manage application lifecycle - connect/disconnect MQTT."""
|
"""Manage application lifecycle - connect/disconnect MQTT and HR variation."""
|
||||||
await mqtt_manager.connect()
|
await mqtt_manager.connect()
|
||||||
|
# Wire up HR variation manager dependencies
|
||||||
|
hr_variation_manager.set_dependencies(
|
||||||
|
mqtt_manager=mqtt_manager,
|
||||||
|
device_registry=device_registry,
|
||||||
|
ws_broadcast=ws_manager.broadcast
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
|
await hr_variation_manager.stop_all()
|
||||||
await mqtt_manager.disconnect()
|
await mqtt_manager.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class MQTTManager:
|
|||||||
return func
|
return func
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
async def publish(self, topic: str, payload: dict | str):
|
async def publish(self, topic: str, payload: dict | str, retain: bool = False):
|
||||||
"""Publish a message to MQTT."""
|
"""Publish a message to MQTT."""
|
||||||
if not self._connected or not self.client:
|
if not self._connected or not self.client:
|
||||||
raise RuntimeError("MQTT not connected")
|
raise RuntimeError("MQTT not connected")
|
||||||
@@ -110,7 +110,13 @@ class MQTTManager:
|
|||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
payload = json.dumps(payload)
|
payload = json.dumps(payload)
|
||||||
|
|
||||||
await self.client.publish(topic, payload)
|
await self.client.publish(topic, payload, retain=retain)
|
||||||
|
|
||||||
|
async def clear_retained(self, topic: str):
|
||||||
|
"""Clear a retained message by publishing empty payload with retain flag."""
|
||||||
|
if not self._connected or not self.client:
|
||||||
|
return
|
||||||
|
await self.client.publish(topic, "", retain=True)
|
||||||
|
|
||||||
async def configure_device(self, device_id: str, device_type: str):
|
async def configure_device(self, device_id: str, device_type: str):
|
||||||
"""Send configuration command to a device."""
|
"""Send configuration command to a device."""
|
||||||
@@ -126,6 +132,21 @@ class MQTTManager:
|
|||||||
values
|
values
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def disconnect_ble(self, device_id: str, duration_ms: int = 0, teardown: bool = False):
|
||||||
|
"""Trigger BLE disconnect on a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: The ESP32 device ID
|
||||||
|
duration_ms: How long to pause advertising after disconnect (0 = immediate resume)
|
||||||
|
teardown: If True, fully tear down BLE stack (device disappears from scans)
|
||||||
|
"""
|
||||||
|
payload = {}
|
||||||
|
if duration_ms > 0:
|
||||||
|
payload["duration_ms"] = duration_ms
|
||||||
|
if teardown:
|
||||||
|
payload["teardown"] = True
|
||||||
|
await self.publish(f"ble-sim/{device_id}/disconnect", payload)
|
||||||
|
|
||||||
|
|
||||||
# Global MQTT manager instance
|
# Global MQTT manager instance
|
||||||
mqtt_manager = MQTTManager()
|
mqtt_manager = MQTTManager()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""API routes for device control."""
|
"""API routes for device control."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .mqtt_client import mqtt_manager
|
from .mqtt_client import mqtt_manager
|
||||||
from .device_registry import device_registry
|
from .device_registry import device_registry
|
||||||
|
from .hr_variation import hr_variation_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -16,11 +17,25 @@ class DeviceConfig(BaseModel):
|
|||||||
|
|
||||||
class DeviceValues(BaseModel):
|
class DeviceValues(BaseModel):
|
||||||
"""Device values update request."""
|
"""Device values update request."""
|
||||||
heart_rate: int | None = None
|
heart_rate: int | None = Field(None, ge=30, le=220) # Valid BPM range
|
||||||
speed: float | None = None # km/h
|
speed: float | None = Field(None, ge=0, le=50) # km/h, max 50 km/h
|
||||||
incline: float | None = None # percent
|
incline: float | None = Field(None, ge=-10, le=40) # percent
|
||||||
cadence: int | None = None # rpm
|
cadence: int | None = Field(None, ge=0, le=300) # rpm
|
||||||
power: int | None = None # watts
|
power: int | None = Field(None, ge=0, le=2000) # watts
|
||||||
|
battery: int | None = Field(None, ge=0, le=100) # battery percentage
|
||||||
|
distance: int | None = Field(None, ge=0) # distance in meters
|
||||||
|
|
||||||
|
|
||||||
|
class HRVariationConfig(BaseModel):
|
||||||
|
"""HR variation configuration."""
|
||||||
|
enabled: bool
|
||||||
|
target: int | None = Field(None, ge=30, le=220)
|
||||||
|
|
||||||
|
|
||||||
|
class BLEDisconnectRequest(BaseModel):
|
||||||
|
"""BLE disconnect request."""
|
||||||
|
duration_ms: int = Field(0, ge=0, le=60000) # Max 60 seconds
|
||||||
|
teardown: bool = Field(False, description="Full BLE stack teardown (device disappears from scans)")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices")
|
@router.get("/devices")
|
||||||
@@ -40,6 +55,40 @@ async def get_device(device_id: str):
|
|||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/devices/{device_id}")
|
||||||
|
async def delete_device(device_id: str):
|
||||||
|
"""Remove a device from the registry and clear its retained MQTT messages."""
|
||||||
|
device = device_registry.get_device(device_id)
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
|
||||||
|
# Stop any HR variation for this device
|
||||||
|
await hr_variation_manager.disable(device_id)
|
||||||
|
|
||||||
|
# Clear retained MQTT messages so device doesn't reappear on restart
|
||||||
|
try:
|
||||||
|
await mqtt_manager.clear_retained(f"ble-sim/{device_id}/status")
|
||||||
|
await mqtt_manager.clear_retained(f"ble-sim/{device_id}/values")
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error but continue with deletion
|
||||||
|
print(f"Warning: Failed to clear MQTT retained messages for {device_id}: {e}")
|
||||||
|
|
||||||
|
# Remove from registry (also marks as deleted to block re-registration)
|
||||||
|
device_registry.remove_device(device_id)
|
||||||
|
|
||||||
|
# Broadcast deletion to all WebSocket clients so they remove the device card
|
||||||
|
try:
|
||||||
|
from .main import ws_manager
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
"type": "device_deleted",
|
||||||
|
"device_id": device_id
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to broadcast device deletion: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok", "device_id": device_id, "message": "Device removed"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{device_id}/configure")
|
@router.post("/devices/{device_id}/configure")
|
||||||
async def configure_device(device_id: str, config: DeviceConfig):
|
async def configure_device(device_id: str, config: DeviceConfig):
|
||||||
"""Configure a device's type."""
|
"""Configure a device's type."""
|
||||||
@@ -67,3 +116,64 @@ async def set_device_values(device_id: str, values: DeviceValues):
|
|||||||
device_registry.update_device(device_id, {"values": values_dict})
|
device_registry.update_device(device_id, {"values": values_dict})
|
||||||
|
|
||||||
return {"status": "ok", "device_id": device_id, "values": values_dict}
|
return {"status": "ok", "device_id": device_id, "values": values_dict}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/disconnect")
|
||||||
|
async def disconnect_ble(device_id: str, request: BLEDisconnectRequest = BLEDisconnectRequest()):
|
||||||
|
"""Simulate a BLE disconnect.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: The ESP32 device ID
|
||||||
|
request: Optional duration_ms and teardown parameters
|
||||||
|
"""
|
||||||
|
if not mqtt_manager.is_connected:
|
||||||
|
raise HTTPException(status_code=503, detail="MQTT not connected")
|
||||||
|
|
||||||
|
await mqtt_manager.disconnect_ble(device_id, request.duration_ms, request.teardown)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"device_id": device_id,
|
||||||
|
"duration_ms": request.duration_ms,
|
||||||
|
"teardown": request.teardown
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{device_id}/hr-variation")
|
||||||
|
async def get_hr_variation(device_id: str):
|
||||||
|
"""Get HR variation state for a device."""
|
||||||
|
return hr_variation_manager.get_state(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/hr-variation")
|
||||||
|
async def set_hr_variation(device_id: str, config: HRVariationConfig):
|
||||||
|
"""Enable or disable HR variation for a device."""
|
||||||
|
if config.enabled:
|
||||||
|
await hr_variation_manager.enable(device_id, config.target)
|
||||||
|
else:
|
||||||
|
await hr_variation_manager.disable(device_id)
|
||||||
|
# If target provided, set it directly
|
||||||
|
if config.target is not None:
|
||||||
|
await hr_variation_manager.set_target(device_id, config.target)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"device_id": device_id,
|
||||||
|
**hr_variation_manager.get_state(device_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HRTargetRequest(BaseModel):
|
||||||
|
"""HR target request."""
|
||||||
|
target: int = Field(ge=30, le=220)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/hr-target")
|
||||||
|
async def set_hr_target(device_id: str, request: HRTargetRequest):
|
||||||
|
"""Set HR target (works with or without variation enabled)."""
|
||||||
|
await hr_variation_manager.set_target(device_id, request.target)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"device_id": device_id,
|
||||||
|
**hr_variation_manager.get_state(device_id)
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,6 +152,32 @@ async def list_tools() -> list[Tool]:
|
|||||||
"required": ["device_id"],
|
"required": ["device_id"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
Tool(
|
||||||
|
name="simulate_ble_disconnect",
|
||||||
|
description="Simulate a BLE client disconnection to test reconnection behavior",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ESP32 device ID",
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 60000,
|
||||||
|
"default": 0,
|
||||||
|
"description": "How long to pause advertising after disconnect (0 = immediate resume)",
|
||||||
|
},
|
||||||
|
"teardown": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Full BLE stack teardown - device completely disappears from scans",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["device_id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -208,6 +234,13 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
device_id = arguments["device_id"]
|
device_id = arguments["device_id"]
|
||||||
bpm = arguments["bpm"]
|
bpm = arguments["bpm"]
|
||||||
|
|
||||||
|
# Validate BPM range
|
||||||
|
if not (30 <= bpm <= 220):
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Error: BPM must be between 30 and 220, got {bpm}"
|
||||||
|
)]
|
||||||
|
|
||||||
await mqtt_client.publish(
|
await mqtt_client.publish(
|
||||||
f"ble-sim/{device_id}/set",
|
f"ble-sim/{device_id}/set",
|
||||||
json.dumps({"heart_rate": bpm})
|
json.dumps({"heart_rate": bpm})
|
||||||
@@ -280,6 +313,38 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
text=f"Device {device_id} not found"
|
text=f"Device {device_id} not found"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
elif name == "simulate_ble_disconnect":
|
||||||
|
device_id = arguments["device_id"]
|
||||||
|
duration_ms = arguments.get("duration_ms", 0)
|
||||||
|
teardown = arguments.get("teardown", False)
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
if duration_ms > 0:
|
||||||
|
payload["duration_ms"] = duration_ms
|
||||||
|
if teardown:
|
||||||
|
payload["teardown"] = True
|
||||||
|
|
||||||
|
await mqtt_client.publish(
|
||||||
|
f"ble-sim/{device_id}/disconnect",
|
||||||
|
json.dumps(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
if teardown:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"BLE stack teardown on {device_id}, will reinit in {duration_ms if duration_ms > 0 else 3000}ms"
|
||||||
|
)]
|
||||||
|
elif duration_ms > 0:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Disconnected BLE on {device_id}, advertising paused for {duration_ms}ms"
|
||||||
|
)]
|
||||||
|
else:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"Disconnected BLE on {device_id}, advertising resumed immediately"
|
||||||
|
)]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return [TextContent(
|
return [TextContent(
|
||||||
type="text",
|
type="text",
|
||||||
|
|||||||
@@ -258,6 +258,65 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-mac {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
background: var(--error);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BLE Test controls */
|
||||||
|
.ble-tests {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ble-tests label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ble-test-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ble-test-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.ble-test-btn:hover { background: #1a4a7a; }
|
||||||
|
.ble-test-btn.warning {
|
||||||
|
background: var(--warning);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
.ble-test-btn.warning:hover { opacity: 0.9; }
|
||||||
|
|
||||||
/* WebSocket connection status */
|
/* WebSocket connection status */
|
||||||
.connection-status {
|
.connection-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -289,7 +348,7 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div>
|
||||||
<h1>pyBTMCP</h1>
|
<h1>pyBTMCP</h1>
|
||||||
<p class="subtitle">BLE Device Simulator <span id="backendVersion"></span></p>
|
<p class="subtitle">BLE Device Simulator • UI v1.5.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 +483,40 @@
|
|||||||
} else if (message.type === 'device_update') {
|
} else if (message.type === 'device_update') {
|
||||||
// Single device updated - update UI efficiently
|
// Single device updated - update UI efficiently
|
||||||
updateDeviceInUI(message.device_id, message.data);
|
updateDeviceInUI(message.device_id, message.data);
|
||||||
|
} else if (message.type === 'device_deleted') {
|
||||||
|
// Device was deleted - remove from UI
|
||||||
|
removeDeviceFromUI(message.device_id);
|
||||||
|
} else if (message.type === 'hr_update') {
|
||||||
|
// HR value update from server-side variation
|
||||||
|
updateHrDisplay(message.device_id, message.heart_rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDeviceFromUI(deviceId) {
|
||||||
|
const container = document.getElementById('devices');
|
||||||
|
const card = container.querySelector(`[data-id="${deviceId}"]`);
|
||||||
|
if (card) {
|
||||||
|
card.remove();
|
||||||
|
console.log(`Removed deleted device card: ${deviceId}`);
|
||||||
|
}
|
||||||
|
// Clean up any pending timers for this device
|
||||||
|
if (hrTargetDebounceTimers[deviceId]) {
|
||||||
|
clearTimeout(hrTargetDebounceTimers[deviceId]);
|
||||||
|
delete hrTargetDebounceTimers[deviceId];
|
||||||
|
}
|
||||||
|
// Clean up any pending value update timers
|
||||||
|
Object.keys(updateTimeouts).forEach(key => {
|
||||||
|
if (key.startsWith(deviceId + '-')) {
|
||||||
|
clearTimeout(updateTimeouts[key]);
|
||||||
|
delete updateTimeouts[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHrDisplay(deviceId, hr) {
|
||||||
|
const display = document.getElementById(`hr-display-${deviceId}`);
|
||||||
|
if (display) {
|
||||||
|
display.textContent = `${hr} BPM`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,18 +542,39 @@
|
|||||||
if (dot) dot.className = `status-dot ${deviceData.online ? '' : 'offline'}`;
|
if (dot) dot.className = `status-dot ${deviceData.online ? '' : 'offline'}`;
|
||||||
if (text) text.textContent = deviceData.online ? 'Online' : 'Offline';
|
if (text) text.textContent = deviceData.online ? 'Online' : 'Offline';
|
||||||
|
|
||||||
// Update IP and version if present
|
// Update IP, MAC, and version if present
|
||||||
const infoDiv = existingCard.querySelector('.device-info');
|
const infoDiv = existingCard.querySelector('.device-info');
|
||||||
if (infoDiv) {
|
if (infoDiv) {
|
||||||
let infoHtml = '';
|
let infoHtml = '';
|
||||||
if (deviceData.ip) {
|
if (deviceData.ip) {
|
||||||
infoHtml += `<a href="http://${deviceData.ip}" target="_blank" class="device-link">Admin: ${deviceData.ip}</a>`;
|
infoHtml += `<a href="http://${deviceData.ip}" target="_blank" class="device-link">Admin: ${deviceData.ip}</a>`;
|
||||||
}
|
}
|
||||||
|
if (deviceData.bt_mac) {
|
||||||
|
infoHtml += `<span class="device-mac">BT: ${deviceData.bt_mac}</span>`;
|
||||||
|
}
|
||||||
if (deviceData.firmware_version) {
|
if (deviceData.firmware_version) {
|
||||||
infoHtml += `<span class="device-version">v${deviceData.firmware_version}</span>`;
|
infoHtml += `<span class="device-version">v${deviceData.firmware_version}</span>`;
|
||||||
}
|
}
|
||||||
infoDiv.innerHTML = infoHtml;
|
infoDiv.innerHTML = infoHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update values if present
|
||||||
|
const values = deviceData.values || {};
|
||||||
|
if (values.heart_rate !== undefined) {
|
||||||
|
updateHrDisplay(deviceId, values.heart_rate);
|
||||||
|
}
|
||||||
|
if (values.speed !== undefined) {
|
||||||
|
const speedDisplay = document.getElementById(`speed-display-${deviceId}`);
|
||||||
|
if (speedDisplay) speedDisplay.textContent = formatSpeed(values.speed);
|
||||||
|
}
|
||||||
|
if (values.distance !== undefined) {
|
||||||
|
const distDisplay = document.getElementById(`distance-display-${deviceId}`);
|
||||||
|
if (distDisplay) distDisplay.textContent = `${values.distance} m`;
|
||||||
|
}
|
||||||
|
if (values.battery !== undefined) {
|
||||||
|
const battDisplay = document.getElementById(`battery-display-${deviceId}`);
|
||||||
|
if (battDisplay) battDisplay.textContent = `${values.battery}%`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings (persisted to localStorage)
|
// Settings (persisted to localStorage)
|
||||||
@@ -469,11 +583,8 @@
|
|||||||
hrVariation: true
|
hrVariation: true
|
||||||
};
|
};
|
||||||
|
|
||||||
// HR variation state per device
|
// Track active devices (for UI state only - business logic is server-side)
|
||||||
let hrVariationIntervals = {};
|
let activeDevices = new Set();
|
||||||
let currentHrTargets = {};
|
|
||||||
let currentHrValues = {};
|
|
||||||
let activeDevices = new Set(); // Track devices being actively controlled
|
|
||||||
|
|
||||||
// Treadmill presets (stored in metric - km/h)
|
// Treadmill presets (stored in metric - km/h)
|
||||||
const treadmillPresets = [
|
const treadmillPresets = [
|
||||||
@@ -515,18 +626,25 @@
|
|||||||
loadDevices(); // Re-render with new units
|
loadDevices(); // Re-render with new units
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHrVariation() {
|
async function toggleHrVariation() {
|
||||||
settings.hrVariation = document.getElementById('hrVariationToggle').checked;
|
settings.hrVariation = document.getElementById('hrVariationToggle').checked;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
updateSettingsDisplay();
|
updateSettingsDisplay();
|
||||||
|
|
||||||
// Stop or start variation for all HR devices
|
// Update all HR devices via API
|
||||||
Object.keys(hrVariationIntervals).forEach(deviceId => {
|
const devices = document.querySelectorAll('.device-card[data-type="heart_rate"]');
|
||||||
if (!settings.hrVariation) {
|
for (const card of devices) {
|
||||||
clearInterval(hrVariationIntervals[deviceId]);
|
const deviceId = card.dataset.id;
|
||||||
delete hrVariationIntervals[deviceId];
|
try {
|
||||||
}
|
await fetch(`${API_BASE}/devices/${deviceId}/hr-variation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: settings.hrVariation })
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle HR variation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSettingsDisplay() {
|
function updateSettingsDisplay() {
|
||||||
@@ -605,11 +723,13 @@
|
|||||||
<div class="device-status">
|
<div class="device-status">
|
||||||
<span class="status-dot ${device.online ? '' : 'offline'}"></span>
|
<span class="status-dot ${device.online ? '' : 'offline'}"></span>
|
||||||
<span class="status-text">${device.online ? 'Online' : 'Offline'}</span>
|
<span class="status-text">${device.online ? 'Online' : 'Offline'}</span>
|
||||||
|
<button class="delete-btn" onclick="deleteDevice('${device.id}')" title="Remove device">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="device-info">
|
<div class="device-info">
|
||||||
${device.ip ? `<a href="http://${device.ip}" target="_blank" class="device-link">Admin: ${device.ip}</a>` : ''}
|
${device.ip ? `<a href="http://${device.ip}" target="_blank" class="device-link">Admin: ${device.ip}</a>` : ''}
|
||||||
|
${device.bt_mac ? `<span class="device-mac">BT: ${device.bt_mac}</span>` : ''}
|
||||||
${device.firmware_version ? `<span class="device-version">v${device.firmware_version}</span>` : ''}
|
${device.firmware_version ? `<span class="device-version">v${device.firmware_version}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -623,15 +743,26 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${device.type ? `
|
||||||
|
<div class="ble-tests">
|
||||||
|
<label>BLE Connection Tests</label>
|
||||||
|
<div class="ble-test-buttons">
|
||||||
|
<button class="ble-test-btn" onclick="disconnectBle('${device.id}', 0, false)" title="Disconnect client, immediately resume advertising">Quick Drop</button>
|
||||||
|
<button class="ble-test-btn" onclick="disconnectBle('${device.id}', 5000, false)" title="Disconnect and pause advertising for 5 seconds">5s Pause</button>
|
||||||
|
<button class="ble-test-btn warning" onclick="disconnectBle('${device.id}', 3000, true)" title="Full BLE teardown - device disappears from scans for 3 seconds">BLE Restart</button>
|
||||||
|
<button class="ble-test-btn warning" onclick="disconnectBle('${device.id}', 10000, true)" title="Full BLE teardown - device disappears from scans for 10 seconds">10s Restart</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
${renderControls(device)}
|
${renderControls(device)}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Setup HR variation for new HR devices
|
// Enable HR variation for new HR devices if setting is on
|
||||||
devices.forEach(device => {
|
devices.forEach(device => {
|
||||||
if (device.type === 'heart_rate' && settings.hrVariation && !hrVariationIntervals[device.id]) {
|
if (device.type === 'heart_rate' && settings.hrVariation) {
|
||||||
const startHr = currentHrValues[device.id] || device.values?.heart_rate || 70;
|
enableHrVariationForDevice(device.id, device.values?.heart_rate || 70);
|
||||||
setupHrVariation(device.id, startHr);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -653,28 +784,30 @@
|
|||||||
const values = device.values || {};
|
const values = device.values || {};
|
||||||
|
|
||||||
if (device.type === 'heart_rate') {
|
if (device.type === 'heart_rate') {
|
||||||
const hr = currentHrValues[device.id] || values.heart_rate || 70;
|
const hr = values.heart_rate || 70;
|
||||||
if (!currentHrTargets[device.id]) currentHrTargets[device.id] = hr;
|
|
||||||
if (!currentHrValues[device.id]) currentHrValues[device.id] = hr;
|
|
||||||
return `
|
return `
|
||||||
<div class="presets">
|
<div class="presets">
|
||||||
${hrPresets.map(p => `
|
${hrPresets.map(p => `
|
||||||
<button class="preset-btn"
|
<button class="preset-btn"
|
||||||
onclick="applyHrPresetGradual('${device.id}', ${p.hr})"
|
onclick="setHrTarget('${device.id}', ${p.hr})">${p.name}</button>
|
||||||
ondblclick="applyHrPresetImmediate('${device.id}', ${p.hr})">${p.name}</button>
|
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 1rem;">
|
|
||||||
Click = gradual transition | Double-click = instant
|
|
||||||
</p>
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label>Heart Rate Target</label>
|
<label>Heart Rate Target</label>
|
||||||
<div class="slider-container">
|
<div class="slider-container">
|
||||||
<input type="range" min="30" max="220" value="${currentHrTargets[device.id]}"
|
<input type="range" min="30" max="220" value="${hr}"
|
||||||
oninput="updateHrTarget('${device.id}', this.value)">
|
oninput="setHrTargetDebounced('${device.id}', this.value)">
|
||||||
<span class="value-display" id="hr-display-${device.id}">${hr} BPM</span>
|
<span class="value-display" id="hr-display-${device.id}">${hr} BPM</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Battery Level</label>
|
||||||
|
<div class="slider-container">
|
||||||
|
<input type="range" min="0" max="100" value="${values.battery || 100}"
|
||||||
|
oninput="updateBattery('${device.id}', this.value)">
|
||||||
|
<span class="value-display" id="battery-display-${device.id}">${values.battery || 100}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,6 +838,13 @@
|
|||||||
<span class="value-display" id="incline-display-${device.id}">${incline}%</span>
|
<span class="value-display" id="incline-display-${device.id}">${incline}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Distance</label>
|
||||||
|
<div class="slider-container" style="justify-content: space-between;">
|
||||||
|
<span class="value-display" id="distance-display-${device.id}">${values.distance || 0} m</span>
|
||||||
|
<button class="preset-btn" onclick="resetDistance('${device.id}')" style="margin: 0; padding: 0.25rem 0.75rem;">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,122 +872,53 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// HR variation - smooth wandering around target
|
// HR control functions - business logic is server-side
|
||||||
function setupHrVariation(deviceId, targetHr) {
|
const hrTargetDebounceTimers = {};
|
||||||
if (hrVariationIntervals[deviceId]) {
|
|
||||||
clearInterval(hrVariationIntervals[deviceId]);
|
|
||||||
}
|
|
||||||
if (!settings.hrVariation) return;
|
|
||||||
|
|
||||||
currentHrTargets[deviceId] = targetHr;
|
async function enableHrVariationForDevice(deviceId, startHr) {
|
||||||
// Use existing value if we have one, otherwise start at target
|
|
||||||
if (!currentHrValues[deviceId]) {
|
|
||||||
currentHrValues[deviceId] = targetHr;
|
|
||||||
}
|
|
||||||
let trend = 0; // -1, 0, or 1
|
|
||||||
|
|
||||||
hrVariationIntervals[deviceId] = setInterval(() => {
|
|
||||||
const target = currentHrTargets[deviceId];
|
|
||||||
let currentHr = currentHrValues[deviceId];
|
|
||||||
|
|
||||||
// Smoothly drift toward target with small random variation
|
|
||||||
const diff = target - currentHr;
|
|
||||||
|
|
||||||
// Change trend occasionally
|
|
||||||
if (Math.random() < 0.3) {
|
|
||||||
trend = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply small change (max ±1 BPM per interval)
|
|
||||||
let change = 0;
|
|
||||||
if (Math.abs(diff) > 3) {
|
|
||||||
// Too far from target, move toward it
|
|
||||||
change = diff > 0 ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
// Near target, wander slightly
|
|
||||||
change = trend * (Math.random() < 0.5 ? 1 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentHr = Math.round(Math.max(30, Math.min(220, currentHr + change)));
|
|
||||||
currentHrValues[deviceId] = currentHr;
|
|
||||||
|
|
||||||
// Send to device
|
|
||||||
sendHrValue(deviceId, currentHr);
|
|
||||||
|
|
||||||
// Update display
|
|
||||||
const display = document.getElementById(`hr-display-${deviceId}`);
|
|
||||||
if (display) {
|
|
||||||
display.textContent = `${currentHr} BPM`;
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gradual transition - just change target, let variation do the work
|
|
||||||
function applyHrPresetGradual(deviceId, targetHr) {
|
|
||||||
currentHrTargets[deviceId] = targetHr;
|
|
||||||
|
|
||||||
// Update slider to show target
|
|
||||||
const card = document.querySelector(`[data-id="${deviceId}"]`);
|
|
||||||
const slider = card?.querySelector('input[type="range"]');
|
|
||||||
if (slider) slider.value = targetHr;
|
|
||||||
|
|
||||||
// Ensure variation is running
|
|
||||||
if (!hrVariationIntervals[deviceId] && settings.hrVariation) {
|
|
||||||
const startHr = currentHrValues[deviceId] || targetHr;
|
|
||||||
setupHrVariation(deviceId, startHr);
|
|
||||||
currentHrTargets[deviceId] = targetHr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If variation is off, just send the value
|
|
||||||
if (!settings.hrVariation) {
|
|
||||||
currentHrValues[deviceId] = targetHr;
|
|
||||||
sendHrValue(deviceId, targetHr);
|
|
||||||
const display = document.getElementById(`hr-display-${deviceId}`);
|
|
||||||
if (display) display.textContent = `${targetHr} BPM`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immediate jump - set both current and target immediately
|
|
||||||
function applyHrPresetImmediate(deviceId, targetHr) {
|
|
||||||
currentHrTargets[deviceId] = targetHr;
|
|
||||||
currentHrValues[deviceId] = targetHr;
|
|
||||||
|
|
||||||
// Update slider
|
|
||||||
const card = document.querySelector(`[data-id="${deviceId}"]`);
|
|
||||||
const slider = card?.querySelector('input[type="range"]');
|
|
||||||
if (slider) slider.value = targetHr;
|
|
||||||
|
|
||||||
// Update display
|
|
||||||
const display = document.getElementById(`hr-display-${deviceId}`);
|
|
||||||
if (display) display.textContent = `${targetHr} BPM`;
|
|
||||||
|
|
||||||
// Send immediately
|
|
||||||
sendHrValue(deviceId, targetHr);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHrTarget(deviceId, value) {
|
|
||||||
const hr = parseInt(value);
|
|
||||||
currentHrTargets[deviceId] = hr;
|
|
||||||
|
|
||||||
if (!settings.hrVariation) {
|
|
||||||
sendHrValue(deviceId, hr);
|
|
||||||
const display = document.getElementById(`hr-display-${deviceId}`);
|
|
||||||
if (display) display.textContent = `${hr} BPM`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendHrValue(deviceId, hr) {
|
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
|
await fetch(`${API_BASE}/devices/${deviceId}/hr-variation`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ heart_rate: hr })
|
body: JSON.stringify({ enabled: true, target: startHr })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enable HR variation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setHrTarget(deviceId, targetHr) {
|
||||||
|
// Update slider UI immediately
|
||||||
|
const card = document.querySelector(`[data-id="${deviceId}"]`);
|
||||||
|
const slider = card?.querySelector('input[type="range"]');
|
||||||
|
if (slider) slider.value = targetHr;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/devices/${deviceId}/hr-target`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target: targetHr })
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send HR:', error);
|
console.error('Failed to send HR:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setHrTargetDebounced(deviceId, value) {
|
||||||
|
const hr = parseInt(value);
|
||||||
|
// Update display immediately
|
||||||
|
const display = document.getElementById(`hr-display-${deviceId}`);
|
||||||
|
if (display) display.textContent = `${hr} BPM`;
|
||||||
|
|
||||||
|
// Debounce the API call
|
||||||
|
if (hrTargetDebounceTimers[deviceId]) {
|
||||||
|
clearTimeout(hrTargetDebounceTimers[deviceId]);
|
||||||
|
}
|
||||||
|
hrTargetDebounceTimers[deviceId] = setTimeout(() => {
|
||||||
|
setHrTarget(deviceId, hr);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
function updateSpeed(deviceId, displayValue) {
|
function updateSpeed(deviceId, displayValue) {
|
||||||
const metricSpeed = displayToMetric(parseFloat(displayValue));
|
const metricSpeed = displayToMetric(parseFloat(displayValue));
|
||||||
const display = document.getElementById(`speed-display-${deviceId}`);
|
const display = document.getElementById(`speed-display-${deviceId}`);
|
||||||
@@ -856,6 +927,28 @@
|
|||||||
updateValueDebounced(deviceId, 'speed', metricSpeed);
|
updateValueDebounced(deviceId, 'speed', metricSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateBattery(deviceId, value) {
|
||||||
|
const display = document.getElementById(`battery-display-${deviceId}`);
|
||||||
|
if (display) display.textContent = `${value}%`;
|
||||||
|
|
||||||
|
updateValueDebounced(deviceId, 'battery', parseInt(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetDistance(deviceId) {
|
||||||
|
const display = document.getElementById(`distance-display-${deviceId}`);
|
||||||
|
if (display) display.textContent = '0 m';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/devices/${deviceId}/values`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ distance: 0 })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset distance:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyTreadmillPreset(deviceId, speedKmh, incline) {
|
function applyTreadmillPreset(deviceId, speedKmh, incline) {
|
||||||
const card = document.querySelector(`[data-id="${deviceId}"]`);
|
const card = document.querySelector(`[data-id="${deviceId}"]`);
|
||||||
|
|
||||||
@@ -902,6 +995,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteDevice(deviceId) {
|
||||||
|
if (!confirm(`Remove device ${deviceId} from the list?`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
console.error('Failed to delete device:', error.detail || response.statusText);
|
||||||
|
alert(`Failed to delete device: ${error.detail || response.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// WebSocket broadcast will handle removing the device from UI
|
||||||
|
// But also remove locally in case WebSocket is not connected
|
||||||
|
removeDeviceFromUI(deviceId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete device:', error);
|
||||||
|
alert('Failed to delete device. Check console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectBle(deviceId, durationMs = 0, teardown = false) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/devices/${deviceId}/disconnect`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ duration_ms: durationMs, teardown: teardown })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
console.error('Failed to disconnect BLE:', error.detail || response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (teardown) {
|
||||||
|
console.log(`BLE teardown on ${deviceId}, reinit in ${durationMs || 3000}ms`);
|
||||||
|
} else if (durationMs > 0) {
|
||||||
|
console.log(`BLE disconnected on ${deviceId}, pausing ${durationMs}ms`);
|
||||||
|
} else {
|
||||||
|
console.log(`BLE disconnected on ${deviceId}, immediate re-advertise`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect BLE:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let updateTimeouts = {};
|
let updateTimeouts = {};
|
||||||
function updateValueDebounced(deviceId, key, value) {
|
function updateValueDebounced(deviceId, key, value) {
|
||||||
const timeoutKey = `${deviceId}-${key}`;
|
const timeoutKey = `${deviceId}-${key}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user