// 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(); private connectionStates = new Map(); private eventListeners: BLEEventListener[] = []; private scanning = false; private connectingDevices = new Set(); // Track devices currently being connected // Health monitoring state private sensorHealthMetrics = new Map(); private communicationStats = new Map(); // Reconnect state private reconnectConfig: ReconnectConfig = { ...DEFAULT_RECONNECT_CONFIG }; private reconnectStates = new Map(); private reconnectTimers = new Map>(); private disconnectionSubscriptions = new Map(); // 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 { 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 { 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(); return new Promise((resolve, reject) => { this.scanning = true; let resolved = false; let maxTimeoutId: ReturnType | null = null; let earlyExitTimeoutId: ReturnType | null = null; const completeScan = (reason: 'early_exit' | 'max_timeout') => { if (resolved) return; resolved = true; // Clear both timeouts if (maxTimeoutId) clearTimeout(maxTimeoutId); if (earlyExitTimeoutId) clearTimeout(earlyExitTimeoutId); this.stopScan(); const duration = Date.now() - startTime; const devices = Array.from(foundDevices.values()); BLELogger.log(`Scan complete (${reason}): found ${devices.length} devices (${(duration / 1000).toFixed(1)}s)`); resolve(devices); }; this.manager.startDeviceScan( null, { allowDuplicates: false }, (error, device) => { if (resolved) return; if (error) { resolved = true; this.scanning = false; if (maxTimeoutId) clearTimeout(maxTimeoutId); if (earlyExitTimeoutId) clearTimeout(earlyExitTimeoutId); 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; // Get full MAC address from device.id on Android (format: "XX:XX:XX:XX:XX:XX") // On iOS device.id is a UUID, so we fall back to partial MAC from name let mac = ''; if (device.id && device.id.includes(':')) { // Android: device.id is the full MAC address with colons mac = device.id.replace(/:/g, '').toUpperCase(); } else { // iOS or fallback: extract partial MAC from name (last 6 hex chars) const macMatch = device.name.match(/_([a-fA-F0-9]{6})$/); 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})`); // PERFORMANCE: Start early exit timer when minimum devices found // This ensures we return results quickly (< 10s) while still finding more devices if ( foundDevices.size >= BLE_CONFIG.SCAN_MIN_DEVICES_FOR_EARLY_EXIT && !earlyExitTimeoutId ) { earlyExitTimeoutId = setTimeout(() => { completeScan('early_exit'); }, BLE_CONFIG.SCAN_EARLY_EXIT_TIMEOUT); } } } ); // Max timeout - absolute limit for scan duration maxTimeoutId = setTimeout(() => { completeScan('max_timeout'); }, BLE_CONFIG.SCAN_TIMEOUT); }); } stopScan(): void { if (this.scanning) { this.manager.stopDeviceScan(); this.scanning = false; } } 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)) { 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 { 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 { 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 | 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 { 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(); 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 { 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 { 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 { 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 { 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 { return new Map(this.sensorHealthMetrics); } /** * Cleanup all BLE connections and state * Should be called on app logout to properly release resources */ async cleanup(): Promise { // 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 { 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 { 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 { 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): 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 { 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 { // 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 { return new Map(this.reconnectStates); } }