Implement comprehensive BLE cleanup functionality that properly disconnects all devices and releases resources when user logs out. Changes: - Add cleanup() method to BLEManager and MockBLEManager - Update IBLEManager interface to include cleanup - Add cleanupBLE() to BLEContext to disconnect all devices - Implement callback mechanism in api.ts for BLE cleanup on logout - Wire up BLE cleanup in app layout to trigger on logout - Add unit tests for BLE cleanup functionality This ensures no BLE connections remain active after logout, preventing resource leaks and potential connection issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
559 lines
20 KiB
TypeScript
559 lines
20 KiB
TypeScript
// Real BLE Manager для физических устройств
|
|
|
|
import { BleManager, Device, State } from 'react-native-ble-plx';
|
|
import { PermissionsAndroid, Platform } from 'react-native';
|
|
import { IBLEManager, WPDevice, WiFiNetwork, WiFiStatus, BLE_CONFIG, BLE_COMMANDS } from './types';
|
|
import base64 from 'react-native-base64';
|
|
|
|
export class RealBLEManager implements IBLEManager {
|
|
private _manager: BleManager | null = null;
|
|
private connectedDevices = new Map<string, Device>();
|
|
private scanning = false;
|
|
|
|
// Lazy initialization to prevent crash on app startup
|
|
private get manager(): BleManager {
|
|
if (!this._manager) {
|
|
console.log('[BLE] Initializing BleManager (lazy)...');
|
|
this._manager = new BleManager();
|
|
}
|
|
return this._manager;
|
|
}
|
|
|
|
constructor() {
|
|
// Don't initialize BleManager here - use lazy initialization
|
|
console.log('[BLE] RealBLEManager created (BleManager will be initialized on first use)');
|
|
}
|
|
|
|
// Check and request permissions
|
|
private async requestPermissions(): Promise<boolean> {
|
|
if (Platform.OS === 'ios') {
|
|
// iOS handles permissions automatically via Info.plist
|
|
return true;
|
|
}
|
|
|
|
if (Platform.OS === 'android') {
|
|
if (Platform.Version >= 31) {
|
|
// Android 12+
|
|
const granted = await PermissionsAndroid.requestMultiple([
|
|
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN!,
|
|
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT!,
|
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION!,
|
|
]);
|
|
|
|
return Object.values(granted).every(
|
|
status => status === PermissionsAndroid.RESULTS.GRANTED
|
|
);
|
|
} else {
|
|
// Android < 12
|
|
const granted = await PermissionsAndroid.request(
|
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION!
|
|
);
|
|
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Check if Bluetooth is enabled
|
|
private async isBluetoothEnabled(): Promise<boolean> {
|
|
const state = await this.manager.state();
|
|
return state === State.PoweredOn;
|
|
}
|
|
|
|
async scanDevices(): Promise<WPDevice[]> {
|
|
const hasPermission = await this.requestPermissions();
|
|
if (!hasPermission) {
|
|
throw new Error('Bluetooth permissions not granted');
|
|
}
|
|
|
|
const isEnabled = await this.isBluetoothEnabled();
|
|
if (!isEnabled) {
|
|
throw new Error('Bluetooth is disabled. Please enable it in settings.');
|
|
}
|
|
|
|
const foundDevices = new Map<string, WPDevice>();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.scanning = true;
|
|
|
|
this.manager.startDeviceScan(
|
|
null,
|
|
{ allowDuplicates: false },
|
|
(error, device) => {
|
|
if (error) {
|
|
this.scanning = false;
|
|
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<boolean> {
|
|
console.log('[BLE] connectDevice started:', deviceId);
|
|
try {
|
|
// Step 0: Check permissions (required for Android 12+)
|
|
console.log('[BLE] Step 0: Checking permissions...');
|
|
const hasPermission = await this.requestPermissions();
|
|
if (!hasPermission) {
|
|
console.error('[BLE] Permissions not granted!');
|
|
throw new Error('Bluetooth permissions not granted');
|
|
}
|
|
console.log('[BLE] Permissions OK');
|
|
|
|
// Step 0.5: Check Bluetooth is enabled
|
|
console.log('[BLE] Checking Bluetooth state...');
|
|
const isEnabled = await this.isBluetoothEnabled();
|
|
if (!isEnabled) {
|
|
console.error('[BLE] Bluetooth is disabled!');
|
|
throw new Error('Bluetooth is disabled. Please enable it in settings.');
|
|
}
|
|
console.log('[BLE] Bluetooth is ON');
|
|
|
|
// Check if already connected
|
|
const existingDevice = this.connectedDevices.get(deviceId);
|
|
if (existingDevice) {
|
|
console.log('[BLE] Checking existing connection...');
|
|
const isConnected = await existingDevice.isConnected();
|
|
if (isConnected) {
|
|
console.log('[BLE] Device already connected:', deviceId);
|
|
return true;
|
|
}
|
|
// Device was in map but disconnected, remove it
|
|
console.log('[BLE] Removing stale connection from map:', deviceId);
|
|
this.connectedDevices.delete(deviceId);
|
|
}
|
|
|
|
console.log('[BLE] Calling manager.connectToDevice with 10s timeout...');
|
|
const device = await this.manager.connectToDevice(deviceId, {
|
|
timeout: 10000, // 10 second timeout
|
|
});
|
|
console.log('[BLE] Connected! Discovering services and characteristics...');
|
|
|
|
await device.discoverAllServicesAndCharacteristics();
|
|
console.log('[BLE] Services discovered');
|
|
|
|
// Request larger MTU for Android (default is 23 bytes which is too small)
|
|
if (Platform.OS === 'android') {
|
|
try {
|
|
const mtu = await device.requestMTU(512);
|
|
console.log('[BLE] MTU negotiated:', mtu);
|
|
} catch (mtuError) {
|
|
console.warn('[BLE] MTU negotiation failed (non-critical):', mtuError);
|
|
}
|
|
}
|
|
|
|
this.connectedDevices.set(deviceId, device);
|
|
console.log('[BLE] connectDevice SUCCESS:', deviceId);
|
|
return true;
|
|
} catch (error: any) {
|
|
console.error('[BLE] connectDevice FAILED:', deviceId, {
|
|
message: error?.message,
|
|
errorCode: error?.errorCode,
|
|
reason: error?.reason,
|
|
stack: error?.stack?.substring(0, 200),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async disconnectDevice(deviceId: string): Promise<void> {
|
|
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
|
|
console.warn('[BLE] disconnectDevice error (ignored):', error?.message);
|
|
} finally {
|
|
this.connectedDevices.delete(deviceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
isDeviceConnected(deviceId: string): boolean {
|
|
return this.connectedDevices.has(deviceId);
|
|
}
|
|
|
|
async sendCommand(deviceId: string, command: string): Promise<string> {
|
|
console.log('[BLE] sendCommand:', { deviceId, command });
|
|
|
|
const device = this.connectedDevices.get(deviceId);
|
|
if (!device) {
|
|
console.error('[BLE] sendCommand FAILED: Device not in connected map');
|
|
throw new Error('Device not connected');
|
|
}
|
|
|
|
// Verify device is still connected
|
|
try {
|
|
const isConnected = await device.isConnected();
|
|
if (!isConnected) {
|
|
console.error('[BLE] sendCommand FAILED: Device disconnected');
|
|
this.connectedDevices.delete(deviceId);
|
|
throw new Error('Device disconnected');
|
|
}
|
|
} catch (checkError: any) {
|
|
console.error('[BLE] Failed to check connection status:', checkError?.message);
|
|
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<typeof setTimeout> | null = null;
|
|
|
|
const cleanup = () => {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
if (subscription) {
|
|
console.log('[BLE] Cleaning up notification subscription');
|
|
try {
|
|
subscription.remove();
|
|
} catch (removeError) {
|
|
// Ignore errors during cleanup - device may already be disconnected
|
|
console.log('[BLE] Subscription cleanup error (ignored):', removeError);
|
|
}
|
|
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'))) {
|
|
console.log('[BLE] Ignoring cancelled operation (normal cleanup)');
|
|
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
|
|
console.log('[BLE] Setting up notification listener with transactionId:', 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) {
|
|
console.log('[BLE] Notification cancelled (normal cleanup)');
|
|
} else {
|
|
console.error('[BLE] Notification error:', {
|
|
message: error?.message || 'null',
|
|
errorCode: errCode || 'null',
|
|
reason: (error as any)?.reason || 'null',
|
|
});
|
|
}
|
|
safeReject(error);
|
|
return;
|
|
}
|
|
|
|
if (characteristic?.value) {
|
|
const decoded = base64.decode(characteristic.value);
|
|
console.log('[BLE] Response received:', decoded.substring(0, 100));
|
|
if (!responseReceived) {
|
|
responseReceived = true;
|
|
cleanup();
|
|
resolve(decoded);
|
|
}
|
|
}
|
|
} catch (callbackError: any) {
|
|
console.error('[BLE] Callback exception:', callbackError?.message);
|
|
safeReject(callbackError);
|
|
}
|
|
},
|
|
transactionId // Explicit transaction ID prevents Android null pointer
|
|
);
|
|
|
|
// Send command
|
|
const encoded = base64.encode(command);
|
|
console.log('[BLE] Writing command to characteristic...');
|
|
await device.writeCharacteristicWithResponseForService(
|
|
BLE_CONFIG.SERVICE_UUID,
|
|
BLE_CONFIG.CHAR_UUID,
|
|
encoded
|
|
);
|
|
console.log('[BLE] Command written successfully');
|
|
|
|
// Timeout
|
|
timeoutId = setTimeout(() => {
|
|
if (!responseReceived) {
|
|
console.error('[BLE] Command timeout after', BLE_CONFIG.COMMAND_TIMEOUT, 'ms');
|
|
safeReject(new Error('Command timeout'));
|
|
}
|
|
}, BLE_CONFIG.COMMAND_TIMEOUT);
|
|
} catch (error: any) {
|
|
console.error('[BLE] sendCommand exception:', {
|
|
message: error?.message,
|
|
errorCode: error?.errorCode,
|
|
});
|
|
safeReject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
async getWiFiList(deviceId: string): Promise<WiFiNetwork[]> {
|
|
console.log('[BLE] getWiFiList started for:', deviceId);
|
|
|
|
// Step 1: Unlock device
|
|
console.log('[BLE] Step 1: Unlocking device...');
|
|
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
|
|
console.log('[BLE] Unlock response:', unlockResponse);
|
|
if (!unlockResponse.includes('ok')) {
|
|
console.error('[BLE] Unlock FAILED - response does not contain "ok"');
|
|
throw new Error('Failed to unlock device');
|
|
}
|
|
|
|
// Step 2: Get WiFi list
|
|
console.log('[BLE] Step 2: Getting WiFi list...');
|
|
const listResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_LIST);
|
|
console.log('[BLE] WiFi list response:', listResponse);
|
|
|
|
// 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<string, WiFiNetwork>();
|
|
for (let i = 3; i < parts.length; i++) {
|
|
const [ssid, rssiStr] = parts[i].split(',');
|
|
if (ssid && rssiStr) {
|
|
const trimmedSsid = ssid.trim();
|
|
const rssi = parseInt(rssiStr, 10);
|
|
|
|
// Skip empty SSIDs
|
|
if (!trimmedSsid) continue;
|
|
|
|
// Keep the one with strongest signal if duplicate
|
|
const existing = networksMap.get(trimmedSsid);
|
|
if (!existing || rssi > existing.rssi) {
|
|
networksMap.set(trimmedSsid, {
|
|
ssid: trimmedSsid,
|
|
rssi: rssi,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to array and sort by signal strength (strongest first)
|
|
return Array.from(networksMap.values()).sort((a, b) => b.rssi - a.rssi);
|
|
}
|
|
|
|
async setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean> {
|
|
console.log('[BLE] setWiFi started:', { deviceId, ssid, passwordLength: password.length });
|
|
|
|
// Step 1: Unlock device
|
|
console.log('[BLE] Step 1: Unlocking device for WiFi config...');
|
|
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
|
|
console.log('[BLE] Unlock response:', unlockResponse);
|
|
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
|
|
console.log('[BLE] === BLE FIX v2 ACTIVE (2026-01-27) ===');
|
|
console.log('[BLE] Step 1.5: Checking current WiFi status...');
|
|
try {
|
|
const statusResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_STATUS);
|
|
console.log('[BLE] Current WiFi status:', statusResponse);
|
|
|
|
// 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()) {
|
|
console.log('[BLE] Sensor is ALREADY connected to target WiFi:', ssid);
|
|
console.log('[BLE] Skipping W| command - returning success');
|
|
return true;
|
|
}
|
|
console.log('[BLE] Sensor connected to different network or not connected:', currentSsid || 'none');
|
|
}
|
|
} catch (statusError) {
|
|
console.warn('[BLE] Failed to check WiFi status, continuing with config:', statusError);
|
|
}
|
|
|
|
// Step 2: Set WiFi credentials
|
|
const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`;
|
|
console.log('[BLE] Step 2: Sending WiFi credentials...');
|
|
const setResponse = await this.sendCommand(deviceId, command);
|
|
console.log('[BLE] WiFi config response:', setResponse);
|
|
|
|
// Parse response: "mac,XXXXXX|W|ok" or "mac,XXXXXX|W|fail" or other errors
|
|
if (setResponse.includes('|W|ok')) {
|
|
console.log('[BLE] WiFi configuration SUCCESS');
|
|
return true;
|
|
}
|
|
|
|
// WiFi config failed - check if sensor is still connected (using old credentials)
|
|
if (setResponse.includes('|W|fail')) {
|
|
console.log('[BLE] W|fail received. Checking if sensor still connected to WiFi...');
|
|
|
|
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) {
|
|
console.log('[BLE] Sensor IS connected to target WiFi (using saved credentials):', currentSsid, 'RSSI:', rssi);
|
|
console.log('[BLE] Password may be wrong but sensor works - returning success');
|
|
return true;
|
|
}
|
|
}
|
|
} catch (recheckError) {
|
|
console.warn('[BLE] Failed to recheck WiFi status after W|fail');
|
|
}
|
|
|
|
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<WiFiStatus | null> {
|
|
// 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<void> {
|
|
// 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<void> {
|
|
console.log('[BLE] Cleanup: disconnecting all devices');
|
|
|
|
// 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) {
|
|
console.warn('[BLE] Cleanup: error disconnecting device', deviceId, error);
|
|
// Continue cleanup even if one device fails
|
|
}
|
|
}
|
|
|
|
// Clear the map
|
|
this.connectedDevices.clear();
|
|
|
|
console.log('[BLE] Cleanup: complete');
|
|
}
|
|
}
|