WellNuo/services/ble/BLEManager.ts
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

1229 lines
37 KiB
TypeScript

// 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 { requestBLEPermissions, checkBluetoothEnabled } from './permissions';
import base64 from 'react-native-base64';
export class RealBLEManager implements IBLEManager {
private _manager: BleManager | null = null;
private connectedDevices = new Map<string, Device>();
private connectionStates = new Map<string, BLEDeviceConnection>();
private eventListeners: BLEEventListener[] = [];
private scanning = false;
private connectingDevices = new Set<string>(); // Track devices currently being connected
// Health monitoring state
private sensorHealthMetrics = new Map<string, SensorHealthMetrics>();
private communicationStats = new Map<string, CommunicationHealth>();
// Reconnect state
private reconnectConfig: ReconnectConfig = { ...DEFAULT_RECONNECT_CONFIG };
private reconnectStates = new Map<string, ReconnectState>();
private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
private disconnectionSubscriptions = new Map<string, any>(); // 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<string, BLEDeviceConnection> {
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<WPDevice[]> {
// 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<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> {
try {
// Check if connection is already in progress
if (this.connectingDevices.has(deviceId)) {
throw new Error('Connection already in progress for this device');
}
// Check if already connected
const existingDevice = this.connectedDevices.get(deviceId);
if (existingDevice) {
const isConnected = await existingDevice.isConnected();
if (isConnected) {
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 = permissionStatus.error || 'Bluetooth permissions not granted';
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, error);
this.emitEvent(deviceId, 'connection_failed', { error });
throw new Error(error);
}
// Step 0.5: Check Bluetooth is enabled
const bluetoothStatus = await checkBluetoothEnabled(this.manager);
if (!bluetoothStatus.enabled) {
const error = bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.';
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, error);
this.emitEvent(deviceId, 'connection_failed', { error });
throw new Error(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);
// Update state to DISCOVERING
this.updateConnectionState(deviceId, BLEConnectionState.DISCOVERING, device.name || undefined);
await device.discoverAllServicesAndCharacteristics();
// Request larger MTU for Android (default is 23 bytes which is too small)
if (Platform.OS === 'android') {
try {
await device.requestMTU(512);
} catch {
// MTU request may fail on some devices - continue anyway
}
}
this.connectedDevices.set(deviceId, device);
// Update state to READY
this.updateConnectionState(deviceId, BLEConnectionState.READY, device.name || undefined);
this.emitEvent(deviceId, 'ready');
return true;
} catch (error: any) {
const errorMessage = error?.message || 'Connection failed';
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, errorMessage);
this.emitEvent(deviceId, 'connection_failed', { error: errorMessage });
return false;
} finally {
// Always remove from connecting set when done (success or failure)
this.connectingDevices.delete(deviceId);
}
}
async disconnectDevice(deviceId: string): Promise<void> {
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<string> {
const startTime = Date.now();
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 {
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) {
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);
// 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();
// 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) {
safeReject(new Error('Command timeout'));
}
}, BLE_CONFIG.COMMAND_TIMEOUT);
} catch (error: any) {
safeReject(error);
}
});
}
async getWiFiList(deviceId: string): Promise<WiFiNetwork[]> {
// 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<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> {
// 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 {
// Ignore status check errors - continue with WiFi config
}
// 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 {
// Ignore recheck errors - password was rejected
}
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);
}
/**
* 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<SensorHealthMetrics | null> {
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<string, SensorHealthMetrics> {
return new Map(this.sensorHealthMetrics);
}
/**
* Cleanup all BLE connections and state
* Should be called on app logout to properly release resources
*/
async cleanup(): Promise<void> {
// 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<BulkOperationResult[]> {
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<BulkOperationResult[]> {
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<BulkWiFiResult[]> {
const results: BulkWiFiResult[] = [];
for (const device of devices) {
const { id: deviceId, name: deviceName } = device;
try {
// Step 1: Connect
onProgress?.(deviceId, 'connecting');
const connected = await this.connectDevice(deviceId);
if (!connected) {
throw new Error('Could not connect to device');
}
// Step 2: Set WiFi
onProgress?.(deviceId, 'configuring');
await this.setWiFi(deviceId, ssid, password);
// Step 3: Reboot
onProgress?.(deviceId, 'rebooting');
await this.rebootDevice(deviceId);
// Success
onProgress?.(deviceId, 'success');
results.push({
deviceId,
deviceName,
success: true,
});
} catch (error: any) {
const errorMessage = error?.message || 'WiFi configuration failed';
onProgress?.(deviceId, 'error', errorMessage);
results.push({
deviceId,
deviceName,
success: false,
error: errorMessage,
});
}
}
return results;
}
// ==================== RECONNECT FUNCTIONALITY ====================
/**
* Set reconnect configuration
*/
setReconnectConfig(config: Partial<ReconnectConfig>): 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<void> {
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<boolean> {
// 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<string, ReconnectState> {
return new Map(this.reconnectStates);
}
}