mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 10:51:53 +00:00
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:
@@ -4,7 +4,7 @@
|
||||
// ============================================
|
||||
// Firmware Version
|
||||
// ============================================
|
||||
#define FIRMWARE_VERSION "1.0.7"
|
||||
#define FIRMWARE_VERSION "1.1.0"
|
||||
|
||||
// ============================================
|
||||
// AP Mode Configuration
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,16 +38,27 @@ 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");
|
||||
|
||||
// Only auto-resume advertising if not paused
|
||||
if (!advertisingPausedFlag) {
|
||||
NimBLEDevice::startAdvertising();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Singleton
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 • UI v1.4.0 • API <span id="backendVersion"></span></p>
|
||||
<p class="subtitle">BLE Device Simulator • UI v1.5.0 • 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}`;
|
||||
|
||||
Reference in New Issue
Block a user