From 6960f248e0ab14e9f742a54f3f1314766d016326 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 09:19:38 -0800 Subject: [PATCH] Implement comprehensive BLE error handling system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- __tests__/services/ble/errors.test.ts | 411 ++++++++++++++++ services/ble/BLEManager.ts | 303 +++++++++--- services/ble/MockBLEManager.ts | 99 +++- services/ble/errors.ts | 661 ++++++++++++++++++++++++++ services/ble/index.ts | 3 + 5 files changed, 1410 insertions(+), 67 deletions(-) create mode 100644 __tests__/services/ble/errors.test.ts create mode 100644 services/ble/errors.ts diff --git a/__tests__/services/ble/errors.test.ts b/__tests__/services/ble/errors.test.ts new file mode 100644 index 0000000..a3ba864 --- /dev/null +++ b/__tests__/services/ble/errors.test.ts @@ -0,0 +1,411 @@ +import { + BLEError, + BLEErrorCode, + BLEErrorSeverity, + BLERecoveryAction, + BLE_ERROR_MESSAGES, + BLE_RECOVERY_ACTIONS, + parseBLEError, + isBLEError, + getErrorInfo, + getRecoveryActionLabel, + BLELogger, +} from '@/services/ble/errors'; + +describe('BLEError', () => { + describe('constructor', () => { + it('creates error with correct code and default message', () => { + const error = new BLEError(BLEErrorCode.CONNECTION_FAILED); + + expect(error.code).toBe(BLEErrorCode.CONNECTION_FAILED); + expect(error.name).toBe('BLEError'); + expect(error.userMessage.title).toBe('Connection Failed'); + expect(error.userMessage.message).toContain('Could not connect'); + expect(error.timestamp).toBeGreaterThan(0); + }); + + it('creates error with custom message', () => { + const error = new BLEError(BLEErrorCode.CONNECTION_TIMEOUT, { + message: 'Custom timeout message', + }); + + expect(error.message).toBe('Custom timeout message'); + expect(error.userMessage.title).toBe('Connection Timeout'); + }); + + it('creates error with device context', () => { + const error = new BLEError(BLEErrorCode.DEVICE_NOT_FOUND, { + deviceId: 'device-123', + deviceName: 'WP_497_81a14c', + }); + + expect(error.deviceId).toBe('device-123'); + expect(error.deviceName).toBe('WP_497_81a14c'); + }); + + it('wraps original error', () => { + const originalError = new Error('Native BLE error'); + const error = new BLEError(BLEErrorCode.COMMAND_FAILED, { + originalError, + }); + + expect(error.originalError).toBe(originalError); + }); + + it('sets correct severity for critical errors', () => { + const permissionError = new BLEError(BLEErrorCode.PERMISSION_DENIED); + const bluetoothError = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED); + + expect(permissionError.severity).toBe(BLEErrorSeverity.CRITICAL); + expect(bluetoothError.severity).toBe(BLEErrorSeverity.CRITICAL); + }); + + it('sets correct severity for warning errors', () => { + const busyError = new BLEError(BLEErrorCode.DEVICE_BUSY); + const scanError = new BLEError(BLEErrorCode.WIFI_SCAN_IN_PROGRESS); + + expect(busyError.severity).toBe(BLEErrorSeverity.WARNING); + expect(scanError.severity).toBe(BLEErrorSeverity.WARNING); + }); + + it('sets recovery actions from mapping', () => { + const connectionError = new BLEError(BLEErrorCode.CONNECTION_FAILED); + const passwordError = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT); + + expect(connectionError.recoveryActions).toContain(BLERecoveryAction.RETRY); + expect(connectionError.recoveryActions).toContain(BLERecoveryAction.MOVE_CLOSER); + + expect(passwordError.recoveryActions).toContain(BLERecoveryAction.CHECK_WIFI_PASSWORD); + }); + }); + + describe('toLogString', () => { + it('formats error for logging with device name', () => { + const error = new BLEError(BLEErrorCode.CONNECTION_FAILED, { + deviceName: 'WP_497_81a14c', + message: 'Connection failed', + }); + + const logString = error.toLogString(); + + expect(logString).toContain('[BLE]'); + expect(logString).toContain('[BLE_CONNECTION_FAILED]'); + expect(logString).toContain('[WP_497_81a14c]'); + expect(logString).toContain('Connection failed'); + }); + + it('uses deviceId when deviceName is not available', () => { + const error = new BLEError(BLEErrorCode.COMMAND_TIMEOUT, { + deviceId: 'device-123', + }); + + const logString = error.toLogString(); + + expect(logString).toContain('[device-123]'); + }); + }); + + describe('isRetryable', () => { + it('returns true for retryable errors', () => { + const connectionError = new BLEError(BLEErrorCode.CONNECTION_FAILED); + const timeoutError = new BLEError(BLEErrorCode.COMMAND_TIMEOUT); + + expect(connectionError.isRetryable()).toBe(true); + expect(timeoutError.isRetryable()).toBe(true); + }); + + it('returns false for non-retryable errors', () => { + const cancelledError = new BLEError(BLEErrorCode.OPERATION_CANCELLED); + const alreadyConnectedError = new BLEError(BLEErrorCode.ALREADY_CONNECTED); + + expect(cancelledError.isRetryable()).toBe(false); + expect(alreadyConnectedError.isRetryable()).toBe(false); + }); + }); + + describe('isSkippable', () => { + it('returns true for skippable errors in batch operations', () => { + const commandError = new BLEError(BLEErrorCode.COMMAND_FAILED); + const rebootError = new BLEError(BLEErrorCode.SENSOR_REBOOT_FAILED); + + expect(commandError.isSkippable()).toBe(true); + expect(rebootError.isSkippable()).toBe(true); + }); + + it('returns false for non-skippable errors', () => { + const permissionError = new BLEError(BLEErrorCode.PERMISSION_DENIED); + const bluetoothError = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED); + + expect(permissionError.isSkippable()).toBe(false); + expect(bluetoothError.isSkippable()).toBe(false); + }); + }); +}); + +describe('parseBLEError', () => { + it('parses permission denied errors', () => { + const error = new Error('Bluetooth permissions not granted'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.PERMISSION_DENIED); + }); + + it('parses bluetooth disabled errors', () => { + const error = new Error('Bluetooth is disabled'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.BLUETOOTH_DISABLED); + }); + + it('parses timeout errors', () => { + const error = new Error('Connection timeout'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.CONNECTION_TIMEOUT); + }); + + it('parses connection in progress errors', () => { + const error = new Error('Connection already in progress'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.CONNECTION_IN_PROGRESS); + }); + + it('parses WiFi password incorrect errors', () => { + const error = new Error('WiFi password is incorrect'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.WIFI_PASSWORD_INCORRECT); + }); + + it('parses WiFi network not found errors', () => { + const error = new Error('WiFi network not found'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.WIFI_NETWORK_NOT_FOUND); + }); + + it('parses device disconnected errors', () => { + const error = new Error('Device disconnected unexpectedly'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.DEVICE_DISCONNECTED); + }); + + it('parses cancelled operation errors', () => { + const error = new Error('Operation was cancelled'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.OPERATION_CANCELLED); + }); + + it('includes device context when provided', () => { + const error = new Error('Connection timeout'); + const bleError = parseBLEError(error, { + deviceId: 'device-123', + deviceName: 'WP_497_81a14c', + }); + + expect(bleError.deviceId).toBe('device-123'); + expect(bleError.deviceName).toBe('WP_497_81a14c'); + }); + + it('defaults to unknown error for unrecognized messages', () => { + const error = new Error('Some random error message'); + const bleError = parseBLEError(error); + + expect(bleError.code).toBe(BLEErrorCode.UNKNOWN_ERROR); + }); + + it('handles non-Error objects', () => { + const bleError = parseBLEError('string error'); + + expect(bleError).toBeInstanceOf(BLEError); + expect(bleError.code).toBe(BLEErrorCode.UNKNOWN_ERROR); + }); +}); + +describe('isBLEError', () => { + it('returns true for BLEError instances', () => { + const error = new BLEError(BLEErrorCode.CONNECTION_FAILED); + + expect(isBLEError(error)).toBe(true); + }); + + it('returns false for regular Error instances', () => { + const error = new Error('Regular error'); + + expect(isBLEError(error)).toBe(false); + }); + + it('returns false for non-error objects', () => { + expect(isBLEError('string')).toBe(false); + expect(isBLEError(null)).toBe(false); + expect(isBLEError(undefined)).toBe(false); + expect(isBLEError({})).toBe(false); + }); +}); + +describe('getErrorInfo', () => { + it('extracts info from BLEError', () => { + const error = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT); + const info = getErrorInfo(error); + + expect(info.code).toBe(BLEErrorCode.WIFI_PASSWORD_INCORRECT); + expect(info.title).toBe('Wrong Password'); + expect(info.message).toContain('incorrect'); + expect(info.severity).toBe(BLEErrorSeverity.ERROR); + expect(info.recoveryActions).toContain(BLERecoveryAction.CHECK_WIFI_PASSWORD); + expect(info.isRetryable).toBe(true); + }); + + it('parses and extracts info from regular Error', () => { + const error = new Error('Bluetooth is disabled'); + const info = getErrorInfo(error); + + expect(info.code).toBe(BLEErrorCode.BLUETOOTH_DISABLED); + expect(info.title).toBe('Bluetooth Disabled'); + expect(info.severity).toBe(BLEErrorSeverity.CRITICAL); + expect(info.recoveryActions).toContain(BLERecoveryAction.ENABLE_BLUETOOTH); + }); +}); + +describe('getRecoveryActionLabel', () => { + it('returns correct labels for all actions', () => { + expect(getRecoveryActionLabel(BLERecoveryAction.RETRY)).toBe('Retry'); + expect(getRecoveryActionLabel(BLERecoveryAction.SKIP)).toBe('Skip'); + expect(getRecoveryActionLabel(BLERecoveryAction.CANCEL)).toBe('Cancel'); + expect(getRecoveryActionLabel(BLERecoveryAction.ENABLE_BLUETOOTH)).toBe('Enable Bluetooth'); + expect(getRecoveryActionLabel(BLERecoveryAction.GRANT_PERMISSIONS)).toBe('Grant Permission'); + expect(getRecoveryActionLabel(BLERecoveryAction.MOVE_CLOSER)).toBe('Move Closer'); + expect(getRecoveryActionLabel(BLERecoveryAction.CHECK_WIFI_PASSWORD)).toBe('Check Password'); + expect(getRecoveryActionLabel(BLERecoveryAction.CONTACT_SUPPORT)).toBe('Contact Support'); + }); +}); + +describe('BLE_ERROR_MESSAGES', () => { + it('has messages for all error codes', () => { + const errorCodes = Object.values(BLEErrorCode); + + for (const code of errorCodes) { + expect(BLE_ERROR_MESSAGES[code]).toBeDefined(); + expect(BLE_ERROR_MESSAGES[code].title).toBeTruthy(); + expect(BLE_ERROR_MESSAGES[code].message).toBeTruthy(); + } + }); +}); + +describe('BLE_RECOVERY_ACTIONS', () => { + it('has recovery actions for all error codes', () => { + const errorCodes = Object.values(BLEErrorCode); + + for (const code of errorCodes) { + expect(BLE_RECOVERY_ACTIONS[code]).toBeDefined(); + expect(Array.isArray(BLE_RECOVERY_ACTIONS[code])).toBe(true); + } + }); +}); + +describe('BLELogger', () => { + // Spy on console methods + const consoleSpy = { + log: jest.spyOn(console, 'log').mockImplementation(), + warn: jest.spyOn(console, 'warn').mockImplementation(), + error: jest.spyOn(console, 'error').mockImplementation(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + BLELogger.enable(); + }); + + afterAll(() => { + consoleSpy.log.mockRestore(); + consoleSpy.warn.mockRestore(); + consoleSpy.error.mockRestore(); + }); + + it('logs messages with BLE prefix', () => { + BLELogger.log('Test message'); + + expect(consoleSpy.log).toHaveBeenCalledWith('[BLE] Test message', ''); + }); + + it('logs warnings with BLE prefix', () => { + BLELogger.warn('Warning message'); + + expect(consoleSpy.warn).toHaveBeenCalledWith('[BLE] Warning message', ''); + }); + + it('logs errors with BLE prefix', () => { + BLELogger.error('Error message'); + + expect(consoleSpy.error).toHaveBeenCalledWith('[BLE] Error message', ''); + }); + + it('logs BLEError with formatted string', () => { + const error = new BLEError(BLEErrorCode.CONNECTION_FAILED, { + deviceName: 'WP_497', + }); + BLELogger.error('Failed', error); + + expect(consoleSpy.error).toHaveBeenCalled(); + const loggedMessage = consoleSpy.error.mock.calls[0][0]; + expect(loggedMessage).toContain('[BLE]'); + expect(loggedMessage).toContain('[BLE_CONNECTION_FAILED]'); + expect(loggedMessage).toContain('[WP_497]'); + }); + + it('does not log when disabled', () => { + BLELogger.disable(); + BLELogger.log('Should not appear'); + BLELogger.warn('Should not appear'); + BLELogger.error('Should not appear'); + + expect(consoleSpy.log).not.toHaveBeenCalled(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + + describe('logBatchProgress', () => { + it('logs batch progress with success indicator', () => { + BLELogger.logBatchProgress(1, 5, 'WP_497', 'connected', true); + + expect(consoleSpy.log).toHaveBeenCalled(); + const loggedMessage = consoleSpy.log.mock.calls[0][0]; + expect(loggedMessage).toContain('[1/5]'); + expect(loggedMessage).toContain('WP_497'); + expect(loggedMessage).toContain('✓'); + expect(loggedMessage).toContain('connected'); + }); + + it('logs batch progress with failure indicator', () => { + BLELogger.logBatchProgress(2, 5, 'WP_498', 'connection failed', false); + + expect(consoleSpy.log).toHaveBeenCalled(); + const loggedMessage = consoleSpy.log.mock.calls[0][0]; + expect(loggedMessage).toContain('[2/5]'); + expect(loggedMessage).toContain('✗'); + }); + + it('logs batch progress with duration', () => { + BLELogger.logBatchProgress(3, 5, 'WP_499', 'SUCCESS', true, 8500); + + expect(consoleSpy.log).toHaveBeenCalled(); + const loggedMessage = consoleSpy.log.mock.calls[0][0]; + expect(loggedMessage).toContain('(8.5s)'); + }); + }); + + describe('logBatchSummary', () => { + it('logs batch summary with counts', () => { + BLELogger.logBatchSummary(5, 4, 1, 45000); + + expect(consoleSpy.log).toHaveBeenCalled(); + const loggedMessage = consoleSpy.log.mock.calls[0][0]; + expect(loggedMessage).toContain('4/5 succeeded'); + expect(loggedMessage).toContain('1 failed'); + expect(loggedMessage).toContain('45.0s'); + }); + }); +}); diff --git a/services/ble/BLEManager.ts b/services/ble/BLEManager.ts index e7c17a4..44bb412 100644 --- a/services/ble/BLEManager.ts +++ b/services/ble/BLEManager.ts @@ -23,6 +23,13 @@ import { 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'; @@ -127,16 +134,27 @@ export class RealBLEManager implements IBLEManager { } async scanDevices(): Promise { + BLELogger.log('Starting device scan...'); + const startTime = Date.now(); + // Check permissions with graceful fallback const permissionStatus = await requestBLEPermissions(); if (!permissionStatus.granted) { - throw new Error(permissionStatus.error || 'Bluetooth permissions not 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) { - throw new Error(bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.'); + const error = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED, { + message: bluetoothStatus.error, + }); + BLELogger.error('Scan failed: Bluetooth disabled', error); + throw error; } const foundDevices = new Map(); @@ -150,7 +168,9 @@ export class RealBLEManager implements IBLEManager { (error, device) => { if (error) { this.scanning = false; - reject(error); + const bleError = parseBLEError(error, { operation: 'scan' }); + BLELogger.error('Scan error', bleError); + reject(bleError); return; } @@ -170,6 +190,8 @@ export class RealBLEManager implements IBLEManager { rssi: device.rssi || -100, wellId, }); + + BLELogger.log(`Found device: ${device.name} (RSSI: ${device.rssi})`); } } ); @@ -177,7 +199,10 @@ export class RealBLEManager implements IBLEManager { // Stop scan after timeout setTimeout(() => { this.stopScan(); - resolve(Array.from(foundDevices.values())); + 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); }); } @@ -190,10 +215,15 @@ export class RealBLEManager implements IBLEManager { } async connectDevice(deviceId: string): Promise { + const startTime = Date.now(); + BLELogger.log(`Connecting to device: ${deviceId}`); + try { // Check if connection is already in progress if (this.connectingDevices.has(deviceId)) { - throw new Error('Connection already in progress for this device'); + const error = new BLEError(BLEErrorCode.CONNECTION_IN_PROGRESS, { deviceId }); + BLELogger.warn('Connection already in progress', error); + throw error; } // Check if already connected @@ -201,6 +231,7 @@ export class RealBLEManager implements IBLEManager { 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; @@ -219,19 +250,27 @@ export class RealBLEManager implements IBLEManager { // Step 0: Check permissions (required for Android 12+) const permissionStatus = await requestBLEPermissions(); if (!permissionStatus.granted) { - const error = permissionStatus.error || 'Bluetooth permissions not granted'; - this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, error); - this.emitEvent(deviceId, 'connection_failed', { error }); - throw new Error(error); + 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 = bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.'; - this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, error); - this.emitEvent(deviceId, 'connection_failed', { error }); - throw new Error(error); + 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, { @@ -240,17 +279,21 @@ export class RealBLEManager implements IBLEManager { // 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'); } } @@ -260,11 +303,19 @@ export class RealBLEManager implements IBLEManager { 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) { - const errorMessage = error?.message || 'Connection failed'; + // 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 }); + 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) @@ -336,10 +387,15 @@ export class RealBLEManager implements IBLEManager { async sendCommand(deviceId: string, command: string): Promise { 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) { - throw new Error('Device not connected'); + const error = new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, { deviceId }); + BLELogger.error('Command failed: device not connected', error); + throw error; } // Verify device is still connected @@ -347,10 +403,18 @@ export class RealBLEManager implements IBLEManager { const isConnected = await device.isConnected(); if (!isConnected) { this.connectedDevices.delete(deviceId); - throw new Error('Device disconnected'); + const error = new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, { deviceId }); + BLELogger.error('Command failed: device disconnected', error); + throw error; } - } catch { - throw new Error('Failed to verify connection'); + } 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 @@ -398,10 +462,15 @@ export class RealBLEManager implements IBLEManager { const deviceKey = `${deviceId}`; this.updateCommunicationStats(deviceKey, false, responseTime); - // Ensure error has a valid message (fixes Android NullPointerException) - const errorMessage = error?.message || error?.reason || 'BLE operation failed'; + // Parse and wrap error with proper BLEError + const bleError = parseBLEError(error, { + deviceId, + deviceName: device.name || undefined, + operation: 'command', + }); - reject(new Error(`[${errorCode}] ${errorMessage}`)); + BLELogger.error(`Command failed for ${deviceId}`, bleError); + reject(bleError); }; try { @@ -455,7 +524,12 @@ export class RealBLEManager implements IBLEManager { // Timeout timeoutId = setTimeout(() => { if (!responseReceived) { - safeReject(new Error('Command timeout')); + 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) { @@ -465,11 +539,23 @@ export class RealBLEManager implements IBLEManager { } async getWiFiList(deviceId: string): Promise { + BLELogger.log(`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 Error('Failed to 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 @@ -478,15 +564,23 @@ export class RealBLEManager implements IBLEManager { // Parse response: "mac,XXXXXX|w|COUNT|SSID1,RSSI1|SSID2,RSSI2|..." const parts = listResponse.split('|'); if (parts.length < 3) { - throw new Error('Invalid WiFi list response'); + 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) { - throw new Error('WiFi scan in progress, please wait'); + 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 } } @@ -518,13 +612,25 @@ export class RealBLEManager implements IBLEManager { } async setWiFi(deviceId: string, ssid: string, password: string): Promise { + 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(',')) { - throw new Error('Network name contains invalid characters. Please select a different network.'); + 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('|')) { - throw new Error('Password contains an invalid character (|). Please use a different password.'); + 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 @@ -532,15 +638,25 @@ export class RealBLEManager implements IBLEManager { try { unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); } catch (err: any) { - if (err.message?.includes('timeout')) { - throw new Error('Sensor not responding. Please move closer and try again.'); + 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; } - throw new Error(`Cannot communicate with sensor: ${err.message}`); + const error = parseBLEError(err, { deviceId, operation: 'unlock' }); + BLELogger.error('Unlock failed', error); + throw error; } if (!unlockResponse.includes('ok')) { - throw new Error('Sensor authentication failed. Please try reconnecting.'); + 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 @@ -565,14 +681,23 @@ export class RealBLEManager implements IBLEManager { try { setResponse = await this.sendCommand(deviceId, command); } catch (err: any) { - if (err.message?.includes('timeout')) { - throw new Error('Sensor did not respond to WiFi config. Please try again.'); + 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; } - throw new Error(`WiFi configuration failed: ${err.message}`); + 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; } @@ -587,6 +712,7 @@ export class RealBLEManager implements IBLEManager { // 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; } } @@ -595,27 +721,53 @@ export class RealBLEManager implements IBLEManager { } // Password was definitely wrong - throw new Error('WiFi password is incorrect. Please check and try again.'); + const error = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT, { deviceId }); + BLELogger.error('WiFi password incorrect', error); + throw error; } if (setResponse.includes('timeout') || setResponse.includes('Timeout')) { - throw new Error('Sensor did not respond to WiFi config. Please try again.'); + 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')) { - throw new Error('WiFi network not found. Make sure the sensor is within range of your router.'); + const error = new BLEError(BLEErrorCode.WIFI_NETWORK_NOT_FOUND, { deviceId }); + BLELogger.error('WiFi network not found', error); + throw error; } // Unknown error - provide helpful message - throw new Error('WiFi configuration failed. Please try again or contact support.'); + 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 { + BLELogger.log(`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 Error('Failed to 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 @@ -624,27 +776,49 @@ export class RealBLEManager implements IBLEManager { // 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: parseInt(rssiStr, 10), + rssi: rssi, connected: true, }; } async rebootDevice(deviceId: string): Promise { - // Step 1: Unlock device - await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); + BLELogger.log(`Rebooting device: ${deviceId}`); - // Step 2: Reboot (device will disconnect) - await this.sendCommand(deviceId, BLE_COMMANDS.REBOOT); + 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); @@ -930,27 +1104,41 @@ export class RealBLEManager implements IBLEManager { onProgress?: (deviceId: string, status: 'connecting' | 'configuring' | 'rebooting' | 'success' | 'error', error?: string) => void ): Promise { const results: BulkWiFiResult[] = []; + const total = devices.length; + const batchStartTime = Date.now(); - for (const device of devices) { - const { id: deviceId, name: deviceName } = device; + 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 Error('Could not connect to device'); + 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, @@ -958,7 +1146,10 @@ export class RealBLEManager implements IBLEManager { success: true, }); } catch (error: any) { - const errorMessage = error?.message || 'WiFi configuration failed'; + 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, @@ -969,6 +1160,12 @@ export class RealBLEManager implements IBLEManager { } } + // 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; } diff --git a/services/ble/MockBLEManager.ts b/services/ble/MockBLEManager.ts index 9fc5b19..afadf11 100644 --- a/services/ble/MockBLEManager.ts +++ b/services/ble/MockBLEManager.ts @@ -19,6 +19,13 @@ import { ReconnectState, DEFAULT_RECONNECT_CONFIG, } from './types'; +import { + BLEError, + BLEErrorCode, + BLELogger, + isBLEError, + parseBLEError, +} from './errors'; const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); @@ -126,7 +133,9 @@ export class MockBLEManager implements IBLEManager { } async scanDevices(): Promise { + BLELogger.log('[Mock] Starting device scan...'); await delay(2000); // Simulate scan delay + BLELogger.log(`[Mock] Scan complete: found ${this.mockDevices.length} devices`); return this.mockDevices; } @@ -134,14 +143,19 @@ export class MockBLEManager implements IBLEManager { } async connectDevice(deviceId: string): Promise { + BLELogger.log(`[Mock] Connecting to device: ${deviceId}`); + try { // Check if connection is already in progress if (this.connectingDevices.has(deviceId)) { - throw new Error('Connection already in progress for this device'); + const error = new BLEError(BLEErrorCode.CONNECTION_IN_PROGRESS, { deviceId }); + BLELogger.warn('[Mock] Connection already in progress', error); + throw error; } // Check if already connected if (this.connectedDevices.has(deviceId)) { + BLELogger.log(`[Mock] Device already connected: ${deviceId}`); this.updateConnectionState(deviceId, BLEConnectionState.READY); this.emitEvent(deviceId, 'ready'); return true; @@ -163,11 +177,15 @@ export class MockBLEManager implements IBLEManager { this.updateConnectionState(deviceId, BLEConnectionState.READY); this.emitEvent(deviceId, 'ready'); + BLELogger.log(`[Mock] Device ready: ${deviceId}`); return true; } catch (error: any) { - const errorMessage = error?.message || 'Connection failed'; + 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 }); + this.emitEvent(deviceId, 'connection_failed', { error: errorMessage, code: bleError.code }); + BLELogger.error(`[Mock] Connection failed for ${deviceId}`, bleError); return false; } finally { // Always remove from connecting set when done (success or failure) @@ -223,14 +241,25 @@ export class MockBLEManager implements IBLEManager { ssid: string, password: string ): Promise { + BLELogger.log(`[Mock] Setting WiFi on device: ${deviceId}, SSID: ${ssid}`); await delay(2000); // Pre-validate credentials (same as real BLEManager) if (ssid.includes('|') || ssid.includes(',')) { - throw new Error('Network name contains invalid characters. Please select a different network.'); + const error = new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, { + deviceId, + message: 'Network name contains invalid characters', + }); + BLELogger.error('[Mock] Invalid SSID characters', error); + throw error; } if (password.includes('|')) { - throw new Error('Password contains an invalid character (|). Please use a different password.'); + const error = new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, { + deviceId, + message: 'Password contains an invalid character (|)', + }); + BLELogger.error('[Mock] Invalid password characters', error); + throw error; } // Simulate various failure scenarios for testing @@ -240,34 +269,56 @@ export class MockBLEManager implements IBLEManager { // Simulate wrong password if (lowerPassword === 'wrongpass' || lowerPassword === 'wrong') { - throw new Error('WiFi password is incorrect. Please check and try again.'); + const error = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT, { deviceId }); + BLELogger.error('[Mock] Wrong password simulated', error); + throw error; } // Simulate network not found if (lowerSsid.includes('notfound') || lowerSsid === 'hidden_network') { - throw new Error('WiFi network not found. Make sure the sensor is within range of your router.'); + const error = new BLEError(BLEErrorCode.WIFI_NETWORK_NOT_FOUND, { deviceId }); + BLELogger.error('[Mock] Network not found simulated', error); + throw error; } // Simulate timeout if (lowerSsid.includes('timeout') || lowerPassword === 'timeout') { - throw new Error('Sensor did not respond to WiFi config. Please try again.'); + const error = new BLEError(BLEErrorCode.SENSOR_NOT_RESPONDING, { + deviceId, + message: 'Sensor did not respond to WiFi config', + }); + BLELogger.error('[Mock] Timeout simulated', error); + throw error; } // Simulate sensor not responding if (lowerSsid.includes('offline')) { - throw new Error('Sensor not responding. Please move closer and try again.'); + const error = new BLEError(BLEErrorCode.SENSOR_NOT_RESPONDING, { deviceId }); + BLELogger.error('[Mock] Sensor offline simulated', error); + throw error; } // Validate password length (WPA/WPA2 requirement) if (password.length < 8) { - throw new Error('Password must be at least 8 characters for WPA/WPA2 networks.'); + const error = new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, { + deviceId, + message: 'Password must be at least 8 characters for WPA/WPA2 networks', + }); + BLELogger.error('[Mock] Password too short', error); + throw error; } if (password.length > 63) { - throw new Error('Password cannot exceed 63 characters.'); + const error = new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, { + deviceId, + message: 'Password cannot exceed 63 characters', + }); + BLELogger.error('[Mock] Password too long', error); + throw error; } // Success for all other cases + BLELogger.log(`[Mock] WiFi configured successfully for ${ssid}`); return true; } @@ -430,25 +481,36 @@ export class MockBLEManager implements IBLEManager { onProgress?: (deviceId: string, status: 'connecting' | 'configuring' | 'rebooting' | 'success' | 'error', error?: string) => void ): Promise { const results: BulkWiFiResult[] = []; + const total = devices.length; + const batchStartTime = Date.now(); - for (const device of devices) { - const { id: deviceId, name: deviceName } = device; + BLELogger.log(`[Mock] 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 (mock) + BLELogger.logBatchProgress(index, total, deviceName, 'connecting...'); onProgress?.(deviceId, 'connecting'); await delay(800); await this.connectDevice(deviceId); + BLELogger.logBatchProgress(index, total, deviceName, 'connected', true); // Step 2: Set WiFi (mock) + BLELogger.logBatchProgress(index, total, deviceName, 'setting WiFi...'); onProgress?.(deviceId, 'configuring'); await delay(1200); await this.setWiFi(deviceId, ssid, password); + BLELogger.logBatchProgress(index, total, deviceName, 'WiFi configured', true); // Step 3: Reboot (mock) + BLELogger.logBatchProgress(index, total, deviceName, 'rebooting...'); onProgress?.(deviceId, 'rebooting'); await delay(600); await this.rebootDevice(deviceId); + BLELogger.logBatchProgress(index, total, deviceName, 'SUCCESS', true); // Success onProgress?.(deviceId, 'success'); @@ -458,7 +520,10 @@ export class MockBLEManager implements IBLEManager { success: true, }); } catch (error: any) { - const errorMessage = error?.message || 'WiFi configuration failed'; + 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, @@ -469,6 +534,12 @@ export class MockBLEManager implements IBLEManager { } } + // 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; } diff --git a/services/ble/errors.ts b/services/ble/errors.ts new file mode 100644 index 0000000..358ba25 --- /dev/null +++ b/services/ble/errors.ts @@ -0,0 +1,661 @@ +// BLE Error Types and Error Handling Utilities + +/** + * BLE Error Codes + * Categorized by operation type for easier handling + */ +export enum BLEErrorCode { + // Connection errors (100-199) + CONNECTION_FAILED = 'BLE_CONNECTION_FAILED', + CONNECTION_TIMEOUT = 'BLE_CONNECTION_TIMEOUT', + CONNECTION_IN_PROGRESS = 'BLE_CONNECTION_IN_PROGRESS', + DEVICE_NOT_FOUND = 'BLE_DEVICE_NOT_FOUND', + DEVICE_OUT_OF_RANGE = 'BLE_DEVICE_OUT_OF_RANGE', + DEVICE_BUSY = 'BLE_DEVICE_BUSY', + ALREADY_CONNECTED = 'BLE_ALREADY_CONNECTED', + + // Permission errors (200-299) + PERMISSION_DENIED = 'BLE_PERMISSION_DENIED', + BLUETOOTH_DISABLED = 'BLE_BLUETOOTH_DISABLED', + LOCATION_DISABLED = 'BLE_LOCATION_DISABLED', + + // Communication errors (300-399) + COMMAND_FAILED = 'BLE_COMMAND_FAILED', + COMMAND_TIMEOUT = 'BLE_COMMAND_TIMEOUT', + INVALID_RESPONSE = 'BLE_INVALID_RESPONSE', + DEVICE_DISCONNECTED = 'BLE_DEVICE_DISCONNECTED', + SERVICE_NOT_FOUND = 'BLE_SERVICE_NOT_FOUND', + CHARACTERISTIC_NOT_FOUND = 'BLE_CHARACTERISTIC_NOT_FOUND', + + // Authentication errors (400-499) + PIN_UNLOCK_FAILED = 'BLE_PIN_UNLOCK_FAILED', + AUTHENTICATION_FAILED = 'BLE_AUTHENTICATION_FAILED', + + // WiFi configuration errors (500-599) + WIFI_CONFIG_FAILED = 'BLE_WIFI_CONFIG_FAILED', + WIFI_PASSWORD_INCORRECT = 'BLE_WIFI_PASSWORD_INCORRECT', + WIFI_NETWORK_NOT_FOUND = 'BLE_WIFI_NETWORK_NOT_FOUND', + WIFI_SCAN_IN_PROGRESS = 'BLE_WIFI_SCAN_IN_PROGRESS', + WIFI_INVALID_CREDENTIALS = 'BLE_WIFI_INVALID_CREDENTIALS', + + // Sensor errors (600-699) + SENSOR_NOT_RESPONDING = 'BLE_SENSOR_NOT_RESPONDING', + SENSOR_REBOOT_FAILED = 'BLE_SENSOR_REBOOT_FAILED', + SENSOR_ATTACH_FAILED = 'BLE_SENSOR_ATTACH_FAILED', + + // General errors (900-999) + UNKNOWN_ERROR = 'BLE_UNKNOWN_ERROR', + OPERATION_CANCELLED = 'BLE_OPERATION_CANCELLED', +} + +/** + * Error severity levels for UI display + */ +export enum BLEErrorSeverity { + INFO = 'info', + WARNING = 'warning', + ERROR = 'error', + CRITICAL = 'critical', +} + +/** + * Recovery action types + */ +export enum BLERecoveryAction { + RETRY = 'retry', + SKIP = 'skip', + CANCEL = 'cancel', + ENABLE_BLUETOOTH = 'enable_bluetooth', + ENABLE_LOCATION = 'enable_location', + GRANT_PERMISSIONS = 'grant_permissions', + MOVE_CLOSER = 'move_closer', + CHECK_SENSOR_POWER = 'check_sensor_power', + CHECK_WIFI_PASSWORD = 'check_wifi_password', + TRY_DIFFERENT_NETWORK = 'try_different_network', + CONTACT_SUPPORT = 'contact_support', +} + +/** + * User-friendly error messages + */ +export const BLE_ERROR_MESSAGES: Record = { + // Connection errors + [BLEErrorCode.CONNECTION_FAILED]: { + title: 'Connection Failed', + message: 'Could not connect to the sensor. Make sure it is powered on and try again.', + }, + [BLEErrorCode.CONNECTION_TIMEOUT]: { + title: 'Connection Timeout', + message: 'The sensor did not respond in time. Move closer and try again.', + }, + [BLEErrorCode.CONNECTION_IN_PROGRESS]: { + title: 'Connection in Progress', + message: 'Already trying to connect to this sensor. Please wait.', + }, + [BLEErrorCode.DEVICE_NOT_FOUND]: { + title: 'Sensor Not Found', + message: 'Could not find the sensor. Make sure it is powered on and nearby.', + }, + [BLEErrorCode.DEVICE_OUT_OF_RANGE]: { + title: 'Sensor Out of Range', + message: 'The sensor is too far away. Move closer and try again.', + }, + [BLEErrorCode.DEVICE_BUSY]: { + title: 'Sensor Busy', + message: 'The sensor is busy. Wait a moment and try again.', + }, + [BLEErrorCode.ALREADY_CONNECTED]: { + title: 'Already Connected', + message: 'Already connected to this sensor.', + }, + + // Permission errors + [BLEErrorCode.PERMISSION_DENIED]: { + title: 'Permission Denied', + message: 'Bluetooth permission is required to connect to sensors. Please grant permission in Settings.', + }, + [BLEErrorCode.BLUETOOTH_DISABLED]: { + title: 'Bluetooth Disabled', + message: 'Bluetooth is turned off. Please enable Bluetooth in your device settings.', + }, + [BLEErrorCode.LOCATION_DISABLED]: { + title: 'Location Required', + message: 'Location access is required for Bluetooth scanning on Android. Please enable location services.', + }, + + // Communication errors + [BLEErrorCode.COMMAND_FAILED]: { + title: 'Command Failed', + message: 'Could not communicate with the sensor. Try reconnecting.', + }, + [BLEErrorCode.COMMAND_TIMEOUT]: { + title: 'No Response', + message: 'The sensor did not respond. Make sure it is powered on and nearby.', + }, + [BLEErrorCode.INVALID_RESPONSE]: { + title: 'Invalid Response', + message: 'Received an unexpected response from the sensor. Try again.', + }, + [BLEErrorCode.DEVICE_DISCONNECTED]: { + title: 'Disconnected', + message: 'Lost connection to the sensor. Try reconnecting.', + }, + [BLEErrorCode.SERVICE_NOT_FOUND]: { + title: 'Sensor Error', + message: 'The sensor may need a firmware update. Please contact support.', + }, + [BLEErrorCode.CHARACTERISTIC_NOT_FOUND]: { + title: 'Sensor Error', + message: 'The sensor may need a firmware update. Please contact support.', + }, + + // Authentication errors + [BLEErrorCode.PIN_UNLOCK_FAILED]: { + title: 'Authentication Failed', + message: 'Could not unlock the sensor. Try reconnecting.', + }, + [BLEErrorCode.AUTHENTICATION_FAILED]: { + title: 'Authentication Failed', + message: 'Sensor authentication failed. Try reconnecting.', + }, + + // WiFi configuration errors + [BLEErrorCode.WIFI_CONFIG_FAILED]: { + title: 'WiFi Setup Failed', + message: 'Could not configure WiFi on the sensor. Check your password and try again.', + }, + [BLEErrorCode.WIFI_PASSWORD_INCORRECT]: { + title: 'Wrong Password', + message: 'The WiFi password is incorrect. Please check and try again.', + }, + [BLEErrorCode.WIFI_NETWORK_NOT_FOUND]: { + title: 'Network Not Found', + message: 'WiFi network not found. Make sure the sensor is within range of your router.', + }, + [BLEErrorCode.WIFI_SCAN_IN_PROGRESS]: { + title: 'Scanning', + message: 'WiFi scan is in progress. Please wait a moment and try again.', + }, + [BLEErrorCode.WIFI_INVALID_CREDENTIALS]: { + title: 'Invalid Credentials', + message: 'The network name or password contains invalid characters.', + }, + + // Sensor errors + [BLEErrorCode.SENSOR_NOT_RESPONDING]: { + title: 'Sensor Not Responding', + message: 'The sensor is not responding. Check if it is powered on.', + }, + [BLEErrorCode.SENSOR_REBOOT_FAILED]: { + title: 'Reboot Failed', + message: 'Could not reboot the sensor. Try again or power cycle manually.', + }, + [BLEErrorCode.SENSOR_ATTACH_FAILED]: { + title: 'Registration Failed', + message: 'Could not register the sensor. Check your internet connection.', + }, + + // General errors + [BLEErrorCode.UNKNOWN_ERROR]: { + title: 'Error', + message: 'An unexpected error occurred. Please try again.', + }, + [BLEErrorCode.OPERATION_CANCELLED]: { + title: 'Cancelled', + message: 'Operation was cancelled.', + }, +}; + +/** + * Map error codes to suggested recovery actions + */ +export const BLE_RECOVERY_ACTIONS: Record = { + // Connection errors + [BLEErrorCode.CONNECTION_FAILED]: [BLERecoveryAction.RETRY, BLERecoveryAction.MOVE_CLOSER, BLERecoveryAction.CHECK_SENSOR_POWER], + [BLEErrorCode.CONNECTION_TIMEOUT]: [BLERecoveryAction.RETRY, BLERecoveryAction.MOVE_CLOSER], + [BLEErrorCode.CONNECTION_IN_PROGRESS]: [], + [BLEErrorCode.DEVICE_NOT_FOUND]: [BLERecoveryAction.RETRY, BLERecoveryAction.CHECK_SENSOR_POWER], + [BLEErrorCode.DEVICE_OUT_OF_RANGE]: [BLERecoveryAction.MOVE_CLOSER, BLERecoveryAction.RETRY], + [BLEErrorCode.DEVICE_BUSY]: [BLERecoveryAction.RETRY], + [BLEErrorCode.ALREADY_CONNECTED]: [], + + // Permission errors + [BLEErrorCode.PERMISSION_DENIED]: [BLERecoveryAction.GRANT_PERMISSIONS], + [BLEErrorCode.BLUETOOTH_DISABLED]: [BLERecoveryAction.ENABLE_BLUETOOTH], + [BLEErrorCode.LOCATION_DISABLED]: [BLERecoveryAction.ENABLE_LOCATION], + + // Communication errors + [BLEErrorCode.COMMAND_FAILED]: [BLERecoveryAction.RETRY, BLERecoveryAction.SKIP], + [BLEErrorCode.COMMAND_TIMEOUT]: [BLERecoveryAction.RETRY, BLERecoveryAction.MOVE_CLOSER], + [BLEErrorCode.INVALID_RESPONSE]: [BLERecoveryAction.RETRY, BLERecoveryAction.CONTACT_SUPPORT], + [BLEErrorCode.DEVICE_DISCONNECTED]: [BLERecoveryAction.RETRY], + [BLEErrorCode.SERVICE_NOT_FOUND]: [BLERecoveryAction.CONTACT_SUPPORT], + [BLEErrorCode.CHARACTERISTIC_NOT_FOUND]: [BLERecoveryAction.CONTACT_SUPPORT], + + // Authentication errors + [BLEErrorCode.PIN_UNLOCK_FAILED]: [BLERecoveryAction.RETRY, BLERecoveryAction.SKIP], + [BLEErrorCode.AUTHENTICATION_FAILED]: [BLERecoveryAction.RETRY, BLERecoveryAction.SKIP], + + // WiFi configuration errors + [BLEErrorCode.WIFI_CONFIG_FAILED]: [BLERecoveryAction.RETRY, BLERecoveryAction.CHECK_WIFI_PASSWORD], + [BLEErrorCode.WIFI_PASSWORD_INCORRECT]: [BLERecoveryAction.CHECK_WIFI_PASSWORD, BLERecoveryAction.RETRY], + [BLEErrorCode.WIFI_NETWORK_NOT_FOUND]: [BLERecoveryAction.TRY_DIFFERENT_NETWORK, BLERecoveryAction.MOVE_CLOSER], + [BLEErrorCode.WIFI_SCAN_IN_PROGRESS]: [BLERecoveryAction.RETRY], + [BLEErrorCode.WIFI_INVALID_CREDENTIALS]: [BLERecoveryAction.TRY_DIFFERENT_NETWORK], + + // Sensor errors + [BLEErrorCode.SENSOR_NOT_RESPONDING]: [BLERecoveryAction.CHECK_SENSOR_POWER, BLERecoveryAction.RETRY], + [BLEErrorCode.SENSOR_REBOOT_FAILED]: [BLERecoveryAction.RETRY, BLERecoveryAction.SKIP], + [BLEErrorCode.SENSOR_ATTACH_FAILED]: [BLERecoveryAction.RETRY, BLERecoveryAction.SKIP], + + // General errors + [BLEErrorCode.UNKNOWN_ERROR]: [BLERecoveryAction.RETRY, BLERecoveryAction.CANCEL], + [BLEErrorCode.OPERATION_CANCELLED]: [], +}; + +/** + * Custom BLE Error class with rich metadata + */ +export class BLEError extends Error { + public readonly code: BLEErrorCode; + public readonly severity: BLEErrorSeverity; + public readonly recoveryActions: BLERecoveryAction[]; + public readonly userMessage: { title: string; message: string }; + public readonly deviceId?: string; + public readonly deviceName?: string; + public readonly timestamp: number; + public readonly originalError?: Error; + + constructor( + code: BLEErrorCode, + options?: { + message?: string; + deviceId?: string; + deviceName?: string; + originalError?: Error; + severity?: BLEErrorSeverity; + } + ) { + const userMessage = BLE_ERROR_MESSAGES[code] || BLE_ERROR_MESSAGES[BLEErrorCode.UNKNOWN_ERROR]; + const technicalMessage = options?.message || options?.originalError?.message || userMessage.message; + + super(technicalMessage); + + this.name = 'BLEError'; + this.code = code; + this.severity = options?.severity || getSeverityForErrorCode(code); + this.recoveryActions = BLE_RECOVERY_ACTIONS[code] || []; + this.userMessage = userMessage; + this.deviceId = options?.deviceId; + this.deviceName = options?.deviceName; + this.timestamp = Date.now(); + this.originalError = options?.originalError; + + // Ensure proper prototype chain + Object.setPrototypeOf(this, BLEError.prototype); + } + + /** + * Get formatted error for logging + */ + toLogString(): string { + const parts = [ + `[BLE] [${this.code}]`, + this.deviceName ? `[${this.deviceName}]` : this.deviceId ? `[${this.deviceId}]` : '', + this.message, + ]; + return parts.filter(Boolean).join(' '); + } + + /** + * Check if error is retryable + */ + isRetryable(): boolean { + return this.recoveryActions.includes(BLERecoveryAction.RETRY); + } + + /** + * Check if error can be skipped (for batch operations) + */ + isSkippable(): boolean { + return this.recoveryActions.includes(BLERecoveryAction.SKIP); + } +} + +/** + * Determine error severity based on error code + */ +function getSeverityForErrorCode(code: BLEErrorCode): BLEErrorSeverity { + // Critical: Cannot proceed without user action + if ([ + BLEErrorCode.PERMISSION_DENIED, + BLEErrorCode.BLUETOOTH_DISABLED, + BLEErrorCode.LOCATION_DISABLED, + ].includes(code)) { + return BLEErrorSeverity.CRITICAL; + } + + // Error: Operation failed but may be recoverable + if ([ + BLEErrorCode.CONNECTION_FAILED, + BLEErrorCode.CONNECTION_TIMEOUT, + BLEErrorCode.WIFI_PASSWORD_INCORRECT, + BLEErrorCode.WIFI_CONFIG_FAILED, + BLEErrorCode.PIN_UNLOCK_FAILED, + BLEErrorCode.SENSOR_NOT_RESPONDING, + ].includes(code)) { + return BLEErrorSeverity.ERROR; + } + + // Warning: Minor issue, may resolve automatically + if ([ + BLEErrorCode.DEVICE_BUSY, + BLEErrorCode.WIFI_SCAN_IN_PROGRESS, + BLEErrorCode.CONNECTION_IN_PROGRESS, + ].includes(code)) { + return BLEErrorSeverity.WARNING; + } + + // Info: Expected states + if ([ + BLEErrorCode.ALREADY_CONNECTED, + BLEErrorCode.OPERATION_CANCELLED, + ].includes(code)) { + return BLEErrorSeverity.INFO; + } + + return BLEErrorSeverity.ERROR; +} + +/** + * Parse native BLE error and convert to BLEError + */ +export function parseBLEError( + error: unknown, + context?: { + deviceId?: string; + deviceName?: string; + operation?: string; + } +): BLEError { + const originalError = error instanceof Error ? error : new Error(String(error)); + const message = originalError.message?.toLowerCase() || ''; + + // Extract error code from message (format: [CODE] message) + const codeMatch = originalError.message?.match(/^\[(\w+)\]/); + const extractedCode = codeMatch?.[1]; + + // Permission/Bluetooth errors + if (message.includes('permission') || message.includes('not granted')) { + return new BLEError(BLEErrorCode.PERMISSION_DENIED, { + ...context, + originalError, + }); + } + + if (message.includes('bluetooth') && (message.includes('disabled') || message.includes('off'))) { + return new BLEError(BLEErrorCode.BLUETOOTH_DISABLED, { + ...context, + originalError, + }); + } + + if (message.includes('location') && (message.includes('disabled') || message.includes('required'))) { + return new BLEError(BLEErrorCode.LOCATION_DISABLED, { + ...context, + originalError, + }); + } + + // Connection errors + if (message.includes('timeout')) { + return new BLEError(BLEErrorCode.CONNECTION_TIMEOUT, { + ...context, + originalError, + }); + } + + if (message.includes('already in progress')) { + return new BLEError(BLEErrorCode.CONNECTION_IN_PROGRESS, { + ...context, + originalError, + }); + } + + if (message.includes('not found') && message.includes('device')) { + return new BLEError(BLEErrorCode.DEVICE_NOT_FOUND, { + ...context, + originalError, + }); + } + + if (message.includes('disconnect') || message.includes('not connected')) { + return new BLEError(BLEErrorCode.DEVICE_DISCONNECTED, { + ...context, + originalError, + }); + } + + // WiFi errors + if (message.includes('password') && (message.includes('incorrect') || message.includes('wrong'))) { + return new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT, { + ...context, + originalError, + }); + } + + if (message.includes('network') && message.includes('not found')) { + return new BLEError(BLEErrorCode.WIFI_NETWORK_NOT_FOUND, { + ...context, + originalError, + }); + } + + if (message.includes('wifi') && message.includes('scan') && message.includes('progress')) { + return new BLEError(BLEErrorCode.WIFI_SCAN_IN_PROGRESS, { + ...context, + originalError, + }); + } + + if (message.includes('invalid character')) { + return new BLEError(BLEErrorCode.WIFI_INVALID_CREDENTIALS, { + ...context, + originalError, + }); + } + + // PIN/Authentication errors + if (message.includes('unlock') || message.includes('pin')) { + return new BLEError(BLEErrorCode.PIN_UNLOCK_FAILED, { + ...context, + originalError, + }); + } + + // Sensor not responding + if (message.includes('not responding') || message.includes('no response')) { + return new BLEError(BLEErrorCode.SENSOR_NOT_RESPONDING, { + ...context, + originalError, + }); + } + + // Cancelled operation (Android errorCode 2) + if (message.includes('cancelled') || extractedCode === '2') { + return new BLEError(BLEErrorCode.OPERATION_CANCELLED, { + ...context, + originalError, + }); + } + + // Service/Characteristic not found + if (message.includes('service') && message.includes('not found')) { + return new BLEError(BLEErrorCode.SERVICE_NOT_FOUND, { + ...context, + originalError, + }); + } + + if (message.includes('characteristic') && message.includes('not found')) { + return new BLEError(BLEErrorCode.CHARACTERISTIC_NOT_FOUND, { + ...context, + originalError, + }); + } + + // Default to connection failed for unrecognized errors + if (message.includes('connect') || message.includes('connection')) { + return new BLEError(BLEErrorCode.CONNECTION_FAILED, { + ...context, + originalError, + }); + } + + // Unknown error + return new BLEError(BLEErrorCode.UNKNOWN_ERROR, { + message: originalError.message, + ...context, + originalError, + }); +} + +/** + * Check if an error is a BLEError + */ +export function isBLEError(error: unknown): error is BLEError { + return error instanceof BLEError; +} + +/** + * Get user-friendly error info from any error + */ +export function getErrorInfo(error: unknown): { + code: BLEErrorCode; + title: string; + message: string; + severity: BLEErrorSeverity; + recoveryActions: BLERecoveryAction[]; + isRetryable: boolean; +} { + if (isBLEError(error)) { + return { + code: error.code, + title: error.userMessage.title, + message: error.userMessage.message, + severity: error.severity, + recoveryActions: error.recoveryActions, + isRetryable: error.isRetryable(), + }; + } + + const parsedError = parseBLEError(error); + return { + code: parsedError.code, + title: parsedError.userMessage.title, + message: parsedError.userMessage.message, + severity: parsedError.severity, + recoveryActions: parsedError.recoveryActions, + isRetryable: parsedError.isRetryable(), + }; +} + +/** + * Get localized action button text for recovery actions + */ +export function getRecoveryActionLabel(action: BLERecoveryAction): string { + switch (action) { + case BLERecoveryAction.RETRY: + return 'Retry'; + case BLERecoveryAction.SKIP: + return 'Skip'; + case BLERecoveryAction.CANCEL: + return 'Cancel'; + case BLERecoveryAction.ENABLE_BLUETOOTH: + return 'Enable Bluetooth'; + case BLERecoveryAction.ENABLE_LOCATION: + return 'Enable Location'; + case BLERecoveryAction.GRANT_PERMISSIONS: + return 'Grant Permission'; + case BLERecoveryAction.MOVE_CLOSER: + return 'Move Closer'; + case BLERecoveryAction.CHECK_SENSOR_POWER: + return 'Check Sensor'; + case BLERecoveryAction.CHECK_WIFI_PASSWORD: + return 'Check Password'; + case BLERecoveryAction.TRY_DIFFERENT_NETWORK: + return 'Try Different Network'; + case BLERecoveryAction.CONTACT_SUPPORT: + return 'Contact Support'; + default: + return 'OK'; + } +} + +/** + * BLE Logger for consistent logging format + */ +export class BLELogger { + private static enabled = true; + private static prefix = '[BLE]'; + + static enable(): void { + BLELogger.enabled = true; + } + + static disable(): void { + BLELogger.enabled = false; + } + + static log(message: string, data?: any): void { + if (!BLELogger.enabled) return; + console.log(`${BLELogger.prefix} ${message}`, data !== undefined ? data : ''); + } + + static warn(message: string, data?: any): void { + if (!BLELogger.enabled) return; + console.warn(`${BLELogger.prefix} ${message}`, data !== undefined ? data : ''); + } + + static error(message: string, error?: any): void { + if (!BLELogger.enabled) return; + if (isBLEError(error)) { + console.error(`${BLELogger.prefix} ${error.toLogString()}`); + } else { + console.error(`${BLELogger.prefix} ${message}`, error !== undefined ? error : ''); + } + } + + /** + * Log batch operation progress + */ + static logBatchProgress( + index: number, + total: number, + deviceName: string, + step: string, + success?: boolean, + duration?: number + ): void { + if (!BLELogger.enabled) return; + + const status = success === undefined ? '●' : success ? '✓' : '✗'; + const durationStr = duration !== undefined ? ` (${(duration / 1000).toFixed(1)}s)` : ''; + + console.log(`${BLELogger.prefix} [${index}/${total}] ${deviceName} — ${status} ${step}${durationStr}`); + } + + /** + * Log batch operation summary + */ + static logBatchSummary( + total: number, + succeeded: number, + failed: number, + duration: number + ): void { + if (!BLELogger.enabled) return; + + console.log(`${BLELogger.prefix} Batch complete: ${succeeded}/${total} succeeded, ${failed} failed (${(duration / 1000).toFixed(1)}s)`); + } +} diff --git a/services/ble/index.ts b/services/ble/index.ts index 42ec2dc..8ed057f 100644 --- a/services/ble/index.ts +++ b/services/ble/index.ts @@ -63,3 +63,6 @@ export * from './types'; // Re-export permission utilities export * from './permissions'; + +// Re-export error types and utilities +export * from './errors';