WellNuo/services/ble/WebBLEManager.ts
Sergei c2064a76eb Add Web Bluetooth support for browser-based sensor setup
- Add webBluetooth.ts with browser detection and compatibility checks
- Add WebBLEManager implementing IBLEManager for Web Bluetooth API
- Add BrowserNotSupported component showing clear error for Safari/Firefox
- Update services/ble/index.ts to use WebBLEManager on web platform
- Add comprehensive tests for browser detection and WebBLEManager

Works in Chrome/Edge/Opera, shows user-friendly error in unsupported browsers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-01 10:48:01 -08:00

999 lines
30 KiB
TypeScript

// Web Bluetooth BLE Manager for browser-based sensor configuration
// Supports Chrome, Edge, Opera (NOT Safari/Firefox)
import {
IBLEManager,
WPDevice,
WiFiNetwork,
WiFiStatus,
BLE_CONFIG,
BLE_COMMANDS,
BLEConnectionState,
BLEDeviceConnection,
BLEEventListener,
BLEConnectionEvent,
SensorHealthMetrics,
SensorHealthStatus,
WiFiSignalQuality,
CommunicationHealth,
BulkOperationResult,
BulkWiFiResult,
ReconnectConfig,
ReconnectState,
DEFAULT_RECONNECT_CONFIG,
} from './types';
import {
BLEError,
BLEErrorCode,
BLELogger,
isBLEError,
parseBLEError,
} from './errors';
import {
checkWebBluetoothSupport,
getUnsupportedBrowserMessage,
} from './webBluetooth';
// Web Bluetooth API types
// These types are available when running in a browser that supports Web Bluetooth
// We use `any` here to avoid conflicts with different type definitions
type WebBluetoothDevice = {
id: string;
name?: string;
gatt?: WebBluetoothGATTServer;
addEventListener(type: string, listener: EventListener): void;
removeEventListener(type: string, listener: EventListener): void;
};
type WebBluetoothGATTServer = {
device: WebBluetoothDevice;
connected: boolean;
connect(): Promise<WebBluetoothGATTServer>;
disconnect(): void;
getPrimaryService(service: string): Promise<WebBluetoothGATTService>;
};
type WebBluetoothGATTService = {
device: WebBluetoothDevice;
uuid: string;
getCharacteristic(characteristic: string): Promise<WebBluetoothGATTCharacteristic>;
};
type WebBluetoothGATTCharacteristic = {
service: WebBluetoothGATTService;
uuid: string;
value?: DataView;
startNotifications(): Promise<WebBluetoothGATTCharacteristic>;
stopNotifications(): Promise<WebBluetoothGATTCharacteristic>;
readValue(): Promise<DataView>;
writeValue(value: BufferSource): Promise<void>;
writeValueWithResponse(value: BufferSource): Promise<void>;
addEventListener(type: string, listener: EventListener): void;
removeEventListener(type: string, listener: EventListener): void;
};
/**
* Web Bluetooth implementation of BLE Manager
* Works in Chrome, Edge, Opera on desktop and Android
*/
export class WebBLEManager implements IBLEManager {
private connectedDevices = new Map<string, WebBluetoothDevice>();
private gattServers = new Map<string, WebBluetoothGATTServer>();
private characteristics = new Map<string, WebBluetoothGATTCharacteristic>();
private connectionStates = new Map<string, BLEDeviceConnection>();
private eventListeners: BLEEventListener[] = [];
private connectingDevices = new Set<string>();
// Health monitoring state
private sensorHealthMetrics = new Map<string, SensorHealthMetrics>();
private communicationStats = new Map<string, CommunicationHealth>();
// Reconnect state
private reconnectConfig: ReconnectConfig = { ...DEFAULT_RECONNECT_CONFIG };
private reconnectStates = new Map<string, ReconnectState>();
private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor() {
// Check browser support on initialization
const support = checkWebBluetoothSupport();
if (!support.supported) {
const msg = getUnsupportedBrowserMessage(support);
BLELogger.warn(`Web Bluetooth not supported: ${msg.message}`);
}
}
/**
* Check if Web Bluetooth is available before any operation
*/
private checkBluetoothAvailable(): void {
const support = checkWebBluetoothSupport();
if (!support.supported) {
const msg = getUnsupportedBrowserMessage(support);
throw new BLEError(BLEErrorCode.BLUETOOTH_DISABLED, {
message: `${msg.title}: ${msg.message} ${msg.suggestion}`,
});
}
}
/**
* Update connection state and notify listeners
*/
private updateConnectionState(
deviceId: string,
state: BLEConnectionState,
deviceName?: string,
error?: string
): void {
const existing = this.connectionStates.get(deviceId);
const now = Date.now();
const connection: BLEDeviceConnection = {
deviceId,
deviceName: deviceName || existing?.deviceName || deviceId,
state,
error,
connectedAt: state === BLEConnectionState.CONNECTED ? now : existing?.connectedAt,
lastActivity: now,
};
this.connectionStates.set(deviceId, connection);
this.emitEvent(deviceId, 'state_changed', { state, error });
}
/**
* Emit event to all registered listeners
*/
private emitEvent(deviceId: string, event: BLEConnectionEvent, data?: any): void {
this.eventListeners.forEach((listener) => {
try {
listener(deviceId, event, data);
} catch {
// Listener error should not crash the app
}
});
}
/**
* Get current connection state for a device
*/
getConnectionState(deviceId: string): BLEConnectionState {
const connection = this.connectionStates.get(deviceId);
return connection?.state || BLEConnectionState.DISCONNECTED;
}
/**
* Get all active connections
*/
getAllConnections(): Map<string, BLEDeviceConnection> {
return new Map(this.connectionStates);
}
/**
* Add event listener
*/
addEventListener(listener: BLEEventListener): void {
if (!this.eventListeners.includes(listener)) {
this.eventListeners.push(listener);
}
}
/**
* Remove event listener
*/
removeEventListener(listener: BLEEventListener): void {
const index = this.eventListeners.indexOf(listener);
if (index > -1) {
this.eventListeners.splice(index, 1);
}
}
/**
* Scan for WellNuo sensor devices
* In Web Bluetooth, this opens a browser picker dialog
*/
async scanDevices(): Promise<WPDevice[]> {
this.checkBluetoothAvailable();
BLELogger.log('[Web] Starting device scan (browser picker)...');
try {
// Request device with name prefix filter
const device = await navigator.bluetooth!.requestDevice({
filters: [{ namePrefix: BLE_CONFIG.DEVICE_NAME_PREFIX }],
optionalServices: [BLE_CONFIG.SERVICE_UUID],
});
if (!device || !device.name) {
BLELogger.log('[Web] No device selected');
return [];
}
// Parse device info from name (WP_497_81a14c)
const wellIdMatch = device.name.match(/WP_(\d+)_/);
const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined;
// Extract partial MAC from name
const macMatch = device.name.match(/_([a-fA-F0-9]{6})$/);
const mac = macMatch ? macMatch[1].toUpperCase() : '';
const wpDevice: WPDevice = {
id: device.id,
name: device.name,
mac,
rssi: -60, // Web Bluetooth doesn't provide RSSI during pairing
wellId,
};
BLELogger.log(`[Web] Device selected: ${device.name}`);
// Store device reference for later connection
this.connectedDevices.set(device.id, device);
return [wpDevice];
} catch (error: any) {
// User cancelled the picker
if (error.name === 'NotFoundError' || error.message?.includes('cancelled')) {
BLELogger.log('[Web] Device selection cancelled by user');
return [];
}
// Permission denied
if (error.name === 'SecurityError' || error.name === 'NotAllowedError') {
throw new BLEError(BLEErrorCode.PERMISSION_DENIED, {
message: 'Bluetooth permission denied. Please allow access in your browser settings.',
originalError: error,
});
}
throw parseBLEError(error, { operation: 'scan' });
}
}
/**
* Stop scan - no-op in Web Bluetooth (picker handles this)
*/
stopScan(): void {
// Web Bluetooth doesn't have a continuous scan to stop
}
/**
* Connect to a device by ID
*/
async connectDevice(deviceId: string): Promise<boolean> {
this.checkBluetoothAvailable();
const startTime = Date.now();
BLELogger.log(`[Web] Connecting to device: ${deviceId}`);
try {
// Check if connection is already in progress
if (this.connectingDevices.has(deviceId)) {
throw new BLEError(BLEErrorCode.CONNECTION_IN_PROGRESS, { deviceId });
}
// Check if already connected
const existingServer = this.gattServers.get(deviceId);
if (existingServer?.connected) {
BLELogger.log(`[Web] Device already connected: ${deviceId}`);
this.updateConnectionState(deviceId, BLEConnectionState.READY);
this.emitEvent(deviceId, 'ready');
return true;
}
// Get device reference
const device = this.connectedDevices.get(deviceId);
if (!device) {
throw new BLEError(BLEErrorCode.DEVICE_NOT_FOUND, {
deviceId,
message: 'Device not found. Please scan for devices first.',
});
}
// Mark as connecting
this.connectingDevices.add(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTING, device.name || undefined);
// Set up disconnection handler
device.addEventListener('gattserverdisconnected', () => {
this.handleDisconnection(deviceId, device.name);
});
// Connect to GATT server
if (!device.gatt) {
throw new BLEError(BLEErrorCode.CONNECTION_FAILED, {
deviceId,
message: 'Device does not support GATT',
});
}
const server = await device.gatt.connect();
this.gattServers.set(deviceId, server);
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTED, device.name || undefined);
// Discover services
this.updateConnectionState(deviceId, BLEConnectionState.DISCOVERING, device.name || undefined);
const service = await server.getPrimaryService(BLE_CONFIG.SERVICE_UUID);
const characteristic = await service.getCharacteristic(BLE_CONFIG.CHAR_UUID);
this.characteristics.set(deviceId, characteristic);
// Enable notifications
await characteristic.startNotifications();
// Ready
this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name || undefined);
this.emitEvent(deviceId, 'ready');
const duration = Date.now() - startTime;
BLELogger.log(`[Web] Device ready: ${device.name || deviceId} (${(duration / 1000).toFixed(1)}s)`);
return true;
} catch (error: any) {
const bleError = isBLEError(error) ? error : parseBLEError(error, { deviceId });
const errorMessage = bleError.userMessage.message;
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, errorMessage);
this.emitEvent(deviceId, 'connection_failed', { error: errorMessage, code: bleError.code });
BLELogger.error(`[Web] Connection failed for ${deviceId}`, bleError);
return false;
} finally {
this.connectingDevices.delete(deviceId);
}
}
/**
* Handle device disconnection
*/
private handleDisconnection(deviceId: string, deviceName?: string): void {
BLELogger.log(`[Web] Device disconnected: ${deviceName || deviceId}`);
// Clean up
this.gattServers.delete(deviceId);
this.characteristics.delete(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, deviceName);
this.emitEvent(deviceId, 'disconnected', { unexpected: true });
// Handle auto-reconnect if enabled
if (this.reconnectConfig.enabled) {
const state = this.reconnectStates.get(deviceId);
if (state && state.attempts < this.reconnectConfig.maxAttempts) {
this.scheduleReconnect(deviceId, deviceName || deviceId);
}
}
}
/**
* Disconnect from a device
*/
async disconnectDevice(deviceId: string): Promise<void> {
BLELogger.log(`[Web] Disconnecting device: ${deviceId}`);
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTING);
// Stop notifications
const characteristic = this.characteristics.get(deviceId);
if (characteristic) {
try {
await characteristic.stopNotifications();
} catch {
// Ignore errors during cleanup
}
}
// Disconnect GATT
const server = this.gattServers.get(deviceId);
if (server?.connected) {
server.disconnect();
}
// Clean up
this.gattServers.delete(deviceId);
this.characteristics.delete(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED);
this.emitEvent(deviceId, 'disconnected');
}
/**
* Check if device is connected
*/
isDeviceConnected(deviceId: string): boolean {
const server = this.gattServers.get(deviceId);
return server?.connected || false;
}
/**
* Update communication stats for a device
*/
private updateCommunicationStats(deviceKey: string, success: boolean, responseTime: number): void {
const existing = this.communicationStats.get(deviceKey);
const now = Date.now();
if (!existing) {
this.communicationStats.set(deviceKey, {
successfulCommands: success ? 1 : 0,
failedCommands: success ? 0 : 1,
averageResponseTime: responseTime,
lastSuccessfulCommand: success ? now : 0,
lastFailedCommand: success ? undefined : now,
});
} else {
const totalCommands = existing.successfulCommands + existing.failedCommands;
const newAverage =
(existing.averageResponseTime * totalCommands + responseTime) / (totalCommands + 1);
this.communicationStats.set(deviceKey, {
successfulCommands: existing.successfulCommands + (success ? 1 : 0),
failedCommands: existing.failedCommands + (success ? 0 : 1),
averageResponseTime: newAverage,
lastSuccessfulCommand: success ? now : existing.lastSuccessfulCommand,
lastFailedCommand: success ? existing.lastFailedCommand : now,
});
}
}
/**
* Send a command to a device and wait for response
*/
async sendCommand(deviceId: string, command: string): Promise<string> {
const startTime = Date.now();
const safeCommand = command.length > 20 ? command.substring(0, 20) + '...' : command;
BLELogger.log(`[Web] Sending command to ${deviceId}: ${safeCommand}`);
const characteristic = this.characteristics.get(deviceId);
if (!characteristic) {
throw new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, { deviceId });
}
return new Promise(async (resolve, reject) => {
let responseReceived = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
characteristic.removeEventListener('characteristicvaluechanged', handleNotification as EventListener);
};
const handleNotification = (event: Event) => {
const target = event.target as unknown as WebBluetoothGATTCharacteristic;
if (!target.value || responseReceived) return;
responseReceived = true;
cleanup();
// Decode response
const decoder = new TextDecoder('utf-8');
const response = decoder.decode(target.value);
// Track successful command
const responseTime = Date.now() - startTime;
this.updateCommunicationStats(deviceId, true, responseTime);
resolve(response);
};
try {
// Set up notification handler
characteristic.addEventListener('characteristicvaluechanged', handleNotification as EventListener);
// Send command
const encoder = new TextEncoder();
const data = encoder.encode(command);
await characteristic.writeValueWithResponse(data);
// Set timeout
timeoutId = setTimeout(() => {
if (!responseReceived) {
responseReceived = true;
cleanup();
const responseTime = Date.now() - startTime;
this.updateCommunicationStats(deviceId, false, responseTime);
reject(new BLEError(BLEErrorCode.COMMAND_TIMEOUT, {
deviceId,
message: `Command timed out after ${BLE_CONFIG.COMMAND_TIMEOUT}ms`,
}));
}
}, BLE_CONFIG.COMMAND_TIMEOUT);
} catch (error: any) {
cleanup();
const responseTime = Date.now() - startTime;
this.updateCommunicationStats(deviceId, false, responseTime);
reject(parseBLEError(error, { deviceId, operation: 'command' }));
}
});
}
/**
* Get WiFi networks list from sensor
*/
async getWiFiList(deviceId: string): Promise<WiFiNetwork[]> {
BLELogger.log(`[Web] Getting WiFi list from device: ${deviceId}`);
// Step 1: Unlock device
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
if (!unlockResponse.includes('ok')) {
throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId });
}
// Step 2: Get WiFi list
const listResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_LIST);
// Parse response: "mac,XXXXXX|w|COUNT|SSID1,RSSI1|SSID2,RSSI2|..."
const parts = listResponse.split('|');
if (parts.length < 3) {
throw new BLEError(BLEErrorCode.INVALID_RESPONSE, {
deviceId,
message: 'Invalid WiFi list response format',
});
}
const count = parseInt(parts[2], 10);
if (count < 0) {
if (count === -1) {
throw new BLEError(BLEErrorCode.WIFI_SCAN_IN_PROGRESS, { deviceId });
}
if (count === -2) {
return []; // No networks found
}
}
// Use Map to deduplicate by SSID
const networksMap = new Map<string, WiFiNetwork>();
for (let i = 3; i < parts.length; i++) {
const [ssid, rssiStr] = parts[i].split(',');
if (ssid && rssiStr) {
const trimmedSsid = ssid.trim();
const rssi = parseInt(rssiStr, 10);
if (!trimmedSsid) continue;
const existing = networksMap.get(trimmedSsid);
if (!existing || rssi > existing.rssi) {
networksMap.set(trimmedSsid, { ssid: trimmedSsid, rssi });
}
}
}
return Array.from(networksMap.values()).sort((a, b) => b.rssi - a.rssi);
}
/**
* Configure WiFi on sensor
*/
async setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean> {
BLELogger.log(`[Web] Setting WiFi on device: ${deviceId}, SSID: ${ssid}`);
// Validate credentials
if (ssid.includes('|') || ssid.includes(',')) {
throw new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, {
deviceId,
message: 'Network name contains invalid characters',
});
}
if (password.includes('|')) {
throw new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, {
deviceId,
message: 'Password contains an invalid character (|)',
});
}
// Step 1: Unlock device
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
if (!unlockResponse.includes('ok')) {
throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId });
}
// Step 2: Set WiFi credentials
const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`;
const setResponse = await this.sendCommand(deviceId, command);
if (setResponse.includes('|W|ok')) {
BLELogger.log(`[Web] WiFi configured successfully for ${ssid}`);
return true;
}
if (setResponse.includes('|W|fail')) {
throw new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT, { deviceId });
}
throw new BLEError(BLEErrorCode.WIFI_CONFIG_FAILED, {
deviceId,
message: `Unexpected response: ${setResponse}`,
});
}
/**
* Get current WiFi status from sensor
*/
async getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null> {
BLELogger.log(`[Web] Getting current WiFi status from device: ${deviceId}`);
// Step 1: Unlock device
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
if (!unlockResponse.includes('ok')) {
throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId });
}
// Step 2: Get current WiFi status
const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
// Parse response: "mac,XXXXXX|a|SSID,RSSI"
const parts = statusResponse.split('|');
if (parts.length < 3) {
return null;
}
const [ssid, rssiStr] = parts[2].split(',');
if (!ssid || ssid.trim() === '') {
return null;
}
return {
ssid: ssid.trim(),
rssi: parseInt(rssiStr, 10),
connected: true,
};
}
/**
* Reboot sensor
*/
async rebootDevice(deviceId: string): Promise<void> {
BLELogger.log(`[Web] Rebooting device: ${deviceId}`);
try {
await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
await this.sendCommand(deviceId, BLE_COMMANDS.REBOOT);
} catch (error: any) {
if (isBLEError(error)) throw error;
throw new BLEError(BLEErrorCode.SENSOR_REBOOT_FAILED, {
deviceId,
originalError: error,
});
}
// Clean up after reboot
this.gattServers.delete(deviceId);
this.characteristics.delete(deviceId);
}
/**
* Get sensor health metrics
*/
async getSensorHealth(wellId: number, mac: string): Promise<SensorHealthMetrics | null> {
// Web Bluetooth requires user interaction for each device scan
// Return cached metrics or null
const deviceKey = `WP_${wellId}_${mac.slice(-6).toLowerCase()}`;
return this.sensorHealthMetrics.get(deviceKey) || null;
}
/**
* Get all cached sensor health metrics
*/
getAllSensorHealth(): Map<string, SensorHealthMetrics> {
return new Map(this.sensorHealthMetrics);
}
/**
* Cleanup all connections
*/
async cleanup(): Promise<void> {
BLELogger.log('[Web] Cleaning up BLE connections');
// Cancel all reconnect timers
this.reconnectTimers.forEach((timer) => {
clearTimeout(timer);
});
this.reconnectTimers.clear();
this.reconnectStates.clear();
// Disconnect all devices
const deviceIds = Array.from(this.gattServers.keys());
for (const deviceId of deviceIds) {
try {
await this.disconnectDevice(deviceId);
} catch {
// Continue cleanup
}
}
// Clear all state
this.connectedDevices.clear();
this.gattServers.clear();
this.characteristics.clear();
this.connectionStates.clear();
this.connectingDevices.clear();
this.sensorHealthMetrics.clear();
this.communicationStats.clear();
this.eventListeners = [];
}
/**
* Bulk disconnect multiple devices
*/
async bulkDisconnect(deviceIds: string[]): Promise<BulkOperationResult[]> {
const results: BulkOperationResult[] = [];
for (const deviceId of deviceIds) {
const connection = this.connectionStates.get(deviceId);
const deviceName = connection?.deviceName || deviceId;
try {
await this.disconnectDevice(deviceId);
results.push({ deviceId, deviceName, success: true });
} catch (error: any) {
results.push({
deviceId,
deviceName,
success: false,
error: error?.message || 'Disconnect failed',
});
}
}
return results;
}
/**
* Bulk reboot multiple devices
*/
async bulkReboot(deviceIds: string[]): Promise<BulkOperationResult[]> {
const results: BulkOperationResult[] = [];
for (const deviceId of deviceIds) {
const connection = this.connectionStates.get(deviceId);
const deviceName = connection?.deviceName || deviceId;
try {
await this.rebootDevice(deviceId);
results.push({ deviceId, deviceName, success: true });
} catch (error: any) {
results.push({
deviceId,
deviceName,
success: false,
error: error?.message || 'Reboot failed',
});
}
}
return results;
}
/**
* Bulk WiFi configuration
*/
async bulkSetWiFi(
devices: { id: string; name: string }[],
ssid: string,
password: string,
onProgress?: (deviceId: string, status: 'connecting' | 'configuring' | 'rebooting' | 'success' | 'error', error?: string) => void
): Promise<BulkWiFiResult[]> {
const results: BulkWiFiResult[] = [];
const total = devices.length;
const batchStartTime = Date.now();
BLELogger.log(`[Web] Starting bulk WiFi setup for ${total} devices, SSID: ${ssid}`);
for (let i = 0; i < devices.length; i++) {
const { id: deviceId, name: deviceName } = devices[i];
const index = i + 1;
try {
// Step 1: Connect
BLELogger.logBatchProgress(index, total, deviceName, 'connecting...');
onProgress?.(deviceId, 'connecting');
const connected = await this.connectDevice(deviceId);
if (!connected) {
throw new BLEError(BLEErrorCode.CONNECTION_FAILED, { deviceId, deviceName });
}
// Step 2: Set WiFi
BLELogger.logBatchProgress(index, total, deviceName, 'setting WiFi...');
onProgress?.(deviceId, 'configuring');
await this.setWiFi(deviceId, ssid, password);
// Step 3: Reboot
BLELogger.logBatchProgress(index, total, deviceName, 'rebooting...');
onProgress?.(deviceId, 'rebooting');
await this.rebootDevice(deviceId);
// Success
BLELogger.logBatchProgress(index, total, deviceName, 'SUCCESS', true);
onProgress?.(deviceId, 'success');
results.push({ deviceId, deviceName, success: true });
} catch (error: any) {
const bleError = isBLEError(error) ? error : parseBLEError(error, { deviceId, deviceName });
const errorMessage = bleError.userMessage.message;
BLELogger.logBatchProgress(index, total, deviceName, `ERROR: ${errorMessage}`, false);
onProgress?.(deviceId, 'error', errorMessage);
results.push({ deviceId, deviceName, success: false, error: errorMessage });
}
}
// Log summary
const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
const batchDuration = Date.now() - batchStartTime;
BLELogger.logBatchSummary(total, succeeded, failed, batchDuration);
return results;
}
// ==================== RECONNECT FUNCTIONALITY ====================
setReconnectConfig(config: Partial<ReconnectConfig>): void {
this.reconnectConfig = { ...this.reconnectConfig, ...config };
}
getReconnectConfig(): ReconnectConfig {
return { ...this.reconnectConfig };
}
enableAutoReconnect(deviceId: string, deviceName?: string): void {
const device = this.connectedDevices.get(deviceId);
this.reconnectStates.set(deviceId, {
deviceId,
deviceName: deviceName || device?.name || deviceId,
attempts: 0,
lastAttemptTime: 0,
isReconnecting: false,
});
}
disableAutoReconnect(deviceId: string): void {
this.cancelReconnect(deviceId);
this.reconnectStates.delete(deviceId);
}
cancelReconnect(deviceId: string): void {
const timer = this.reconnectTimers.get(deviceId);
if (timer) {
clearTimeout(timer);
this.reconnectTimers.delete(deviceId);
}
const state = this.reconnectStates.get(deviceId);
if (state?.isReconnecting) {
this.reconnectStates.set(deviceId, {
...state,
isReconnecting: false,
nextAttemptTime: undefined,
});
}
}
private scheduleReconnect(deviceId: string, deviceName: string): void {
const state = this.reconnectStates.get(deviceId);
if (!state) return;
const delay = Math.min(
this.reconnectConfig.delayMs * Math.pow(this.reconnectConfig.backoffMultiplier, state.attempts),
this.reconnectConfig.maxDelayMs
);
const nextAttemptTime = Date.now() + delay;
this.reconnectStates.set(deviceId, {
...state,
nextAttemptTime,
isReconnecting: true,
});
this.emitEvent(deviceId, 'state_changed', {
state: BLEConnectionState.CONNECTING,
reconnecting: true,
nextAttemptIn: delay,
});
const timer = setTimeout(() => {
this.attemptReconnect(deviceId, deviceName);
}, delay);
this.reconnectTimers.set(deviceId, timer);
}
private async attemptReconnect(deviceId: string, deviceName: string): Promise<void> {
const state = this.reconnectStates.get(deviceId);
if (!state) return;
const newAttempts = state.attempts + 1;
this.reconnectStates.set(deviceId, {
...state,
attempts: newAttempts,
lastAttemptTime: Date.now(),
isReconnecting: true,
});
try {
const success = await this.connectDevice(deviceId);
if (success) {
this.reconnectStates.set(deviceId, {
deviceId,
deviceName,
attempts: 0,
lastAttemptTime: Date.now(),
isReconnecting: false,
});
this.emitEvent(deviceId, 'ready', { reconnected: true });
} else {
throw new Error('Connection failed');
}
} catch (error: any) {
this.reconnectStates.set(deviceId, {
...state,
attempts: newAttempts,
lastAttemptTime: Date.now(),
isReconnecting: newAttempts < this.reconnectConfig.maxAttempts,
lastError: error?.message || 'Reconnection failed',
});
if (newAttempts < this.reconnectConfig.maxAttempts) {
this.scheduleReconnect(deviceId, deviceName);
} else {
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, deviceName, 'Max reconnection attempts reached');
this.emitEvent(deviceId, 'connection_failed', {
error: 'Max reconnection attempts reached',
reconnectFailed: true,
});
}
}
}
async manualReconnect(deviceId: string): Promise<boolean> {
this.cancelReconnect(deviceId);
const state = this.reconnectStates.get(deviceId);
const connection = this.connectionStates.get(deviceId);
const deviceName = state?.deviceName || connection?.deviceName || deviceId;
this.reconnectStates.set(deviceId, {
deviceId,
deviceName,
attempts: 0,
lastAttemptTime: Date.now(),
isReconnecting: true,
});
try {
const success = await this.connectDevice(deviceId);
this.reconnectStates.set(deviceId, {
deviceId,
deviceName,
attempts: 0,
lastAttemptTime: Date.now(),
isReconnecting: false,
});
return success;
} catch (error: any) {
this.reconnectStates.set(deviceId, {
deviceId,
deviceName,
attempts: 1,
lastAttemptTime: Date.now(),
isReconnecting: false,
lastError: error?.message || 'Reconnection failed',
});
return false;
}
}
getReconnectState(deviceId: string): ReconnectState | undefined {
return this.reconnectStates.get(deviceId);
}
getAllReconnectStates(): Map<string, ReconnectState> {
return new Map(this.reconnectStates);
}
}