diff --git a/README.md b/README.md index fe43944..aa2a1bb 100644 --- a/README.md +++ b/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. +## 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: diff --git a/firmware/esp32_ble_sim/include/ble_services.h b/firmware/esp32_ble_sim/include/ble_services.h index 483694e..6da0217 100644 --- a/firmware/esp32_ble_sim/include/ble_services.h +++ b/firmware/esp32_ble_sim/include/ble_services.h @@ -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 diff --git a/firmware/esp32_ble_sim/include/config.h b/firmware/esp32_ble_sim/include/config.h index dd9cd4b..3a4b0d6 100644 --- a/firmware/esp32_ble_sim/include/config.h +++ b/firmware/esp32_ble_sim/include/config.h @@ -4,7 +4,7 @@ // ============================================ // Firmware Version // ============================================ -#define FIRMWARE_VERSION "1.0.0" +#define FIRMWARE_VERSION "1.0.5" // ============================================ // AP Mode Configuration diff --git a/firmware/esp32_ble_sim/include/web_portal.h b/firmware/esp32_ble_sim/include/web_portal.h index a824422..e9ba606 100644 --- a/firmware/esp32_ble_sim/include/web_portal.h +++ b/firmware/esp32_ble_sim/include/web_portal.h @@ -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 diff --git a/firmware/esp32_ble_sim/src/ble_services.cpp b/firmware/esp32_ble_sim/src/ble_services.cpp index 5649a2a..57f2ca3 100644 --- a/firmware/esp32_ble_sim/src/ble_services.cpp +++ b/firmware/esp32_ble_sim/src/ble_services.cpp @@ -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(); } diff --git a/firmware/esp32_ble_sim/src/main.cpp b/firmware/esp32_ble_sim/src/main.cpp index 81ae607..9c3e0c8 100644 --- a/firmware/esp32_ble_sim/src/main.cpp +++ b/firmware/esp32_ble_sim/src/main.cpp @@ -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()) { + batteryLevel = doc["battery"]; + if (batteryLevel > 100) batteryLevel = 100; + updateBatteryLevel(batteryLevel); + Serial.print("Battery level set to: "); + Serial.println(batteryLevel); + } if (doc["speed"].is()) { 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()) { + 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); } } diff --git a/firmware/esp32_ble_sim/src/web_portal.cpp b/firmware/esp32_ble_sim/src/web_portal.cpp index a92dff3..233ef30 100644 --- a/firmware/esp32_ble_sim/src/web_portal.cpp +++ b/firmware/esp32_ble_sim/src/web_portal.cpp @@ -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(

BLE Simulator

-

Loading...

+

Loading... • UI v1.1.0

Status

@@ -107,6 +130,24 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
+ + + +

WiFi Configuration

@@ -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); @@ -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"); diff --git a/src/api/main.py b/src/api/main.py index caf5b66..466ef36 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -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 diff --git a/src/api/routes.py b/src/api/routes.py index e9f1c4d..656f6a9 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -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") diff --git a/src/mcp/server.py b/src/mcp/server.py index bc8d4e2..2c275cd 100644 --- a/src/mcp/server.py +++ b/src/mcp/server.py @@ -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}) diff --git a/src/web/static/index.html b/src/web/static/index.html index e0a5aee..0a638b3 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -289,7 +289,7 @@

pyBTMCP

-

BLE Device Simulator

+

BLE Device Simulator • UI v1.2.3 • API

@@ -675,6 +675,14 @@ ${hr} BPM
+
+ +
+ + ${values.battery || 100}% +
+
`; } @@ -705,6 +713,13 @@ ${incline}%
+
+ +
+ ${values.distance || 0} m + +
+
`; } @@ -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}"]`);