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:
2026-01-19 15:40:51 -05:00
parent 1c6b9db903
commit 9b740ebdd0
11 changed files with 406 additions and 68 deletions

View File

@@ -92,6 +92,24 @@ To use pyBTMCP with Claude Desktop, add it to your MCP configuration:
After saving the configuration, restart Claude Desktop. You should see the "ble-simulator" MCP server available.
## 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
Once configured, Claude can use these tools to control BLE devices:

View File

@@ -12,6 +12,10 @@
#define HEART_RATE_MEASUREMENT_UUID "2A37"
#define BODY_SENSOR_LOCATION_UUID "2A38"
// Battery Service
#define BATTERY_SERVICE_UUID "180F"
#define BATTERY_LEVEL_UUID "2A19"
// Fitness Machine Service
#define FITNESS_MACHINE_SERVICE_UUID "1826"
#define TREADMILL_DATA_UUID "2ACD"
@@ -49,11 +53,18 @@ void setupBLE_Treadmill();
*/
void notifyHeartRate(uint8_t bpm);
/**
* Update battery level
* @param level Battery level 0-100%
*/
void updateBatteryLevel(uint8_t level);
/**
* Send treadmill data notification
* @param speed Speed in 0.01 km/h units
* @param incline Incline in 0.1% units
* @param distance Total distance in meters
*/
void notifyTreadmill(uint16_t speed, int16_t incline);
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance);
#endif // BLE_SERVICES_H

View File

@@ -4,7 +4,7 @@
// ============================================
// Firmware Version
// ============================================
#define FIRMWARE_VERSION "1.0.0"
#define FIRMWARE_VERSION "1.0.5"
// ============================================
// AP Mode Configuration

View File

@@ -23,8 +23,18 @@ struct PortalStatus {
String ipAddress;
String deviceType;
bool bleStarted;
uint32_t treadmillDistance;
uint8_t batteryLevel;
};
// Callback for resetting treadmill distance
typedef void (*ResetDistanceCallback)();
void setResetDistanceCallback(ResetDistanceCallback callback);
// Callback for setting battery level
typedef void (*SetBatteryCallback)(uint8_t level);
void setSetBatteryCallback(SetBatteryCallback callback);
void updatePortalStatus(const PortalStatus& status);
#endif // WEB_PORTAL_H

View File

@@ -20,6 +20,9 @@ static NimBLEAdvertising* pAdvertising = nullptr;
// Heart Rate
static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
// Battery
static NimBLECharacteristic* pBatteryLevel = nullptr;
// Treadmill
static NimBLECharacteristic* pTreadmillData = nullptr;
@@ -66,6 +69,7 @@ void stopBLE() {
// Clear services (will be recreated on next setup)
pHeartRateMeasurement = nullptr;
pBatteryLevel = nullptr;
pTreadmillData = nullptr;
Serial.println("BLE stopped");
@@ -83,11 +87,11 @@ void setupBLE_HeartRate() {
}
// Create Heart Rate Service
NimBLEService* pService = pServer->createService(HEART_RATE_SERVICE_UUID);
NimBLEService* pHRService = pServer->createService(HEART_RATE_SERVICE_UUID);
// Heart Rate Measurement Characteristic
// Flags: Notify
pHeartRateMeasurement = pService->createCharacteristic(
pHeartRateMeasurement = pHRService->createCharacteristic(
HEART_RATE_MEASUREMENT_UUID,
NIMBLE_PROPERTY::NOTIFY
);
@@ -95,18 +99,34 @@ void setupBLE_HeartRate() {
// Body Sensor Location Characteristic
// Flags: Read
// Value: 1 = Chest
NimBLECharacteristic* pBodySensorLocation = pService->createCharacteristic(
NimBLECharacteristic* pBodySensorLocation = pHRService->createCharacteristic(
BODY_SENSOR_LOCATION_UUID,
NIMBLE_PROPERTY::READ
);
uint8_t sensorLocation = 1; // Chest
pBodySensorLocation->setValue(&sensorLocation, 1);
// Start the service
pService->start();
// Start Heart Rate service
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
pAdvertising->addServiceUUID(HEART_RATE_SERVICE_UUID);
pAdvertising->addServiceUUID(BATTERY_SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->setMinPreferred(0x06);
pAdvertising->setMaxPreferred(0x12);
@@ -117,7 +137,7 @@ void setupBLE_HeartRate() {
// Start advertising
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();
}
// ============================================
// 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
// ============================================
void notifyTreadmill(uint16_t speed, int16_t incline) {
void notifyTreadmill(uint16_t speed, int16_t incline, uint32_t distance) {
if (!pTreadmillData || !deviceConnected) return;
// 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 2: Total Distance 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
uint8_t data[8];
// We'll include: Instantaneous Speed + Total Distance + Inclination + Ramp Angle
uint8_t data[11];
// Flags: Inclination and Ramp Angle present (bit 3)
uint16_t flags = 0x0008;
// Flags: Total Distance present (bit 2) + Inclination and Ramp Angle present (bit 3)
uint16_t flags = 0x000C;
data[0] = flags & 0xFF;
data[1] = (flags >> 8) & 0xFF;
@@ -227,15 +260,20 @@ void notifyTreadmill(uint16_t speed, int16_t incline) {
data[2] = speed & 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)
data[4] = incline & 0xFF;
data[5] = (incline >> 8) & 0xFF;
data[7] = incline & 0xFF;
data[8] = (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;
data[9] = rampAngle & 0xFF;
data[10] = (rampAngle >> 8) & 0xFF;
pTreadmillData->setValue(data, 8);
pTreadmillData->setValue(data, 11);
pTreadmillData->notify();
}

View File

@@ -42,8 +42,11 @@ bool apModeActive = false;
// Simulated values
uint8_t heartRate = 70;
uint8_t batteryLevel = 100; // Battery level 0-100%
uint16_t treadmillSpeed = 0; // 0.01 km/h resolution
int16_t treadmillIncline = 0; // 0.1% resolution
uint32_t treadmillDistance = 0; // Total distance in meters
float distanceAccumulator = 0.0; // Fractional distance accumulator
// Timing
unsigned long lastNotify = 0;
@@ -165,7 +168,7 @@ void publishStatus() {
serializeJson(doc, payload);
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() {
@@ -175,9 +178,11 @@ void publishValues() {
if (currentDeviceType == DEVICE_HEART_RATE) {
doc["heart_rate"] = heartRate;
doc["battery"] = batteryLevel;
} else if (currentDeviceType == DEVICE_TREADMILL) {
doc["speed"] = treadmillSpeed / 100.0;
doc["incline"] = treadmillIncline / 10.0;
doc["distance"] = treadmillDistance;
}
String payload;
@@ -230,6 +235,13 @@ void handleMqttMessage(char* topic, byte* payload, unsigned int length) {
Serial.print("Heart rate set to: ");
Serial.println(heartRate);
}
if (doc["battery"].is<int>()) {
batteryLevel = doc["battery"];
if (batteryLevel > 100) batteryLevel = 100;
updateBatteryLevel(batteryLevel);
Serial.print("Battery level set to: ");
Serial.println(batteryLevel);
}
if (doc["speed"].is<float>()) {
float speed = doc["speed"];
treadmillSpeed = (uint16_t)(speed * 100);
@@ -242,6 +254,12 @@ void handleMqttMessage(char* topic, byte* payload, unsigned int length) {
Serial.print("Incline set to: ");
Serial.println(incline);
}
if (doc["distance"].is<int>()) {
treadmillDistance = doc["distance"];
distanceAccumulator = (float)treadmillDistance; // Sync accumulator
Serial.print("Distance set to: ");
Serial.println(treadmillDistance);
}
publishValues();
}
@@ -285,9 +303,14 @@ void connectToMQTT() {
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;
Serial.println("MQTT connected!");
Serial.println("MQTT connected with LWT!");
// Subscribe to control topics
String configTopic = String("ble-sim/") + configManager.getDeviceId() + "/config";
@@ -299,7 +322,7 @@ void connectToMQTT() {
Serial.print("Subscribed to: ");
Serial.println(configTopic);
// Publish initial status
// Publish initial status (with retain so new subscribers see current state)
publishStatus();
} else {
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
// ============================================
@@ -316,6 +358,8 @@ void updateStatus() {
status.mqttConnected = mqttConnected;
status.bleStarted = bleStarted;
status.ipAddress = wifiConnected ? WiFi.localIP().toString() : "";
status.treadmillDistance = treadmillDistance;
status.batteryLevel = batteryLevel;
if (currentDeviceType == DEVICE_HEART_RATE) {
status.deviceType = "Heart Rate";
@@ -353,6 +397,8 @@ void setup() {
// Start web configuration portal
setupWebPortal();
setResetDistanceCallback(resetTreadmillDistance);
setSetBatteryCallback(setBatteryLevelCallback);
// Initialize MQTT
setupMQTT();
@@ -390,7 +436,13 @@ void loop() {
if (currentDeviceType == DEVICE_HEART_RATE) {
notifyHeartRate(heartRate);
} 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);
}
}

View File

@@ -5,6 +5,8 @@
WebServer server(80);
PortalStatus currentStatus = {};
ResetDistanceCallback resetDistanceCallback = nullptr;
SetBatteryCallback setBatteryCallback = nullptr;
// HTML template with embedded CSS and JS
const char INDEX_HTML[] PROGMEM = R"rawliteral(
@@ -78,6 +80,27 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
button:hover { background: #22c55e; }
.btn-danger { background: #f87171; }
.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-success { background: #064e3b; }
.msg-error { background: #7f1d1d; }
@@ -85,7 +108,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</head>
<body>
<h1>BLE Simulator</h1>
<p class="subtitle" id="apName">Loading...</p>
<p class="subtitle"><span id="apName">Loading...</span> &bull; UI v1.1.0</p>
<div class="card">
<h2>Status</h2>
@@ -107,6 +130,24 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
</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">
<h2>WiFi Configuration</h2>
<div id="message"></div>
@@ -164,6 +205,16 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
updateStatusDots(data.status);
} catch (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('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) => {
@@ -223,9 +293,45 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
}
}
// Load config once, then only update status
loadConfig();
setInterval(updateStatus, 5000);
async function resetDistance() {
try {
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>
</body>
</html>
@@ -252,6 +358,8 @@ void handleGetStatus() {
status["bleStarted"] = currentStatus.bleStarted;
status["deviceType"] = currentStatus.deviceType;
status["ipAddress"] = currentStatus.ipAddress;
status["treadmillDistance"] = currentStatus.treadmillDistance;
status["batteryLevel"] = currentStatus.batteryLevel;
String response;
serializeJson(doc, response);
@@ -299,11 +407,55 @@ void handleReset() {
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() {
server.on("/", HTTP_GET, handleRoot);
server.on("/api/status", HTTP_GET, handleGetStatus);
server.on("/api/config", HTTP_POST, handlePostConfig);
server.on("/api/reset", HTTP_POST, handleReset);
server.on("/api/reset-distance", HTTP_POST, handleResetDistance);
server.on("/api/set-battery", HTTP_POST, handleSetBattery);
server.begin();
Serial.println("Web portal started on http://192.168.4.1");

View File

@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
# Version info
__version__ = "1.0.0"
__version__ = "1.1.2"
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

View File

@@ -1,7 +1,7 @@
"""API routes for device control."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from .mqtt_client import mqtt_manager
from .device_registry import device_registry
@@ -16,11 +16,11 @@ class DeviceConfig(BaseModel):
class DeviceValues(BaseModel):
"""Device values update request."""
heart_rate: int | None = None
speed: float | None = None # km/h
incline: float | None = None # percent
cadence: int | None = None # rpm
power: int | None = None # watts
heart_rate: int | None = Field(None, ge=30, le=220) # Valid BPM range
speed: float | None = Field(None, ge=0, le=50) # km/h, max 50 km/h
incline: float | None = Field(None, ge=-10, le=40) # percent
cadence: int | None = Field(None, ge=0, le=300) # rpm
power: int | None = Field(None, ge=0, le=2000) # watts
@router.get("/devices")

View File

@@ -208,6 +208,13 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
device_id = arguments["device_id"]
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(
f"ble-sim/{device_id}/set",
json.dumps({"heart_rate": bpm})

View File

@@ -289,7 +289,7 @@
<div class="header">
<div>
<h1>pyBTMCP</h1>
<p class="subtitle">BLE Device Simulator <span id="backendVersion"></span></p>
<p class="subtitle">BLE Device Simulator &bull; UI v1.2.3 &bull; API <span id="backendVersion"></span></p>
</div>
<div class="header-buttons">
<div class="connection-status">
@@ -675,6 +675,14 @@
<span class="value-display" id="hr-display-${device.id}">${hr} BPM</span>
</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>
</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 '';
}
// HR variation - smooth wandering around target
function setupHrVariation(deviceId, targetHr) {
// HR variation - smooth realistic human heart rate simulation
const hrVariationState = {}; // Store per-device state
function setupHrVariation(deviceId, startHr) {
if (hrVariationIntervals[deviceId]) {
clearInterval(hrVariationIntervals[deviceId]);
}
if (!settings.hrVariation) return;
currentHrTargets[deviceId] = targetHr;
// Use existing value if we have one, otherwise start at target
if (!currentHrValues[deviceId]) {
currentHrValues[deviceId] = targetHr;
// Only set target if not already set (don't overwrite user's target)
if (!currentHrTargets[deviceId]) {
currentHrTargets[deviceId] = startHr;
}
// Use existing value if we have one, otherwise start at startHr
if (!currentHrValues[deviceId]) {
currentHrValues[deviceId] = startHr;
}
// Initialize smooth variation state
if (!hrVariationState[deviceId]) {
hrVariationState[deviceId] = {
phase: Math.random() * Math.PI * 2, // Random start phase
floatHr: currentHrValues[deviceId] // Floating point HR for smooth transitions
};
}
let trend = 0; // -1, 0, or 1
hrVariationIntervals[deviceId] = setInterval(() => {
const target = currentHrTargets[deviceId];
let currentHr = currentHrValues[deviceId];
const state = hrVariationState[deviceId];
// Smoothly drift toward target with small random variation
const diff = target - currentHr;
// Slowly advance phase (completes cycle in ~30 seconds)
state.phase += 0.2;
// Change trend occasionally
if (Math.random() < 0.3) {
trend = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
}
// Smooth sinusoidal base variation (±2 BPM)
const sineVariation = Math.sin(state.phase) * 2;
// 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);
}
// Small random walk component (±0.3 per tick, smooths out)
const randomWalk = (Math.random() - 0.5) * 0.6;
currentHr = Math.round(Math.max(30, Math.min(220, currentHr + change)));
currentHrValues[deviceId] = currentHr;
// Calculate ideal HR with smooth variation
const idealHr = target + sineVariation + randomWalk;
// Send to device
sendHrValue(deviceId, currentHr);
// Smooth transition toward ideal (move 30% of the way each tick)
state.floatHr += (idealHr - state.floatHr) * 0.3;
// Update display
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) {
display.textContent = `${currentHr} BPM`;
// Clamp to ±3 of target
state.floatHr = Math.max(target - 3, Math.min(target + 3, state.floatHr));
// Round for display/transmission
const currentHr = Math.round(Math.max(30, Math.min(220, state.floatHr)));
// Only send if value changed
if (currentHr !== currentHrValues[deviceId]) {
currentHrValues[deviceId] = currentHr;
sendHrValue(deviceId, currentHr);
const display = document.getElementById(`hr-display-${deviceId}`);
if (display) {
display.textContent = `${currentHr} BPM`;
}
}
}, 1000);
}
@@ -795,7 +824,6 @@
if (!hrVariationIntervals[deviceId] && settings.hrVariation) {
const startHr = currentHrValues[deviceId] || targetHr;
setupHrVariation(deviceId, startHr);
currentHrTargets[deviceId] = targetHr;
}
// If variation is off, just send the value
@@ -856,6 +884,28 @@
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) {
const card = document.querySelector(`[data-id="${deviceId}"]`);