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() {