mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 10:41:54 +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
|
// Firmware Version
|
||||||
// ============================================
|
// ============================================
|
||||||
#define FIRMWARE_VERSION "1.0.7"
|
#define FIRMWARE_VERSION "1.1.0"
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// AP Mode Configuration
|
// AP Mode Configuration
|
||||||
|
|||||||
@@ -44,13 +44,23 @@ public:
|
|||||||
// State
|
// State
|
||||||
bool isClientConnected() const { return deviceConnected; }
|
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:
|
private:
|
||||||
BleService() = default;
|
BleService() = default;
|
||||||
|
|
||||||
bool deviceConnected = false;
|
bool deviceConnected = false;
|
||||||
unsigned long lastNotify = 0;
|
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 initBLE();
|
||||||
|
void reinitBLE(); // Reinit BLE and restore services
|
||||||
};
|
};
|
||||||
|
|
||||||
#define bleService BleService::getInstance()
|
#define bleService BleService::getInstance()
|
||||||
|
|||||||
@@ -47,11 +47,8 @@ void setup() {
|
|||||||
|
|
||||||
Serial.println("\nReady!");
|
Serial.println("\nReady!");
|
||||||
if (!configService.isConfigured()) {
|
if (!configService.isConfigured()) {
|
||||||
Serial.println("Configure at: http://192.168.4.1");
|
Serial.println("Configure at: http://192.168.4.1\n");
|
||||||
} else {
|
|
||||||
Serial.println("Connecting to WiFi...");
|
|
||||||
}
|
}
|
||||||
Serial.println("Waiting for MQTT commands...\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ static NimBLEService* pBatteryService = nullptr;
|
|||||||
static NimBLEService* pFitnessMachineService = nullptr;
|
static NimBLEService* pFitnessMachineService = nullptr;
|
||||||
|
|
||||||
static bool bleInitialized = false;
|
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
|
// Forward declare for callback
|
||||||
static void onBleConnect();
|
static void onBleConnect(NimBLEServer* server);
|
||||||
static void onBleDisconnect();
|
static void onBleDisconnect();
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -28,7 +30,7 @@ static void onBleDisconnect();
|
|||||||
// ============================================
|
// ============================================
|
||||||
class ServerCallbacks : public NimBLEServerCallbacks {
|
class ServerCallbacks : public NimBLEServerCallbacks {
|
||||||
void onConnect(NimBLEServer* pServer) override {
|
void onConnect(NimBLEServer* pServer) override {
|
||||||
onBleConnect();
|
onBleConnect(pServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onDisconnect(NimBLEServer* pServer) override {
|
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);
|
deviceState.setBleClientConnected(true);
|
||||||
Serial.println("BLE client connected");
|
Serial.print("BLE client connected (connId: ");
|
||||||
|
Serial.print(currentConnId);
|
||||||
|
Serial.println(")");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void onBleDisconnect() {
|
static void onBleDisconnect() {
|
||||||
|
currentConnId = 0;
|
||||||
deviceState.setBleClientConnected(false);
|
deviceState.setBleClientConnected(false);
|
||||||
Serial.println("BLE client disconnected");
|
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() {
|
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;
|
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
|
// Send notifications at regular intervals
|
||||||
if (millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
|
if (millis() - lastNotify >= BLE_NOTIFY_INTERVAL) {
|
||||||
lastNotify = millis();
|
lastNotify = millis();
|
||||||
@@ -269,3 +298,84 @@ void BleService::notifyTreadmill(uint16_t speed, int16_t incline, uint32_t dista
|
|||||||
pTreadmillData->setValue(data, 11);
|
pTreadmillData->setValue(data, 11);
|
||||||
pTreadmillData->notify();
|
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();
|
connectToMQTT();
|
||||||
mqtt.loop();
|
mqtt.loop();
|
||||||
|
|
||||||
// Periodic status report
|
// Periodic status and values report
|
||||||
if (mqttConnected && millis() - lastStatusReport >= STATUS_REPORT_INTERVAL) {
|
if (mqttConnected && millis() - lastStatusReport >= STATUS_REPORT_INTERVAL) {
|
||||||
lastStatusReport = millis();
|
lastStatusReport = millis();
|
||||||
publishStatus();
|
publishStatus();
|
||||||
|
publishValues(); // Include current values (e.g., accumulated distance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +84,11 @@ void MqttService::connectToMQTT() {
|
|||||||
// Subscribe to control topics
|
// Subscribe to control topics
|
||||||
String configTopic = String("ble-sim/") + configService.getDeviceId() + "/config";
|
String configTopic = String("ble-sim/") + configService.getDeviceId() + "/config";
|
||||||
String setTopic = String("ble-sim/") + configService.getDeviceId() + "/set";
|
String setTopic = String("ble-sim/") + configService.getDeviceId() + "/set";
|
||||||
|
String disconnectTopic = String("ble-sim/") + configService.getDeviceId() + "/disconnect";
|
||||||
|
|
||||||
mqtt.subscribe(configTopic.c_str());
|
mqtt.subscribe(configTopic.c_str());
|
||||||
mqtt.subscribe(setTopic.c_str());
|
mqtt.subscribe(setTopic.c_str());
|
||||||
|
mqtt.subscribe(disconnectTopic.c_str());
|
||||||
|
|
||||||
Serial.print("Subscribed to: ");
|
Serial.print("Subscribed to: ");
|
||||||
Serial.println(configTopic);
|
Serial.println(configTopic);
|
||||||
@@ -161,6 +164,23 @@ void MqttService::handleMessage(char* topic, byte* payload, unsigned int length)
|
|||||||
|
|
||||||
publishValues();
|
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() {
|
void MqttService::publishStatus() {
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ class HRVariationManager:
|
|||||||
|
|
||||||
async def _send_hr(self, device_id: str, hr: int):
|
async def _send_hr(self, device_id: str, hr: int):
|
||||||
"""Send HR value to device via MQTT and update registry."""
|
"""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:
|
if self._mqtt_manager and self._mqtt_manager.is_connected:
|
||||||
await self._mqtt_manager.publish(
|
await self._mqtt_manager.publish(
|
||||||
f"ble-sim/{device_id}/set",
|
f"ble-sim/{device_id}/set",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
# Version info
|
# Version info
|
||||||
__version__ = "1.3.1"
|
__version__ = "1.4.0"
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|||||||
@@ -132,6 +132,21 @@ class MQTTManager:
|
|||||||
values
|
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
|
# Global MQTT manager instance
|
||||||
mqtt_manager = MQTTManager()
|
mqtt_manager = MQTTManager()
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ class HRVariationConfig(BaseModel):
|
|||||||
target: int | None = Field(None, ge=30, le=220)
|
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")
|
@router.get("/devices")
|
||||||
async def list_devices():
|
async def list_devices():
|
||||||
"""List all connected 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}
|
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")
|
@router.get("/devices/{device_id}/hr-variation")
|
||||||
async def get_hr_variation(device_id: str):
|
async def get_hr_variation(device_id: str):
|
||||||
"""Get HR variation state for a device."""
|
"""Get HR variation state for a device."""
|
||||||
|
|||||||
@@ -152,6 +152,32 @@ async def list_tools() -> list[Tool]:
|
|||||||
"required": ["device_id"],
|
"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"
|
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:
|
else:
|
||||||
return [TextContent(
|
return [TextContent(
|
||||||
type="text",
|
type="text",
|
||||||
|
|||||||
@@ -280,6 +280,43 @@
|
|||||||
opacity: 0.8;
|
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 */
|
/* WebSocket connection status */
|
||||||
.connection-status {
|
.connection-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -311,7 +348,7 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div>
|
||||||
<h1>pyBTMCP</h1>
|
<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>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<div class="connection-status">
|
<div class="connection-status">
|
||||||
@@ -706,6 +743,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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)}
|
${renderControls(device)}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).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 = {};
|
let updateTimeouts = {};
|
||||||
function updateValueDebounced(deviceId, key, value) {
|
function updateValueDebounced(deviceId, key, value) {
|
||||||
const timeoutKey = `${deviceId}-${key}`;
|
const timeoutKey = `${deviceId}-${key}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user