mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 09:31:52 +00:00
v1.1.2: Smooth HR variation, validation, and UI improvements
- Fix HR variation to use smooth sinusoidal algorithm instead of erratic jumps - Clamp HR values to ±3 of target (was exceeding bounds) - Add Pydantic Field validation for device values in API - Add runtime BPM validation in MCP server - Add battery slider control for HR monitor - Add distance display and reset button for treadmill - Add UI/API version display in header - Firmware: web portal improvements, battery/distance controls Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
18
README.md
18
README.md
@@ -92,6 +92,24 @@ To use pyBTMCP with Claude Desktop, add it to your MCP configuration:
|
||||
|
||||
After saving the configuration, restart Claude Desktop. You should see the "ble-simulator" MCP server available.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// ============================================
|
||||
// Firmware Version
|
||||
// ============================================
|
||||
#define FIRMWARE_VERSION "1.0.0"
|
||||
#define FIRMWARE_VERSION "1.0.5"
|
||||
|
||||
// ============================================
|
||||
// AP Mode Configuration
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> • 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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 • UI v1.2.3 • 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}"]`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user