@onekeyfe/hd-transport-react-native 1.2.0-alpha.5 → 1.2.0-alpha.51

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,14 @@ 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
+ const DEVICE_SCAN_TIMEOUT_MS = 3000;
112
110
  const IOS_NOTIFY_READY_DELAY_MS = 150;
113
111
  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
112
  export type ProtocolV2BleTuning = {
119
113
  iosPacketLength?: number;
120
114
  androidPacketLength?: number;
121
- highVolumeWriteBurstSize?: number;
122
- highVolumeWritePauseMs?: number;
123
- highVolumeWriteFlushDelayMs?: number;
124
- highVolumeWriteWithResponse?: boolean;
125
115
  };
126
116
 
127
117
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
@@ -129,10 +119,6 @@ type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
129
119
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
130
120
  iosPacketLength: IOS_PACKET_LENGTH,
131
121
  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
122
  };
137
123
 
138
124
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -153,27 +139,13 @@ export function configureProtocolV2BleTuning(tuning: ProtocolV2BleTuning = {}) {
153
139
  tuning.androidPacketLength,
154
140
  protocolV2BleTuning.androidPacketLength
155
141
  ),
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
142
  };
171
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning configured:', protocolV2BleTuning);
143
+ Log?.debug('[ReactNativeBleTransport] BLE tuning configured', protocolV2BleTuning);
172
144
  }
173
145
 
174
146
  export function resetProtocolV2BleTuning() {
175
147
  protocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
176
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning reset:', protocolV2BleTuning);
148
+ Log?.debug('[ReactNativeBleTransport] BLE tuning reset', protocolV2BleTuning);
177
149
  }
178
150
 
179
151
  export function getProtocolV2BleTuning() {
@@ -188,16 +160,6 @@ function getDeviceDisplayName(device?: Device | null) {
188
160
  return device?.name || device?.localName || null;
189
161
  }
190
162
 
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
163
  const ANDROID_REQUEST_MTU = 256;
202
164
 
203
165
  const connectOptions: Record<string, unknown> = {
@@ -222,9 +184,10 @@ const requestAndroidMtu = async (device: Device) => {
222
184
 
223
185
  try {
224
186
  const mtuDevice = await device.requestMTU(ANDROID_REQUEST_MTU);
225
- Log?.debug('[ReactNativeBleTransport] Android MTU requested:', {
187
+ Log?.debug('[ReactNativeBleTransport] MTU configured', {
188
+ deviceId: device.id,
226
189
  requested: ANDROID_REQUEST_MTU,
227
- mtu: mtuDevice.mtu,
190
+ actual: mtuDevice.mtu,
228
191
  });
229
192
  return mtuDevice;
230
193
  } catch (error) {
@@ -272,6 +235,8 @@ export default class ReactNativeBleTransport {
272
235
 
273
236
  _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
274
237
 
238
+ private protocolV2SchemaConfiguration: string | undefined;
239
+
275
240
  name = 'ReactNativeBleTransport';
276
241
 
277
242
  configured = false;
@@ -282,6 +247,8 @@ export default class ReactNativeBleTransport {
282
247
 
283
248
  runPromise: Deferred<any> | null = null;
284
249
 
250
+ private runPromiseDeviceId: string | null = null;
251
+
285
252
  emitter?: EventEmitter;
286
253
 
287
254
  firmwareUploadWriteRecoveryIds = new Set<string>();
@@ -297,12 +264,31 @@ export default class ReactNativeBleTransport {
297
264
 
298
265
  private protocolV2FramePromises: Map<string, Deferred<Uint8Array>> = new Map();
299
266
 
300
- private activeProtocolV2Call: { uuid: string; token: number } | null = null;
301
-
302
- private nextProtocolV2CallToken = 1;
267
+ private protocolV2Links = new ProtocolV2LinkManager<string>({
268
+ getSchemas: () => {
269
+ if (!this._messages || !this._messagesV2) {
270
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
271
+ }
272
+ return {
273
+ protocolV1: this._messages,
274
+ protocolV2: this._messagesV2,
275
+ };
276
+ },
277
+ classifyError: () => 'link-fatal',
278
+ onLinkInvalidated: async (uuid, reason) => {
279
+ this.protocolV2Assemblers.get(uuid)?.reset();
280
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
281
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
282
+ if (reason.startsWith('Protocol V2 link-fatal error:')) {
283
+ await this.releaseNative(uuid, true);
284
+ }
285
+ },
286
+ });
303
287
 
304
288
  private monitorTokens: Map<string, number> = new Map();
305
289
 
290
+ private disconnectEventTokens: Map<string, number> = new Map();
291
+
306
292
  private nextMonitorToken = 1;
307
293
 
308
294
  constructor(options: TransportOptions) {
@@ -321,8 +307,19 @@ export default class ReactNativeBleTransport {
321
307
  }
322
308
 
323
309
  configureProtocolV2(signedData: any) {
310
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
311
+ if (this.protocolV2SchemaConfiguration === configuration) {
312
+ return;
313
+ }
314
+
315
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
324
316
  this._messagesV2 = parseConfigure(signedData);
325
- Log?.debug('[ReactNativeBleTransport] Protocol V2 schema configured');
317
+ this.protocolV2SchemaConfiguration = configuration;
318
+ if (isReconfiguration) {
319
+ this.protocolV2Links
320
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
321
+ .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
322
+ }
326
323
  }
327
324
 
328
325
  listen() {
@@ -352,29 +349,15 @@ export default class ReactNativeBleTransport {
352
349
  }
353
350
  }
354
351
 
355
- let fallbackServiceUuid: string | undefined;
356
-
357
352
  if (!infos) {
358
353
  const services = await device.services();
359
354
  Log?.debug(
360
355
  '[ReactNativeBleTransport] Known OneKey service UUID not found, discovered services:',
361
356
  services?.map(service => service.uuid)
362
357
  );
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
358
  }
376
359
 
377
- if (!infos && !fallbackServiceUuid) {
360
+ if (!infos) {
378
361
  try {
379
362
  Log?.debug('cancel connection when service not found');
380
363
  await device.cancelConnection();
@@ -384,9 +367,7 @@ export default class ReactNativeBleTransport {
384
367
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
385
368
  }
386
369
 
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';
370
+ const { serviceUuid, writeUuid, notifyUuid } = infos;
390
371
 
391
372
  if (!serviceUuid) {
392
373
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
@@ -436,6 +417,7 @@ export default class ReactNativeBleTransport {
436
417
 
437
418
  attachDisconnectSubscription(transport: BleTransport, device: Device, uuid: string) {
438
419
  transport.disconnectSubscription?.remove();
420
+ const { monitorToken } = transport;
439
421
  transport.disconnectSubscription = device.onDisconnected(() => {
440
422
  if (this.firmwareUploadWriteRecoveryIds.has(uuid)) {
441
423
  Log?.debug('device disconnect ignored during FirmwareUpload write recovery: ', uuid);
@@ -445,18 +427,17 @@ export default class ReactNativeBleTransport {
445
427
  Log?.debug('device disconnect ignored for stale transport: ', device?.id);
446
428
  return;
447
429
  }
430
+ if (this.monitorTokens.get(uuid) !== monitorToken) {
431
+ Log?.debug('device disconnect ignored for stale generation: ', device?.id);
432
+ return;
433
+ }
448
434
 
449
435
  try {
450
436
  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) {
437
+ this.emitDeviceDisconnect(uuid, device?.name, monitorToken);
438
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
457
439
  const error = ERRORS.TypedError(HardwareErrorCode.BleConnectedError);
458
440
  this.runPromise.reject(error);
459
- this.rejectAllProtocolV2Frames(error);
460
441
  }
461
442
  } catch (e) {
462
443
  Log?.debug('device disconnect error: ', e);
@@ -466,6 +447,22 @@ export default class ReactNativeBleTransport {
466
447
  });
467
448
  }
468
449
 
450
+ private emitDeviceDisconnect(uuid: string, name: string | null | undefined, token?: number) {
451
+ if (token === undefined || this.disconnectEventTokens.get(uuid) === token) {
452
+ return;
453
+ }
454
+ if (this.monitorTokens.get(uuid) !== token) {
455
+ Log?.debug('device disconnect event ignored for stale generation: ', uuid);
456
+ return;
457
+ }
458
+ this.disconnectEventTokens.set(uuid, token);
459
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
460
+ name,
461
+ id: uuid,
462
+ connectId: uuid,
463
+ });
464
+ }
465
+
469
466
  async reconnectFirmwareUploadTransport(uuid: string, transport: BleTransport) {
470
467
  this.firmwareUploadWriteRecoveryIds.add(uuid);
471
468
  try {
@@ -553,14 +550,13 @@ export default class ReactNativeBleTransport {
553
550
  }
554
551
 
555
552
  blePlxManager.startDeviceScan(
556
- null,
553
+ getBluetoothServiceUuids(),
557
554
  {
558
555
  allowDuplicates: true,
559
556
  scanMode: ScanMode.LowLatency,
560
557
  },
561
558
  (error, device) => {
562
559
  if (error) {
563
- Log?.debug('ble scan manager: ', blePlxManager);
564
560
  Log?.debug('ble scan error: ', error);
565
561
  if (
566
562
  [BleErrorCode.BluetoothPoweredOff, BleErrorCode.BluetoothInUnknownState].includes(
@@ -584,33 +580,14 @@ export default class ReactNativeBleTransport {
584
580
  }
585
581
 
586
582
  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
-
583
+ const isOneKey = isOnekeyBluetoothDevice({
584
+ id: device?.id,
585
+ name: device?.name,
586
+ localName: device?.localName,
587
+ serviceUuids: device?.serviceUUIDs,
588
+ });
604
589
  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
590
  addDevice(device as unknown as Device);
613
- Log?.debug('search device end ======================\n');
614
591
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
615
592
  Log?.debug('[ReactNativeBleTransport] Pro2-like BLE device was not accepted:', {
616
593
  name: device?.name,
@@ -622,12 +599,27 @@ export default class ReactNativeBleTransport {
622
599
  }
623
600
  );
624
601
 
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);
602
+ getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
603
+ devices => {
604
+ for (const device of devices) {
605
+ const localName =
606
+ 'localName' in device && typeof device.localName === 'string'
607
+ ? device.localName
608
+ : null;
609
+ if (
610
+ isOnekeyBluetoothDevice({
611
+ id: device.id,
612
+ name: device.name,
613
+ localName,
614
+ serviceUuids: device.serviceUUIDs,
615
+ })
616
+ ) {
617
+ Log?.debug('search connected peripheral: ', device.id);
618
+ addDevice(device as unknown as Device);
619
+ }
620
+ }
629
621
  }
630
- });
622
+ );
631
623
 
632
624
  const addDevice = (device: Device) => {
633
625
  if (deviceList.every(d => d.id !== device.id)) {
@@ -641,6 +633,12 @@ export default class ReactNativeBleTransport {
641
633
  name: displayName,
642
634
  commType: 'ble',
643
635
  } as IOneKeyDevice);
636
+ Log?.debug('[ReactNativeBleTransport] OneKey BLE device discovered', {
637
+ deviceId: device.id,
638
+ name: displayName,
639
+ serviceUUIDs: device.serviceUUIDs,
640
+ protocolHint,
641
+ });
644
642
  }
645
643
  };
646
644
 
@@ -651,6 +649,46 @@ export default class ReactNativeBleTransport {
651
649
  });
652
650
  }
653
651
 
652
+ private async installTransportForAcquire(
653
+ uuid: string,
654
+ device: Device,
655
+ characteristics?: ResolvedBleCharacteristics
656
+ ) {
657
+ const { writeCharacteristic, notifyCharacteristic } =
658
+ characteristics ?? (await this.resolveCharacteristics(device));
659
+ const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
660
+ if (Platform.OS === 'android') {
661
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
662
+ }
663
+ const monitorToken = this.nextMonitorToken;
664
+ this.nextMonitorToken += 1;
665
+ const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
666
+ transport.monitorToken = monitorToken;
667
+ transport.notifyTransactionId = notifyTransactionId;
668
+ this.monitorTokens.set(uuid, monitorToken);
669
+ transport.notifySubscription = this._monitorCharacteristic(
670
+ transport.notifyCharacteristic,
671
+ uuid,
672
+ monitorToken,
673
+ notifyTransactionId
674
+ );
675
+ transportCache[uuid] = transport;
676
+ this.protocolV2Assemblers.set(
677
+ uuid,
678
+ new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
679
+ );
680
+
681
+ if (Platform.OS === 'ios') {
682
+ await new Promise<void>(resolve => {
683
+ setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
684
+ });
685
+ } else if (Platform.OS === 'android') {
686
+ await delay(ANDROID_NOTIFY_READY_DELAY_MS);
687
+ }
688
+
689
+ return transport;
690
+ }
691
+
654
692
  async acquire(input: BleAcquireInput) {
655
693
  const { uuid, forceCleanRunPromise, expectedProtocol } = input;
656
694
 
@@ -684,9 +722,8 @@ export default class ReactNativeBleTransport {
684
722
  if (forceCleanRunPromise && this.runPromise) {
685
723
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
686
724
  this.runPromise.reject(error);
687
- this.rejectAllProtocolV2Frames(error);
688
725
  this.runPromise = null;
689
- this.activeProtocolV2Call = null;
726
+ this.runPromiseDeviceId = null;
690
727
  Log?.debug('Force clean Bluetooth run promise, forceCleanRunPromise: ', forceCleanRunPromise);
691
728
  }
692
729
 
@@ -775,12 +812,16 @@ export default class ReactNativeBleTransport {
775
812
  }
776
813
 
777
814
  device = await requestAndroidMtu(device);
778
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(device);
815
+ const acquiredDevice = device;
816
+ const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
817
+ acquiredDevice
818
+ );
779
819
 
780
820
  const protocolHint = expectedProtocol
781
821
  ? undefined
782
- : this.deviceProtocolHints.get(uuid) ??
783
- inferProtocolHintFromDeviceName(getDeviceDisplayName(device));
822
+ : input.protocolHint ??
823
+ this.deviceProtocolHints.get(uuid) ??
824
+ inferProtocolHintFromDeviceName(getDeviceDisplayName(acquiredDevice));
784
825
 
785
826
  // release transport before new transport instance
786
827
  await this.release(uuid, true);
@@ -788,45 +829,30 @@ export default class ReactNativeBleTransport {
788
829
  this.deviceProtocolHints.set(uuid, protocolHint);
789
830
  }
790
831
 
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,
832
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
833
+ writeCharacteristic,
834
+ notifyCharacteristic,
825
835
  });
826
836
 
827
- this.attachDisconnectSubscription(transport, device, uuid);
828
-
829
- return { uuid, protocolType };
837
+ try {
838
+ const protocolType = await this.detectProtocol(
839
+ uuid,
840
+ expectedProtocol,
841
+ protocolHint,
842
+ async () => {
843
+ await this.installTransportForAcquire(uuid, acquiredDevice);
844
+ }
845
+ );
846
+ const currentTransport = transportCache[uuid];
847
+ if (!currentTransport) {
848
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
849
+ }
850
+ this.attachDisconnectSubscription(currentTransport, acquiredDevice, uuid);
851
+ return { uuid, protocolType };
852
+ } catch (error) {
853
+ await this.release(uuid, true);
854
+ throw error;
855
+ }
830
856
  }
831
857
 
832
858
  _monitorCharacteristic(
@@ -853,7 +879,30 @@ export default class ReactNativeBleTransport {
853
879
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
854
880
  return;
855
881
  }
856
- if (this.runPromise) {
882
+ if (this.deviceProtocol.get(uuid) === 'V2') {
883
+ let errorCode:
884
+ | typeof HardwareErrorCode.BleDeviceBondError
885
+ | typeof HardwareErrorCode.BleCharacteristicNotifyError
886
+ | typeof HardwareErrorCode.BleCharacteristicNotifyChangeFailure
887
+ | typeof HardwareErrorCode.BleTimeoutError =
888
+ HardwareErrorCode.BleCharacteristicNotifyError;
889
+ if (error.reason?.includes('The connection has timed out unexpectedly')) {
890
+ errorCode = HardwareErrorCode.BleTimeoutError;
891
+ } else if (error.reason?.includes('Encryption is insufficient')) {
892
+ errorCode = HardwareErrorCode.BleDeviceBondError;
893
+ } else if (
894
+ error.reason?.includes('Cannot write client characteristic config descriptor') ||
895
+ error.reason?.includes('Cannot find client characteristic config descriptor') ||
896
+ error.reason?.includes('The handle is invalid') ||
897
+ error.reason?.includes('Writing is not permitted') ||
898
+ error.reason?.includes('notify change failed for device')
899
+ ) {
900
+ errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
901
+ }
902
+ this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
903
+ return;
904
+ }
905
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
857
906
  let ERROR:
858
907
  | typeof HardwareErrorCode.BleDeviceBondError
859
908
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -876,7 +925,6 @@ export default class ReactNativeBleTransport {
876
925
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
877
926
  );
878
927
  this.runPromise.reject(notifyError);
879
- this.rejectAllProtocolV2Frames(notifyError);
880
928
  Log?.debug(
881
929
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
882
930
  );
@@ -884,7 +932,6 @@ export default class ReactNativeBleTransport {
884
932
  }
885
933
  const notifyError = ERRORS.TypedError(ERROR);
886
934
  this.runPromise.reject(notifyError);
887
- this.rejectAllProtocolV2Frames(notifyError);
888
935
  Log?.debug(': monitor notify error, and has unreleased Promise', Error);
889
936
  }
890
937
 
@@ -908,7 +955,7 @@ export default class ReactNativeBleTransport {
908
955
  return;
909
956
  }
910
957
  if (protocol === 'V2') {
911
- this.handleProtocolV2Notification(uuid, new Uint8Array(data));
958
+ this.handleProtocolV2Notification(uuid, monitorToken, new Uint8Array(data));
912
959
  return;
913
960
  }
914
961
  // console.log('[hd-transport-react-native] Received a packet, ', 'buffer: ', data);
@@ -929,13 +976,18 @@ export default class ReactNativeBleTransport {
929
976
  // );
930
977
  bufferLength = 0;
931
978
  buffer = [];
932
- this.runPromise?.resolve(value.toString('hex'));
979
+ if (this.runPromiseDeviceId === uuid) {
980
+ this.runPromise?.resolve(value.toString('hex'));
981
+ }
933
982
  }
934
983
  } catch (error) {
935
984
  Log?.debug('monitor data error: ', error);
936
985
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
937
- this.runPromise?.reject(notifyError);
938
- this.rejectAllProtocolV2Frames(notifyError);
986
+ if (this.deviceProtocol.get(uuid) === 'V2') {
987
+ this.rejectProtocolV2Frames(uuid, notifyError);
988
+ } else if (this.runPromiseDeviceId === uuid) {
989
+ this.runPromise?.reject(notifyError);
990
+ }
939
991
  }
940
992
  }, notifyTransactionId);
941
993
 
@@ -943,13 +995,18 @@ export default class ReactNativeBleTransport {
943
995
  }
944
996
 
945
997
  async release(uuid: string, onclose = false) {
998
+ await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
999
+ return this.releaseNative(uuid, onclose);
1000
+ }
1001
+
1002
+ private async releaseNative(uuid: string, onclose = false) {
946
1003
  const transport = transportCache[uuid];
947
- if (this.runPromise) {
1004
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
948
1005
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
949
1006
  this.runPromise.reject(error);
950
1007
  this.runPromise = null;
951
- this.rejectAllProtocolV2Frames(error);
952
- this.activeProtocolV2Call = null;
1008
+ this.runPromiseDeviceId = null;
1009
+ this.rejectProtocolV2Frames(uuid, error);
953
1010
  } else {
954
1011
  this.resetProtocolV2Frames(uuid);
955
1012
  }
@@ -957,9 +1014,6 @@ export default class ReactNativeBleTransport {
957
1014
  if (Platform.OS === 'android' && !onclose && transport) {
958
1015
  this.protocolV2Assemblers.get(uuid)?.reset();
959
1016
  this.resetProtocolV2Frames(uuid);
960
- if (this.activeProtocolV2Call?.uuid === uuid) {
961
- this.activeProtocolV2Call = null;
962
- }
963
1017
  return Promise.resolve(true);
964
1018
  }
965
1019
 
@@ -993,7 +1047,7 @@ export default class ReactNativeBleTransport {
993
1047
  }
994
1048
 
995
1049
  this.deviceProtocol.delete(uuid);
996
- this.deviceProtocolHints.delete(uuid);
1050
+ // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
997
1051
  this.protocolV2Assemblers.get(uuid)?.reset();
998
1052
  this.protocolV2Assemblers.delete(uuid);
999
1053
  this.resetProtocolV2Frames(uuid);
@@ -1025,13 +1079,6 @@ export default class ReactNativeBleTransport {
1025
1079
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1026
1080
  }
1027
1081
 
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
1082
  const protocol = this.getProtocolType(uuid);
1036
1083
  if (!protocol) {
1037
1084
  throw ERRORS.TypedError(
@@ -1039,31 +1086,17 @@ export default class ReactNativeBleTransport {
1039
1086
  `Device protocol has not been detected for ${uuid}`
1040
1087
  );
1041
1088
  }
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
- }
1089
+ Log?.debug('transport call', createTransportCallLog(name, protocol, data));
1062
1090
 
1063
1091
  if (protocol === 'V2') {
1064
1092
  return this.callProtocolV2(uuid, name, data, options);
1065
1093
  }
1066
1094
 
1095
+ const forceRun = name === 'Initialize' || name === 'Cancel';
1096
+ if (this.runPromise && !forceRun) {
1097
+ throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
1098
+ }
1099
+
1067
1100
  return this.callProtocolV1(uuid, name, data, options);
1068
1101
  }
1069
1102
 
@@ -1080,7 +1113,24 @@ export default class ReactNativeBleTransport {
1080
1113
  const transport = this.getCachedTransport(uuid);
1081
1114
  const runPromise = createDeferred<string>();
1082
1115
  runPromise.promise.catch(() => undefined);
1116
+ const supersededRunPromise = this.runPromise;
1117
+ if (supersededRunPromise) {
1118
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1119
+ // the superseded deferred now so its response race resolves and its finally block
1120
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1121
+ // tear down the shared connection while another call is using it.
1122
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1123
+ }
1083
1124
  this.runPromise = runPromise;
1125
+ this.runPromiseDeviceId = uuid;
1126
+ // A superseded call's late write failure must not clear the successor's ownership;
1127
+ // only the call that still owns the slot may release it.
1128
+ const releaseOwnershipIfCurrent = () => {
1129
+ if (this.runPromise === runPromise) {
1130
+ this.runPromise = null;
1131
+ this.runPromiseDeviceId = null;
1132
+ }
1133
+ };
1084
1134
  const messages = this._messages;
1085
1135
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1086
1136
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1152,19 +1202,18 @@ export default class ReactNativeBleTransport {
1152
1202
  buffers,
1153
1203
  data => transport.writeWithRetry(data),
1154
1204
  e => {
1155
- this.runPromise = null;
1205
+ releaseOwnershipIfCurrent();
1156
1206
  Log?.error('writeCharacteristic write error: ', e);
1157
1207
  }
1158
1208
  );
1159
1209
  } else if (name === 'FirmwareUpload') {
1160
- Log?.debug('[ReactNativeBleTransport] FirmwareUpload write uses throttled BLE packets:', {
1210
+ Log?.debug('[ReactNativeBleTransport] Firmware upload transport configured', {
1161
1211
  packetCapacity: FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY,
1162
1212
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1163
1213
  pauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1164
1214
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1165
1215
  maxRetries: FIRMWARE_UPLOAD_WRITE_MAX_RETRIES,
1166
1216
  });
1167
-
1168
1217
  await writeFirmwareUploadChunkedData(
1169
1218
  buffers,
1170
1219
  async data => {
@@ -1181,36 +1230,19 @@ export default class ReactNativeBleTransport {
1181
1230
  if (!retryType || attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES) {
1182
1231
  throw error;
1183
1232
  }
1184
- const shouldReconnect = retryType === 'reconnectable';
1185
- const delayMs = shouldReconnect
1186
- ? FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS
1187
- : resolveFirmwareUploadRetryDelay(attempt);
1233
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1188
1234
  Log?.debug('[ReactNativeBleTransport] FirmwareUpload write retry:', {
1189
1235
  attempt: attempt + 1,
1190
1236
  delayMs,
1191
- reconnect: shouldReconnect,
1192
1237
  error,
1193
1238
  });
1194
- if (shouldReconnect) {
1195
- this.firmwareUploadWriteRecoveryIds.add(uuid);
1196
- }
1197
1239
  await delay(delayMs);
1198
1240
  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
1241
  }
1210
1242
  }
1211
1243
  },
1212
1244
  e => {
1213
- this.runPromise = null;
1245
+ releaseOwnershipIfCurrent();
1214
1246
  Log?.error('writeCharacteristic write error: ', e);
1215
1247
  }
1216
1248
  );
@@ -1218,12 +1250,11 @@ export default class ReactNativeBleTransport {
1218
1250
  for (const o of buffers) {
1219
1251
  const outData = o.toString('base64');
1220
1252
  // Upload resources on low-end phones may OOM
1221
- // this.Log.debug('send hex strting: ', o.toString('hex'));
1222
1253
  try {
1223
1254
  await transport.writeCharacteristic.writeWithoutResponse(outData);
1224
1255
  } catch (e) {
1225
1256
  Log?.debug('writeCharacteristic write error: ', e);
1226
- this.runPromise = null;
1257
+ releaseOwnershipIfCurrent();
1227
1258
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1228
1259
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1229
1260
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1256,20 +1287,33 @@ export default class ReactNativeBleTransport {
1256
1287
  throw new Error('Returning data is not string.');
1257
1288
  }
1258
1289
 
1259
- Log?.debug('receive data: ', response);
1260
1290
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1261
1291
  return check.call(jsonData);
1262
1292
  } catch (e) {
1263
- if (name === 'Initialize' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1264
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe call failed:', e);
1293
+ if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1294
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1265
1295
  } else {
1266
1296
  Log?.error('call error: ', e);
1267
1297
  }
1298
+ const isProbeTimeout =
1299
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1300
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1301
+ // transport; its late timeout must not tear down the connection the current
1302
+ // call is actively using.
1303
+ const isStaleCall = this.runPromise !== runPromise;
1304
+ if (
1305
+ !isProbeTimeout &&
1306
+ !isStaleCall &&
1307
+ (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1308
+ ) {
1309
+ await this.disconnect(uuid);
1310
+ }
1268
1311
  throw e;
1269
1312
  } finally {
1270
1313
  if (timeout) clearTimeout(timeout);
1271
1314
  if (this.runPromise === runPromise) {
1272
1315
  this.runPromise = null;
1316
+ this.runPromiseDeviceId = null;
1273
1317
  }
1274
1318
  }
1275
1319
  }
@@ -1279,8 +1323,9 @@ export default class ReactNativeBleTransport {
1279
1323
  }
1280
1324
 
1281
1325
  async disconnect(session: string) {
1282
- Log?.debug('transport-react-native transport resetSession: ', session);
1326
+ await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1283
1327
  const transport = transportCache[session];
1328
+ const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1284
1329
 
1285
1330
  // Clean up disconnect subscription first to prevent onDisconnected callback
1286
1331
  // from being triggered when we cancel the connection below
@@ -1341,20 +1386,16 @@ export default class ReactNativeBleTransport {
1341
1386
  this.deviceProtocolHints.delete(session);
1342
1387
  this.protocolV2Assemblers.delete(session);
1343
1388
  this.resetProtocolV2Frames(session);
1344
- if (this.activeProtocolV2Call?.uuid === session) {
1345
- this.activeProtocolV2Call = null;
1346
- }
1347
1389
 
1348
1390
  // emit the disconnect event
1349
1391
  try {
1350
- this.emitter?.emit('device-disconnect', {
1351
- name: transport?.device?.name,
1352
- id: session,
1353
- connectId: session,
1354
- });
1392
+ this.emitDeviceDisconnect(session, transport?.device?.name, monitorToken);
1355
1393
  } catch (e) {
1356
1394
  Log?.error('resetSession: emit disconnect event error: ', e);
1357
1395
  }
1396
+ if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1397
+ this.monitorTokens.delete(session);
1398
+ }
1358
1399
  // eslint-disable-next-line no-promise-executor-return
1359
1400
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1360
1401
  }
@@ -1365,6 +1406,7 @@ export default class ReactNativeBleTransport {
1365
1406
  // this.runPromise.reject(new Error('Transport_CallCanceled'));
1366
1407
  }
1367
1408
  this.runPromise = null;
1409
+ this.runPromiseDeviceId = null;
1368
1410
  }
1369
1411
 
1370
1412
  private getCachedTransport(uuid: string) {
@@ -1385,7 +1427,7 @@ export default class ReactNativeBleTransport {
1385
1427
  private createProtocolDetectionError() {
1386
1428
  return ERRORS.TypedError(
1387
1429
  HardwareErrorCode.BleTimeoutError,
1388
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
1430
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
1389
1431
  );
1390
1432
  }
1391
1433
 
@@ -1398,42 +1440,61 @@ export default class ReactNativeBleTransport {
1398
1440
  private async detectProtocol(
1399
1441
  uuid: string,
1400
1442
  expectedProtocol?: ProtocolType,
1401
- protocolHint?: ProtocolType
1443
+ protocolHint?: ProtocolType,
1444
+ rebuildTransport?: () => Promise<void>
1402
1445
  ): Promise<ProtocolType> {
1403
1446
  if (expectedProtocol === 'V1') {
1404
1447
  if (await this.probeProtocolV1(uuid)) {
1405
1448
  this.deviceProtocol.set(uuid, 'V1');
1406
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> V1 (expected)`);
1449
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1450
+ deviceId: uuid,
1451
+ protocol: 'V1',
1452
+ source: 'expected',
1453
+ });
1407
1454
  return 'V1';
1408
1455
  }
1409
1456
  throw this.createProtocolMismatchError(expectedProtocol);
1410
1457
  }
1411
1458
 
1412
1459
  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';
1460
+ if (await this.probeProtocolV2(uuid)) {
1461
+ this.deviceProtocol.set(uuid, 'V2');
1462
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1463
+ deviceId: uuid,
1464
+ protocol: 'V2',
1465
+ source: 'expected',
1466
+ });
1467
+ return 'V2';
1468
+ }
1469
+ throw this.createProtocolMismatchError(expectedProtocol);
1418
1470
  }
1419
1471
 
1420
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
1421
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1
1422
- // 不能作为最终结论。
1472
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
1473
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
1423
1474
  const probeOrder: ProtocolType[] =
1424
1475
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1425
1476
 
1426
1477
  for (let i = 0; i < probeOrder.length; i += 1) {
1427
1478
  const protocol = probeOrder[i];
1428
1479
  if (i > 0) {
1429
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
1480
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
1430
1481
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
1482
+ if (!transportCache[uuid]) {
1483
+ if (!rebuildTransport) {
1484
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1485
+ }
1486
+ await rebuildTransport();
1487
+ }
1431
1488
  }
1432
1489
  const detected =
1433
1490
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1434
1491
  if (detected) {
1435
1492
  this.deviceProtocol.set(uuid, protocol);
1436
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> ${protocol}`);
1493
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1494
+ deviceId: uuid,
1495
+ protocol,
1496
+ source: 'probe',
1497
+ });
1437
1498
  return protocol;
1438
1499
  }
1439
1500
  }
@@ -1444,11 +1505,12 @@ export default class ReactNativeBleTransport {
1444
1505
 
1445
1506
  private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
1446
1507
  const transport = transportCache[uuid];
1508
+ await this.protocolV2Links.invalidateLink(
1509
+ uuid,
1510
+ `Reset notify state after Protocol ${protocol} probe`
1511
+ );
1447
1512
  this.protocolV2Assemblers.get(uuid)?.reset();
1448
1513
  this.resetProtocolV2Frames(uuid);
1449
- if (this.activeProtocolV2Call?.uuid === uuid) {
1450
- this.activeProtocolV2Call = null;
1451
- }
1452
1514
  if (this.runPromise) {
1453
1515
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
1454
1516
  this.runPromise.reject(error);
@@ -1500,11 +1562,13 @@ export default class ReactNativeBleTransport {
1500
1562
 
1501
1563
  try {
1502
1564
  this.deviceProtocol.set(uuid, 'V1');
1503
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1565
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
1566
+ // session before Core has a chance to restore a hidden wallet.
1567
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1504
1568
  return true;
1505
1569
  } catch (error) {
1506
1570
  this.clearProbeProtocol(uuid, 'V1');
1507
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe failed:', error);
1571
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1508
1572
  return false;
1509
1573
  }
1510
1574
  }
@@ -1533,13 +1597,9 @@ export default class ReactNativeBleTransport {
1533
1597
  return detected;
1534
1598
  }
1535
1599
 
1536
- private handleProtocolV2Notification(uuid: string, data: Uint8Array) {
1600
+ private handleProtocolV2Notification(uuid: string, monitorToken: number, data: Uint8Array) {
1537
1601
  try {
1538
- if (!this.runPromise || this.activeProtocolV2Call?.uuid !== uuid) {
1539
- this.protocolV2Assemblers.get(uuid)?.reset();
1540
- this.resetProtocolV2Frames(uuid);
1541
- return;
1542
- }
1602
+ if (this.monitorTokens.get(uuid) !== monitorToken) return;
1543
1603
 
1544
1604
  if (data.length === 0) return;
1545
1605
 
@@ -1552,8 +1612,15 @@ export default class ReactNativeBleTransport {
1552
1612
  } catch (error) {
1553
1613
  Log?.debug('[ReactNativeBleTransport] Protocol V2 notification error:', error);
1554
1614
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1555
- this.runPromise?.reject(notifyError);
1556
- this.rejectAllProtocolV2Frames(notifyError);
1615
+ this.rejectProtocolV2Frames(uuid, notifyError);
1616
+ this.protocolV2Links
1617
+ .invalidateLink(uuid, `Protocol V2 notification error: ${error}`)
1618
+ .catch(invalidateError =>
1619
+ Log?.debug(
1620
+ '[ReactNativeBleTransport] Protocol V2 notify cleanup failed:',
1621
+ invalidateError
1622
+ )
1623
+ );
1557
1624
  }
1558
1625
  }
1559
1626
 
@@ -1576,21 +1643,17 @@ export default class ReactNativeBleTransport {
1576
1643
  this.getProtocolV2FrameQueue(uuid).push(frame);
1577
1644
  }
1578
1645
 
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
1646
  private resetProtocolV2Frames(uuid: string) {
1588
- this.protocolV2FrameQueues.delete(uuid);
1589
- this.protocolV2FramePromises.delete(uuid);
1647
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
1590
1648
  }
1591
1649
 
1592
- private isActiveProtocolV2Call(uuid: string, token: number) {
1593
- return this.activeProtocolV2Call?.uuid === uuid && this.activeProtocolV2Call.token === token;
1650
+ private rejectProtocolV2Frames(uuid: string, error: Error) {
1651
+ this.protocolV2FrameQueues.delete(uuid);
1652
+ const framePromise = this.protocolV2FramePromises.get(uuid);
1653
+ if (framePromise) {
1654
+ this.protocolV2FramePromises.delete(uuid);
1655
+ framePromise.reject(error);
1656
+ }
1594
1657
  }
1595
1658
 
1596
1659
  private async readProtocolV2Frame(uuid: string) {
@@ -1610,10 +1673,46 @@ export default class ReactNativeBleTransport {
1610
1673
  }
1611
1674
  }
1612
1675
 
1676
+ private async writeProtocolV2Packet(
1677
+ transport: BleTransport,
1678
+ base64: string,
1679
+ context: ProtocolV2CallContext,
1680
+ assertCurrentGeneration: () => void
1681
+ ) {
1682
+ let attempt = 0;
1683
+ for (;;) {
1684
+ assertCurrentGeneration();
1685
+ if (context.signal.aborted) {
1686
+ throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1687
+ }
1688
+ try {
1689
+ await transport.writeCharacteristic.writeWithoutResponse(base64);
1690
+ assertCurrentGeneration();
1691
+ return;
1692
+ } catch (error) {
1693
+ if (
1694
+ getFirmwareUploadWriteRetryType(error) !== 'congested' ||
1695
+ attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
1696
+ ) {
1697
+ throw error;
1698
+ }
1699
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1700
+ attempt += 1;
1701
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 congested write retry:', {
1702
+ name: context.messageName,
1703
+ attempt,
1704
+ delayMs,
1705
+ });
1706
+ await delay(delayMs);
1707
+ }
1708
+ }
1709
+ }
1710
+
1613
1711
  private async writeProtocolV2Frame(
1614
1712
  transport: BleTransport,
1615
1713
  frame: Uint8Array,
1616
- options?: { highVolume?: boolean; writeWithResponse?: boolean }
1714
+ context: ProtocolV2CallContext,
1715
+ assertCurrentGeneration: () => void
1617
1716
  ) {
1618
1717
  const tuning = getProtocolV2BleTuning();
1619
1718
  const packetCapacity = resolveProtocolV2PacketCapacity({
@@ -1622,37 +1721,24 @@ export default class ReactNativeBleTransport {
1622
1721
  androidPacketLength: tuning.androidPacketLength,
1623
1722
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1624
1723
  });
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
- }
1724
+ await writeProtocolV2BleFrame({
1725
+ frame,
1726
+ packetCapacity,
1727
+ assertActive: assertCurrentGeneration,
1728
+ signal: context.signal,
1729
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
1730
+ burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1731
+ burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1732
+ flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1733
+ wait: delay,
1734
+ writePacket: packet =>
1735
+ this.writeProtocolV2Packet(
1736
+ transport,
1737
+ Buffer.from(packet).toString('base64'),
1738
+ context,
1739
+ assertCurrentGeneration
1740
+ ),
1741
+ });
1656
1742
  }
1657
1743
 
1658
1744
  private async callProtocolV2(
@@ -1665,101 +1751,76 @@ export default class ReactNativeBleTransport {
1665
1751
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1666
1752
  }
1667
1753
 
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
- };
1754
+ const callOptions = options;
1693
1755
  const highVolumeWrite = LogBlockCommand.has(name);
1694
1756
 
1695
1757
  if (highVolumeWrite) {
1696
1758
  const tuning = getProtocolV2BleTuning();
1697
- Log?.debug(
1698
- '[ReactNativeBleTransport] Protocol V2 high-volume write uses throttled writeWithoutResponse:',
1759
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1699
1760
  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
- );
1761
+ writeMode: 'withoutResponse',
1762
+ packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
1763
+ });
1709
1764
  }
1710
1765
 
1711
1766
  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;
1767
+ return await this.protocolV2Links.call(
1768
+ uuid,
1769
+ () => this.createProtocolV2Adapter(uuid),
1770
+ name,
1771
+ data,
1772
+ callOptions
1773
+ );
1742
1774
  } catch (e) {
1743
- if (this.isActiveProtocolV2Call(uuid, callToken)) {
1744
- this.protocolV2Assemblers.get(uuid)?.reset();
1745
- this.resetProtocolV2Frames(uuid);
1746
- }
1747
1775
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1748
1776
  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
1777
  }
1761
1778
  }
1762
1779
 
1780
+ private createProtocolV2Adapter(uuid: string) {
1781
+ const generation = this.monitorTokens.get(uuid) ?? 0;
1782
+ const assertCurrentGeneration = () => {
1783
+ if (this.monitorTokens.get(uuid) !== generation) {
1784
+ throw new Error(`Protocol V2 monitor generation changed for ${uuid}`);
1785
+ }
1786
+ };
1787
+
1788
+ return {
1789
+ router: PROTOCOL_V2_CHANNEL_BLE_UART,
1790
+ maxFrameBytes: PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
1791
+ generation,
1792
+ prepareCall: () => {
1793
+ assertCurrentGeneration();
1794
+ this.protocolV2Assemblers.get(uuid)?.reset();
1795
+ this.resetProtocolV2Frames(uuid);
1796
+ },
1797
+ writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1798
+ assertCurrentGeneration();
1799
+ const currentTransport = this.getCachedTransport(uuid);
1800
+ await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
1801
+ },
1802
+ readFrame: async () => {
1803
+ assertCurrentGeneration();
1804
+ const rxFrame = await this.readProtocolV2Frame(uuid);
1805
+ if (!(rxFrame instanceof Uint8Array)) {
1806
+ throw new Error('Protocol V2 response is not Uint8Array');
1807
+ }
1808
+ return rxFrame;
1809
+ },
1810
+ reset: (reason: string) => {
1811
+ this.protocolV2Assemblers.get(uuid)?.reset();
1812
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
1813
+ },
1814
+ logger: Log,
1815
+ logPrefix: 'ProtocolV2 RN-BLE',
1816
+ createTimeoutError: (messageName: string, timeout: number) =>
1817
+ ERRORS.TypedError(
1818
+ HardwareErrorCode.BleTimeoutError,
1819
+ `BLE response timeout after ${timeout}ms for ${messageName}`
1820
+ ),
1821
+ };
1822
+ }
1823
+
1763
1824
  getProtocolType(path: string): ProtocolType | undefined {
1764
1825
  return this.deviceProtocol.get(path);
1765
1826
  }