UNPKG

@sfourdrinier/react-native-ble-plx

Version:
713 lines (614 loc) 21.3 kB
import { Device } from './Device' import { BleError, BleErrorCode, BleErrorCodeMessage } from './BleError' import type { BleManager } from './BleManager' import type { DeviceId, ConnectionOptions, Subscription } from './TypeDefinition' /** * Connection options with retry and timeout support */ export interface ConnectionOptionsWithRetry { /** * Connection options to pass to the device */ connectionOptions?: ConnectionOptions /** * Maximum number of retry attempts (default: 3) */ maxRetries?: number /** * Initial delay before first retry in milliseconds (default: 1000) */ initialDelayMs?: number /** * Maximum delay between retries in milliseconds (default: 30000) */ maxDelayMs?: number /** * Multiplier for exponential backoff (default: 2) */ backoffMultiplier?: number /** * Connection timeout in milliseconds (default: 30000) * Set to 0 to disable timeout */ timeoutMs?: number } /** * Auto-reconnection options */ export interface AutoReconnectOptions extends ConnectionOptionsWithRetry { /** * Enable automatic reconnection on unexpected disconnects */ enabled: boolean } /** * Event callbacks for connection events */ export interface ConnectionCallbacks { /** * Called when a device successfully connects or reconnects */ onConnect?: (device: Device) => void /** * Called when connection fails after all retries */ onConnectFailed?: (deviceId: DeviceId, error: BleError) => void /** * Called when a connection attempt starts (includes retry attempts) */ onConnecting?: (deviceId: DeviceId, attempt: number, maxAttempts: number) => void /** * Called when a device disconnects unexpectedly */ onDisconnect?: (deviceId: DeviceId, error: BleError | null) => void } type ResolvedConnectionOptions = { connectionOptions?: ConnectionOptions maxRetries: number initialDelayMs: number maxDelayMs: number backoffMultiplier: number timeoutMs: number } const ignoreConnectionCancellationError = () => { // Native cancellation can reject if the connection already ended. } /** * Internal state for a device connection */ interface DeviceConnectionState { deviceId: DeviceId options: ResolvedConnectionOptions reconnectOptions?: ResolvedConnectionOptions isConnecting: boolean retryCount: number timeoutId?: ReturnType<typeof setTimeout> connectionTimeoutId?: ReturnType<typeof setTimeout> disconnectSubscription?: Subscription autoReconnect: boolean callbacks?: ConnectionCallbacks cancelled: boolean attemptId: number // Generation token to invalidate old async attempts pendingPromise?: { resolve: (device: Device) => void reject: (error: BleError) => void } } /** * ConnectionManager unifies connection queuing, retry logic, and automatic reconnection. * * This manager replaces both ConnectionQueue and ReconnectionManager with a single, * unified state machine per device that prevents conflicts and connection storms. * * Features: * - Single connection state per device (no competing retry engines) * - Concurrent connections to different devices * - Exponential backoff retry logic * - Configurable connection timeout * - Automatic reconnection on unexpected disconnects * - Comprehensive event callbacks * * @example * const manager = new ConnectionManager(bleManager); * * // Connect with retry logic * const device = await manager.connect('AA:BB:CC:DD:EE:FF', { * maxRetries: 5, * timeoutMs: 15000, * backoffMultiplier: 1.5 * }); * * // Enable auto-reconnect for a device * manager.enableAutoReconnect('AA:BB:CC:DD:EE:FF', { * maxRetries: 10, * initialDelayMs: 2000 * }, { * onConnect: (device) => console.log('Connected!', device.id), * onDisconnect: (deviceId, error) => console.log('Disconnected', deviceId) * }); * * // Cancel a connection * manager.cancel('AA:BB:CC:DD:EE:FF'); */ export class ConnectionManager { private _manager: BleManager private _devices: Map<DeviceId, DeviceConnectionState> = new Map() private _globalCallbacks: ConnectionCallbacks = {} constructor(manager: BleManager) { this._manager = manager } /** * Set global callbacks for all connection events. * * @param callbacks Callback functions for connection events */ setGlobalCallbacks(callbacks: ConnectionCallbacks): void { this._globalCallbacks = callbacks } /** * Connect to a device with retry logic and timeout. * * @param deviceId Device identifier to connect to * @param options Connection and retry options * @returns Promise resolving to connected Device */ connect(deviceId: DeviceId, options?: ConnectionOptionsWithRetry): Promise<Device> { const existing = this._devices.get(deviceId) // If there's already a connection attempt in progress, coalesce to the same promise if (existing && existing.pendingPromise && !existing.cancelled) { const pending = existing.pendingPromise // Return a new promise that resolves/rejects when the existing one does return new Promise((resolve, reject) => { const originalResolve = pending.resolve const originalReject = pending.reject // Chain to the original promise pending.resolve = (device: Device) => { originalResolve(device) resolve(device) } pending.reject = (error: BleError) => { originalReject(error) reject(error) } }) } return new Promise((resolve, reject) => { // Cancel and clean up existing state if present if (existing) { // Reject any existing pending promise before replacing if (existing.pendingPromise) { existing.pendingPromise.reject( new BleError( { errorCode: BleErrorCode.OperationCancelled, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection attempt replaced for device ${deviceId}` }, BleErrorCodeMessage ) ) } this._cancelState(existing) } const connectionOptions: ResolvedConnectionOptions = { maxRetries: options?.maxRetries ?? 3, initialDelayMs: options?.initialDelayMs ?? 1000, maxDelayMs: options?.maxDelayMs ?? 30000, backoffMultiplier: options?.backoffMultiplier ?? 2, timeoutMs: options?.timeoutMs ?? 30000, connectionOptions: options?.connectionOptions } const state: DeviceConnectionState = { deviceId, options: connectionOptions, reconnectOptions: existing?.reconnectOptions, isConnecting: false, retryCount: 0, autoReconnect: existing?.autoReconnect ?? false, // Preserve autoReconnect flag if it exists callbacks: existing?.callbacks, // Preserve callbacks if they exist disconnectSubscription: existing?.disconnectSubscription, // Preserve disconnect subscription cancelled: false, attemptId: existing ? existing.attemptId + 1 : 0, // Increment attemptId if reusing state pendingPromise: { resolve, reject } } this._devices.set(deviceId, state) this._attemptConnection(state) }) } /** * Enable automatic reconnection for a device. * * @param deviceId Device identifier * @param options Connection and retry options for reconnection * @param callbacks Optional callbacks for this specific device */ enableAutoReconnect(deviceId: DeviceId, options?: ConnectionOptionsWithRetry, callbacks?: ConnectionCallbacks): void { // If already exists, update settings let state = this._devices.get(deviceId) const existingReconnectOptions = state?.reconnectOptions const reconnectOptions: ResolvedConnectionOptions = { maxRetries: options?.maxRetries ?? existingReconnectOptions?.maxRetries ?? 5, initialDelayMs: options?.initialDelayMs ?? existingReconnectOptions?.initialDelayMs ?? 1000, maxDelayMs: options?.maxDelayMs ?? existingReconnectOptions?.maxDelayMs ?? 30000, backoffMultiplier: options?.backoffMultiplier ?? existingReconnectOptions?.backoffMultiplier ?? 2, timeoutMs: options?.timeoutMs ?? existingReconnectOptions?.timeoutMs ?? 30000, connectionOptions: options?.connectionOptions ?? existingReconnectOptions?.connectionOptions } if (state) { // Update existing state state.autoReconnect = true state.callbacks = callbacks state.reconnectOptions = reconnectOptions state.options = { ...reconnectOptions } } else { state = { deviceId, options: reconnectOptions, reconnectOptions, isConnecting: false, retryCount: 0, autoReconnect: true, callbacks, cancelled: false, attemptId: 0 } this._devices.set(deviceId, state) } // Subscribe to disconnection events if not already subscribed if (!state.disconnectSubscription) { state.disconnectSubscription = this._manager.onDeviceDisconnected(deviceId, (error, _device) => { const currentState = this._devices.get(deviceId) if (!currentState) return // Notify disconnect callbacks currentState.callbacks?.onDisconnect?.(deviceId, error) this._globalCallbacks.onDisconnect?.(deviceId, error) // Auto-reconnect on ANY disconnect unless explicitly cancelled // This handles all platform behaviors including quirky null-error disconnects if (currentState.autoReconnect && !currentState.cancelled) { this._startReconnection(deviceId) } }) } } /** * Disable automatic reconnection for a device. * * @param deviceId Device identifier * @returns True if auto-reconnect was disabled, false if it wasn't enabled */ disableAutoReconnect(deviceId: DeviceId): boolean { const state = this._devices.get(deviceId) if (!state || !state.autoReconnect) { return false } state.autoReconnect = false // If not currently connecting, clean up if (!state.isConnecting && !state.pendingPromise) { this._cancelState(state) this._devices.delete(deviceId) } return true } /** * Cancel a pending or in-progress connection attempt. * * @param deviceId Device identifier to cancel connection for * @returns True if a connection was cancelled */ cancel(deviceId: DeviceId): boolean { const state = this._devices.get(deviceId) if (!state) { return false } // Increment attemptId to invalidate any in-flight async attempts state.attemptId++ this._cancelState(state) // Only reject promise and call callbacks if there was actually a pending connection if (state.pendingPromise) { const error = new BleError( { errorCode: BleErrorCode.OperationCancelled, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection cancelled for device ${deviceId}` }, BleErrorCodeMessage ) // Reject pending promise state.pendingPromise.reject(error) state.pendingPromise = undefined // Notify callbacks state.callbacks?.onConnectFailed?.(deviceId, error) this._globalCallbacks.onConnectFailed?.(deviceId, error) } // For auto-reconnect devices, reset cancelled flag so future disconnects can trigger reconnection if (state.autoReconnect) { state.cancelled = false } else { // Remove if auto-reconnect is disabled this._devices.delete(deviceId) } return true } /** * Cancel all pending connections. */ cancelAll(): void { const deviceIds = Array.from(this._devices.keys()) for (const deviceId of deviceIds) { this.cancel(deviceId) } } /** * Get the number of devices with active or pending connections. */ get activeCount(): number { let count = 0 for (const state of this._devices.values()) { if (state.isConnecting || state.pendingPromise) { count++ } } return count } /** * Check if a device has a pending or active connection. */ isConnecting(deviceId: DeviceId): boolean { const state = this._devices.get(deviceId) return state?.isConnecting ?? false } /** * Check if auto-reconnect is enabled for a device. */ isAutoReconnectEnabled(deviceId: DeviceId): boolean { const state = this._devices.get(deviceId) return state?.autoReconnect ?? false } /** * Get the current retry count for a device. */ getRetryCount(deviceId: DeviceId): number { const state = this._devices.get(deviceId) return state?.retryCount ?? 0 } /** * Cancel state timers and subscriptions */ private _cancelState(state: DeviceConnectionState): void { state.cancelled = true if (state.timeoutId) { clearTimeout(state.timeoutId) state.timeoutId = undefined } if (state.connectionTimeoutId) { clearTimeout(state.connectionTimeoutId) state.connectionTimeoutId = undefined } if (state.disconnectSubscription && !state.autoReconnect) { state.disconnectSubscription.remove() state.disconnectSubscription = undefined } if (state.isConnecting) { this._manager.cancelDeviceConnection(state.deviceId).catch(ignoreConnectionCancellationError) } } /** * Start reconnection after unexpected disconnect */ private _startReconnection(deviceId: DeviceId): void { const state = this._devices.get(deviceId) if (!state || state.isConnecting || state.cancelled) { return } // Don't start if a retry is already scheduled if (state.timeoutId) { return } if (state.reconnectOptions) { state.options = { ...state.reconnectOptions } } state.retryCount = 0 this._scheduleConnection(state, 0) } /** * Schedule a connection attempt with delay */ private _scheduleConnection(state: DeviceConnectionState, delay: number): void { if (state.cancelled) { return } // Clear any existing retry timer to prevent multiple scheduled attempts if (state.timeoutId) { clearTimeout(state.timeoutId) state.timeoutId = undefined } if (delay === 0) { Promise.resolve().then(() => { if (!state.cancelled) { this._attemptConnection(state) } }) return } state.timeoutId = setTimeout(() => { if (!state.cancelled) { this._attemptConnection(state) } }, delay) } /** * Attempt to connect to a device with timeout and retry logic */ private async _attemptConnection(state: DeviceConnectionState): Promise<void> { // Early returns to prevent concurrent attempts if (state.cancelled) { return } if (state.isConnecting) { // Already connecting - don't start another attempt return } // Clear the retry timer since we're now consuming this scheduled attempt if (state.timeoutId) { clearTimeout(state.timeoutId) state.timeoutId = undefined } // Capture attempt ID to detect if this attempt was invalidated during async operations const myAttemptId = state.attemptId state.isConnecting = true state.retryCount++ const maxRetries = state.options.maxRetries ?? 3 // Notify connecting callbacks state.callbacks?.onConnecting?.(state.deviceId, state.retryCount, maxRetries) this._globalCallbacks.onConnecting?.(state.deviceId, state.retryCount, maxRetries) // Set up connection timeout if configured let timeoutError: BleError | null = null const timeoutMs = state.options.timeoutMs ?? 0 if (timeoutMs > 0) { state.connectionTimeoutId = setTimeout(() => { timeoutError = new BleError( { errorCode: BleErrorCode.OperationTimedOut, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection timeout after ${timeoutMs}ms for device ${state.deviceId}` }, BleErrorCodeMessage ) // Cancel the connection attempt this._manager.cancelDeviceConnection(state.deviceId).catch(ignoreConnectionCancellationError) }, timeoutMs) } try { const device = await this._manager.connectToDevice(state.deviceId, state.options.connectionOptions) // Check if this attempt was invalidated during the async connect operation if (state.attemptId !== myAttemptId) { // This attempt is stale - a newer attempt has started or cancel was called return } // Clear connection timeout if (state.connectionTimeoutId) { clearTimeout(state.connectionTimeoutId) state.connectionTimeoutId = undefined } // Check if we timed out or were cancelled if (timeoutError) { throw timeoutError } // Check if cancelled or removed after await (race condition protection) const currentState = this._devices.get(state.deviceId) if (!currentState || currentState !== state || currentState.cancelled) { // State was cancelled during connection - disconnect immediately await this._manager.cancelDeviceConnection(state.deviceId).catch(ignoreConnectionCancellationError) // Check again after the disconnect await if (state.attemptId !== myAttemptId) { return } return } // Success! state.isConnecting = false state.retryCount = 0 // Notify callbacks state.callbacks?.onConnect?.(device) this._globalCallbacks.onConnect?.(device) // Resolve pending promise if exists if (state.pendingPromise) { state.pendingPromise.resolve(device) state.pendingPromise = undefined } // Clean up if auto-reconnect is disabled if (!state.autoReconnect) { this._devices.delete(state.deviceId) } } catch (error) { // Check if this attempt was invalidated during the async operation if (state.attemptId !== myAttemptId) { // This attempt is stale - don't schedule retry or update state return } // Clear connection timeout if (state.connectionTimeoutId) { clearTimeout(state.connectionTimeoutId) state.connectionTimeoutId = undefined } // Use timeout error if it occurred const actualError = timeoutError || error // Check if cancelled after await const currentState = this._devices.get(state.deviceId) if (!currentState || currentState !== state || currentState.cancelled) { return } state.isConnecting = false if (state.retryCount >= maxRetries) { // Max retries exceeded const bleError = actualError instanceof BleError ? actualError : new BleError( { errorCode: BleErrorCode.UnknownError, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: actualError instanceof Error ? actualError.message : String(actualError) }, BleErrorCodeMessage ) // Notify callbacks state.callbacks?.onConnectFailed?.(state.deviceId, bleError) this._globalCallbacks.onConnectFailed?.(state.deviceId, bleError) // Reject pending promise if exists if (state.pendingPromise) { state.pendingPromise.reject(bleError) state.pendingPromise = undefined } // Clean up if auto-reconnect is disabled if (!state.autoReconnect) { this._devices.delete(state.deviceId) } return } // Calculate delay with exponential backoff const delay = Math.min( state.options.initialDelayMs * Math.pow(state.options.backoffMultiplier, state.retryCount - 1), state.options.maxDelayMs ) // Schedule retry this._scheduleConnection(state, delay) } } /** * Destroy the manager and cancel all pending connections. */ destroy(): void { // Force remove ALL subscriptions before clearing (prevents leaks) for (const state of this._devices.values()) { // Cancel timers state.cancelled = true if (state.timeoutId) { clearTimeout(state.timeoutId) } if (state.connectionTimeoutId) { clearTimeout(state.connectionTimeoutId) } // Remove subscriptions if (state.disconnectSubscription) { state.disconnectSubscription.remove() state.disconnectSubscription = undefined } // Cancel native connection if in progress if (state.isConnecting) { this._manager.cancelDeviceConnection(state.deviceId).catch(ignoreConnectionCancellationError) } } // Clear all state (don't call cancelAll to avoid unnecessary promise rejections during cleanup) this._devices.clear() this._globalCallbacks = {} } }