@sfourdrinier/react-native-ble-plx
Version:
React Native Bluetooth Low Energy library
590 lines (531 loc) • 18.4 kB
JavaScript
"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