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

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,31 @@ 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
+ isPro2FindMyAdvertisementName,
32
+ } from '@onekeyfe/hd-shared';
23
33
 
24
34
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
25
- import {
26
- hasWritableCapability,
27
- resolveBleWriteMode,
28
- resolveProtocolV2PacketCapacity,
29
- } from './bleStrategy';
35
+ import { hasWritableCapability, resolveProtocolV2PacketCapacity } from './bleStrategy';
30
36
  import { subscribeBleOn } from './subscribeBleOn';
31
37
  import {
32
38
  ANDROID_PACKET_LENGTH,
33
39
  IOS_PACKET_LENGTH,
34
- getBleUuidKey,
35
40
  getBluetoothServiceUuids,
36
41
  getInfosForServiceUuid,
37
42
  isSameBleUuid,
@@ -40,6 +45,7 @@ import { isHeaderChunk } from './utils/validateNotify';
40
45
  import BleTransport from './BleTransport';
41
46
  import timer from './utils/timer';
42
47
  import { bleLogger, setBleLogger } from './logger';
48
+ import { createTransportCallLog } from './transportLog';
43
49
 
44
50
  import type { Deferred } from '@onekeyfe/hd-shared';
45
51
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
@@ -55,13 +61,13 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
55
61
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
56
62
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
57
63
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
58
- const FIRMWARE_UPLOAD_RECONNECT_RETRY_DELAY_MS = 2000;
64
+ const IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS = 5;
59
65
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
60
66
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
61
67
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
62
68
  const ANDROID_GATT_CONGESTED_STATUS = 143;
63
69
 
64
- type FirmwareUploadWriteRetryType = 'congested' | 'reconnectable';
70
+ type FirmwareUploadWriteRetryType = 'congested';
65
71
  type ResolvedBleCharacteristics = {
66
72
  writeCharacteristic: Characteristic;
67
73
  notifyCharacteristic: Characteristic;
@@ -72,7 +78,9 @@ const delay = (ms: number) =>
72
78
  setTimeout(resolve, ms);
73
79
  });
74
80
 
75
- const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRetryType | null => {
81
+ export const getFirmwareUploadWriteRetryType = (
82
+ error: unknown
83
+ ): FirmwareUploadWriteRetryType | null => {
76
84
  if (!error || typeof error !== 'object') return null;
77
85
  const bleWriteError = error as {
78
86
  androidErrorCode?: unknown;
@@ -83,13 +91,6 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
83
91
  name?: unknown;
84
92
  };
85
93
 
86
- if (
87
- bleWriteError.errorCode === BleErrorCode.DeviceDisconnected ||
88
- bleWriteError.errorCode === BleErrorCode.CharacteristicNotFound
89
- ) {
90
- return 'reconnectable';
91
- }
92
-
93
94
  if (
94
95
  bleWriteError.androidErrorCode === ANDROID_GATT_CONGESTED_STATUS ||
95
96
  bleWriteError.status === ANDROID_GATT_CONGESTED_STATUS
@@ -105,23 +106,14 @@ const getFirmwareUploadWriteRetryType = (error: unknown): FirmwareUploadWriteRet
105
106
 
106
107
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
107
108
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
108
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
109
109
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
110
110
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
111
- const DEVICE_SCAN_TIMEOUT_MS = 8000;
111
+ const DEVICE_SCAN_TIMEOUT_MS = 3000;
112
112
  const IOS_NOTIFY_READY_DELAY_MS = 150;
113
113
  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
114
  export type ProtocolV2BleTuning = {
119
115
  iosPacketLength?: number;
120
116
  androidPacketLength?: number;
121
- highVolumeWriteBurstSize?: number;
122
- highVolumeWritePauseMs?: number;
123
- highVolumeWriteFlushDelayMs?: number;
124
- highVolumeWriteWithResponse?: boolean;
125
117
  };
126
118
 
127
119
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
@@ -129,10 +121,6 @@ type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
129
121
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
130
122
  iosPacketLength: IOS_PACKET_LENGTH,
131
123
  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
124
  };
137
125
 
138
126
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -153,27 +141,13 @@ export function configureProtocolV2BleTuning(tuning: ProtocolV2BleTuning = {}) {
153
141
  tuning.androidPacketLength,
154
142
  protocolV2BleTuning.androidPacketLength
155
143
  ),
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
144
  };
171
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning configured:', protocolV2BleTuning);
145
+ Log?.debug('[ReactNativeBleTransport] BLE tuning configured', protocolV2BleTuning);
172
146
  }
173
147
 
174
148
  export function resetProtocolV2BleTuning() {
175
149
  protocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
176
- Log?.debug('[ReactNativeBleTransport] Protocol V2 BLE tuning reset:', protocolV2BleTuning);
150
+ Log?.debug('[ReactNativeBleTransport] BLE tuning reset', protocolV2BleTuning);
177
151
  }
178
152
 
179
153
  export function getProtocolV2BleTuning() {
@@ -188,16 +162,6 @@ function getDeviceDisplayName(device?: Device | null) {
188
162
  return device?.name || device?.localName || null;
189
163
  }
190
164
 
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
165
  const ANDROID_REQUEST_MTU = 256;
202
166
 
203
167
  const connectOptions: Record<string, unknown> = {
@@ -222,9 +186,10 @@ const requestAndroidMtu = async (device: Device) => {
222
186
 
223
187
  try {
224
188
  const mtuDevice = await device.requestMTU(ANDROID_REQUEST_MTU);
225
- Log?.debug('[ReactNativeBleTransport] Android MTU requested:', {
189
+ Log?.debug('[ReactNativeBleTransport] MTU configured', {
190
+ deviceId: device.id,
226
191
  requested: ANDROID_REQUEST_MTU,
227
- mtu: mtuDevice.mtu,
192
+ actual: mtuDevice.mtu,
228
193
  });
229
194
  return mtuDevice;
230
195
  } catch (error) {
@@ -272,6 +237,8 @@ export default class ReactNativeBleTransport {
272
237
 
273
238
  _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
274
239
 
240
+ private protocolV2SchemaConfiguration: string | undefined;
241
+
275
242
  name = 'ReactNativeBleTransport';
276
243
 
277
244
  configured = false;
@@ -282,6 +249,8 @@ export default class ReactNativeBleTransport {
282
249
 
283
250
  runPromise: Deferred<any> | null = null;
284
251
 
252
+ private runPromiseDeviceId: string | null = null;
253
+
285
254
  emitter?: EventEmitter;
286
255
 
287
256
  firmwareUploadWriteRecoveryIds = new Set<string>();
@@ -297,12 +266,31 @@ export default class ReactNativeBleTransport {
297
266
 
298
267
  private protocolV2FramePromises: Map<string, Deferred<Uint8Array>> = new Map();
299
268
 
300
- private activeProtocolV2Call: { uuid: string; token: number } | null = null;
301
-
302
- private nextProtocolV2CallToken = 1;
269
+ private protocolV2Links = new ProtocolV2LinkManager<string>({
270
+ getSchemas: () => {
271
+ if (!this._messages || !this._messagesV2) {
272
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
273
+ }
274
+ return {
275
+ protocolV1: this._messages,
276
+ protocolV2: this._messagesV2,
277
+ };
278
+ },
279
+ classifyError: () => 'link-fatal',
280
+ onLinkInvalidated: async (uuid, reason) => {
281
+ this.protocolV2Assemblers.get(uuid)?.reset();
282
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
283
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
284
+ if (reason.startsWith('Protocol V2 link-fatal error:')) {
285
+ await this.releaseNative(uuid, true);
286
+ }
287
+ },
288
+ });
303
289
 
304
290
  private monitorTokens: Map<string, number> = new Map();
305
291
 
292
+ private disconnectEventTokens: Map<string, number> = new Map();
293
+
306
294
  private nextMonitorToken = 1;
307
295
 
308
296
  constructor(options: TransportOptions) {
@@ -321,8 +309,19 @@ export default class ReactNativeBleTransport {
321
309
  }
322
310
 
323
311
  configureProtocolV2(signedData: any) {
312
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
313
+ if (this.protocolV2SchemaConfiguration === configuration) {
314
+ return;
315
+ }
316
+
317
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
324
318
  this._messagesV2 = parseConfigure(signedData);
325
- Log?.debug('[ReactNativeBleTransport] Protocol V2 schema configured');
319
+ this.protocolV2SchemaConfiguration = configuration;
320
+ if (isReconfiguration) {
321
+ this.protocolV2Links
322
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
323
+ .catch(error => Log?.debug('Protocol V2 schema link cleanup failed:', error));
324
+ }
326
325
  }
327
326
 
328
327
  listen() {
@@ -352,29 +351,15 @@ export default class ReactNativeBleTransport {
352
351
  }
353
352
  }
354
353
 
355
- let fallbackServiceUuid: string | undefined;
356
-
357
354
  if (!infos) {
358
355
  const services = await device.services();
359
356
  Log?.debug(
360
357
  '[ReactNativeBleTransport] Known OneKey service UUID not found, discovered services:',
361
358
  services?.map(service => service.uuid)
362
359
  );
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
360
  }
376
361
 
377
- if (!infos && !fallbackServiceUuid) {
362
+ if (!infos) {
378
363
  try {
379
364
  Log?.debug('cancel connection when service not found');
380
365
  await device.cancelConnection();
@@ -384,9 +369,7 @@ export default class ReactNativeBleTransport {
384
369
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
385
370
  }
386
371
 
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';
372
+ const { serviceUuid, writeUuid, notifyUuid } = infos;
390
373
 
391
374
  if (!serviceUuid) {
392
375
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
@@ -436,6 +419,7 @@ export default class ReactNativeBleTransport {
436
419
 
437
420
  attachDisconnectSubscription(transport: BleTransport, device: Device, uuid: string) {
438
421
  transport.disconnectSubscription?.remove();
422
+ const { monitorToken } = transport;
439
423
  transport.disconnectSubscription = device.onDisconnected(() => {
440
424
  if (this.firmwareUploadWriteRecoveryIds.has(uuid)) {
441
425
  Log?.debug('device disconnect ignored during FirmwareUpload write recovery: ', uuid);
@@ -445,18 +429,17 @@ export default class ReactNativeBleTransport {
445
429
  Log?.debug('device disconnect ignored for stale transport: ', device?.id);
446
430
  return;
447
431
  }
432
+ if (this.monitorTokens.get(uuid) !== monitorToken) {
433
+ Log?.debug('device disconnect ignored for stale generation: ', device?.id);
434
+ return;
435
+ }
448
436
 
449
437
  try {
450
438
  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) {
439
+ this.emitDeviceDisconnect(uuid, device?.name, monitorToken);
440
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
457
441
  const error = ERRORS.TypedError(HardwareErrorCode.BleConnectedError);
458
442
  this.runPromise.reject(error);
459
- this.rejectAllProtocolV2Frames(error);
460
443
  }
461
444
  } catch (e) {
462
445
  Log?.debug('device disconnect error: ', e);
@@ -466,6 +449,22 @@ export default class ReactNativeBleTransport {
466
449
  });
467
450
  }
468
451
 
452
+ private emitDeviceDisconnect(uuid: string, name: string | null | undefined, token?: number) {
453
+ if (token === undefined || this.disconnectEventTokens.get(uuid) === token) {
454
+ return;
455
+ }
456
+ if (this.monitorTokens.get(uuid) !== token) {
457
+ Log?.debug('device disconnect event ignored for stale generation: ', uuid);
458
+ return;
459
+ }
460
+ this.disconnectEventTokens.set(uuid, token);
461
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
462
+ name,
463
+ id: uuid,
464
+ connectId: uuid,
465
+ });
466
+ }
467
+
469
468
  async reconnectFirmwareUploadTransport(uuid: string, transport: BleTransport) {
470
469
  this.firmwareUploadWriteRecoveryIds.add(uuid);
471
470
  try {
@@ -553,14 +552,13 @@ export default class ReactNativeBleTransport {
553
552
  }
554
553
 
555
554
  blePlxManager.startDeviceScan(
556
- null,
555
+ getBluetoothServiceUuids(),
557
556
  {
558
557
  allowDuplicates: true,
559
558
  scanMode: ScanMode.LowLatency,
560
559
  },
561
560
  (error, device) => {
562
561
  if (error) {
563
- Log?.debug('ble scan manager: ', blePlxManager);
564
562
  Log?.debug('ble scan error: ', error);
565
563
  if (
566
564
  [BleErrorCode.BluetoothPoweredOff, BleErrorCode.BluetoothInUnknownState].includes(
@@ -584,33 +582,23 @@ export default class ReactNativeBleTransport {
584
582
  }
585
583
 
586
584
  const displayName = getDeviceDisplayName(device);
585
+ // iOS may report a service-only advertisement before the named scan response.
586
+ // Do not cache that incomplete advertisement as an unknown device.
587
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
588
+ const isFindMyPeripheral =
589
+ isPro2FindMyAdvertisementName(device?.name) ||
590
+ isPro2FindMyAdvertisementName(device?.localName);
587
591
  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', {
592
+ !isUnnamedIOSPeripheral &&
593
+ !isFindMyPeripheral &&
594
+ isOnekeyBluetoothDevice({
595
+ id: device?.id,
596
596
  name: device?.name,
597
597
  localName: device?.localName,
598
- id: device?.id,
599
- serviceUUIDs: device?.serviceUUIDs,
600
- accepted: isOneKey,
598
+ serviceUuids: device?.serviceUUIDs,
601
599
  });
602
- }
603
-
604
600
  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
601
  addDevice(device as unknown as Device);
613
- Log?.debug('search device end ======================\n');
614
602
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
615
603
  Log?.debug('[ReactNativeBleTransport] Pro2-like BLE device was not accepted:', {
616
604
  name: device?.name,
@@ -622,12 +610,32 @@ export default class ReactNativeBleTransport {
622
610
  }
623
611
  );
624
612
 
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);
613
+ getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
614
+ devices => {
615
+ for (const device of devices) {
616
+ const localName =
617
+ 'localName' in device && typeof device.localName === 'string'
618
+ ? device.localName
619
+ : null;
620
+ const isFindMyPeripheral =
621
+ isPro2FindMyAdvertisementName(device.name) ||
622
+ isPro2FindMyAdvertisementName(localName);
623
+
624
+ if (
625
+ !isFindMyPeripheral &&
626
+ isOnekeyBluetoothDevice({
627
+ id: device.id,
628
+ name: device.name,
629
+ localName,
630
+ serviceUuids: device.serviceUUIDs,
631
+ })
632
+ ) {
633
+ Log?.debug('search connected peripheral: ', device.id);
634
+ addDevice(device as unknown as Device);
635
+ }
636
+ }
629
637
  }
630
- });
638
+ );
631
639
 
632
640
  const addDevice = (device: Device) => {
633
641
  if (deviceList.every(d => d.id !== device.id)) {
@@ -641,6 +649,12 @@ export default class ReactNativeBleTransport {
641
649
  name: displayName,
642
650
  commType: 'ble',
643
651
  } as IOneKeyDevice);
652
+ Log?.debug('[ReactNativeBleTransport] OneKey BLE device discovered', {
653
+ deviceId: device.id,
654
+ name: displayName,
655
+ serviceUUIDs: device.serviceUUIDs,
656
+ protocolHint,
657
+ });
644
658
  }
645
659
  };
646
660
 
@@ -651,6 +665,46 @@ export default class ReactNativeBleTransport {
651
665
  });
652
666
  }
653
667
 
668
+ private async installTransportForAcquire(
669
+ uuid: string,
670
+ device: Device,
671
+ characteristics?: ResolvedBleCharacteristics
672
+ ) {
673
+ const { writeCharacteristic, notifyCharacteristic } =
674
+ characteristics ?? (await this.resolveCharacteristics(device));
675
+ const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
676
+ if (Platform.OS === 'android') {
677
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
678
+ }
679
+ const monitorToken = this.nextMonitorToken;
680
+ this.nextMonitorToken += 1;
681
+ const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
682
+ transport.monitorToken = monitorToken;
683
+ transport.notifyTransactionId = notifyTransactionId;
684
+ this.monitorTokens.set(uuid, monitorToken);
685
+ transport.notifySubscription = this._monitorCharacteristic(
686
+ transport.notifyCharacteristic,
687
+ uuid,
688
+ monitorToken,
689
+ notifyTransactionId
690
+ );
691
+ transportCache[uuid] = transport;
692
+ this.protocolV2Assemblers.set(
693
+ uuid,
694
+ new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
695
+ );
696
+
697
+ if (Platform.OS === 'ios') {
698
+ await new Promise<void>(resolve => {
699
+ setTimeout(resolve, IOS_NOTIFY_READY_DELAY_MS);
700
+ });
701
+ } else if (Platform.OS === 'android') {
702
+ await delay(ANDROID_NOTIFY_READY_DELAY_MS);
703
+ }
704
+
705
+ return transport;
706
+ }
707
+
654
708
  async acquire(input: BleAcquireInput) {
655
709
  const { uuid, forceCleanRunPromise, expectedProtocol } = input;
656
710
 
@@ -684,9 +738,8 @@ export default class ReactNativeBleTransport {
684
738
  if (forceCleanRunPromise && this.runPromise) {
685
739
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
686
740
  this.runPromise.reject(error);
687
- this.rejectAllProtocolV2Frames(error);
688
741
  this.runPromise = null;
689
- this.activeProtocolV2Call = null;
742
+ this.runPromiseDeviceId = null;
690
743
  Log?.debug('Force clean Bluetooth run promise, forceCleanRunPromise: ', forceCleanRunPromise);
691
744
  }
692
745
 
@@ -775,12 +828,16 @@ export default class ReactNativeBleTransport {
775
828
  }
776
829
 
777
830
  device = await requestAndroidMtu(device);
778
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(device);
831
+ const acquiredDevice = device;
832
+ const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
833
+ acquiredDevice
834
+ );
779
835
 
780
836
  const protocolHint = expectedProtocol
781
837
  ? undefined
782
- : this.deviceProtocolHints.get(uuid) ??
783
- inferProtocolHintFromDeviceName(getDeviceDisplayName(device));
838
+ : input.protocolHint ??
839
+ this.deviceProtocolHints.get(uuid) ??
840
+ inferProtocolHintFromDeviceName(getDeviceDisplayName(acquiredDevice));
784
841
 
785
842
  // release transport before new transport instance
786
843
  await this.release(uuid, true);
@@ -788,45 +845,30 @@ export default class ReactNativeBleTransport {
788
845
  this.deviceProtocolHints.set(uuid, protocolHint);
789
846
  }
790
847
 
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,
848
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
849
+ writeCharacteristic,
850
+ notifyCharacteristic,
825
851
  });
826
852
 
827
- this.attachDisconnectSubscription(transport, device, uuid);
828
-
829
- return { uuid, protocolType };
853
+ try {
854
+ const protocolType = await this.detectProtocol(
855
+ uuid,
856
+ expectedProtocol,
857
+ protocolHint,
858
+ async () => {
859
+ await this.installTransportForAcquire(uuid, acquiredDevice);
860
+ }
861
+ );
862
+ const currentTransport = transportCache[uuid];
863
+ if (!currentTransport) {
864
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
865
+ }
866
+ this.attachDisconnectSubscription(currentTransport, acquiredDevice, uuid);
867
+ return { uuid, protocolType };
868
+ } catch (error) {
869
+ await this.release(uuid, true);
870
+ throw error;
871
+ }
830
872
  }
831
873
 
832
874
  _monitorCharacteristic(
@@ -853,7 +895,30 @@ export default class ReactNativeBleTransport {
853
895
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
854
896
  return;
855
897
  }
856
- if (this.runPromise) {
898
+ if (this.deviceProtocol.get(uuid) === 'V2') {
899
+ let errorCode:
900
+ | typeof HardwareErrorCode.BleDeviceBondError
901
+ | typeof HardwareErrorCode.BleCharacteristicNotifyError
902
+ | typeof HardwareErrorCode.BleCharacteristicNotifyChangeFailure
903
+ | typeof HardwareErrorCode.BleTimeoutError =
904
+ HardwareErrorCode.BleCharacteristicNotifyError;
905
+ if (error.reason?.includes('The connection has timed out unexpectedly')) {
906
+ errorCode = HardwareErrorCode.BleTimeoutError;
907
+ } else if (error.reason?.includes('Encryption is insufficient')) {
908
+ errorCode = HardwareErrorCode.BleDeviceBondError;
909
+ } else if (
910
+ error.reason?.includes('Cannot write client characteristic config descriptor') ||
911
+ error.reason?.includes('Cannot find client characteristic config descriptor') ||
912
+ error.reason?.includes('The handle is invalid') ||
913
+ error.reason?.includes('Writing is not permitted') ||
914
+ error.reason?.includes('notify change failed for device')
915
+ ) {
916
+ errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
917
+ }
918
+ this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
919
+ return;
920
+ }
921
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
857
922
  let ERROR:
858
923
  | typeof HardwareErrorCode.BleDeviceBondError
859
924
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -876,7 +941,6 @@ export default class ReactNativeBleTransport {
876
941
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
877
942
  );
878
943
  this.runPromise.reject(notifyError);
879
- this.rejectAllProtocolV2Frames(notifyError);
880
944
  Log?.debug(
881
945
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
882
946
  );
@@ -884,7 +948,6 @@ export default class ReactNativeBleTransport {
884
948
  }
885
949
  const notifyError = ERRORS.TypedError(ERROR);
886
950
  this.runPromise.reject(notifyError);
887
- this.rejectAllProtocolV2Frames(notifyError);
888
951
  Log?.debug(': monitor notify error, and has unreleased Promise', Error);
889
952
  }
890
953
 
@@ -908,7 +971,7 @@ export default class ReactNativeBleTransport {
908
971
  return;
909
972
  }
910
973
  if (protocol === 'V2') {
911
- this.handleProtocolV2Notification(uuid, new Uint8Array(data));
974
+ this.handleProtocolV2Notification(uuid, monitorToken, new Uint8Array(data));
912
975
  return;
913
976
  }
914
977
  // console.log('[hd-transport-react-native] Received a packet, ', 'buffer: ', data);
@@ -929,13 +992,18 @@ export default class ReactNativeBleTransport {
929
992
  // );
930
993
  bufferLength = 0;
931
994
  buffer = [];
932
- this.runPromise?.resolve(value.toString('hex'));
995
+ if (this.runPromiseDeviceId === uuid) {
996
+ this.runPromise?.resolve(value.toString('hex'));
997
+ }
933
998
  }
934
999
  } catch (error) {
935
1000
  Log?.debug('monitor data error: ', error);
936
1001
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
937
- this.runPromise?.reject(notifyError);
938
- this.rejectAllProtocolV2Frames(notifyError);
1002
+ if (this.deviceProtocol.get(uuid) === 'V2') {
1003
+ this.rejectProtocolV2Frames(uuid, notifyError);
1004
+ } else if (this.runPromiseDeviceId === uuid) {
1005
+ this.runPromise?.reject(notifyError);
1006
+ }
939
1007
  }
940
1008
  }, notifyTransactionId);
941
1009
 
@@ -943,13 +1011,18 @@ export default class ReactNativeBleTransport {
943
1011
  }
944
1012
 
945
1013
  async release(uuid: string, onclose = false) {
1014
+ await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
1015
+ return this.releaseNative(uuid, onclose);
1016
+ }
1017
+
1018
+ private async releaseNative(uuid: string, onclose = false) {
946
1019
  const transport = transportCache[uuid];
947
- if (this.runPromise) {
1020
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
948
1021
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
949
1022
  this.runPromise.reject(error);
950
1023
  this.runPromise = null;
951
- this.rejectAllProtocolV2Frames(error);
952
- this.activeProtocolV2Call = null;
1024
+ this.runPromiseDeviceId = null;
1025
+ this.rejectProtocolV2Frames(uuid, error);
953
1026
  } else {
954
1027
  this.resetProtocolV2Frames(uuid);
955
1028
  }
@@ -957,9 +1030,6 @@ export default class ReactNativeBleTransport {
957
1030
  if (Platform.OS === 'android' && !onclose && transport) {
958
1031
  this.protocolV2Assemblers.get(uuid)?.reset();
959
1032
  this.resetProtocolV2Frames(uuid);
960
- if (this.activeProtocolV2Call?.uuid === uuid) {
961
- this.activeProtocolV2Call = null;
962
- }
963
1033
  return Promise.resolve(true);
964
1034
  }
965
1035
 
@@ -993,7 +1063,7 @@ export default class ReactNativeBleTransport {
993
1063
  }
994
1064
 
995
1065
  this.deviceProtocol.delete(uuid);
996
- this.deviceProtocolHints.delete(uuid);
1066
+ // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
997
1067
  this.protocolV2Assemblers.get(uuid)?.reset();
998
1068
  this.protocolV2Assemblers.delete(uuid);
999
1069
  this.resetProtocolV2Frames(uuid);
@@ -1025,13 +1095,6 @@ export default class ReactNativeBleTransport {
1025
1095
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1026
1096
  }
1027
1097
 
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
1098
  const protocol = this.getProtocolType(uuid);
1036
1099
  if (!protocol) {
1037
1100
  throw ERRORS.TypedError(
@@ -1039,31 +1102,17 @@ export default class ReactNativeBleTransport {
1039
1102
  `Device protocol has not been detected for ${uuid}`
1040
1103
  );
1041
1104
  }
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
- }
1105
+ Log?.debug('transport call', createTransportCallLog(name, protocol, data));
1062
1106
 
1063
1107
  if (protocol === 'V2') {
1064
1108
  return this.callProtocolV2(uuid, name, data, options);
1065
1109
  }
1066
1110
 
1111
+ const forceRun = name === 'Initialize' || name === 'Cancel';
1112
+ if (this.runPromise && !forceRun) {
1113
+ throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
1114
+ }
1115
+
1067
1116
  return this.callProtocolV1(uuid, name, data, options);
1068
1117
  }
1069
1118
 
@@ -1081,6 +1130,7 @@ export default class ReactNativeBleTransport {
1081
1130
  const runPromise = createDeferred<string>();
1082
1131
  runPromise.promise.catch(() => undefined);
1083
1132
  this.runPromise = runPromise;
1133
+ this.runPromiseDeviceId = uuid;
1084
1134
  const messages = this._messages;
1085
1135
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1086
1136
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1157,14 +1207,13 @@ export default class ReactNativeBleTransport {
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 => {
@@ -1174,38 +1223,21 @@ export default class ReactNativeBleTransport {
1174
1223
  // eslint-disable-next-line no-constant-condition
1175
1224
  while (true) {
1176
1225
  try {
1177
- await transport.writeCharacteristic.writeWithoutResponse(data);
1226
+ await transport.writeWithRetry(data);
1178
1227
  return;
1179
1228
  } catch (error) {
1180
1229
  const retryType = getFirmwareUploadWriteRetryType(error);
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
  },
@@ -1218,9 +1250,14 @@ 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
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1254
+ const shouldUseWriteWithResponse =
1255
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1256
+ if (shouldUseWriteWithResponse) {
1257
+ await transport.writeCharacteristic.writeWithResponse(outData);
1258
+ } else {
1259
+ await transport.writeCharacteristic.writeWithoutResponse(outData);
1260
+ }
1224
1261
  } catch (e) {
1225
1262
  Log?.debug('writeCharacteristic write error: ', e);
1226
1263
  this.runPromise = null;
@@ -1256,20 +1293,28 @@ export default class ReactNativeBleTransport {
1256
1293
  throw new Error('Returning data is not string.');
1257
1294
  }
1258
1295
 
1259
- Log?.debug('receive data: ', response);
1260
1296
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1261
1297
  return check.call(jsonData);
1262
1298
  } catch (e) {
1263
- if (name === 'Initialize' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1264
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe call failed:', e);
1299
+ if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1300
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1265
1301
  } else {
1266
1302
  Log?.error('call error: ', e);
1267
1303
  }
1304
+ const isProbeTimeout =
1305
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1306
+ if (
1307
+ !isProbeTimeout &&
1308
+ (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1309
+ ) {
1310
+ await this.disconnect(uuid);
1311
+ }
1268
1312
  throw e;
1269
1313
  } finally {
1270
1314
  if (timeout) clearTimeout(timeout);
1271
1315
  if (this.runPromise === runPromise) {
1272
1316
  this.runPromise = null;
1317
+ this.runPromiseDeviceId = null;
1273
1318
  }
1274
1319
  }
1275
1320
  }
@@ -1279,8 +1324,9 @@ export default class ReactNativeBleTransport {
1279
1324
  }
1280
1325
 
1281
1326
  async disconnect(session: string) {
1282
- Log?.debug('transport-react-native transport resetSession: ', session);
1327
+ await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1283
1328
  const transport = transportCache[session];
1329
+ const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1284
1330
 
1285
1331
  // Clean up disconnect subscription first to prevent onDisconnected callback
1286
1332
  // from being triggered when we cancel the connection below
@@ -1341,20 +1387,16 @@ export default class ReactNativeBleTransport {
1341
1387
  this.deviceProtocolHints.delete(session);
1342
1388
  this.protocolV2Assemblers.delete(session);
1343
1389
  this.resetProtocolV2Frames(session);
1344
- if (this.activeProtocolV2Call?.uuid === session) {
1345
- this.activeProtocolV2Call = null;
1346
- }
1347
1390
 
1348
1391
  // emit the disconnect event
1349
1392
  try {
1350
- this.emitter?.emit('device-disconnect', {
1351
- name: transport?.device?.name,
1352
- id: session,
1353
- connectId: session,
1354
- });
1393
+ this.emitDeviceDisconnect(session, transport?.device?.name, monitorToken);
1355
1394
  } catch (e) {
1356
1395
  Log?.error('resetSession: emit disconnect event error: ', e);
1357
1396
  }
1397
+ if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1398
+ this.monitorTokens.delete(session);
1399
+ }
1358
1400
  // eslint-disable-next-line no-promise-executor-return
1359
1401
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1360
1402
  }
@@ -1365,6 +1407,7 @@ export default class ReactNativeBleTransport {
1365
1407
  // this.runPromise.reject(new Error('Transport_CallCanceled'));
1366
1408
  }
1367
1409
  this.runPromise = null;
1410
+ this.runPromiseDeviceId = null;
1368
1411
  }
1369
1412
 
1370
1413
  private getCachedTransport(uuid: string) {
@@ -1385,7 +1428,7 @@ export default class ReactNativeBleTransport {
1385
1428
  private createProtocolDetectionError() {
1386
1429
  return ERRORS.TypedError(
1387
1430
  HardwareErrorCode.BleTimeoutError,
1388
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
1431
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
1389
1432
  );
1390
1433
  }
1391
1434
 
@@ -1398,42 +1441,71 @@ export default class ReactNativeBleTransport {
1398
1441
  private async detectProtocol(
1399
1442
  uuid: string,
1400
1443
  expectedProtocol?: ProtocolType,
1401
- protocolHint?: ProtocolType
1444
+ protocolHint?: ProtocolType,
1445
+ rebuildTransport?: () => Promise<void>
1402
1446
  ): Promise<ProtocolType> {
1447
+ if (Platform.OS === 'ios' && expectedProtocol) {
1448
+ this.deviceProtocol.set(uuid, expectedProtocol);
1449
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
1450
+ deviceId: uuid,
1451
+ protocol: expectedProtocol,
1452
+ source: 'expected',
1453
+ });
1454
+ return expectedProtocol;
1455
+ }
1456
+
1403
1457
  if (expectedProtocol === 'V1') {
1404
1458
  if (await this.probeProtocolV1(uuid)) {
1405
1459
  this.deviceProtocol.set(uuid, 'V1');
1406
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> V1 (expected)`);
1460
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1461
+ deviceId: uuid,
1462
+ protocol: 'V1',
1463
+ source: 'expected',
1464
+ });
1407
1465
  return 'V1';
1408
1466
  }
1409
1467
  throw this.createProtocolMismatchError(expectedProtocol);
1410
1468
  }
1411
1469
 
1412
1470
  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';
1471
+ if (await this.probeProtocolV2(uuid)) {
1472
+ this.deviceProtocol.set(uuid, 'V2');
1473
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1474
+ deviceId: uuid,
1475
+ protocol: 'V2',
1476
+ source: 'expected',
1477
+ });
1478
+ return 'V2';
1479
+ }
1480
+ throw this.createProtocolMismatchError(expectedProtocol);
1418
1481
  }
1419
1482
 
1420
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
1421
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1
1422
- // 不能作为最终结论。
1483
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
1484
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
1423
1485
  const probeOrder: ProtocolType[] =
1424
1486
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1425
1487
 
1426
1488
  for (let i = 0; i < probeOrder.length; i += 1) {
1427
1489
  const protocol = probeOrder[i];
1428
1490
  if (i > 0) {
1429
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
1491
+ // Reset subscriptions and buffers after a failed probe before trying another protocol.
1430
1492
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
1493
+ if (!transportCache[uuid]) {
1494
+ if (!rebuildTransport) {
1495
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1496
+ }
1497
+ await rebuildTransport();
1498
+ }
1431
1499
  }
1432
1500
  const detected =
1433
1501
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1434
1502
  if (detected) {
1435
1503
  this.deviceProtocol.set(uuid, protocol);
1436
- Log?.debug(`[ReactNativeBleTransport] detectProtocol: uuid=${uuid} -> ${protocol}`);
1504
+ Log?.debug('[ReactNativeBleTransport] protocol detected', {
1505
+ deviceId: uuid,
1506
+ protocol,
1507
+ source: 'probe',
1508
+ });
1437
1509
  return protocol;
1438
1510
  }
1439
1511
  }
@@ -1444,11 +1516,12 @@ export default class ReactNativeBleTransport {
1444
1516
 
1445
1517
  private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
1446
1518
  const transport = transportCache[uuid];
1519
+ await this.protocolV2Links.invalidateLink(
1520
+ uuid,
1521
+ `Reset notify state after Protocol ${protocol} probe`
1522
+ );
1447
1523
  this.protocolV2Assemblers.get(uuid)?.reset();
1448
1524
  this.resetProtocolV2Frames(uuid);
1449
- if (this.activeProtocolV2Call?.uuid === uuid) {
1450
- this.activeProtocolV2Call = null;
1451
- }
1452
1525
  if (this.runPromise) {
1453
1526
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
1454
1527
  this.runPromise.reject(error);
@@ -1500,11 +1573,13 @@ export default class ReactNativeBleTransport {
1500
1573
 
1501
1574
  try {
1502
1575
  this.deviceProtocol.set(uuid, 'V1');
1503
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1576
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
1577
+ // session before Core has a chance to restore a hidden wallet.
1578
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1504
1579
  return true;
1505
1580
  } catch (error) {
1506
1581
  this.clearProbeProtocol(uuid, 'V1');
1507
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize probe failed:', error);
1582
+ Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1508
1583
  return false;
1509
1584
  }
1510
1585
  }
@@ -1533,13 +1608,9 @@ export default class ReactNativeBleTransport {
1533
1608
  return detected;
1534
1609
  }
1535
1610
 
1536
- private handleProtocolV2Notification(uuid: string, data: Uint8Array) {
1611
+ private handleProtocolV2Notification(uuid: string, monitorToken: number, data: Uint8Array) {
1537
1612
  try {
1538
- if (!this.runPromise || this.activeProtocolV2Call?.uuid !== uuid) {
1539
- this.protocolV2Assemblers.get(uuid)?.reset();
1540
- this.resetProtocolV2Frames(uuid);
1541
- return;
1542
- }
1613
+ if (this.monitorTokens.get(uuid) !== monitorToken) return;
1543
1614
 
1544
1615
  if (data.length === 0) return;
1545
1616
 
@@ -1552,8 +1623,15 @@ export default class ReactNativeBleTransport {
1552
1623
  } catch (error) {
1553
1624
  Log?.debug('[ReactNativeBleTransport] Protocol V2 notification error:', error);
1554
1625
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1555
- this.runPromise?.reject(notifyError);
1556
- this.rejectAllProtocolV2Frames(notifyError);
1626
+ this.rejectProtocolV2Frames(uuid, notifyError);
1627
+ this.protocolV2Links
1628
+ .invalidateLink(uuid, `Protocol V2 notification error: ${error}`)
1629
+ .catch(invalidateError =>
1630
+ Log?.debug(
1631
+ '[ReactNativeBleTransport] Protocol V2 notify cleanup failed:',
1632
+ invalidateError
1633
+ )
1634
+ );
1557
1635
  }
1558
1636
  }
1559
1637
 
@@ -1576,21 +1654,17 @@ export default class ReactNativeBleTransport {
1576
1654
  this.getProtocolV2FrameQueue(uuid).push(frame);
1577
1655
  }
1578
1656
 
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
1657
  private resetProtocolV2Frames(uuid: string) {
1588
- this.protocolV2FrameQueues.delete(uuid);
1589
- this.protocolV2FramePromises.delete(uuid);
1658
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
1590
1659
  }
1591
1660
 
1592
- private isActiveProtocolV2Call(uuid: string, token: number) {
1593
- return this.activeProtocolV2Call?.uuid === uuid && this.activeProtocolV2Call.token === token;
1661
+ private rejectProtocolV2Frames(uuid: string, error: Error) {
1662
+ this.protocolV2FrameQueues.delete(uuid);
1663
+ const framePromise = this.protocolV2FramePromises.get(uuid);
1664
+ if (framePromise) {
1665
+ this.protocolV2FramePromises.delete(uuid);
1666
+ framePromise.reject(error);
1667
+ }
1594
1668
  }
1595
1669
 
1596
1670
  private async readProtocolV2Frame(uuid: string) {
@@ -1610,10 +1684,53 @@ export default class ReactNativeBleTransport {
1610
1684
  }
1611
1685
  }
1612
1686
 
1687
+ private async writeProtocolV2Packet(
1688
+ transport: BleTransport,
1689
+ base64: string,
1690
+ context: ProtocolV2CallContext,
1691
+ assertCurrentGeneration: () => void
1692
+ ) {
1693
+ const shouldUseWriteWithResponse =
1694
+ transport.writeCharacteristic.isWritableWithResponse &&
1695
+ (context.writeWithResponse === true || (Platform.OS === 'ios' && !context.highVolume));
1696
+ let attempt = 0;
1697
+ for (;;) {
1698
+ assertCurrentGeneration();
1699
+ if (context.signal.aborted) {
1700
+ throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1701
+ }
1702
+ try {
1703
+ if (shouldUseWriteWithResponse) {
1704
+ await transport.writeCharacteristic.writeWithResponse(base64);
1705
+ } else {
1706
+ await transport.writeCharacteristic.writeWithoutResponse(base64);
1707
+ }
1708
+ assertCurrentGeneration();
1709
+ return;
1710
+ } catch (error) {
1711
+ if (
1712
+ getFirmwareUploadWriteRetryType(error) !== 'congested' ||
1713
+ attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
1714
+ ) {
1715
+ throw error;
1716
+ }
1717
+ const delayMs = resolveFirmwareUploadRetryDelay(attempt);
1718
+ attempt += 1;
1719
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 congested write retry:', {
1720
+ name: context.messageName,
1721
+ attempt,
1722
+ delayMs,
1723
+ });
1724
+ await delay(delayMs);
1725
+ }
1726
+ }
1727
+ }
1728
+
1613
1729
  private async writeProtocolV2Frame(
1614
1730
  transport: BleTransport,
1615
1731
  frame: Uint8Array,
1616
- options?: { highVolume?: boolean; writeWithResponse?: boolean }
1732
+ context: ProtocolV2CallContext,
1733
+ assertCurrentGeneration: () => void
1617
1734
  ) {
1618
1735
  const tuning = getProtocolV2BleTuning();
1619
1736
  const packetCapacity = resolveProtocolV2PacketCapacity({
@@ -1622,37 +1739,31 @@ export default class ReactNativeBleTransport {
1622
1739
  androidPacketLength: tuning.androidPacketLength,
1623
1740
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1624
1741
  });
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
- }
1742
+ // Match Desktop BLE pacing so Pro2 firmware can finish the previous response
1743
+ // before the next single-packet control command is written.
1744
+ const initialDelayMs =
1745
+ Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
1746
+ ? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
1747
+ : 0;
1748
+ await writeProtocolV2BleFrame({
1749
+ frame,
1750
+ packetCapacity,
1751
+ assertActive: assertCurrentGeneration,
1752
+ signal: context.signal,
1753
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
1754
+ initialDelayMs,
1755
+ burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1756
+ burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1757
+ flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1758
+ wait: delay,
1759
+ writePacket: packet =>
1760
+ this.writeProtocolV2Packet(
1761
+ transport,
1762
+ Buffer.from(packet).toString('base64'),
1763
+ context,
1764
+ assertCurrentGeneration
1765
+ ),
1766
+ });
1656
1767
  }
1657
1768
 
1658
1769
  private async callProtocolV2(
@@ -1665,101 +1776,76 @@ export default class ReactNativeBleTransport {
1665
1776
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
1666
1777
  }
1667
1778
 
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
- };
1779
+ const callOptions = options;
1693
1780
  const highVolumeWrite = LogBlockCommand.has(name);
1694
1781
 
1695
1782
  if (highVolumeWrite) {
1696
1783
  const tuning = getProtocolV2BleTuning();
1697
- Log?.debug(
1698
- '[ReactNativeBleTransport] Protocol V2 high-volume write uses throttled writeWithoutResponse:',
1784
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1699
1785
  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
- );
1786
+ writeMode: options?.writeWithResponse ? 'withResponse' : 'withoutResponse',
1787
+ packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
1788
+ });
1709
1789
  }
1710
1790
 
1711
1791
  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;
1792
+ return await this.protocolV2Links.call(
1793
+ uuid,
1794
+ () => this.createProtocolV2Adapter(uuid),
1795
+ name,
1796
+ data,
1797
+ callOptions
1798
+ );
1742
1799
  } catch (e) {
1743
- if (this.isActiveProtocolV2Call(uuid, callToken)) {
1744
- this.protocolV2Assemblers.get(uuid)?.reset();
1745
- this.resetProtocolV2Frames(uuid);
1746
- }
1747
1800
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1748
1801
  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
1802
  }
1761
1803
  }
1762
1804
 
1805
+ private createProtocolV2Adapter(uuid: string) {
1806
+ const generation = this.monitorTokens.get(uuid) ?? 0;
1807
+ const assertCurrentGeneration = () => {
1808
+ if (this.monitorTokens.get(uuid) !== generation) {
1809
+ throw new Error(`Protocol V2 monitor generation changed for ${uuid}`);
1810
+ }
1811
+ };
1812
+
1813
+ return {
1814
+ router: PROTOCOL_V2_CHANNEL_BLE_UART,
1815
+ maxFrameBytes: PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
1816
+ generation,
1817
+ prepareCall: () => {
1818
+ assertCurrentGeneration();
1819
+ this.protocolV2Assemblers.get(uuid)?.reset();
1820
+ this.resetProtocolV2Frames(uuid);
1821
+ },
1822
+ writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1823
+ assertCurrentGeneration();
1824
+ const currentTransport = this.getCachedTransport(uuid);
1825
+ await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
1826
+ },
1827
+ readFrame: async () => {
1828
+ assertCurrentGeneration();
1829
+ const rxFrame = await this.readProtocolV2Frame(uuid);
1830
+ if (!(rxFrame instanceof Uint8Array)) {
1831
+ throw new Error('Protocol V2 response is not Uint8Array');
1832
+ }
1833
+ return rxFrame;
1834
+ },
1835
+ reset: (reason: string) => {
1836
+ this.protocolV2Assemblers.get(uuid)?.reset();
1837
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
1838
+ },
1839
+ logger: Log,
1840
+ logPrefix: 'ProtocolV2 RN-BLE',
1841
+ createTimeoutError: (messageName: string, timeout: number) =>
1842
+ ERRORS.TypedError(
1843
+ HardwareErrorCode.BleTimeoutError,
1844
+ `BLE response timeout after ${timeout}ms for ${messageName}`
1845
+ ),
1846
+ };
1847
+ }
1848
+
1763
1849
  getProtocolType(path: string): ProtocolType | undefined {
1764
1850
  return this.deviceProtocol.get(path);
1765
1851
  }