v1.3.1: Fix stale devices in admin UI and firmware device type persistence

Admin UI fixes:
- Track deleted devices to prevent MQTT re-registration from stale messages
- Broadcast device_deleted event via WebSocket to sync all clients
- Add removeDeviceFromUI() to clean up device cards and pending timers
- Improve delete error handling with user feedback

Firmware fixes (v1.0.7):
- Fix device type strings to match API expectations (heart_rate vs Heart Rate)
- Clean up BLE services when switching device types (prevents service accumulation)
- Call stop() before setupHeartRate()/setupTreadmill() to reset NimBLE state
- Track service pointers for proper cleanup with pServer->removeService()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 17:18:57 -05:00
parent c95cd33343
commit 0d34cae441
7 changed files with 136 additions and 34 deletions

View File

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

View File

@@ -7,9 +7,9 @@ DeviceState& DeviceState::getInstance() {
const char* DeviceState::getDeviceTypeString() const { const char* DeviceState::getDeviceTypeString() const {
switch (deviceType) { switch (deviceType) {
case DeviceType::HEART_RATE: return "Heart Rate"; case DeviceType::HEART_RATE: return "heart_rate";
case DeviceType::TREADMILL: return "Treadmill"; case DeviceType::TREADMILL: return "treadmill";
default: return "Not configured"; default: return "";
} }
} }

View File

@@ -12,6 +12,11 @@ static NimBLECharacteristic* pHeartRateMeasurement = nullptr;
static NimBLECharacteristic* pBatteryLevel = nullptr; static NimBLECharacteristic* pBatteryLevel = nullptr;
static NimBLECharacteristic* pTreadmillData = nullptr; static NimBLECharacteristic* pTreadmillData = nullptr;
// Track created services for cleanup
static NimBLEService* pHeartRateService = nullptr;
static NimBLEService* pBatteryService = nullptr;
static NimBLEService* pFitnessMachineService = nullptr;
static bool bleInitialized = false; static bool bleInitialized = false;
// Forward declare for callback // Forward declare for callback
@@ -97,13 +102,32 @@ void BleService::initBLE() {
void BleService::stop() { void BleService::stop() {
if (pAdvertising) { if (pAdvertising) {
pAdvertising->stop(); pAdvertising->stop();
// Clear all service UUIDs from advertising
pAdvertising->reset();
} }
// Remove existing services from the server
if (pServer) {
if (pHeartRateService) {
pServer->removeService(pHeartRateService);
pHeartRateService = nullptr;
}
if (pBatteryService) {
pServer->removeService(pBatteryService);
pBatteryService = nullptr;
}
if (pFitnessMachineService) {
pServer->removeService(pFitnessMachineService);
pFitnessMachineService = nullptr;
}
}
// Clear characteristic pointers
pHeartRateMeasurement = nullptr; pHeartRateMeasurement = nullptr;
pBatteryLevel = nullptr; pBatteryLevel = nullptr;
pTreadmillData = nullptr; pTreadmillData = nullptr;
Serial.println("BLE stopped"); Serial.println("BLE stopped and services cleaned up");
} }
// ============================================ // ============================================
@@ -112,29 +136,28 @@ void BleService::stop() {
void BleService::setupHeartRate() { void BleService::setupHeartRate() {
Serial.println("Setting up Heart Rate Service..."); Serial.println("Setting up Heart Rate Service...");
if (pAdvertising) { // Clean up any existing services first
pAdvertising->stop(); stop();
}
// Create Heart Rate Service // Create Heart Rate Service
NimBLEService* pHRService = pServer->createService(HEART_RATE_SERVICE_UUID); pHeartRateService = pServer->createService(HEART_RATE_SERVICE_UUID);
pHeartRateMeasurement = pHRService->createCharacteristic( pHeartRateMeasurement = pHeartRateService->createCharacteristic(
HEART_RATE_MEASUREMENT_UUID, HEART_RATE_MEASUREMENT_UUID,
NIMBLE_PROPERTY::NOTIFY NIMBLE_PROPERTY::NOTIFY
); );
NimBLECharacteristic* pBodySensorLocation = pHRService->createCharacteristic( NimBLECharacteristic* pBodySensorLocation = pHeartRateService->createCharacteristic(
BODY_SENSOR_LOCATION_UUID, BODY_SENSOR_LOCATION_UUID,
NIMBLE_PROPERTY::READ NIMBLE_PROPERTY::READ
); );
uint8_t sensorLocation = 1; // Chest uint8_t sensorLocation = 1; // Chest
pBodySensorLocation->setValue(&sensorLocation, 1); pBodySensorLocation->setValue(&sensorLocation, 1);
pHRService->start(); pHeartRateService->start();
// Create Battery Service // Create Battery Service
NimBLEService* pBatteryService = pServer->createService(BATTERY_SERVICE_UUID); pBatteryService = pServer->createService(BATTERY_SERVICE_UUID);
pBatteryLevel = pBatteryService->createCharacteristic( pBatteryLevel = pBatteryService->createCharacteristic(
BATTERY_LEVEL_UUID, BATTERY_LEVEL_UUID,
@@ -164,13 +187,12 @@ void BleService::setupHeartRate() {
void BleService::setupTreadmill() { void BleService::setupTreadmill() {
Serial.println("Setting up Fitness Machine Service (Treadmill)..."); Serial.println("Setting up Fitness Machine Service (Treadmill)...");
if (pAdvertising) { // Clean up any existing services first
pAdvertising->stop(); stop();
}
NimBLEService* pService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID); pFitnessMachineService = pServer->createService(FITNESS_MACHINE_SERVICE_UUID);
NimBLECharacteristic* pFeature = pService->createCharacteristic( NimBLECharacteristic* pFeature = pFitnessMachineService->createCharacteristic(
FITNESS_MACHINE_FEATURE_UUID, FITNESS_MACHINE_FEATURE_UUID,
NIMBLE_PROPERTY::READ NIMBLE_PROPERTY::READ
); );
@@ -181,12 +203,12 @@ void BleService::setupTreadmill() {
}; };
pFeature->setValue(featureData, 8); pFeature->setValue(featureData, 8);
pTreadmillData = pService->createCharacteristic( pTreadmillData = pFitnessMachineService->createCharacteristic(
TREADMILL_DATA_UUID, TREADMILL_DATA_UUID,
NIMBLE_PROPERTY::NOTIFY NIMBLE_PROPERTY::NOTIFY
); );
pService->start(); pFitnessMachineService->start();
pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID); pAdvertising->addServiceUUID(FITNESS_MACHINE_SERVICE_UUID);
pAdvertising->setScanResponse(true); pAdvertising->setScanResponse(true);

View File

@@ -9,9 +9,18 @@ class DeviceRegistry:
def __init__(self): def __init__(self):
self._devices: dict[str, dict[str, Any]] = {} self._devices: dict[str, dict[str, Any]] = {}
# Track recently deleted devices to prevent MQTT re-registration
self._deleted_devices: set[str] = set()
def register_device(self, device_id: str, info: dict | None = None) -> bool:
"""Register a new device or update existing.
Returns False if device was recently deleted (won't be registered).
"""
# Skip recently deleted devices - prevents stale MQTT messages from re-registering
if device_id in self._deleted_devices:
return False
def register_device(self, device_id: str, info: dict | None = None):
"""Register a new device or update existing."""
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
if device_id not in self._devices: if device_id not in self._devices:
@@ -30,10 +39,16 @@ class DeviceRegistry:
if info: if info:
self._devices[device_id].update(info) self._devices[device_id].update(info)
def update_device(self, device_id: str, updates: dict): return True
"""Update device state."""
def update_device(self, device_id: str, updates: dict) -> bool:
"""Update device state.
Returns False if device was recently deleted (won't be updated).
"""
if device_id not in self._devices: if device_id not in self._devices:
self.register_device(device_id) if not self.register_device(device_id):
return False # Device was deleted, don't update
for key, value in updates.items(): for key, value in updates.items():
if key == "values" and isinstance(value, dict): if key == "values" and isinstance(value, dict):
@@ -45,6 +60,7 @@ class DeviceRegistry:
self._devices[device_id][key] = value self._devices[device_id][key] = value
self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat() self._devices[device_id]["last_seen"] = datetime.utcnow().isoformat()
return True
def mark_offline(self, device_id: str): def mark_offline(self, device_id: str):
"""Mark a device as offline.""" """Mark a device as offline."""
@@ -60,8 +76,22 @@ class DeviceRegistry:
return list(self._devices.values()) return list(self._devices.values())
def remove_device(self, device_id: str): def remove_device(self, device_id: str):
"""Remove a device from the registry.""" """Remove a device from the registry and mark as deleted."""
self._devices.pop(device_id, None) self._devices.pop(device_id, None)
# Track as deleted to prevent MQTT re-registration
self._deleted_devices.add(device_id)
def is_deleted(self, device_id: str) -> bool:
"""Check if a device was recently deleted."""
return device_id in self._deleted_devices
def clear_deleted(self, device_id: str):
"""Allow a deleted device to be re-registered (for manual re-add)."""
self._deleted_devices.discard(device_id)
def clear_all_deleted(self):
"""Clear all deleted device tracking (e.g., on server restart)."""
self._deleted_devices.clear()
# Global registry instance # Global registry instance

View File

@@ -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.0" __version__ = "1.3.1"
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
@@ -56,7 +56,10 @@ async def handle_device_status(topic: str, payload: str):
device_id = parts[1] device_id = parts[1]
try: try:
data = json.loads(payload) data = json.loads(payload)
device_registry.register_device(device_id) # Skip if device was recently deleted (prevents stale MQTT messages from re-registering)
if not device_registry.register_device(device_id):
print(f"Ignoring status from deleted device: {device_id}")
return
update_data = { update_data = {
"online": data.get("online", True), "online": data.get("online", True),
"type": data.get("type"), "type": data.get("type"),
@@ -85,7 +88,10 @@ async def handle_device_values(topic: str, payload: str):
device_id = parts[1] device_id = parts[1]
try: try:
data = json.loads(payload) data = json.loads(payload)
device_registry.update_device(device_id, {"values": data}) # Skip if device was recently deleted
if not device_registry.update_device(device_id, {"values": data}):
print(f"Ignoring values from deleted device: {device_id}")
return
print(f"Device {device_id} values updated: {data}") print(f"Device {device_id} values updated: {data}")
# Broadcast to WebSocket clients # Broadcast to WebSocket clients
await ws_manager.broadcast({ await ws_manager.broadcast({

View File

@@ -63,12 +63,23 @@ async def delete_device(device_id: str):
try: try:
await mqtt_manager.clear_retained(f"ble-sim/{device_id}/status") await mqtt_manager.clear_retained(f"ble-sim/{device_id}/status")
await mqtt_manager.clear_retained(f"ble-sim/{device_id}/values") await mqtt_manager.clear_retained(f"ble-sim/{device_id}/values")
except Exception: except Exception as e:
pass # MQTT might not be connected # Log the error but continue with deletion
print(f"Warning: Failed to clear MQTT retained messages for {device_id}: {e}")
# Remove from registry # Remove from registry (also marks as deleted to block re-registration)
device_registry.remove_device(device_id) device_registry.remove_device(device_id)
# Broadcast deletion to all WebSocket clients so they remove the device card
try:
from .main import ws_manager
await ws_manager.broadcast({
"type": "device_deleted",
"device_id": device_id
})
except Exception as e:
print(f"Warning: Failed to broadcast device deletion: {e}")
return {"status": "ok", "device_id": device_id, "message": "Device removed"} return {"status": "ok", "device_id": device_id, "message": "Device removed"}

View File

@@ -446,12 +446,36 @@
} else if (message.type === 'device_update') { } else if (message.type === 'device_update') {
// Single device updated - update UI efficiently // Single device updated - update UI efficiently
updateDeviceInUI(message.device_id, message.data); updateDeviceInUI(message.device_id, message.data);
} else if (message.type === 'device_deleted') {
// Device was deleted - remove from UI
removeDeviceFromUI(message.device_id);
} else if (message.type === 'hr_update') { } else if (message.type === 'hr_update') {
// HR value update from server-side variation // HR value update from server-side variation
updateHrDisplay(message.device_id, message.heart_rate); updateHrDisplay(message.device_id, message.heart_rate);
} }
} }
function removeDeviceFromUI(deviceId) {
const container = document.getElementById('devices');
const card = container.querySelector(`[data-id="${deviceId}"]`);
if (card) {
card.remove();
console.log(`Removed deleted device card: ${deviceId}`);
}
// Clean up any pending timers for this device
if (hrTargetDebounceTimers[deviceId]) {
clearTimeout(hrTargetDebounceTimers[deviceId]);
delete hrTargetDebounceTimers[deviceId];
}
// Clean up any pending value update timers
Object.keys(updateTimeouts).forEach(key => {
if (key.startsWith(deviceId + '-')) {
clearTimeout(updateTimeouts[key]);
delete updateTimeouts[key];
}
});
}
function updateHrDisplay(deviceId, hr) { function updateHrDisplay(deviceId, hr) {
const display = document.getElementById(`hr-display-${deviceId}`); const display = document.getElementById(`hr-display-${deviceId}`);
if (display) { if (display) {
@@ -925,12 +949,21 @@
async function deleteDevice(deviceId) { async function deleteDevice(deviceId) {
if (!confirm(`Remove device ${deviceId} from the list?`)) return; if (!confirm(`Remove device ${deviceId} from the list?`)) return;
try { try {
await fetch(`${API_BASE}/devices/${deviceId}`, { const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'DELETE' method: 'DELETE'
}); });
loadDevices(); if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.error('Failed to delete device:', error.detail || response.statusText);
alert(`Failed to delete device: ${error.detail || response.statusText}`);
return;
}
// WebSocket broadcast will handle removing the device from UI
// But also remove locally in case WebSocket is not connected
removeDeviceFromUI(deviceId);
} catch (error) { } catch (error) {
console.error('Failed to delete device:', error); console.error('Failed to delete device:', error);
alert('Failed to delete device. Check console for details.');
} }
} }