// Real BLE Manager для физических устройств import { BleManager, Device, State } from 'react-native-ble-plx'; import { Platform } from 'react-native'; import { IBLEManager, WPDevice, WiFiNetwork, WiFiStatus, BLE_CONFIG, BLE_COMMANDS } from './types'; 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 scanning = false; // Lazy initialization to prevent crash on app startup private get manager(): BleManager { if (!this._manager) { this._manager = new BleManager(); } return this._manager; } constructor() { // Don't initialize BleManager here - use lazy initialization } async scanDevices(): Promise { // Check permissions with graceful fallback const permissionStatus = await requestBLEPermissions(); if (!permissionStatus.granted) { throw new Error(permissionStatus.error || 'Bluetooth permissions not granted'); } // 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 foundDevices = new Map(); return new Promise((resolve, reject) => { this.scanning = true; this.manager.startDeviceScan( null, { allowDuplicates: false }, (error, device) => { if (error) { this.scanning = false; reject(error); return; } if (device && device.name?.startsWith(BLE_CONFIG.DEVICE_NAME_PREFIX)) { // Parse well_id from name (WP_497_81a14c -> 497) const wellIdMatch = device.name.match(/WP_(\d+)_/); const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined; // Extract MAC from device name (last part after underscore) const macMatch = device.name.match(/_([a-fA-F0-9]{6})$/); const mac = macMatch ? macMatch[1].toUpperCase() : ''; foundDevices.set(device.id, { id: device.id, name: device.name, mac: mac, rssi: device.rssi || -100, wellId, }); } } ); // Stop scan after timeout setTimeout(() => { this.stopScan(); resolve(Array.from(foundDevices.values())); }, BLE_CONFIG.SCAN_TIMEOUT); }); } stopScan(): void { if (this.scanning) { this.manager.stopDeviceScan(); this.scanning = false; } } async connectDevice(deviceId: string): Promise { try { // Step 0: Check permissions (required for Android 12+) const permissionStatus = await requestBLEPermissions(); if (!permissionStatus.granted) { throw new Error(permissionStatus.error || 'Bluetooth permissions not granted'); } // Step 0.5: Check Bluetooth is enabled const bluetoothStatus = await checkBluetoothEnabled(this.manager); if (!bluetoothStatus.enabled) { throw new Error(bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.'); } // Check if already connected const existingDevice = this.connectedDevices.get(deviceId); if (existingDevice) { const isConnected = await existingDevice.isConnected(); if (isConnected) { return true; } // Device was in map but disconnected, remove it this.connectedDevices.delete(deviceId); } const device = await this.manager.connectToDevice(deviceId, { timeout: 10000, // 10 second timeout }); await device.discoverAllServicesAndCharacteristics(); // Request larger MTU for Android (default is 23 bytes which is too small) if (Platform.OS === 'android') { try { const mtu = await device.requestMTU(512); } catch (mtuError) { } } this.connectedDevices.set(deviceId, device); return true; } catch (error: any) { return false; } } async disconnectDevice(deviceId: string): Promise { const device = this.connectedDevices.get(deviceId); if (device) { try { // Cancel any pending operations before disconnecting // This helps prevent Android NullPointerException in monitor callbacks await device.cancelConnection(); } catch (error: any) { // Log but don't throw - device may already be disconnected } finally { this.connectedDevices.delete(deviceId); } } } isDeviceConnected(deviceId: string): boolean { return this.connectedDevices.has(deviceId); } async sendCommand(deviceId: string, command: string): Promise { const device = this.connectedDevices.get(deviceId); if (!device) { throw new Error('Device not connected'); } // Verify device is still connected try { const isConnected = await device.isConnected(); if (!isConnected) { this.connectedDevices.delete(deviceId); throw new Error('Device disconnected'); } } catch (checkError: any) { throw new Error('Failed to verify connection'); } // 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 (removeError) { // 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(); // Ensure error has a valid message (fixes Android NullPointerException) const errorMessage = error?.message || error?.reason || 'BLE operation failed'; reject(new Error(`[${errorCode}] ${errorMessage}`)); }; 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(); 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) { safeReject(new Error('Command timeout')); } }, BLE_CONFIG.COMMAND_TIMEOUT); } catch (error: any) { safeReject(error); } }); } async getWiFiList(deviceId: string): Promise { // 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'); } // Step 2: Get WiFi list const listResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_LIST); // Parse response: "mac,XXXXXX|w|COUNT|SSID1,RSSI1|SSID2,RSSI2|..." const parts = listResponse.split('|'); if (parts.length < 3) { throw new Error('Invalid WiFi list response'); } const count = parseInt(parts[2], 10); if (count < 0) { if (count === -1) { throw new Error('WiFi scan in progress, please wait'); } if (count === -2) { 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 { // Step 1: Unlock device const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK); if (!unlockResponse.includes('ok')) { throw new Error(`Device unlock failed: ${unlockResponse}`); } // 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 (statusError) { } // Step 2: Set WiFi credentials const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`; const setResponse = await this.sendCommand(deviceId, command); // Parse response: "mac,XXXXXX|W|ok" or "mac,XXXXXX|W|fail" or other errors if (setResponse.includes('|W|ok')) { 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) { return true; } } } catch (recheckError) { } throw new Error('WiFi credentials rejected by sensor. Check password.'); } if (setResponse.includes('timeout') || setResponse.includes('Timeout')) { throw new Error('Sensor did not respond to WiFi config. Try again.'); } // Unknown error - include raw response for debugging throw new Error(`WiFi config failed: ${setResponse.substring(0, 100)}`); } async getCurrentWiFi(deviceId: string): Promise { // 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'); } // 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) { return null; } const [ssid, rssiStr] = parts[2].split(','); if (!ssid || ssid.trim() === '') { return null; // Not connected } return { ssid: ssid.trim(), rssi: parseInt(rssiStr, 10), connected: true, }; } async rebootDevice(deviceId: string): Promise { // 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); // Remove from connected devices this.connectedDevices.delete(deviceId); } /** * 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(); } // Disconnect all connected devices const deviceIds = Array.from(this.connectedDevices.keys()); for (const deviceId of deviceIds) { try { await this.disconnectDevice(deviceId); } catch (error) { // Continue cleanup even if one device fails } } // Clear the map this.connectedDevices.clear(); } }