UNPKG

@sfourdrinier/react-native-ble-plx

Version:
590 lines (531 loc) 18.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ConnectionManager = void 0; var _BleError = require("./BleError"); /** * Connection options with retry and timeout support */ /** * Auto-reconnection options */ /** * Event callbacks for connection events */ const ignoreConnectionCancellationError = () => { // Native cancellation can reject if the connection already ended. }; /** * Internal state for a device connection */ /** * 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'); */ class ConnectionManager { _devices = new Map(); _globalCallbacks = {}; constructor(manager) { this._manager = manager; } /** * Set global callbacks for all connection events. * * @param callbacks Callback functions for connection events */ setGlobalCallbacks(callbacks) { 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, options) { 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 => { originalResolve(device); resolve(device); }; pending.reject = error => { 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.BleError({ errorCode: _BleError.BleErrorCode.OperationCancelled, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection attempt replaced for device ${deviceId}` }, _BleError.BleErrorCodeMessage)); } this._cancelState(existing); } const connectionOptions = { 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 = { 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, options, callbacks) { // If already exists, update settings let state = this._devices.get(deviceId); const existingReconnectOptions = state?.reconnectOptions; const reconnectOptions = { 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) { 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) { 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.BleError({ errorCode: _BleError.BleErrorCode.OperationCancelled, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection cancelled for device ${deviceId}` }, _BleError.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() { 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() { 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) { const state = this._devices.get(deviceId); return state?.isConnecting ?? false; } /** * Check if auto-reconnect is enabled for a device. */ isAutoReconnectEnabled(deviceId) { const state = this._devices.get(deviceId); return state?.autoReconnect ?? false; } /** * Get the current retry count for a device. */ getRetryCount(deviceId) { const state = this._devices.get(deviceId); return state?.retryCount ?? 0; } /** * Cancel state timers and subscriptions */ _cancelState(state) { 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 */ _startReconnection(deviceId) { 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 */ _scheduleConnection(state, delay) { 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 */ async _attemptConnection(state) { // 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 = null; const timeoutMs = state.options.timeoutMs ?? 0; if (timeoutMs > 0) { state.connectionTimeoutId = setTimeout(() => { timeoutError = new _BleError.BleError({ errorCode: _BleError.BleErrorCode.OperationTimedOut, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: `Connection timeout after ${timeoutMs}ms for device ${state.deviceId}` }, _BleError.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.BleError ? actualError : new _BleError.BleError({ errorCode: _BleError.BleErrorCode.UnknownError, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: actualError instanceof Error ? actualError.message : String(actualError) }, _BleError.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() { // 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 = {}; } } exports.ConnectionManager = ConnectionManager; //# sourceMappingURL=ConnectionManager.js.map