@sfourdrinier/react-native-ble-plx
Version:
React Native Bluetooth Low Energy library
713 lines (614 loc) • 21.3 kB
text/typescript
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 = {}
}
}