@onekeyfe/hd-transport-react-native 1.2.0-alpha.14 → 1.2.0-alpha.140

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,7 +52,6 @@ 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';
@@ -53,24 +67,52 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
53
67
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
54
68
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
55
69
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
56
- const FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS = 2000;
57
70
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
58
71
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
59
72
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
60
73
  const ANDROID_GATT_CONGESTED_STATUS = 143;
61
74
 
62
- type FirmwareUploadWriteRetryType = 'congested' | 'reconnectable';
75
+ type FirmwareUploadWriteRetryType = 'congested';
63
76
  type ResolvedBleCharacteristics = {
64
77
  writeCharacteristic: Characteristic;
65
78
  notifyCharacteristic: Characteristic;
66
79
  };
67
80
 
81
+ const isAsciiWhitespace = (code: number) =>
82
+ code === 0x09 ||
83
+ code === 0x0a ||
84
+ code === 0x0b ||
85
+ code === 0x0c ||
86
+ code === 0x0d ||
87
+ code === 0x20;
88
+
89
+ const hasGattCongestedStatus = (text: string) => {
90
+ let searchFrom = 0;
91
+ while (searchFrom < text.length) {
92
+ const statusIndex = text.indexOf('status', searchFrom);
93
+ if (statusIndex < 0) return false;
94
+
95
+ let cursor = statusIndex + 'status'.length;
96
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
97
+ if (text[cursor] === ':' || text[cursor] === '=') {
98
+ cursor += 1;
99
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
100
+ }
101
+ if (text.startsWith(String(ANDROID_GATT_CONGESTED_STATUS), cursor)) return true;
102
+
103
+ searchFrom = statusIndex + 'status'.length;
104
+ }
105
+ return false;
106
+ };
107
+
68
108
  const delay = (ms: number) =>
69
109
  new Promise<void>(resolve => {
70
110
  setTimeout(resolve, ms);
71
111
  });
72
112
 
73
- const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRetryType | null => {
113
+ export const getFirmwareUploadWriteRetryType = (
114
+ error: unknown
115
+ ): FirmwareUploadWriteRetryType | null => {
74
116
  if (!error || typeof error !== 'object') return null;
75
117
  const bleWriteError = error as {
76
118
  androidErrorCode?: unknown;
@@ -81,13 +123,6 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
81
123
  name?: unknown;
82
124
  };
83
125
 
84
- if (
85
- bleWriteError.errorCode === BleErrorCode.DeviceDisconnected ||
86
- bleWriteError.errorCode === BleErrorCode.CharacteristicNotFound
87
- ) {
88
- return 'reconnectable';
89
- }
90
-
91
126
  if (
92
127
  bleWriteError.androidErrorCode === ANDROID_GATT_CONGESTED_STATUS ||
93
128
  bleWriteError.status === ANDROID_GATT_CONGESTED_STATUS
@@ -98,15 +133,31 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
98
133
  const text = [bleWriteError.reason, bleWriteError.message, bleWriteError.name]
99
134
  .filter(value => typeof value === 'string')
100
135
  .join(' ');
101
- return /GATT_CONGESTED|status\s*[:=]?\s*143/.test(text) ? 'congested' : null;
136
+ return text.includes('GATT_CONGESTED') || hasGattCongestedStatus(text) ? 'congested' : null;
102
137
  };
103
138
 
104
139
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
105
140
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
106
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
107
- const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
141
+ const PROTOCOL_PROBE_TIMEOUT_MS = 3000;
108
142
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
109
- const DEVICE_SCAN_TIMEOUT_MS = 8000;
143
+ /**
144
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
145
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
146
+ * stops reporting ready while staying connected, so the write promise never settles.
147
+ * Response timeouts cannot cover that — they are armed after the writes complete —
148
+ * and an unbounded write leaves the whole transport unusable until the process dies.
149
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
150
+ */
151
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
152
+ export const BLE_NATIVE_TEARDOWN_TIMEOUT_MS = 3_000;
153
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
154
+ const isWedgedWriteError = (error: unknown): boolean =>
155
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
156
+ typeof (error as { message?: unknown })?.message === 'string' &&
157
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
158
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
159
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
160
+ const DEVICE_SCAN_TIMEOUT_MS = 3000;
110
161
  const IOS_NOTIFY_READY_DELAY_MS = 150;
111
162
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
112
163
  export type ProtocolV2BleTuning = {
@@ -117,8 +168,8 @@ export type ProtocolV2BleTuning = {
117
168
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
118
169
 
119
170
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
120
- iosPacketLength: IOS_PACKET_LENGTH,
121
- androidPacketLength: ANDROID_PACKET_LENGTH,
171
+ iosPacketLength: IOS_PROTOCOL_V2_PACKET_LENGTH,
172
+ androidPacketLength: ANDROID_PROTOCOL_V2_PACKET_LENGTH,
122
173
  };
123
174
 
124
175
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -160,24 +211,60 @@ function getDeviceDisplayName(device?: Device | null) {
160
211
  return device?.name || device?.localName || null;
161
212
  }
162
213
 
163
- function isGenericBleService(uuid?: string | null) {
164
- return ['1800', '1801', '180a'].includes(getBleUuidKey(uuid));
165
- }
214
+ const IOS_REQUEST_MTU = 247;
215
+ const ANDROID_REQUEST_MTU = 517;
216
+ const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
217
+ const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
166
218
 
167
- function hasKnownOneKeyService(device?: Device | null) {
168
- return (device?.serviceUUIDs ?? []).some(serviceUuid =>
169
- getInfosForServiceUuid(serviceUuid, 'classic')
170
- );
171
- }
219
+ const getRequestedBleMtu = () =>
220
+ Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
172
221
 
173
- const ANDROID_REQUEST_MTU = 256;
222
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
174
223
 
175
224
  const connectOptions: Record<string, unknown> = {
176
- requestMTU: ANDROID_REQUEST_MTU,
177
- timeout: 3000,
225
+ requestMTU: getRequestedBleMtu(),
226
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
178
227
  refreshGatt: 'OnConnected',
179
228
  };
180
229
 
230
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
231
+ const fallbackConnectOptions: Record<string, unknown> = {
232
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
233
+ };
234
+
235
+ /**
236
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
237
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
238
+ * firmware install tears the link down) can leave the promise unsettled — observed
239
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
240
+ * inside the native budget, so this only fires when the native timeout did not.
241
+ */
242
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
243
+ /**
244
+ * Service discovery and characteristic resolution run after connect() succeeds, but
245
+ * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
246
+ * device reboot, these calls can remain pending forever unless they have their own
247
+ * budget.
248
+ */
249
+ export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
250
+ /**
251
+ * How many times a known device may fail its own protocol before we probe the others
252
+ * again. Reconnect polling during a device reboot repeats this every few seconds, and
253
+ * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
254
+ * device we just spoke V1 to dominates the wait. A firmware update can legitimately
255
+ * change a device's protocol, so the shortcut has to expire rather than stick.
256
+ */
257
+ export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
258
+ /** BLE setup timeouts since the last successful setup before the manager is recreated. */
259
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
260
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
261
+ const isConnectTimeoutError = (error: unknown): boolean =>
262
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
263
+ typeof (error as { message?: unknown })?.message === 'string' &&
264
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
265
+ const isNativeOperationTimeoutError = (error: unknown): boolean =>
266
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
267
+
181
268
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
182
269
 
183
270
  const tryToGetConfiguration = (device: Device) => {
@@ -189,23 +276,32 @@ const tryToGetConfiguration = (device: Device) => {
189
276
  return infos;
190
277
  };
191
278
 
192
- const requestAndroidMtu = async (device: Device) => {
193
- if (Platform.OS !== 'android') return device;
279
+ const requestNegotiatedMtu = async (
280
+ device: Device,
281
+ stage: 'connected' | 'servicesAndNotifyReady' | 'highThroughput',
282
+ attempt: number
283
+ ) => {
284
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return device;
194
285
 
195
286
  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
- });
287
+ // iOS ignores the requested value but react-native-ble-plx returns a fresh
288
+ // Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
289
+ const mtuDevice = await device.requestMTU(getRequestedBleMtu());
202
290
  return mtuDevice;
203
291
  } catch (error) {
204
- Log?.debug('[ReactNativeBleTransport] Android MTU request failed:', error);
292
+ Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
293
+ platform: Platform.OS,
294
+ stage,
295
+ attempt,
296
+ actual: device.mtu,
297
+ error: error instanceof Error ? error.message : String(error),
298
+ });
205
299
  return device;
206
300
  }
207
301
  };
208
302
 
303
+ const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'connected', 0);
304
+
209
305
  type IOBleErrorRemap = Error | BleError | null | undefined;
210
306
 
211
307
  function remapError(error: IOBleErrorRemap) {
@@ -245,6 +341,8 @@ export default class ReactNativeBleTransport {
245
341
 
246
342
  _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
247
343
 
344
+ private protocolV2SchemaConfiguration: string | undefined;
345
+
248
346
  name = 'ReactNativeBleTransport';
249
347
 
250
348
  configured = false;
@@ -255,6 +353,8 @@ export default class ReactNativeBleTransport {
255
353
 
256
354
  runPromise: Deferred<any> | null = null;
257
355
 
356
+ private runPromiseDeviceId: string | null = null;
357
+
258
358
  emitter?: EventEmitter;
259
359
 
260
360
  firmwareUploadWriteRecoveryIds = new Set<string>();
@@ -262,8 +362,28 @@ export default class ReactNativeBleTransport {
262
362
  /** Per-device protocol type detected by active wire-level probe after connect. */
263
363
  private deviceProtocol: Map<string, ProtocolType> = new Map();
264
364
 
365
+ /**
366
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
367
+ * must route with it, but acquire() must not treat it as a detected protocol: a
368
+ * probe that never answers would otherwise leave the reuse fast path handing out a
369
+ * transport that was never validated.
370
+ */
371
+ private probingProtocols: Map<string, ProtocolType> = new Map();
372
+
373
+ /** Consecutive write timeouts per device; reset by any write that completes. */
374
+ private writeTimeoutCounts: Map<string, number> = new Map();
375
+
376
+ /** BLE setup timeouts per device since the last complete characteristic resolution. */
377
+ private connectionSetupTimeoutCounts: Map<string, number> = new Map();
378
+
265
379
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
266
380
 
381
+ /** Protocol this device actually answered on, kept across reconnects of one session. */
382
+ private sessionProtocols: Map<string, ProtocolType> = new Map();
383
+
384
+ /** Consecutive detections that failed while trusting sessionProtocols. */
385
+ private protocolReprobeFailures: Map<string, number> = new Map();
386
+
267
387
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
268
388
 
269
389
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -286,15 +406,26 @@ export default class ReactNativeBleTransport {
286
406
  this.rejectProtocolV2Frames(uuid, new Error(reason));
287
407
  Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
288
408
  if (reason.startsWith('Protocol V2 link-fatal error:')) {
289
- await this.release(uuid, true);
409
+ await this.releaseNative(uuid, true);
290
410
  }
291
411
  },
292
412
  });
293
413
 
294
414
  private monitorTokens: Map<string, number> = new Map();
295
415
 
416
+ private disconnectEventTokens: Map<string, number> = new Map();
417
+
418
+ private protocolV2HighVolumeLogSignatures: Map<string, Set<string>> = new Map();
419
+
420
+ private androidHighPriorityDevices: Set<string> = new Set();
421
+
422
+ private androidPriorityResetTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
423
+
296
424
  private nextMonitorToken = 1;
297
425
 
426
+ /** Serializes transport lifecycle changes for the same physical device. */
427
+ private lifecycleOperations: Map<string, Promise<void>> = new Map();
428
+
298
429
  constructor(options: TransportOptions) {
299
430
  this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
300
431
  }
@@ -311,10 +442,19 @@ export default class ReactNativeBleTransport {
311
442
  }
312
443
 
313
444
  configureProtocolV2(signedData: any) {
445
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
446
+ if (this.protocolV2SchemaConfiguration === configuration) {
447
+ return;
448
+ }
449
+
450
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
314
451
  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));
452
+ this.protocolV2SchemaConfiguration = configuration;
453
+ if (isReconfiguration) {
454
+ this.protocolV2Links
455
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
456
+ .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
457
+ }
318
458
  }
319
459
 
320
460
  listen() {
@@ -344,29 +484,15 @@ export default class ReactNativeBleTransport {
344
484
  }
345
485
  }
346
486
 
347
- let fallbackServiceUuid: string | undefined;
348
-
349
487
  if (!infos) {
350
488
  const services = await device.services();
351
489
  Log?.debug(
352
490
  '[ReactNativeBleTransport] Known OneKey service UUID not found, discovered services:',
353
491
  services?.map(service => service.uuid)
354
492
  );
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
493
  }
368
494
 
369
- if (!infos && !fallbackServiceUuid) {
495
+ if (!infos) {
370
496
  try {
371
497
  Log?.debug('cancel connection when service not found');
372
498
  await device.cancelConnection();
@@ -376,9 +502,7 @@ export default class ReactNativeBleTransport {
376
502
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
377
503
  }
378
504
 
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';
505
+ const { serviceUuid, writeUuid, notifyUuid } = infos;
382
506
 
383
507
  if (!serviceUuid) {
384
508
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
@@ -428,6 +552,7 @@ export default class ReactNativeBleTransport {
428
552
 
429
553
  attachDisconnectSubscription(transport: BleTransport, device: Device, uuid: string) {
430
554
  transport.disconnectSubscription?.remove();
555
+ const { monitorToken } = transport;
431
556
  transport.disconnectSubscription = device.onDisconnected(() => {
432
557
  if (this.firmwareUploadWriteRecoveryIds.has(uuid)) {
433
558
  Log?.debug('device disconnect ignored during FirmwareUpload write recovery: ', uuid);
@@ -437,18 +562,17 @@ export default class ReactNativeBleTransport {
437
562
  Log?.debug('device disconnect ignored for stale transport: ', device?.id);
438
563
  return;
439
564
  }
565
+ if (this.monitorTokens.get(uuid) !== monitorToken) {
566
+ Log?.debug('device disconnect ignored for stale generation: ', device?.id);
567
+ return;
568
+ }
440
569
 
441
570
  try {
442
571
  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) {
572
+ this.emitDeviceDisconnect(uuid, device?.name, monitorToken);
573
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
449
574
  const error = ERRORS.TypedError(HardwareErrorCode.BleConnectedError);
450
575
  this.runPromise.reject(error);
451
- this.rejectAllProtocolV2Frames(error);
452
576
  }
453
577
  } catch (e) {
454
578
  Log?.debug('device disconnect error: ', e);
@@ -458,6 +582,22 @@ export default class ReactNativeBleTransport {
458
582
  });
459
583
  }
460
584
 
585
+ private emitDeviceDisconnect(uuid: string, name: string | null | undefined, token?: number) {
586
+ if (token === undefined || this.disconnectEventTokens.get(uuid) === token) {
587
+ return;
588
+ }
589
+ if (this.monitorTokens.get(uuid) !== token) {
590
+ Log?.debug('device disconnect event ignored for stale generation: ', uuid);
591
+ return;
592
+ }
593
+ this.disconnectEventTokens.set(uuid, token);
594
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
595
+ name,
596
+ id: uuid,
597
+ connectId: uuid,
598
+ });
599
+ }
600
+
461
601
  async reconnectFirmwareUploadTransport(uuid: string, transport: BleTransport) {
462
602
  this.firmwareUploadWriteRecoveryIds.add(uuid);
463
603
  try {
@@ -470,22 +610,21 @@ export default class ReactNativeBleTransport {
470
610
  const isConnected = await device.isConnected().catch(() => false);
471
611
  if (!isConnected) {
472
612
  try {
473
- device = await device.connect(connectOptions);
613
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
474
614
  } catch (e) {
475
615
  if (
476
616
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
477
617
  e.errorCode === BleErrorCode.OperationCancelled
478
618
  ) {
479
- device = await device.connect();
619
+ device = await this.connectWithTimeout(uuid, () => device.connect());
480
620
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
481
621
  throw e;
482
622
  }
483
623
  }
484
624
  }
485
625
 
486
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
487
- device
488
- );
626
+ const { writeCharacteristic, notifyCharacteristic } =
627
+ await this.resolveCharacteristicsWithTimeout(uuid, device);
489
628
 
490
629
  transport.device = device;
491
630
  transport.writeCharacteristic = writeCharacteristic;
@@ -575,10 +714,19 @@ export default class ReactNativeBleTransport {
575
714
  }
576
715
 
577
716
  const displayName = getDeviceDisplayName(device);
717
+ // iOS may report a service-only advertisement before the named scan response.
718
+ // Do not cache that incomplete advertisement as an unknown device.
719
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
578
720
  const isOneKey =
579
- isOnekeyDevice(device?.name ?? null, device?.id) ||
580
- isOnekeyDevice(device?.localName ?? null, device?.id) ||
581
- hasKnownOneKeyService(device);
721
+ !isUnnamedIOSPeripheral &&
722
+ isOnekeyBluetoothDevice({
723
+ id: device?.id,
724
+ name: device?.name,
725
+ localName: device?.localName,
726
+ // The native scan is already restricted to the OneKey communication service,
727
+ // but ble-plx permits the returned advertisement field to be null.
728
+ serviceUuids: device?.serviceUUIDs ?? getBluetoothServiceUuids(),
729
+ });
582
730
  if (isOneKey) {
583
731
  addDevice(device as unknown as Device);
584
732
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -595,10 +743,18 @@ export default class ReactNativeBleTransport {
595
743
  getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
596
744
  devices => {
597
745
  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) {
746
+ const localName =
747
+ 'localName' in device && typeof device.localName === 'string'
748
+ ? device.localName
749
+ : null;
750
+ if (
751
+ isOnekeyBluetoothDevice({
752
+ id: device.id,
753
+ name: device.name,
754
+ localName,
755
+ serviceUuids: device.serviceUUIDs,
756
+ })
757
+ ) {
602
758
  Log?.debug('search connected peripheral: ', device.id);
603
759
  addDevice(device as unknown as Device);
604
760
  }
@@ -634,13 +790,92 @@ export default class ReactNativeBleTransport {
634
790
  });
635
791
  }
636
792
 
793
+ private async installTransportForAcquire(
794
+ uuid: string,
795
+ device: Device,
796
+ characteristics?: ResolvedBleCharacteristics
797
+ ) {
798
+ const { writeCharacteristic, notifyCharacteristic } =
799
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
800
+ const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
801
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
802
+ const monitorToken = this.nextMonitorToken;
803
+ this.nextMonitorToken += 1;
804
+ const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
805
+ transport.monitorToken = monitorToken;
806
+ transport.notifyTransactionId = notifyTransactionId;
807
+ this.monitorTokens.set(uuid, monitorToken);
808
+ transport.notifySubscription = this._monitorCharacteristic(
809
+ transport.notifyCharacteristic,
810
+ uuid,
811
+ monitorToken,
812
+ notifyTransactionId
813
+ );
814
+ transportCache[uuid] = transport;
815
+ this.protocolV2HighVolumeLogSignatures.set(uuid, new Set());
816
+ this.protocolV2Assemblers.set(
817
+ uuid,
818
+ new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
819
+ );
820
+
821
+ if (Platform.OS === 'ios') {
822
+ await new Promise<void>(resolve => {
823
+ setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
824
+ });
825
+ } else if (Platform.OS === 'android') {
826
+ await delay(ANDROID_NOTIFY_READY_DELAY_MS);
827
+ }
828
+
829
+ const initialMtu = transport.mtuSize;
830
+ let refreshAttempts = 0;
831
+ if (
832
+ (Platform.OS === 'ios' || Platform.OS === 'android') &&
833
+ shouldRefreshNegotiatedMtu(transport.mtuSize)
834
+ ) {
835
+ refreshAttempts += 1;
836
+ let refreshedDevice = await requestNegotiatedMtu(
837
+ transport.device,
838
+ 'servicesAndNotifyReady',
839
+ 1
840
+ );
841
+ transport.device = refreshedDevice;
842
+ transport.mtuSize =
843
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
844
+
845
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
846
+ await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
847
+ refreshAttempts += 1;
848
+ refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
849
+ transport.device = refreshedDevice;
850
+ transport.mtuSize =
851
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
852
+ }
853
+ }
854
+
855
+ Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
856
+ platform: Platform.OS,
857
+ requested: getRequestedBleMtu(),
858
+ initial: initialMtu,
859
+ actual: transport.mtuSize,
860
+ refreshAttempts,
861
+ });
862
+
863
+ return transport;
864
+ }
865
+
637
866
  async acquire(input: BleAcquireInput) {
638
- const { uuid, forceCleanRunPromise, expectedProtocol } = input;
867
+ const { uuid } = input;
639
868
 
640
869
  if (!uuid) {
641
870
  throw ERRORS.TypedError(HardwareErrorCode.BleRequiredUUID);
642
871
  }
643
872
 
873
+ return this.runLifecycleOperation(uuid, () => this.acquireUnlocked(input));
874
+ }
875
+
876
+ private async acquireUnlocked(input: BleAcquireInput) {
877
+ const { uuid, forceCleanRunPromise, expectedProtocol } = input;
878
+
644
879
  const cachedTransport = transportCache[uuid];
645
880
  if (cachedTransport) {
646
881
  const cachedProtocol = this.deviceProtocol.get(uuid);
@@ -659,7 +894,7 @@ export default class ReactNativeBleTransport {
659
894
  * connection, clean it up before creating a new transport instance.
660
895
  */
661
896
  Log?.debug('transport not reusable, will release: ', uuid);
662
- await this.release(uuid, true);
897
+ await this.releaseUnlocked(uuid, true);
663
898
  }
664
899
 
665
900
  let device: Device | null = null;
@@ -667,8 +902,8 @@ export default class ReactNativeBleTransport {
667
902
  if (forceCleanRunPromise && this.runPromise) {
668
903
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
669
904
  this.runPromise.reject(error);
670
- this.rejectAllProtocolV2Frames(error);
671
905
  this.runPromise = null;
906
+ this.runPromiseDeviceId = null;
672
907
  Log?.debug('Force clean Bluetooth run promise, forceCleanRunPromise: ', forceCleanRunPromise);
673
908
  }
674
909
 
@@ -704,15 +939,22 @@ export default class ReactNativeBleTransport {
704
939
  if (!device) {
705
940
  Log?.debug('try to connect to device: ', uuid);
706
941
  try {
707
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
942
+ device = await this.connectWithTimeout(uuid, () =>
943
+ blePlxManager.connectToDevice(uuid, connectOptions)
944
+ );
708
945
  } catch (e) {
709
946
  Log?.debug('try to connect to device has error: ', e);
947
+ if (isConnectTimeoutError(e)) {
948
+ throw e;
949
+ }
710
950
  if (
711
951
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
712
952
  e.errorCode === BleErrorCode.OperationCancelled
713
953
  ) {
714
954
  Log?.debug('first try to reconnect without params');
715
- device = await blePlxManager.connectToDevice(uuid);
955
+ device = await this.connectWithTimeout(uuid, () =>
956
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
957
+ );
716
958
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
717
959
  Log?.debug('device already connected');
718
960
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -728,26 +970,36 @@ export default class ReactNativeBleTransport {
728
970
 
729
971
  if (!(await device.isConnected())) {
730
972
  Log?.debug('not connected, try to connect to device: ', uuid);
973
+ const disconnectedDevice = device;
731
974
 
732
975
  try {
733
- device = await device.connect(connectOptions);
976
+ device = await this.connectWithTimeout(uuid, () =>
977
+ disconnectedDevice.connect(connectOptions)
978
+ );
734
979
  } catch (e) {
735
980
  Log?.debug('not connected, try to connect to device has error: ', e);
981
+ if (isConnectTimeoutError(e)) {
982
+ throw e;
983
+ }
736
984
  if (
737
985
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
738
986
  e.errorCode === BleErrorCode.OperationCancelled
739
987
  ) {
740
988
  Log?.debug('second try to reconnect without params');
741
989
  try {
742
- device = await device.connect();
990
+ device = await this.connectWithTimeout(uuid, () =>
991
+ disconnectedDevice.connect(fallbackConnectOptions)
992
+ );
743
993
  } catch (e) {
744
994
  Log?.debug('last try to reconnect error: ', e);
745
995
  // last try to reconnect device if this issue exists
746
996
  // https://github.com/dotintent/react-native-ble-plx/issues/426
747
997
  if (e.errorCode === BleErrorCode.OperationCancelled) {
748
998
  Log?.debug('last try to reconnect');
749
- await device.cancelConnection();
750
- device = await device.connect();
999
+ await disconnectedDevice.cancelConnection();
1000
+ device = await this.connectWithTimeout(uuid, () =>
1001
+ disconnectedDevice.connect(fallbackConnectOptions)
1002
+ );
751
1003
  }
752
1004
  }
753
1005
  } else {
@@ -756,59 +1008,47 @@ export default class ReactNativeBleTransport {
756
1008
  }
757
1009
  }
758
1010
 
759
- device = await requestAndroidMtu(device);
760
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(device);
1011
+ device = await resolveNegotiatedMtu(device);
1012
+ const acquiredDevice = device;
1013
+ const { writeCharacteristic, notifyCharacteristic } =
1014
+ await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
761
1015
 
762
1016
  const protocolHint = expectedProtocol
763
1017
  ? undefined
764
- : this.deviceProtocolHints.get(uuid) ??
765
- inferProtocolHintFromDeviceName(getDeviceDisplayName(device));
1018
+ : input.protocolHint ??
1019
+ this.deviceProtocolHints.get(uuid) ??
1020
+ inferProtocolHintFromDeviceName(getDeviceDisplayName(acquiredDevice));
766
1021
 
767
1022
  // release transport before new transport instance
768
- await this.release(uuid, true);
1023
+ await this.releaseUnlocked(uuid, true);
769
1024
  if (protocolHint) {
770
1025
  this.deviceProtocolHints.set(uuid, protocolHint);
771
1026
  }
772
1027
 
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,
1028
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
1029
+ writeCharacteristic,
1030
+ notifyCharacteristic,
807
1031
  });
808
1032
 
809
- this.attachDisconnectSubscription(transport, device, uuid);
810
-
811
- return { uuid, protocolType };
1033
+ try {
1034
+ const protocolType = await this.detectProtocol(
1035
+ uuid,
1036
+ expectedProtocol,
1037
+ protocolHint,
1038
+ async () => {
1039
+ await this.installTransportForAcquire(uuid, acquiredDevice);
1040
+ }
1041
+ );
1042
+ const currentTransport = transportCache[uuid];
1043
+ if (!currentTransport) {
1044
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1045
+ }
1046
+ this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1047
+ return { uuid, protocolType };
1048
+ } catch (error) {
1049
+ await this.releaseUnlocked(uuid, true);
1050
+ throw error;
1051
+ }
812
1052
  }
813
1053
 
814
1054
  _monitorCharacteristic(
@@ -835,7 +1075,7 @@ export default class ReactNativeBleTransport {
835
1075
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
836
1076
  return;
837
1077
  }
838
- if (this.deviceProtocol.get(uuid) === 'V2') {
1078
+ if (this.getActiveProtocol(uuid) === 'V2') {
839
1079
  let errorCode:
840
1080
  | typeof HardwareErrorCode.BleDeviceBondError
841
1081
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -858,7 +1098,7 @@ export default class ReactNativeBleTransport {
858
1098
  this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
859
1099
  return;
860
1100
  }
861
- if (this.runPromise) {
1101
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
862
1102
  let ERROR:
863
1103
  | typeof HardwareErrorCode.BleDeviceBondError
864
1104
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -881,7 +1121,6 @@ export default class ReactNativeBleTransport {
881
1121
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
882
1122
  );
883
1123
  this.runPromise.reject(notifyError);
884
- this.rejectAllProtocolV2Frames(notifyError);
885
1124
  Log?.debug(
886
1125
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
887
1126
  );
@@ -889,7 +1128,6 @@ export default class ReactNativeBleTransport {
889
1128
  }
890
1129
  const notifyError = ERRORS.TypedError(ERROR);
891
1130
  this.runPromise.reject(notifyError);
892
- this.rejectAllProtocolV2Frames(notifyError);
893
1131
  Log?.debug(': monitor notify error, and has unreleased Promise', Error);
894
1132
  }
895
1133
 
@@ -907,7 +1145,7 @@ export default class ReactNativeBleTransport {
907
1145
 
908
1146
  try {
909
1147
  const data = Buffer.from(c.value as string, 'base64');
910
- const protocol = this.deviceProtocol.get(uuid);
1148
+ const protocol = this.getActiveProtocol(uuid);
911
1149
  if (!protocol) {
912
1150
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
913
1151
  return;
@@ -934,14 +1172,16 @@ export default class ReactNativeBleTransport {
934
1172
  // );
935
1173
  bufferLength = 0;
936
1174
  buffer = [];
937
- this.runPromise?.resolve(value.toString('hex'));
1175
+ if (this.runPromiseDeviceId === uuid) {
1176
+ this.runPromise?.resolve(value.toString('hex'));
1177
+ }
938
1178
  }
939
1179
  } catch (error) {
940
1180
  Log?.debug('monitor data error: ', error);
941
1181
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
942
- if (this.deviceProtocol.get(uuid) === 'V2') {
1182
+ if (this.getActiveProtocol(uuid) === 'V2') {
943
1183
  this.rejectProtocolV2Frames(uuid, notifyError);
944
- } else {
1184
+ } else if (this.runPromiseDeviceId === uuid) {
945
1185
  this.runPromise?.reject(notifyError);
946
1186
  }
947
1187
  }
@@ -951,13 +1191,23 @@ export default class ReactNativeBleTransport {
951
1191
  }
952
1192
 
953
1193
  async release(uuid: string, onclose = false) {
954
- const transport = transportCache[uuid];
1194
+ return this.runLifecycleOperation(uuid, () => this.releaseUnlocked(uuid, onclose));
1195
+ }
1196
+
1197
+ private async releaseUnlocked(uuid: string, onclose = false) {
955
1198
  await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
956
- if (this.runPromise) {
1199
+ return this.releaseNative(uuid, onclose);
1200
+ }
1201
+
1202
+ private async releaseNative(uuid: string, onclose = false) {
1203
+ const transport = transportCache[uuid];
1204
+ const manager = this.blePlxManager;
1205
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
957
1206
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
958
1207
  this.runPromise.reject(error);
959
1208
  this.runPromise = null;
960
- this.rejectAllProtocolV2Frames(error);
1209
+ this.runPromiseDeviceId = null;
1210
+ this.rejectProtocolV2Frames(uuid, error);
961
1211
  } else {
962
1212
  this.resetProtocolV2Frames(uuid);
963
1213
  }
@@ -985,34 +1235,56 @@ export default class ReactNativeBleTransport {
985
1235
  );
986
1236
  transport.notifySubscription?.remove();
987
1237
  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
- }
1238
+ if (transportCache[uuid] === transport) {
1239
+ delete transportCache[uuid];
995
1240
  }
996
-
997
- delete transportCache[uuid];
998
1241
  }
999
1242
 
1243
+ this.protocolV2HighVolumeLogSignatures.delete(uuid);
1244
+
1000
1245
  this.deviceProtocol.delete(uuid);
1001
- // 设备名称提示不依赖当前连接;保留它可让重连优先探测 V2。
1246
+ this.probingProtocols.delete(uuid);
1247
+ // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1002
1248
  this.protocolV2Assemblers.get(uuid)?.reset();
1003
1249
  this.protocolV2Assemblers.delete(uuid);
1004
1250
  this.resetProtocolV2Frames(uuid);
1005
1251
 
1006
- try {
1007
- await this.blePlxManager?.cancelTransaction(uuid);
1008
- } catch (e) {
1009
- Log?.debug('release: cancel transaction error (ignored): ', e?.message || e);
1010
- }
1252
+ await this.runNativeTeardown(uuid, manager, async () => {
1253
+ const operations: Promise<unknown>[] = [
1254
+ this.runBestEffortNativeOperation('release: restore connection priority', () =>
1255
+ this.restoreAndroidConnectionPriority(uuid, transport)
1256
+ ),
1257
+ ];
1258
+ if (transport?.notifyTransactionId && manager) {
1259
+ operations.push(
1260
+ this.runBestEffortNativeOperation('release: cancel notify transaction', () =>
1261
+ manager.cancelTransaction(transport.notifyTransactionId as string)
1262
+ )
1263
+ );
1264
+ }
1265
+ if (manager) {
1266
+ operations.push(
1267
+ this.runBestEffortNativeOperation('release: cancel transaction', () =>
1268
+ manager.cancelTransaction(uuid)
1269
+ )
1270
+ );
1271
+ }
1272
+ await Promise.all(operations);
1273
+ });
1011
1274
 
1012
1275
  return Promise.resolve(true);
1013
1276
  }
1014
1277
 
1015
1278
  async post(session: string, name: string, data: Record<string, unknown>) {
1279
+ if (this.getProtocolType(session) === 'V2') {
1280
+ await this.protocolV2Links.sendFlowControl(
1281
+ session,
1282
+ () => this.createProtocolV2Adapter(session),
1283
+ name,
1284
+ data
1285
+ );
1286
+ return;
1287
+ }
1016
1288
  await this.call(session, name, data);
1017
1289
  }
1018
1290
 
@@ -1037,8 +1309,6 @@ export default class ReactNativeBleTransport {
1037
1309
  `Device protocol has not been detected for ${uuid}`
1038
1310
  );
1039
1311
  }
1040
- Log?.debug('transport call', createTransportCallLog(name, protocol, data));
1041
-
1042
1312
  if (protocol === 'V2') {
1043
1313
  return this.callProtocolV2(uuid, name, data, options);
1044
1314
  }
@@ -1064,7 +1334,25 @@ export default class ReactNativeBleTransport {
1064
1334
  const transport = this.getCachedTransport(uuid);
1065
1335
  const runPromise = createDeferred<string>();
1066
1336
  runPromise.promise.catch(() => undefined);
1337
+ const supersededRunPromise = this.runPromise;
1338
+ if (supersededRunPromise) {
1339
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1340
+ // the superseded deferred now so its response race resolves and its finally block
1341
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1342
+ // tear down the shared connection while another call is using it.
1343
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1344
+ }
1067
1345
  this.runPromise = runPromise;
1346
+ this.runPromiseDeviceId = uuid;
1347
+ // A superseded call's late write failure must not clear the successor's ownership;
1348
+ // only the call that still owns the slot may release it.
1349
+ const releaseOwnershipIfCurrent = () => {
1350
+ if (this.runPromise === runPromise) {
1351
+ this.runPromise = null;
1352
+ this.runPromiseDeviceId = null;
1353
+ }
1354
+ };
1355
+ const isCurrentOwner = () => this.runPromise === runPromise;
1068
1356
  const messages = this._messages;
1069
1357
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1070
1358
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1090,6 +1378,9 @@ export default class ReactNativeBleTransport {
1090
1378
  chunk = ByteBuffer.allocate(packetCapacity);
1091
1379
  } catch (e) {
1092
1380
  onError(e);
1381
+ if (isWedgedWriteError(e)) {
1382
+ throw e;
1383
+ }
1093
1384
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1094
1385
  }
1095
1386
  }
@@ -1121,6 +1412,9 @@ export default class ReactNativeBleTransport {
1121
1412
  }
1122
1413
  } catch (e) {
1123
1414
  onError(e);
1415
+ if (isWedgedWriteError(e)) {
1416
+ throw e;
1417
+ }
1124
1418
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1125
1419
  }
1126
1420
  }
@@ -1134,9 +1428,15 @@ export default class ReactNativeBleTransport {
1134
1428
  if (name === 'EmmcFileWrite') {
1135
1429
  await writeChunkedData(
1136
1430
  buffers,
1137
- data => transport.writeWithRetry(data),
1431
+ data =>
1432
+ this.writeBlePacket(
1433
+ uuid,
1434
+ data,
1435
+ payload => transport.writeWithRetry(payload),
1436
+ isCurrentOwner
1437
+ ),
1138
1438
  e => {
1139
- this.runPromise = null;
1439
+ releaseOwnershipIfCurrent();
1140
1440
  Log?.error('writeCharacteristic write error: ', e);
1141
1441
  }
1142
1442
  );
@@ -1157,43 +1457,31 @@ export default class ReactNativeBleTransport {
1157
1457
  // eslint-disable-next-line no-constant-condition
1158
1458
  while (true) {
1159
1459
  try {
1160
- await transport.writeCharacteristic.writeWithoutResponse(data);
1460
+ await this.writeBlePacket(
1461
+ uuid,
1462
+ data,
1463
+ payload => transport.writeWithRetry(payload),
1464
+ isCurrentOwner
1465
+ );
1161
1466
  return;
1162
1467
  } catch (error) {
1163
1468
  const retryType = getFirmwareUploadWriteRetryType(error);
1164
1469
  if (!retryType || attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1165
1470
  throw error;
1166
1471
  }
1167
- const shouldReconnect = retryType === 'reconnectable';
1168
- const delayMs = shouldReconnect
1169
- ? FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS
1170
- : resolveFirmwareUploadRetryDelay(attempt);
1472
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1171
1473
  Log?.debug('[ReactNativeBleTransport] FirmwareUpload write retry:', {
1172
1474
  attempt: attempt + 1,
1173
1475
  delayMs,
1174
- reconnect: shouldReconnect,
1175
1476
  error,
1176
1477
  });
1177
- if (shouldReconnect) {
1178
- this.firmwareUploadWriteRecoveryIds.add(uuid);
1179
- }
1180
1478
  await delay(delayMs);
1181
1479
  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
1480
  }
1193
1481
  }
1194
1482
  },
1195
1483
  e => {
1196
- this.runPromise = null;
1484
+ releaseOwnershipIfCurrent();
1197
1485
  Log?.error('writeCharacteristic write error: ', e);
1198
1486
  }
1199
1487
  );
@@ -1202,10 +1490,23 @@ export default class ReactNativeBleTransport {
1202
1490
  const outData = o.toString('base64');
1203
1491
  // Upload resources on low-end phones may OOM
1204
1492
  try {
1205
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1493
+ const shouldUseWriteWithResponse =
1494
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1495
+ await this.writeBlePacket(
1496
+ uuid,
1497
+ outData,
1498
+ payload =>
1499
+ shouldUseWriteWithResponse
1500
+ ? transport.writeCharacteristic.writeWithResponse(payload)
1501
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
1502
+ isCurrentOwner
1503
+ );
1206
1504
  } catch (e) {
1207
1505
  Log?.debug('writeCharacteristic write error: ', e);
1208
- this.runPromise = null;
1506
+ releaseOwnershipIfCurrent();
1507
+ if (isWedgedWriteError(e)) {
1508
+ throw e;
1509
+ }
1209
1510
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1210
1511
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1211
1512
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1241,16 +1542,30 @@ export default class ReactNativeBleTransport {
1241
1542
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1242
1543
  return check.call(jsonData);
1243
1544
  } catch (e) {
1244
- if (name === 'Initialize' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1245
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe call failed:', e);
1545
+ if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1546
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1246
1547
  } else {
1247
1548
  Log?.error('call error: ', e);
1248
1549
  }
1550
+ const isProbeTimeout =
1551
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1552
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1553
+ // transport; its late timeout must not tear down the connection the current
1554
+ // call is actively using.
1555
+ const isStaleCall = this.runPromise !== runPromise;
1556
+ if (
1557
+ !isProbeTimeout &&
1558
+ !isStaleCall &&
1559
+ (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1560
+ ) {
1561
+ await this.disconnect(uuid);
1562
+ }
1249
1563
  throw e;
1250
1564
  } finally {
1251
1565
  if (timeout) clearTimeout(timeout);
1252
1566
  if (this.runPromise === runPromise) {
1253
1567
  this.runPromise = null;
1568
+ this.runPromiseDeviceId = null;
1254
1569
  }
1255
1570
  }
1256
1571
  }
@@ -1260,8 +1575,14 @@ export default class ReactNativeBleTransport {
1260
1575
  }
1261
1576
 
1262
1577
  async disconnect(session: string) {
1578
+ return this.runLifecycleOperation(session, () => this.disconnectUnlocked(session));
1579
+ }
1580
+
1581
+ private async disconnectUnlocked(session: string) {
1263
1582
  await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1264
1583
  const transport = transportCache[session];
1584
+ const manager = this.blePlxManager;
1585
+ const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1265
1586
 
1266
1587
  // Clean up disconnect subscription first to prevent onDisconnected callback
1267
1588
  // from being triggered when we cancel the connection below
@@ -1289,60 +1610,232 @@ export default class ReactNativeBleTransport {
1289
1610
  }
1290
1611
  }
1291
1612
 
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
1613
  // clear the transport cache
1318
- if (transportCache[session]) {
1614
+ if (!transport || transportCache[session] === transport) {
1319
1615
  delete transportCache[session];
1320
1616
  }
1321
1617
  this.deviceProtocol.delete(session);
1618
+ this.probingProtocols.delete(session);
1322
1619
  this.deviceProtocolHints.delete(session);
1620
+ this.sessionProtocols.delete(session);
1621
+ this.protocolReprobeFailures.delete(session);
1323
1622
  this.protocolV2Assemblers.delete(session);
1324
1623
  this.resetProtocolV2Frames(session);
1325
1624
 
1326
1625
  // emit the disconnect event
1327
1626
  try {
1328
- this.emitter?.emit('device-disconnect', {
1329
- name: transport?.device?.name,
1330
- id: session,
1331
- connectId: session,
1332
- });
1627
+ this.emitDeviceDisconnect(session, transport?.device?.name, monitorToken);
1333
1628
  } catch (e) {
1334
1629
  Log?.error('resetSession: emit disconnect event error: ', e);
1335
1630
  }
1631
+ if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1632
+ this.monitorTokens.delete(session);
1633
+ }
1634
+
1635
+ await this.runNativeTeardown(session, manager, async () => {
1636
+ const operations: Promise<unknown>[] = [];
1637
+ if (manager) {
1638
+ operations.push(
1639
+ this.runBestEffortNativeOperation('disconnect: cancel transaction', () =>
1640
+ manager.cancelTransaction(session)
1641
+ )
1642
+ );
1643
+ operations.push(
1644
+ this.runBestEffortNativeOperation('disconnect: cancel device connection', () =>
1645
+ manager.cancelDeviceConnection(session)
1646
+ )
1647
+ );
1648
+ }
1649
+ if (transport?.device) {
1650
+ operations.push(
1651
+ this.runBestEffortNativeOperation('disconnect: device cancel connection', () =>
1652
+ transport.device.cancelConnection()
1653
+ )
1654
+ );
1655
+ }
1656
+ await Promise.all(operations);
1657
+ });
1658
+
1336
1659
  // eslint-disable-next-line no-promise-executor-return
1337
1660
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1338
1661
  }
1339
1662
 
1663
+ private async runNativeTeardown(
1664
+ uuid: string,
1665
+ manager: BlePlxManager | undefined,
1666
+ teardown: () => Promise<void>
1667
+ ) {
1668
+ let timer: ReturnType<typeof setTimeout> | undefined;
1669
+ let timedOut = false;
1670
+ const pending = Promise.resolve()
1671
+ .then(teardown)
1672
+ .catch(error => {
1673
+ Log?.debug('BLE native teardown error (ignored): ', error?.message || error);
1674
+ });
1675
+ try {
1676
+ await Promise.race([
1677
+ pending,
1678
+ new Promise<void>(resolve => {
1679
+ timer = setTimeout(() => {
1680
+ timedOut = true;
1681
+ resolve();
1682
+ }, BLE_NATIVE_TEARDOWN_TIMEOUT_MS);
1683
+ }),
1684
+ ]);
1685
+ } finally {
1686
+ if (timer) clearTimeout(timer);
1687
+ }
1688
+
1689
+ if (timedOut) {
1690
+ Log?.error('[ReactNativeBleTransport] BLE native teardown timed out:', uuid);
1691
+ if (this.blePlxManager === manager) {
1692
+ this.resetPlxManager();
1693
+ }
1694
+ }
1695
+ }
1696
+
1697
+ private runBestEffortNativeOperation(label: string, operation: () => Promise<unknown>) {
1698
+ return Promise.resolve()
1699
+ .then(operation)
1700
+ .catch(error => {
1701
+ Log?.debug(`${label} error (ignored): `, error?.message || error);
1702
+ });
1703
+ }
1704
+
1705
+ private async runLifecycleOperation<T>(uuid: string, operation: () => Promise<T>): Promise<T> {
1706
+ const previousOperation = this.lifecycleOperations.get(uuid) ?? Promise.resolve();
1707
+ let completeOperation!: () => void;
1708
+ const operationGate = new Promise<void>(resolve => {
1709
+ completeOperation = resolve;
1710
+ });
1711
+ const operationTail = previousOperation.catch(() => undefined).then(() => operationGate);
1712
+ this.lifecycleOperations.set(uuid, operationTail);
1713
+
1714
+ await previousOperation.catch(() => undefined);
1715
+ try {
1716
+ return await operation();
1717
+ } finally {
1718
+ completeOperation();
1719
+ if (this.lifecycleOperations.get(uuid) === operationTail) {
1720
+ this.lifecycleOperations.delete(uuid);
1721
+ }
1722
+ }
1723
+ }
1724
+
1340
1725
  cancel() {
1341
1726
  Log?.debug('transport-react-native transport cancel');
1342
1727
  if (this.runPromise) {
1343
1728
  // this.runPromise.reject(new Error('Transport_CallCanceled'));
1344
1729
  }
1345
1730
  this.runPromise = null;
1731
+ this.runPromiseDeviceId = null;
1732
+ }
1733
+
1734
+ /** Run a native connect under the JS backstop budget. */
1735
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1736
+ let timer: ReturnType<typeof setTimeout> | undefined;
1737
+ let timedOut = false;
1738
+ const pending = connect();
1739
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1740
+ // surface as an unhandled rejection after we have already given up on it.
1741
+ pending.catch(() => undefined);
1742
+ try {
1743
+ const result = await Promise.race([
1744
+ pending,
1745
+ new Promise<never>((_, reject) => {
1746
+ timer = setTimeout(() => {
1747
+ timedOut = true;
1748
+ reject(
1749
+ ERRORS.TypedError(
1750
+ HardwareErrorCode.BleConnectedError,
1751
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1752
+ )
1753
+ );
1754
+ }, BLE_CONNECT_TIMEOUT_MS);
1755
+ }),
1756
+ ]);
1757
+ return result;
1758
+ } catch (error) {
1759
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1760
+ this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1761
+ }
1762
+ throw error;
1763
+ } finally {
1764
+ if (timer) clearTimeout(timer);
1765
+ }
1766
+ }
1767
+
1768
+ /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1769
+ private async resolveCharacteristicsWithTimeout(
1770
+ uuid: string,
1771
+ device: Device
1772
+ ): Promise<ResolvedBleCharacteristics> {
1773
+ let timer: ReturnType<typeof setTimeout> | undefined;
1774
+ let timedOut = false;
1775
+ const pending = this.resolveCharacteristics(device);
1776
+ pending.catch(() => undefined);
1777
+ try {
1778
+ const result = await Promise.race([
1779
+ pending,
1780
+ new Promise<never>((_, reject) => {
1781
+ timer = setTimeout(() => {
1782
+ timedOut = true;
1783
+ reject(
1784
+ ERRORS.TypedError(
1785
+ HardwareErrorCode.BleConnectedError,
1786
+ `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1787
+ )
1788
+ );
1789
+ }, BLE_GATT_SETUP_TIMEOUT_MS);
1790
+ }),
1791
+ ]);
1792
+ this.connectionSetupTimeoutCounts.delete(uuid);
1793
+ return result;
1794
+ } catch (error) {
1795
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1796
+ this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1797
+ }
1798
+ throw error;
1799
+ } finally {
1800
+ if (timer) clearTimeout(timer);
1801
+ }
1802
+ }
1803
+
1804
+ /**
1805
+ * Give up on a BLE setup operation the native layer did not settle. The abandoned
1806
+ * operation still owns native connection/GATT state that can poison the next attempt,
1807
+ * so it is cleared here without awaiting the same queue that stopped responding.
1808
+ */
1809
+ private abandonStalledConnection(
1810
+ uuid: string,
1811
+ stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1812
+ ) {
1813
+ const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1814
+ this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1815
+ Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1816
+ stage,
1817
+ setupTimeoutsSinceSuccess: timeouts,
1818
+ });
1819
+
1820
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1821
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1822
+ });
1823
+ const stalled = transportCache[uuid];
1824
+ if (stalled) {
1825
+ delete transportCache[uuid];
1826
+ }
1827
+ this.deviceProtocol.delete(uuid);
1828
+ this.probingProtocols.delete(uuid);
1829
+ this.protocolV2Assemblers.delete(uuid);
1830
+ this.resetProtocolV2Frames(uuid);
1831
+
1832
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1833
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1834
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1835
+ Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1836
+ this.resetPlxManager();
1837
+ this.connectionSetupTimeoutCounts.delete(uuid);
1838
+ }
1346
1839
  }
1347
1840
 
1348
1841
  private getCachedTransport(uuid: string) {
@@ -1353,6 +1846,137 @@ export default class ReactNativeBleTransport {
1353
1846
  return transport;
1354
1847
  }
1355
1848
 
1849
+ /**
1850
+ * Write one packet under a bounded budget. A write that never settles means the
1851
+ * peripheral is wedged even though the GATT link still reports connected, so the
1852
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1853
+ * cached and every later call would hang on it again.
1854
+ */
1855
+ private async writeBlePacket(
1856
+ uuid: string,
1857
+ data: string,
1858
+ write: (payload: string) => Promise<unknown>,
1859
+ isCurrentOwner?: () => boolean
1860
+ ) {
1861
+ let timer: ReturnType<typeof setTimeout> | undefined;
1862
+ let timedOut = false;
1863
+ try {
1864
+ await Promise.race([
1865
+ write(data),
1866
+ new Promise<never>((_, reject) => {
1867
+ timer = setTimeout(() => {
1868
+ timedOut = true;
1869
+ reject(
1870
+ ERRORS.TypedError(
1871
+ HardwareErrorCode.BleWriteCharacteristicError,
1872
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1873
+ )
1874
+ );
1875
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1876
+ }),
1877
+ ]);
1878
+ this.writeTimeoutCounts.delete(uuid);
1879
+ } catch (error) {
1880
+ if (timedOut) {
1881
+ // A superseded call's late write must not tear down the link the current
1882
+ // call is using; only the owner of the transport may declare it dead.
1883
+ if (isCurrentOwner && !isCurrentOwner()) {
1884
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1885
+ } else {
1886
+ this.tearDownWedgedLink(uuid);
1887
+ }
1888
+ }
1889
+ throw error;
1890
+ } finally {
1891
+ if (timer) clearTimeout(timer);
1892
+ }
1893
+ }
1894
+
1895
+ /**
1896
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1897
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1898
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1899
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1900
+ */
1901
+ private tearDownWedgedLink(uuid: string) {
1902
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1903
+ this.writeTimeoutCounts.set(uuid, timeouts);
1904
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1905
+ consecutiveWriteTimeouts: timeouts,
1906
+ });
1907
+
1908
+ const wedged = transportCache[uuid];
1909
+ this.disconnect(uuid).catch(error => {
1910
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1911
+ });
1912
+ if (wedged && transportCache[uuid] === wedged) {
1913
+ delete transportCache[uuid];
1914
+ }
1915
+ this.deviceProtocol.delete(uuid);
1916
+ this.probingProtocols.delete(uuid);
1917
+ this.protocolV2Assemblers.delete(uuid);
1918
+ this.resetProtocolV2Frames(uuid);
1919
+
1920
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1921
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1922
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1923
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1924
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1925
+ this.resetPlxManager();
1926
+ this.writeTimeoutCounts.delete(uuid);
1927
+ }
1928
+ }
1929
+
1930
+ private resetPlxManager() {
1931
+ const manager = this.blePlxManager;
1932
+ this.blePlxManager = undefined;
1933
+ const reason = 'React Native BLE manager reset';
1934
+ // Destroying the shared manager invalidates every peripheral it owns. Notify
1935
+ // each cached session before clearing generations so Core cannot retain a
1936
+ // silently stale connection for an unrelated device.
1937
+ Object.entries(transportCache).forEach(([uuid, cachedTransport]) => {
1938
+ try {
1939
+ cachedTransport.disconnectSubscription?.remove();
1940
+ } catch (error) {
1941
+ Log?.debug('BLE manager reset disconnect subscription removal failed:', error);
1942
+ }
1943
+ cachedTransport.disconnectSubscription = undefined;
1944
+ try {
1945
+ cachedTransport.notifySubscription?.remove();
1946
+ } catch (error) {
1947
+ Log?.debug('BLE manager reset notify subscription removal failed:', error);
1948
+ }
1949
+ cachedTransport.notifySubscription = undefined;
1950
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
1951
+ try {
1952
+ this.emitDeviceDisconnect(
1953
+ uuid,
1954
+ cachedTransport.device?.name,
1955
+ cachedTransport.monitorToken ?? this.monitorTokens.get(uuid)
1956
+ );
1957
+ } catch (error) {
1958
+ Log?.debug('BLE manager reset disconnect event failed:', error);
1959
+ }
1960
+ delete transportCache[uuid];
1961
+ });
1962
+ this.protocolV2Links.invalidateAllLinks(reason).catch(error => {
1963
+ Log?.debug('[ReactNativeBleTransport] BLE manager link invalidation failed:', error);
1964
+ });
1965
+ this.deviceProtocol.clear();
1966
+ this.probingProtocols.clear();
1967
+ this.sessionProtocols.clear();
1968
+ this.protocolReprobeFailures.clear();
1969
+ this.writeTimeoutCounts.clear();
1970
+ this.connectionSetupTimeoutCounts.clear();
1971
+ this.monitorTokens.clear();
1972
+ this.protocolV2Assemblers.clear();
1973
+ try {
1974
+ manager?.destroy();
1975
+ } catch (error) {
1976
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1977
+ }
1978
+ }
1979
+
1356
1980
  private createProtocolMismatchError(expected: ProtocolType) {
1357
1981
  return ERRORS.TypedError(
1358
1982
  HardwareErrorCode.RuntimeError,
@@ -1363,24 +1987,47 @@ export default class ReactNativeBleTransport {
1363
1987
  private createProtocolDetectionError() {
1364
1988
  return ERRORS.TypedError(
1365
1989
  HardwareErrorCode.BleTimeoutError,
1366
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
1990
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
1367
1991
  );
1368
1992
  }
1369
1993
 
1370
1994
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1995
+ if (this.probingProtocols.get(uuid) === protocol) {
1996
+ this.probingProtocols.delete(uuid);
1997
+ }
1371
1998
  if (this.deviceProtocol.get(uuid) === protocol) {
1372
1999
  this.deviceProtocol.delete(uuid);
1373
2000
  }
1374
2001
  }
1375
2002
 
2003
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
2004
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
2005
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
2006
+ }
2007
+
1376
2008
  private async detectProtocol(
1377
2009
  uuid: string,
1378
2010
  expectedProtocol?: ProtocolType,
1379
- protocolHint?: ProtocolType
2011
+ protocolHint?: ProtocolType,
2012
+ rebuildTransport?: () => Promise<void>
1380
2013
  ): Promise<ProtocolType> {
2014
+ // iOS still skips an extra V1 Initialize during acquire. Expected V2 must
2015
+ // Ping so USB-priority `link disabled` can surface instead of a later
2016
+ // unmapped RuntimeError.
2017
+ if (Platform.OS === 'ios' && expectedProtocol === 'V1') {
2018
+ this.deviceProtocol.set(uuid, expectedProtocol);
2019
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
2020
+ deviceId: uuid,
2021
+ protocol: expectedProtocol,
2022
+ source: 'expected',
2023
+ });
2024
+ return expectedProtocol;
2025
+ }
2026
+
1381
2027
  if (expectedProtocol === 'V1') {
1382
2028
  if (await this.probeProtocolV1(uuid)) {
1383
2029
  this.deviceProtocol.set(uuid, 'V1');
2030
+ this.sessionProtocols.set(uuid, 'V1');
1384
2031
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1385
2032
  deviceId: uuid,
1386
2033
  protocol: 'V1',
@@ -1392,33 +2039,52 @@ export default class ReactNativeBleTransport {
1392
2039
  }
1393
2040
 
1394
2041
  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';
2042
+ if (await this.probeProtocolV2(uuid)) {
2043
+ this.deviceProtocol.set(uuid, 'V2');
2044
+ this.sessionProtocols.set(uuid, 'V2');
2045
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
2046
+ deviceId: uuid,
2047
+ protocol: 'V2',
2048
+ source: 'expected',
2049
+ });
2050
+ return 'V2';
2051
+ }
2052
+ throw this.createProtocolMismatchError(expectedProtocol);
1404
2053
  }
1405
2054
 
1406
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
1407
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1
1408
- // 不能作为最终结论。
1409
- const probeOrder: ProtocolType[] =
2055
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
2056
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
2057
+ const sessionProtocol = this.sessionProtocols.get(uuid);
2058
+ const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
2059
+ const fullProbeOrder: ProtocolType[] =
1410
2060
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
2061
+ // A device that already answered on a protocol in this session keeps answering on
2062
+ // it; while it is rebooting nothing answers at all, so probing the other protocol
2063
+ // only adds its timeout to every poll.
2064
+ const trustSessionProtocol =
2065
+ sessionProtocol !== undefined &&
2066
+ !protocolHint &&
2067
+ reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
2068
+ const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1411
2069
 
1412
2070
  for (let i = 0; i < probeOrder.length; i += 1) {
1413
2071
  const protocol = probeOrder[i];
1414
2072
  if (i > 0) {
1415
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
2073
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
1416
2074
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
2075
+ if (!transportCache[uuid]) {
2076
+ if (!rebuildTransport) {
2077
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
2078
+ }
2079
+ await rebuildTransport();
2080
+ }
1417
2081
  }
1418
2082
  const detected =
1419
2083
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1420
2084
  if (detected) {
1421
2085
  this.deviceProtocol.set(uuid, protocol);
2086
+ this.sessionProtocols.set(uuid, protocol);
2087
+ this.protocolReprobeFailures.delete(uuid);
1422
2088
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1423
2089
  deviceId: uuid,
1424
2090
  protocol,
@@ -1428,7 +2094,16 @@ export default class ReactNativeBleTransport {
1428
2094
  }
1429
2095
  }
1430
2096
 
2097
+ if (trustSessionProtocol) {
2098
+ // Still silent on its own protocol: count it, and let the streak expire the
2099
+ // shortcut so a device that genuinely switched protocols is found again.
2100
+ this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
2101
+ } else {
2102
+ this.protocolReprobeFailures.delete(uuid);
2103
+ }
2104
+
1431
2105
  this.deviceProtocol.delete(uuid);
2106
+ this.probingProtocols.delete(uuid);
1432
2107
  throw this.createProtocolDetectionError();
1433
2108
  }
1434
2109
 
@@ -1490,12 +2165,20 @@ export default class ReactNativeBleTransport {
1490
2165
  }
1491
2166
 
1492
2167
  try {
1493
- this.deviceProtocol.set(uuid, 'V1');
1494
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2168
+ this.probingProtocols.set(uuid, 'V1');
2169
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
2170
+ // session before Core has a chance to restore a hidden wallet.
2171
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2172
+ this.probingProtocols.delete(uuid);
1495
2173
  return true;
1496
2174
  } catch (error) {
1497
2175
  this.clearProbeProtocol(uuid, 'V1');
1498
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe failed:', error);
2176
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2177
+ // A wedged write already dropped the link, so probing another protocol on it
2178
+ // would only fail against a torn-down transport: surface the real cause.
2179
+ if (isWedgedWriteError(error)) {
2180
+ throw error;
2181
+ }
1499
2182
  return false;
1500
2183
  }
1501
2184
  }
@@ -1505,7 +2188,7 @@ export default class ReactNativeBleTransport {
1505
2188
  return false;
1506
2189
  }
1507
2190
 
1508
- this.deviceProtocol.set(uuid, 'V2');
2191
+ this.probingProtocols.set(uuid, 'V2');
1509
2192
  this.protocolV2Assemblers.get(uuid)?.reset();
1510
2193
  const detected = await probeProtocolV2Helper({
1511
2194
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1520,6 +2203,8 @@ export default class ReactNativeBleTransport {
1520
2203
  });
1521
2204
  if (!detected) {
1522
2205
  this.clearProbeProtocol(uuid, 'V2');
2206
+ } else {
2207
+ this.probingProtocols.delete(uuid);
1523
2208
  }
1524
2209
  return detected;
1525
2210
  }
@@ -1570,17 +2255,8 @@ export default class ReactNativeBleTransport {
1570
2255
  this.getProtocolV2FrameQueue(uuid).push(frame);
1571
2256
  }
1572
2257
 
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
2258
  private resetProtocolV2Frames(uuid: string) {
1582
- this.protocolV2FrameQueues.delete(uuid);
1583
- this.protocolV2FramePromises.delete(uuid);
2259
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
1584
2260
  }
1585
2261
 
1586
2262
  private rejectProtocolV2Frames(uuid: string, error: Error) {
@@ -1609,19 +2285,95 @@ export default class ReactNativeBleTransport {
1609
2285
  }
1610
2286
  }
1611
2287
 
1612
- private async writeProtocolV2Frame(transport: BleTransport, frame: Uint8Array) {
2288
+ private async writeProtocolV2Packet(
2289
+ uuid: string,
2290
+ transport: BleTransport,
2291
+ base64: string,
2292
+ context: ProtocolV2CallContext,
2293
+ assertCurrentGeneration: () => void
2294
+ ) {
2295
+ const shouldUseWriteWithResponse = shouldWriteProtocolV2WithResponse({
2296
+ platform: Platform.OS,
2297
+ highThroughput: context.highThroughput,
2298
+ requestedWithResponse: context.writeWithResponse,
2299
+ characteristic: transport.writeCharacteristic,
2300
+ });
2301
+ let attempt = 0;
2302
+ for (;;) {
2303
+ assertCurrentGeneration();
2304
+ if (context.signal.aborted) {
2305
+ throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
2306
+ }
2307
+ try {
2308
+ await this.writeBlePacket(
2309
+ uuid,
2310
+ base64,
2311
+ payload =>
2312
+ shouldUseWriteWithResponse
2313
+ ? transport.writeCharacteristic.writeWithResponse(payload)
2314
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
2315
+ // Same rule as Protocol V1: a write from a superseded generation must not
2316
+ // tear down the link that the current generation is using.
2317
+ () => {
2318
+ try {
2319
+ assertCurrentGeneration();
2320
+ return !context.signal.aborted;
2321
+ } catch {
2322
+ return false;
2323
+ }
2324
+ }
2325
+ );
2326
+ assertCurrentGeneration();
2327
+ return;
2328
+ } catch (error) {
2329
+ if (
2330
+ getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2331
+ attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
2332
+ ) {
2333
+ throw error;
2334
+ }
2335
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
2336
+ attempt += 1;
2337
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 congested write retry:', {
2338
+ name: context.messageName,
2339
+ attempt,
2340
+ delayMs,
2341
+ });
2342
+ await delay(delayMs);
2343
+ }
2344
+ }
2345
+ }
2346
+
2347
+ private async writeProtocolV2Frame(
2348
+ uuid: string,
2349
+ transport: BleTransport,
2350
+ frame: Uint8Array,
2351
+ context: ProtocolV2CallContext,
2352
+ assertCurrentGeneration: () => void
2353
+ ) {
1613
2354
  const tuning = getProtocolV2BleTuning();
1614
2355
  const packetCapacity = resolveProtocolV2PacketCapacity({
1615
2356
  platform: Platform.OS,
1616
2357
  iosPacketLength: tuning.iosPacketLength,
1617
2358
  androidPacketLength: tuning.androidPacketLength,
1618
- mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
2359
+ mtu: transport.mtuSize,
2360
+ });
2361
+ await writeProtocolV2BleFrame({
2362
+ frame,
2363
+ packetCapacity,
2364
+ assertActive: assertCurrentGeneration,
2365
+ signal: context.signal,
2366
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
2367
+ wait: delay,
2368
+ writePacket: packet =>
2369
+ this.writeProtocolV2Packet(
2370
+ uuid,
2371
+ transport,
2372
+ Buffer.from(packet).toString('base64'),
2373
+ context,
2374
+ assertCurrentGeneration
2375
+ ),
1619
2376
  });
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
2377
  }
1626
2378
 
1627
2379
  private async callProtocolV2(
@@ -1634,19 +2386,45 @@ export default class ReactNativeBleTransport {
1634
2386
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1635
2387
  }
1636
2388
 
1637
- const callOptions = {
1638
- ...options,
1639
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
1640
- };
1641
- const highVolumeWrite = LogBlockCommand.has(name);
2389
+ const callOptions = options;
2390
+ const highThroughputWrite = isProtocolV2HighThroughputCall(name);
1642
2391
 
1643
- if (highVolumeWrite) {
2392
+ if (highThroughputWrite) {
2393
+ await this.ensureProtocolV2HighThroughputMtu(uuid);
1644
2394
  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,
2395
+ const currentTransport = this.getCachedTransport(uuid);
2396
+ const writeWithResponse = shouldWriteProtocolV2WithResponse({
2397
+ platform: Platform.OS,
2398
+ highThroughput: true,
2399
+ requestedWithResponse: options?.writeWithResponse,
2400
+ characteristic: currentTransport.writeCharacteristic,
1649
2401
  });
2402
+ const packetCapacity = resolveProtocolV2PacketCapacity({
2403
+ platform: Platform.OS,
2404
+ iosPacketLength: tuning.iosPacketLength,
2405
+ androidPacketLength: tuning.androidPacketLength,
2406
+ mtu: currentTransport.mtuSize,
2407
+ });
2408
+ const writeMode = writeWithResponse ? 'withResponse' : 'withoutResponse';
2409
+ const logSignature = `${name}:${writeMode}:${String(
2410
+ currentTransport.mtuSize
2411
+ )}:${packetCapacity}`;
2412
+ const loggedSignatures =
2413
+ this.protocolV2HighVolumeLogSignatures.get(uuid) ?? new Set<string>();
2414
+ if (!loggedSignatures.has(logSignature)) {
2415
+ loggedSignatures.add(logSignature);
2416
+ this.protocolV2HighVolumeLogSignatures.set(uuid, loggedSignatures);
2417
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2418
+ name,
2419
+ writeMode,
2420
+ reportedMtu: currentTransport.mtuSize,
2421
+ packetCapacity,
2422
+ });
2423
+ }
2424
+ }
2425
+
2426
+ if (highThroughputWrite) {
2427
+ await this.enableAndroidHighConnectionPriority(uuid);
1650
2428
  }
1651
2429
 
1652
2430
  try {
@@ -1660,6 +2438,90 @@ export default class ReactNativeBleTransport {
1660
2438
  } catch (e) {
1661
2439
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1662
2440
  throw e;
2441
+ } finally {
2442
+ if (highThroughputWrite) {
2443
+ this.scheduleAndroidBalancedConnectionPriority(uuid);
2444
+ }
2445
+ }
2446
+ }
2447
+
2448
+ private async ensureProtocolV2HighThroughputMtu(uuid: string) {
2449
+ const transport = this.getCachedTransport(uuid);
2450
+ if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2451
+
2452
+ const refreshedDevice = await requestNegotiatedMtu(transport.device, 'highThroughput', 1);
2453
+ transport.device = refreshedDevice;
2454
+ transport.mtuSize =
2455
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
2456
+
2457
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
2458
+ throw ERRORS.TypedError(
2459
+ HardwareErrorCode.BleConnectedError,
2460
+ `Protocol V2 high-throughput BLE MTU unavailable: ${String(transport.mtuSize)}`
2461
+ );
2462
+ }
2463
+ }
2464
+
2465
+ private clearAndroidPriorityResetTimer(uuid: string) {
2466
+ const timerId = this.androidPriorityResetTimers.get(uuid);
2467
+ if (timerId !== undefined) {
2468
+ clearTimeout(timerId);
2469
+ this.androidPriorityResetTimers.delete(uuid);
2470
+ }
2471
+ }
2472
+
2473
+ private async enableAndroidHighConnectionPriority(uuid: string) {
2474
+ if (Platform.OS !== 'android') return;
2475
+
2476
+ this.clearAndroidPriorityResetTimer(uuid);
2477
+ if (this.androidHighPriorityDevices.has(uuid)) return;
2478
+
2479
+ const transport = transportCache[uuid];
2480
+ if (!transport) return;
2481
+
2482
+ try {
2483
+ transport.device = await transport.device.requestConnectionPriority(ConnectionPriority.High);
2484
+ this.androidHighPriorityDevices.add(uuid);
2485
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2486
+ priority: 'high',
2487
+ });
2488
+ } catch (error) {
2489
+ Log?.debug('[ReactNativeBleTransport] Android BLE high priority request failed', {
2490
+ error: error instanceof Error ? error.message : String(error),
2491
+ });
2492
+ }
2493
+ }
2494
+
2495
+ private scheduleAndroidBalancedConnectionPriority(uuid: string) {
2496
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.has(uuid)) return;
2497
+
2498
+ this.clearAndroidPriorityResetTimer(uuid);
2499
+ const timerId = setTimeout(() => {
2500
+ this.androidPriorityResetTimers.delete(uuid);
2501
+ this.restoreAndroidConnectionPriority(uuid, transportCache[uuid]).catch(error =>
2502
+ Log?.debug('[ReactNativeBleTransport] Android BLE priority restore failed', error)
2503
+ );
2504
+ }, ANDROID_HIGH_PRIORITY_IDLE_MS);
2505
+ this.androidPriorityResetTimers.set(uuid, timerId);
2506
+ }
2507
+
2508
+ private async restoreAndroidConnectionPriority(uuid: string, transport?: BleTransport) {
2509
+ this.clearAndroidPriorityResetTimer(uuid);
2510
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.delete(uuid) || !transport) {
2511
+ return;
2512
+ }
2513
+
2514
+ try {
2515
+ transport.device = await transport.device.requestConnectionPriority(
2516
+ ConnectionPriority.Balanced
2517
+ );
2518
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2519
+ priority: 'balanced',
2520
+ });
2521
+ } catch (error) {
2522
+ Log?.debug('[ReactNativeBleTransport] Android BLE balanced priority request failed', {
2523
+ error: error instanceof Error ? error.message : String(error),
2524
+ });
1663
2525
  }
1664
2526
  }
1665
2527
 
@@ -1680,10 +2542,16 @@ export default class ReactNativeBleTransport {
1680
2542
  this.protocolV2Assemblers.get(uuid)?.reset();
1681
2543
  this.resetProtocolV2Frames(uuid);
1682
2544
  },
1683
- writeFrame: async (frame: Uint8Array) => {
2545
+ writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1684
2546
  assertCurrentGeneration();
1685
2547
  const currentTransport = this.getCachedTransport(uuid);
1686
- await this.writeProtocolV2Frame(currentTransport, frame);
2548
+ await this.writeProtocolV2Frame(
2549
+ uuid,
2550
+ currentTransport,
2551
+ frame,
2552
+ context,
2553
+ assertCurrentGeneration
2554
+ );
1687
2555
  },
1688
2556
  readFrame: async () => {
1689
2557
  assertCurrentGeneration();
@@ -1694,6 +2562,7 @@ export default class ReactNativeBleTransport {
1694
2562
  return rxFrame;
1695
2563
  },
1696
2564
  reset: (reason: string) => {
2565
+ if (this.monitorTokens.get(uuid) !== generation) return;
1697
2566
  this.protocolV2Assemblers.get(uuid)?.reset();
1698
2567
  this.rejectProtocolV2Frames(uuid, new Error(reason));
1699
2568
  },
@@ -1708,6 +2577,6 @@ export default class ReactNativeBleTransport {
1708
2577
  }
1709
2578
 
1710
2579
  getProtocolType(path: string): ProtocolType | undefined {
1711
- return this.deviceProtocol.get(path);
2580
+ return this.getActiveProtocol(path);
1712
2581
  }
1713
2582
  }