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. 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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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();
} }

View File

@@ -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);
} }
} }

View File

@@ -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> &bull; 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");

View File

@@ -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

View File

@@ -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")

View File

@@ -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})

View File

@@ -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 &bull; UI v1.2.3 &bull; 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}"]`);