WellNuo/contexts/BLEContext.tsx
Sergei f8156b2dc7 Add BLE auto-reconnect with exponential backoff
- Add ReconnectConfig and ReconnectState types for configurable reconnect behavior
- Implement auto-reconnect in BLEManager with exponential backoff (default: 3 attempts, 1.5x multiplier)
- Add connection monitoring via device.onDisconnected() for unexpected disconnections
- Update BLEContext with reconnectingDevices state and reconnect actions
- Create ConnectionStatusIndicator component for visual connection feedback
- Enhance device settings screen with reconnect UI and manual reconnect capability
- Add comprehensive tests for reconnect logic and UI component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:31:15 -08:00

491 lines
15 KiB
TypeScript

// BLE Context - Global state for Bluetooth management
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import {
bleManager,
WPDevice,
WiFiNetwork,
WiFiStatus,
isBLEAvailable,
checkBLEReadiness,
BulkOperationResult,
BulkWiFiResult,
ReconnectConfig,
ReconnectState,
BLEConnectionState,
} from '@/services/ble';
import { setOnLogoutBLECleanupCallback } from '@/services/api';
import { BleManager } from 'react-native-ble-plx';
interface BLEContextType {
// State
foundDevices: WPDevice[];
isScanning: boolean;
connectedDevices: Set<string>;
isBLEAvailable: boolean;
error: string | null;
permissionError: boolean; // true if error is related to permissions
reconnectingDevices: Set<string>; // Devices currently attempting to reconnect
// Actions
scanDevices: () => Promise<void>;
stopScan: () => void;
connectDevice: (deviceId: string) => Promise<boolean>;
disconnectDevice: (deviceId: string) => Promise<void>;
getWiFiList: (deviceId: string) => Promise<WiFiNetwork[]>;
setWiFi: (deviceId: string, ssid: string, password: string) => Promise<boolean>;
getCurrentWiFi: (deviceId: string) => Promise<WiFiStatus | null>;
rebootDevice: (deviceId: string) => Promise<void>;
cleanupBLE: () => Promise<void>;
clearError: () => void;
checkPermissions: () => Promise<boolean>; // Manual permission check with UI prompts
// Bulk operations
bulkDisconnect: (deviceIds: string[]) => Promise<BulkOperationResult[]>;
bulkReboot: (deviceIds: string[]) => Promise<BulkOperationResult[]>;
bulkSetWiFi: (
devices: { id: string; name: string }[],
ssid: string,
password: string,
onProgress?: (deviceId: string, status: 'connecting' | 'configuring' | 'rebooting' | 'success' | 'error', error?: string) => void
) => Promise<BulkWiFiResult[]>;
// Reconnect functionality
enableAutoReconnect: (deviceId: string, deviceName?: string) => void;
disableAutoReconnect: (deviceId: string) => void;
manualReconnect: (deviceId: string) => Promise<boolean>;
cancelReconnect: (deviceId: string) => void;
getReconnectState: (deviceId: string) => ReconnectState | undefined;
setReconnectConfig: (config: Partial<ReconnectConfig>) => void;
getConnectionState: (deviceId: string) => BLEConnectionState;
}
const BLEContext = createContext<BLEContextType | undefined>(undefined);
export function BLEProvider({ children }: { children: ReactNode }) {
const [foundDevices, setFoundDevices] = useState<WPDevice[]>([]);
const [isScanning, setIsScanning] = useState(false);
const [connectedDevices, setConnectedDevices] = useState<Set<string>>(new Set());
const [reconnectingDevices, setReconnectingDevices] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [permissionError, setPermissionError] = useState(false);
// Lazy BleManager instance for permission checks
const [bleManagerInstance] = useState(() => new BleManager());
const isPermissionError = (errorMessage: string): boolean => {
const permissionKeywords = [
'permission',
'unauthorized',
'not granted',
'denied',
'bluetooth is disabled',
'enable it in settings',
];
const lowerMessage = errorMessage.toLowerCase();
return permissionKeywords.some(keyword => lowerMessage.includes(keyword));
};
const checkPermissions = useCallback(async (): Promise<boolean> => {
try {
setError(null);
setPermissionError(false);
const ready = await checkBLEReadiness(bleManagerInstance);
return ready;
} catch {
const errorMsg = 'Failed to check Bluetooth permissions';
setError(errorMsg);
setPermissionError(true);
return false;
}
}, [bleManagerInstance]);
const scanDevices = useCallback(async () => {
try {
setError(null);
setPermissionError(false);
setIsScanning(true);
const devices = await bleManager.scanDevices();
// Sort by RSSI (strongest first)
const sorted = devices.sort((a, b) => b.rssi - a.rssi);
setFoundDevices(sorted);
} catch (err: any) {
const errorMsg = err.message || 'Failed to scan for devices';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
throw err;
} finally {
setIsScanning(false);
}
}, []);
const stopScan = useCallback(() => {
bleManager.stopScan();
setIsScanning(false);
}, []);
const connectDevice = useCallback(async (deviceId: string): Promise<boolean> => {
try {
setError(null);
setPermissionError(false);
const success = await bleManager.connectDevice(deviceId);
if (success) {
setConnectedDevices(prev => new Set(prev).add(deviceId));
} else {
// Connection failed but no exception - set user-friendly error
const errorMsg = 'Could not connect to sensor. Please move closer and try again.';
setError(errorMsg);
setPermissionError(false);
}
return success;
} catch (err: any) {
const errorMsg = err.message || 'Failed to connect to device';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
return false;
}
}, []);
const disconnectDevice = useCallback(async (deviceId: string): Promise<void> => {
try {
await bleManager.disconnectDevice(deviceId);
setConnectedDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
} catch (err: any) {
setError(err.message || 'Failed to disconnect device');
}
}, []);
const getWiFiList = useCallback(async (deviceId: string): Promise<WiFiNetwork[]> => {
try {
setError(null);
const networks = await bleManager.getWiFiList(deviceId);
return networks;
} catch (err: any) {
setError(err.message || 'Failed to get WiFi networks');
throw err;
}
}, []);
const setWiFi = useCallback(
async (deviceId: string, ssid: string, password: string): Promise<boolean> => {
try {
setError(null);
return await bleManager.setWiFi(deviceId, ssid, password);
} catch (err: any) {
setError(err.message || 'Failed to configure WiFi');
throw err;
}
},
[]
);
const getCurrentWiFi = useCallback(
async (deviceId: string): Promise<WiFiStatus | null> => {
try {
setError(null);
return await bleManager.getCurrentWiFi(deviceId);
} catch (err: any) {
setError(err.message || 'Failed to get current WiFi');
throw err;
}
},
[]
);
const rebootDevice = useCallback(async (deviceId: string): Promise<void> => {
try {
setError(null);
await bleManager.rebootDevice(deviceId);
// Remove from connected devices
setConnectedDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
} catch (err: any) {
setError(err.message || 'Failed to reboot device');
throw err;
}
}, []);
const clearError = useCallback(() => {
setError(null);
setPermissionError(false);
}, []);
const cleanupBLE = useCallback(async () => {
try {
// Stop any ongoing scan
if (isScanning) {
stopScan();
}
// Cleanup via bleManager (disconnects all devices)
await bleManager.cleanup();
// Clear context state
setFoundDevices([]);
setConnectedDevices(new Set());
setError(null);
} catch {
// Don't throw - we want to allow logout to proceed even if BLE cleanup fails
}
}, [isScanning, stopScan]);
// Bulk operations
const bulkDisconnect = useCallback(async (deviceIds: string[]): Promise<BulkOperationResult[]> => {
try {
setError(null);
setPermissionError(false);
const results = await bleManager.bulkDisconnect(deviceIds);
// Update connected devices set
const successfulDisconnects = results.filter(r => r.success).map(r => r.deviceId);
if (successfulDisconnects.length > 0) {
setConnectedDevices(prev => {
const next = new Set(prev);
successfulDisconnects.forEach(id => next.delete(id));
return next;
});
}
return results;
} catch (err: any) {
const errorMsg = err.message || 'Bulk disconnect failed';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
throw err;
}
}, []);
const bulkReboot = useCallback(async (deviceIds: string[]): Promise<BulkOperationResult[]> => {
try {
setError(null);
setPermissionError(false);
const results = await bleManager.bulkReboot(deviceIds);
// Devices that were rebooted are no longer connected
const successfulReboots = results.filter(r => r.success).map(r => r.deviceId);
if (successfulReboots.length > 0) {
setConnectedDevices(prev => {
const next = new Set(prev);
successfulReboots.forEach(id => next.delete(id));
return next;
});
}
return results;
} catch (err: any) {
const errorMsg = err.message || 'Bulk reboot failed';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
throw err;
}
}, []);
const bulkSetWiFi = useCallback(async (
devices: { id: string; name: string }[],
ssid: string,
password: string,
onProgress?: (deviceId: string, status: 'connecting' | 'configuring' | 'rebooting' | 'success' | 'error', error?: string) => void
): Promise<BulkWiFiResult[]> => {
try {
setError(null);
setPermissionError(false);
const results = await bleManager.bulkSetWiFi(devices, ssid, password, onProgress);
// Update connected devices - successful setups result in reboots (disconnected)
const successfulSetups = results.filter(r => r.success).map(r => r.deviceId);
if (successfulSetups.length > 0) {
setConnectedDevices(prev => {
const next = new Set(prev);
successfulSetups.forEach(id => next.delete(id));
return next;
});
}
return results;
} catch (err: any) {
const errorMsg = err.message || 'Bulk WiFi configuration failed';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
throw err;
}
}, []);
// Reconnect functionality
const enableAutoReconnect = useCallback((deviceId: string, deviceName?: string) => {
bleManager.enableAutoReconnect(deviceId, deviceName);
}, []);
const disableAutoReconnect = useCallback((deviceId: string) => {
bleManager.disableAutoReconnect(deviceId);
setReconnectingDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
}, []);
const manualReconnect = useCallback(async (deviceId: string): Promise<boolean> => {
try {
setError(null);
setPermissionError(false);
setReconnectingDevices(prev => new Set(prev).add(deviceId));
const success = await bleManager.manualReconnect(deviceId);
if (success) {
setConnectedDevices(prev => new Set(prev).add(deviceId));
}
setReconnectingDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
return success;
} catch (err: any) {
const errorMsg = err.message || 'Reconnection failed';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
setReconnectingDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
return false;
}
}, []);
const cancelReconnect = useCallback((deviceId: string) => {
bleManager.cancelReconnect(deviceId);
setReconnectingDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
}, []);
const getReconnectState = useCallback((deviceId: string): ReconnectState | undefined => {
return bleManager.getReconnectState(deviceId);
}, []);
const setReconnectConfig = useCallback((config: Partial<ReconnectConfig>) => {
bleManager.setReconnectConfig(config);
}, []);
const getConnectionState = useCallback((deviceId: string): BLEConnectionState => {
return bleManager.getConnectionState(deviceId);
}, []);
// Register BLE cleanup callback for logout
useEffect(() => {
setOnLogoutBLECleanupCallback(cleanupBLE);
return () => {
setOnLogoutBLECleanupCallback(null);
};
}, [cleanupBLE]);
// Set up BLE event listeners for connection state changes
useEffect(() => {
const handleBLEEvent = (deviceId: string, event: string, data?: any) => {
switch (event) {
case 'disconnected':
// Device unexpectedly disconnected
setConnectedDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
// Check if auto-reconnect is in progress
if (data?.unexpected) {
const reconnectState = bleManager.getReconnectState(deviceId);
if (reconnectState?.isReconnecting) {
setReconnectingDevices(prev => new Set(prev).add(deviceId));
}
}
break;
case 'ready':
// Device connected or reconnected
setConnectedDevices(prev => new Set(prev).add(deviceId));
setReconnectingDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
break;
case 'connection_failed':
// Check if max reconnect attempts reached
if (data?.reconnectFailed) {
setReconnectingDevices(prev => {
const next = new Set(prev);
next.delete(deviceId);
return next;
});
setError(`Failed to reconnect to device after multiple attempts`);
}
break;
case 'state_changed':
if (data?.reconnecting) {
setReconnectingDevices(prev => new Set(prev).add(deviceId));
}
break;
}
};
bleManager.addEventListener(handleBLEEvent);
return () => {
bleManager.removeEventListener(handleBLEEvent);
};
}, []);
const value: BLEContextType = {
foundDevices,
isScanning,
connectedDevices,
reconnectingDevices,
isBLEAvailable,
error,
permissionError,
scanDevices,
stopScan,
connectDevice,
disconnectDevice,
getWiFiList,
setWiFi,
getCurrentWiFi,
rebootDevice,
cleanupBLE,
clearError,
checkPermissions,
bulkDisconnect,
bulkReboot,
bulkSetWiFi,
// Reconnect functionality
enableAutoReconnect,
disableAutoReconnect,
manualReconnect,
cancelReconnect,
getReconnectState,
setReconnectConfig,
getConnectionState,
};
return <BLEContext.Provider value={value}>{children}</BLEContext.Provider>;
}
export function useBLE() {
const context = useContext(BLEContext);
if (context === undefined) {
throw new Error('useBLE must be used within a BLEProvider');
}
return context;
}