@onekeyfe/hd-transport-react-native 1.2.2-alpha.11 → 1.2.2-alpha.111

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
@@ -47,6 +47,7 @@ import {
47
47
  getInfosForServiceUuid,
48
48
  isSameBleUuid,
49
49
  } from './constants';
50
+ import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from './bleNativeDisconnect';
50
51
  import {
51
52
  isBleStaleBondHardwareError,
52
53
  isNativeBleStaleBondError,
@@ -76,6 +77,8 @@ const { check, ProtocolV1, parseConfigure } = transport;
76
77
  const Log = bleLogger;
77
78
 
78
79
  const transportCache: Record<string, BleTransport> = {};
80
+ // ble-plx shares one manager across transport instances in this JS runtime.
81
+ let bleManagerResetPromise: Promise<void> | undefined;
79
82
  const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
80
83
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
81
84
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
@@ -168,6 +171,23 @@ const isWedgedWriteError = (error: unknown): boolean =>
168
171
  (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
169
172
  typeof (error as { message?: unknown })?.message === 'string' &&
170
173
  (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
174
+ const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
175
+ const code = (error as { errorCode?: unknown })?.errorCode;
176
+ // Bonding and GATT failures are not evidence of a protocol mismatch. Preserve
177
+ // them instead of probing another protocol on an unusable connection.
178
+ // Native PLX disconnects (errorCode 201 / iOS 7) must match before they are
179
+ // mapped: Protocol V2 writes rethrow them unchanged unless normalized first.
180
+ return (
181
+ isBleStaleBondHardwareError(error) ||
182
+ isNativeBleDisconnectError(error) ||
183
+ code === HardwareErrorCode.BleDeviceNotBonded ||
184
+ code === HardwareErrorCode.BleDeviceBondedCanceled ||
185
+ code === HardwareErrorCode.BleDeviceDisconnected ||
186
+ code === HardwareErrorCode.BleCharacteristicNotifyError ||
187
+ code === HardwareErrorCode.BleCharacteristicNotifyChangeFailure ||
188
+ code === HardwareErrorCode.BleWriteCharacteristicError
189
+ );
190
+ };
171
191
  /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
172
192
  export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
173
193
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
@@ -320,9 +340,9 @@ const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'c
320
340
 
321
341
  type IOBleErrorRemap = Error | BleError | null | undefined;
322
342
 
323
- function remapError(error: IOBleErrorRemap, mapProtocolV2StaleBond: boolean) {
343
+ function remapError(error: IOBleErrorRemap) {
324
344
  if (error instanceof BleError) {
325
- if (mapProtocolV2StaleBond && isNativeBleStaleBondError(error)) {
345
+ if (isNativeBleStaleBondError(error)) {
326
346
  throw toBleStaleBondHardwareError(error);
327
347
  }
328
348
 
@@ -360,6 +380,8 @@ export default class ReactNativeBleTransport {
360
380
 
361
381
  stopped = false;
362
382
 
383
+ private readonly bondAbortController = new AbortController();
384
+
363
385
  scanTimeout = DEVICE_SCAN_TIMEOUT_MS;
364
386
 
365
387
  runPromise: Deferred<any> | null = null;
@@ -430,6 +452,14 @@ export default class ReactNativeBleTransport {
430
452
  this.rejectProtocolV2Frames(uuid, new Error(reason));
431
453
  Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
432
454
  if (reason.startsWith('Protocol V2 link-fatal error:')) {
455
+ if (this.probingProtocols.get(uuid) !== 'V2') {
456
+ const transport = transportCache[uuid];
457
+ try {
458
+ this.emitDeviceDisconnect(uuid, transport?.device?.name, transport?.monitorToken);
459
+ } catch {
460
+ Log?.error('[ReactNativeBleTransport] Protocol V2 disconnect listener failed');
461
+ }
462
+ }
433
463
  await this.releaseNative(uuid, true);
434
464
  }
435
465
  },
@@ -450,6 +480,10 @@ export default class ReactNativeBleTransport {
450
480
  /** Serializes transport lifecycle changes for the same physical device. */
451
481
  private lifecycleOperations: Map<string, Promise<void>> = new Map();
452
482
 
483
+ private stopPromise?: Promise<void>;
484
+
485
+ private scanCleanups = new Set<() => Promise<void>>();
486
+
453
487
  constructor(options: TransportOptions) {
454
488
  this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
455
489
  }
@@ -485,10 +519,34 @@ export default class ReactNativeBleTransport {
485
519
  // empty
486
520
  }
487
521
 
488
- getPlxManager(): Promise<BlePlxManager> {
489
- if (this.blePlxManager) return Promise.resolve(this.blePlxManager);
490
- this.blePlxManager = new BlePlxManager();
491
- return Promise.resolve(this.blePlxManager);
522
+ async getPlxManager(): Promise<BlePlxManager> {
523
+ while (bleManagerResetPromise) {
524
+ await this.waitForManagerReset();
525
+ }
526
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
527
+ if (!this.blePlxManager) this.blePlxManager = new BlePlxManager();
528
+ return this.blePlxManager;
529
+ }
530
+
531
+ private async waitForManagerReset(): Promise<void> {
532
+ if (!bleManagerResetPromise) return;
533
+ let timeout: ReturnType<typeof setTimeout> | undefined;
534
+ try {
535
+ await Promise.race([
536
+ bleManagerResetPromise,
537
+ new Promise<never>((_, reject) => {
538
+ timeout = setTimeout(
539
+ () => reject(this.createWedgedBleSetupError()),
540
+ BLE_CONNECT_TIMEOUT_MS
541
+ );
542
+ }),
543
+ ]);
544
+ } catch {
545
+ // A timeout or failed destroy is not permission to reuse the old singleton.
546
+ throw this.createWedgedBleSetupError();
547
+ } finally {
548
+ if (timeout) clearTimeout(timeout);
549
+ }
492
550
  }
493
551
 
494
552
  async resolveCharacteristics(device: Device): Promise<ResolvedBleCharacteristics> {
@@ -677,35 +735,58 @@ export default class ReactNativeBleTransport {
677
735
  * @returns
678
736
  */
679
737
  async enumerate() {
680
- // eslint-disable-next-line no-async-promise-executor
681
- return new Promise<IOneKeyDevice[]>(async (resolve, reject) => {
682
- const deviceList: IOneKeyDevice[] = [];
683
- const blePlxManager = await this.getPlxManager();
684
- try {
685
- await subscribeBleOn(blePlxManager);
686
- } catch (error) {
687
- Log?.debug('subscribeBleOn error: ', error);
688
- reject(error);
689
- return;
690
- }
691
-
692
- if (Platform.OS === 'android' && Platform.Version >= 31) {
693
- Log?.debug('requesting permissions, please wait...');
738
+ const scanStartedAt = Date.now();
739
+ let firstDeviceMs: number | undefined;
740
+ const blePlxManager = await this.getPlxManager();
741
+ await subscribeBleOn(blePlxManager);
742
+ if (Platform.OS === 'android' && Platform.Version >= 31) {
743
+ Log?.debug('requesting permissions, please wait...');
694
744
 
695
- const resultConnect = await PermissionsAndroid.requestMultiple([
696
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
697
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
698
- ]);
745
+ const resultConnect = await PermissionsAndroid.requestMultiple([
746
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
747
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
748
+ ]);
699
749
 
700
- Log?.debug('requesting permissions, result: ', resultConnect);
701
- if (
702
- resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
703
- resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
704
- ) {
705
- reject(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
706
- return;
707
- }
750
+ Log?.debug('requesting permissions, result: ', resultConnect);
751
+ if (
752
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
753
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
754
+ ) {
755
+ throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
708
756
  }
757
+ }
758
+
759
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
760
+ return new Promise<IOneKeyDevice[]>((resolve, reject) => {
761
+ const deviceList: IOneKeyDevice[] = [];
762
+ let finished = false;
763
+ let scanCleanup: Promise<void> | undefined;
764
+ const finishScan = (error?: unknown) => {
765
+ if (scanCleanup) return scanCleanup;
766
+ finished = true;
767
+ clearScanTimer();
768
+ scanCleanup = this.runNativeTeardown('scan', blePlxManager, async () => {
769
+ await blePlxManager.stopDeviceScan();
770
+ }).then(() => {
771
+ this.scanCleanups.delete(cancelScan);
772
+ if (error) reject(error);
773
+ else resolve(deviceList);
774
+ });
775
+ return scanCleanup;
776
+ };
777
+ const cancelScan = () =>
778
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
779
+ this.scanCleanups.add(cancelScan);
780
+
781
+ const clearScanTimer = timer.timeout(() => {
782
+ Log?.debug('[ReactNativeBleTransport] scan completed', {
783
+ elapsedMs: Date.now() - scanStartedAt,
784
+ firstDeviceMs,
785
+ deviceCount: deviceList.length,
786
+ scanWindowMs: this.scanTimeout,
787
+ });
788
+ finishScan();
789
+ }, this.scanTimeout);
709
790
 
710
791
  blePlxManager.startDeviceScan(
711
792
  getBluetoothServiceUuids(),
@@ -721,18 +802,17 @@ export default class ReactNativeBleTransport {
721
802
  error.errorCode
722
803
  )
723
804
  ) {
724
- reject(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
805
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
725
806
  } else if (error.errorCode === BleErrorCode.BluetoothUnauthorized) {
726
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
807
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
727
808
  } else if (error.errorCode === BleErrorCode.LocationServicesDisabled) {
728
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
809
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
729
810
  } else if (error.errorCode === BleErrorCode.ScanStartFailed) {
730
811
  // Android Bluetooth will report an error when the search frequency is too fast,
731
812
  // then nothing is processed and an empty array of devices is returned.
732
813
  // Then the next search will be back to normal
733
- timer.timeout(() => {}, this.scanTimeout);
734
814
  } else {
735
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
815
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
736
816
  }
737
817
  return;
738
818
  }
@@ -764,6 +844,7 @@ export default class ReactNativeBleTransport {
764
844
  }
765
845
  );
766
846
 
847
+ if (finished) return;
767
848
  getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
768
849
  devices => {
769
850
  for (const device of devices) {
@@ -783,11 +864,13 @@ export default class ReactNativeBleTransport {
783
864
  addDevice(device as unknown as Device);
784
865
  }
785
866
  }
786
- }
867
+ },
868
+ error => Log?.debug('search connected peripheral failed:', error)
787
869
  );
788
870
 
789
871
  const addDevice = (device: Device) => {
790
- if (deviceList.every(d => d.id !== device.id)) {
872
+ if (!finished && deviceList.every(d => d.id !== device.id)) {
873
+ firstDeviceMs ??= Date.now() - scanStartedAt;
791
874
  const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
792
875
 
793
876
  deviceList.push({
@@ -802,11 +885,6 @@ export default class ReactNativeBleTransport {
802
885
  });
803
886
  }
804
887
  };
805
-
806
- timer.timeout(() => {
807
- blePlxManager.stopDeviceScan();
808
- resolve(deviceList);
809
- }, this.scanTimeout);
810
888
  });
811
889
  }
812
890
 
@@ -817,6 +895,7 @@ export default class ReactNativeBleTransport {
817
895
  ) {
818
896
  const { writeCharacteristic, notifyCharacteristic } =
819
897
  characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
898
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
820
899
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
821
900
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
822
901
  const monitorToken = this.nextMonitorToken;
@@ -845,6 +924,7 @@ export default class ReactNativeBleTransport {
845
924
  } else if (Platform.OS === 'android') {
846
925
  await delay(ANDROID_NOTIFY_READY_DELAY_MS);
847
926
  }
927
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
848
928
 
849
929
  const initialMtu = transport.mtuSize;
850
930
  let refreshAttempts = 0;
@@ -858,12 +938,14 @@ export default class ReactNativeBleTransport {
858
938
  'servicesAndNotifyReady',
859
939
  1
860
940
  );
941
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
861
942
  transport.device = refreshedDevice;
862
943
  transport.mtuSize =
863
944
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
864
945
 
865
946
  if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
866
947
  await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
948
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
867
949
  refreshAttempts += 1;
868
950
  refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
869
951
  transport.device = refreshedDevice;
@@ -872,6 +954,7 @@ export default class ReactNativeBleTransport {
872
954
  }
873
955
  }
874
956
 
957
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
875
958
  Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
876
959
  platform: Platform.OS,
877
960
  requested: getRequestedBleMtu(),
@@ -894,6 +977,7 @@ export default class ReactNativeBleTransport {
894
977
  }
895
978
 
896
979
  private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
980
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
897
981
  const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
898
982
  const shouldMapProtocolV2StaleBond = expectedProtocol
899
983
  ? expectedProtocol === 'V2'
@@ -949,6 +1033,7 @@ export default class ReactNativeBleTransport {
949
1033
  cachedProtocol &&
950
1034
  (!expectedProtocol || cachedProtocol === expectedProtocol)
951
1035
  ) {
1036
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
952
1037
  Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
953
1038
  return { uuid, protocolType: cachedProtocol };
954
1039
  }
@@ -980,14 +1065,26 @@ export default class ReactNativeBleTransport {
980
1065
  throw error;
981
1066
  }
982
1067
 
1068
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
983
1069
  if (Platform.OS === 'android') {
984
- const bondState = await pairDevice(uuid);
985
- if (bondState.bonding) {
986
- await onDeviceBondState(uuid);
987
- } else if (!bondState.bonded) {
988
- throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1070
+ // Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
1071
+ try {
1072
+ const bondState = await pairDevice(uuid);
1073
+ if (bondState.bonding) {
1074
+ await onDeviceBondState(uuid, this.bondAbortController.signal);
1075
+ } else if (!bondState.bonded) {
1076
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1077
+ }
1078
+ } catch (error) {
1079
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1080
+ await this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
1081
+ blePlxManager.cancelDeviceConnection(uuid)
1082
+ );
1083
+ });
1084
+ throw error;
989
1085
  }
990
1086
  }
1087
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
991
1088
 
992
1089
  if (!device) {
993
1090
  const devices = await blePlxManager.devices([uuid]);
@@ -1024,7 +1121,7 @@ export default class ReactNativeBleTransport {
1024
1121
  Log?.debug('device already connected');
1025
1122
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
1026
1123
  } else {
1027
- remapError(e, shouldMapProtocolV2StaleBond);
1124
+ remapError(e);
1028
1125
  }
1029
1126
  }
1030
1127
  }
@@ -1055,28 +1152,48 @@ export default class ReactNativeBleTransport {
1055
1152
  device = await this.connectWithTimeout(uuid, () =>
1056
1153
  disconnectedDevice.connect(fallbackConnectOptions)
1057
1154
  );
1058
- } catch (e) {
1059
- Log?.debug('last try to reconnect error: ', e);
1155
+ } catch (fallbackError) {
1156
+ Log?.debug('last try to reconnect error: ', fallbackError);
1060
1157
  // last try to reconnect device if this issue exists
1061
1158
  // https://github.com/dotintent/react-native-ble-plx/issues/426
1062
- if (e.errorCode === BleErrorCode.OperationCancelled) {
1159
+ if (fallbackError.errorCode === BleErrorCode.OperationCancelled) {
1063
1160
  Log?.debug('last try to reconnect');
1064
1161
  await disconnectedDevice.cancelConnection();
1065
1162
  device = await this.connectWithTimeout(uuid, () =>
1066
1163
  disconnectedDevice.connect(fallbackConnectOptions)
1067
1164
  );
1165
+ } else {
1166
+ remapError(fallbackError);
1068
1167
  }
1069
1168
  }
1070
1169
  } else {
1071
- remapError(e, shouldMapProtocolV2StaleBond);
1170
+ remapError(e);
1072
1171
  }
1073
1172
  }
1074
1173
  }
1075
1174
 
1175
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1176
+ if (Platform.OS === 'android' && !(await device.isConnected().catch(() => false))) {
1177
+ const disconnectedDevice = device;
1178
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1179
+ await Promise.all([
1180
+ this.runBestEffortNativeOperation('connect failure: cancel manager connection', () =>
1181
+ blePlxManager.cancelDeviceConnection(uuid)
1182
+ ),
1183
+ this.runBestEffortNativeOperation('connect failure: cancel device connection', () =>
1184
+ disconnectedDevice.cancelConnection()
1185
+ ),
1186
+ ]);
1187
+ });
1188
+ throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1189
+ }
1190
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1076
1191
  device = await resolveNegotiatedMtu(device);
1192
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1077
1193
  const acquiredDevice = device;
1078
1194
  const { writeCharacteristic, notifyCharacteristic } =
1079
1195
  await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1196
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1080
1197
 
1081
1198
  const protocolHint = expectedProtocol
1082
1199
  ? undefined
@@ -1138,6 +1255,7 @@ export default class ReactNativeBleTransport {
1138
1255
  await this.installTransportForAcquire(uuid, acquiredDevice);
1139
1256
  }
1140
1257
  );
1258
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1141
1259
  const currentTransport = transportCache[uuid];
1142
1260
  if (!currentTransport) {
1143
1261
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
@@ -1145,11 +1263,9 @@ export default class ReactNativeBleTransport {
1145
1263
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1146
1264
  return { uuid, protocolType };
1147
1265
  } catch (error) {
1148
- if (isBleStaleBondHardwareError(error)) {
1149
- await this.disconnectUnlocked(uuid);
1150
- } else {
1151
- await this.releaseUnlocked(uuid, true);
1152
- }
1266
+ // A failed acquire must retire the physical link before Core retries. Logical
1267
+ // release leaves GATT connected even when neither protocol receives a response.
1268
+ await this.disconnectUnlocked(uuid);
1153
1269
  throw error;
1154
1270
  } finally {
1155
1271
  this.acquiringProtocolV2.delete(uuid);
@@ -1678,7 +1794,49 @@ export default class ReactNativeBleTransport {
1678
1794
  }
1679
1795
 
1680
1796
  stop() {
1797
+ if (this.stopPromise) return this.stopPromise;
1681
1798
  this.stopped = true;
1799
+ // Bonding precedes GATT, so cancelDeviceConnection cannot end this wait.
1800
+ this.bondAbortController.abort();
1801
+ const deviceIds = new Set([
1802
+ ...this.monitorTokens.keys(),
1803
+ ...this.sessionProtocols.keys(),
1804
+ ...this.lifecycleOperations.keys(),
1805
+ ...(this.runPromiseDeviceId ? [this.runPromiseDeviceId] : []),
1806
+ ]);
1807
+ const scans = Array.from(this.scanCleanups, cleanup => cleanup());
1808
+ this.androidPriorityResetTimers.forEach(timeout => clearTimeout(timeout));
1809
+ this.androidPriorityResetTimers.clear();
1810
+ this.androidHighPriorityDevices.clear();
1811
+ const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1812
+ this.runPromise?.reject(error);
1813
+ this.runPromise = null;
1814
+ this.runPromiseDeviceId = null;
1815
+ deviceIds.forEach(uuid => this.rejectProtocolV2Frames(uuid, error));
1816
+ const manager = this.blePlxManager;
1817
+ // Cancel native setup before waiting for its lifecycle lock. Otherwise stop
1818
+ // waits for the very connect/MTU/GATT operation it needs to interrupt.
1819
+ const pendingConnections = manager
1820
+ ? Array.from(this.lifecycleOperations.keys(), uuid =>
1821
+ this.runNativeTeardown(uuid, manager, async () => {
1822
+ await this.runBestEffortNativeOperation('stop: cancel pending device connection', () =>
1823
+ manager.cancelDeviceConnection(uuid)
1824
+ );
1825
+ })
1826
+ )
1827
+ : [];
1828
+ // Release only this transport's endpoints; other connectors may share ble-plx.
1829
+ this.stopPromise = Promise.all([
1830
+ ...scans,
1831
+ ...pendingConnections,
1832
+ ...Array.from(deviceIds, uuid => this.disconnect(uuid)),
1833
+ ]).then(async () => {
1834
+ await this.protocolV2Links.invalidateAllLinks('React Native BLE transport stopped');
1835
+ await this.waitForManagerReset();
1836
+ this.blePlxManager = undefined;
1837
+ this.emitter = undefined;
1838
+ });
1839
+ return this.stopPromise;
1682
1840
  }
1683
1841
 
1684
1842
  async disconnect(session: string) {
@@ -1830,17 +1988,27 @@ export default class ReactNativeBleTransport {
1830
1988
  }
1831
1989
  }
1832
1990
 
1833
- cancel() {
1991
+ async cancel() {
1834
1992
  Log?.debug('transport-react-native transport cancel');
1835
- if (this.runPromise) {
1836
- // this.runPromise.reject(new Error('Transport_CallCanceled'));
1993
+ const pending = this.runPromise;
1994
+ const deviceId = this.runPromiseDeviceId;
1995
+ if (pending) {
1996
+ pending.reject(ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled));
1997
+ if (this.runPromise === pending) {
1998
+ this.runPromise = null;
1999
+ this.runPromiseDeviceId = null;
2000
+ }
2001
+ // A V1 read cannot be safely reused after abandoning its response.
2002
+ // Drain native teardown before DeviceCommands releases the operation.
2003
+ if (deviceId) await this.disconnect(deviceId);
1837
2004
  }
1838
- this.runPromise = null;
1839
- this.runPromiseDeviceId = null;
1840
2005
  }
1841
2006
 
1842
2007
  /** Run a native connect under the JS backstop budget. */
1843
2008
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2009
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2010
+ const startedAt = Date.now();
2011
+ let succeeded = false;
1844
2012
  let timer: ReturnType<typeof setTimeout> | undefined;
1845
2013
  let timedOut = false;
1846
2014
  const pending = connect();
@@ -1862,6 +2030,7 @@ export default class ReactNativeBleTransport {
1862
2030
  }, BLE_CONNECT_TIMEOUT_MS);
1863
2031
  }),
1864
2032
  ]);
2033
+ succeeded = true;
1865
2034
  return result;
1866
2035
  } catch (error) {
1867
2036
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1876,6 +2045,12 @@ export default class ReactNativeBleTransport {
1876
2045
  throw error;
1877
2046
  } finally {
1878
2047
  if (timer) clearTimeout(timer);
2048
+ Log?.debug('[ReactNativeBleTransport] connect completed', {
2049
+ connectIdSuffix: uuid.slice(-8),
2050
+ elapsedMs: Date.now() - startedAt,
2051
+ succeeded,
2052
+ backstopExpired: timedOut,
2053
+ });
1879
2054
  }
1880
2055
  }
1881
2056
 
@@ -1884,6 +2059,8 @@ export default class ReactNativeBleTransport {
1884
2059
  uuid: string,
1885
2060
  device: Device
1886
2061
  ): Promise<ResolvedBleCharacteristics> {
2062
+ const startedAt = Date.now();
2063
+ let succeeded = false;
1887
2064
  let timer: ReturnType<typeof setTimeout> | undefined;
1888
2065
  let timedOut = false;
1889
2066
  const pending = this.resolveCharacteristics(device);
@@ -1904,6 +2081,7 @@ export default class ReactNativeBleTransport {
1904
2081
  }),
1905
2082
  ]);
1906
2083
  this.connectionSetupTimeoutCounts.delete(uuid);
2084
+ succeeded = true;
1907
2085
  return result;
1908
2086
  } catch (error) {
1909
2087
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1918,6 +2096,12 @@ export default class ReactNativeBleTransport {
1918
2096
  throw error;
1919
2097
  } finally {
1920
2098
  if (timer) clearTimeout(timer);
2099
+ Log?.debug('[ReactNativeBleTransport] GATT setup completed', {
2100
+ connectIdSuffix: uuid.slice(-8),
2101
+ elapsedMs: Date.now() - startedAt,
2102
+ succeeded,
2103
+ backstopExpired: timedOut,
2104
+ });
1921
2105
  }
1922
2106
  }
1923
2107
 
@@ -2061,6 +2245,7 @@ export default class ReactNativeBleTransport {
2061
2245
  }
2062
2246
 
2063
2247
  private resetPlxManager() {
2248
+ if (bleManagerResetPromise) return;
2064
2249
  const manager = this.blePlxManager;
2065
2250
  this.blePlxManager = undefined;
2066
2251
  const reason = 'React Native BLE manager reset';
@@ -2107,11 +2292,21 @@ export default class ReactNativeBleTransport {
2107
2292
  this.connectionSetupTimeoutCounts.clear();
2108
2293
  this.monitorTokens.clear();
2109
2294
  this.protocolV2Assemblers.clear();
2295
+ let reset: Promise<void>;
2110
2296
  try {
2111
- manager?.destroy();
2297
+ reset = Promise.resolve(manager?.destroy());
2112
2298
  } catch (error) {
2113
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
2299
+ reset = Promise.reject(error);
2114
2300
  }
2301
+ bleManagerResetPromise = reset;
2302
+ reset.then(
2303
+ () => {
2304
+ if (bleManagerResetPromise === reset) bleManagerResetPromise = undefined;
2305
+ },
2306
+ error => {
2307
+ Log?.error('[ReactNativeBleTransport] BLE manager destroy failed:', error);
2308
+ }
2309
+ );
2115
2310
  }
2116
2311
 
2117
2312
  private createProtocolMismatchError(expected: ProtocolType) {
@@ -2314,9 +2509,7 @@ export default class ReactNativeBleTransport {
2314
2509
  } catch (error) {
2315
2510
  this.clearProbeProtocol(uuid, 'V1');
2316
2511
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2317
- // A wedged write already dropped the link, so probing another protocol on it
2318
- // would only fail against a torn-down transport: surface the real cause.
2319
- if (isWedgedWriteError(error)) {
2512
+ if (shouldRethrowProtocolProbeError(error)) {
2320
2513
  throw error;
2321
2514
  }
2322
2515
  return false;
@@ -2341,7 +2534,7 @@ export default class ReactNativeBleTransport {
2341
2534
  this.protocolV2Assemblers.get(uuid)?.reset();
2342
2535
  this.resetProtocolV2Frames(uuid);
2343
2536
  },
2344
- shouldRethrow: isBleStaleBondHardwareError,
2537
+ shouldRethrow: shouldRethrowProtocolProbeError,
2345
2538
  });
2346
2539
  if (!detected) {
2347
2540
  this.clearProbeProtocol(uuid, 'V2');
@@ -2488,6 +2681,9 @@ export default class ReactNativeBleTransport {
2488
2681
  this.rememberStaleBondError(uuid, bondError);
2489
2682
  throw bondError;
2490
2683
  }
2684
+ if (isNativeBleDisconnectError(error)) {
2685
+ throw toBleDisconnectHardwareError(error);
2686
+ }
2491
2687
  if (
2492
2688
  getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2493
2689
  attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
@@ -2548,6 +2744,7 @@ export default class ReactNativeBleTransport {
2548
2744
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
2549
2745
  }
2550
2746
 
2747
+ const isProtocolProbe = this.probingProtocols.get(uuid) === 'V2';
2551
2748
  const callOptions = options;
2552
2749
  const highThroughputWrite = isProtocolV2HighThroughputCall(name);
2553
2750
 
@@ -2599,6 +2796,19 @@ export default class ReactNativeBleTransport {
2599
2796
  );
2600
2797
  } catch (e) {
2601
2798
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
2799
+ if (
2800
+ !isProtocolProbe &&
2801
+ e?.errorCode === HardwareErrorCode.BleTimeoutError &&
2802
+ !this.monitorTokens.has(uuid)
2803
+ ) {
2804
+ // The failed link has finished invalidating. Disconnect outside that
2805
+ // callback to avoid waiting on its own invalidation or acquire lock.
2806
+ await this.runLifecycleOperation(uuid, async () => {
2807
+ // A queued timeout leaves its active monitor intact; a newer acquire
2808
+ // may also have installed one while cleanup waited for the lifecycle lock.
2809
+ if (!this.monitorTokens.has(uuid)) await this.disconnectUnlocked(uuid);
2810
+ });
2811
+ }
2602
2812
  throw e;
2603
2813
  } finally {
2604
2814
  if (highThroughputWrite) {