- Add BLEError class with error codes, severity levels, and recovery actions - Create error types for connection, permission, communication, WiFi, and sensor errors - Add user-friendly error messages with localized titles - Implement BLELogger for consistent logging with batch progress tracking - Add parseBLEError utility to parse native BLE errors into typed BLEErrors - Update BLEManager to use new error types with proper logging - Update MockBLEManager to match error handling behavior for consistency - Add comprehensive tests for error handling utilities (41 tests passing) This enables proper error categorization, user-friendly messages, and recovery suggestions for BLE operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1456 lines
46 KiB
TypeScript
1456 lines
46 KiB
TypeScript
// Real BLE Manager для физических устройств
|
|
|
|
import { BleManager, Device } from 'react-native-ble-plx';
|
|
import { Platform } from 'react-native';
|
|
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,
|
|
parseBLEError,
|
|
isBLEError,
|
|
} from './errors';
|
|
import { requestBLEPermissions, checkBluetoothEnabled } from './permissions';
|
|
import base64 from 'react-native-base64';
|
|
|
|
export class RealBLEManager implements IBLEManager {
|
|
private _manager: BleManager | null = null;
|
|
private connectedDevices = new Map<string, Device>();
|
|
private connectionStates = new Map<string, BLEDeviceConnection>();
|
|
private eventListeners: BLEEventListener[] = [];
|
|
private scanning = false;
|
|
private connectingDevices = new Set<string>(); // Track devices currently being connected
|
|
|
|
// 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>>();
|
|
private disconnectionSubscriptions = new Map<string, any>(); // Device disconnect monitors
|
|
|
|
// Lazy initialization to prevent crash on app startup
|
|
private get manager(): BleManager {
|
|
if (!this._manager) {
|
|
this._manager = new BleManager();
|
|
}
|
|
return this._manager;
|
|
}
|
|
|
|
// Empty constructor - using lazy initialization for BleManager
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
async scanDevices(): Promise<WPDevice[]> {
|
|
BLELogger.log('Starting device scan...');
|
|
const startTime = Date.now();
|
|
|
|
// Check permissions with graceful fallback
|
|
const permissionStatus = await requestBLEPermissions();
|
|
if (!permissionStatus.granted) {
|
|
const error = new BLEError(BLEErrorCode.PERMISSION_DENIED, {
|
|
message: permissionStatus.error,
|
|
});
|
|
BLELogger.error('Scan failed: permission denied', error);
|
|
throw error;
|
|
}
|
|
|
|
// Check Bluetooth state
|
|
const bluetoothStatus = await checkBluetoothEnabled(this.manager);
|
|
if (!bluetoothStatus.enabled) {
|
|
const error = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED, {
|
|
message: bluetoothStatus.error,
|
|
});
|
|
BLELogger.error('Scan failed: Bluetooth disabled', error);
|
|
throw error;
|
|
}
|
|
|
|
const foundDevices = new Map<string, WPDevice>();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.scanning = true;
|
|
|
|
this.manager.startDeviceScan(
|
|
null,
|
|
{ allowDuplicates: false },
|
|
(error, device) => {
|
|
if (error) {
|
|
this.scanning = false;
|
|
const bleError = parseBLEError(error, { operation: 'scan' });
|
|
BLELogger.error('Scan error', bleError);
|
|
reject(bleError);
|
|
return;
|
|
}
|
|
|
|
if (device && device.name?.startsWith(BLE_CONFIG.DEVICE_NAME_PREFIX)) {
|
|
// Parse well_id from name (WP_497_81a14c -> 497)
|
|
const wellIdMatch = device.name.match(/WP_(\d+)_/);
|
|
const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined;
|
|
|
|
// Extract MAC from device name (last part after underscore)
|
|
const macMatch = device.name.match(/_([a-fA-F0-9]{6})$/);
|
|
const mac = macMatch ? macMatch[1].toUpperCase() : '';
|
|
|
|
foundDevices.set(device.id, {
|
|
id: device.id,
|
|
name: device.name,
|
|
mac: mac,
|
|
rssi: device.rssi || -100,
|
|
wellId,
|
|
});
|
|
|
|
BLELogger.log(`Found device: ${device.name} (RSSI: ${device.rssi})`);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Stop scan after timeout
|
|
setTimeout(() => {
|
|
this.stopScan();
|
|
const duration = Date.now() - startTime;
|
|
const devices = Array.from(foundDevices.values());
|
|
BLELogger.log(`Scan complete: found ${devices.length} devices (${(duration / 1000).toFixed(1)}s)`);
|
|
resolve(devices);
|
|
}, BLE_CONFIG.SCAN_TIMEOUT);
|
|
});
|
|
}
|
|
|
|
stopScan(): void {
|
|
if (this.scanning) {
|
|
this.manager.stopDeviceScan();
|
|
this.scanning = false;
|
|
}
|
|
}
|
|
|
|
async connectDevice(deviceId: string): Promise<boolean> {
|
|
const startTime = Date.now();
|
|
BLELogger.log(`Connecting to device: ${deviceId}`);
|
|
|
|
try {
|
|
// Check if connection is already in progress
|
|
if (this.connectingDevices.has(deviceId)) {
|
|
const error = new BLEError(BLEErrorCode.CONNECTION_IN_PROGRESS, { deviceId });
|
|
BLELogger.warn('Connection already in progress', error);
|
|
throw error;
|
|
}
|
|
|
|
// Check if already connected
|
|
const existingDevice = this.connectedDevices.get(deviceId);
|
|
if (existingDevice) {
|
|
const isConnected = await existingDevice.isConnected();
|
|
if (isConnected) {
|
|
BLELogger.log(`Device already connected: ${deviceId}`);
|
|
this.updateConnectionState(deviceId, BLEConnectionState.READY, existingDevice.name || undefined);
|
|
this.emitEvent(deviceId, 'ready');
|
|
return true;
|
|
}
|
|
// Device was in map but disconnected, remove it
|
|
this.connectedDevices.delete(deviceId);
|
|
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED);
|
|
}
|
|
|
|
// Mark device as connecting
|
|
this.connectingDevices.add(deviceId);
|
|
|
|
// Update state to CONNECTING
|
|
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTING);
|
|
|
|
// Step 0: Check permissions (required for Android 12+)
|
|
const permissionStatus = await requestBLEPermissions();
|
|
if (!permissionStatus.granted) {
|
|
const error = new BLEError(BLEErrorCode.PERMISSION_DENIED, {
|
|
deviceId,
|
|
message: permissionStatus.error,
|
|
});
|
|
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, error.userMessage.message);
|
|
this.emitEvent(deviceId, 'connection_failed', { error: error.message, code: error.code });
|
|
BLELogger.error('Connection failed: permission denied', error);
|
|
throw error;
|
|
}
|
|
|
|
// Step 0.5: Check Bluetooth is enabled
|
|
const bluetoothStatus = await checkBluetoothEnabled(this.manager);
|
|
if (!bluetoothStatus.enabled) {
|
|
const error = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED, {
|
|
deviceId,
|
|
message: bluetoothStatus.error,
|
|
});
|
|
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, error.userMessage.message);
|
|
this.emitEvent(deviceId, 'connection_failed', { error: error.message, code: error.code });
|
|
BLELogger.error('Connection failed: Bluetooth disabled', error);
|
|
throw error;
|
|
}
|
|
|
|
const device = await this.manager.connectToDevice(deviceId, {
|
|
timeout: 10000, // 10 second timeout
|
|
});
|
|
|
|
// Update state to CONNECTED
|
|
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTED, device.name || undefined);
|
|
BLELogger.log(`Connected to device: ${device.name || deviceId}`);
|
|
|
|
// Update state to DISCOVERING
|
|
this.updateConnectionState(deviceId, BLEConnectionState.DISCOVERING, device.name || undefined);
|
|
await device.discoverAllServicesAndCharacteristics();
|
|
BLELogger.log(`Services discovered for: ${device.name || deviceId}`);
|
|
|
|
// Request larger MTU for Android (default is 23 bytes which is too small)
|
|
if (Platform.OS === 'android') {
|
|
try {
|
|
await device.requestMTU(512);
|
|
BLELogger.log('MTU increased to 512');
|
|
} catch {
|
|
// MTU request may fail on some devices - continue anyway
|
|
BLELogger.warn('MTU request failed, continuing with default');
|
|
}
|
|
}
|
|
|
|
this.connectedDevices.set(deviceId, device);
|
|
|
|
// Update state to READY
|
|
this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name || undefined);
|
|
this.emitEvent(deviceId, 'ready');
|
|
|
|
const duration = Date.now() - startTime;
|
|
BLELogger.log(`Device ready: ${device.name || deviceId} (${(duration / 1000).toFixed(1)}s)`);
|
|
|
|
return true;
|
|
} catch (error: any) {
|
|
// Parse error if not already a BLEError
|
|
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(`Connection failed for ${deviceId}`, bleError);
|
|
|
|
return false;
|
|
} finally {
|
|
// Always remove from connecting set when done (success or failure)
|
|
this.connectingDevices.delete(deviceId);
|
|
}
|
|
}
|
|
|
|
async disconnectDevice(deviceId: string): Promise<void> {
|
|
const device = this.connectedDevices.get(deviceId);
|
|
if (device) {
|
|
try {
|
|
// Update state to DISCONNECTING
|
|
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTING);
|
|
|
|
// Cancel any pending operations before disconnecting
|
|
// This helps prevent Android NullPointerException in monitor callbacks
|
|
await device.cancelConnection();
|
|
|
|
// Update state to DISCONNECTED
|
|
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED);
|
|
this.emitEvent(deviceId, 'disconnected');
|
|
} catch {
|
|
// Log but don't throw - device may already be disconnected
|
|
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED);
|
|
this.emitEvent(deviceId, 'disconnected');
|
|
} finally {
|
|
this.connectedDevices.delete(deviceId);
|
|
}
|
|
} else {
|
|
// Not in connected devices map, just update state
|
|
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED);
|
|
this.emitEvent(deviceId, 'disconnected');
|
|
}
|
|
}
|
|
|
|
isDeviceConnected(deviceId: string): boolean {
|
|
return this.connectedDevices.has(deviceId);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
}
|
|
}
|
|
|
|
async sendCommand(deviceId: string, command: string): Promise<string> {
|
|
const startTime = Date.now();
|
|
// Only log first 20 chars to avoid logging passwords
|
|
const safeCommand = command.length > 20 ? command.substring(0, 20) + '...' : command;
|
|
BLELogger.log(`Sending command to ${deviceId}: ${safeCommand}`);
|
|
|
|
const device = this.connectedDevices.get(deviceId);
|
|
if (!device) {
|
|
const error = new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, { deviceId });
|
|
BLELogger.error('Command failed: device not connected', error);
|
|
throw error;
|
|
}
|
|
|
|
// Verify device is still connected
|
|
try {
|
|
const isConnected = await device.isConnected();
|
|
if (!isConnected) {
|
|
this.connectedDevices.delete(deviceId);
|
|
const error = new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, { deviceId });
|
|
BLELogger.error('Command failed: device disconnected', error);
|
|
throw error;
|
|
}
|
|
} catch (err) {
|
|
if (isBLEError(err)) throw err;
|
|
const error = new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, {
|
|
deviceId,
|
|
originalError: err instanceof Error ? err : undefined,
|
|
});
|
|
BLELogger.error('Command failed: connection verification failed', error);
|
|
throw error;
|
|
}
|
|
|
|
// Generate unique transaction ID to prevent Android null pointer issues
|
|
const transactionId = `cmd_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
let responseReceived = false;
|
|
let subscription: any = null;
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const cleanup = () => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
if (subscription) {
|
|
try {
|
|
subscription.remove();
|
|
} catch {
|
|
// Ignore errors during cleanup - device may already be disconnected
|
|
}
|
|
subscription = null;
|
|
}
|
|
};
|
|
|
|
// Safe reject wrapper to handle null error messages (Android BLE crash fix)
|
|
const safeReject = (error: any) => {
|
|
if (responseReceived) return;
|
|
|
|
// Extract error code (numeric or string)
|
|
const errorCode = error?.errorCode || error?.code || 'BLE_ERROR';
|
|
|
|
// Ignore "Operation was cancelled" (code 2) - this is expected when we cleanup
|
|
// This happens when subscription is removed but BLE still tries to send callback
|
|
if (errorCode === 2 || errorCode === 'OperationCancelled' ||
|
|
(error?.message && error.message.includes('cancelled'))) {
|
|
return;
|
|
}
|
|
|
|
responseReceived = true;
|
|
cleanup();
|
|
|
|
// Track failed command
|
|
const responseTime = Date.now() - startTime;
|
|
const deviceKey = `${deviceId}`;
|
|
this.updateCommunicationStats(deviceKey, false, responseTime);
|
|
|
|
// Parse and wrap error with proper BLEError
|
|
const bleError = parseBLEError(error, {
|
|
deviceId,
|
|
deviceName: device.name || undefined,
|
|
operation: 'command',
|
|
});
|
|
|
|
BLELogger.error(`Command failed for ${deviceId}`, bleError);
|
|
reject(bleError);
|
|
};
|
|
|
|
try {
|
|
// Subscribe to notifications with explicit transactionId
|
|
subscription = device.monitorCharacteristicForService(
|
|
BLE_CONFIG.SERVICE_UUID,
|
|
BLE_CONFIG.CHAR_UUID,
|
|
(error, characteristic) => {
|
|
// Wrap callback in try-catch to prevent crashes
|
|
try {
|
|
if (error) {
|
|
const errCode = (error as any)?.errorCode;
|
|
// errorCode 2 = "Operation was cancelled" — normal BLE cleanup, not a real error
|
|
if (errCode === 2) {
|
|
} else {
|
|
// Notification error
|
|
}
|
|
safeReject(error);
|
|
return;
|
|
}
|
|
|
|
if (characteristic?.value) {
|
|
const decoded = base64.decode(characteristic.value);
|
|
if (!responseReceived) {
|
|
responseReceived = true;
|
|
cleanup();
|
|
|
|
// Track successful command
|
|
const responseTime = Date.now() - startTime;
|
|
const deviceKey = `${deviceId}`;
|
|
this.updateCommunicationStats(deviceKey, true, responseTime);
|
|
|
|
resolve(decoded);
|
|
}
|
|
}
|
|
} catch (callbackError: any) {
|
|
safeReject(callbackError);
|
|
}
|
|
},
|
|
transactionId // Explicit transaction ID prevents Android null pointer
|
|
);
|
|
|
|
// Send command
|
|
const encoded = base64.encode(command);
|
|
await device.writeCharacteristicWithResponseForService(
|
|
BLE_CONFIG.SERVICE_UUID,
|
|
BLE_CONFIG.CHAR_UUID,
|
|
encoded
|
|
);
|
|
|
|
// Timeout
|
|
timeoutId = setTimeout(() => {
|
|
if (!responseReceived) {
|
|
const timeoutError = new BLEError(BLEErrorCode.COMMAND_TIMEOUT, {
|
|
deviceId,
|
|
deviceName: device.name || undefined,
|
|
message: `Command timed out after ${BLE_CONFIG.COMMAND_TIMEOUT}ms`,
|
|
});
|
|
safeReject(timeoutError);
|
|
}
|
|
}, BLE_CONFIG.COMMAND_TIMEOUT);
|
|
} catch (error: any) {
|
|
safeReject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
async getWiFiList(deviceId: string): Promise<WiFiNetwork[]> {
|
|
BLELogger.log(`Getting WiFi list from device: ${deviceId}`);
|
|
|
|
// Step 1: Unlock device
|
|
try {
|
|
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
|
|
if (!unlockResponse.includes('ok')) {
|
|
throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId });
|
|
}
|
|
BLELogger.log('Device unlocked successfully');
|
|
} catch (err) {
|
|
if (isBLEError(err)) throw err;
|
|
const error = new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, {
|
|
deviceId,
|
|
originalError: err instanceof Error ? err : undefined,
|
|
});
|
|
BLELogger.error('Failed to unlock device', error);
|
|
throw error;
|
|
}
|
|
|
|
// 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) {
|
|
const error = new BLEError(BLEErrorCode.INVALID_RESPONSE, {
|
|
deviceId,
|
|
message: 'Invalid WiFi list response format',
|
|
});
|
|
BLELogger.error('Invalid response', error);
|
|
throw error;
|
|
}
|
|
|
|
const count = parseInt(parts[2], 10);
|
|
if (count < 0) {
|
|
if (count === -1) {
|
|
const error = new BLEError(BLEErrorCode.WIFI_SCAN_IN_PROGRESS, { deviceId });
|
|
BLELogger.warn('WiFi scan in progress', error);
|
|
throw error;
|
|
}
|
|
if (count === -2) {
|
|
BLELogger.log('No WiFi networks found');
|
|
return []; // No networks found
|
|
}
|
|
}
|
|
|
|
// Use Map to deduplicate by SSID, keeping the strongest signal
|
|
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);
|
|
|
|
// Skip empty SSIDs
|
|
if (!trimmedSsid) continue;
|
|
|
|
// Keep the one with strongest signal if duplicate
|
|
const existing = networksMap.get(trimmedSsid);
|
|
if (!existing || rssi > existing.rssi) {
|
|
networksMap.set(trimmedSsid, {
|
|
ssid: trimmedSsid,
|
|
rssi: rssi,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to array and sort by signal strength (strongest first)
|
|
return Array.from(networksMap.values()).sort((a, b) => b.rssi - a.rssi);
|
|
}
|
|
|
|
async setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean> {
|
|
BLELogger.log(`Setting WiFi on device: ${deviceId}, SSID: ${ssid}`);
|
|
|
|
// Pre-validate credentials before BLE transmission
|
|
// Check for characters that would break BLE protocol parsing
|
|
if (ssid.includes('|') || ssid.includes(',')) {
|
|
const error = new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, {
|
|
deviceId,
|
|
message: 'Network name contains invalid characters',
|
|
});
|
|
BLELogger.error('Invalid SSID characters', error);
|
|
throw error;
|
|
}
|
|
if (password.includes('|')) {
|
|
const error = new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, {
|
|
deviceId,
|
|
message: 'Password contains an invalid character (|)',
|
|
});
|
|
BLELogger.error('Invalid password characters', error);
|
|
throw error;
|
|
}
|
|
|
|
// Step 1: Unlock device
|
|
let unlockResponse: string;
|
|
try {
|
|
unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
|
|
} catch (err: any) {
|
|
if (isBLEError(err) && err.code === BLEErrorCode.COMMAND_TIMEOUT) {
|
|
const error = new BLEError(BLEErrorCode.SENSOR_NOT_RESPONDING, {
|
|
deviceId,
|
|
originalError: err,
|
|
});
|
|
BLELogger.error('Sensor not responding during unlock', error);
|
|
throw error;
|
|
}
|
|
const error = parseBLEError(err, { deviceId, operation: 'unlock' });
|
|
BLELogger.error('Unlock failed', error);
|
|
throw error;
|
|
}
|
|
|
|
if (!unlockResponse.includes('ok')) {
|
|
const error = new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId });
|
|
BLELogger.error('PIN unlock rejected', error);
|
|
throw error;
|
|
}
|
|
BLELogger.log('Device unlocked for WiFi configuration');
|
|
|
|
// Step 1.5: Check if already connected to the target WiFi
|
|
// This prevents "W|fail" when sensor uses old saved credentials
|
|
try {
|
|
const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
|
|
|
|
// Parse: "mac,XXXXXX|a|SSID,RSSI" or "mac,XXXXXX|a|,0" (not connected)
|
|
const parts = statusResponse.split('|');
|
|
if (parts.length >= 3) {
|
|
const [currentSsid] = parts[2].split(',');
|
|
if (currentSsid && currentSsid.trim().toLowerCase() === ssid.toLowerCase()) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore status check errors - continue with WiFi config
|
|
}
|
|
|
|
// Step 2: Set WiFi credentials
|
|
const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`;
|
|
let setResponse: string;
|
|
try {
|
|
setResponse = await this.sendCommand(deviceId, command);
|
|
} catch (err: any) {
|
|
if (isBLEError(err) && err.code === BLEErrorCode.COMMAND_TIMEOUT) {
|
|
const error = new BLEError(BLEErrorCode.SENSOR_NOT_RESPONDING, {
|
|
deviceId,
|
|
message: 'Sensor did not respond to WiFi config',
|
|
originalError: err,
|
|
});
|
|
BLELogger.error('WiFi config timeout', error);
|
|
throw error;
|
|
}
|
|
const error = parseBLEError(err, { deviceId, operation: 'wifi_config' });
|
|
BLELogger.error('WiFi config failed', error);
|
|
throw error;
|
|
}
|
|
|
|
// Parse response: "mac,XXXXXX|W|ok" or "mac,XXXXXX|W|fail" or other errors
|
|
if (setResponse.includes('|W|ok')) {
|
|
BLELogger.log(`WiFi configured successfully for ${ssid}`);
|
|
return true;
|
|
}
|
|
|
|
// WiFi config failed - check if sensor is still connected (using old credentials)
|
|
if (setResponse.includes('|W|fail')) {
|
|
try {
|
|
const recheckResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
|
|
const parts = recheckResponse.split('|');
|
|
if (parts.length >= 3) {
|
|
const [currentSsid, rssiStr] = parts[2].split(',');
|
|
const rssi = parseInt(rssiStr, 10);
|
|
|
|
// If connected to target SSID (using old credentials), consider it success
|
|
if (currentSsid && currentSsid.trim().toLowerCase() === ssid.toLowerCase() && rssi < 0) {
|
|
BLELogger.log(`Sensor already connected to ${ssid} (using existing credentials)`);
|
|
return true;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore recheck errors - password was rejected
|
|
}
|
|
|
|
// Password was definitely wrong
|
|
const error = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT, { deviceId });
|
|
BLELogger.error('WiFi password incorrect', error);
|
|
throw error;
|
|
}
|
|
|
|
if (setResponse.includes('timeout') || setResponse.includes('Timeout')) {
|
|
const error = new BLEError(BLEErrorCode.SENSOR_NOT_RESPONDING, {
|
|
deviceId,
|
|
message: 'Sensor did not respond to WiFi config',
|
|
});
|
|
BLELogger.error('WiFi config timeout', error);
|
|
throw error;
|
|
}
|
|
|
|
// Check for specific error patterns
|
|
if (setResponse.includes('not found') || setResponse.includes('no network')) {
|
|
const error = new BLEError(BLEErrorCode.WIFI_NETWORK_NOT_FOUND, { deviceId });
|
|
BLELogger.error('WiFi network not found', error);
|
|
throw error;
|
|
}
|
|
|
|
// Unknown error - provide helpful message
|
|
const error = new BLEError(BLEErrorCode.WIFI_CONFIG_FAILED, {
|
|
deviceId,
|
|
message: `Unexpected response: ${setResponse}`,
|
|
});
|
|
BLELogger.error('WiFi config failed with unknown error', error);
|
|
throw error;
|
|
}
|
|
|
|
async getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null> {
|
|
BLELogger.log(`Getting current WiFi status from device: ${deviceId}`);
|
|
|
|
// Step 1: Unlock device
|
|
try {
|
|
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
|
|
if (!unlockResponse.includes('ok')) {
|
|
throw new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { deviceId });
|
|
}
|
|
} catch (err) {
|
|
if (isBLEError(err)) throw err;
|
|
const error = new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, {
|
|
deviceId,
|
|
originalError: err instanceof Error ? err : undefined,
|
|
});
|
|
BLELogger.error('Failed to unlock device for WiFi status', error);
|
|
throw error;
|
|
}
|
|
|
|
// Step 2: Get current WiFi status
|
|
const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
|
|
|
|
// Parse response: "mac,XXXXXX|a|SSID,RSSI" or "mac,XXXXXX|a|,0" (not connected)
|
|
const parts = statusResponse.split('|');
|
|
if (parts.length < 3) {
|
|
BLELogger.log('No WiFi status available (invalid response)');
|
|
return null;
|
|
}
|
|
|
|
const [ssid, rssiStr] = parts[2].split(',');
|
|
if (!ssid || ssid.trim() === '') {
|
|
BLELogger.log('Sensor not connected to any WiFi network');
|
|
return null; // Not connected
|
|
}
|
|
|
|
const rssi = parseInt(rssiStr, 10);
|
|
BLELogger.log(`Current WiFi: ${ssid} (RSSI: ${rssi})`);
|
|
|
|
return {
|
|
ssid: ssid.trim(),
|
|
rssi: rssi,
|
|
connected: true,
|
|
};
|
|
}
|
|
|
|
async rebootDevice(deviceId: string): Promise<void> {
|
|
BLELogger.log(`Rebooting device: ${deviceId}`);
|
|
|
|
try {
|
|
// Step 1: Unlock device
|
|
await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
|
|
|
|
// Step 2: Reboot (device will disconnect)
|
|
await this.sendCommand(deviceId, BLE_COMMANDS.REBOOT);
|
|
|
|
BLELogger.log(`Reboot command sent to ${deviceId}`);
|
|
} catch (err) {
|
|
if (isBLEError(err)) {
|
|
BLELogger.error('Reboot failed', err);
|
|
throw err;
|
|
}
|
|
const error = new BLEError(BLEErrorCode.SENSOR_REBOOT_FAILED, {
|
|
deviceId,
|
|
originalError: err instanceof Error ? err : undefined,
|
|
});
|
|
BLELogger.error('Reboot failed', error);
|
|
throw error;
|
|
}
|
|
|
|
// Remove from connected devices
|
|
this.connectedDevices.delete(deviceId);
|
|
}
|
|
|
|
/**
|
|
* Calculate WiFi signal quality from RSSI value
|
|
*/
|
|
private getWiFiSignalQuality(rssi: number | null): WiFiSignalQuality {
|
|
if (rssi === null) return WiFiSignalQuality.UNKNOWN;
|
|
|
|
if (rssi >= -50) return WiFiSignalQuality.EXCELLENT;
|
|
if (rssi >= -60) return WiFiSignalQuality.GOOD;
|
|
if (rssi >= -70) return WiFiSignalQuality.FAIR;
|
|
return WiFiSignalQuality.WEAK;
|
|
}
|
|
|
|
/**
|
|
* Calculate overall sensor health status
|
|
*/
|
|
private calculateOverallHealth(
|
|
connectionStatus: 'online' | 'warning' | 'offline',
|
|
wifiQuality: WiFiSignalQuality,
|
|
commHealth: CommunicationHealth
|
|
): SensorHealthStatus {
|
|
// Critical: offline or very poor communication
|
|
if (connectionStatus === 'offline') {
|
|
return SensorHealthStatus.CRITICAL;
|
|
}
|
|
|
|
// Calculate communication success rate
|
|
const totalCommands = commHealth.successfulCommands + commHealth.failedCommands;
|
|
const successRate = totalCommands > 0 ? commHealth.successfulCommands / totalCommands : 1;
|
|
|
|
// Poor: warning status or high failure rate or weak WiFi
|
|
if (
|
|
connectionStatus === 'warning' ||
|
|
successRate < 0.5 ||
|
|
wifiQuality === WiFiSignalQuality.WEAK
|
|
) {
|
|
return SensorHealthStatus.POOR;
|
|
}
|
|
|
|
// Fair: moderate success rate or fair WiFi
|
|
if (successRate < 0.8 || wifiQuality === WiFiSignalQuality.FAIR) {
|
|
return SensorHealthStatus.FAIR;
|
|
}
|
|
|
|
// Good: high success rate and good WiFi
|
|
if (successRate >= 0.9 && wifiQuality === WiFiSignalQuality.GOOD) {
|
|
return SensorHealthStatus.GOOD;
|
|
}
|
|
|
|
// Excellent: perfect or near-perfect performance
|
|
return SensorHealthStatus.EXCELLENT;
|
|
}
|
|
|
|
/**
|
|
* Get sensor health metrics for a specific device
|
|
* Attempts to connect to the device via BLE and query its WiFi status
|
|
*/
|
|
async getSensorHealth(wellId: number, mac: string): Promise<SensorHealthMetrics | null> {
|
|
try {
|
|
const deviceKey = `WP_${wellId}_${mac.slice(-6).toLowerCase()}`;
|
|
|
|
// Try to find device via BLE scan
|
|
const devices = await this.scanDevices();
|
|
const device = devices.find((d) => d.wellId === wellId && d.mac === mac);
|
|
|
|
if (!device) {
|
|
// Device not found via BLE - return null or cached metrics
|
|
return this.sensorHealthMetrics.get(deviceKey) || null;
|
|
}
|
|
|
|
// Connect to device
|
|
const connected = await this.connectDevice(device.id);
|
|
if (!connected) {
|
|
return this.sensorHealthMetrics.get(deviceKey) || null;
|
|
}
|
|
|
|
// Get WiFi status
|
|
let wifiStatus: WiFiStatus | null = null;
|
|
try {
|
|
wifiStatus = await this.getCurrentWiFi(device.id);
|
|
} catch {
|
|
// WiFi status query failed - continue with partial data
|
|
}
|
|
|
|
// Get communication stats
|
|
const commStats = this.communicationStats.get(deviceKey) || {
|
|
successfulCommands: 0,
|
|
failedCommands: 0,
|
|
averageResponseTime: 0,
|
|
lastSuccessfulCommand: Date.now(),
|
|
};
|
|
|
|
// Calculate metrics
|
|
const now = Date.now();
|
|
const lastSeenMinutesAgo = 0; // Just connected
|
|
const wifiRssi = wifiStatus?.rssi || null;
|
|
const wifiQuality = this.getWiFiSignalQuality(wifiRssi);
|
|
|
|
const connectionStatus: 'online' | 'warning' | 'offline' =
|
|
lastSeenMinutesAgo < 5 ? 'online' : lastSeenMinutesAgo < 60 ? 'warning' : 'offline';
|
|
|
|
const overallHealth = this.calculateOverallHealth(connectionStatus, wifiQuality, commStats);
|
|
|
|
// Get connection state
|
|
const connState = this.connectionStates.get(device.id);
|
|
|
|
const metrics: SensorHealthMetrics = {
|
|
deviceId: device.id,
|
|
deviceName: deviceKey,
|
|
overallHealth,
|
|
connectionStatus,
|
|
lastSeen: new Date(),
|
|
lastSeenMinutesAgo,
|
|
wifiSignalQuality: wifiQuality,
|
|
wifiRssi,
|
|
wifiSsid: wifiStatus?.ssid || null,
|
|
wifiConnected: wifiStatus?.connected || false,
|
|
communication: commStats,
|
|
bleRssi: device.rssi,
|
|
bleConnectionAttempts: 1,
|
|
bleConnectionFailures: 0,
|
|
lastHealthCheck: now,
|
|
lastBleConnection: connState?.connectedAt,
|
|
};
|
|
|
|
// Cache metrics
|
|
this.sensorHealthMetrics.set(deviceKey, metrics);
|
|
|
|
// Disconnect after health check
|
|
await this.disconnectDevice(device.id);
|
|
|
|
return metrics;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all cached sensor health metrics
|
|
*/
|
|
getAllSensorHealth(): Map<string, SensorHealthMetrics> {
|
|
return new Map(this.sensorHealthMetrics);
|
|
}
|
|
|
|
/**
|
|
* Cleanup all BLE connections and state
|
|
* Should be called on app logout to properly release resources
|
|
*/
|
|
async cleanup(): Promise<void> {
|
|
|
|
// Stop any ongoing scan
|
|
if (this.scanning) {
|
|
this.stopScan();
|
|
}
|
|
|
|
// Cancel all pending reconnects
|
|
for (const timer of this.reconnectTimers.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
this.reconnectTimers.clear();
|
|
|
|
// Remove all disconnection subscriptions
|
|
for (const subscription of this.disconnectionSubscriptions.values()) {
|
|
try {
|
|
subscription.remove();
|
|
} catch {
|
|
// Ignore errors during cleanup
|
|
}
|
|
}
|
|
this.disconnectionSubscriptions.clear();
|
|
|
|
// Clear reconnect states
|
|
this.reconnectStates.clear();
|
|
|
|
// Disconnect all connected devices
|
|
const deviceIds = Array.from(this.connectedDevices.keys());
|
|
for (const deviceId of deviceIds) {
|
|
try {
|
|
await this.disconnectDevice(deviceId);
|
|
} catch {
|
|
// Continue cleanup even if one device fails
|
|
}
|
|
}
|
|
|
|
// Clear the maps and sets
|
|
this.connectedDevices.clear();
|
|
this.connectionStates.clear();
|
|
this.connectingDevices.clear();
|
|
|
|
// Clear health monitoring data
|
|
this.sensorHealthMetrics.clear();
|
|
this.communicationStats.clear();
|
|
|
|
// Clear event listeners
|
|
this.eventListeners = [];
|
|
|
|
}
|
|
|
|
/**
|
|
* Bulk disconnect multiple devices
|
|
* Useful for cleanup or batch operations
|
|
*/
|
|
async bulkDisconnect(deviceIds: string[]): Promise<BulkOperationResult[]> {
|
|
const results: BulkOperationResult[] = [];
|
|
|
|
for (const deviceId of deviceIds) {
|
|
const device = this.connectedDevices.get(deviceId);
|
|
const deviceName = device?.name || 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
|
|
* Useful for applying settings changes to multiple sensors
|
|
*/
|
|
async bulkReboot(deviceIds: string[]): Promise<BulkOperationResult[]> {
|
|
const results: BulkOperationResult[] = [];
|
|
|
|
for (const deviceId of deviceIds) {
|
|
const device = this.connectedDevices.get(deviceId);
|
|
const deviceName = device?.name || deviceId;
|
|
|
|
try {
|
|
// Connect if not already connected
|
|
if (!this.isDeviceConnected(deviceId)) {
|
|
const connected = await this.connectDevice(deviceId);
|
|
if (!connected) {
|
|
throw new Error('Could not connect to device');
|
|
}
|
|
}
|
|
|
|
// Reboot
|
|
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 for multiple devices
|
|
* Configures all devices with the same WiFi credentials sequentially
|
|
*/
|
|
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(`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 deviceStartTime = Date.now();
|
|
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 });
|
|
}
|
|
BLELogger.logBatchProgress(index, total, deviceName, 'connected', true);
|
|
|
|
// Step 2: Set WiFi
|
|
BLELogger.logBatchProgress(index, total, deviceName, 'setting WiFi...');
|
|
onProgress?.(deviceId, 'configuring');
|
|
await this.setWiFi(deviceId, ssid, password);
|
|
BLELogger.logBatchProgress(index, total, deviceName, 'WiFi configured', true);
|
|
|
|
// Step 3: Reboot
|
|
BLELogger.logBatchProgress(index, total, deviceName, 'rebooting...');
|
|
onProgress?.(deviceId, 'rebooting');
|
|
await this.rebootDevice(deviceId);
|
|
BLELogger.logBatchProgress(index, total, deviceName, 'rebooted', true);
|
|
|
|
// Success
|
|
const duration = Date.now() - deviceStartTime;
|
|
BLELogger.logBatchProgress(index, total, deviceName, 'SUCCESS', true, duration);
|
|
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 ====================
|
|
|
|
/**
|
|
* Set reconnect configuration
|
|
*/
|
|
setReconnectConfig(config: Partial<ReconnectConfig>): void {
|
|
this.reconnectConfig = { ...this.reconnectConfig, ...config };
|
|
}
|
|
|
|
/**
|
|
* Get current reconnect configuration
|
|
*/
|
|
getReconnectConfig(): ReconnectConfig {
|
|
return { ...this.reconnectConfig };
|
|
}
|
|
|
|
/**
|
|
* Enable auto-reconnect for a device
|
|
* Monitors the device for disconnection and attempts to reconnect
|
|
*/
|
|
enableAutoReconnect(deviceId: string, deviceName?: string): void {
|
|
const device = this.connectedDevices.get(deviceId);
|
|
if (!device) {
|
|
return;
|
|
}
|
|
|
|
// Initialize reconnect state
|
|
this.reconnectStates.set(deviceId, {
|
|
deviceId,
|
|
deviceName: deviceName || device.name || deviceId,
|
|
attempts: 0,
|
|
lastAttemptTime: 0,
|
|
isReconnecting: false,
|
|
});
|
|
|
|
// Set up disconnection monitor
|
|
this.setupDisconnectionMonitor(deviceId, deviceName || device.name || deviceId);
|
|
}
|
|
|
|
/**
|
|
* Set up a monitor for device disconnection
|
|
*/
|
|
private setupDisconnectionMonitor(deviceId: string, deviceName: string): void {
|
|
// Remove any existing subscription
|
|
const existingSub = this.disconnectionSubscriptions.get(deviceId);
|
|
if (existingSub) {
|
|
try {
|
|
existingSub.remove();
|
|
} catch {
|
|
// Ignore removal errors
|
|
}
|
|
}
|
|
|
|
const device = this.connectedDevices.get(deviceId);
|
|
if (!device) {
|
|
return;
|
|
}
|
|
|
|
// Monitor for disconnection
|
|
const subscription = device.onDisconnected((error, disconnectedDevice) => {
|
|
// Clean up device from connected map
|
|
this.connectedDevices.delete(deviceId);
|
|
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED, deviceName);
|
|
this.emitEvent(deviceId, 'disconnected', { unexpected: true, error: error?.message });
|
|
|
|
// Check if auto-reconnect is enabled and should attempt
|
|
if (this.reconnectConfig.enabled) {
|
|
const state = this.reconnectStates.get(deviceId);
|
|
if (state && state.attempts < this.reconnectConfig.maxAttempts) {
|
|
this.scheduleReconnect(deviceId, deviceName);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.disconnectionSubscriptions.set(deviceId, subscription);
|
|
}
|
|
|
|
/**
|
|
* Schedule a reconnection attempt
|
|
*/
|
|
private scheduleReconnect(deviceId: string, deviceName: string): void {
|
|
const state = this.reconnectStates.get(deviceId);
|
|
if (!state) return;
|
|
|
|
// Calculate delay with exponential backoff
|
|
const delay = Math.min(
|
|
this.reconnectConfig.delayMs * Math.pow(this.reconnectConfig.backoffMultiplier, state.attempts),
|
|
this.reconnectConfig.maxDelayMs
|
|
);
|
|
|
|
const nextAttemptTime = Date.now() + delay;
|
|
|
|
// Update state
|
|
this.reconnectStates.set(deviceId, {
|
|
...state,
|
|
nextAttemptTime,
|
|
isReconnecting: true,
|
|
});
|
|
|
|
// Emit event for UI updates
|
|
this.emitEvent(deviceId, 'state_changed', {
|
|
state: BLEConnectionState.CONNECTING,
|
|
reconnecting: true,
|
|
nextAttemptIn: delay,
|
|
});
|
|
|
|
// Schedule reconnect attempt
|
|
const timer = setTimeout(() => {
|
|
this.attemptReconnect(deviceId, deviceName);
|
|
}, delay);
|
|
|
|
// Store timer for potential cancellation
|
|
this.reconnectTimers.set(deviceId, timer);
|
|
}
|
|
|
|
/**
|
|
* Attempt to reconnect to a device
|
|
*/
|
|
private async attemptReconnect(deviceId: string, deviceName: string): Promise<void> {
|
|
const state = this.reconnectStates.get(deviceId);
|
|
if (!state) return;
|
|
|
|
// Update attempt count
|
|
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) {
|
|
// Reset reconnect state on success
|
|
this.reconnectStates.set(deviceId, {
|
|
deviceId,
|
|
deviceName,
|
|
attempts: 0,
|
|
lastAttemptTime: Date.now(),
|
|
isReconnecting: false,
|
|
});
|
|
|
|
// Re-enable monitoring
|
|
this.setupDisconnectionMonitor(deviceId, deviceName);
|
|
|
|
this.emitEvent(deviceId, 'ready', { reconnected: true });
|
|
} else {
|
|
throw new Error('Connection failed');
|
|
}
|
|
} catch (error: any) {
|
|
const errorMessage = error?.message || 'Reconnection failed';
|
|
|
|
this.reconnectStates.set(deviceId, {
|
|
...state,
|
|
attempts: newAttempts,
|
|
lastAttemptTime: Date.now(),
|
|
isReconnecting: newAttempts < this.reconnectConfig.maxAttempts,
|
|
lastError: errorMessage,
|
|
});
|
|
|
|
// Try again if under max attempts
|
|
if (newAttempts < this.reconnectConfig.maxAttempts) {
|
|
this.scheduleReconnect(deviceId, deviceName);
|
|
} else {
|
|
// Max attempts reached - emit failure event
|
|
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, deviceName, 'Max reconnection attempts reached');
|
|
this.emitEvent(deviceId, 'connection_failed', {
|
|
error: 'Max reconnection attempts reached',
|
|
reconnectFailed: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable auto-reconnect for a device
|
|
*/
|
|
disableAutoReconnect(deviceId: string): void {
|
|
// Cancel any pending reconnect
|
|
this.cancelReconnect(deviceId);
|
|
|
|
// Remove reconnect state
|
|
this.reconnectStates.delete(deviceId);
|
|
|
|
// Remove disconnection subscription
|
|
const subscription = this.disconnectionSubscriptions.get(deviceId);
|
|
if (subscription) {
|
|
try {
|
|
subscription.remove();
|
|
} catch {
|
|
// Ignore removal errors
|
|
}
|
|
this.disconnectionSubscriptions.delete(deviceId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a pending reconnect attempt
|
|
*/
|
|
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 && state.isReconnecting) {
|
|
this.reconnectStates.set(deviceId, {
|
|
...state,
|
|
isReconnecting: false,
|
|
nextAttemptTime: undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manually trigger a reconnect attempt
|
|
* Resets the attempt counter and tries immediately
|
|
*/
|
|
async manualReconnect(deviceId: string): Promise<boolean> {
|
|
// Cancel any pending auto-reconnect
|
|
this.cancelReconnect(deviceId);
|
|
|
|
const state = this.reconnectStates.get(deviceId);
|
|
const deviceName = state?.deviceName || this.connectionStates.get(deviceId)?.deviceName || deviceId;
|
|
|
|
// Reset attempts for manual reconnect
|
|
this.reconnectStates.set(deviceId, {
|
|
deviceId,
|
|
deviceName,
|
|
attempts: 0,
|
|
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,
|
|
});
|
|
|
|
// Set up monitoring if auto-reconnect is enabled
|
|
if (this.reconnectConfig.enabled) {
|
|
this.setupDisconnectionMonitor(deviceId, deviceName);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get reconnect state for a device
|
|
*/
|
|
getReconnectState(deviceId: string): ReconnectState | undefined {
|
|
return this.reconnectStates.get(deviceId);
|
|
}
|
|
|
|
/**
|
|
* Get all reconnect states
|
|
*/
|
|
getAllReconnectStates(): Map<string, ReconnectState> {
|
|
return new Map(this.reconnectStates);
|
|
}
|
|
}
|