UNPKG

@sfourdrinier/react-native-ble-plx

Version:
301 lines (263 loc) 8.04 kB
import { Device } from './Device' import { BleError, BleErrorCode, BleErrorCodeMessage } from './BleError' import type { BleManager } from './BleManager' import type { DeviceId, ConnectionOptions } from './TypeDefinition' const ignoreConnectionCancellationError = () => { // Native cancellation can reject if the connection already ended. } /** * Connection attempt state */ interface ConnectionAttempt { deviceId: DeviceId options?: ConnectionOptions resolve: (device: Device) => void reject: (error: BleError) => void retryCount: number maxRetries: number initialDelayMs: number maxDelayMs: number backoffMultiplier: number timeoutId?: ReturnType<typeof setTimeout> cancelled: boolean isConnecting: boolean } /** * Options for queuing a connection attempt */ export interface QueuedConnectionOptions { /** * 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 } /** * ConnectionQueue manages connection attempts with automatic retry logic * and queue management to prevent connection storms. * * Features: * - Queues connection attempts to prevent multiple simultaneous connections * - Exponential backoff retry logic * - Configurable retry limits and delays * - Ability to cancel pending/in-progress connections * * @example * const queue = new ConnectionQueue(bleManager); * * // Connect with retry logic * const device = await queue.connect('AA:BB:CC:DD:EE:FF', { * maxRetries: 5, * initialDelayMs: 1000, * backoffMultiplier: 2 * }); * * // Cancel a pending connection * queue.cancel('AA:BB:CC:DD:EE:FF'); */ export class ConnectionQueue { private _manager: BleManager private _queue: Map<DeviceId, ConnectionAttempt> = new Map() constructor(manager: BleManager) { this._manager = manager } /** * Queue a connection attempt with retry logic. * * @param deviceId Device identifier to connect to * @param options Connection and retry options * @returns Promise resolving to connected Device */ connect(deviceId: DeviceId, options?: QueuedConnectionOptions): Promise<Device> { return new Promise((resolve, reject) => { // Check if there's already a pending connection for this device const existing = this._queue.get(deviceId) if (existing && !existing.cancelled) { reject( new BleError( { errorCode: BleErrorCode.OperationCancelled, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection already pending for device ${deviceId}` }, BleErrorCodeMessage ) ) return } const attempt: ConnectionAttempt = { deviceId, options: options?.connectionOptions, resolve, reject, retryCount: 0, maxRetries: options?.maxRetries ?? 3, initialDelayMs: options?.initialDelayMs ?? 1000, maxDelayMs: options?.maxDelayMs ?? 30000, backoffMultiplier: options?.backoffMultiplier ?? 2, cancelled: false, isConnecting: false } this._queue.set(deviceId, attempt) this._processQueue() }) } /** * 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 attempt = this._queue.get(deviceId) if (!attempt) { return false } attempt.cancelled = true if (attempt.timeoutId) { clearTimeout(attempt.timeoutId) } this._queue.delete(deviceId) attempt.reject( new BleError( { errorCode: BleErrorCode.OperationCancelled, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection cancelled for device ${deviceId}` }, BleErrorCodeMessage ) ) // If this was actively connecting, try to disconnect if (attempt.isConnecting) { this._manager.cancelDeviceConnection(deviceId).catch(ignoreConnectionCancellationError) } // Process queue to start next pending connection this._processQueue() return true } /** * Cancel all pending connections. */ cancelAll(): void { const deviceIds = Array.from(this._queue.keys()) for (const deviceId of deviceIds) { this.cancel(deviceId) } } /** * Get the number of pending connections in the queue. */ get pendingCount(): number { return this._queue.size } /** * Check if a device has a pending connection. */ isPending(deviceId: DeviceId): boolean { const attempt = this._queue.get(deviceId) return attempt != null && !attempt.cancelled } /** * Process the connection queue * Allows concurrent connections to different devices */ private _processQueue(): void { // Start connection attempts for all pending (not connecting, not cancelled) devices for (const attempt of this._queue.values()) { if (!attempt.cancelled && !attempt.isConnecting) { // Start this connection attempt asynchronously this._attemptConnection(attempt) } } } /** * Attempt to connect to a device with retry logic * Runs asynchronously per device, allowing concurrent connections */ private async _attemptConnection(attempt: ConnectionAttempt): Promise<void> { if (attempt.cancelled) { this._queue.delete(attempt.deviceId) return } // Mark as actively connecting (prevents duplicate concurrent attempts to same device) attempt.isConnecting = true try { const device = await this._manager.connectToDevice(attempt.deviceId, attempt.options) if (attempt.cancelled) { // Connection succeeded but was cancelled, disconnect await this._manager.cancelDeviceConnection(attempt.deviceId).catch(ignoreConnectionCancellationError) this._queue.delete(attempt.deviceId) return } // Success! this._queue.delete(attempt.deviceId) attempt.resolve(device) } catch (error) { if (attempt.cancelled) { this._queue.delete(attempt.deviceId) return } attempt.retryCount++ if (attempt.retryCount >= attempt.maxRetries) { // Max retries exceeded this._queue.delete(attempt.deviceId) // Normalize error to BleError const bleError = error instanceof BleError ? error : new BleError( { errorCode: BleErrorCode.UnknownError, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: error instanceof Error ? error.message : String(error) }, BleErrorCodeMessage ) attempt.reject(bleError) return } // Calculate delay with exponential backoff const delay = Math.min( attempt.initialDelayMs * Math.pow(attempt.backoffMultiplier, attempt.retryCount - 1), attempt.maxDelayMs ) // Mark as not connecting during delay (allows cancellation) attempt.isConnecting = false // Schedule retry attempt.timeoutId = setTimeout(() => { if (!attempt.cancelled) { this._attemptConnection(attempt) } }, delay) } } /** * Destroy the queue and cancel all pending connections. */ destroy(): void { this.cancelAll() this._queue.clear() } }