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

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