@onekeyfe/hd-transport-react-native 1.2.0-alpha.17 → 1.2.0-alpha.170

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -5,30 +5,45 @@ import {
5
5
  BleError,
6
6
  BleErrorCode,
7
7
  BleManager as BlePlxManager,
8
+ ConnectionPriority,
8
9
  ScanMode,
9
10
  } from 'react-native-ble-plx';
10
11
  import ByteBuffer from 'bytebuffer';
11
12
  import transport, {
12
- LogBlockCommand,
13
13
  type OneKeyDeviceInfoBase,
14
14
  PROTOCOL_V1_MESSAGE_HEADER_SIZE,
15
15
  PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
16
16
  PROTOCOL_V2_CHANNEL_BLE_UART,
17
17
  type ProtocolType,
18
+ type ProtocolV2CallContext,
18
19
  ProtocolV2FrameAssembler,
19
20
  ProtocolV2LinkManager,
21
+ TRANSPORT_EVENT,
20
22
  type TransportCallOptions,
23
+ isProtocolV2HighThroughputCall,
21
24
  probeProtocolV2 as probeProtocolV2Helper,
25
+ writeProtocolV2BleFrame,
22
26
  } from '@onekeyfe/hd-transport';
23
- import { ERRORS, HardwareErrorCode, createDeferred, isOnekeyDevice } from '@onekeyfe/hd-shared';
27
+ import {
28
+ ERRORS,
29
+ HardwareErrorCode,
30
+ createDeferred,
31
+ isOnekeyBluetoothDevice,
32
+ } from '@onekeyfe/hd-shared';
24
33
 
25
34
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
26
- import { hasWritableCapability, resolveProtocolV2PacketCapacity } from './bleStrategy';
35
+ import {
36
+ hasWritableCapability,
37
+ resolveProtocolV2PacketCapacity,
38
+ shouldRefreshNegotiatedMtu,
39
+ shouldWriteProtocolV2WithResponse,
40
+ } from './bleStrategy';
27
41
  import { subscribeBleOn } from './subscribeBleOn';
28
42
  import {
29
43
  ANDROID_PACKET_LENGTH,
44
+ ANDROID_PROTOCOL_V2_PACKET_LENGTH,
30
45
  IOS_PACKET_LENGTH,
31
- getBleUuidKey,
46
+ IOS_PROTOCOL_V2_PACKET_LENGTH,
32
47
  getBluetoothServiceUuids,
33
48
  getInfosForServiceUuid,
34
49
  isSameBleUuid,
@@ -37,13 +52,21 @@ import { isHeaderChunk } from './utils/validateNotify';
37
52
  import BleTransport from './BleTransport';
38
53
  import timer from './utils/timer';
39
54
  import { bleLogger, setBleLogger } from './logger';
40
- import { createTransportCallLog } from './transportLog';
41
55
 
42
56
  import type { Deferred } from '@onekeyfe/hd-shared';
43
57
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
44
58
  import type EventEmitter from 'events';
45
59
  import type { BleAcquireInput, TransportOptions } from './types';
46
60
 
61
+ type FirmwareInstallBleAcquireInput = BleAcquireInput & {
62
+ /**
63
+ * Reuse the already-verified protocol after an expected firmware-install
64
+ * disconnect. The install loader accepts status requests but may not answer
65
+ * the generic protocol probe used by a normal acquire.
66
+ */
67
+ skipProtocolProbe?: boolean;
68
+ };
69
+
47
70
  const { check, ProtocolV1, parseConfigure } = transport;
48
71
 
49
72
  const Log = bleLogger;
@@ -53,24 +76,52 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
53
76
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
54
77
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
55
78
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
56
- const FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS = 2000;
57
79
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
58
80
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
59
81
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
60
82
  const ANDROID_GATT_CONGESTED_STATUS = 143;
61
83
 
62
- type FirmwareUploadWriteRetryType = 'congested' | 'reconnectable';
84
+ type FirmwareUploadWriteRetryType = 'congested';
63
85
  type ResolvedBleCharacteristics = {
64
86
  writeCharacteristic: Characteristic;
65
87
  notifyCharacteristic: Characteristic;
66
88
  };
67
89
 
90
+ const isAsciiWhitespace = (code: number) =>
91
+ code === 0x09 ||
92
+ code === 0x0a ||
93
+ code === 0x0b ||
94
+ code === 0x0c ||
95
+ code === 0x0d ||
96
+ code === 0x20;
97
+
98
+ const hasGattCongestedStatus = (text: string) => {
99
+ let searchFrom = 0;
100
+ while (searchFrom < text.length) {
101
+ const statusIndex = text.indexOf('status', searchFrom);
102
+ if (statusIndex < 0) return false;
103
+
104
+ let cursor = statusIndex + 'status'.length;
105
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
106
+ if (text[cursor] === ':' || text[cursor] === '=') {
107
+ cursor += 1;
108
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
109
+ }
110
+ if (text.startsWith(String(ANDROID_GATT_CONGESTED_STATUS), cursor)) return true;
111
+
112
+ searchFrom = statusIndex + 'status'.length;
113
+ }
114
+ return false;
115
+ };
116
+
68
117
  const delay = (ms: number) =>
69
118
  new Promise<void>(resolve => {
70
119
  setTimeout(resolve, ms);
71
120
  });
72
121
 
73
- const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRetryType | null => {
122
+ export const getFirmwareUploadWriteRetryType = (
123
+ error: unknown
124
+ ): FirmwareUploadWriteRetryType | null => {
74
125
  if (!error || typeof error !== 'object') return null;
75
126
  const bleWriteError = error as {
76
127
  androidErrorCode?: unknown;
@@ -81,13 +132,6 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
81
132
  name?: unknown;
82
133
  };
83
134
 
84
- if (
85
- bleWriteError.errorCode === BleErrorCode.DeviceDisconnected ||
86
- bleWriteError.errorCode === BleErrorCode.CharacteristicNotFound
87
- ) {
88
- return 'reconnectable';
89
- }
90
-
91
135
  if (
92
136
  bleWriteError.androidErrorCode === ANDROID_GATT_CONGESTED_STATUS ||
93
137
  bleWriteError.status === ANDROID_GATT_CONGESTED_STATUS
@@ -98,15 +142,31 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
98
142
  const text = [bleWriteError.reason, bleWriteError.message, bleWriteError.name]
99
143
  .filter(value => typeof value === 'string')
100
144
  .join(' ');
101
- return /GATT_CONGESTED|status\s*[:=]?\s*143/.test(text) ? 'congested' : null;
145
+ return text.includes('GATT_CONGESTED') || hasGattCongestedStatus(text) ? 'congested' : null;
102
146
  };
103
147
 
104
148
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
105
149
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
106
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
107
- const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
150
+ const PROTOCOL_PROBE_TIMEOUT_MS = 3000;
108
151
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
109
- const DEVICE_SCAN_TIMEOUT_MS = 8000;
152
+ /**
153
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
154
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
155
+ * stops reporting ready while staying connected, so the write promise never settles.
156
+ * Response timeouts cannot cover that — they are armed after the writes complete —
157
+ * and an unbounded write leaves the whole transport unusable until the process dies.
158
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
159
+ */
160
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
161
+ export const BLE_NATIVE_TEARDOWN_TIMEOUT_MS = 3_000;
162
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
163
+ const isWedgedWriteError = (error: unknown): boolean =>
164
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
165
+ typeof (error as { message?: unknown })?.message === 'string' &&
166
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
167
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
168
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
169
+ const DEVICE_SCAN_TIMEOUT_MS = 3000;
110
170
  const IOS_NOTIFY_READY_DELAY_MS = 150;
111
171
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
112
172
  export type ProtocolV2BleTuning = {
@@ -117,8 +177,8 @@ export type ProtocolV2BleTuning = {
117
177
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
118
178
 
119
179
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
120
- iosPacketLength: IOS_PACKET_LENGTH,
121
- androidPacketLength: ANDROID_PACKET_LENGTH,
180
+ iosPacketLength: IOS_PROTOCOL_V2_PACKET_LENGTH,
181
+ androidPacketLength: ANDROID_PROTOCOL_V2_PACKET_LENGTH,
122
182
  };
123
183
 
124
184
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -152,32 +212,71 @@ export function getProtocolV2BleTuning() {
152
212
  return { ...protocolV2BleTuning };
153
213
  }
154
214
 
155
- function inferProtocolHintFromDeviceName(name?: string | null): ProtocolType | undefined {
156
- return /\bpro\s*2\b/i.test(name ?? '') ? 'V2' : undefined;
157
- }
158
-
159
215
  function getDeviceDisplayName(device?: Device | null) {
160
216
  return device?.name || device?.localName || null;
161
217
  }
162
218
 
163
- function isGenericBleService(uuid?: string | null) {
164
- return ['1800', '1801', '180a'].includes(getBleUuidKey(uuid));
165
- }
219
+ const IOS_REQUEST_MTU = 247;
220
+ const ANDROID_REQUEST_MTU = 517;
221
+ const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
222
+ const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
166
223
 
167
- function hasKnownOneKeyService(device?: Device | null) {
168
- return (device?.serviceUUIDs ?? []).some(serviceUuid =>
169
- getInfosForServiceUuid(serviceUuid, 'classic')
170
- );
171
- }
224
+ const getRequestedBleMtu = () =>
225
+ Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
172
226
 
173
- const ANDROID_REQUEST_MTU = 256;
227
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
174
228
 
175
229
  const connectOptions: Record<string, unknown> = {
176
- requestMTU: ANDROID_REQUEST_MTU,
177
- timeout: 3000,
230
+ requestMTU: getRequestedBleMtu(),
231
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
178
232
  refreshGatt: 'OnConnected',
179
233
  };
180
234
 
235
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
236
+ const fallbackConnectOptions: Record<string, unknown> = {
237
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
238
+ };
239
+
240
+ /**
241
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
242
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
243
+ * firmware install tears the link down) can leave the promise unsettled — observed
244
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
245
+ * inside the native budget, so this only fires when the native timeout did not.
246
+ */
247
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
248
+ /**
249
+ * Service discovery and characteristic resolution run after connect() succeeds, but
250
+ * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
251
+ * device reboot, these calls can remain pending forever unless they have their own
252
+ * budget.
253
+ */
254
+ export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
255
+ /**
256
+ * How many times a known device may fail its own protocol before we probe the others
257
+ * again. Reconnect polling during a device reboot repeats this every few seconds, and
258
+ * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
259
+ * device we just spoke V1 to dominates the wait. A firmware update can legitimately
260
+ * change a device's protocol, so the shortcut has to expire rather than stick.
261
+ */
262
+ export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
263
+ /** BLE setup timeouts since the last successful setup before the manager is recreated. */
264
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
265
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
266
+ export const BLE_SETUP_WEDGED_MESSAGE = 'BLE setup wedged repeatedly';
267
+ const isConnectTimeoutError = (error: unknown): boolean =>
268
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
269
+ typeof (error as { message?: unknown })?.message === 'string' &&
270
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
271
+ const isWedgedBleSetupError = (error: unknown): boolean =>
272
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.PollingTimeout &&
273
+ typeof (error as { message?: unknown })?.message === 'string' &&
274
+ (error as { message: string }).message.startsWith(BLE_SETUP_WEDGED_MESSAGE);
275
+ const shouldRethrowBleSetupError = (error: unknown): boolean =>
276
+ isConnectTimeoutError(error) || isWedgedBleSetupError(error);
277
+ const isNativeOperationTimeoutError = (error: unknown): boolean =>
278
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
279
+
181
280
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
182
281
 
183
282
  const tryToGetConfiguration = (device: Device) => {
@@ -189,23 +288,32 @@ const tryToGetConfiguration = (device: Device) => {
189
288
  return infos;
190
289
  };
191
290
 
192
- const requestAndroidMtu = async (device: Device) => {
193
- if (Platform.OS !== 'android') return device;
291
+ const requestNegotiatedMtu = async (
292
+ device: Device,
293
+ stage: 'connected' | 'servicesAndNotifyReady' | 'highThroughput',
294
+ attempt: number
295
+ ) => {
296
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return device;
194
297
 
195
298
  try {
196
- const mtuDevice = await device.requestMTU(ANDROID_REQUEST_MTU);
197
- Log?.debug('[ReactNativeBleTransport] MTU configured', {
198
- deviceId: device.id,
199
- requested: ANDROID_REQUEST_MTU,
200
- actual: mtuDevice.mtu,
201
- });
299
+ // iOS ignores the requested value but react-native-ble-plx returns a fresh
300
+ // Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
301
+ const mtuDevice = await device.requestMTU(getRequestedBleMtu());
202
302
  return mtuDevice;
203
303
  } catch (error) {
204
- Log?.debug('[ReactNativeBleTransport] Android MTU request failed:', error);
304
+ Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
305
+ platform: Platform.OS,
306
+ stage,
307
+ attempt,
308
+ actual: device.mtu,
309
+ error: error instanceof Error ? error.message : String(error),
310
+ });
205
311
  return device;
206
312
  }
207
313
  };
208
314
 
315
+ const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'connected', 0);
316
+
209
317
  type IOBleErrorRemap = Error | BleError | null | undefined;
210
318
 
211
319
  function remapError(error: IOBleErrorRemap) {
@@ -245,6 +353,8 @@ export default class ReactNativeBleTransport {
245
353
 
246
354
  _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
247
355
 
356
+ private protocolV2SchemaConfiguration: string | undefined;
357
+
248
358
  name = 'ReactNativeBleTransport';
249
359
 
250
360
  configured = false;
@@ -255,6 +365,8 @@ export default class ReactNativeBleTransport {
255
365
 
256
366
  runPromise: Deferred<any> | null = null;
257
367
 
368
+ private runPromiseDeviceId: string | null = null;
369
+
258
370
  emitter?: EventEmitter;
259
371
 
260
372
  firmwareUploadWriteRecoveryIds = new Set<string>();
@@ -262,8 +374,31 @@ export default class ReactNativeBleTransport {
262
374
  /** Per-device protocol type detected by active wire-level probe after connect. */
263
375
  private deviceProtocol: Map<string, ProtocolType> = new Map();
264
376
 
377
+ /**
378
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
379
+ * must route with it, but acquire() must not treat it as a detected protocol: a
380
+ * probe that never answers would otherwise leave the reuse fast path handing out a
381
+ * transport that was never validated.
382
+ */
383
+ private probingProtocols: Map<string, ProtocolType> = new Map();
384
+
385
+ /** Consecutive write timeouts per device; reset by any write that completes. */
386
+ private writeTimeoutCounts: Map<string, number> = new Map();
387
+
388
+ /** BLE setup timeouts per device since the last complete characteristic resolution. */
389
+ private connectionSetupTimeoutCounts: Map<string, number> = new Map();
390
+
265
391
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
266
392
 
393
+ /** Protocol this device actually answered on, kept across reconnects of one session. */
394
+ private sessionProtocols: Map<string, ProtocolType> = new Map();
395
+
396
+ /** Endpoints that answered a V2 probe in this transport lifetime. Survives disconnect. */
397
+ private confirmedProtocolV2 = new Set<string>();
398
+
399
+ /** Consecutive detections that failed while trusting sessionProtocols. */
400
+ private protocolReprobeFailures: Map<string, number> = new Map();
401
+
267
402
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
268
403
 
269
404
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -286,15 +421,26 @@ export default class ReactNativeBleTransport {
286
421
  this.rejectProtocolV2Frames(uuid, new Error(reason));
287
422
  Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
288
423
  if (reason.startsWith('Protocol V2 link-fatal error:')) {
289
- await this.release(uuid, true);
424
+ await this.releaseNative(uuid, true);
290
425
  }
291
426
  },
292
427
  });
293
428
 
294
429
  private monitorTokens: Map<string, number> = new Map();
295
430
 
431
+ private disconnectEventTokens: Map<string, number> = new Map();
432
+
433
+ private protocolV2HighVolumeLogSignatures: Map<string, Set<string>> = new Map();
434
+
435
+ private androidHighPriorityDevices: Set<string> = new Set();
436
+
437
+ private androidPriorityResetTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
438
+
296
439
  private nextMonitorToken = 1;
297
440
 
441
+ /** Serializes transport lifecycle changes for the same physical device. */
442
+ private lifecycleOperations: Map<string, Promise<void>> = new Map();
443
+
298
444
  constructor(options: TransportOptions) {
299
445
  this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
300
446
  }
@@ -311,10 +457,19 @@ export default class ReactNativeBleTransport {
311
457
  }
312
458
 
313
459
  configureProtocolV2(signedData: any) {
460
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
461
+ if (this.protocolV2SchemaConfiguration === configuration) {
462
+ return;
463
+ }
464
+
465
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
314
466
  this._messagesV2 = parseConfigure(signedData);
315
- this.protocolV2Links
316
- .invalidateAllLinks('Protocol V2 schema reconfigured')
317
- .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
467
+ this.protocolV2SchemaConfiguration = configuration;
468
+ if (isReconfiguration) {
469
+ this.protocolV2Links
470
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
471
+ .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
472
+ }
318
473
  }
319
474
 
320
475
  listen() {
@@ -344,29 +499,15 @@ export default class ReactNativeBleTransport {
344
499
  }
345
500
  }
346
501
 
347
- let fallbackServiceUuid: string | undefined;
348
-
349
502
  if (!infos) {
350
503
  const services = await device.services();
351
504
  Log?.debug(
352
505
  '[ReactNativeBleTransport] Known OneKey service UUID not found, discovered services:',
353
506
  services?.map(service => service.uuid)
354
507
  );
355
-
356
- const knownService = services.find(service =>
357
- getInfosForServiceUuid(service.uuid, 'classic')
358
- );
359
- const fallbackService =
360
- knownService ?? services.find(service => !isGenericBleService(service.uuid)) ?? services[0];
361
-
362
- if (fallbackService) {
363
- fallbackServiceUuid = fallbackService.uuid;
364
- characteristics = await device.characteristicsForService(fallbackService.uuid);
365
- Log?.debug('[ReactNativeBleTransport] Using fallback BLE service:', fallbackService.uuid);
366
- }
367
508
  }
368
509
 
369
- if (!infos && !fallbackServiceUuid) {
510
+ if (!infos) {
370
511
  try {
371
512
  Log?.debug('cancel connection when service not found');
372
513
  await device.cancelConnection();
@@ -376,9 +517,7 @@ export default class ReactNativeBleTransport {
376
517
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
377
518
  }
378
519
 
379
- const serviceUuid = infos?.serviceUuid ?? fallbackServiceUuid;
380
- const writeUuid = infos?.writeUuid ?? '00000002-0000-1000-8000-00805f9b34fb';
381
- const notifyUuid = infos?.notifyUuid ?? '00000003-0000-1000-8000-00805f9b34fb';
520
+ const { serviceUuid, writeUuid, notifyUuid } = infos;
382
521
 
383
522
  if (!serviceUuid) {
384
523
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
@@ -428,6 +567,7 @@ export default class ReactNativeBleTransport {
428
567
 
429
568
  attachDisconnectSubscription(transport: BleTransport, device: Device, uuid: string) {
430
569
  transport.disconnectSubscription?.remove();
570
+ const { monitorToken } = transport;
431
571
  transport.disconnectSubscription = device.onDisconnected(() => {
432
572
  if (this.firmwareUploadWriteRecoveryIds.has(uuid)) {
433
573
  Log?.debug('device disconnect ignored during FirmwareUpload write recovery: ', uuid);
@@ -437,18 +577,17 @@ export default class ReactNativeBleTransport {
437
577
  Log?.debug('device disconnect ignored for stale transport: ', device?.id);
438
578
  return;
439
579
  }
580
+ if (this.monitorTokens.get(uuid) !== monitorToken) {
581
+ Log?.debug('device disconnect ignored for stale generation: ', device?.id);
582
+ return;
583
+ }
440
584
 
441
585
  try {
442
586
  Log?.debug('device disconnect: ', device?.id);
443
- this.emitter?.emit('device-disconnect', {
444
- name: device?.name,
445
- id: device?.id,
446
- connectId: device?.id,
447
- });
448
- if (this.runPromise) {
587
+ this.emitDeviceDisconnect(uuid, device?.name, monitorToken);
588
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
449
589
  const error = ERRORS.TypedError(HardwareErrorCode.BleConnectedError);
450
590
  this.runPromise.reject(error);
451
- this.rejectAllProtocolV2Frames(error);
452
591
  }
453
592
  } catch (e) {
454
593
  Log?.debug('device disconnect error: ', e);
@@ -458,6 +597,22 @@ export default class ReactNativeBleTransport {
458
597
  });
459
598
  }
460
599
 
600
+ private emitDeviceDisconnect(uuid: string, name: string | null | undefined, token?: number) {
601
+ if (token === undefined || this.disconnectEventTokens.get(uuid) === token) {
602
+ return;
603
+ }
604
+ if (this.monitorTokens.get(uuid) !== token) {
605
+ Log?.debug('device disconnect event ignored for stale generation: ', uuid);
606
+ return;
607
+ }
608
+ this.disconnectEventTokens.set(uuid, token);
609
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
610
+ name,
611
+ id: uuid,
612
+ connectId: uuid,
613
+ });
614
+ }
615
+
461
616
  async reconnectFirmwareUploadTransport(uuid: string, transport: BleTransport) {
462
617
  this.firmwareUploadWriteRecoveryIds.add(uuid);
463
618
  try {
@@ -470,22 +625,21 @@ export default class ReactNativeBleTransport {
470
625
  const isConnected = await device.isConnected().catch(() => false);
471
626
  if (!isConnected) {
472
627
  try {
473
- device = await device.connect(connectOptions);
628
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
474
629
  } catch (e) {
475
630
  if (
476
631
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
477
632
  e.errorCode === BleErrorCode.OperationCancelled
478
633
  ) {
479
- device = await device.connect();
634
+ device = await this.connectWithTimeout(uuid, () => device.connect());
480
635
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
481
636
  throw e;
482
637
  }
483
638
  }
484
639
  }
485
640
 
486
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
487
- device
488
- );
641
+ const { writeCharacteristic, notifyCharacteristic } =
642
+ await this.resolveCharacteristicsWithTimeout(uuid, device);
489
643
 
490
644
  transport.device = device;
491
645
  transport.writeCharacteristic = writeCharacteristic;
@@ -575,10 +729,19 @@ export default class ReactNativeBleTransport {
575
729
  }
576
730
 
577
731
  const displayName = getDeviceDisplayName(device);
732
+ // iOS may report a service-only advertisement before the named scan response.
733
+ // Do not cache that incomplete advertisement as an unknown device.
734
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
578
735
  const isOneKey =
579
- isOnekeyDevice(device?.name ?? null, device?.id) ||
580
- isOnekeyDevice(device?.localName ?? null, device?.id) ||
581
- hasKnownOneKeyService(device);
736
+ !isUnnamedIOSPeripheral &&
737
+ isOnekeyBluetoothDevice({
738
+ id: device?.id,
739
+ name: device?.name,
740
+ localName: device?.localName,
741
+ // The native scan is already restricted to the OneKey communication service,
742
+ // but ble-plx permits the returned advertisement field to be null.
743
+ serviceUuids: device?.serviceUUIDs ?? getBluetoothServiceUuids(),
744
+ });
582
745
  if (isOneKey) {
583
746
  addDevice(device as unknown as Device);
584
747
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -595,10 +758,18 @@ export default class ReactNativeBleTransport {
595
758
  getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
596
759
  devices => {
597
760
  for (const device of devices) {
598
- const { serviceUUIDs } = device as { serviceUUIDs?: string[] };
599
- const hasCachedServiceUuid = Boolean(serviceUUIDs?.length);
600
- const keepDevice = Platform.OS === 'ios' || hasCachedServiceUuid;
601
- if (keepDevice) {
761
+ const localName =
762
+ 'localName' in device && typeof device.localName === 'string'
763
+ ? device.localName
764
+ : null;
765
+ if (
766
+ isOnekeyBluetoothDevice({
767
+ id: device.id,
768
+ name: device.name,
769
+ localName,
770
+ serviceUuids: device.serviceUUIDs,
771
+ })
772
+ ) {
602
773
  Log?.debug('search connected peripheral: ', device.id);
603
774
  addDevice(device as unknown as Device);
604
775
  }
@@ -609,10 +780,7 @@ export default class ReactNativeBleTransport {
609
780
  const addDevice = (device: Device) => {
610
781
  if (deviceList.every(d => d.id !== device.id)) {
611
782
  const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
612
- const protocolHint = inferProtocolHintFromDeviceName(displayName);
613
- if (protocolHint) {
614
- this.deviceProtocolHints.set(device.id, protocolHint);
615
- }
783
+
616
784
  deviceList.push({
617
785
  ...device,
618
786
  name: displayName,
@@ -622,7 +790,6 @@ export default class ReactNativeBleTransport {
622
790
  deviceId: device.id,
623
791
  name: displayName,
624
792
  serviceUUIDs: device.serviceUUIDs,
625
- protocolHint,
626
793
  });
627
794
  }
628
795
  };
@@ -634,13 +801,92 @@ export default class ReactNativeBleTransport {
634
801
  });
635
802
  }
636
803
 
637
- async acquire(input: BleAcquireInput) {
638
- const { uuid, forceCleanRunPromise, expectedProtocol } = input;
804
+ private async installTransportForAcquire(
805
+ uuid: string,
806
+ device: Device,
807
+ characteristics?: ResolvedBleCharacteristics
808
+ ) {
809
+ const { writeCharacteristic, notifyCharacteristic } =
810
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
811
+ const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
812
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
813
+ const monitorToken = this.nextMonitorToken;
814
+ this.nextMonitorToken += 1;
815
+ const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
816
+ transport.monitorToken = monitorToken;
817
+ transport.notifyTransactionId = notifyTransactionId;
818
+ this.monitorTokens.set(uuid, monitorToken);
819
+ transport.notifySubscription = this._monitorCharacteristic(
820
+ transport.notifyCharacteristic,
821
+ uuid,
822
+ monitorToken,
823
+ notifyTransactionId
824
+ );
825
+ transportCache[uuid] = transport;
826
+ this.protocolV2HighVolumeLogSignatures.set(uuid, new Set());
827
+ this.protocolV2Assemblers.set(
828
+ uuid,
829
+ new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
830
+ );
831
+
832
+ if (Platform.OS === 'ios') {
833
+ await new Promise<void>(resolve => {
834
+ setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
835
+ });
836
+ } else if (Platform.OS === 'android') {
837
+ await delay(ANDROID_NOTIFY_READY_DELAY_MS);
838
+ }
839
+
840
+ const initialMtu = transport.mtuSize;
841
+ let refreshAttempts = 0;
842
+ if (
843
+ (Platform.OS === 'ios' || Platform.OS === 'android') &&
844
+ shouldRefreshNegotiatedMtu(transport.mtuSize)
845
+ ) {
846
+ refreshAttempts += 1;
847
+ let refreshedDevice = await requestNegotiatedMtu(
848
+ transport.device,
849
+ 'servicesAndNotifyReady',
850
+ 1
851
+ );
852
+ transport.device = refreshedDevice;
853
+ transport.mtuSize =
854
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
855
+
856
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
857
+ await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
858
+ refreshAttempts += 1;
859
+ refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
860
+ transport.device = refreshedDevice;
861
+ transport.mtuSize =
862
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
863
+ }
864
+ }
865
+
866
+ Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
867
+ platform: Platform.OS,
868
+ requested: getRequestedBleMtu(),
869
+ initial: initialMtu,
870
+ actual: transport.mtuSize,
871
+ refreshAttempts,
872
+ });
873
+
874
+ return transport;
875
+ }
876
+
877
+ async acquire(input: FirmwareInstallBleAcquireInput) {
878
+ const { uuid } = input;
639
879
 
640
880
  if (!uuid) {
641
881
  throw ERRORS.TypedError(HardwareErrorCode.BleRequiredUUID);
642
882
  }
643
883
 
884
+ return this.runLifecycleOperation(uuid, () => this.acquireUnlocked(input));
885
+ }
886
+
887
+ private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
888
+ const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
889
+
644
890
  const cachedTransport = transportCache[uuid];
645
891
  if (cachedTransport) {
646
892
  const cachedProtocol = this.deviceProtocol.get(uuid);
@@ -659,7 +905,7 @@ export default class ReactNativeBleTransport {
659
905
  * connection, clean it up before creating a new transport instance.
660
906
  */
661
907
  Log?.debug('transport not reusable, will release: ', uuid);
662
- await this.release(uuid, true);
908
+ await this.releaseUnlocked(uuid, true);
663
909
  }
664
910
 
665
911
  let device: Device | null = null;
@@ -667,8 +913,8 @@ export default class ReactNativeBleTransport {
667
913
  if (forceCleanRunPromise && this.runPromise) {
668
914
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
669
915
  this.runPromise.reject(error);
670
- this.rejectAllProtocolV2Frames(error);
671
916
  this.runPromise = null;
917
+ this.runPromiseDeviceId = null;
672
918
  Log?.debug('Force clean Bluetooth run promise, forceCleanRunPromise: ', forceCleanRunPromise);
673
919
  }
674
920
 
@@ -704,15 +950,22 @@ export default class ReactNativeBleTransport {
704
950
  if (!device) {
705
951
  Log?.debug('try to connect to device: ', uuid);
706
952
  try {
707
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
953
+ device = await this.connectWithTimeout(uuid, () =>
954
+ blePlxManager.connectToDevice(uuid, connectOptions)
955
+ );
708
956
  } catch (e) {
709
957
  Log?.debug('try to connect to device has error: ', e);
958
+ if (shouldRethrowBleSetupError(e)) {
959
+ throw e;
960
+ }
710
961
  if (
711
962
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
712
963
  e.errorCode === BleErrorCode.OperationCancelled
713
964
  ) {
714
965
  Log?.debug('first try to reconnect without params');
715
- device = await blePlxManager.connectToDevice(uuid);
966
+ device = await this.connectWithTimeout(uuid, () =>
967
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
968
+ );
716
969
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
717
970
  Log?.debug('device already connected');
718
971
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -728,26 +981,36 @@ export default class ReactNativeBleTransport {
728
981
 
729
982
  if (!(await device.isConnected())) {
730
983
  Log?.debug('not connected, try to connect to device: ', uuid);
984
+ const disconnectedDevice = device;
731
985
 
732
986
  try {
733
- device = await device.connect(connectOptions);
987
+ device = await this.connectWithTimeout(uuid, () =>
988
+ disconnectedDevice.connect(connectOptions)
989
+ );
734
990
  } catch (e) {
735
991
  Log?.debug('not connected, try to connect to device has error: ', e);
992
+ if (shouldRethrowBleSetupError(e)) {
993
+ throw e;
994
+ }
736
995
  if (
737
996
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
738
997
  e.errorCode === BleErrorCode.OperationCancelled
739
998
  ) {
740
999
  Log?.debug('second try to reconnect without params');
741
1000
  try {
742
- device = await device.connect();
1001
+ device = await this.connectWithTimeout(uuid, () =>
1002
+ disconnectedDevice.connect(fallbackConnectOptions)
1003
+ );
743
1004
  } catch (e) {
744
1005
  Log?.debug('last try to reconnect error: ', e);
745
1006
  // last try to reconnect device if this issue exists
746
1007
  // https://github.com/dotintent/react-native-ble-plx/issues/426
747
1008
  if (e.errorCode === BleErrorCode.OperationCancelled) {
748
1009
  Log?.debug('last try to reconnect');
749
- await device.cancelConnection();
750
- device = await device.connect();
1010
+ await disconnectedDevice.cancelConnection();
1011
+ device = await this.connectWithTimeout(uuid, () =>
1012
+ disconnectedDevice.connect(fallbackConnectOptions)
1013
+ );
751
1014
  }
752
1015
  }
753
1016
  } else {
@@ -756,59 +1019,72 @@ export default class ReactNativeBleTransport {
756
1019
  }
757
1020
  }
758
1021
 
759
- device = await requestAndroidMtu(device);
760
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(device);
1022
+ device = await resolveNegotiatedMtu(device);
1023
+ const acquiredDevice = device;
1024
+ const { writeCharacteristic, notifyCharacteristic } =
1025
+ await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
761
1026
 
762
1027
  const protocolHint = expectedProtocol
763
1028
  ? undefined
764
- : this.deviceProtocolHints.get(uuid) ??
765
- inferProtocolHintFromDeviceName(getDeviceDisplayName(device));
1029
+ : input.protocolHint ?? this.deviceProtocolHints.get(uuid);
766
1030
 
767
1031
  // release transport before new transport instance
768
- await this.release(uuid, true);
1032
+ await this.releaseUnlocked(uuid, true);
769
1033
  if (protocolHint) {
770
1034
  this.deviceProtocolHints.set(uuid, protocolHint);
771
1035
  }
772
1036
 
773
- const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
774
- if (Platform.OS === 'android') {
775
- transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
776
- }
777
- const monitorToken = this.nextMonitorToken;
778
- this.nextMonitorToken += 1;
779
- const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
780
- transport.monitorToken = monitorToken;
781
- transport.notifyTransactionId = notifyTransactionId;
782
- this.monitorTokens.set(uuid, monitorToken);
783
- transport.notifySubscription = this._monitorCharacteristic(
784
- transport.notifyCharacteristic,
785
- uuid,
786
- monitorToken,
787
- notifyTransactionId
788
- );
789
- transportCache[uuid] = transport;
790
-
791
- this.protocolV2Assemblers.set(uuid, new ProtocolV2FrameAssembler());
792
-
793
- if (Platform.OS === 'ios') {
794
- await new Promise<void>(resolve => {
795
- setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
796
- });
797
- } else if (Platform.OS === 'android') {
798
- await delay(ANDROID_NOTIFY_READY_DELAY_MS);
799
- }
800
-
801
- const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
802
-
803
- this.emitter?.emit('device-connect', {
804
- name: device.name,
805
- id: device.id,
806
- connectId: device.id,
1037
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
1038
+ writeCharacteristic,
1039
+ notifyCharacteristic,
807
1040
  });
808
1041
 
809
- this.attachDisconnectSubscription(transport, device, uuid);
1042
+ try {
1043
+ if (skipProtocolProbe) {
1044
+ if (!expectedProtocol) {
1045
+ throw ERRORS.TypedError(
1046
+ HardwareErrorCode.RuntimeError,
1047
+ 'skipProtocolProbe requires an expected BLE protocol'
1048
+ );
1049
+ }
1050
+ this.deviceProtocol.set(uuid, expectedProtocol);
1051
+ this.sessionProtocols.set(uuid, expectedProtocol);
1052
+ this.protocolReprobeFailures.delete(uuid);
1053
+ Log?.debug('[ReactNativeBleTransport] protocol selected without probe', {
1054
+ deviceId: uuid,
1055
+ protocol: expectedProtocol,
1056
+ source: 'firmware-install-reconnect',
1057
+ });
1058
+ const currentTransport = transportCache[uuid];
1059
+ if (!currentTransport) {
1060
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1061
+ }
1062
+ this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1063
+ return { uuid, protocolType: expectedProtocol };
1064
+ }
810
1065
 
811
- return { uuid, protocolType };
1066
+ const protocolType = await this.detectProtocol(
1067
+ uuid,
1068
+ expectedProtocol,
1069
+ protocolHint,
1070
+ async () => {
1071
+ await this.installTransportForAcquire(uuid, acquiredDevice);
1072
+ }
1073
+ );
1074
+ const currentTransport = transportCache[uuid];
1075
+ if (!currentTransport) {
1076
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1077
+ }
1078
+ this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1079
+ return { uuid, protocolType };
1080
+ } catch (error) {
1081
+ if ((error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceBondError) {
1082
+ await this.disconnectUnlocked(uuid);
1083
+ } else {
1084
+ await this.releaseUnlocked(uuid, true);
1085
+ }
1086
+ throw error;
1087
+ }
812
1088
  }
813
1089
 
814
1090
  _monitorCharacteristic(
@@ -835,7 +1111,7 @@ export default class ReactNativeBleTransport {
835
1111
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
836
1112
  return;
837
1113
  }
838
- if (this.deviceProtocol.get(uuid) === 'V2') {
1114
+ if (this.getActiveProtocol(uuid) === 'V2') {
839
1115
  let errorCode:
840
1116
  | typeof HardwareErrorCode.BleDeviceBondError
841
1117
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -858,7 +1134,7 @@ export default class ReactNativeBleTransport {
858
1134
  this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
859
1135
  return;
860
1136
  }
861
- if (this.runPromise) {
1137
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
862
1138
  let ERROR:
863
1139
  | typeof HardwareErrorCode.BleDeviceBondError
864
1140
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -881,7 +1157,6 @@ export default class ReactNativeBleTransport {
881
1157
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
882
1158
  );
883
1159
  this.runPromise.reject(notifyError);
884
- this.rejectAllProtocolV2Frames(notifyError);
885
1160
  Log?.debug(
886
1161
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
887
1162
  );
@@ -889,7 +1164,6 @@ export default class ReactNativeBleTransport {
889
1164
  }
890
1165
  const notifyError = ERRORS.TypedError(ERROR);
891
1166
  this.runPromise.reject(notifyError);
892
- this.rejectAllProtocolV2Frames(notifyError);
893
1167
  Log?.debug(': monitor notify error, and has unreleased Promise', Error);
894
1168
  }
895
1169
 
@@ -907,7 +1181,7 @@ export default class ReactNativeBleTransport {
907
1181
 
908
1182
  try {
909
1183
  const data = Buffer.from(c.value as string, 'base64');
910
- const protocol = this.deviceProtocol.get(uuid);
1184
+ const protocol = this.getActiveProtocol(uuid);
911
1185
  if (!protocol) {
912
1186
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
913
1187
  return;
@@ -934,14 +1208,16 @@ export default class ReactNativeBleTransport {
934
1208
  // );
935
1209
  bufferLength = 0;
936
1210
  buffer = [];
937
- this.runPromise?.resolve(value.toString('hex'));
1211
+ if (this.runPromiseDeviceId === uuid) {
1212
+ this.runPromise?.resolve(value.toString('hex'));
1213
+ }
938
1214
  }
939
1215
  } catch (error) {
940
1216
  Log?.debug('monitor data error: ', error);
941
1217
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
942
- if (this.deviceProtocol.get(uuid) === 'V2') {
1218
+ if (this.getActiveProtocol(uuid) === 'V2') {
943
1219
  this.rejectProtocolV2Frames(uuid, notifyError);
944
- } else {
1220
+ } else if (this.runPromiseDeviceId === uuid) {
945
1221
  this.runPromise?.reject(notifyError);
946
1222
  }
947
1223
  }
@@ -951,13 +1227,23 @@ export default class ReactNativeBleTransport {
951
1227
  }
952
1228
 
953
1229
  async release(uuid: string, onclose = false) {
954
- const transport = transportCache[uuid];
1230
+ return this.runLifecycleOperation(uuid, () => this.releaseUnlocked(uuid, onclose));
1231
+ }
1232
+
1233
+ private async releaseUnlocked(uuid: string, onclose = false) {
955
1234
  await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
956
- if (this.runPromise) {
1235
+ return this.releaseNative(uuid, onclose);
1236
+ }
1237
+
1238
+ private async releaseNative(uuid: string, onclose = false) {
1239
+ const transport = transportCache[uuid];
1240
+ const manager = this.blePlxManager;
1241
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
957
1242
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
958
1243
  this.runPromise.reject(error);
959
1244
  this.runPromise = null;
960
- this.rejectAllProtocolV2Frames(error);
1245
+ this.runPromiseDeviceId = null;
1246
+ this.rejectProtocolV2Frames(uuid, error);
961
1247
  } else {
962
1248
  this.resetProtocolV2Frames(uuid);
963
1249
  }
@@ -985,34 +1271,56 @@ export default class ReactNativeBleTransport {
985
1271
  );
986
1272
  transport.notifySubscription?.remove();
987
1273
  transport.notifySubscription = undefined;
988
-
989
- if (transport.notifyTransactionId) {
990
- try {
991
- await this.blePlxManager?.cancelTransaction(transport.notifyTransactionId);
992
- } catch (e) {
993
- Log?.debug('release: cancel notify transaction error (ignored): ', e?.message || e);
994
- }
1274
+ if (transportCache[uuid] === transport) {
1275
+ delete transportCache[uuid];
995
1276
  }
996
-
997
- delete transportCache[uuid];
998
1277
  }
999
1278
 
1279
+ this.protocolV2HighVolumeLogSignatures.delete(uuid);
1280
+
1000
1281
  this.deviceProtocol.delete(uuid);
1001
- // 设备名称提示不依赖当前连接;保留它可让重连优先探测 V2。
1282
+ this.probingProtocols.delete(uuid);
1283
+ // Confirmed protocol and caller hints stay in deviceProtocol / protocolHint.
1002
1284
  this.protocolV2Assemblers.get(uuid)?.reset();
1003
1285
  this.protocolV2Assemblers.delete(uuid);
1004
1286
  this.resetProtocolV2Frames(uuid);
1005
1287
 
1006
- try {
1007
- await this.blePlxManager?.cancelTransaction(uuid);
1008
- } catch (e) {
1009
- Log?.debug('release: cancel transaction error (ignored): ', e?.message || e);
1010
- }
1288
+ await this.runNativeTeardown(uuid, manager, async () => {
1289
+ const operations: Promise<unknown>[] = [
1290
+ this.runBestEffortNativeOperation('release: restore connection priority', () =>
1291
+ this.restoreAndroidConnectionPriority(uuid, transport)
1292
+ ),
1293
+ ];
1294
+ if (transport?.notifyTransactionId && manager) {
1295
+ operations.push(
1296
+ this.runBestEffortNativeOperation('release: cancel notify transaction', () =>
1297
+ manager.cancelTransaction(transport.notifyTransactionId as string)
1298
+ )
1299
+ );
1300
+ }
1301
+ if (manager) {
1302
+ operations.push(
1303
+ this.runBestEffortNativeOperation('release: cancel transaction', () =>
1304
+ manager.cancelTransaction(uuid)
1305
+ )
1306
+ );
1307
+ }
1308
+ await Promise.all(operations);
1309
+ });
1011
1310
 
1012
1311
  return Promise.resolve(true);
1013
1312
  }
1014
1313
 
1015
1314
  async post(session: string, name: string, data: Record<string, unknown>) {
1315
+ if (this.getProtocolType(session) === 'V2') {
1316
+ await this.protocolV2Links.sendFlowControl(
1317
+ session,
1318
+ () => this.createProtocolV2Adapter(session),
1319
+ name,
1320
+ data
1321
+ );
1322
+ return;
1323
+ }
1016
1324
  await this.call(session, name, data);
1017
1325
  }
1018
1326
 
@@ -1037,8 +1345,6 @@ export default class ReactNativeBleTransport {
1037
1345
  `Device protocol has not been detected for ${uuid}`
1038
1346
  );
1039
1347
  }
1040
- Log?.debug('transport call', createTransportCallLog(name, protocol, data));
1041
-
1042
1348
  if (protocol === 'V2') {
1043
1349
  return this.callProtocolV2(uuid, name, data, options);
1044
1350
  }
@@ -1064,7 +1370,25 @@ export default class ReactNativeBleTransport {
1064
1370
  const transport = this.getCachedTransport(uuid);
1065
1371
  const runPromise = createDeferred<string>();
1066
1372
  runPromise.promise.catch(() => undefined);
1373
+ const supersededRunPromise = this.runPromise;
1374
+ if (supersededRunPromise) {
1375
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1376
+ // the superseded deferred now so its response race resolves and its finally block
1377
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1378
+ // tear down the shared connection while another call is using it.
1379
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1380
+ }
1067
1381
  this.runPromise = runPromise;
1382
+ this.runPromiseDeviceId = uuid;
1383
+ // A superseded call's late write failure must not clear the successor's ownership;
1384
+ // only the call that still owns the slot may release it.
1385
+ const releaseOwnershipIfCurrent = () => {
1386
+ if (this.runPromise === runPromise) {
1387
+ this.runPromise = null;
1388
+ this.runPromiseDeviceId = null;
1389
+ }
1390
+ };
1391
+ const isCurrentOwner = () => this.runPromise === runPromise;
1068
1392
  const messages = this._messages;
1069
1393
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1070
1394
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1090,6 +1414,9 @@ export default class ReactNativeBleTransport {
1090
1414
  chunk = ByteBuffer.allocate(packetCapacity);
1091
1415
  } catch (e) {
1092
1416
  onError(e);
1417
+ if (isWedgedWriteError(e)) {
1418
+ throw e;
1419
+ }
1093
1420
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1094
1421
  }
1095
1422
  }
@@ -1121,6 +1448,9 @@ export default class ReactNativeBleTransport {
1121
1448
  }
1122
1449
  } catch (e) {
1123
1450
  onError(e);
1451
+ if (isWedgedWriteError(e)) {
1452
+ throw e;
1453
+ }
1124
1454
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1125
1455
  }
1126
1456
  }
@@ -1134,9 +1464,15 @@ export default class ReactNativeBleTransport {
1134
1464
  if (name === 'EmmcFileWrite') {
1135
1465
  await writeChunkedData(
1136
1466
  buffers,
1137
- data => transport.writeWithRetry(data),
1467
+ data =>
1468
+ this.writeBlePacket(
1469
+ uuid,
1470
+ data,
1471
+ payload => transport.writeWithRetry(payload),
1472
+ isCurrentOwner
1473
+ ),
1138
1474
  e => {
1139
- this.runPromise = null;
1475
+ releaseOwnershipIfCurrent();
1140
1476
  Log?.error('writeCharacteristic write error: ', e);
1141
1477
  }
1142
1478
  );
@@ -1157,43 +1493,31 @@ export default class ReactNativeBleTransport {
1157
1493
  // eslint-disable-next-line no-constant-condition
1158
1494
  while (true) {
1159
1495
  try {
1160
- await transport.writeCharacteristic.writeWithoutResponse(data);
1496
+ await this.writeBlePacket(
1497
+ uuid,
1498
+ data,
1499
+ payload => transport.writeWithRetry(payload),
1500
+ isCurrentOwner
1501
+ );
1161
1502
  return;
1162
1503
  } catch (error) {
1163
1504
  const retryType = getFirmwareUploadWriteRetryType(error);
1164
1505
  if (!retryType || attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1165
1506
  throw error;
1166
1507
  }
1167
- const shouldReconnect = retryType === 'reconnectable';
1168
- const delayMs = shouldReconnect
1169
- ? FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS
1170
- : resolveFirmwareUploadRetryDelay(attempt);
1508
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1171
1509
  Log?.debug('[ReactNativeBleTransport] FirmwareUpload write retry:', {
1172
1510
  attempt: attempt + 1,
1173
1511
  delayMs,
1174
- reconnect: shouldReconnect,
1175
1512
  error,
1176
1513
  });
1177
- if (shouldReconnect) {
1178
- this.firmwareUploadWriteRecoveryIds.add(uuid);
1179
- }
1180
1514
  await delay(delayMs);
1181
1515
  attempt += 1;
1182
- if (shouldReconnect) {
1183
- try {
1184
- await this.reconnectFirmwareUploadTransport(uuid, transport);
1185
- } catch (e) {
1186
- Log?.debug('[ReactNativeBleTransport] FirmwareUpload reconnect error:', e);
1187
- if (attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1188
- throw e;
1189
- }
1190
- }
1191
- }
1192
1516
  }
1193
1517
  }
1194
1518
  },
1195
1519
  e => {
1196
- this.runPromise = null;
1520
+ releaseOwnershipIfCurrent();
1197
1521
  Log?.error('writeCharacteristic write error: ', e);
1198
1522
  }
1199
1523
  );
@@ -1202,10 +1526,23 @@ export default class ReactNativeBleTransport {
1202
1526
  const outData = o.toString('base64');
1203
1527
  // Upload resources on low-end phones may OOM
1204
1528
  try {
1205
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1529
+ const shouldUseWriteWithResponse =
1530
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1531
+ await this.writeBlePacket(
1532
+ uuid,
1533
+ outData,
1534
+ payload =>
1535
+ shouldUseWriteWithResponse
1536
+ ? transport.writeCharacteristic.writeWithResponse(payload)
1537
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
1538
+ isCurrentOwner
1539
+ );
1206
1540
  } catch (e) {
1207
1541
  Log?.debug('writeCharacteristic write error: ', e);
1208
- this.runPromise = null;
1542
+ releaseOwnershipIfCurrent();
1543
+ if (isWedgedWriteError(e)) {
1544
+ throw e;
1545
+ }
1209
1546
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1210
1547
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1211
1548
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1241,16 +1578,30 @@ export default class ReactNativeBleTransport {
1241
1578
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1242
1579
  return check.call(jsonData);
1243
1580
  } catch (e) {
1244
- if (name === 'Initialize' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1245
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe call failed:', e);
1581
+ if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1582
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1246
1583
  } else {
1247
1584
  Log?.error('call error: ', e);
1248
1585
  }
1586
+ const isProbeTimeout =
1587
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1588
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1589
+ // transport; its late timeout must not tear down the connection the current
1590
+ // call is actively using.
1591
+ const isStaleCall = this.runPromise !== runPromise;
1592
+ if (
1593
+ !isProbeTimeout &&
1594
+ !isStaleCall &&
1595
+ (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1596
+ ) {
1597
+ await this.disconnect(uuid);
1598
+ }
1249
1599
  throw e;
1250
1600
  } finally {
1251
1601
  if (timeout) clearTimeout(timeout);
1252
1602
  if (this.runPromise === runPromise) {
1253
1603
  this.runPromise = null;
1604
+ this.runPromiseDeviceId = null;
1254
1605
  }
1255
1606
  }
1256
1607
  }
@@ -1260,8 +1611,14 @@ export default class ReactNativeBleTransport {
1260
1611
  }
1261
1612
 
1262
1613
  async disconnect(session: string) {
1614
+ return this.runLifecycleOperation(session, () => this.disconnectUnlocked(session));
1615
+ }
1616
+
1617
+ private async disconnectUnlocked(session: string) {
1263
1618
  await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1264
1619
  const transport = transportCache[session];
1620
+ const manager = this.blePlxManager;
1621
+ const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1265
1622
 
1266
1623
  // Clean up disconnect subscription first to prevent onDisconnected callback
1267
1624
  // from being triggered when we cancel the connection below
@@ -1289,60 +1646,253 @@ export default class ReactNativeBleTransport {
1289
1646
  }
1290
1647
  }
1291
1648
 
1292
- // cancel the ble transaction
1293
- if (session) {
1294
- try {
1295
- await this.blePlxManager?.cancelTransaction(session);
1296
- } catch (e) {
1297
- Log?.debug('resetSession: cancel transaction error (ignored): ', e?.message || e);
1298
- }
1299
- }
1300
-
1301
- // disconnect the device via the device object
1302
- if (transport?.device) {
1303
- try {
1304
- await transport.device.cancelConnection();
1305
- } catch (e) {
1306
- Log?.debug('resetSession: device.cancelConnection error (ignored): ', e?.message || e);
1307
- }
1308
- }
1309
-
1310
- // disconnect the device via the ble manager
1311
- try {
1312
- await this.blePlxManager?.cancelDeviceConnection(session);
1313
- } catch (e) {
1314
- Log?.debug('resetSession: manager.cancelDeviceConnection error (ignored): ', e?.message || e);
1315
- }
1316
-
1317
1649
  // clear the transport cache
1318
- if (transportCache[session]) {
1650
+ if (!transport || transportCache[session] === transport) {
1319
1651
  delete transportCache[session];
1320
1652
  }
1321
1653
  this.deviceProtocol.delete(session);
1654
+ this.probingProtocols.delete(session);
1322
1655
  this.deviceProtocolHints.delete(session);
1656
+ this.sessionProtocols.delete(session);
1657
+ this.protocolReprobeFailures.delete(session);
1323
1658
  this.protocolV2Assemblers.delete(session);
1324
1659
  this.resetProtocolV2Frames(session);
1325
1660
 
1326
1661
  // emit the disconnect event
1327
1662
  try {
1328
- this.emitter?.emit('device-disconnect', {
1329
- name: transport?.device?.name,
1330
- id: session,
1331
- connectId: session,
1332
- });
1663
+ this.emitDeviceDisconnect(session, transport?.device?.name, monitorToken);
1333
1664
  } catch (e) {
1334
1665
  Log?.error('resetSession: emit disconnect event error: ', e);
1335
1666
  }
1667
+ if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1668
+ this.monitorTokens.delete(session);
1669
+ }
1670
+
1671
+ await this.runNativeTeardown(session, manager, async () => {
1672
+ const operations: Promise<unknown>[] = [];
1673
+ if (manager) {
1674
+ operations.push(
1675
+ this.runBestEffortNativeOperation('disconnect: cancel transaction', () =>
1676
+ manager.cancelTransaction(session)
1677
+ )
1678
+ );
1679
+ operations.push(
1680
+ this.runBestEffortNativeOperation('disconnect: cancel device connection', () =>
1681
+ manager.cancelDeviceConnection(session)
1682
+ )
1683
+ );
1684
+ }
1685
+ if (transport?.device) {
1686
+ operations.push(
1687
+ this.runBestEffortNativeOperation('disconnect: device cancel connection', () =>
1688
+ transport.device.cancelConnection()
1689
+ )
1690
+ );
1691
+ }
1692
+ await Promise.all(operations);
1693
+ });
1694
+
1336
1695
  // eslint-disable-next-line no-promise-executor-return
1337
1696
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1338
1697
  }
1339
1698
 
1699
+ private async runNativeTeardown(
1700
+ uuid: string,
1701
+ manager: BlePlxManager | undefined,
1702
+ teardown: () => Promise<void>
1703
+ ) {
1704
+ let timer: ReturnType<typeof setTimeout> | undefined;
1705
+ let timedOut = false;
1706
+ const pending = Promise.resolve()
1707
+ .then(teardown)
1708
+ .catch(error => {
1709
+ Log?.debug('BLE native teardown error (ignored): ', error?.message || error);
1710
+ });
1711
+ try {
1712
+ await Promise.race([
1713
+ pending,
1714
+ new Promise<void>(resolve => {
1715
+ timer = setTimeout(() => {
1716
+ timedOut = true;
1717
+ resolve();
1718
+ }, BLE_NATIVE_TEARDOWN_TIMEOUT_MS);
1719
+ }),
1720
+ ]);
1721
+ } finally {
1722
+ if (timer) clearTimeout(timer);
1723
+ }
1724
+
1725
+ if (timedOut) {
1726
+ Log?.error('[ReactNativeBleTransport] BLE native teardown timed out:', uuid);
1727
+ if (this.blePlxManager === manager) {
1728
+ this.resetPlxManager();
1729
+ }
1730
+ }
1731
+ }
1732
+
1733
+ private runBestEffortNativeOperation(label: string, operation: () => Promise<unknown>) {
1734
+ return Promise.resolve()
1735
+ .then(operation)
1736
+ .catch(error => {
1737
+ Log?.debug(`${label} error (ignored): `, error?.message || error);
1738
+ });
1739
+ }
1740
+
1741
+ private async runLifecycleOperation<T>(uuid: string, operation: () => Promise<T>): Promise<T> {
1742
+ const previousOperation = this.lifecycleOperations.get(uuid) ?? Promise.resolve();
1743
+ let completeOperation!: () => void;
1744
+ const operationGate = new Promise<void>(resolve => {
1745
+ completeOperation = resolve;
1746
+ });
1747
+ const operationTail = previousOperation.catch(() => undefined).then(() => operationGate);
1748
+ this.lifecycleOperations.set(uuid, operationTail);
1749
+
1750
+ await previousOperation.catch(() => undefined);
1751
+ try {
1752
+ return await operation();
1753
+ } finally {
1754
+ completeOperation();
1755
+ if (this.lifecycleOperations.get(uuid) === operationTail) {
1756
+ this.lifecycleOperations.delete(uuid);
1757
+ }
1758
+ }
1759
+ }
1760
+
1340
1761
  cancel() {
1341
1762
  Log?.debug('transport-react-native transport cancel');
1342
1763
  if (this.runPromise) {
1343
1764
  // this.runPromise.reject(new Error('Transport_CallCanceled'));
1344
1765
  }
1345
1766
  this.runPromise = null;
1767
+ this.runPromiseDeviceId = null;
1768
+ }
1769
+
1770
+ /** Run a native connect under the JS backstop budget. */
1771
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1772
+ let timer: ReturnType<typeof setTimeout> | undefined;
1773
+ let timedOut = false;
1774
+ const pending = connect();
1775
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1776
+ // surface as an unhandled rejection after we have already given up on it.
1777
+ pending.catch(() => undefined);
1778
+ try {
1779
+ const result = await Promise.race([
1780
+ pending,
1781
+ new Promise<never>((_, reject) => {
1782
+ timer = setTimeout(() => {
1783
+ timedOut = true;
1784
+ reject(
1785
+ ERRORS.TypedError(
1786
+ HardwareErrorCode.BleConnectedError,
1787
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1788
+ )
1789
+ );
1790
+ }, BLE_CONNECT_TIMEOUT_MS);
1791
+ }),
1792
+ ]);
1793
+ return result;
1794
+ } catch (error) {
1795
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1796
+ const resetManager = this.abandonStalledConnection(
1797
+ uuid,
1798
+ timedOut ? 'connect-backstop' : 'connect-native'
1799
+ );
1800
+ if (resetManager) {
1801
+ throw this.createWedgedBleSetupError();
1802
+ }
1803
+ }
1804
+ throw error;
1805
+ } finally {
1806
+ if (timer) clearTimeout(timer);
1807
+ }
1808
+ }
1809
+
1810
+ /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1811
+ private async resolveCharacteristicsWithTimeout(
1812
+ uuid: string,
1813
+ device: Device
1814
+ ): Promise<ResolvedBleCharacteristics> {
1815
+ let timer: ReturnType<typeof setTimeout> | undefined;
1816
+ let timedOut = false;
1817
+ const pending = this.resolveCharacteristics(device);
1818
+ pending.catch(() => undefined);
1819
+ try {
1820
+ const result = await Promise.race([
1821
+ pending,
1822
+ new Promise<never>((_, reject) => {
1823
+ timer = setTimeout(() => {
1824
+ timedOut = true;
1825
+ reject(
1826
+ ERRORS.TypedError(
1827
+ HardwareErrorCode.BleConnectedError,
1828
+ `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1829
+ )
1830
+ );
1831
+ }, BLE_GATT_SETUP_TIMEOUT_MS);
1832
+ }),
1833
+ ]);
1834
+ this.connectionSetupTimeoutCounts.delete(uuid);
1835
+ return result;
1836
+ } catch (error) {
1837
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1838
+ const resetManager = this.abandonStalledConnection(
1839
+ uuid,
1840
+ timedOut ? 'gatt-backstop' : 'gatt-native'
1841
+ );
1842
+ if (resetManager) {
1843
+ throw this.createWedgedBleSetupError();
1844
+ }
1845
+ }
1846
+ throw error;
1847
+ } finally {
1848
+ if (timer) clearTimeout(timer);
1849
+ }
1850
+ }
1851
+
1852
+ /**
1853
+ * Give up on a BLE setup operation the native layer did not settle. The abandoned
1854
+ * operation still owns native connection/GATT state that can poison the next attempt,
1855
+ * so it is cleared here without awaiting the same queue that stopped responding.
1856
+ * Returns true when the manager itself was reset so the caller can stop Core retries.
1857
+ */
1858
+ private abandonStalledConnection(
1859
+ uuid: string,
1860
+ stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1861
+ ): boolean {
1862
+ const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1863
+ this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1864
+ Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1865
+ stage,
1866
+ setupTimeoutsSinceSuccess: timeouts,
1867
+ });
1868
+
1869
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1870
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1871
+ });
1872
+ const stalled = transportCache[uuid];
1873
+ if (stalled) {
1874
+ delete transportCache[uuid];
1875
+ }
1876
+ this.deviceProtocol.delete(uuid);
1877
+ this.probingProtocols.delete(uuid);
1878
+ this.protocolV2Assemblers.delete(uuid);
1879
+ this.resetProtocolV2Frames(uuid);
1880
+
1881
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1882
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1883
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1884
+ Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1885
+ this.resetPlxManager();
1886
+ this.connectionSetupTimeoutCounts.delete(uuid);
1887
+ return true;
1888
+ }
1889
+ return false;
1890
+ }
1891
+
1892
+ private createWedgedBleSetupError() {
1893
+ // PollingTimeout is not retried by connectDeviceForBle and already maps
1894
+ // to the App "connection failed" help text.
1895
+ return ERRORS.TypedError(HardwareErrorCode.PollingTimeout, BLE_SETUP_WEDGED_MESSAGE);
1346
1896
  }
1347
1897
 
1348
1898
  private getCachedTransport(uuid: string) {
@@ -1353,9 +1903,144 @@ export default class ReactNativeBleTransport {
1353
1903
  return transport;
1354
1904
  }
1355
1905
 
1356
- private createProtocolMismatchError(expected: ProtocolType) {
1906
+ /**
1907
+ * Write one packet under a bounded budget. A write that never settles means the
1908
+ * peripheral is wedged even though the GATT link still reports connected, so the
1909
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1910
+ * cached and every later call would hang on it again.
1911
+ */
1912
+ private async writeBlePacket(
1913
+ uuid: string,
1914
+ data: string,
1915
+ write: (payload: string) => Promise<unknown>,
1916
+ isCurrentOwner?: () => boolean
1917
+ ) {
1918
+ let timer: ReturnType<typeof setTimeout> | undefined;
1919
+ let timedOut = false;
1920
+ try {
1921
+ await Promise.race([
1922
+ write(data),
1923
+ new Promise<never>((_, reject) => {
1924
+ timer = setTimeout(() => {
1925
+ timedOut = true;
1926
+ reject(
1927
+ ERRORS.TypedError(
1928
+ HardwareErrorCode.BleWriteCharacteristicError,
1929
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1930
+ )
1931
+ );
1932
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1933
+ }),
1934
+ ]);
1935
+ this.writeTimeoutCounts.delete(uuid);
1936
+ } catch (error) {
1937
+ if (timedOut) {
1938
+ // A superseded call's late write must not tear down the link the current
1939
+ // call is using; only the owner of the transport may declare it dead.
1940
+ if (isCurrentOwner && !isCurrentOwner()) {
1941
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1942
+ } else {
1943
+ this.tearDownWedgedLink(uuid);
1944
+ }
1945
+ }
1946
+ throw error;
1947
+ } finally {
1948
+ if (timer) clearTimeout(timer);
1949
+ }
1950
+ }
1951
+
1952
+ /**
1953
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1954
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1955
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1956
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1957
+ */
1958
+ private tearDownWedgedLink(uuid: string) {
1959
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1960
+ this.writeTimeoutCounts.set(uuid, timeouts);
1961
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1962
+ consecutiveWriteTimeouts: timeouts,
1963
+ });
1964
+
1965
+ const wedged = transportCache[uuid];
1966
+ this.disconnect(uuid).catch(error => {
1967
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1968
+ });
1969
+ if (wedged && transportCache[uuid] === wedged) {
1970
+ delete transportCache[uuid];
1971
+ }
1972
+ this.deviceProtocol.delete(uuid);
1973
+ this.probingProtocols.delete(uuid);
1974
+ this.protocolV2Assemblers.delete(uuid);
1975
+ this.resetProtocolV2Frames(uuid);
1976
+
1977
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1978
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1979
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1980
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1981
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1982
+ this.resetPlxManager();
1983
+ this.writeTimeoutCounts.delete(uuid);
1984
+ }
1985
+ }
1986
+
1987
+ private resetPlxManager() {
1988
+ const manager = this.blePlxManager;
1989
+ this.blePlxManager = undefined;
1990
+ const reason = 'React Native BLE manager reset';
1991
+ // Destroying the shared manager invalidates every peripheral it owns. Notify
1992
+ // each cached session before clearing generations so Core cannot retain a
1993
+ // silently stale connection for an unrelated device.
1994
+ Object.entries(transportCache).forEach(([uuid, cachedTransport]) => {
1995
+ try {
1996
+ cachedTransport.disconnectSubscription?.remove();
1997
+ } catch (error) {
1998
+ Log?.debug('BLE manager reset disconnect subscription removal failed:', error);
1999
+ }
2000
+ cachedTransport.disconnectSubscription = undefined;
2001
+ try {
2002
+ cachedTransport.notifySubscription?.remove();
2003
+ } catch (error) {
2004
+ Log?.debug('BLE manager reset notify subscription removal failed:', error);
2005
+ }
2006
+ cachedTransport.notifySubscription = undefined;
2007
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
2008
+ try {
2009
+ this.emitDeviceDisconnect(
2010
+ uuid,
2011
+ cachedTransport.device?.name,
2012
+ cachedTransport.monitorToken ?? this.monitorTokens.get(uuid)
2013
+ );
2014
+ } catch (error) {
2015
+ Log?.debug('BLE manager reset disconnect event failed:', error);
2016
+ }
2017
+ delete transportCache[uuid];
2018
+ });
2019
+ this.protocolV2Links.invalidateAllLinks(reason).catch(error => {
2020
+ Log?.debug('[ReactNativeBleTransport] BLE manager link invalidation failed:', error);
2021
+ });
2022
+ this.deviceProtocol.clear();
2023
+ this.probingProtocols.clear();
2024
+ this.sessionProtocols.clear();
2025
+ this.confirmedProtocolV2.clear();
2026
+ this.protocolReprobeFailures.clear();
2027
+ this.writeTimeoutCounts.clear();
2028
+ this.connectionSetupTimeoutCounts.clear();
2029
+ this.monitorTokens.clear();
2030
+ this.protocolV2Assemblers.clear();
2031
+ try {
2032
+ manager?.destroy();
2033
+ } catch (error) {
2034
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
2035
+ }
2036
+ }
2037
+
2038
+ private createProtocolMismatchError(expected: ProtocolType, uuid: string) {
2039
+ // A generic Ping miss is not a bond failure. Only a later miss after this
2040
+ // endpoint already answered V2, or a native encryption/pairing error, is.
2041
+ const isStaleV2Bond = expected === 'V2' && this.confirmedProtocolV2.has(uuid);
1357
2042
  return ERRORS.TypedError(
1358
- HardwareErrorCode.RuntimeError,
2043
+ isStaleV2Bond ? HardwareErrorCode.BleDeviceBondError : HardwareErrorCode.RuntimeError,
1359
2044
  `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
1360
2045
  );
1361
2046
  }
@@ -1363,24 +2048,47 @@ export default class ReactNativeBleTransport {
1363
2048
  private createProtocolDetectionError() {
1364
2049
  return ERRORS.TypedError(
1365
2050
  HardwareErrorCode.BleTimeoutError,
1366
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
2051
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
1367
2052
  );
1368
2053
  }
1369
2054
 
1370
2055
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
2056
+ if (this.probingProtocols.get(uuid) === protocol) {
2057
+ this.probingProtocols.delete(uuid);
2058
+ }
1371
2059
  if (this.deviceProtocol.get(uuid) === protocol) {
1372
2060
  this.deviceProtocol.delete(uuid);
1373
2061
  }
1374
2062
  }
1375
2063
 
2064
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
2065
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
2066
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
2067
+ }
2068
+
1376
2069
  private async detectProtocol(
1377
2070
  uuid: string,
1378
2071
  expectedProtocol?: ProtocolType,
1379
- protocolHint?: ProtocolType
2072
+ protocolHint?: ProtocolType,
2073
+ rebuildTransport?: () => Promise<void>
1380
2074
  ): Promise<ProtocolType> {
2075
+ // iOS still skips an extra V1 Initialize during acquire. Expected V2 must
2076
+ // Ping so USB-priority `link disabled` can surface instead of a later
2077
+ // unmapped RuntimeError.
2078
+ if (Platform.OS === 'ios' && expectedProtocol === 'V1') {
2079
+ this.deviceProtocol.set(uuid, expectedProtocol);
2080
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
2081
+ deviceId: uuid,
2082
+ protocol: expectedProtocol,
2083
+ source: 'expected',
2084
+ });
2085
+ return expectedProtocol;
2086
+ }
2087
+
1381
2088
  if (expectedProtocol === 'V1') {
1382
2089
  if (await this.probeProtocolV1(uuid)) {
1383
2090
  this.deviceProtocol.set(uuid, 'V1');
2091
+ this.sessionProtocols.set(uuid, 'V1');
1384
2092
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1385
2093
  deviceId: uuid,
1386
2094
  protocol: 'V1',
@@ -1388,37 +2096,60 @@ export default class ReactNativeBleTransport {
1388
2096
  });
1389
2097
  return 'V1';
1390
2098
  }
1391
- throw this.createProtocolMismatchError(expectedProtocol);
2099
+ throw this.createProtocolMismatchError(expectedProtocol, uuid);
1392
2100
  }
1393
2101
 
1394
2102
  if (expectedProtocol === 'V2') {
1395
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
1396
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
1397
- this.deviceProtocol.set(uuid, 'V2');
1398
- Log?.debug('[ReactNativeBleTransport] protocol detected', {
1399
- deviceId: uuid,
1400
- protocol: 'V2',
1401
- source: 'expected',
1402
- });
1403
- return 'V2';
2103
+ if (await this.probeProtocolV2(uuid)) {
2104
+ this.deviceProtocol.set(uuid, 'V2');
2105
+ this.sessionProtocols.set(uuid, 'V2');
2106
+ this.confirmedProtocolV2.add(uuid);
2107
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
2108
+ deviceId: uuid,
2109
+ protocol: 'V2',
2110
+ source: 'expected',
2111
+ });
2112
+ return 'V2';
2113
+ }
2114
+ throw this.createProtocolMismatchError(expectedProtocol, uuid);
1404
2115
  }
1405
2116
 
1406
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
1407
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1
1408
- // 不能作为最终结论。
1409
- const probeOrder: ProtocolType[] =
2117
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
2118
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
2119
+ const sessionProtocol = this.sessionProtocols.get(uuid);
2120
+ const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
2121
+ const fullProbeOrder: ProtocolType[] =
1410
2122
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
2123
+ // A device that already answered on a protocol in this session keeps answering on
2124
+ // it; while it is rebooting nothing answers at all, so probing the other protocol
2125
+ // only adds its timeout to every poll.
2126
+ const trustSessionProtocol =
2127
+ sessionProtocol !== undefined &&
2128
+ !protocolHint &&
2129
+ reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
2130
+ const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1411
2131
 
1412
2132
  for (let i = 0; i < probeOrder.length; i += 1) {
1413
2133
  const protocol = probeOrder[i];
1414
2134
  if (i > 0) {
1415
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
2135
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
1416
2136
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
2137
+ if (!transportCache[uuid]) {
2138
+ if (!rebuildTransport) {
2139
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
2140
+ }
2141
+ await rebuildTransport();
2142
+ }
1417
2143
  }
1418
2144
  const detected =
1419
2145
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1420
2146
  if (detected) {
1421
2147
  this.deviceProtocol.set(uuid, protocol);
2148
+ this.sessionProtocols.set(uuid, protocol);
2149
+ if (protocol === 'V2') {
2150
+ this.confirmedProtocolV2.add(uuid);
2151
+ }
2152
+ this.protocolReprobeFailures.delete(uuid);
1422
2153
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1423
2154
  deviceId: uuid,
1424
2155
  protocol,
@@ -1428,7 +2159,16 @@ export default class ReactNativeBleTransport {
1428
2159
  }
1429
2160
  }
1430
2161
 
2162
+ if (trustSessionProtocol) {
2163
+ // Still silent on its own protocol: count it, and let the streak expire the
2164
+ // shortcut so a device that genuinely switched protocols is found again.
2165
+ this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
2166
+ } else {
2167
+ this.protocolReprobeFailures.delete(uuid);
2168
+ }
2169
+
1431
2170
  this.deviceProtocol.delete(uuid);
2171
+ this.probingProtocols.delete(uuid);
1432
2172
  throw this.createProtocolDetectionError();
1433
2173
  }
1434
2174
 
@@ -1490,12 +2230,20 @@ export default class ReactNativeBleTransport {
1490
2230
  }
1491
2231
 
1492
2232
  try {
1493
- this.deviceProtocol.set(uuid, 'V1');
1494
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2233
+ this.probingProtocols.set(uuid, 'V1');
2234
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
2235
+ // session before Core has a chance to restore a hidden wallet.
2236
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2237
+ this.probingProtocols.delete(uuid);
1495
2238
  return true;
1496
2239
  } catch (error) {
1497
2240
  this.clearProbeProtocol(uuid, 'V1');
1498
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe failed:', error);
2241
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2242
+ // A wedged write already dropped the link, so probing another protocol on it
2243
+ // would only fail against a torn-down transport: surface the real cause.
2244
+ if (isWedgedWriteError(error)) {
2245
+ throw error;
2246
+ }
1499
2247
  return false;
1500
2248
  }
1501
2249
  }
@@ -1505,7 +2253,7 @@ export default class ReactNativeBleTransport {
1505
2253
  return false;
1506
2254
  }
1507
2255
 
1508
- this.deviceProtocol.set(uuid, 'V2');
2256
+ this.probingProtocols.set(uuid, 'V2');
1509
2257
  this.protocolV2Assemblers.get(uuid)?.reset();
1510
2258
  const detected = await probeProtocolV2Helper({
1511
2259
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1520,6 +2268,8 @@ export default class ReactNativeBleTransport {
1520
2268
  });
1521
2269
  if (!detected) {
1522
2270
  this.clearProbeProtocol(uuid, 'V2');
2271
+ } else {
2272
+ this.probingProtocols.delete(uuid);
1523
2273
  }
1524
2274
  return detected;
1525
2275
  }
@@ -1570,17 +2320,8 @@ export default class ReactNativeBleTransport {
1570
2320
  this.getProtocolV2FrameQueue(uuid).push(frame);
1571
2321
  }
1572
2322
 
1573
- private rejectAllProtocolV2Frames(error: Error) {
1574
- this.protocolV2FrameQueues.clear();
1575
- for (const framePromise of this.protocolV2FramePromises.values()) {
1576
- framePromise.reject(error);
1577
- }
1578
- this.protocolV2FramePromises.clear();
1579
- }
1580
-
1581
2323
  private resetProtocolV2Frames(uuid: string) {
1582
- this.protocolV2FrameQueues.delete(uuid);
1583
- this.protocolV2FramePromises.delete(uuid);
2324
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
1584
2325
  }
1585
2326
 
1586
2327
  private rejectProtocolV2Frames(uuid: string, error: Error) {
@@ -1609,19 +2350,95 @@ export default class ReactNativeBleTransport {
1609
2350
  }
1610
2351
  }
1611
2352
 
1612
- private async writeProtocolV2Frame(transport: BleTransport, frame: Uint8Array) {
2353
+ private async writeProtocolV2Packet(
2354
+ uuid: string,
2355
+ transport: BleTransport,
2356
+ base64: string,
2357
+ context: ProtocolV2CallContext,
2358
+ assertCurrentGeneration: () => void
2359
+ ) {
2360
+ const shouldUseWriteWithResponse = shouldWriteProtocolV2WithResponse({
2361
+ platform: Platform.OS,
2362
+ highThroughput: context.highThroughput,
2363
+ requestedWithResponse: context.writeWithResponse,
2364
+ characteristic: transport.writeCharacteristic,
2365
+ });
2366
+ let attempt = 0;
2367
+ for (;;) {
2368
+ assertCurrentGeneration();
2369
+ if (context.signal.aborted) {
2370
+ throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
2371
+ }
2372
+ try {
2373
+ await this.writeBlePacket(
2374
+ uuid,
2375
+ base64,
2376
+ payload =>
2377
+ shouldUseWriteWithResponse
2378
+ ? transport.writeCharacteristic.writeWithResponse(payload)
2379
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
2380
+ // Same rule as Protocol V1: a write from a superseded generation must not
2381
+ // tear down the link that the current generation is using.
2382
+ () => {
2383
+ try {
2384
+ assertCurrentGeneration();
2385
+ return !context.signal.aborted;
2386
+ } catch {
2387
+ return false;
2388
+ }
2389
+ }
2390
+ );
2391
+ assertCurrentGeneration();
2392
+ return;
2393
+ } catch (error) {
2394
+ if (
2395
+ getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2396
+ attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
2397
+ ) {
2398
+ throw error;
2399
+ }
2400
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
2401
+ attempt += 1;
2402
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 congested write retry:', {
2403
+ name: context.messageName,
2404
+ attempt,
2405
+ delayMs,
2406
+ });
2407
+ await delay(delayMs);
2408
+ }
2409
+ }
2410
+ }
2411
+
2412
+ private async writeProtocolV2Frame(
2413
+ uuid: string,
2414
+ transport: BleTransport,
2415
+ frame: Uint8Array,
2416
+ context: ProtocolV2CallContext,
2417
+ assertCurrentGeneration: () => void
2418
+ ) {
1613
2419
  const tuning = getProtocolV2BleTuning();
1614
2420
  const packetCapacity = resolveProtocolV2PacketCapacity({
1615
2421
  platform: Platform.OS,
1616
2422
  iosPacketLength: tuning.iosPacketLength,
1617
2423
  androidPacketLength: tuning.androidPacketLength,
1618
- mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
2424
+ mtu: transport.mtuSize,
2425
+ });
2426
+ await writeProtocolV2BleFrame({
2427
+ frame,
2428
+ packetCapacity,
2429
+ assertActive: assertCurrentGeneration,
2430
+ signal: context.signal,
2431
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
2432
+ wait: delay,
2433
+ writePacket: packet =>
2434
+ this.writeProtocolV2Packet(
2435
+ uuid,
2436
+ transport,
2437
+ Buffer.from(packet).toString('base64'),
2438
+ context,
2439
+ assertCurrentGeneration
2440
+ ),
1619
2441
  });
1620
- for (let offset = 0; offset < frame.length; offset += packetCapacity) {
1621
- const chunk = frame.slice(offset, offset + packetCapacity);
1622
- const base64 = Buffer.from(chunk).toString('base64');
1623
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1624
- }
1625
2442
  }
1626
2443
 
1627
2444
  private async callProtocolV2(
@@ -1634,19 +2451,45 @@ export default class ReactNativeBleTransport {
1634
2451
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1635
2452
  }
1636
2453
 
1637
- const callOptions = {
1638
- ...options,
1639
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
1640
- };
1641
- const highVolumeWrite = LogBlockCommand.has(name);
2454
+ const callOptions = options;
2455
+ const highThroughputWrite = isProtocolV2HighThroughputCall(name);
1642
2456
 
1643
- if (highVolumeWrite) {
2457
+ if (highThroughputWrite) {
2458
+ await this.ensureProtocolV2HighThroughputMtu(uuid);
1644
2459
  const tuning = getProtocolV2BleTuning();
1645
- Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1646
- name,
1647
- writeMode: 'withoutResponse',
1648
- packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
2460
+ const currentTransport = this.getCachedTransport(uuid);
2461
+ const writeWithResponse = shouldWriteProtocolV2WithResponse({
2462
+ platform: Platform.OS,
2463
+ highThroughput: true,
2464
+ requestedWithResponse: options?.writeWithResponse,
2465
+ characteristic: currentTransport.writeCharacteristic,
1649
2466
  });
2467
+ const packetCapacity = resolveProtocolV2PacketCapacity({
2468
+ platform: Platform.OS,
2469
+ iosPacketLength: tuning.iosPacketLength,
2470
+ androidPacketLength: tuning.androidPacketLength,
2471
+ mtu: currentTransport.mtuSize,
2472
+ });
2473
+ const writeMode = writeWithResponse ? 'withResponse' : 'withoutResponse';
2474
+ const logSignature = `${name}:${writeMode}:${String(
2475
+ currentTransport.mtuSize
2476
+ )}:${packetCapacity}`;
2477
+ const loggedSignatures =
2478
+ this.protocolV2HighVolumeLogSignatures.get(uuid) ?? new Set<string>();
2479
+ if (!loggedSignatures.has(logSignature)) {
2480
+ loggedSignatures.add(logSignature);
2481
+ this.protocolV2HighVolumeLogSignatures.set(uuid, loggedSignatures);
2482
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2483
+ name,
2484
+ writeMode,
2485
+ reportedMtu: currentTransport.mtuSize,
2486
+ packetCapacity,
2487
+ });
2488
+ }
2489
+ }
2490
+
2491
+ if (highThroughputWrite) {
2492
+ await this.enableAndroidHighConnectionPriority(uuid);
1650
2493
  }
1651
2494
 
1652
2495
  try {
@@ -1660,6 +2503,90 @@ export default class ReactNativeBleTransport {
1660
2503
  } catch (e) {
1661
2504
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1662
2505
  throw e;
2506
+ } finally {
2507
+ if (highThroughputWrite) {
2508
+ this.scheduleAndroidBalancedConnectionPriority(uuid);
2509
+ }
2510
+ }
2511
+ }
2512
+
2513
+ private async ensureProtocolV2HighThroughputMtu(uuid: string) {
2514
+ const transport = this.getCachedTransport(uuid);
2515
+ if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2516
+
2517
+ const refreshedDevice = await requestNegotiatedMtu(transport.device, 'highThroughput', 1);
2518
+ transport.device = refreshedDevice;
2519
+ transport.mtuSize =
2520
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
2521
+
2522
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
2523
+ throw ERRORS.TypedError(
2524
+ HardwareErrorCode.BleConnectedError,
2525
+ `Protocol V2 high-throughput BLE MTU unavailable: ${String(transport.mtuSize)}`
2526
+ );
2527
+ }
2528
+ }
2529
+
2530
+ private clearAndroidPriorityResetTimer(uuid: string) {
2531
+ const timerId = this.androidPriorityResetTimers.get(uuid);
2532
+ if (timerId !== undefined) {
2533
+ clearTimeout(timerId);
2534
+ this.androidPriorityResetTimers.delete(uuid);
2535
+ }
2536
+ }
2537
+
2538
+ private async enableAndroidHighConnectionPriority(uuid: string) {
2539
+ if (Platform.OS !== 'android') return;
2540
+
2541
+ this.clearAndroidPriorityResetTimer(uuid);
2542
+ if (this.androidHighPriorityDevices.has(uuid)) return;
2543
+
2544
+ const transport = transportCache[uuid];
2545
+ if (!transport) return;
2546
+
2547
+ try {
2548
+ transport.device = await transport.device.requestConnectionPriority(ConnectionPriority.High);
2549
+ this.androidHighPriorityDevices.add(uuid);
2550
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2551
+ priority: 'high',
2552
+ });
2553
+ } catch (error) {
2554
+ Log?.debug('[ReactNativeBleTransport] Android BLE high priority request failed', {
2555
+ error: error instanceof Error ? error.message : String(error),
2556
+ });
2557
+ }
2558
+ }
2559
+
2560
+ private scheduleAndroidBalancedConnectionPriority(uuid: string) {
2561
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.has(uuid)) return;
2562
+
2563
+ this.clearAndroidPriorityResetTimer(uuid);
2564
+ const timerId = setTimeout(() => {
2565
+ this.androidPriorityResetTimers.delete(uuid);
2566
+ this.restoreAndroidConnectionPriority(uuid, transportCache[uuid]).catch(error =>
2567
+ Log?.debug('[ReactNativeBleTransport] Android BLE priority restore failed', error)
2568
+ );
2569
+ }, ANDROID_HIGH_PRIORITY_IDLE_MS);
2570
+ this.androidPriorityResetTimers.set(uuid, timerId);
2571
+ }
2572
+
2573
+ private async restoreAndroidConnectionPriority(uuid: string, transport?: BleTransport) {
2574
+ this.clearAndroidPriorityResetTimer(uuid);
2575
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.delete(uuid) || !transport) {
2576
+ return;
2577
+ }
2578
+
2579
+ try {
2580
+ transport.device = await transport.device.requestConnectionPriority(
2581
+ ConnectionPriority.Balanced
2582
+ );
2583
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2584
+ priority: 'balanced',
2585
+ });
2586
+ } catch (error) {
2587
+ Log?.debug('[ReactNativeBleTransport] Android BLE balanced priority request failed', {
2588
+ error: error instanceof Error ? error.message : String(error),
2589
+ });
1663
2590
  }
1664
2591
  }
1665
2592
 
@@ -1680,10 +2607,16 @@ export default class ReactNativeBleTransport {
1680
2607
  this.protocolV2Assemblers.get(uuid)?.reset();
1681
2608
  this.resetProtocolV2Frames(uuid);
1682
2609
  },
1683
- writeFrame: async (frame: Uint8Array) => {
2610
+ writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1684
2611
  assertCurrentGeneration();
1685
2612
  const currentTransport = this.getCachedTransport(uuid);
1686
- await this.writeProtocolV2Frame(currentTransport, frame);
2613
+ await this.writeProtocolV2Frame(
2614
+ uuid,
2615
+ currentTransport,
2616
+ frame,
2617
+ context,
2618
+ assertCurrentGeneration
2619
+ );
1687
2620
  },
1688
2621
  readFrame: async () => {
1689
2622
  assertCurrentGeneration();
@@ -1694,6 +2627,7 @@ export default class ReactNativeBleTransport {
1694
2627
  return rxFrame;
1695
2628
  },
1696
2629
  reset: (reason: string) => {
2630
+ if (this.monitorTokens.get(uuid) !== generation) return;
1697
2631
  this.protocolV2Assemblers.get(uuid)?.reset();
1698
2632
  this.rejectProtocolV2Frames(uuid, new Error(reason));
1699
2633
  },
@@ -1708,6 +2642,6 @@ export default class ReactNativeBleTransport {
1708
2642
  }
1709
2643
 
1710
2644
  getProtocolType(path: string): ProtocolType | undefined {
1711
- return this.deviceProtocol.get(path);
2645
+ return this.getActiveProtocol(path);
1712
2646
  }
1713
2647
  }