From aab2526f99a875d0bab13ca71a697bf4b9918d22 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 19 Jan 2026 18:35:03 -0500 Subject: [PATCH] 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 --- firmware/esp32_ble_sim/include/config.h | 2 +- .../include/services/ble_service.h | 10 ++ firmware/esp32_ble_sim/src/main.cpp | 5 +- .../src/services/ble_service.cpp | 120 +++++++++++++++++- .../src/services/mqtt_service.cpp | 22 +++- src/api/hr_variation.py | 9 ++ src/api/main.py | 2 +- src/api/mqtt_client.py | 15 +++ src/api/routes.py | 27 ++++ src/mcp/server.py | 58 +++++++++ src/web/static/index.html | 75 ++++++++++- 11 files changed, 332 insertions(+), 13 deletions(-) diff --git a/firmware/esp32_ble_sim/include/config.h b/firmware/esp32_ble_sim/include/config.h index 46b02fe..fdd739d 100644 --- a/firmware/esp32_ble_sim/include/config.h +++ b/firmware/esp32_ble_sim/include/config.h @@ -4,7 +4,7 @@ // ============================================ // Firmware Version // ============================================ -#define FIRMWARE_VERSION "1.0.7" +#define FIRMWARE_VERSION "1.1.0" // ============================================ // AP Mode Configuration diff --git a/firmware/esp32_ble_sim/include/services/ble_service.h b/firmware/esp32_ble_sim/include/services/ble_service.h index 4fd0660..7472abf 100644 --- a/firmware/esp32_ble_sim/include/services/ble_service.h +++ b/firmware/esp32_ble_sim/include/services/ble_service.h @@ -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() diff --git a/firmware/esp32_ble_sim/src/main.cpp b/firmware/esp32_ble_sim/src/main.cpp index a6b7d47..fd452f3 100644 --- a/firmware/esp32_ble_sim/src/main.cpp +++ b/firmware/esp32_ble_sim/src/main.cpp @@ -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() { diff --git a/firmware/esp32_ble_sim/src/services/ble_service.cpp b/firmware/esp32_ble_sim/src/services/ble_service.cpp index 707492a..2fb0635 100644 --- a/firmware/esp32_ble_sim/src/services/ble_service.cpp +++ b/firmware/esp32_ble_sim/src/services/ble_service.cpp @@ -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"); +} diff --git a/firmware/esp32_ble_sim/src/services/mqtt_service.cpp b/firmware/esp32_ble_sim/src/services/mqtt_service.cpp index d711ce8..b3418be 100644 --- a/firmware/esp32_ble_sim/src/services/mqtt_service.cpp +++ b/firmware/esp32_ble_sim/src/services/mqtt_service.cpp @@ -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() { diff --git a/src/api/hr_variation.py b/src/api/hr_variation.py index 90adb17..f87517c 100644 --- a/src/api/hr_variation.py +++ b/src/api/hr_variation.py @@ -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", diff --git a/src/api/main.py b/src/api/main.py index 741f49f..60bdbd4 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -5,7 +5,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect # Version info -__version__ = "1.3.1" +__version__ = "1.4.0" from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse diff --git a/src/api/mqtt_client.py b/src/api/mqtt_client.py index f2b63d9..d93c6fc 100644 --- a/src/api/mqtt_client.py +++ b/src/api/mqtt_client.py @@ -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() diff --git a/src/api/routes.py b/src/api/routes.py index 44d972b..27655e4 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -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.""" diff --git a/src/mcp/server.py b/src/mcp/server.py index 2c275cd..e5d5c1f 100644 --- a/src/mcp/server.py +++ b/src/mcp/server.py @@ -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", diff --git a/src/web/static/index.html b/src/web/static/index.html index 5b7a5f4..131b312 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -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 @@

pyBTMCP

-

BLE Device Simulator • UI v1.4.0 • API

+

BLE Device Simulator • UI v1.5.0 • API

@@ -706,6 +743,18 @@
+ ${device.type ? ` +
+ +
+ + + + +
+
+ ` : ''} + ${renderControls(device)}
`).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}`;