mirror of
https://github.com/mattintech/pyBTMCP.git
synced 2026-07-11 12:01:53 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user