@onekeyfe/hd-transport-react-native 1.2.0-alpha.12 → 1.2.0-alpha.121

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,46 @@ 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
+ isPro2FindMyAdvertisementName,
33
+ } from '@onekeyfe/hd-shared';
24
34
 
25
35
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
26
- import { hasWritableCapability, resolveProtocolV2PacketCapacity } from './bleStrategy';
36
+ import {
37
+ hasWritableCapability,
38
+ resolveProtocolV2PacketCapacity,
39
+ shouldRefreshNegotiatedMtu,
40
+ shouldWriteProtocolV2WithResponse,
41
+ } from './bleStrategy';
27
42
  import { subscribeBleOn } from './subscribeBleOn';
28
43
  import {
29
44
  ANDROID_PACKET_LENGTH,
45
+ ANDROID_PROTOCOL_V2_PACKET_LENGTH,
30
46
  IOS_PACKET_LENGTH,
31
- getBleUuidKey,
47
+ IOS_PROTOCOL_V2_PACKET_LENGTH,
32
48
  getBluetoothServiceUuids,
33
49
  getInfosForServiceUuid,
34
50
  isSameBleUuid,
@@ -37,7 +53,6 @@ import { isHeaderChunk } from './utils/validateNotify';
37
53
  import BleTransport from './BleTransport';
38
54
  import timer from './utils/timer';
39
55
  import { bleLogger, setBleLogger } from './logger';
40
- import { createTransportCallLog } from './transportLog';
41
56
 
42
57
  import type { Deferred } from '@onekeyfe/hd-shared';
43
58
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
@@ -53,24 +68,52 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
53
68
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
54
69
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
55
70
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
56
- const FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS = 2000;
57
71
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
58
72
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
59
73
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
60
74
  const ANDROID_GATT_CONGESTED_STATUS = 143;
61
75
 
62
- type FirmwareUploadWriteRetryType = 'congested' | 'reconnectable';
76
+ type FirmwareUploadWriteRetryType = 'congested';
63
77
  type ResolvedBleCharacteristics = {
64
78
  writeCharacteristic: Characteristic;
65
79
  notifyCharacteristic: Characteristic;
66
80
  };
67
81
 
82
+ const isAsciiWhitespace = (code: number) =>
83
+ code === 0x09 ||
84
+ code === 0x0a ||
85
+ code === 0x0b ||
86
+ code === 0x0c ||
87
+ code === 0x0d ||
88
+ code === 0x20;
89
+
90
+ const hasGattCongestedStatus = (text: string) => {
91
+ let searchFrom = 0;
92
+ while (searchFrom < text.length) {
93
+ const statusIndex = text.indexOf('status', searchFrom);
94
+ if (statusIndex < 0) return false;
95
+
96
+ let cursor = statusIndex + 'status'.length;
97
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
98
+ if (text[cursor] === ':' || text[cursor] === '=') {
99
+ cursor += 1;
100
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
101
+ }
102
+ if (text.startsWith(String(ANDROID_GATT_CONGESTED_STATUS), cursor)) return true;
103
+
104
+ searchFrom = statusIndex + 'status'.length;
105
+ }
106
+ return false;
107
+ };
108
+
68
109
  const delay = (ms: number) =>
69
110
  new Promise<void>(resolve => {
70
111
  setTimeout(resolve, ms);
71
112
  });
72
113
 
73
- const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRetryType | null => {
114
+ export const getFirmwareUploadWriteRetryType = (
115
+ error: unknown
116
+ ): FirmwareUploadWriteRetryType | null => {
74
117
  if (!error || typeof error !== 'object') return null;
75
118
  const bleWriteError = error as {
76
119
  androidErrorCode?: unknown;
@@ -81,13 +124,6 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
81
124
  name?: unknown;
82
125
  };
83
126
 
84
- if (
85
- bleWriteError.errorCode === BleErrorCode.DeviceDisconnected ||
86
- bleWriteError.errorCode === BleErrorCode.CharacteristicNotFound
87
- ) {
88
- return 'reconnectable';
89
- }
90
-
91
127
  if (
92
128
  bleWriteError.androidErrorCode === ANDROID_GATT_CONGESTED_STATUS ||
93
129
  bleWriteError.status === ANDROID_GATT_CONGESTED_STATUS
@@ -98,15 +134,30 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
98
134
  const text = [bleWriteError.reason, bleWriteError.message, bleWriteError.name]
99
135
  .filter(value => typeof value === 'string')
100
136
  .join(' ');
101
- return /GATT_CONGESTED|status\s*[:=]?\s*143/.test(text) ? 'congested' : null;
137
+ return text.includes('GATT_CONGESTED') || hasGattCongestedStatus(text) ? 'congested' : null;
102
138
  };
103
139
 
104
140
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
105
141
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
106
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
107
- const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
142
+ const PROTOCOL_PROBE_TIMEOUT_MS = 3000;
108
143
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
109
- const DEVICE_SCAN_TIMEOUT_MS = 8000;
144
+ /**
145
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
146
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
147
+ * stops reporting ready while staying connected, so the write promise never settles.
148
+ * Response timeouts cannot cover that — they are armed after the writes complete —
149
+ * and an unbounded write leaves the whole transport unusable until the process dies.
150
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
151
+ */
152
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_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,13 +406,21 @@ 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
 
298
426
  constructor(options: TransportOptions) {
@@ -311,10 +439,19 @@ export default class ReactNativeBleTransport {
311
439
  }
312
440
 
313
441
  configureProtocolV2(signedData: any) {
442
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
443
+ if (this.protocolV2SchemaConfiguration === configuration) {
444
+ return;
445
+ }
446
+
447
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
314
448
  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));
449
+ this.protocolV2SchemaConfiguration = configuration;
450
+ if (isReconfiguration) {
451
+ this.protocolV2Links
452
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
453
+ .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
454
+ }
318
455
  }
319
456
 
320
457
  listen() {
@@ -344,29 +481,15 @@ export default class ReactNativeBleTransport {
344
481
  }
345
482
  }
346
483
 
347
- let fallbackServiceUuid: string | undefined;
348
-
349
484
  if (!infos) {
350
485
  const services = await device.services();
351
486
  Log?.debug(
352
487
  '[ReactNativeBleTransport] Known OneKey service UUID not found, discovered services:',
353
488
  services?.map(service => service.uuid)
354
489
  );
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
490
  }
368
491
 
369
- if (!infos && !fallbackServiceUuid) {
492
+ if (!infos) {
370
493
  try {
371
494
  Log?.debug('cancel connection when service not found');
372
495
  await device.cancelConnection();
@@ -376,9 +499,7 @@ export default class ReactNativeBleTransport {
376
499
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
377
500
  }
378
501
 
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';
502
+ const { serviceUuid, writeUuid, notifyUuid } = infos;
382
503
 
383
504
  if (!serviceUuid) {
384
505
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
@@ -428,6 +549,7 @@ export default class ReactNativeBleTransport {
428
549
 
429
550
  attachDisconnectSubscription(transport: BleTransport, device: Device, uuid: string) {
430
551
  transport.disconnectSubscription?.remove();
552
+ const { monitorToken } = transport;
431
553
  transport.disconnectSubscription = device.onDisconnected(() => {
432
554
  if (this.firmwareUploadWriteRecoveryIds.has(uuid)) {
433
555
  Log?.debug('device disconnect ignored during FirmwareUpload write recovery: ', uuid);
@@ -437,18 +559,17 @@ export default class ReactNativeBleTransport {
437
559
  Log?.debug('device disconnect ignored for stale transport: ', device?.id);
438
560
  return;
439
561
  }
562
+ if (this.monitorTokens.get(uuid) !== monitorToken) {
563
+ Log?.debug('device disconnect ignored for stale generation: ', device?.id);
564
+ return;
565
+ }
440
566
 
441
567
  try {
442
568
  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) {
569
+ this.emitDeviceDisconnect(uuid, device?.name, monitorToken);
570
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
449
571
  const error = ERRORS.TypedError(HardwareErrorCode.BleConnectedError);
450
572
  this.runPromise.reject(error);
451
- this.rejectAllProtocolV2Frames(error);
452
573
  }
453
574
  } catch (e) {
454
575
  Log?.debug('device disconnect error: ', e);
@@ -458,6 +579,22 @@ export default class ReactNativeBleTransport {
458
579
  });
459
580
  }
460
581
 
582
+ private emitDeviceDisconnect(uuid: string, name: string | null | undefined, token?: number) {
583
+ if (token === undefined || this.disconnectEventTokens.get(uuid) === token) {
584
+ return;
585
+ }
586
+ if (this.monitorTokens.get(uuid) !== token) {
587
+ Log?.debug('device disconnect event ignored for stale generation: ', uuid);
588
+ return;
589
+ }
590
+ this.disconnectEventTokens.set(uuid, token);
591
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
592
+ name,
593
+ id: uuid,
594
+ connectId: uuid,
595
+ });
596
+ }
597
+
461
598
  async reconnectFirmwareUploadTransport(uuid: string, transport: BleTransport) {
462
599
  this.firmwareUploadWriteRecoveryIds.add(uuid);
463
600
  try {
@@ -470,22 +607,21 @@ export default class ReactNativeBleTransport {
470
607
  const isConnected = await device.isConnected().catch(() => false);
471
608
  if (!isConnected) {
472
609
  try {
473
- device = await device.connect(connectOptions);
610
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
474
611
  } catch (e) {
475
612
  if (
476
613
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
477
614
  e.errorCode === BleErrorCode.OperationCancelled
478
615
  ) {
479
- device = await device.connect();
616
+ device = await this.connectWithTimeout(uuid, () => device.connect());
480
617
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
481
618
  throw e;
482
619
  }
483
620
  }
484
621
  }
485
622
 
486
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
487
- device
488
- );
623
+ const { writeCharacteristic, notifyCharacteristic } =
624
+ await this.resolveCharacteristicsWithTimeout(uuid, device);
489
625
 
490
626
  transport.device = device;
491
627
  transport.writeCharacteristic = writeCharacteristic;
@@ -545,7 +681,7 @@ export default class ReactNativeBleTransport {
545
681
  }
546
682
 
547
683
  blePlxManager.startDeviceScan(
548
- null,
684
+ getBluetoothServiceUuids(),
549
685
  {
550
686
  allowDuplicates: true,
551
687
  scanMode: ScanMode.LowLatency,
@@ -575,10 +711,21 @@ export default class ReactNativeBleTransport {
575
711
  }
576
712
 
577
713
  const displayName = getDeviceDisplayName(device);
714
+ // iOS may report a service-only advertisement before the named scan response.
715
+ // Do not cache that incomplete advertisement as an unknown device.
716
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
717
+ const isFindMyPeripheral =
718
+ isPro2FindMyAdvertisementName(device?.name) ||
719
+ isPro2FindMyAdvertisementName(device?.localName);
578
720
  const isOneKey =
579
- isOnekeyDevice(device?.name ?? null, device?.id) ||
580
- isOnekeyDevice(device?.localName ?? null, device?.id) ||
581
- hasKnownOneKeyService(device);
721
+ !isUnnamedIOSPeripheral &&
722
+ !isFindMyPeripheral &&
723
+ isOnekeyBluetoothDevice({
724
+ id: device?.id,
725
+ name: device?.name,
726
+ localName: device?.localName,
727
+ serviceUuids: device?.serviceUUIDs,
728
+ });
582
729
  if (isOneKey) {
583
730
  addDevice(device as unknown as Device);
584
731
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -592,11 +739,32 @@ export default class ReactNativeBleTransport {
592
739
  }
593
740
  );
594
741
 
595
- getConnectedDeviceIds(getBluetoothServiceUuids()).then(devices => {
596
- for (const device of devices) {
597
- addDevice(device as unknown as Device);
742
+ getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
743
+ devices => {
744
+ for (const device of devices) {
745
+ const localName =
746
+ 'localName' in device && typeof device.localName === 'string'
747
+ ? device.localName
748
+ : null;
749
+ const isFindMyPeripheral =
750
+ isPro2FindMyAdvertisementName(device.name) ||
751
+ isPro2FindMyAdvertisementName(localName);
752
+
753
+ if (
754
+ !isFindMyPeripheral &&
755
+ isOnekeyBluetoothDevice({
756
+ id: device.id,
757
+ name: device.name,
758
+ localName,
759
+ serviceUuids: device.serviceUUIDs,
760
+ })
761
+ ) {
762
+ Log?.debug('search connected peripheral: ', device.id);
763
+ addDevice(device as unknown as Device);
764
+ }
765
+ }
598
766
  }
599
- });
767
+ );
600
768
 
601
769
  const addDevice = (device: Device) => {
602
770
  if (deviceList.every(d => d.id !== device.id)) {
@@ -626,6 +794,79 @@ export default class ReactNativeBleTransport {
626
794
  });
627
795
  }
628
796
 
797
+ private async installTransportForAcquire(
798
+ uuid: string,
799
+ device: Device,
800
+ characteristics?: ResolvedBleCharacteristics
801
+ ) {
802
+ const { writeCharacteristic, notifyCharacteristic } =
803
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
804
+ const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
805
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
806
+ const monitorToken = this.nextMonitorToken;
807
+ this.nextMonitorToken += 1;
808
+ const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
809
+ transport.monitorToken = monitorToken;
810
+ transport.notifyTransactionId = notifyTransactionId;
811
+ this.monitorTokens.set(uuid, monitorToken);
812
+ transport.notifySubscription = this._monitorCharacteristic(
813
+ transport.notifyCharacteristic,
814
+ uuid,
815
+ monitorToken,
816
+ notifyTransactionId
817
+ );
818
+ transportCache[uuid] = transport;
819
+ this.protocolV2HighVolumeLogSignatures.set(uuid, new Set());
820
+ this.protocolV2Assemblers.set(
821
+ uuid,
822
+ new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
823
+ );
824
+
825
+ if (Platform.OS === 'ios') {
826
+ await new Promise<void>(resolve => {
827
+ setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
828
+ });
829
+ } else if (Platform.OS === 'android') {
830
+ await delay(ANDROID_NOTIFY_READY_DELAY_MS);
831
+ }
832
+
833
+ const initialMtu = transport.mtuSize;
834
+ let refreshAttempts = 0;
835
+ if (
836
+ (Platform.OS === 'ios' || Platform.OS === 'android') &&
837
+ shouldRefreshNegotiatedMtu(transport.mtuSize)
838
+ ) {
839
+ refreshAttempts += 1;
840
+ let refreshedDevice = await requestNegotiatedMtu(
841
+ transport.device,
842
+ 'servicesAndNotifyReady',
843
+ 1
844
+ );
845
+ transport.device = refreshedDevice;
846
+ transport.mtuSize =
847
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
848
+
849
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
850
+ await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
851
+ refreshAttempts += 1;
852
+ refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
853
+ transport.device = refreshedDevice;
854
+ transport.mtuSize =
855
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
856
+ }
857
+ }
858
+
859
+ Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
860
+ platform: Platform.OS,
861
+ requested: getRequestedBleMtu(),
862
+ initial: initialMtu,
863
+ actual: transport.mtuSize,
864
+ refreshAttempts,
865
+ });
866
+
867
+ return transport;
868
+ }
869
+
629
870
  async acquire(input: BleAcquireInput) {
630
871
  const { uuid, forceCleanRunPromise, expectedProtocol } = input;
631
872
 
@@ -659,8 +900,8 @@ export default class ReactNativeBleTransport {
659
900
  if (forceCleanRunPromise && this.runPromise) {
660
901
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
661
902
  this.runPromise.reject(error);
662
- this.rejectAllProtocolV2Frames(error);
663
903
  this.runPromise = null;
904
+ this.runPromiseDeviceId = null;
664
905
  Log?.debug('Force clean Bluetooth run promise, forceCleanRunPromise: ', forceCleanRunPromise);
665
906
  }
666
907
 
@@ -696,15 +937,22 @@ export default class ReactNativeBleTransport {
696
937
  if (!device) {
697
938
  Log?.debug('try to connect to device: ', uuid);
698
939
  try {
699
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
940
+ device = await this.connectWithTimeout(uuid, () =>
941
+ blePlxManager.connectToDevice(uuid, connectOptions)
942
+ );
700
943
  } catch (e) {
701
944
  Log?.debug('try to connect to device has error: ', e);
945
+ if (isConnectTimeoutError(e)) {
946
+ throw e;
947
+ }
702
948
  if (
703
949
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
704
950
  e.errorCode === BleErrorCode.OperationCancelled
705
951
  ) {
706
952
  Log?.debug('first try to reconnect without params');
707
- device = await blePlxManager.connectToDevice(uuid);
953
+ device = await this.connectWithTimeout(uuid, () =>
954
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
955
+ );
708
956
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
709
957
  Log?.debug('device already connected');
710
958
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -720,26 +968,36 @@ export default class ReactNativeBleTransport {
720
968
 
721
969
  if (!(await device.isConnected())) {
722
970
  Log?.debug('not connected, try to connect to device: ', uuid);
971
+ const disconnectedDevice = device;
723
972
 
724
973
  try {
725
- device = await device.connect(connectOptions);
974
+ device = await this.connectWithTimeout(uuid, () =>
975
+ disconnectedDevice.connect(connectOptions)
976
+ );
726
977
  } catch (e) {
727
978
  Log?.debug('not connected, try to connect to device has error: ', e);
979
+ if (isConnectTimeoutError(e)) {
980
+ throw e;
981
+ }
728
982
  if (
729
983
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
730
984
  e.errorCode === BleErrorCode.OperationCancelled
731
985
  ) {
732
986
  Log?.debug('second try to reconnect without params');
733
987
  try {
734
- device = await device.connect();
988
+ device = await this.connectWithTimeout(uuid, () =>
989
+ disconnectedDevice.connect(fallbackConnectOptions)
990
+ );
735
991
  } catch (e) {
736
992
  Log?.debug('last try to reconnect error: ', e);
737
993
  // last try to reconnect device if this issue exists
738
994
  // https://github.com/dotintent/react-native-ble-plx/issues/426
739
995
  if (e.errorCode === BleErrorCode.OperationCancelled) {
740
996
  Log?.debug('last try to reconnect');
741
- await device.cancelConnection();
742
- device = await device.connect();
997
+ await disconnectedDevice.cancelConnection();
998
+ device = await this.connectWithTimeout(uuid, () =>
999
+ disconnectedDevice.connect(fallbackConnectOptions)
1000
+ );
743
1001
  }
744
1002
  }
745
1003
  } else {
@@ -748,13 +1006,16 @@ export default class ReactNativeBleTransport {
748
1006
  }
749
1007
  }
750
1008
 
751
- device = await requestAndroidMtu(device);
752
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(device);
1009
+ device = await resolveNegotiatedMtu(device);
1010
+ const acquiredDevice = device;
1011
+ const { writeCharacteristic, notifyCharacteristic } =
1012
+ await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
753
1013
 
754
1014
  const protocolHint = expectedProtocol
755
1015
  ? undefined
756
- : this.deviceProtocolHints.get(uuid) ??
757
- inferProtocolHintFromDeviceName(getDeviceDisplayName(device));
1016
+ : input.protocolHint ??
1017
+ this.deviceProtocolHints.get(uuid) ??
1018
+ inferProtocolHintFromDeviceName(getDeviceDisplayName(acquiredDevice));
758
1019
 
759
1020
  // release transport before new transport instance
760
1021
  await this.release(uuid, true);
@@ -762,45 +1023,30 @@ export default class ReactNativeBleTransport {
762
1023
  this.deviceProtocolHints.set(uuid, protocolHint);
763
1024
  }
764
1025
 
765
- const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
766
- if (Platform.OS === 'android') {
767
- transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
768
- }
769
- const monitorToken = this.nextMonitorToken;
770
- this.nextMonitorToken += 1;
771
- const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
772
- transport.monitorToken = monitorToken;
773
- transport.notifyTransactionId = notifyTransactionId;
774
- this.monitorTokens.set(uuid, monitorToken);
775
- transport.notifySubscription = this._monitorCharacteristic(
776
- transport.notifyCharacteristic,
777
- uuid,
778
- monitorToken,
779
- notifyTransactionId
780
- );
781
- transportCache[uuid] = transport;
782
-
783
- this.protocolV2Assemblers.set(uuid, new ProtocolV2FrameAssembler());
784
-
785
- if (Platform.OS === 'ios') {
786
- await new Promise<void>(resolve => {
787
- setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
788
- });
789
- } else if (Platform.OS === 'android') {
790
- await delay(ANDROID_NOTIFY_READY_DELAY_MS);
791
- }
792
-
793
- const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
794
-
795
- this.emitter?.emit('device-connect', {
796
- name: device.name,
797
- id: device.id,
798
- connectId: device.id,
1026
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
1027
+ writeCharacteristic,
1028
+ notifyCharacteristic,
799
1029
  });
800
1030
 
801
- this.attachDisconnectSubscription(transport, device, uuid);
802
-
803
- return { uuid, protocolType };
1031
+ try {
1032
+ const protocolType = await this.detectProtocol(
1033
+ uuid,
1034
+ expectedProtocol,
1035
+ protocolHint,
1036
+ async () => {
1037
+ await this.installTransportForAcquire(uuid, acquiredDevice);
1038
+ }
1039
+ );
1040
+ const currentTransport = transportCache[uuid];
1041
+ if (!currentTransport) {
1042
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1043
+ }
1044
+ this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1045
+ return { uuid, protocolType };
1046
+ } catch (error) {
1047
+ await this.release(uuid, true);
1048
+ throw error;
1049
+ }
804
1050
  }
805
1051
 
806
1052
  _monitorCharacteristic(
@@ -827,7 +1073,7 @@ export default class ReactNativeBleTransport {
827
1073
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
828
1074
  return;
829
1075
  }
830
- if (this.deviceProtocol.get(uuid) === 'V2') {
1076
+ if (this.getActiveProtocol(uuid) === 'V2') {
831
1077
  let errorCode:
832
1078
  | typeof HardwareErrorCode.BleDeviceBondError
833
1079
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -850,7 +1096,7 @@ export default class ReactNativeBleTransport {
850
1096
  this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
851
1097
  return;
852
1098
  }
853
- if (this.runPromise) {
1099
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
854
1100
  let ERROR:
855
1101
  | typeof HardwareErrorCode.BleDeviceBondError
856
1102
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -873,7 +1119,6 @@ export default class ReactNativeBleTransport {
873
1119
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
874
1120
  );
875
1121
  this.runPromise.reject(notifyError);
876
- this.rejectAllProtocolV2Frames(notifyError);
877
1122
  Log?.debug(
878
1123
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
879
1124
  );
@@ -881,7 +1126,6 @@ export default class ReactNativeBleTransport {
881
1126
  }
882
1127
  const notifyError = ERRORS.TypedError(ERROR);
883
1128
  this.runPromise.reject(notifyError);
884
- this.rejectAllProtocolV2Frames(notifyError);
885
1129
  Log?.debug(': monitor notify error, and has unreleased Promise', Error);
886
1130
  }
887
1131
 
@@ -899,7 +1143,7 @@ export default class ReactNativeBleTransport {
899
1143
 
900
1144
  try {
901
1145
  const data = Buffer.from(c.value as string, 'base64');
902
- const protocol = this.deviceProtocol.get(uuid);
1146
+ const protocol = this.getActiveProtocol(uuid);
903
1147
  if (!protocol) {
904
1148
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
905
1149
  return;
@@ -926,14 +1170,16 @@ export default class ReactNativeBleTransport {
926
1170
  // );
927
1171
  bufferLength = 0;
928
1172
  buffer = [];
929
- this.runPromise?.resolve(value.toString('hex'));
1173
+ if (this.runPromiseDeviceId === uuid) {
1174
+ this.runPromise?.resolve(value.toString('hex'));
1175
+ }
930
1176
  }
931
1177
  } catch (error) {
932
1178
  Log?.debug('monitor data error: ', error);
933
1179
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
934
- if (this.deviceProtocol.get(uuid) === 'V2') {
1180
+ if (this.getActiveProtocol(uuid) === 'V2') {
935
1181
  this.rejectProtocolV2Frames(uuid, notifyError);
936
- } else {
1182
+ } else if (this.runPromiseDeviceId === uuid) {
937
1183
  this.runPromise?.reject(notifyError);
938
1184
  }
939
1185
  }
@@ -943,13 +1189,18 @@ export default class ReactNativeBleTransport {
943
1189
  }
944
1190
 
945
1191
  async release(uuid: string, onclose = false) {
946
- const transport = transportCache[uuid];
947
1192
  await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
948
- if (this.runPromise) {
1193
+ return this.releaseNative(uuid, onclose);
1194
+ }
1195
+
1196
+ private async releaseNative(uuid: string, onclose = false) {
1197
+ const transport = transportCache[uuid];
1198
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
949
1199
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
950
1200
  this.runPromise.reject(error);
951
1201
  this.runPromise = null;
952
- this.rejectAllProtocolV2Frames(error);
1202
+ this.runPromiseDeviceId = null;
1203
+ this.rejectProtocolV2Frames(uuid, error);
953
1204
  } else {
954
1205
  this.resetProtocolV2Frames(uuid);
955
1206
  }
@@ -960,6 +1211,8 @@ export default class ReactNativeBleTransport {
960
1211
  return Promise.resolve(true);
961
1212
  }
962
1213
 
1214
+ await this.restoreAndroidConnectionPriority(uuid, transport);
1215
+
963
1216
  if (transport) {
964
1217
  if (this.monitorTokens.get(uuid) === transport.monitorToken) {
965
1218
  this.monitorTokens.delete(uuid);
@@ -989,8 +1242,11 @@ export default class ReactNativeBleTransport {
989
1242
  delete transportCache[uuid];
990
1243
  }
991
1244
 
1245
+ this.protocolV2HighVolumeLogSignatures.delete(uuid);
1246
+
992
1247
  this.deviceProtocol.delete(uuid);
993
- // 设备名称提示不依赖当前连接;保留它可让重连优先探测 V2。
1248
+ this.probingProtocols.delete(uuid);
1249
+ // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
994
1250
  this.protocolV2Assemblers.get(uuid)?.reset();
995
1251
  this.protocolV2Assemblers.delete(uuid);
996
1252
  this.resetProtocolV2Frames(uuid);
@@ -1029,8 +1285,6 @@ export default class ReactNativeBleTransport {
1029
1285
  `Device protocol has not been detected for ${uuid}`
1030
1286
  );
1031
1287
  }
1032
- Log?.debug('transport call', createTransportCallLog(name, protocol, data));
1033
-
1034
1288
  if (protocol === 'V2') {
1035
1289
  return this.callProtocolV2(uuid, name, data, options);
1036
1290
  }
@@ -1056,7 +1310,25 @@ export default class ReactNativeBleTransport {
1056
1310
  const transport = this.getCachedTransport(uuid);
1057
1311
  const runPromise = createDeferred<string>();
1058
1312
  runPromise.promise.catch(() => undefined);
1313
+ const supersededRunPromise = this.runPromise;
1314
+ if (supersededRunPromise) {
1315
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1316
+ // the superseded deferred now so its response race resolves and its finally block
1317
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1318
+ // tear down the shared connection while another call is using it.
1319
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1320
+ }
1059
1321
  this.runPromise = runPromise;
1322
+ this.runPromiseDeviceId = uuid;
1323
+ // A superseded call's late write failure must not clear the successor's ownership;
1324
+ // only the call that still owns the slot may release it.
1325
+ const releaseOwnershipIfCurrent = () => {
1326
+ if (this.runPromise === runPromise) {
1327
+ this.runPromise = null;
1328
+ this.runPromiseDeviceId = null;
1329
+ }
1330
+ };
1331
+ const isCurrentOwner = () => this.runPromise === runPromise;
1060
1332
  const messages = this._messages;
1061
1333
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1062
1334
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1082,6 +1354,9 @@ export default class ReactNativeBleTransport {
1082
1354
  chunk = ByteBuffer.allocate(packetCapacity);
1083
1355
  } catch (e) {
1084
1356
  onError(e);
1357
+ if (isWedgedWriteError(e)) {
1358
+ throw e;
1359
+ }
1085
1360
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1086
1361
  }
1087
1362
  }
@@ -1113,6 +1388,9 @@ export default class ReactNativeBleTransport {
1113
1388
  }
1114
1389
  } catch (e) {
1115
1390
  onError(e);
1391
+ if (isWedgedWriteError(e)) {
1392
+ throw e;
1393
+ }
1116
1394
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1117
1395
  }
1118
1396
  }
@@ -1126,9 +1404,15 @@ export default class ReactNativeBleTransport {
1126
1404
  if (name === 'EmmcFileWrite') {
1127
1405
  await writeChunkedData(
1128
1406
  buffers,
1129
- data => transport.writeWithRetry(data),
1407
+ data =>
1408
+ this.writeBlePacket(
1409
+ uuid,
1410
+ data,
1411
+ payload => transport.writeWithRetry(payload),
1412
+ isCurrentOwner
1413
+ ),
1130
1414
  e => {
1131
- this.runPromise = null;
1415
+ releaseOwnershipIfCurrent();
1132
1416
  Log?.error('writeCharacteristic write error: ', e);
1133
1417
  }
1134
1418
  );
@@ -1149,43 +1433,31 @@ export default class ReactNativeBleTransport {
1149
1433
  // eslint-disable-next-line no-constant-condition
1150
1434
  while (true) {
1151
1435
  try {
1152
- await transport.writeCharacteristic.writeWithoutResponse(data);
1436
+ await this.writeBlePacket(
1437
+ uuid,
1438
+ data,
1439
+ payload => transport.writeWithRetry(payload),
1440
+ isCurrentOwner
1441
+ );
1153
1442
  return;
1154
1443
  } catch (error) {
1155
1444
  const retryType = getFirmwareUploadWriteRetryType(error);
1156
1445
  if (!retryType || attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1157
1446
  throw error;
1158
1447
  }
1159
- const shouldReconnect = retryType === 'reconnectable';
1160
- const delayMs = shouldReconnect
1161
- ? FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS
1162
- : resolveFirmwareUploadRetryDelay(attempt);
1448
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1163
1449
  Log?.debug('[ReactNativeBleTransport] FirmwareUpload write retry:', {
1164
1450
  attempt: attempt + 1,
1165
1451
  delayMs,
1166
- reconnect: shouldReconnect,
1167
1452
  error,
1168
1453
  });
1169
- if (shouldReconnect) {
1170
- this.firmwareUploadWriteRecoveryIds.add(uuid);
1171
- }
1172
1454
  await delay(delayMs);
1173
1455
  attempt += 1;
1174
- if (shouldReconnect) {
1175
- try {
1176
- await this.reconnectFirmwareUploadTransport(uuid, transport);
1177
- } catch (e) {
1178
- Log?.debug('[ReactNativeBleTransport] FirmwareUpload reconnect error:', e);
1179
- if (attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1180
- throw e;
1181
- }
1182
- }
1183
- }
1184
1456
  }
1185
1457
  }
1186
1458
  },
1187
1459
  e => {
1188
- this.runPromise = null;
1460
+ releaseOwnershipIfCurrent();
1189
1461
  Log?.error('writeCharacteristic write error: ', e);
1190
1462
  }
1191
1463
  );
@@ -1194,10 +1466,23 @@ export default class ReactNativeBleTransport {
1194
1466
  const outData = o.toString('base64');
1195
1467
  // Upload resources on low-end phones may OOM
1196
1468
  try {
1197
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1469
+ const shouldUseWriteWithResponse =
1470
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1471
+ await this.writeBlePacket(
1472
+ uuid,
1473
+ outData,
1474
+ payload =>
1475
+ shouldUseWriteWithResponse
1476
+ ? transport.writeCharacteristic.writeWithResponse(payload)
1477
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
1478
+ isCurrentOwner
1479
+ );
1198
1480
  } catch (e) {
1199
1481
  Log?.debug('writeCharacteristic write error: ', e);
1200
- this.runPromise = null;
1482
+ releaseOwnershipIfCurrent();
1483
+ if (isWedgedWriteError(e)) {
1484
+ throw e;
1485
+ }
1201
1486
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1202
1487
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1203
1488
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1233,16 +1518,30 @@ export default class ReactNativeBleTransport {
1233
1518
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1234
1519
  return check.call(jsonData);
1235
1520
  } catch (e) {
1236
- if (name === 'Initialize' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1237
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe call failed:', e);
1521
+ if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1522
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1238
1523
  } else {
1239
1524
  Log?.error('call error: ', e);
1240
1525
  }
1526
+ const isProbeTimeout =
1527
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1528
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1529
+ // transport; its late timeout must not tear down the connection the current
1530
+ // call is actively using.
1531
+ const isStaleCall = this.runPromise !== runPromise;
1532
+ if (
1533
+ !isProbeTimeout &&
1534
+ !isStaleCall &&
1535
+ (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1536
+ ) {
1537
+ await this.disconnect(uuid);
1538
+ }
1241
1539
  throw e;
1242
1540
  } finally {
1243
1541
  if (timeout) clearTimeout(timeout);
1244
1542
  if (this.runPromise === runPromise) {
1245
1543
  this.runPromise = null;
1544
+ this.runPromiseDeviceId = null;
1246
1545
  }
1247
1546
  }
1248
1547
  }
@@ -1254,6 +1553,7 @@ export default class ReactNativeBleTransport {
1254
1553
  async disconnect(session: string) {
1255
1554
  await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1256
1555
  const transport = transportCache[session];
1556
+ const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1257
1557
 
1258
1558
  // Clean up disconnect subscription first to prevent onDisconnected callback
1259
1559
  // from being triggered when we cancel the connection below
@@ -1311,20 +1611,22 @@ export default class ReactNativeBleTransport {
1311
1611
  delete transportCache[session];
1312
1612
  }
1313
1613
  this.deviceProtocol.delete(session);
1614
+ this.probingProtocols.delete(session);
1314
1615
  this.deviceProtocolHints.delete(session);
1616
+ this.sessionProtocols.delete(session);
1617
+ this.protocolReprobeFailures.delete(session);
1315
1618
  this.protocolV2Assemblers.delete(session);
1316
1619
  this.resetProtocolV2Frames(session);
1317
1620
 
1318
1621
  // emit the disconnect event
1319
1622
  try {
1320
- this.emitter?.emit('device-disconnect', {
1321
- name: transport?.device?.name,
1322
- id: session,
1323
- connectId: session,
1324
- });
1623
+ this.emitDeviceDisconnect(session, transport?.device?.name, monitorToken);
1325
1624
  } catch (e) {
1326
1625
  Log?.error('resetSession: emit disconnect event error: ', e);
1327
1626
  }
1627
+ if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1628
+ this.monitorTokens.delete(session);
1629
+ }
1328
1630
  // eslint-disable-next-line no-promise-executor-return
1329
1631
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1330
1632
  }
@@ -1335,6 +1637,114 @@ export default class ReactNativeBleTransport {
1335
1637
  // this.runPromise.reject(new Error('Transport_CallCanceled'));
1336
1638
  }
1337
1639
  this.runPromise = null;
1640
+ this.runPromiseDeviceId = null;
1641
+ }
1642
+
1643
+ /** Run a native connect under the JS backstop budget. */
1644
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1645
+ let timer: ReturnType<typeof setTimeout> | undefined;
1646
+ let timedOut = false;
1647
+ const pending = connect();
1648
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1649
+ // surface as an unhandled rejection after we have already given up on it.
1650
+ pending.catch(() => undefined);
1651
+ try {
1652
+ const result = await Promise.race([
1653
+ pending,
1654
+ new Promise<never>((_, reject) => {
1655
+ timer = setTimeout(() => {
1656
+ timedOut = true;
1657
+ reject(
1658
+ ERRORS.TypedError(
1659
+ HardwareErrorCode.BleConnectedError,
1660
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1661
+ )
1662
+ );
1663
+ }, BLE_CONNECT_TIMEOUT_MS);
1664
+ }),
1665
+ ]);
1666
+ return result;
1667
+ } catch (error) {
1668
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1669
+ this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1670
+ }
1671
+ throw error;
1672
+ } finally {
1673
+ if (timer) clearTimeout(timer);
1674
+ }
1675
+ }
1676
+
1677
+ /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1678
+ private async resolveCharacteristicsWithTimeout(
1679
+ uuid: string,
1680
+ device: Device
1681
+ ): Promise<ResolvedBleCharacteristics> {
1682
+ let timer: ReturnType<typeof setTimeout> | undefined;
1683
+ let timedOut = false;
1684
+ const pending = this.resolveCharacteristics(device);
1685
+ pending.catch(() => undefined);
1686
+ try {
1687
+ const result = await Promise.race([
1688
+ pending,
1689
+ new Promise<never>((_, reject) => {
1690
+ timer = setTimeout(() => {
1691
+ timedOut = true;
1692
+ reject(
1693
+ ERRORS.TypedError(
1694
+ HardwareErrorCode.BleConnectedError,
1695
+ `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1696
+ )
1697
+ );
1698
+ }, BLE_GATT_SETUP_TIMEOUT_MS);
1699
+ }),
1700
+ ]);
1701
+ this.connectionSetupTimeoutCounts.delete(uuid);
1702
+ return result;
1703
+ } catch (error) {
1704
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1705
+ this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1706
+ }
1707
+ throw error;
1708
+ } finally {
1709
+ if (timer) clearTimeout(timer);
1710
+ }
1711
+ }
1712
+
1713
+ /**
1714
+ * Give up on a BLE setup operation the native layer did not settle. The abandoned
1715
+ * operation still owns native connection/GATT state that can poison the next attempt,
1716
+ * so it is cleared here without awaiting the same queue that stopped responding.
1717
+ */
1718
+ private abandonStalledConnection(
1719
+ uuid: string,
1720
+ stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1721
+ ) {
1722
+ const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1723
+ this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1724
+ Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1725
+ stage,
1726
+ setupTimeoutsSinceSuccess: timeouts,
1727
+ });
1728
+
1729
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1730
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1731
+ });
1732
+ const stalled = transportCache[uuid];
1733
+ if (stalled) {
1734
+ delete transportCache[uuid];
1735
+ }
1736
+ this.deviceProtocol.delete(uuid);
1737
+ this.probingProtocols.delete(uuid);
1738
+ this.protocolV2Assemblers.delete(uuid);
1739
+ this.resetProtocolV2Frames(uuid);
1740
+
1741
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1742
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1743
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1744
+ Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1745
+ this.resetPlxManager();
1746
+ this.connectionSetupTimeoutCounts.delete(uuid);
1747
+ }
1338
1748
  }
1339
1749
 
1340
1750
  private getCachedTransport(uuid: string) {
@@ -1345,6 +1755,109 @@ export default class ReactNativeBleTransport {
1345
1755
  return transport;
1346
1756
  }
1347
1757
 
1758
+ /**
1759
+ * Write one packet under a bounded budget. A write that never settles means the
1760
+ * peripheral is wedged even though the GATT link still reports connected, so the
1761
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1762
+ * cached and every later call would hang on it again.
1763
+ */
1764
+ private async writeBlePacket(
1765
+ uuid: string,
1766
+ data: string,
1767
+ write: (payload: string) => Promise<unknown>,
1768
+ isCurrentOwner?: () => boolean
1769
+ ) {
1770
+ let timer: ReturnType<typeof setTimeout> | undefined;
1771
+ let timedOut = false;
1772
+ try {
1773
+ await Promise.race([
1774
+ write(data),
1775
+ new Promise<never>((_, reject) => {
1776
+ timer = setTimeout(() => {
1777
+ timedOut = true;
1778
+ reject(
1779
+ ERRORS.TypedError(
1780
+ HardwareErrorCode.BleWriteCharacteristicError,
1781
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1782
+ )
1783
+ );
1784
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1785
+ }),
1786
+ ]);
1787
+ this.writeTimeoutCounts.delete(uuid);
1788
+ } catch (error) {
1789
+ if (timedOut) {
1790
+ // A superseded call's late write must not tear down the link the current
1791
+ // call is using; only the owner of the transport may declare it dead.
1792
+ if (isCurrentOwner && !isCurrentOwner()) {
1793
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1794
+ } else {
1795
+ this.tearDownWedgedLink(uuid);
1796
+ }
1797
+ }
1798
+ throw error;
1799
+ } finally {
1800
+ if (timer) clearTimeout(timer);
1801
+ }
1802
+ }
1803
+
1804
+ /**
1805
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1806
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1807
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1808
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1809
+ */
1810
+ private tearDownWedgedLink(uuid: string) {
1811
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1812
+ this.writeTimeoutCounts.set(uuid, timeouts);
1813
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1814
+ consecutiveWriteTimeouts: timeouts,
1815
+ });
1816
+
1817
+ const wedged = transportCache[uuid];
1818
+ this.disconnect(uuid).catch(error => {
1819
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1820
+ });
1821
+ if (wedged && transportCache[uuid] === wedged) {
1822
+ delete transportCache[uuid];
1823
+ }
1824
+ this.deviceProtocol.delete(uuid);
1825
+ this.probingProtocols.delete(uuid);
1826
+ this.protocolV2Assemblers.delete(uuid);
1827
+ this.resetProtocolV2Frames(uuid);
1828
+
1829
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1830
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1831
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1832
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1833
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1834
+ this.resetPlxManager();
1835
+ this.writeTimeoutCounts.delete(uuid);
1836
+ }
1837
+ }
1838
+
1839
+ private resetPlxManager() {
1840
+ const manager = this.blePlxManager;
1841
+ this.blePlxManager = undefined;
1842
+ // Every cached transport belongs to the destroyed manager's peripherals.
1843
+ Object.keys(transportCache).forEach(key => {
1844
+ delete transportCache[key];
1845
+ });
1846
+ this.deviceProtocol.clear();
1847
+ this.probingProtocols.clear();
1848
+ this.sessionProtocols.clear();
1849
+ this.protocolReprobeFailures.clear();
1850
+ this.writeTimeoutCounts.clear();
1851
+ this.connectionSetupTimeoutCounts.clear();
1852
+ this.monitorTokens.clear();
1853
+ this.protocolV2Assemblers.clear();
1854
+ try {
1855
+ manager?.destroy();
1856
+ } catch (error) {
1857
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1858
+ }
1859
+ }
1860
+
1348
1861
  private createProtocolMismatchError(expected: ProtocolType) {
1349
1862
  return ERRORS.TypedError(
1350
1863
  HardwareErrorCode.RuntimeError,
@@ -1355,24 +1868,44 @@ export default class ReactNativeBleTransport {
1355
1868
  private createProtocolDetectionError() {
1356
1869
  return ERRORS.TypedError(
1357
1870
  HardwareErrorCode.BleTimeoutError,
1358
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
1871
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
1359
1872
  );
1360
1873
  }
1361
1874
 
1362
1875
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1876
+ if (this.probingProtocols.get(uuid) === protocol) {
1877
+ this.probingProtocols.delete(uuid);
1878
+ }
1363
1879
  if (this.deviceProtocol.get(uuid) === protocol) {
1364
1880
  this.deviceProtocol.delete(uuid);
1365
1881
  }
1366
1882
  }
1367
1883
 
1884
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1885
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1886
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1887
+ }
1888
+
1368
1889
  private async detectProtocol(
1369
1890
  uuid: string,
1370
1891
  expectedProtocol?: ProtocolType,
1371
- protocolHint?: ProtocolType
1892
+ protocolHint?: ProtocolType,
1893
+ rebuildTransport?: () => Promise<void>
1372
1894
  ): Promise<ProtocolType> {
1895
+ if (Platform.OS === 'ios' && expectedProtocol) {
1896
+ this.deviceProtocol.set(uuid, expectedProtocol);
1897
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
1898
+ deviceId: uuid,
1899
+ protocol: expectedProtocol,
1900
+ source: 'expected',
1901
+ });
1902
+ return expectedProtocol;
1903
+ }
1904
+
1373
1905
  if (expectedProtocol === 'V1') {
1374
1906
  if (await this.probeProtocolV1(uuid)) {
1375
1907
  this.deviceProtocol.set(uuid, 'V1');
1908
+ this.sessionProtocols.set(uuid, 'V1');
1376
1909
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1377
1910
  deviceId: uuid,
1378
1911
  protocol: 'V1',
@@ -1384,33 +1917,52 @@ export default class ReactNativeBleTransport {
1384
1917
  }
1385
1918
 
1386
1919
  if (expectedProtocol === 'V2') {
1387
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
1388
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
1389
- this.deviceProtocol.set(uuid, 'V2');
1390
- Log?.debug('[ReactNativeBleTransport] protocol detected', {
1391
- deviceId: uuid,
1392
- protocol: 'V2',
1393
- source: 'expected',
1394
- });
1395
- return 'V2';
1920
+ if (await this.probeProtocolV2(uuid)) {
1921
+ this.deviceProtocol.set(uuid, 'V2');
1922
+ this.sessionProtocols.set(uuid, 'V2');
1923
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1924
+ deviceId: uuid,
1925
+ protocol: 'V2',
1926
+ source: 'expected',
1927
+ });
1928
+ return 'V2';
1929
+ }
1930
+ throw this.createProtocolMismatchError(expectedProtocol);
1396
1931
  }
1397
1932
 
1398
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
1399
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1
1400
- // 不能作为最终结论。
1401
- const probeOrder: ProtocolType[] =
1933
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
1934
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
1935
+ const sessionProtocol = this.sessionProtocols.get(uuid);
1936
+ const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1937
+ const fullProbeOrder: ProtocolType[] =
1402
1938
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1939
+ // A device that already answered on a protocol in this session keeps answering on
1940
+ // it; while it is rebooting nothing answers at all, so probing the other protocol
1941
+ // only adds its timeout to every poll.
1942
+ const trustSessionProtocol =
1943
+ sessionProtocol !== undefined &&
1944
+ !protocolHint &&
1945
+ reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1946
+ const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1403
1947
 
1404
1948
  for (let i = 0; i < probeOrder.length; i += 1) {
1405
1949
  const protocol = probeOrder[i];
1406
1950
  if (i > 0) {
1407
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
1951
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
1408
1952
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
1953
+ if (!transportCache[uuid]) {
1954
+ if (!rebuildTransport) {
1955
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1956
+ }
1957
+ await rebuildTransport();
1958
+ }
1409
1959
  }
1410
1960
  const detected =
1411
1961
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1412
1962
  if (detected) {
1413
1963
  this.deviceProtocol.set(uuid, protocol);
1964
+ this.sessionProtocols.set(uuid, protocol);
1965
+ this.protocolReprobeFailures.delete(uuid);
1414
1966
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1415
1967
  deviceId: uuid,
1416
1968
  protocol,
@@ -1420,7 +1972,16 @@ export default class ReactNativeBleTransport {
1420
1972
  }
1421
1973
  }
1422
1974
 
1975
+ if (trustSessionProtocol) {
1976
+ // Still silent on its own protocol: count it, and let the streak expire the
1977
+ // shortcut so a device that genuinely switched protocols is found again.
1978
+ this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1979
+ } else {
1980
+ this.protocolReprobeFailures.delete(uuid);
1981
+ }
1982
+
1423
1983
  this.deviceProtocol.delete(uuid);
1984
+ this.probingProtocols.delete(uuid);
1424
1985
  throw this.createProtocolDetectionError();
1425
1986
  }
1426
1987
 
@@ -1482,12 +2043,20 @@ export default class ReactNativeBleTransport {
1482
2043
  }
1483
2044
 
1484
2045
  try {
1485
- this.deviceProtocol.set(uuid, 'V1');
1486
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2046
+ this.probingProtocols.set(uuid, 'V1');
2047
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
2048
+ // session before Core has a chance to restore a hidden wallet.
2049
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2050
+ this.probingProtocols.delete(uuid);
1487
2051
  return true;
1488
2052
  } catch (error) {
1489
2053
  this.clearProbeProtocol(uuid, 'V1');
1490
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe failed:', error);
2054
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2055
+ // A wedged write already dropped the link, so probing another protocol on it
2056
+ // would only fail against a torn-down transport: surface the real cause.
2057
+ if (isWedgedWriteError(error)) {
2058
+ throw error;
2059
+ }
1491
2060
  return false;
1492
2061
  }
1493
2062
  }
@@ -1497,7 +2066,7 @@ export default class ReactNativeBleTransport {
1497
2066
  return false;
1498
2067
  }
1499
2068
 
1500
- this.deviceProtocol.set(uuid, 'V2');
2069
+ this.probingProtocols.set(uuid, 'V2');
1501
2070
  this.protocolV2Assemblers.get(uuid)?.reset();
1502
2071
  const detected = await probeProtocolV2Helper({
1503
2072
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1512,6 +2081,8 @@ export default class ReactNativeBleTransport {
1512
2081
  });
1513
2082
  if (!detected) {
1514
2083
  this.clearProbeProtocol(uuid, 'V2');
2084
+ } else {
2085
+ this.probingProtocols.delete(uuid);
1515
2086
  }
1516
2087
  return detected;
1517
2088
  }
@@ -1562,17 +2133,8 @@ export default class ReactNativeBleTransport {
1562
2133
  this.getProtocolV2FrameQueue(uuid).push(frame);
1563
2134
  }
1564
2135
 
1565
- private rejectAllProtocolV2Frames(error: Error) {
1566
- this.protocolV2FrameQueues.clear();
1567
- for (const framePromise of this.protocolV2FramePromises.values()) {
1568
- framePromise.reject(error);
1569
- }
1570
- this.protocolV2FramePromises.clear();
1571
- }
1572
-
1573
2136
  private resetProtocolV2Frames(uuid: string) {
1574
- this.protocolV2FrameQueues.delete(uuid);
1575
- this.protocolV2FramePromises.delete(uuid);
2137
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
1576
2138
  }
1577
2139
 
1578
2140
  private rejectProtocolV2Frames(uuid: string, error: Error) {
@@ -1601,19 +2163,95 @@ export default class ReactNativeBleTransport {
1601
2163
  }
1602
2164
  }
1603
2165
 
1604
- private async writeProtocolV2Frame(transport: BleTransport, frame: Uint8Array) {
2166
+ private async writeProtocolV2Packet(
2167
+ uuid: string,
2168
+ transport: BleTransport,
2169
+ base64: string,
2170
+ context: ProtocolV2CallContext,
2171
+ assertCurrentGeneration: () => void
2172
+ ) {
2173
+ const shouldUseWriteWithResponse = shouldWriteProtocolV2WithResponse({
2174
+ platform: Platform.OS,
2175
+ highThroughput: context.highThroughput,
2176
+ requestedWithResponse: context.writeWithResponse,
2177
+ characteristic: transport.writeCharacteristic,
2178
+ });
2179
+ let attempt = 0;
2180
+ for (;;) {
2181
+ assertCurrentGeneration();
2182
+ if (context.signal.aborted) {
2183
+ throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
2184
+ }
2185
+ try {
2186
+ await this.writeBlePacket(
2187
+ uuid,
2188
+ base64,
2189
+ payload =>
2190
+ shouldUseWriteWithResponse
2191
+ ? transport.writeCharacteristic.writeWithResponse(payload)
2192
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
2193
+ // Same rule as Protocol V1: a write from a superseded generation must not
2194
+ // tear down the link that the current generation is using.
2195
+ () => {
2196
+ try {
2197
+ assertCurrentGeneration();
2198
+ return !context.signal.aborted;
2199
+ } catch {
2200
+ return false;
2201
+ }
2202
+ }
2203
+ );
2204
+ assertCurrentGeneration();
2205
+ return;
2206
+ } catch (error) {
2207
+ if (
2208
+ getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2209
+ attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
2210
+ ) {
2211
+ throw error;
2212
+ }
2213
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
2214
+ attempt += 1;
2215
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 congested write retry:', {
2216
+ name: context.messageName,
2217
+ attempt,
2218
+ delayMs,
2219
+ });
2220
+ await delay(delayMs);
2221
+ }
2222
+ }
2223
+ }
2224
+
2225
+ private async writeProtocolV2Frame(
2226
+ uuid: string,
2227
+ transport: BleTransport,
2228
+ frame: Uint8Array,
2229
+ context: ProtocolV2CallContext,
2230
+ assertCurrentGeneration: () => void
2231
+ ) {
1605
2232
  const tuning = getProtocolV2BleTuning();
1606
2233
  const packetCapacity = resolveProtocolV2PacketCapacity({
1607
2234
  platform: Platform.OS,
1608
2235
  iosPacketLength: tuning.iosPacketLength,
1609
2236
  androidPacketLength: tuning.androidPacketLength,
1610
- mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
2237
+ mtu: transport.mtuSize,
2238
+ });
2239
+ await writeProtocolV2BleFrame({
2240
+ frame,
2241
+ packetCapacity,
2242
+ assertActive: assertCurrentGeneration,
2243
+ signal: context.signal,
2244
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
2245
+ wait: delay,
2246
+ writePacket: packet =>
2247
+ this.writeProtocolV2Packet(
2248
+ uuid,
2249
+ transport,
2250
+ Buffer.from(packet).toString('base64'),
2251
+ context,
2252
+ assertCurrentGeneration
2253
+ ),
1611
2254
  });
1612
- for (let offset = 0; offset < frame.length; offset += packetCapacity) {
1613
- const chunk = frame.slice(offset, offset + packetCapacity);
1614
- const base64 = Buffer.from(chunk).toString('base64');
1615
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1616
- }
1617
2255
  }
1618
2256
 
1619
2257
  private async callProtocolV2(
@@ -1626,19 +2264,45 @@ export default class ReactNativeBleTransport {
1626
2264
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1627
2265
  }
1628
2266
 
1629
- const callOptions = {
1630
- ...options,
1631
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
1632
- };
1633
- const highVolumeWrite = LogBlockCommand.has(name);
2267
+ const callOptions = options;
2268
+ const highThroughputWrite = isProtocolV2HighThroughputCall(name);
1634
2269
 
1635
- if (highVolumeWrite) {
2270
+ if (highThroughputWrite) {
2271
+ await this.ensureProtocolV2HighThroughputMtu(uuid);
1636
2272
  const tuning = getProtocolV2BleTuning();
1637
- Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1638
- name,
1639
- writeMode: 'withoutResponse',
1640
- packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
2273
+ const currentTransport = this.getCachedTransport(uuid);
2274
+ const writeWithResponse = shouldWriteProtocolV2WithResponse({
2275
+ platform: Platform.OS,
2276
+ highThroughput: true,
2277
+ requestedWithResponse: options?.writeWithResponse,
2278
+ characteristic: currentTransport.writeCharacteristic,
2279
+ });
2280
+ const packetCapacity = resolveProtocolV2PacketCapacity({
2281
+ platform: Platform.OS,
2282
+ iosPacketLength: tuning.iosPacketLength,
2283
+ androidPacketLength: tuning.androidPacketLength,
2284
+ mtu: currentTransport.mtuSize,
1641
2285
  });
2286
+ const writeMode = writeWithResponse ? 'withResponse' : 'withoutResponse';
2287
+ const logSignature = `${name}:${writeMode}:${String(
2288
+ currentTransport.mtuSize
2289
+ )}:${packetCapacity}`;
2290
+ const loggedSignatures =
2291
+ this.protocolV2HighVolumeLogSignatures.get(uuid) ?? new Set<string>();
2292
+ if (!loggedSignatures.has(logSignature)) {
2293
+ loggedSignatures.add(logSignature);
2294
+ this.protocolV2HighVolumeLogSignatures.set(uuid, loggedSignatures);
2295
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2296
+ name,
2297
+ writeMode,
2298
+ reportedMtu: currentTransport.mtuSize,
2299
+ packetCapacity,
2300
+ });
2301
+ }
2302
+ }
2303
+
2304
+ if (highThroughputWrite) {
2305
+ await this.enableAndroidHighConnectionPriority(uuid);
1642
2306
  }
1643
2307
 
1644
2308
  try {
@@ -1652,6 +2316,90 @@ export default class ReactNativeBleTransport {
1652
2316
  } catch (e) {
1653
2317
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1654
2318
  throw e;
2319
+ } finally {
2320
+ if (highThroughputWrite) {
2321
+ this.scheduleAndroidBalancedConnectionPriority(uuid);
2322
+ }
2323
+ }
2324
+ }
2325
+
2326
+ private async ensureProtocolV2HighThroughputMtu(uuid: string) {
2327
+ const transport = this.getCachedTransport(uuid);
2328
+ if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2329
+
2330
+ const refreshedDevice = await requestNegotiatedMtu(transport.device, 'highThroughput', 1);
2331
+ transport.device = refreshedDevice;
2332
+ transport.mtuSize =
2333
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
2334
+
2335
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
2336
+ throw ERRORS.TypedError(
2337
+ HardwareErrorCode.BleConnectedError,
2338
+ `Protocol V2 high-throughput BLE MTU unavailable: ${String(transport.mtuSize)}`
2339
+ );
2340
+ }
2341
+ }
2342
+
2343
+ private clearAndroidPriorityResetTimer(uuid: string) {
2344
+ const timerId = this.androidPriorityResetTimers.get(uuid);
2345
+ if (timerId !== undefined) {
2346
+ clearTimeout(timerId);
2347
+ this.androidPriorityResetTimers.delete(uuid);
2348
+ }
2349
+ }
2350
+
2351
+ private async enableAndroidHighConnectionPriority(uuid: string) {
2352
+ if (Platform.OS !== 'android') return;
2353
+
2354
+ this.clearAndroidPriorityResetTimer(uuid);
2355
+ if (this.androidHighPriorityDevices.has(uuid)) return;
2356
+
2357
+ const transport = transportCache[uuid];
2358
+ if (!transport) return;
2359
+
2360
+ try {
2361
+ transport.device = await transport.device.requestConnectionPriority(ConnectionPriority.High);
2362
+ this.androidHighPriorityDevices.add(uuid);
2363
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2364
+ priority: 'high',
2365
+ });
2366
+ } catch (error) {
2367
+ Log?.debug('[ReactNativeBleTransport] Android BLE high priority request failed', {
2368
+ error: error instanceof Error ? error.message : String(error),
2369
+ });
2370
+ }
2371
+ }
2372
+
2373
+ private scheduleAndroidBalancedConnectionPriority(uuid: string) {
2374
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.has(uuid)) return;
2375
+
2376
+ this.clearAndroidPriorityResetTimer(uuid);
2377
+ const timerId = setTimeout(() => {
2378
+ this.androidPriorityResetTimers.delete(uuid);
2379
+ this.restoreAndroidConnectionPriority(uuid, transportCache[uuid]).catch(error =>
2380
+ Log?.debug('[ReactNativeBleTransport] Android BLE priority restore failed', error)
2381
+ );
2382
+ }, ANDROID_HIGH_PRIORITY_IDLE_MS);
2383
+ this.androidPriorityResetTimers.set(uuid, timerId);
2384
+ }
2385
+
2386
+ private async restoreAndroidConnectionPriority(uuid: string, transport?: BleTransport) {
2387
+ this.clearAndroidPriorityResetTimer(uuid);
2388
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.delete(uuid) || !transport) {
2389
+ return;
2390
+ }
2391
+
2392
+ try {
2393
+ transport.device = await transport.device.requestConnectionPriority(
2394
+ ConnectionPriority.Balanced
2395
+ );
2396
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
2397
+ priority: 'balanced',
2398
+ });
2399
+ } catch (error) {
2400
+ Log?.debug('[ReactNativeBleTransport] Android BLE balanced priority request failed', {
2401
+ error: error instanceof Error ? error.message : String(error),
2402
+ });
1655
2403
  }
1656
2404
  }
1657
2405
 
@@ -1672,10 +2420,16 @@ export default class ReactNativeBleTransport {
1672
2420
  this.protocolV2Assemblers.get(uuid)?.reset();
1673
2421
  this.resetProtocolV2Frames(uuid);
1674
2422
  },
1675
- writeFrame: async (frame: Uint8Array) => {
2423
+ writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1676
2424
  assertCurrentGeneration();
1677
2425
  const currentTransport = this.getCachedTransport(uuid);
1678
- await this.writeProtocolV2Frame(currentTransport, frame);
2426
+ await this.writeProtocolV2Frame(
2427
+ uuid,
2428
+ currentTransport,
2429
+ frame,
2430
+ context,
2431
+ assertCurrentGeneration
2432
+ );
1679
2433
  },
1680
2434
  readFrame: async () => {
1681
2435
  assertCurrentGeneration();
@@ -1700,6 +2454,6 @@ export default class ReactNativeBleTransport {
1700
2454
  }
1701
2455
 
1702
2456
  getProtocolType(path: string): ProtocolType | undefined {
1703
- return this.deviceProtocol.get(path);
2457
+ return this.getActiveProtocol(path);
1704
2458
  }
1705
2459
  }