mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 12:01:53 +00:00
v1.1.2: Smooth HR variation, validation, and UI improvements
- Fix HR variation to use smooth sinusoidal algorithm instead of erratic jumps - Clamp HR values to ±3 of target (was exceeding bounds) - Add Pydantic Field validation for device values in API - Add runtime BPM validation in MCP server - Add battery slider control for HR monitor - Add distance display and reset button for treadmill - Add UI/API version display in header - Firmware: web portal improvements, battery/distance controls Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
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:
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
#define HEART_RATE_MEASUREMENT_UUID "2A37"
|
#define HEART_RATE_MEASUREMENT_UUID "2A37"
|
||||||
#define BODY_SENSOR_LOCATION_UUID "2A38"
|
#define BODY_SENSOR_LOCATION_UUID "2A38"
|
||||||
|
|
||||||
|
// Battery Service
|
||||||
|
#define BATTERY_SERVICE_UUID "180F"
|
||||||
|
#define BATTERY_LEVEL_UUID "2A19"
|
||||||
|
|
||||||
// Fitness Machine Service
|
// Fitness Machine Service
|
||||||
#define FITNESS_MACHINE_SERVICE_UUID "1826"
|
#define FITNESS_MACHINE_SERVICE_UUID "1826"
|
||||||
#define TREADMILL_DATA_UUID "2ACD"
|
#define TREADMILL_DATA_UUID "2ACD"
|
||||||
@@ -49,11 +53,18 @@ void setupBLE_Treadmill();
|
|||||||
*/
|
*/
|
||||||
void notifyHeartRate(uint8_t bpm);
|
void notifyHeartRate(uint8_t bpm);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update battery level
|
||||||
|
* @param level Battery level 0-100%
|
||||||
|
*/
|
||||||
|
void updateBatteryLevel(uint8_t level);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send treadmill data notification
|
* Send treadmill data notification
|
||||||
* @param speed Speed in 0.01 km/h units
|
* @param speed Speed in 0.01 km/h units
|
||||||
* @param incline Incline in 0.1% units
|
* @param incline Incline in 0.1% units
|
||||||
|
* @param distance Total distance in meters
|
||||||
*/
|
*/
|
||||||
void notifyTreadmill(uint16_t speed, int16_t incline);
|
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance);
|
||||||
|
|
||||||
#endif // BLE_SERVICES_H
|
#endif // BLE_SERVICES_H
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// Firmware Version
|
// Firmware Version
|
||||||
// ============================================
|
// ============================================
|
||||||
#define FIRMWARE_VERSION "1.0.0"
|
#define FIRMWARE_VERSION "1.0.5"
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// AP Mode Configuration
|
// AP Mode Configuration
|
||||||
|
|||||||
@@ -23,8 +23,18 @@ struct PortalStatus {
|
|||||||
String ipAddress;
|
String ipAddress;
|
||||||
String deviceType;
|
String deviceType;
|
||||||
bool bleStarted;
|
bool bleStarted;
|
||||||
|
uint32_t treadmillDistance;
|
||||||
|
uint8_t batteryLevel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Callback for resetting treadmill distance
|
||||||
|
typedef void (*ResetDistanceCallback)();
|
||||||
|
void setResetDistanceCallback(ResetDistanceCallback callback);
|
||||||
|
|
||||||
|
// Callback for setting battery level
|
||||||
|
typedef void (*SetBatteryCallback)(uint8_t level);
|
||||||
|
void setSetBatteryCallback(SetBatteryCallback callback);
|
||||||
|
|
||||||
void updatePortalStatus(const PortalStatus& status);
|
void updatePortalStatus(const PortalStatus& status);
|
||||||
|
|
||||||
#endif // WEB_PORTAL_H
|
#endif // WEB_PORTAL_H
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ static NimBLEAdvertising* pAdvertising = nullptr;
|
|||||||
// Heart Rate
|
// Heart Rate
|
||||||
static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
|
static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
|
||||||
|
|
||||||
|
// Battery
|
||||||
|
static NimBLECharacteristic* pBatteryLevel = nullptr;
|
||||||
|
|
||||||
// Treadmill
|
// Treadmill
|
||||||
static NimBLECharacteristic* pTreadmillData = nullptr;
|
static NimBLECharacteristic* pTreadmillData = nullptr;
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ void stopBLE() {
|
|||||||
|
|
||||||
// Clear services (will be recreated on next setup)
|
// Clear services (will be recreated on next setup)
|
||||||
pHeartRateMeasurement = nullptr;
|
pHeartRateMeasurement = nullptr;
|
||||||
|
pBatteryLevel = nullptr;
|
||||||
pTreadmillData = nullptr;
|
pTreadmillData = nullptr;
|
||||||
|
|
||||||
Serial.println("BLE stopped");
|
Serial.println("BLE stopped");
|
||||||
@@ -83,11 +87,11 @@ void setupBLE_HeartRate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create Heart Rate Service
|
// Create Heart Rate Service
|
||||||
NimBLEService* pService = pServer->createService(HEART_RATE_SERVICE_UUID);
|
NimBLEService* pHRService = pServer->createService(HEART_RATE_SERVICE_UUID);
|
||||||
|
|
||||||
// Heart Rate Measurement Characteristic
|
// Heart Rate Measurement Characteristic
|
||||||
// Flags: Notify
|
// Flags: Notify
|
||||||
pHeartRateMeasurement = pService->createCharacteristic(
|
pHeartRateMeasurement = pHRService->createCharacteristic(
|
||||||
HEART_RATE_MEASUREMENT_UUID,
|
HEART_RATE_MEASUREMENT_UUID,
|
||||||
NIMBLE_PROPERTY::NOTIFY
|
NIMBLE_PROPERTY::NOTIFY
|
||||||
);
|
);
|
||||||
@@ -95,18 +99,34 @@ void setupBLE_HeartRate() {
|
|||||||
// Body Sensor Location Characteristic
|
// Body Sensor Location Characteristic
|
||||||
// Flags: Read
|
// Flags: Read
|
||||||
// Value: 1 = Chest
|
// Value: 1 = Chest
|
||||||
NimBLECharacteristic* pBodySensorLocation = pService->createCharacteristic(
|
NimBLECharacteristic* pBodySensorLocation = pHRService->createCharacteristic(
|
||||||
BODY_SENSOR_LOCATION_UUID,
|
BODY_SENSOR_LOCATION_UUID,
|
||||||
NIMBLE_PROPERTY::READ
|
NIMBLE_PROPERTY::READ
|
||||||
);
|
);
|
||||||
uint8_t sensorLocation = 1; // Chest
|
uint8_t sensorLocation = 1; // Chest
|
||||||
pBodySensorLocation->setValue(&sensorLocation, 1);
|
pBodySensorLocation->setValue(&sensorLocation, 1);
|
||||||
|
|
||||||
// Start the service
|
// Start Heart Rate service
|
||||||
pService->start();
|
pHRService->start();
|
||||||
|
|
||||||
|
// Create Battery Service
|
||||||
|
NimBLEService* pBatteryService = pServer->createService(BATTERY_SERVICE_UUID);
|
||||||
|
|
||||||
|
// Battery Level Characteristic
|
||||||
|
// Flags: Read, Notify
|
||||||
|
pBatteryLevel = pBatteryService->createCharacteristic(
|
||||||
|
BATTERY_LEVEL_UUID,
|
||||||
|
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
|
||||||
|
);
|
||||||
|
uint8_t initialBattery = 100;
|
||||||
|
pBatteryLevel->setValue(&initialBattery, 1);
|
||||||
|
|
||||||
|
// Start Battery service
|
||||||
|
pBatteryService->start();
|
||||||
|
|
||||||
// Configure advertising
|
// Configure advertising
|
||||||
pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID);
|
pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID);
|
||||||
|
pAdvertising->addServiceUUID(BATTERY_SERVICE_UUID);
|
||||||
pAdvertising->setScanResponse(true);
|
pAdvertising->setScanResponse(true);
|
||||||
pAdvertising->setMinPreferred(0x06);
|
pAdvertising->setMinPreferred(0x06);
|
||||||
pAdvertising->setMaxPreferred(0x12);
|
pAdvertising->setMaxPreferred(0x12);
|
||||||
@@ -117,7 +137,7 @@ void setupBLE_HeartRate() {
|
|||||||
// Start advertising
|
// Start advertising
|
||||||
NimBLEDevice::startAdvertising();
|
NimBLEDevice::startAdvertising();
|
||||||
|
|
||||||
Serial.println("Heart Rate Service started, advertising...");
|
Serial.println("Heart Rate + Battery Services started, advertising...");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -201,10 +221,23 @@ void notifyHeartRate(uint8_t bpm) {
|
|||||||
pHeartRateMeasurement->notify();
|
pHeartRateMeasurement->notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Battery Level Update
|
||||||
|
// ============================================
|
||||||
|
void updateBatteryLevel(uint8_t level) {
|
||||||
|
if (!pBatteryLevel) return;
|
||||||
|
|
||||||
|
// Clamp to 0-100
|
||||||
|
if (level > 100) level = 100;
|
||||||
|
|
||||||
|
pBatteryLevel->setValue(&level, 1);
|
||||||
|
pBatteryLevel->notify();
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Treadmill Data Notification
|
// Treadmill Data Notification
|
||||||
// ============================================
|
// ============================================
|
||||||
void notifyTreadmill(uint16_t speed, int16_t incline) {
|
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance) {
|
||||||
if (!pTreadmillData || !deviceConnected) return;
|
if (!pTreadmillData || !deviceConnected) return;
|
||||||
|
|
||||||
// Treadmill Data format (per Bluetooth FTMS spec):
|
// Treadmill Data format (per Bluetooth FTMS spec):
|
||||||
@@ -213,13 +246,13 @@ void notifyTreadmill(uint16_t speed, int16_t incline) {
|
|||||||
// Bit 1: Average Speed present
|
// Bit 1: Average Speed present
|
||||||
// Bit 2: Total Distance present
|
// Bit 2: Total Distance present
|
||||||
// Bit 3: Inclination and Ramp Angle present
|
// Bit 3: Inclination and Ramp Angle present
|
||||||
// Following bytes: Data fields based on flags
|
// Following bytes: Data fields based on flags (in order of flag bits)
|
||||||
|
|
||||||
// We'll include: Instantaneous Speed + Inclination + Ramp Angle
|
// We'll include: Instantaneous Speed + Total Distance + Inclination + Ramp Angle
|
||||||
uint8_t data[8];
|
uint8_t data[11];
|
||||||
|
|
||||||
// Flags: Inclination and Ramp Angle present (bit 3)
|
// Flags: Total Distance present (bit 2) + Inclination and Ramp Angle present (bit 3)
|
||||||
uint16_t flags = 0x0008;
|
uint16_t flags = 0x000C;
|
||||||
data[0] = flags & 0xFF;
|
data[0] = flags & 0xFF;
|
||||||
data[1] = (flags >> 8) & 0xFF;
|
data[1] = (flags >> 8) & 0xFF;
|
||||||
|
|
||||||
@@ -227,15 +260,20 @@ void notifyTreadmill(uint16_t speed, int16_t incline) {
|
|||||||
data[2] = speed & 0xFF;
|
data[2] = speed & 0xFF;
|
||||||
data[3] = (speed >> 8) & 0xFF;
|
data[3] = (speed >> 8) & 0xFF;
|
||||||
|
|
||||||
|
// Total Distance (uint24, meters) - 3 bytes, little endian
|
||||||
|
data[4] = distance & 0xFF;
|
||||||
|
data[5] = (distance >> 8) & 0xFF;
|
||||||
|
data[6] = (distance >> 16) & 0xFF;
|
||||||
|
|
||||||
// Inclination (sint16, 0.1% resolution)
|
// Inclination (sint16, 0.1% resolution)
|
||||||
data[4] = incline & 0xFF;
|
data[7] = incline & 0xFF;
|
||||||
data[5] = (incline >> 8) & 0xFF;
|
data[8] = (incline >> 8) & 0xFF;
|
||||||
|
|
||||||
// Ramp Angle Setting (sint16, 0.1 degree resolution) - set to 0
|
// Ramp Angle Setting (sint16, 0.1 degree resolution) - set to 0
|
||||||
int16_t rampAngle = 0;
|
int16_t rampAngle = 0;
|
||||||
data[6] = rampAngle & 0xFF;
|
data[9] = rampAngle & 0xFF;
|
||||||
data[7] = (rampAngle >> 8) & 0xFF;
|
data[10] = (rampAngle >> 8) & 0xFF;
|
||||||
|
|
||||||
pTreadmillData->setValue(data, 8);
|
pTreadmillData->setValue(data, 11);
|
||||||
pTreadmillData->notify();
|
pTreadmillData->notify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,11 @@ bool apModeActive = false;
|
|||||||
|
|
||||||
// Simulated values
|
// Simulated values
|
||||||
uint8_t heartRate = 70;
|
uint8_t heartRate = 70;
|
||||||
|
uint8_t batteryLevel = 100; // Battery level 0-100%
|
||||||
uint16_t treadmillSpeed = 0; // 0.01 km/h resolution
|
uint16_t treadmillSpeed = 0; // 0.01 km/h resolution
|
||||||
int16_t treadmillIncline = 0; // 0.1% resolution
|
int16_t treadmillIncline = 0; // 0.1% resolution
|
||||||
|
uint32_t treadmillDistance = 0; // Total distance in meters
|
||||||
|
float distanceAccumulator = 0.0; // Fractional distance accumulator
|
||||||
|
|
||||||
// Timing
|
// Timing
|
||||||
unsigned long lastNotify = 0;
|
unsigned long lastNotify = 0;
|
||||||
@@ -165,7 +168,7 @@ void publishStatus() {
|
|||||||
serializeJson(doc, payload);
|
serializeJson(doc, payload);
|
||||||
|
|
||||||
String topic = String("ble-sim/") + configManager.getDeviceId() + "/status";
|
String topic = String("ble-sim/") + configManager.getDeviceId() + "/status";
|
||||||
mqtt.publish(topic.c_str(), payload.c_str());
|
mqtt.publish(topic.c_str(), payload.c_str(), true); // retained
|
||||||
}
|
}
|
||||||
|
|
||||||
void publishValues() {
|
void publishValues() {
|
||||||
@@ -175,9 +178,11 @@ void publishValues() {
|
|||||||
|
|
||||||
if (currentDeviceType == DEVICE_HEART_RATE) {
|
if (currentDeviceType == DEVICE_HEART_RATE) {
|
||||||
doc["heart_rate"] = heartRate;
|
doc["heart_rate"] = heartRate;
|
||||||
|
doc["battery"] = batteryLevel;
|
||||||
} else if (currentDeviceType == DEVICE_TREADMILL) {
|
} else if (currentDeviceType == DEVICE_TREADMILL) {
|
||||||
doc["speed"] = treadmillSpeed / 100.0;
|
doc["speed"] = treadmillSpeed / 100.0;
|
||||||
doc["incline"] = treadmillIncline / 10.0;
|
doc["incline"] = treadmillIncline / 10.0;
|
||||||
|
doc["distance"] = treadmillDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
String payload;
|
String payload;
|
||||||
@@ -230,6 +235,13 @@ void handleMqttMessage(char* topic, byte* payload, unsigned int length) {
|
|||||||
Serial.print("Heart rate set to: ");
|
Serial.print("Heart rate set to: ");
|
||||||
Serial.println(heartRate);
|
Serial.println(heartRate);
|
||||||
}
|
}
|
||||||
|
if (doc["battery"].is<int>()) {
|
||||||
|
batteryLevel = doc["battery"];
|
||||||
|
if (batteryLevel > 100) batteryLevel = 100;
|
||||||
|
updateBatteryLevel(batteryLevel);
|
||||||
|
Serial.print("Battery level set to: ");
|
||||||
|
Serial.println(batteryLevel);
|
||||||
|
}
|
||||||
if (doc["speed"].is<float>()) {
|
if (doc["speed"].is<float>()) {
|
||||||
float speed = doc["speed"];
|
float speed = doc["speed"];
|
||||||
treadmillSpeed = (uint16_t)(speed * 100);
|
treadmillSpeed = (uint16_t)(speed * 100);
|
||||||
@@ -242,6 +254,12 @@ void handleMqttMessage(char* topic, byte* payload, unsigned int length) {
|
|||||||
Serial.print("Incline set to: ");
|
Serial.print("Incline set to: ");
|
||||||
Serial.println(incline);
|
Serial.println(incline);
|
||||||
}
|
}
|
||||||
|
if (doc["distance"].is<int>()) {
|
||||||
|
treadmillDistance = doc["distance"];
|
||||||
|
distanceAccumulator = (float)treadmillDistance; // Sync accumulator
|
||||||
|
Serial.print("Distance set to: ");
|
||||||
|
Serial.println(treadmillDistance);
|
||||||
|
}
|
||||||
|
|
||||||
publishValues();
|
publishValues();
|
||||||
}
|
}
|
||||||
@@ -285,9 +303,14 @@ void connectToMQTT() {
|
|||||||
|
|
||||||
String clientId = String("esp32-") + String(random(0xffff), HEX);
|
String clientId = String("esp32-") + String(random(0xffff), HEX);
|
||||||
|
|
||||||
if (mqtt.connect(clientId.c_str())) {
|
// Set up Last Will and Testament (LWT) for disconnect detection
|
||||||
|
String statusTopic = String("ble-sim/") + configManager.getDeviceId() + "/status";
|
||||||
|
String willMessage = "{\"online\":false}";
|
||||||
|
|
||||||
|
// Connect with LWT: topic, QoS 1, retain true, message
|
||||||
|
if (mqtt.connect(clientId.c_str(), statusTopic.c_str(), 1, true, willMessage.c_str())) {
|
||||||
mqttConnected = true;
|
mqttConnected = true;
|
||||||
Serial.println("MQTT connected!");
|
Serial.println("MQTT connected with LWT!");
|
||||||
|
|
||||||
// Subscribe to control topics
|
// Subscribe to control topics
|
||||||
String configTopic = String("ble-sim/") + configManager.getDeviceId() + "/config";
|
String configTopic = String("ble-sim/") + configManager.getDeviceId() + "/config";
|
||||||
@@ -299,7 +322,7 @@ void connectToMQTT() {
|
|||||||
Serial.print("Subscribed to: ");
|
Serial.print("Subscribed to: ");
|
||||||
Serial.println(configTopic);
|
Serial.println(configTopic);
|
||||||
|
|
||||||
// Publish initial status
|
// Publish initial status (with retain so new subscribers see current state)
|
||||||
publishStatus();
|
publishStatus();
|
||||||
} else {
|
} else {
|
||||||
Serial.print("MQTT connection failed, rc=");
|
Serial.print("MQTT connection failed, rc=");
|
||||||
@@ -307,6 +330,25 @@ void connectToMQTT() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Reset Treadmill Distance
|
||||||
|
// ============================================
|
||||||
|
void resetTreadmillDistance() {
|
||||||
|
treadmillDistance = 0;
|
||||||
|
distanceAccumulator = 0.0;
|
||||||
|
Serial.println("Treadmill distance reset to 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Set Battery Level (from web UI)
|
||||||
|
// ============================================
|
||||||
|
void setBatteryLevelCallback(uint8_t level) {
|
||||||
|
batteryLevel = level;
|
||||||
|
updateBatteryLevel(batteryLevel);
|
||||||
|
Serial.print("Battery level set via web UI: ");
|
||||||
|
Serial.println(batteryLevel);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Update Portal Status
|
// Update Portal Status
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -316,6 +358,8 @@ void updateStatus() {
|
|||||||
status.mqttConnected = mqttConnected;
|
status.mqttConnected = mqttConnected;
|
||||||
status.bleStarted = bleStarted;
|
status.bleStarted = bleStarted;
|
||||||
status.ipAddress = wifiConnected ? WiFi.localIP().toString() : "";
|
status.ipAddress = wifiConnected ? WiFi.localIP().toString() : "";
|
||||||
|
status.treadmillDistance = treadmillDistance;
|
||||||
|
status.batteryLevel = batteryLevel;
|
||||||
|
|
||||||
if (currentDeviceType == DEVICE_HEART_RATE) {
|
if (currentDeviceType == DEVICE_HEART_RATE) {
|
||||||
status.deviceType = "Heart Rate";
|
status.deviceType = "Heart Rate";
|
||||||
@@ -353,6 +397,8 @@ void setup() {
|
|||||||
|
|
||||||
// Start web configuration portal
|
// Start web configuration portal
|
||||||
setupWebPortal();
|
setupWebPortal();
|
||||||
|
setResetDistanceCallback(resetTreadmillDistance);
|
||||||
|
setSetBatteryCallback(setBatteryLevelCallback);
|
||||||
|
|
||||||
// Initialize MQTT
|
// Initialize MQTT
|
||||||
setupMQTT();
|
setupMQTT();
|
||||||
@@ -390,7 +436,13 @@ void loop() {
|
|||||||
if (currentDeviceType == DEVICE_HEART_RATE) {
|
if (currentDeviceType == DEVICE_HEART_RATE) {
|
||||||
notifyHeartRate(heartRate);
|
notifyHeartRate(heartRate);
|
||||||
} else if (currentDeviceType == DEVICE_TREADMILL) {
|
} else if (currentDeviceType == DEVICE_TREADMILL) {
|
||||||
notifyTreadmill(treadmillSpeed, treadmillIncline);
|
// Accumulate distance based on speed
|
||||||
|
// Speed is in 0.01 km/h units, interval is 1 second
|
||||||
|
// meters per second = (speed/100) * 1000 / 3600 = speed / 360
|
||||||
|
distanceAccumulator += treadmillSpeed / 360.0;
|
||||||
|
treadmillDistance = (uint32_t)distanceAccumulator;
|
||||||
|
|
||||||
|
notifyTreadmill(treadmillSpeed, treadmillIncline, treadmillDistance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
WebServer server(80);
|
WebServer server(80);
|
||||||
PortalStatus currentStatus = {};
|
PortalStatus currentStatus = {};
|
||||||
|
ResetDistanceCallback resetDistanceCallback = nullptr;
|
||||||
|
SetBatteryCallback setBatteryCallback = nullptr;
|
||||||
|
|
||||||
// HTML template with embedded CSS and JS
|
// HTML template with embedded CSS and JS
|
||||||
const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
||||||
@@ -78,6 +80,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 +108,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.1.0</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Status</h2>
|
<h2>Status</h2>
|
||||||
@@ -107,6 +130,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>
|
||||||
@@ -164,6 +205,16 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
|||||||
updateStatusDots(data.status);
|
updateStatusDots(data.status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to update status:', e);
|
console.error('Failed to update status:', e);
|
||||||
|
// Show disconnected state when device is unreachable
|
||||||
|
updateStatusDots({
|
||||||
|
wifiConnected: false,
|
||||||
|
mqttConnected: false,
|
||||||
|
bleStarted: false,
|
||||||
|
deviceType: 'Not configured',
|
||||||
|
ipAddress: '',
|
||||||
|
treadmillDistance: 0,
|
||||||
|
batteryLevel: 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +229,25 @@ 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 || '-';
|
||||||
|
|
||||||
|
// Show heart rate card only when configured as heart rate
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show treadmill card only when configured as treadmill
|
||||||
|
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,9 +293,45 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery slider handler
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load config with retry, then update status periodically
|
||||||
|
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>
|
||||||
@@ -252,6 +358,8 @@ void handleGetStatus() {
|
|||||||
status["bleStarted"] = currentStatus.bleStarted;
|
status["bleStarted"] = currentStatus.bleStarted;
|
||||||
status["deviceType"] = currentStatus.deviceType;
|
status["deviceType"] = currentStatus.deviceType;
|
||||||
status["ipAddress"] = currentStatus.ipAddress;
|
status["ipAddress"] = currentStatus.ipAddress;
|
||||||
|
status["treadmillDistance"] = currentStatus.treadmillDistance;
|
||||||
|
status["batteryLevel"] = currentStatus.batteryLevel;
|
||||||
|
|
||||||
String response;
|
String response;
|
||||||
serializeJson(doc, response);
|
serializeJson(doc, response);
|
||||||
@@ -299,11 +407,55 @@ void handleReset() {
|
|||||||
ESP.restart();
|
ESP.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleResetDistance() {
|
||||||
|
if (resetDistanceCallback) {
|
||||||
|
resetDistanceCallback();
|
||||||
|
Serial.println("Treadmill distance reset");
|
||||||
|
}
|
||||||
|
server.send(200, "application/json", "{\"success\":true}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void setResetDistanceCallback(ResetDistanceCallback callback) {
|
||||||
|
resetDistanceCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
void 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;
|
||||||
|
|
||||||
|
if (setBatteryCallback) {
|
||||||
|
setBatteryCallback(level);
|
||||||
|
Serial.print("Battery level set to: ");
|
||||||
|
Serial.println(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.send(200, "application/json", "{\"success\":true}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSetBatteryCallback(SetBatteryCallback callback) {
|
||||||
|
setBatteryCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
void setupWebPortal() {
|
void setupWebPortal() {
|
||||||
server.on("/", HTTP_GET, handleRoot);
|
server.on("/", HTTP_GET, handleRoot);
|
||||||
server.on("/api/status", HTTP_GET, handleGetStatus);
|
server.on("/api/status", HTTP_GET, handleGetStatus);
|
||||||
server.on("/api/config", HTTP_POST, handlePostConfig);
|
server.on("/api/config", HTTP_POST, handlePostConfig);
|
||||||
server.on("/api/reset", HTTP_POST, handleReset);
|
server.on("/api/reset", HTTP_POST, handleReset);
|
||||||
|
server.on("/api/reset-distance", HTTP_POST, handleResetDistance);
|
||||||
|
server.on("/api/set-battery", HTTP_POST, handleSetBattery);
|
||||||
|
|
||||||
server.begin();
|
server.begin();
|
||||||
Serial.println("Web portal started on http://192.168.4.1");
|
Serial.println("Web portal started on http://192.168.4.1");
|
||||||
|
|||||||
@@ -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.1.2"
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""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
|
||||||
@@ -16,11 +16,11 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices")
|
@router.get("/devices")
|
||||||
|
|||||||
@@ -208,6 +208,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})
|
||||||
|
|||||||
@@ -289,7 +289,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.2.3 • API <span id="backendVersion"></span></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<div class="connection-status">
|
<div class="connection-status">
|
||||||
@@ -675,6 +675,14 @@
|
|||||||
<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 +713,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,52 +747,66 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// HR variation - smooth wandering around target
|
// HR variation - smooth realistic human heart rate simulation
|
||||||
function setupHrVariation(deviceId, targetHr) {
|
const hrVariationState = {}; // Store per-device state
|
||||||
|
|
||||||
|
function setupHrVariation(deviceId, startHr) {
|
||||||
if (hrVariationIntervals[deviceId]) {
|
if (hrVariationIntervals[deviceId]) {
|
||||||
clearInterval(hrVariationIntervals[deviceId]);
|
clearInterval(hrVariationIntervals[deviceId]);
|
||||||
}
|
}
|
||||||
if (!settings.hrVariation) return;
|
if (!settings.hrVariation) return;
|
||||||
|
|
||||||
currentHrTargets[deviceId] = targetHr;
|
// Only set target if not already set (don't overwrite user's target)
|
||||||
// Use existing value if we have one, otherwise start at target
|
if (!currentHrTargets[deviceId]) {
|
||||||
if (!currentHrValues[deviceId]) {
|
currentHrTargets[deviceId] = startHr;
|
||||||
currentHrValues[deviceId] = targetHr;
|
}
|
||||||
|
// Use existing value if we have one, otherwise start at startHr
|
||||||
|
if (!currentHrValues[deviceId]) {
|
||||||
|
currentHrValues[deviceId] = startHr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize smooth variation state
|
||||||
|
if (!hrVariationState[deviceId]) {
|
||||||
|
hrVariationState[deviceId] = {
|
||||||
|
phase: Math.random() * Math.PI * 2, // Random start phase
|
||||||
|
floatHr: currentHrValues[deviceId] // Floating point HR for smooth transitions
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let trend = 0; // -1, 0, or 1
|
|
||||||
|
|
||||||
hrVariationIntervals[deviceId] = setInterval(() => {
|
hrVariationIntervals[deviceId] = setInterval(() => {
|
||||||
const target = currentHrTargets[deviceId];
|
const target = currentHrTargets[deviceId];
|
||||||
let currentHr = currentHrValues[deviceId];
|
const state = hrVariationState[deviceId];
|
||||||
|
|
||||||
// Smoothly drift toward target with small random variation
|
// Slowly advance phase (completes cycle in ~30 seconds)
|
||||||
const diff = target - currentHr;
|
state.phase += 0.2;
|
||||||
|
|
||||||
// Change trend occasionally
|
// Smooth sinusoidal base variation (±2 BPM)
|
||||||
if (Math.random() < 0.3) {
|
const sineVariation = Math.sin(state.phase) * 2;
|
||||||
trend = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply small change (max ±1 BPM per interval)
|
// Small random walk component (±0.3 per tick, smooths out)
|
||||||
let change = 0;
|
const randomWalk = (Math.random() - 0.5) * 0.6;
|
||||||
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)));
|
// Calculate ideal HR with smooth variation
|
||||||
currentHrValues[deviceId] = currentHr;
|
const idealHr = target + sineVariation + randomWalk;
|
||||||
|
|
||||||
// Send to device
|
// Smooth transition toward ideal (move 30% of the way each tick)
|
||||||
sendHrValue(deviceId, currentHr);
|
state.floatHr += (idealHr - state.floatHr) * 0.3;
|
||||||
|
|
||||||
// Update display
|
// Clamp to ±3 of target
|
||||||
const display = document.getElementById(`hr-display-${deviceId}`);
|
state.floatHr = Math.max(target - 3, Math.min(target + 3, state.floatHr));
|
||||||
if (display) {
|
|
||||||
display.textContent = `${currentHr} BPM`;
|
// Round for display/transmission
|
||||||
|
const currentHr = Math.round(Math.max(30, Math.min(220, state.floatHr)));
|
||||||
|
|
||||||
|
// Only send if value changed
|
||||||
|
if (currentHr !== currentHrValues[deviceId]) {
|
||||||
|
currentHrValues[deviceId] = currentHr;
|
||||||
|
sendHrValue(deviceId, currentHr);
|
||||||
|
|
||||||
|
const display = document.getElementById(`hr-display-${deviceId}`);
|
||||||
|
if (display) {
|
||||||
|
display.textContent = `${currentHr} BPM`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
@@ -795,7 +824,6 @@
|
|||||||
if (!hrVariationIntervals[deviceId] && settings.hrVariation) {
|
if (!hrVariationIntervals[deviceId] && settings.hrVariation) {
|
||||||
const startHr = currentHrValues[deviceId] || targetHr;
|
const startHr = currentHrValues[deviceId] || targetHr;
|
||||||
setupHrVariation(deviceId, startHr);
|
setupHrVariation(deviceId, startHr);
|
||||||
currentHrTargets[deviceId] = targetHr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If variation is off, just send the value
|
// If variation is off, just send the value
|
||||||
@@ -856,6 +884,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}"]`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user