v1.4.0: BLE disconnect simulation with teardown support

Features:
- Add BLE disconnect simulation to test client reconnection behavior
- Support immediate disconnect, timed advertising pause, and full BLE teardown
- Add preset test buttons in admin UI (Quick Drop, 5s Pause, BLE Restart, 10s Restart)
- Add simulate_ble_disconnect MCP tool with duration_ms and teardown params
- Add POST /devices/{id}/disconnect API endpoint

Fixes:
- Fix treadmill distance not updating in admin UI (publish values periodically)
- Fix HR variation sending to non-heart_rate devices
- Fix BLE connection ID 0 being treated as "not connected"
- Clean up misleading startup log messages

Firmware: 1.1.0, API: 1.4.0, UI: 1.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 18:35:03 -05:00
parent 0a48a2928e
commit aab2526f99
11 changed files with 332 additions and 13 deletions

View File

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

View File

@@ -44,13 +44,23 @@ public:
// State
bool isClientConnected() const { return deviceConnected; }
// Disconnect simulation
void disconnectClient(); // Force disconnect + immediate re-advertise
void disconnectClientForDuration(int ms); // Disconnect + pause advertising for duration
void teardownForDuration(int ms); // Full BLE deinit + reinit after duration
private:
BleService() = default;
bool deviceConnected = false;
unsigned long lastNotify = 0;
unsigned long advertisingResumeTime = 0; // When to resume advertising (0 = not paused)
bool advertisingPaused = false;
unsigned long teardownResumeTime = 0; // When to reinit BLE after teardown
bool teardownPending = false;
void initBLE();
void reinitBLE(); // Reinit BLE and restore services
};
#define bleService BleService::getInstance()

View File

@@ -47,11 +47,8 @@ void setup() {
Serial.println("\nReady!");
if (!configService.isConfigured()) {
Serial.println("Configure at: http://192.168.4.1");
} else {
Serial.println("Connecting to WiFi...");
Serial.println("Configure at: http://192.168.4.1\n");
}
Serial.println("Waiting for MQTT commands...\n");
}
void loop() {

View File

@@ -18,9 +18,11 @@ static NimBLEService* pBatteryService = nullptr;
static NimBLEService* pFitnessMachineService = nullptr;
static bool bleInitialized = false;
static uint16_t currentConnId = 0; // Track connection ID for disconnect
static bool advertisingPausedFlag = false; // Shared with callbacks
// Forward declare for callback
static void onBleConnect();
static void onBleConnect(NimBLEServer* server);
static void onBleDisconnect();
// ============================================
@@ -28,7 +30,7 @@ static void onBleDisconnect();
// ============================================
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* pServer) override {
onBleConnect();
onBleConnect(pServer);
}
void onDisconnect(NimBLEServer* pServer) override {
@@ -36,15 +38,26 @@ class ServerCallbacks : public NimBLEServerCallbacks {
}
};
static void onBleConnect() {
static void onBleConnect(NimBLEServer* server) {
// Track connection ID for forced disconnect
// getPeerInfo(0) gets the first (most recent) connected peer
NimBLEConnInfo peerInfo = server->getPeerInfo(0);
currentConnId = peerInfo.getConnHandle();
deviceState.setBleClientConnected(true);
Serial.println("BLE client connected");
Serial.print("BLE client connected (connId: ");
Serial.print(currentConnId);
Serial.println(")");
}
static void onBleDisconnect() {
currentConnId = 0;
deviceState.setBleClientConnected(false);
Serial.println("BLE client disconnected");
NimBLEDevice::startAdvertising();
// Only auto-resume advertising if not paused
if (!advertisingPausedFlag) {
NimBLEDevice::startAdvertising();
}
}
// ============================================
@@ -63,8 +76,24 @@ void BleService::setup() {
}
void BleService::loop() {
// Check if BLE should be reinitialized after teardown
if (teardownPending && teardownResumeTime > 0 && millis() >= teardownResumeTime) {
teardownResumeTime = 0;
teardownPending = false;
reinitBLE();
}
if (!deviceState.isBleStarted()) return;
// Check if advertising should be resumed after timed pause
if (advertisingPaused && advertisingResumeTime > 0 && millis() >= advertisingResumeTime) {
advertisingResumeTime = 0;
advertisingPaused = false;
advertisingPausedFlag = false;
NimBLEDevice::startAdvertising();
Serial.println("Advertising resumed after timed pause");
}
// Send notifications at regular intervals
if (millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
lastNotify = millis();
@@ -269,3 +298,84 @@ void BleService::notifyTreadmill(uint16_t speed, int16_t incline, uint32_t dista
pTreadmillData->setValue(data, 11);
pTreadmillData->notify();
}
// ============================================
// Disconnect Simulation
// ============================================
void BleService::disconnectClient() {
if (!pServer || !deviceState.getConnectionState().bleClientConnected) {
Serial.println("No BLE client connected to disconnect");
return;
}
Serial.println("Forcing BLE client disconnect (immediate re-advertise)");
pServer->disconnect(currentConnId);
// onBleDisconnect callback will handle re-advertising
}
void BleService::disconnectClientForDuration(int ms) {
if (!pServer || !deviceState.getConnectionState().bleClientConnected) {
Serial.println("No BLE client connected to disconnect");
return;
}
Serial.print("Forcing BLE client disconnect, pausing advertising for ");
Serial.print(ms);
Serial.println("ms");
// Set flags before disconnect so callback knows not to auto-resume
advertisingPaused = true;
advertisingPausedFlag = true;
advertisingResumeTime = millis() + ms;
pServer->disconnect(currentConnId);
// onBleDisconnect callback will NOT resume advertising due to flag
}
void BleService::teardownForDuration(int ms) {
Serial.print("Tearing down BLE stack, will reinit in ");
Serial.print(ms);
Serial.println("ms");
// Clear all service/characteristic pointers
pHeartRateMeasurement = nullptr;
pBatteryLevel = nullptr;
pTreadmillData = nullptr;
pHeartRateService = nullptr;
pBatteryService = nullptr;
pFitnessMachineService = nullptr;
pServer = nullptr;
pAdvertising = nullptr;
// Full BLE deinit
NimBLEDevice::deinit(true);
bleInitialized = false;
currentConnId = 0;
deviceConnected = false;
deviceState.setBleClientConnected(false);
// Schedule reinit
teardownPending = true;
teardownResumeTime = millis() + ms;
Serial.println("BLE stack torn down - device will disappear from scans");
}
void BleService::reinitBLE() {
Serial.println("Reinitializing BLE stack after teardown...");
// Reinit the BLE stack
initBLE();
// Restore the previous device type configuration
DeviceType currentType = deviceState.getDeviceType();
if (currentType == DeviceType::HEART_RATE) {
setupHeartRate();
Serial.println("Restored Heart Rate service");
} else if (currentType == DeviceType::TREADMILL) {
setupTreadmill();
Serial.println("Restored Treadmill service");
}
Serial.println("BLE stack reinitialized - device visible again");
}

View File

@@ -30,10 +30,11 @@ void MqttService::loop() {
connectToMQTT();
mqtt.loop();
// Periodic status report
// Periodic status and values report
if (mqttConnected && millis() - lastStatusReport >= STATUS_REPORT_INTERVAL) {
lastStatusReport = millis();
publishStatus();
publishValues(); // Include current values (e.g., accumulated distance)
}
}
@@ -83,9 +84,11 @@ void MqttService::connectToMQTT() {
// Subscribe to control topics
String configTopic = String("ble-sim/") + configService.getDeviceId() + "/config";
String setTopic = String("ble-sim/") + configService.getDeviceId() + "/set";
String disconnectTopic = String("ble-sim/") + configService.getDeviceId() + "/disconnect";
mqtt.subscribe(configTopic.c_str());
mqtt.subscribe(setTopic.c_str());
mqtt.subscribe(disconnectTopic.c_str());
Serial.print("Subscribed to: ");
Serial.println(configTopic);
@@ -161,6 +164,23 @@ void MqttService::handleMessage(char* topic, byte* payload, unsigned int length)
publishValues();
}
// Handle BLE disconnect command
else if (topicStr == baseTopic + "/disconnect") {
int duration = doc["duration_ms"] | 0; // 0 = immediate resume
bool teardown = doc["teardown"] | false; // Full BLE stack teardown
if (teardown) {
// Full teardown - device disappears from scans
int teardownDuration = duration > 0 ? duration : 3000; // Default 3s for teardown
bleService.teardownForDuration(teardownDuration);
} else if (duration > 0) {
// Just disconnect and pause advertising
bleService.disconnectClientForDuration(duration);
} else {
// Just disconnect, immediate re-advertise
bleService.disconnectClient();
}
}
}
void MqttService::publishStatus() {

View File

@@ -139,6 +139,15 @@ class HRVariationManager:
async def _send_hr(self, device_id: str, hr: int):
"""Send HR value to device via MQTT and update registry."""
# Only send HR to devices configured as heart_rate type
if self._device_registry:
device = self._device_registry.get_device(device_id)
if device and device.get("type") != "heart_rate":
# Device is not a heart rate monitor, disable variation
if device_id in self._devices and self._devices[device_id].enabled:
self._devices[device_id].enabled = False
return
if self._mqtt_manager and self._mqtt_manager.is_connected:
await self._mqtt_manager.publish(
f"ble-sim/{device_id}/set",

View File

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

View File

@@ -132,6 +132,21 @@ class MQTTManager:
values
)
async def disconnect_ble(self, device_id: str, duration_ms: int = 0, teardown: bool = False):
"""Trigger BLE disconnect on a device.
Args:
device_id: The ESP32 device ID
duration_ms: How long to pause advertising after disconnect (0 = immediate resume)
teardown: If True, fully tear down BLE stack (device disappears from scans)
"""
payload = {}
if duration_ms > 0:
payload["duration_ms"] = duration_ms
if teardown:
payload["teardown"] = True
await self.publish(f"ble-sim/{device_id}/disconnect", payload)
# Global MQTT manager instance
mqtt_manager = MQTTManager()

View File

@@ -32,6 +32,12 @@ class HRVariationConfig(BaseModel):
target: int | None = Field(None, ge=30, le=220)
class BLEDisconnectRequest(BaseModel):
"""BLE disconnect request."""
duration_ms: int = Field(0, ge=0, le=60000) # Max 60 seconds
teardown: bool = Field(False, description="Full BLE stack teardown (device disappears from scans)")
@router.get("/devices")
async def list_devices():
"""List all connected devices."""
@@ -112,6 +118,27 @@ async def set_device_values(device_id: str, values: DeviceValues):
return {"status": "ok", "device_id": device_id, "values": values_dict}
@router.post("/devices/{device_id}/disconnect")
async def disconnect_ble(device_id: str, request: BLEDisconnectRequest = BLEDisconnectRequest()):
"""Simulate a BLE disconnect.
Args:
device_id: The ESP32 device ID
request: Optional duration_ms and teardown parameters
"""
if not mqtt_manager.is_connected:
raise HTTPException(status_code=503, detail="MQTT not connected")
await mqtt_manager.disconnect_ble(device_id, request.duration_ms, request.teardown)
return {
"status": "ok",
"device_id": device_id,
"duration_ms": request.duration_ms,
"teardown": request.teardown
}
@router.get("/devices/{device_id}/hr-variation")
async def get_hr_variation(device_id: str):
"""Get HR variation state for a device."""

View File

@@ -152,6 +152,32 @@ async def list_tools() -> list[Tool]:
"required": ["device_id"],
},
),
Tool(
name="simulate_ble_disconnect",
description="Simulate a BLE client disconnection to test reconnection behavior",
inputSchema={
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "The ESP32 device ID",
},
"duration_ms": {
"type": "integer",
"minimum": 0,
"maximum": 60000,
"default": 0,
"description": "How long to pause advertising after disconnect (0 = immediate resume)",
},
"teardown": {
"type": "boolean",
"default": False,
"description": "Full BLE stack teardown - device completely disappears from scans",
},
},
"required": ["device_id"],
},
),
]
@@ -287,6 +313,38 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
text=f"Device {device_id} not found"
)]
elif name == "simulate_ble_disconnect":
device_id = arguments["device_id"]
duration_ms = arguments.get("duration_ms", 0)
teardown = arguments.get("teardown", False)
payload = {}
if duration_ms > 0:
payload["duration_ms"] = duration_ms
if teardown:
payload["teardown"] = True
await mqtt_client.publish(
f"ble-sim/{device_id}/disconnect",
json.dumps(payload)
)
if teardown:
return [TextContent(
type="text",
text=f"BLE stack teardown on {device_id}, will reinit in {duration_ms if duration_ms > 0 else 3000}ms"
)]
elif duration_ms > 0:
return [TextContent(
type="text",
text=f"Disconnected BLE on {device_id}, advertising paused for {duration_ms}ms"
)]
else:
return [TextContent(
type="text",
text=f"Disconnected BLE on {device_id}, advertising resumed immediately"
)]
else:
return [TextContent(
type="text",

View File

@@ -280,6 +280,43 @@
opacity: 0.8;
}
/* BLE Test controls */
.ble-tests {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--accent);
}
.ble-tests label {
display: block;
color: var(--text-dim);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.ble-test-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.ble-test-btn {
background: var(--accent);
color: var(--text);
border: none;
border-radius: 8px;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.2s;
}
.ble-test-btn:hover { background: #1a4a7a; }
.ble-test-btn.warning {
background: var(--warning);
color: var(--bg);
}
.ble-test-btn.warning:hover { opacity: 0.9; }
/* WebSocket connection status */
.connection-status {
display: flex;
@@ -311,7 +348,7 @@
<div class="header">
<div>
<h1>pyBTMCP</h1>
<p class="subtitle">BLE Device Simulator &bull; UI v1.4.0 &bull; API <span id="backendVersion"></span></p>
<p class="subtitle">BLE Device Simulator &bull; UI v1.5.0 &bull; API <span id="backendVersion"></span></p>
</div>
<div class="header-buttons">
<div class="connection-status">
@@ -706,6 +743,18 @@
</select>
</div>
${device.type ? `
<div class="ble-tests">
<label>BLE Connection Tests</label>
<div class="ble-test-buttons">
<button class="ble-test-btn" onclick="disconnectBle('${device.id}', 0, false)" title="Disconnect client, immediately resume advertising">Quick Drop</button>
<button class="ble-test-btn" onclick="disconnectBle('${device.id}', 5000, false)" title="Disconnect and pause advertising for 5 seconds">5s Pause</button>
<button class="ble-test-btn warning" onclick="disconnectBle('${device.id}', 3000, true)" title="Full BLE teardown - device disappears from scans for 3 seconds">BLE Restart</button>
<button class="ble-test-btn warning" onclick="disconnectBle('${device.id}', 10000, true)" title="Full BLE teardown - device disappears from scans for 10 seconds">10s Restart</button>
</div>
</div>
` : ''}
${renderControls(device)}
</div>
`).join('');
@@ -967,6 +1016,30 @@
}
}
async function disconnectBle(deviceId, durationMs = 0, teardown = false) {
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/disconnect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration_ms: durationMs, teardown: teardown })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.error('Failed to disconnect BLE:', error.detail || response.statusText);
return;
}
if (teardown) {
console.log(`BLE teardown on ${deviceId}, reinit in ${durationMs || 3000}ms`);
} else if (durationMs > 0) {
console.log(`BLE disconnected on ${deviceId}, pausing ${durationMs}ms`);
} else {
console.log(`BLE disconnected on ${deviceId}, immediate re-advertise`);
}
} catch (error) {
console.error('Failed to disconnect BLE:', error);
}
}
let updateTimeouts = {};
function updateValueDebounced(deviceId, key, value) {
const timeoutKey = `${deviceId}-${key}`;