- 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>
491 lines
15 KiB
TypeScript
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;
|
|
}
|