@onekeyfe/hd-transport-react-native 1.2.0-alpha.6 → 1.2.0-alpha.63

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
@@ -12,26 +12,30 @@ import transport, {
12
12
  LogBlockCommand,
13
13
  type OneKeyDeviceInfoBase,
14
14
  PROTOCOL_V1_MESSAGE_HEADER_SIZE,
15
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
15
16
  PROTOCOL_V2_CHANNEL_BLE_UART,
16
17
  type ProtocolType,
18
+ type ProtocolV2CallContext,
17
19
  ProtocolV2FrameAssembler,
18
- ProtocolV2Session,
20
+ ProtocolV2LinkManager,
21
+ TRANSPORT_EVENT,
19
22
  type TransportCallOptions,
20
23
  probeProtocolV2 as probeProtocolV2Helper,
24
+ writeProtocolV2BleFrame,
21
25
  } from '@onekeyfe/hd-transport';
22
- import { ERRORS, HardwareErrorCode, createDeferred, isOnekeyDevice } from '@onekeyfe/hd-shared';
26
+ import {
27
+ ERRORS,
28
+ HardwareErrorCode,
29
+ createDeferred,
30
+ isOnekeyBluetoothDevice,
31
+ } from '@onekeyfe/hd-shared';
23
32
 
24
33
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
25
- import {
26
- hasWritableCapability,
27
- resolveBleWriteMode,
28
- resolveProtocolV2PacketCapacity,
29
- } from './bleStrategy';
34
+ import { hasWritableCapability, resolveProtocolV2PacketCapacity } from './bleStrategy';
30
35
  import { subscribeBleOn } from './subscribeBleOn';
31
36
  import {
32
37
  ANDROID_PACKET_LENGTH,
33
38
  IOS_PACKET_LENGTH,
34
- getBleUuidKey,
35
39
  getBluetoothServiceUuids,
36
40
  getInfosForServiceUuid,
37
41
  isSameBleUuid,
@@ -40,6 +44,7 @@ import { isHeaderChunk } from './utils/validateNotify';
40
44
  import BleTransport from './BleTransport';
41
45
  import timer from './utils/timer';
42
46
  import { bleLogger, setBleLogger } from './logger';
47
+ import { createTransportCallLog } from './transportLog';
43
48
 
44
49
  import type { Deferred } from '@onekeyfe/hd-shared';
45
50
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
@@ -55,13 +60,12 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
55
60
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
56
61
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
57
62
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
58
- const FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS = 2000;
59
63
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
60
64
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
61
65
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
62
66
  const ANDROID_GATT_CONGESTED_STATUS = 143;
63
67
 
64
- type FirmwareUploadWriteRetryType = 'congested' | 'reconnectable';
68
+ type FirmwareUploadWriteRetryType = 'congested';
65
69
  type ResolvedBleCharacteristics = {
66
70
  writeCharacteristic: Characteristic;
67
71
  notifyCharacteristic: Characteristic;
@@ -72,7 +76,9 @@ const delay = (ms: number) =>
72
76
  setTimeout(resolve, ms);
73
77
  });
74
78
 
75
- const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRetryType | null => {
79
+ export const getFirmwareUploadWriteRetryType = (
80
+ error: unknown
81
+ ): FirmwareUploadWriteRetryType | null => {
76
82
  if (!error || typeof error !== 'object') return null;
77
83
  const bleWriteError = error as {
78
84
  androidErrorCode?: unknown;
@@ -83,13 +89,6 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
83
89
  name?: unknown;
84
90
  };
85
91
 
86
- if (
87
- bleWriteError.errorCode === BleErrorCode.DeviceDisconnected ||
88
- bleWriteError.errorCode === BleErrorCode.CharacteristicNotFound
89
- ) {
90
- return 'reconnectable';
91
- }
92
-
93
92
  if (
94
93
  bleWriteError.androidErrorCode === ANDROID_GATT_CONGESTED_STATUS ||
95
94
  bleWriteError.status === ANDROID_GATT_CONGESTED_STATUS
@@ -105,23 +104,30 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
105
104
 
106
105
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
107
106
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
108
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
109
107
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
110
108
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
111
- const DEVICE_SCAN_TIMEOUT_MS = 8000;
109
+ /**
110
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
111
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
112
+ * stops reporting ready while staying connected, so the write promise never settles.
113
+ * Response timeouts cannot cover that — they are armed after the writes complete —
114
+ * and an unbounded write leaves the whole transport unusable until the process dies.
115
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
116
+ */
117
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
118
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
119
+ const isWedgedWriteError = (error: unknown): boolean =>
120
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
121
+ typeof (error as { message?: unknown })?.message === 'string' &&
122
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
123
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
124
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
125
+ const DEVICE_SCAN_TIMEOUT_MS = 3000;
112
126
  const IOS_NOTIFY_READY_DELAY_MS = 150;
113
127
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
114
- const HIGH_VOLUME_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 6;
115
- const HIGH_VOLUME_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 6 : 2;
116
- const HIGH_VOLUME_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 20 : 8;
117
-
118
128
  export type ProtocolV2BleTuning = {
119
129
  iosPacketLength?: number;
120
130
  androidPacketLength?: number;
121
- highVolumeWriteBurstSize?: number;
122
- highVolumeWritePauseMs?: number;
123
- highVolumeWriteFlushDelayMs?: number;
124
- highVolumeWriteWithResponse?: boolean;
125
131
  };
126
132
 
127
133
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
@@ -129,10 +135,6 @@ type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
129
135
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
130
136
  iosPacketLength: IOS_PACKET_LENGTH,
131
137
  androidPacketLength: ANDROID_PACKET_LENGTH,
132
- highVolumeWriteBurstSize: HIGH_VOLUME_WRITE_BURST_SIZE,
133
- highVolumeWritePauseMs: HIGH_VOLUME_WRITE_PAUSE_MS,
134
- highVolumeWriteFlushDelayMs: HIGH_VOLUME_WRITE_FLUSH_DELAY_MS,
135
- highVolumeWriteWithResponse: false,
136
138
  };
137
139
 
138
140
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -153,27 +155,13 @@ export function configureProtocolV2BleTuning(tuning: ProtocolV2BleTuning = {}) {
153
155
  tuning.androidPacketLength,
154
156
  protocolV2BleTuning.androidPacketLength
155
157
  ),
156
- highVolumeWriteBurstSize: normalizePositiveInteger(
157
- tuning.highVolumeWriteBurstSize,
158
- protocolV2BleTuning.highVolumeWriteBurstSize
159
- ),
160
- highVolumeWritePauseMs: normalizePositiveInteger(
161
- tuning.highVolumeWritePauseMs,
162
- protocolV2BleTuning.highVolumeWritePauseMs
163
- ),
164
- highVolumeWriteFlushDelayMs: normalizePositiveInteger(
165
- tuning.highVolumeWriteFlushDelayMs,
166
- protocolV2BleTuning.highVolumeWriteFlushDelayMs
167
- ),
168
- highVolumeWriteWithResponse:
169
- tuning.highVolumeWriteWithResponse ?? protocolV2BleTuning.highVolumeWriteWithResponse,
170
158
  };
171
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning configured:', protocolV2BleTuning);
159
+ Log?.debug('[ReactNativeBleTransport] BLE tuning configured', protocolV2BleTuning);
172
160
  }
173
161
 
174
162
  export function resetProtocolV2BleTuning() {
175
163
  protocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
176
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning reset:', protocolV2BleTuning);
164
+ Log?.debug('[ReactNativeBleTransport] BLE tuning reset', protocolV2BleTuning);
177
165
  }
178
166
 
179
167
  export function getProtocolV2BleTuning() {
@@ -188,24 +176,37 @@ function getDeviceDisplayName(device?: Device | null) {
188
176
  return device?.name || device?.localName || null;
189
177
  }
190
178
 
191
- function isGenericBleService(uuid?: string | null) {
192
- return ['1800', '1801', '180a'].includes(getBleUuidKey(uuid));
193
- }
194
-
195
- function hasKnownOneKeyService(device?: Device | null) {
196
- return (device?.serviceUUIDs ?? []).some(serviceUuid =>
197
- getInfosForServiceUuid(serviceUuid, 'classic')
198
- );
199
- }
200
-
201
179
  const ANDROID_REQUEST_MTU = 256;
202
180
 
181
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
182
+
203
183
  const connectOptions: Record<string, unknown> = {
204
184
  requestMTU: ANDROID_REQUEST_MTU,
205
- timeout: 3000,
185
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
206
186
  refreshGatt: 'OnConnected',
207
187
  };
208
188
 
189
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
190
+ const fallbackConnectOptions: Record<string, unknown> = {
191
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
192
+ };
193
+
194
+ /**
195
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
196
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
197
+ * firmware install tears the link down) can leave the promise unsettled — observed
198
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
199
+ * inside the native budget, so this only fires when the native timeout did not.
200
+ */
201
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
202
+ /** Consecutive connect timeouts on one device before the BLE manager itself is recreated. */
203
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
204
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
205
+ const isConnectTimeoutError = (error: unknown): boolean =>
206
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
207
+ typeof (error as { message?: unknown })?.message === 'string' &&
208
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
209
+
209
210
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
210
211
 
211
212
  const tryToGetConfiguration = (device: Device) => {
@@ -222,9 +223,10 @@ const requestAndroidMtu = async (device: Device) => {
222
223
 
223
224
  try {
224
225
  const mtuDevice = await device.requestMTU(ANDROID_REQUEST_MTU);
225
- Log?.debug('[ReactNativeBleTransport] Android MTU requested:', {
226
+ Log?.debug('[ReactNativeBleTransport] MTU configured', {
227
+ deviceId: device.id,
226
228
  requested: ANDROID_REQUEST_MTU,
227
- mtu: mtuDevice.mtu,
229
+ actual: mtuDevice.mtu,
228
230
  });
229
231
  return mtuDevice;
230
232
  } catch (error) {
@@ -272,6 +274,8 @@ export default class ReactNativeBleTransport {
272
274
 
273
275
  _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
274
276
 
277
+ private protocolV2SchemaConfiguration: string | undefined;
278
+
275
279
  name = 'ReactNativeBleTransport';
276
280
 
277
281
  configured = false;
@@ -282,6 +286,8 @@ export default class ReactNativeBleTransport {
282
286
 
283
287
  runPromise: Deferred<any> | null = null;
284
288
 
289
+ private runPromiseDeviceId: string | null = null;
290
+
285
291
  emitter?: EventEmitter;
286
292
 
287
293
  firmwareUploadWriteRecoveryIds = new Set<string>();
@@ -289,6 +295,20 @@ export default class ReactNativeBleTransport {
289
295
  /** Per-device protocol type detected by active wire-level probe after connect. */
290
296
  private deviceProtocol: Map<string, ProtocolType> = new Map();
291
297
 
298
+ /**
299
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
300
+ * must route with it, but acquire() must not treat it as a detected protocol: a
301
+ * probe that never answers would otherwise leave the reuse fast path handing out a
302
+ * transport that was never validated.
303
+ */
304
+ private probingProtocols: Map<string, ProtocolType> = new Map();
305
+
306
+ /** Consecutive write timeouts per device; reset by any write that completes. */
307
+ private writeTimeoutCounts: Map<string, number> = new Map();
308
+
309
+ /** Consecutive connect timeouts per device; reset by any connect that settles. */
310
+ private connectTimeoutCounts: Map<string, number> = new Map();
311
+
292
312
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
293
313
 
294
314
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -297,12 +317,31 @@ export default class ReactNativeBleTransport {
297
317
 
298
318
  private protocolV2FramePromises: Map<string, Deferred<Uint8Array>> = new Map();
299
319
 
300
- private activeProtocolV2Call: { uuid: string; token: number } | null = null;
301
-
302
- private nextProtocolV2CallToken = 1;
320
+ private protocolV2Links = new ProtocolV2LinkManager<string>({
321
+ getSchemas: () => {
322
+ if (!this._messages || !this._messagesV2) {
323
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
324
+ }
325
+ return {
326
+ protocolV1: this._messages,
327
+ protocolV2: this._messagesV2,
328
+ };
329
+ },
330
+ classifyError: () => 'link-fatal',
331
+ onLinkInvalidated: async (uuid, reason) => {
332
+ this.protocolV2Assemblers.get(uuid)?.reset();
333
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
334
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
335
+ if (reason.startsWith('Protocol V2 link-fatal error:')) {
336
+ await this.releaseNative(uuid, true);
337
+ }
338
+ },
339
+ });
303
340
 
304
341
  private monitorTokens: Map<string, number> = new Map();
305
342
 
343
+ private disconnectEventTokens: Map<string, number> = new Map();
344
+
306
345
  private nextMonitorToken = 1;
307
346
 
308
347
  constructor(options: TransportOptions) {
@@ -321,8 +360,19 @@ export default class ReactNativeBleTransport {
321
360
  }
322
361
 
323
362
  configureProtocolV2(signedData: any) {
363
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
364
+ if (this.protocolV2SchemaConfiguration === configuration) {
365
+ return;
366
+ }
367
+
368
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
324
369
  this._messagesV2 = parseConfigure(signedData);
325
- Log?.debug('[ReactNativeBleTransport] Protocol V2 schema configured');
370
+ this.protocolV2SchemaConfiguration = configuration;
371
+ if (isReconfiguration) {
372
+ this.protocolV2Links
373
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
374
+ .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
375
+ }
326
376
  }
327
377
 
328
378
  listen() {
@@ -352,29 +402,15 @@ export default class ReactNativeBleTransport {
352
402
  }
353
403
  }
354
404
 
355
- let fallbackServiceUuid: string | undefined;
356
-
357
405
  if (!infos) {
358
406
  const services = await device.services();
359
407
  Log?.debug(
360
408
  '[ReactNativeBleTransport] Known OneKey service UUID not found, discovered services:',
361
409
  services?.map(service => service.uuid)
362
410
  );
363
-
364
- const knownService = services.find(service =>
365
- getInfosForServiceUuid(service.uuid, 'classic')
366
- );
367
- const fallbackService =
368
- knownService ?? services.find(service => !isGenericBleService(service.uuid)) ?? services[0];
369
-
370
- if (fallbackService) {
371
- fallbackServiceUuid = fallbackService.uuid;
372
- characteristics = await device.characteristicsForService(fallbackService.uuid);
373
- Log?.debug('[ReactNativeBleTransport] Using fallback BLE service:', fallbackService.uuid);
374
- }
375
411
  }
376
412
 
377
- if (!infos && !fallbackServiceUuid) {
413
+ if (!infos) {
378
414
  try {
379
415
  Log?.debug('cancel connection when service not found');
380
416
  await device.cancelConnection();
@@ -384,9 +420,7 @@ export default class ReactNativeBleTransport {
384
420
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
385
421
  }
386
422
 
387
- const serviceUuid = infos?.serviceUuid ?? fallbackServiceUuid;
388
- const writeUuid = infos?.writeUuid ?? '00000002-0000-1000-8000-00805f9b34fb';
389
- const notifyUuid = infos?.notifyUuid ?? '00000003-0000-1000-8000-00805f9b34fb';
423
+ const { serviceUuid, writeUuid, notifyUuid } = infos;
390
424
 
391
425
  if (!serviceUuid) {
392
426
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
@@ -436,6 +470,7 @@ export default class ReactNativeBleTransport {
436
470
 
437
471
  attachDisconnectSubscription(transport: BleTransport, device: Device, uuid: string) {
438
472
  transport.disconnectSubscription?.remove();
473
+ const { monitorToken } = transport;
439
474
  transport.disconnectSubscription = device.onDisconnected(() => {
440
475
  if (this.firmwareUploadWriteRecoveryIds.has(uuid)) {
441
476
  Log?.debug('device disconnect ignored during FirmwareUpload write recovery: ', uuid);
@@ -445,18 +480,17 @@ export default class ReactNativeBleTransport {
445
480
  Log?.debug('device disconnect ignored for stale transport: ', device?.id);
446
481
  return;
447
482
  }
483
+ if (this.monitorTokens.get(uuid) !== monitorToken) {
484
+ Log?.debug('device disconnect ignored for stale generation: ', device?.id);
485
+ return;
486
+ }
448
487
 
449
488
  try {
450
489
  Log?.debug('device disconnect: ', device?.id);
451
- this.emitter?.emit('device-disconnect', {
452
- name: device?.name,
453
- id: device?.id,
454
- connectId: device?.id,
455
- });
456
- if (this.runPromise) {
490
+ this.emitDeviceDisconnect(uuid, device?.name, monitorToken);
491
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
457
492
  const error = ERRORS.TypedError(HardwareErrorCode.BleConnectedError);
458
493
  this.runPromise.reject(error);
459
- this.rejectAllProtocolV2Frames(error);
460
494
  }
461
495
  } catch (e) {
462
496
  Log?.debug('device disconnect error: ', e);
@@ -466,6 +500,22 @@ export default class ReactNativeBleTransport {
466
500
  });
467
501
  }
468
502
 
503
+ private emitDeviceDisconnect(uuid: string, name: string | null | undefined, token?: number) {
504
+ if (token === undefined || this.disconnectEventTokens.get(uuid) === token) {
505
+ return;
506
+ }
507
+ if (this.monitorTokens.get(uuid) !== token) {
508
+ Log?.debug('device disconnect event ignored for stale generation: ', uuid);
509
+ return;
510
+ }
511
+ this.disconnectEventTokens.set(uuid, token);
512
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
513
+ name,
514
+ id: uuid,
515
+ connectId: uuid,
516
+ });
517
+ }
518
+
469
519
  async reconnectFirmwareUploadTransport(uuid: string, transport: BleTransport) {
470
520
  this.firmwareUploadWriteRecoveryIds.add(uuid);
471
521
  try {
@@ -478,13 +528,13 @@ export default class ReactNativeBleTransport {
478
528
  const isConnected = await device.isConnected().catch(() => false);
479
529
  if (!isConnected) {
480
530
  try {
481
- device = await device.connect(connectOptions);
531
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
482
532
  } catch (e) {
483
533
  if (
484
534
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
485
535
  e.errorCode === BleErrorCode.OperationCancelled
486
536
  ) {
487
- device = await device.connect();
537
+ device = await this.connectWithTimeout(uuid, () => device.connect());
488
538
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
489
539
  throw e;
490
540
  }
@@ -553,14 +603,13 @@ export default class ReactNativeBleTransport {
553
603
  }
554
604
 
555
605
  blePlxManager.startDeviceScan(
556
- null,
606
+ getBluetoothServiceUuids(),
557
607
  {
558
608
  allowDuplicates: true,
559
609
  scanMode: ScanMode.LowLatency,
560
610
  },
561
611
  (error, device) => {
562
612
  if (error) {
563
- Log?.debug('ble scan manager: ', blePlxManager);
564
613
  Log?.debug('ble scan error: ', error);
565
614
  if (
566
615
  [BleErrorCode.BluetoothPoweredOff, BleErrorCode.BluetoothInUnknownState].includes(
@@ -584,33 +633,14 @@ export default class ReactNativeBleTransport {
584
633
  }
585
634
 
586
635
  const displayName = getDeviceDisplayName(device);
587
- const isOneKey =
588
- isOnekeyDevice(device?.name ?? null, device?.id) ||
589
- isOnekeyDevice(device?.localName ?? null, device?.id) ||
590
- hasKnownOneKeyService(device);
591
- const shouldTraceCandidate =
592
- !!displayName && /onekey|bixinkey|pro\s*2|pro\b|touch|^k\d|^t\d/i.test(displayName);
593
-
594
- if (shouldTraceCandidate) {
595
- Log?.debug('[ReactNativeBleTransport] scan candidate', {
596
- name: device?.name,
597
- localName: device?.localName,
598
- id: device?.id,
599
- serviceUUIDs: device?.serviceUUIDs,
600
- accepted: isOneKey,
601
- });
602
- }
603
-
636
+ const isOneKey = isOnekeyBluetoothDevice({
637
+ id: device?.id,
638
+ name: device?.name,
639
+ localName: device?.localName,
640
+ serviceUuids: device?.serviceUUIDs,
641
+ });
604
642
  if (isOneKey) {
605
- Log?.debug('search device start ======================');
606
- const { name, localName, id, serviceUUIDs } = device ?? {};
607
- Log?.debug(
608
- `device name: ${name ?? ''}\nlocalName: ${localName ?? ''}\nid: ${
609
- id ?? ''
610
- }\nserviceUUIDs: ${(serviceUUIDs ?? []).join(',')}`
611
- );
612
643
  addDevice(device as unknown as Device);
613
- Log?.debug('search device end ======================\n');
614
644
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
615
645
  Log?.debug('[ReactNativeBleTransport] Pro2-like BLE device was not accepted:', {
616
646
  name: device?.name,
@@ -622,12 +652,27 @@ export default class ReactNativeBleTransport {
622
652
  }
623
653
  );
624
654
 
625
- getConnectedDeviceIds(getBluetoothServiceUuids()).then(devices => {
626
- for (const device of devices) {
627
- Log?.debug('search connected peripheral: ', device.id);
628
- addDevice(device as unknown as Device);
655
+ getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
656
+ devices => {
657
+ for (const device of devices) {
658
+ const localName =
659
+ 'localName' in device && typeof device.localName === 'string'
660
+ ? device.localName
661
+ : null;
662
+ if (
663
+ isOnekeyBluetoothDevice({
664
+ id: device.id,
665
+ name: device.name,
666
+ localName,
667
+ serviceUuids: device.serviceUUIDs,
668
+ })
669
+ ) {
670
+ Log?.debug('search connected peripheral: ', device.id);
671
+ addDevice(device as unknown as Device);
672
+ }
673
+ }
629
674
  }
630
- });
675
+ );
631
676
 
632
677
  const addDevice = (device: Device) => {
633
678
  if (deviceList.every(d => d.id !== device.id)) {
@@ -641,6 +686,12 @@ export default class ReactNativeBleTransport {
641
686
  name: displayName,
642
687
  commType: 'ble',
643
688
  } as IOneKeyDevice);
689
+ Log?.debug('[ReactNativeBleTransport] OneKey BLE device discovered', {
690
+ deviceId: device.id,
691
+ name: displayName,
692
+ serviceUUIDs: device.serviceUUIDs,
693
+ protocolHint,
694
+ });
644
695
  }
645
696
  };
646
697
 
@@ -651,6 +702,46 @@ export default class ReactNativeBleTransport {
651
702
  });
652
703
  }
653
704
 
705
+ private async installTransportForAcquire(
706
+ uuid: string,
707
+ device: Device,
708
+ characteristics?: ResolvedBleCharacteristics
709
+ ) {
710
+ const { writeCharacteristic, notifyCharacteristic } =
711
+ characteristics ?? (await this.resolveCharacteristics(device));
712
+ const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
713
+ if (Platform.OS === 'android') {
714
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
715
+ }
716
+ const monitorToken = this.nextMonitorToken;
717
+ this.nextMonitorToken += 1;
718
+ const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
719
+ transport.monitorToken = monitorToken;
720
+ transport.notifyTransactionId = notifyTransactionId;
721
+ this.monitorTokens.set(uuid, monitorToken);
722
+ transport.notifySubscription = this._monitorCharacteristic(
723
+ transport.notifyCharacteristic,
724
+ uuid,
725
+ monitorToken,
726
+ notifyTransactionId
727
+ );
728
+ transportCache[uuid] = transport;
729
+ this.protocolV2Assemblers.set(
730
+ uuid,
731
+ new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
732
+ );
733
+
734
+ if (Platform.OS === 'ios') {
735
+ await new Promise<void>(resolve => {
736
+ setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
737
+ });
738
+ } else if (Platform.OS === 'android') {
739
+ await delay(ANDROID_NOTIFY_READY_DELAY_MS);
740
+ }
741
+
742
+ return transport;
743
+ }
744
+
654
745
  async acquire(input: BleAcquireInput) {
655
746
  const { uuid, forceCleanRunPromise, expectedProtocol } = input;
656
747
 
@@ -684,9 +775,8 @@ export default class ReactNativeBleTransport {
684
775
  if (forceCleanRunPromise && this.runPromise) {
685
776
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
686
777
  this.runPromise.reject(error);
687
- this.rejectAllProtocolV2Frames(error);
688
778
  this.runPromise = null;
689
- this.activeProtocolV2Call = null;
779
+ this.runPromiseDeviceId = null;
690
780
  Log?.debug('Force clean Bluetooth run promise, forceCleanRunPromise: ', forceCleanRunPromise);
691
781
  }
692
782
 
@@ -722,15 +812,22 @@ export default class ReactNativeBleTransport {
722
812
  if (!device) {
723
813
  Log?.debug('try to connect to device: ', uuid);
724
814
  try {
725
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
815
+ device = await this.connectWithTimeout(uuid, () =>
816
+ blePlxManager.connectToDevice(uuid, connectOptions)
817
+ );
726
818
  } catch (e) {
727
819
  Log?.debug('try to connect to device has error: ', e);
820
+ if (isConnectTimeoutError(e)) {
821
+ throw e;
822
+ }
728
823
  if (
729
824
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
730
825
  e.errorCode === BleErrorCode.OperationCancelled
731
826
  ) {
732
827
  Log?.debug('first try to reconnect without params');
733
- device = await blePlxManager.connectToDevice(uuid);
828
+ device = await this.connectWithTimeout(uuid, () =>
829
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
830
+ );
734
831
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
735
832
  Log?.debug('device already connected');
736
833
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -746,26 +843,36 @@ export default class ReactNativeBleTransport {
746
843
 
747
844
  if (!(await device.isConnected())) {
748
845
  Log?.debug('not connected, try to connect to device: ', uuid);
846
+ const disconnectedDevice = device;
749
847
 
750
848
  try {
751
- device = await device.connect(connectOptions);
849
+ device = await this.connectWithTimeout(uuid, () =>
850
+ disconnectedDevice.connect(connectOptions)
851
+ );
752
852
  } catch (e) {
753
853
  Log?.debug('not connected, try to connect to device has error: ', e);
854
+ if (isConnectTimeoutError(e)) {
855
+ throw e;
856
+ }
754
857
  if (
755
858
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
756
859
  e.errorCode === BleErrorCode.OperationCancelled
757
860
  ) {
758
861
  Log?.debug('second try to reconnect without params');
759
862
  try {
760
- device = await device.connect();
863
+ device = await this.connectWithTimeout(uuid, () =>
864
+ disconnectedDevice.connect(fallbackConnectOptions)
865
+ );
761
866
  } catch (e) {
762
867
  Log?.debug('last try to reconnect error: ', e);
763
868
  // last try to reconnect device if this issue exists
764
869
  // https://github.com/dotintent/react-native-ble-plx/issues/426
765
870
  if (e.errorCode === BleErrorCode.OperationCancelled) {
766
871
  Log?.debug('last try to reconnect');
767
- await device.cancelConnection();
768
- device = await device.connect();
872
+ await disconnectedDevice.cancelConnection();
873
+ device = await this.connectWithTimeout(uuid, () =>
874
+ disconnectedDevice.connect(fallbackConnectOptions)
875
+ );
769
876
  }
770
877
  }
771
878
  } else {
@@ -775,12 +882,16 @@ export default class ReactNativeBleTransport {
775
882
  }
776
883
 
777
884
  device = await requestAndroidMtu(device);
778
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(device);
885
+ const acquiredDevice = device;
886
+ const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
887
+ acquiredDevice
888
+ );
779
889
 
780
890
  const protocolHint = expectedProtocol
781
891
  ? undefined
782
- : this.deviceProtocolHints.get(uuid) ??
783
- inferProtocolHintFromDeviceName(getDeviceDisplayName(device));
892
+ : input.protocolHint ??
893
+ this.deviceProtocolHints.get(uuid) ??
894
+ inferProtocolHintFromDeviceName(getDeviceDisplayName(acquiredDevice));
784
895
 
785
896
  // release transport before new transport instance
786
897
  await this.release(uuid, true);
@@ -788,45 +899,30 @@ export default class ReactNativeBleTransport {
788
899
  this.deviceProtocolHints.set(uuid, protocolHint);
789
900
  }
790
901
 
791
- const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
792
- if (Platform.OS === 'android') {
793
- transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
794
- }
795
- const monitorToken = this.nextMonitorToken;
796
- this.nextMonitorToken += 1;
797
- const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
798
- transport.monitorToken = monitorToken;
799
- transport.notifyTransactionId = notifyTransactionId;
800
- this.monitorTokens.set(uuid, monitorToken);
801
- transport.notifySubscription = this._monitorCharacteristic(
802
- transport.notifyCharacteristic,
803
- uuid,
804
- monitorToken,
805
- notifyTransactionId
806
- );
807
- transportCache[uuid] = transport;
808
-
809
- this.protocolV2Assemblers.set(uuid, new ProtocolV2FrameAssembler());
810
-
811
- if (Platform.OS === 'ios') {
812
- await new Promise<void>(resolve => {
813
- setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
814
- });
815
- } else if (Platform.OS === 'android') {
816
- await delay(ANDROID_NOTIFY_READY_DELAY_MS);
817
- }
818
-
819
- const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
820
-
821
- this.emitter?.emit('device-connect', {
822
- name: device.name,
823
- id: device.id,
824
- connectId: device.id,
902
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
903
+ writeCharacteristic,
904
+ notifyCharacteristic,
825
905
  });
826
906
 
827
- this.attachDisconnectSubscription(transport, device, uuid);
828
-
829
- return { uuid, protocolType };
907
+ try {
908
+ const protocolType = await this.detectProtocol(
909
+ uuid,
910
+ expectedProtocol,
911
+ protocolHint,
912
+ async () => {
913
+ await this.installTransportForAcquire(uuid, acquiredDevice);
914
+ }
915
+ );
916
+ const currentTransport = transportCache[uuid];
917
+ if (!currentTransport) {
918
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
919
+ }
920
+ this.attachDisconnectSubscription(currentTransport, acquiredDevice, uuid);
921
+ return { uuid, protocolType };
922
+ } catch (error) {
923
+ await this.release(uuid, true);
924
+ throw error;
925
+ }
830
926
  }
831
927
 
832
928
  _monitorCharacteristic(
@@ -853,7 +949,30 @@ export default class ReactNativeBleTransport {
853
949
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
854
950
  return;
855
951
  }
856
- if (this.runPromise) {
952
+ if (this.getActiveProtocol(uuid) === 'V2') {
953
+ let errorCode:
954
+ | typeof HardwareErrorCode.BleDeviceBondError
955
+ | typeof HardwareErrorCode.BleCharacteristicNotifyError
956
+ | typeof HardwareErrorCode.BleCharacteristicNotifyChangeFailure
957
+ | typeof HardwareErrorCode.BleTimeoutError =
958
+ HardwareErrorCode.BleCharacteristicNotifyError;
959
+ if (error.reason?.includes('The connection has timed out unexpectedly')) {
960
+ errorCode = HardwareErrorCode.BleTimeoutError;
961
+ } else if (error.reason?.includes('Encryption is insufficient')) {
962
+ errorCode = HardwareErrorCode.BleDeviceBondError;
963
+ } else if (
964
+ error.reason?.includes('Cannot write client characteristic config descriptor') ||
965
+ error.reason?.includes('Cannot find client characteristic config descriptor') ||
966
+ error.reason?.includes('The handle is invalid') ||
967
+ error.reason?.includes('Writing is not permitted') ||
968
+ error.reason?.includes('notify change failed for device')
969
+ ) {
970
+ errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
971
+ }
972
+ this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
973
+ return;
974
+ }
975
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
857
976
  let ERROR:
858
977
  | typeof HardwareErrorCode.BleDeviceBondError
859
978
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -876,7 +995,6 @@ export default class ReactNativeBleTransport {
876
995
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
877
996
  );
878
997
  this.runPromise.reject(notifyError);
879
- this.rejectAllProtocolV2Frames(notifyError);
880
998
  Log?.debug(
881
999
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
882
1000
  );
@@ -884,7 +1002,6 @@ export default class ReactNativeBleTransport {
884
1002
  }
885
1003
  const notifyError = ERRORS.TypedError(ERROR);
886
1004
  this.runPromise.reject(notifyError);
887
- this.rejectAllProtocolV2Frames(notifyError);
888
1005
  Log?.debug(': monitor notify error, and has unreleased Promise', Error);
889
1006
  }
890
1007
 
@@ -902,13 +1019,13 @@ export default class ReactNativeBleTransport {
902
1019
 
903
1020
  try {
904
1021
  const data = Buffer.from(c.value as string, 'base64');
905
- const protocol = this.deviceProtocol.get(uuid);
1022
+ const protocol = this.getActiveProtocol(uuid);
906
1023
  if (!protocol) {
907
1024
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
908
1025
  return;
909
1026
  }
910
1027
  if (protocol === 'V2') {
911
- this.handleProtocolV2Notification(uuid, new Uint8Array(data));
1028
+ this.handleProtocolV2Notification(uuid, monitorToken, new Uint8Array(data));
912
1029
  return;
913
1030
  }
914
1031
  // console.log('[hd-transport-react-native] Received a packet, ', 'buffer: ', data);
@@ -929,13 +1046,18 @@ export default class ReactNativeBleTransport {
929
1046
  // );
930
1047
  bufferLength = 0;
931
1048
  buffer = [];
932
- this.runPromise?.resolve(value.toString('hex'));
1049
+ if (this.runPromiseDeviceId === uuid) {
1050
+ this.runPromise?.resolve(value.toString('hex'));
1051
+ }
933
1052
  }
934
1053
  } catch (error) {
935
1054
  Log?.debug('monitor data error: ', error);
936
1055
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
937
- this.runPromise?.reject(notifyError);
938
- this.rejectAllProtocolV2Frames(notifyError);
1056
+ if (this.getActiveProtocol(uuid) === 'V2') {
1057
+ this.rejectProtocolV2Frames(uuid, notifyError);
1058
+ } else if (this.runPromiseDeviceId === uuid) {
1059
+ this.runPromise?.reject(notifyError);
1060
+ }
939
1061
  }
940
1062
  }, notifyTransactionId);
941
1063
 
@@ -943,13 +1065,18 @@ export default class ReactNativeBleTransport {
943
1065
  }
944
1066
 
945
1067
  async release(uuid: string, onclose = false) {
1068
+ await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
1069
+ return this.releaseNative(uuid, onclose);
1070
+ }
1071
+
1072
+ private async releaseNative(uuid: string, onclose = false) {
946
1073
  const transport = transportCache[uuid];
947
- if (this.runPromise) {
1074
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
948
1075
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
949
1076
  this.runPromise.reject(error);
950
1077
  this.runPromise = null;
951
- this.rejectAllProtocolV2Frames(error);
952
- this.activeProtocolV2Call = null;
1078
+ this.runPromiseDeviceId = null;
1079
+ this.rejectProtocolV2Frames(uuid, error);
953
1080
  } else {
954
1081
  this.resetProtocolV2Frames(uuid);
955
1082
  }
@@ -957,9 +1084,6 @@ export default class ReactNativeBleTransport {
957
1084
  if (Platform.OS === 'android' && !onclose && transport) {
958
1085
  this.protocolV2Assemblers.get(uuid)?.reset();
959
1086
  this.resetProtocolV2Frames(uuid);
960
- if (this.activeProtocolV2Call?.uuid === uuid) {
961
- this.activeProtocolV2Call = null;
962
- }
963
1087
  return Promise.resolve(true);
964
1088
  }
965
1089
 
@@ -993,7 +1117,8 @@ export default class ReactNativeBleTransport {
993
1117
  }
994
1118
 
995
1119
  this.deviceProtocol.delete(uuid);
996
- this.deviceProtocolHints.delete(uuid);
1120
+ this.probingProtocols.delete(uuid);
1121
+ // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
997
1122
  this.protocolV2Assemblers.get(uuid)?.reset();
998
1123
  this.protocolV2Assemblers.delete(uuid);
999
1124
  this.resetProtocolV2Frames(uuid);
@@ -1025,13 +1150,6 @@ export default class ReactNativeBleTransport {
1025
1150
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1026
1151
  }
1027
1152
 
1028
- const forceRun = name === 'Initialize' || name === 'Cancel';
1029
-
1030
- Log?.debug('transport-react-native call this.runPromise', this.runPromise);
1031
- if (this.runPromise && !forceRun) {
1032
- throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
1033
- }
1034
-
1035
1153
  const protocol = this.getProtocolType(uuid);
1036
1154
  if (!protocol) {
1037
1155
  throw ERRORS.TypedError(
@@ -1039,31 +1157,17 @@ export default class ReactNativeBleTransport {
1039
1157
  `Device protocol has not been detected for ${uuid}`
1040
1158
  );
1041
1159
  }
1042
- // Upload resources on low-end phones may OOM
1043
- if (name === 'ResourceUpdate' || name === 'ResourceAck') {
1044
- Log?.debug('transport-react-native', 'call-', ' name: ', name, ' data: ', {
1045
- file_name: data?.file_name,
1046
- hash: data?.hash,
1047
- });
1048
- } else if (LogBlockCommand.has(name)) {
1049
- Log?.debug('transport-react-native', 'call-', ' name: ', name, ' protocol: ', protocol);
1050
- } else {
1051
- Log?.debug(
1052
- 'transport-react-native',
1053
- 'call-',
1054
- ' name: ',
1055
- name,
1056
- ' data: ',
1057
- data,
1058
- ' protocol: ',
1059
- protocol
1060
- );
1061
- }
1160
+ Log?.debug('transport call', createTransportCallLog(name, protocol, data));
1062
1161
 
1063
1162
  if (protocol === 'V2') {
1064
1163
  return this.callProtocolV2(uuid, name, data, options);
1065
1164
  }
1066
1165
 
1166
+ const forceRun = name === 'Initialize' || name === 'Cancel';
1167
+ if (this.runPromise && !forceRun) {
1168
+ throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
1169
+ }
1170
+
1067
1171
  return this.callProtocolV1(uuid, name, data, options);
1068
1172
  }
1069
1173
 
@@ -1080,7 +1184,25 @@ export default class ReactNativeBleTransport {
1080
1184
  const transport = this.getCachedTransport(uuid);
1081
1185
  const runPromise = createDeferred<string>();
1082
1186
  runPromise.promise.catch(() => undefined);
1187
+ const supersededRunPromise = this.runPromise;
1188
+ if (supersededRunPromise) {
1189
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1190
+ // the superseded deferred now so its response race resolves and its finally block
1191
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1192
+ // tear down the shared connection while another call is using it.
1193
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1194
+ }
1083
1195
  this.runPromise = runPromise;
1196
+ this.runPromiseDeviceId = uuid;
1197
+ // A superseded call's late write failure must not clear the successor's ownership;
1198
+ // only the call that still owns the slot may release it.
1199
+ const releaseOwnershipIfCurrent = () => {
1200
+ if (this.runPromise === runPromise) {
1201
+ this.runPromise = null;
1202
+ this.runPromiseDeviceId = null;
1203
+ }
1204
+ };
1205
+ const isCurrentOwner = () => this.runPromise === runPromise;
1084
1206
  const messages = this._messages;
1085
1207
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1086
1208
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1106,6 +1228,9 @@ export default class ReactNativeBleTransport {
1106
1228
  chunk = ByteBuffer.allocate(packetCapacity);
1107
1229
  } catch (e) {
1108
1230
  onError(e);
1231
+ if (isWedgedWriteError(e)) {
1232
+ throw e;
1233
+ }
1109
1234
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1110
1235
  }
1111
1236
  }
@@ -1137,6 +1262,9 @@ export default class ReactNativeBleTransport {
1137
1262
  }
1138
1263
  } catch (e) {
1139
1264
  onError(e);
1265
+ if (isWedgedWriteError(e)) {
1266
+ throw e;
1267
+ }
1140
1268
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1141
1269
  }
1142
1270
  }
@@ -1150,21 +1278,26 @@ export default class ReactNativeBleTransport {
1150
1278
  if (name === 'EmmcFileWrite') {
1151
1279
  await writeChunkedData(
1152
1280
  buffers,
1153
- data => transport.writeWithRetry(data),
1281
+ data =>
1282
+ this.writeBlePacket(
1283
+ uuid,
1284
+ data,
1285
+ payload => transport.writeWithRetry(payload),
1286
+ isCurrentOwner
1287
+ ),
1154
1288
  e => {
1155
- this.runPromise = null;
1289
+ releaseOwnershipIfCurrent();
1156
1290
  Log?.error('writeCharacteristic write error: ', e);
1157
1291
  }
1158
1292
  );
1159
1293
  } else if (name === 'FirmwareUpload') {
1160
- Log?.debug('[ReactNativeBleTransport] FirmwareUpload write uses throttled BLE packets:', {
1294
+ Log?.debug('[ReactNativeBleTransport] Firmware upload transport configured', {
1161
1295
  packetCapacity: FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY,
1162
1296
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1163
1297
  pauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1164
1298
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1165
1299
  maxRetries: FIRMWARE_UPLOAD_WRITE_MAX_RETRIES,
1166
1300
  });
1167
-
1168
1301
  await writeFirmwareUploadChunkedData(
1169
1302
  buffers,
1170
1303
  async data => {
@@ -1174,43 +1307,31 @@ export default class ReactNativeBleTransport {
1174
1307
  // eslint-disable-next-line no-constant-condition
1175
1308
  while (true) {
1176
1309
  try {
1177
- await transport.writeCharacteristic.writeWithoutResponse(data);
1310
+ await this.writeBlePacket(
1311
+ uuid,
1312
+ data,
1313
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1314
+ isCurrentOwner
1315
+ );
1178
1316
  return;
1179
1317
  } catch (error) {
1180
1318
  const retryType = getFirmwareUploadWriteRetryType(error);
1181
1319
  if (!retryType || attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1182
1320
  throw error;
1183
1321
  }
1184
- const shouldReconnect = retryType === 'reconnectable';
1185
- const delayMs = shouldReconnect
1186
- ? FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS
1187
- : resolveFirmwareUploadRetryDelay(attempt);
1322
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1188
1323
  Log?.debug('[ReactNativeBleTransport] FirmwareUpload write retry:', {
1189
1324
  attempt: attempt + 1,
1190
1325
  delayMs,
1191
- reconnect: shouldReconnect,
1192
1326
  error,
1193
1327
  });
1194
- if (shouldReconnect) {
1195
- this.firmwareUploadWriteRecoveryIds.add(uuid);
1196
- }
1197
1328
  await delay(delayMs);
1198
1329
  attempt += 1;
1199
- if (shouldReconnect) {
1200
- try {
1201
- await this.reconnectFirmwareUploadTransport(uuid, transport);
1202
- } catch (e) {
1203
- Log?.debug('[ReactNativeBleTransport] FirmwareUpload reconnect error:', e);
1204
- if (attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1205
- throw e;
1206
- }
1207
- }
1208
- }
1209
1330
  }
1210
1331
  }
1211
1332
  },
1212
1333
  e => {
1213
- this.runPromise = null;
1334
+ releaseOwnershipIfCurrent();
1214
1335
  Log?.error('writeCharacteristic write error: ', e);
1215
1336
  }
1216
1337
  );
@@ -1218,12 +1339,19 @@ export default class ReactNativeBleTransport {
1218
1339
  for (const o of buffers) {
1219
1340
  const outData = o.toString('base64');
1220
1341
  // Upload resources on low-end phones may OOM
1221
- // this.Log.debug('send hex strting: ', o.toString('hex'));
1222
1342
  try {
1223
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1343
+ await this.writeBlePacket(
1344
+ uuid,
1345
+ outData,
1346
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1347
+ isCurrentOwner
1348
+ );
1224
1349
  } catch (e) {
1225
1350
  Log?.debug('writeCharacteristic write error: ', e);
1226
- this.runPromise = null;
1351
+ releaseOwnershipIfCurrent();
1352
+ if (isWedgedWriteError(e)) {
1353
+ throw e;
1354
+ }
1227
1355
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1228
1356
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1229
1357
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1256,20 +1384,33 @@ export default class ReactNativeBleTransport {
1256
1384
  throw new Error('Returning data is not string.');
1257
1385
  }
1258
1386
 
1259
- Log?.debug('receive data: ', response);
1260
1387
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1261
1388
  return check.call(jsonData);
1262
1389
  } catch (e) {
1263
- if (name === 'Initialize' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1264
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe call failed:', e);
1390
+ if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1391
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1265
1392
  } else {
1266
1393
  Log?.error('call error: ', e);
1267
1394
  }
1395
+ const isProbeTimeout =
1396
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1397
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1398
+ // transport; its late timeout must not tear down the connection the current
1399
+ // call is actively using.
1400
+ const isStaleCall = this.runPromise !== runPromise;
1401
+ if (
1402
+ !isProbeTimeout &&
1403
+ !isStaleCall &&
1404
+ (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1405
+ ) {
1406
+ await this.disconnect(uuid);
1407
+ }
1268
1408
  throw e;
1269
1409
  } finally {
1270
1410
  if (timeout) clearTimeout(timeout);
1271
1411
  if (this.runPromise === runPromise) {
1272
1412
  this.runPromise = null;
1413
+ this.runPromiseDeviceId = null;
1273
1414
  }
1274
1415
  }
1275
1416
  }
@@ -1279,8 +1420,9 @@ export default class ReactNativeBleTransport {
1279
1420
  }
1280
1421
 
1281
1422
  async disconnect(session: string) {
1282
- Log?.debug('transport-react-native transport resetSession: ', session);
1423
+ await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1283
1424
  const transport = transportCache[session];
1425
+ const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1284
1426
 
1285
1427
  // Clean up disconnect subscription first to prevent onDisconnected callback
1286
1428
  // from being triggered when we cancel the connection below
@@ -1338,23 +1480,20 @@ export default class ReactNativeBleTransport {
1338
1480
  delete transportCache[session];
1339
1481
  }
1340
1482
  this.deviceProtocol.delete(session);
1483
+ this.probingProtocols.delete(session);
1341
1484
  this.deviceProtocolHints.delete(session);
1342
1485
  this.protocolV2Assemblers.delete(session);
1343
1486
  this.resetProtocolV2Frames(session);
1344
- if (this.activeProtocolV2Call?.uuid === session) {
1345
- this.activeProtocolV2Call = null;
1346
- }
1347
1487
 
1348
1488
  // emit the disconnect event
1349
1489
  try {
1350
- this.emitter?.emit('device-disconnect', {
1351
- name: transport?.device?.name,
1352
- id: session,
1353
- connectId: session,
1354
- });
1490
+ this.emitDeviceDisconnect(session, transport?.device?.name, monitorToken);
1355
1491
  } catch (e) {
1356
1492
  Log?.error('resetSession: emit disconnect event error: ', e);
1357
1493
  }
1494
+ if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1495
+ this.monitorTokens.delete(session);
1496
+ }
1358
1497
  // eslint-disable-next-line no-promise-executor-return
1359
1498
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1360
1499
  }
@@ -1365,6 +1504,76 @@ export default class ReactNativeBleTransport {
1365
1504
  // this.runPromise.reject(new Error('Transport_CallCanceled'));
1366
1505
  }
1367
1506
  this.runPromise = null;
1507
+ this.runPromiseDeviceId = null;
1508
+ }
1509
+
1510
+ /** Run a native connect under the JS backstop budget. */
1511
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1512
+ let timer: ReturnType<typeof setTimeout> | undefined;
1513
+ let timedOut = false;
1514
+ const pending = connect();
1515
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1516
+ // surface as an unhandled rejection after we have already given up on it.
1517
+ pending.catch(() => undefined);
1518
+ try {
1519
+ const result = await Promise.race([
1520
+ pending,
1521
+ new Promise<never>((_, reject) => {
1522
+ timer = setTimeout(() => {
1523
+ timedOut = true;
1524
+ reject(
1525
+ ERRORS.TypedError(
1526
+ HardwareErrorCode.BleConnectedError,
1527
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1528
+ )
1529
+ );
1530
+ }, BLE_CONNECT_TIMEOUT_MS);
1531
+ }),
1532
+ ]);
1533
+ this.connectTimeoutCounts.delete(uuid);
1534
+ return result;
1535
+ } catch (error) {
1536
+ if (timedOut) {
1537
+ this.abandonStalledConnect(uuid);
1538
+ }
1539
+ throw error;
1540
+ } finally {
1541
+ if (timer) clearTimeout(timer);
1542
+ }
1543
+ }
1544
+
1545
+ /**
1546
+ * Give up on a connect the native layer never settled. The abandoned attempt still
1547
+ * holds a native "connecting" entry that would cancel the NEXT attempt out from under
1548
+ * itself, so it is cleared here — fire and forget, because that call talks to the very
1549
+ * queue that just stopped responding.
1550
+ */
1551
+ private abandonStalledConnect(uuid: string) {
1552
+ const timeouts = (this.connectTimeoutCounts.get(uuid) ?? 0) + 1;
1553
+ this.connectTimeoutCounts.set(uuid, timeouts);
1554
+ Log?.error('[ReactNativeBleTransport] BLE connect timed out:', uuid, {
1555
+ consecutiveConnectTimeouts: timeouts,
1556
+ });
1557
+
1558
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1559
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1560
+ });
1561
+ const stalled = transportCache[uuid];
1562
+ if (stalled) {
1563
+ delete transportCache[uuid];
1564
+ }
1565
+ this.deviceProtocol.delete(uuid);
1566
+ this.probingProtocols.delete(uuid);
1567
+ this.protocolV2Assemblers.delete(uuid);
1568
+ this.resetProtocolV2Frames(uuid);
1569
+
1570
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1571
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1572
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1573
+ Log?.error('[ReactNativeBleTransport] BLE connects wedged repeatedly, resetting BLE manager');
1574
+ this.resetPlxManager();
1575
+ this.connectTimeoutCounts.delete(uuid);
1576
+ }
1368
1577
  }
1369
1578
 
1370
1579
  private getCachedTransport(uuid: string) {
@@ -1375,6 +1584,105 @@ export default class ReactNativeBleTransport {
1375
1584
  return transport;
1376
1585
  }
1377
1586
 
1587
+ /**
1588
+ * Write one packet under a bounded budget. A write that never settles means the
1589
+ * peripheral is wedged even though the GATT link still reports connected, so the
1590
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1591
+ * cached and every later call would hang on it again.
1592
+ */
1593
+ private async writeBlePacket(
1594
+ uuid: string,
1595
+ data: string,
1596
+ write: (payload: string) => Promise<unknown>,
1597
+ isCurrentOwner?: () => boolean
1598
+ ) {
1599
+ let timer: ReturnType<typeof setTimeout> | undefined;
1600
+ let timedOut = false;
1601
+ try {
1602
+ await Promise.race([
1603
+ write(data),
1604
+ new Promise<never>((_, reject) => {
1605
+ timer = setTimeout(() => {
1606
+ timedOut = true;
1607
+ reject(
1608
+ ERRORS.TypedError(
1609
+ HardwareErrorCode.BleWriteCharacteristicError,
1610
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1611
+ )
1612
+ );
1613
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1614
+ }),
1615
+ ]);
1616
+ this.writeTimeoutCounts.delete(uuid);
1617
+ } catch (error) {
1618
+ if (timedOut) {
1619
+ // A superseded call's late write must not tear down the link the current
1620
+ // call is using; only the owner of the transport may declare it dead.
1621
+ if (isCurrentOwner && !isCurrentOwner()) {
1622
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1623
+ } else {
1624
+ this.tearDownWedgedLink(uuid);
1625
+ }
1626
+ }
1627
+ throw error;
1628
+ } finally {
1629
+ if (timer) clearTimeout(timer);
1630
+ }
1631
+ }
1632
+
1633
+ /**
1634
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1635
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1636
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1637
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1638
+ */
1639
+ private tearDownWedgedLink(uuid: string) {
1640
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1641
+ this.writeTimeoutCounts.set(uuid, timeouts);
1642
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1643
+ consecutiveWriteTimeouts: timeouts,
1644
+ });
1645
+
1646
+ const wedged = transportCache[uuid];
1647
+ this.disconnect(uuid).catch(error => {
1648
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1649
+ });
1650
+ if (wedged && transportCache[uuid] === wedged) {
1651
+ delete transportCache[uuid];
1652
+ }
1653
+ this.deviceProtocol.delete(uuid);
1654
+ this.probingProtocols.delete(uuid);
1655
+ this.protocolV2Assemblers.delete(uuid);
1656
+ this.resetProtocolV2Frames(uuid);
1657
+
1658
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1659
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1660
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1661
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1662
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1663
+ this.resetPlxManager();
1664
+ this.writeTimeoutCounts.delete(uuid);
1665
+ }
1666
+ }
1667
+
1668
+ private resetPlxManager() {
1669
+ const manager = this.blePlxManager;
1670
+ this.blePlxManager = undefined;
1671
+ // Every cached transport belongs to the destroyed manager's peripherals.
1672
+ Object.keys(transportCache).forEach(key => {
1673
+ delete transportCache[key];
1674
+ });
1675
+ this.deviceProtocol.clear();
1676
+ this.probingProtocols.clear();
1677
+ this.monitorTokens.clear();
1678
+ this.protocolV2Assemblers.clear();
1679
+ try {
1680
+ manager?.destroy();
1681
+ } catch (error) {
1682
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1683
+ }
1684
+ }
1685
+
1378
1686
  private createProtocolMismatchError(expected: ProtocolType) {
1379
1687
  return ERRORS.TypedError(
1380
1688
  HardwareErrorCode.RuntimeError,
@@ -1385,70 +1693,99 @@ export default class ReactNativeBleTransport {
1385
1693
  private createProtocolDetectionError() {
1386
1694
  return ERRORS.TypedError(
1387
1695
  HardwareErrorCode.BleTimeoutError,
1388
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
1696
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
1389
1697
  );
1390
1698
  }
1391
1699
 
1392
1700
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1701
+ if (this.probingProtocols.get(uuid) === protocol) {
1702
+ this.probingProtocols.delete(uuid);
1703
+ }
1393
1704
  if (this.deviceProtocol.get(uuid) === protocol) {
1394
1705
  this.deviceProtocol.delete(uuid);
1395
1706
  }
1396
1707
  }
1397
1708
 
1709
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1710
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1711
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1712
+ }
1713
+
1398
1714
  private async detectProtocol(
1399
1715
  uuid: string,
1400
1716
  expectedProtocol?: ProtocolType,
1401
- protocolHint?: ProtocolType
1717
+ protocolHint?: ProtocolType,
1718
+ rebuildTransport?: () => Promise<void>
1402
1719
  ): Promise<ProtocolType> {
1403
1720
  if (expectedProtocol === 'V1') {
1404
1721
  if (await this.probeProtocolV1(uuid)) {
1405
1722
  this.deviceProtocol.set(uuid, 'V1');
1406
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> V1 (expected)`);
1723
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1724
+ deviceId: uuid,
1725
+ protocol: 'V1',
1726
+ source: 'expected',
1727
+ });
1407
1728
  return 'V1';
1408
1729
  }
1409
1730
  throw this.createProtocolMismatchError(expectedProtocol);
1410
1731
  }
1411
1732
 
1412
1733
  if (expectedProtocol === 'V2') {
1413
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
1414
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
1415
- this.deviceProtocol.set(uuid, 'V2');
1416
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> V2 (expected)`);
1417
- return 'V2';
1734
+ if (await this.probeProtocolV2(uuid)) {
1735
+ this.deviceProtocol.set(uuid, 'V2');
1736
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1737
+ deviceId: uuid,
1738
+ protocol: 'V2',
1739
+ source: 'expected',
1740
+ });
1741
+ return 'V2';
1742
+ }
1743
+ throw this.createProtocolMismatchError(expectedProtocol);
1418
1744
  }
1419
1745
 
1420
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
1421
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1
1422
- // 不能作为最终结论。
1746
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
1747
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
1423
1748
  const probeOrder: ProtocolType[] =
1424
1749
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1425
1750
 
1426
1751
  for (let i = 0; i < probeOrder.length; i += 1) {
1427
1752
  const protocol = probeOrder[i];
1428
1753
  if (i > 0) {
1429
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
1754
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
1430
1755
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
1756
+ if (!transportCache[uuid]) {
1757
+ if (!rebuildTransport) {
1758
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1759
+ }
1760
+ await rebuildTransport();
1761
+ }
1431
1762
  }
1432
1763
  const detected =
1433
1764
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1434
1765
  if (detected) {
1435
1766
  this.deviceProtocol.set(uuid, protocol);
1436
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> ${protocol}`);
1767
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1768
+ deviceId: uuid,
1769
+ protocol,
1770
+ source: 'probe',
1771
+ });
1437
1772
  return protocol;
1438
1773
  }
1439
1774
  }
1440
1775
 
1441
1776
  this.deviceProtocol.delete(uuid);
1777
+ this.probingProtocols.delete(uuid);
1442
1778
  throw this.createProtocolDetectionError();
1443
1779
  }
1444
1780
 
1445
1781
  private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
1446
1782
  const transport = transportCache[uuid];
1783
+ await this.protocolV2Links.invalidateLink(
1784
+ uuid,
1785
+ `Reset notify state after Protocol ${protocol} probe`
1786
+ );
1447
1787
  this.protocolV2Assemblers.get(uuid)?.reset();
1448
1788
  this.resetProtocolV2Frames(uuid);
1449
- if (this.activeProtocolV2Call?.uuid === uuid) {
1450
- this.activeProtocolV2Call = null;
1451
- }
1452
1789
  if (this.runPromise) {
1453
1790
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
1454
1791
  this.runPromise.reject(error);
@@ -1499,12 +1836,20 @@ export default class ReactNativeBleTransport {
1499
1836
  }
1500
1837
 
1501
1838
  try {
1502
- this.deviceProtocol.set(uuid, 'V1');
1503
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1839
+ this.probingProtocols.set(uuid, 'V1');
1840
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
1841
+ // session before Core has a chance to restore a hidden wallet.
1842
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1843
+ this.probingProtocols.delete(uuid);
1504
1844
  return true;
1505
1845
  } catch (error) {
1506
1846
  this.clearProbeProtocol(uuid, 'V1');
1507
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe failed:', error);
1847
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1848
+ // A wedged write already dropped the link, so probing another protocol on it
1849
+ // would only fail against a torn-down transport: surface the real cause.
1850
+ if (isWedgedWriteError(error)) {
1851
+ throw error;
1852
+ }
1508
1853
  return false;
1509
1854
  }
1510
1855
  }
@@ -1514,7 +1859,7 @@ export default class ReactNativeBleTransport {
1514
1859
  return false;
1515
1860
  }
1516
1861
 
1517
- this.deviceProtocol.set(uuid, 'V2');
1862
+ this.probingProtocols.set(uuid, 'V2');
1518
1863
  this.protocolV2Assemblers.get(uuid)?.reset();
1519
1864
  const detected = await probeProtocolV2Helper({
1520
1865
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1529,17 +1874,15 @@ export default class ReactNativeBleTransport {
1529
1874
  });
1530
1875
  if (!detected) {
1531
1876
  this.clearProbeProtocol(uuid, 'V2');
1877
+ } else {
1878
+ this.probingProtocols.delete(uuid);
1532
1879
  }
1533
1880
  return detected;
1534
1881
  }
1535
1882
 
1536
- private handleProtocolV2Notification(uuid: string, data: Uint8Array) {
1883
+ private handleProtocolV2Notification(uuid: string, monitorToken: number, data: Uint8Array) {
1537
1884
  try {
1538
- if (!this.runPromise || this.activeProtocolV2Call?.uuid !== uuid) {
1539
- this.protocolV2Assemblers.get(uuid)?.reset();
1540
- this.resetProtocolV2Frames(uuid);
1541
- return;
1542
- }
1885
+ if (this.monitorTokens.get(uuid) !== monitorToken) return;
1543
1886
 
1544
1887
  if (data.length === 0) return;
1545
1888
 
@@ -1552,8 +1895,15 @@ export default class ReactNativeBleTransport {
1552
1895
  } catch (error) {
1553
1896
  Log?.debug('[ReactNativeBleTransport] Protocol V2 notification error:', error);
1554
1897
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1555
- this.runPromise?.reject(notifyError);
1556
- this.rejectAllProtocolV2Frames(notifyError);
1898
+ this.rejectProtocolV2Frames(uuid, notifyError);
1899
+ this.protocolV2Links
1900
+ .invalidateLink(uuid, `Protocol V2 notification error: ${error}`)
1901
+ .catch(invalidateError =>
1902
+ Log?.debug(
1903
+ '[ReactNativeBleTransport] Protocol V2 notify cleanup failed:',
1904
+ invalidateError
1905
+ )
1906
+ );
1557
1907
  }
1558
1908
  }
1559
1909
 
@@ -1576,21 +1926,17 @@ export default class ReactNativeBleTransport {
1576
1926
  this.getProtocolV2FrameQueue(uuid).push(frame);
1577
1927
  }
1578
1928
 
1579
- private rejectAllProtocolV2Frames(error: Error) {
1580
- this.protocolV2FrameQueues.clear();
1581
- for (const framePromise of this.protocolV2FramePromises.values()) {
1582
- framePromise.reject(error);
1583
- }
1584
- this.protocolV2FramePromises.clear();
1585
- }
1586
-
1587
1929
  private resetProtocolV2Frames(uuid: string) {
1588
- this.protocolV2FrameQueues.delete(uuid);
1589
- this.protocolV2FramePromises.delete(uuid);
1930
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
1590
1931
  }
1591
1932
 
1592
- private isActiveProtocolV2Call(uuid: string, token: number) {
1593
- return this.activeProtocolV2Call?.uuid === uuid && this.activeProtocolV2Call.token === token;
1933
+ private rejectProtocolV2Frames(uuid: string, error: Error) {
1934
+ this.protocolV2FrameQueues.delete(uuid);
1935
+ const framePromise = this.protocolV2FramePromises.get(uuid);
1936
+ if (framePromise) {
1937
+ this.protocolV2FramePromises.delete(uuid);
1938
+ framePromise.reject(error);
1939
+ }
1594
1940
  }
1595
1941
 
1596
1942
  private async readProtocolV2Frame(uuid: string) {
@@ -1610,10 +1956,62 @@ export default class ReactNativeBleTransport {
1610
1956
  }
1611
1957
  }
1612
1958
 
1959
+ private async writeProtocolV2Packet(
1960
+ uuid: string,
1961
+ transport: BleTransport,
1962
+ base64: string,
1963
+ context: ProtocolV2CallContext,
1964
+ assertCurrentGeneration: () => void
1965
+ ) {
1966
+ let attempt = 0;
1967
+ for (;;) {
1968
+ assertCurrentGeneration();
1969
+ if (context.signal.aborted) {
1970
+ throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1971
+ }
1972
+ try {
1973
+ await this.writeBlePacket(
1974
+ uuid,
1975
+ base64,
1976
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1977
+ // Same rule as Protocol V1: a write from a superseded generation must not
1978
+ // tear down the link that the current generation is using.
1979
+ () => {
1980
+ try {
1981
+ assertCurrentGeneration();
1982
+ return !context.signal.aborted;
1983
+ } catch {
1984
+ return false;
1985
+ }
1986
+ }
1987
+ );
1988
+ assertCurrentGeneration();
1989
+ return;
1990
+ } catch (error) {
1991
+ if (
1992
+ getFirmwareUploadWriteRetryType(error) !== 'congested' ||
1993
+ attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
1994
+ ) {
1995
+ throw error;
1996
+ }
1997
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1998
+ attempt += 1;
1999
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 congested write retry:', {
2000
+ name: context.messageName,
2001
+ attempt,
2002
+ delayMs,
2003
+ });
2004
+ await delay(delayMs);
2005
+ }
2006
+ }
2007
+ }
2008
+
1613
2009
  private async writeProtocolV2Frame(
2010
+ uuid: string,
1614
2011
  transport: BleTransport,
1615
2012
  frame: Uint8Array,
1616
- options?: { highVolume?: boolean; writeWithResponse?: boolean }
2013
+ context: ProtocolV2CallContext,
2014
+ assertCurrentGeneration: () => void
1617
2015
  ) {
1618
2016
  const tuning = getProtocolV2BleTuning();
1619
2017
  const packetCapacity = resolveProtocolV2PacketCapacity({
@@ -1622,37 +2020,25 @@ export default class ReactNativeBleTransport {
1622
2020
  androidPacketLength: tuning.androidPacketLength,
1623
2021
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1624
2022
  });
1625
- const writeWithResponse =
1626
- !!options?.writeWithResponse || (!!options?.highVolume && tuning.highVolumeWriteWithResponse);
1627
- const writeMode = resolveBleWriteMode(
1628
- transport.writeCharacteristic,
1629
- writeWithResponse ? 'withResponse' : 'withoutResponse'
1630
- );
1631
- const shouldThrottle = !!options?.highVolume && writeMode === 'withoutResponse';
1632
- let packetsWritten = 0;
1633
-
1634
- for (let offset = 0; offset < frame.length; offset += packetCapacity) {
1635
- const chunk = frame.slice(offset, offset + packetCapacity);
1636
- const base64 = Buffer.from(chunk).toString('base64');
1637
- if (writeMode === 'withResponse') {
1638
- await transport.writeCharacteristic.writeWithResponse(base64);
1639
- } else {
1640
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1641
- }
1642
- packetsWritten += 1;
1643
-
1644
- if (
1645
- shouldThrottle &&
1646
- packetsWritten % tuning.highVolumeWriteBurstSize === 0 &&
1647
- offset + packetCapacity < frame.length
1648
- ) {
1649
- await delay(tuning.highVolumeWritePauseMs);
1650
- }
1651
- }
1652
-
1653
- if (shouldThrottle) {
1654
- await delay(tuning.highVolumeWriteFlushDelayMs);
1655
- }
2023
+ await writeProtocolV2BleFrame({
2024
+ frame,
2025
+ packetCapacity,
2026
+ assertActive: assertCurrentGeneration,
2027
+ signal: context.signal,
2028
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
2029
+ burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
2030
+ burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
2031
+ flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
2032
+ wait: delay,
2033
+ writePacket: packet =>
2034
+ this.writeProtocolV2Packet(
2035
+ uuid,
2036
+ transport,
2037
+ Buffer.from(packet).toString('base64'),
2038
+ context,
2039
+ assertCurrentGeneration
2040
+ ),
2041
+ });
1656
2042
  }
1657
2043
 
1658
2044
  private async callProtocolV2(
@@ -1665,102 +2051,83 @@ export default class ReactNativeBleTransport {
1665
2051
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1666
2052
  }
1667
2053
 
1668
- const forceRun = name === 'Initialize' || name === 'Cancel' || name === 'Ping';
1669
- if (this.runPromise) {
1670
- if (!forceRun) {
1671
- throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
1672
- }
1673
- const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
1674
- this.runPromise.reject(error);
1675
- this.rejectAllProtocolV2Frames(error);
1676
- this.runPromise = null;
1677
- this.activeProtocolV2Call = null;
1678
- }
1679
-
1680
- const transport = this.getCachedTransport(uuid);
1681
- const runPromise = createDeferred<Uint8Array>();
1682
- runPromise.promise.catch(() => undefined);
1683
- this.runPromise = runPromise;
1684
- const callToken = this.nextProtocolV2CallToken++;
1685
- this.activeProtocolV2Call = { uuid, token: callToken };
1686
- this.protocolV2Assemblers.get(uuid)?.reset();
1687
- this.resetProtocolV2Frames(uuid);
1688
- let completed = false;
1689
- const callOptions = {
1690
- ...options,
1691
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
1692
- };
2054
+ const callOptions = options;
1693
2055
  const highVolumeWrite = LogBlockCommand.has(name);
1694
2056
 
1695
2057
  if (highVolumeWrite) {
1696
2058
  const tuning = getProtocolV2BleTuning();
1697
- Log?.debug(
1698
- '[ReactNativeBleTransport] Protocol V2 high-volume write uses throttled writeWithoutResponse:',
2059
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1699
2060
  name,
1700
- {
1701
- packetCapacity:
1702
- Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
1703
- burstSize: tuning.highVolumeWriteBurstSize,
1704
- pauseMs: tuning.highVolumeWritePauseMs,
1705
- flushDelayMs: tuning.highVolumeWriteFlushDelayMs,
1706
- writeWithResponse: tuning.highVolumeWriteWithResponse,
1707
- }
1708
- );
2061
+ writeMode: 'withoutResponse',
2062
+ packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
2063
+ });
1709
2064
  }
1710
2065
 
1711
2066
  try {
1712
- const session = new ProtocolV2Session({
1713
- schemas: {
1714
- protocolV1: this._messages,
1715
- protocolV2: this._messagesV2,
1716
- },
1717
- router: PROTOCOL_V2_CHANNEL_BLE_UART,
1718
- writeFrame: async (frame: Uint8Array) => {
1719
- await this.writeProtocolV2Frame(transport, frame, {
1720
- highVolume: highVolumeWrite,
1721
- });
1722
- },
1723
- readFrame: async () => {
1724
- const rxFrame = await this.readProtocolV2Frame(uuid);
1725
- if (!(rxFrame instanceof Uint8Array)) {
1726
- throw new Error('Protocol V2 response is not Uint8Array');
1727
- }
1728
- return rxFrame;
1729
- },
1730
- logger: Log,
1731
- logPrefix: 'ProtocolV2 RN-BLE',
1732
- createTimeoutError: (_messageName: string, timeout: number) =>
1733
- ERRORS.TypedError(
1734
- HardwareErrorCode.BleTimeoutError,
1735
- `BLE response timeout after ${timeout}ms for ${name}`
1736
- ),
1737
- });
1738
-
1739
- const result = await session.call(name, data, callOptions);
1740
- completed = true;
1741
- return result;
2067
+ return await this.protocolV2Links.call(
2068
+ uuid,
2069
+ () => this.createProtocolV2Adapter(uuid),
2070
+ name,
2071
+ data,
2072
+ callOptions
2073
+ );
1742
2074
  } catch (e) {
1743
- if (this.isActiveProtocolV2Call(uuid, callToken)) {
1744
- this.protocolV2Assemblers.get(uuid)?.reset();
1745
- this.resetProtocolV2Frames(uuid);
1746
- }
1747
2075
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1748
2076
  throw e;
1749
- } finally {
1750
- if (this.isActiveProtocolV2Call(uuid, callToken)) {
1751
- if (!completed) {
1752
- this.protocolV2Assemblers.get(uuid)?.reset();
1753
- }
1754
- this.resetProtocolV2Frames(uuid);
1755
- this.activeProtocolV2Call = null;
1756
- }
1757
- if (this.runPromise === runPromise) {
1758
- this.runPromise = null;
1759
- }
1760
2077
  }
1761
2078
  }
1762
2079
 
2080
+ private createProtocolV2Adapter(uuid: string) {
2081
+ const generation = this.monitorTokens.get(uuid) ?? 0;
2082
+ const assertCurrentGeneration = () => {
2083
+ if (this.monitorTokens.get(uuid) !== generation) {
2084
+ throw new Error(`Protocol V2 monitor generation changed for ${uuid}`);
2085
+ }
2086
+ };
2087
+
2088
+ return {
2089
+ router: PROTOCOL_V2_CHANNEL_BLE_UART,
2090
+ maxFrameBytes: PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
2091
+ generation,
2092
+ prepareCall: () => {
2093
+ assertCurrentGeneration();
2094
+ this.protocolV2Assemblers.get(uuid)?.reset();
2095
+ this.resetProtocolV2Frames(uuid);
2096
+ },
2097
+ writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
2098
+ assertCurrentGeneration();
2099
+ const currentTransport = this.getCachedTransport(uuid);
2100
+ await this.writeProtocolV2Frame(
2101
+ uuid,
2102
+ currentTransport,
2103
+ frame,
2104
+ context,
2105
+ assertCurrentGeneration
2106
+ );
2107
+ },
2108
+ readFrame: async () => {
2109
+ assertCurrentGeneration();
2110
+ const rxFrame = await this.readProtocolV2Frame(uuid);
2111
+ if (!(rxFrame instanceof Uint8Array)) {
2112
+ throw new Error('Protocol V2 response is not Uint8Array');
2113
+ }
2114
+ return rxFrame;
2115
+ },
2116
+ reset: (reason: string) => {
2117
+ this.protocolV2Assemblers.get(uuid)?.reset();
2118
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
2119
+ },
2120
+ logger: Log,
2121
+ logPrefix: 'ProtocolV2 RN-BLE',
2122
+ createTimeoutError: (messageName: string, timeout: number) =>
2123
+ ERRORS.TypedError(
2124
+ HardwareErrorCode.BleTimeoutError,
2125
+ `BLE response timeout after ${timeout}ms for ${messageName}`
2126
+ ),
2127
+ };
2128
+ }
2129
+
1763
2130
  getProtocolType(path: string): ProtocolType | undefined {
1764
- return this.deviceProtocol.get(path);
2131
+ return this.getActiveProtocol(path);
1765
2132
  }
1766
2133
  }