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

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;
@@ -450,6 +472,10 @@ export default class ReactNativeBleTransport {
450
472
  /** Serializes transport lifecycle changes for the same physical device. */
451
473
  private lifecycleOperations: Map<string, Promise<void>> = new Map();
452
474
 
475
+ private stopPromise?: Promise<void>;
476
+
477
+ private scanCleanups = new Set<() => Promise<void>>();
478
+
453
479
  constructor(options: TransportOptions) {
454
480
  this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
455
481
  }
@@ -485,10 +511,34 @@ export default class ReactNativeBleTransport {
485
511
  // empty
486
512
  }
487
513
 
488
- getPlxManager(): Promise<BlePlxManager> {
489
- if (this.blePlxManager) return Promise.resolve(this.blePlxManager);
490
- this.blePlxManager = new BlePlxManager();
491
- return Promise.resolve(this.blePlxManager);
514
+ async getPlxManager(): Promise<BlePlxManager> {
515
+ while (bleManagerResetPromise) {
516
+ await this.waitForManagerReset();
517
+ }
518
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
519
+ if (!this.blePlxManager) this.blePlxManager = new BlePlxManager();
520
+ return this.blePlxManager;
521
+ }
522
+
523
+ private async waitForManagerReset(): Promise<void> {
524
+ if (!bleManagerResetPromise) return;
525
+ let timeout: ReturnType<typeof setTimeout> | undefined;
526
+ try {
527
+ await Promise.race([
528
+ bleManagerResetPromise,
529
+ new Promise<never>((_, reject) => {
530
+ timeout = setTimeout(
531
+ () => reject(this.createWedgedBleSetupError()),
532
+ BLE_CONNECT_TIMEOUT_MS
533
+ );
534
+ }),
535
+ ]);
536
+ } catch {
537
+ // A timeout or failed destroy is not permission to reuse the old singleton.
538
+ throw this.createWedgedBleSetupError();
539
+ } finally {
540
+ if (timeout) clearTimeout(timeout);
541
+ }
492
542
  }
493
543
 
494
544
  async resolveCharacteristics(device: Device): Promise<ResolvedBleCharacteristics> {
@@ -677,35 +727,58 @@ export default class ReactNativeBleTransport {
677
727
  * @returns
678
728
  */
679
729
  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...');
730
+ const scanStartedAt = Date.now();
731
+ let firstDeviceMs: number | undefined;
732
+ const blePlxManager = await this.getPlxManager();
733
+ await subscribeBleOn(blePlxManager);
734
+ if (Platform.OS === 'android' && Platform.Version >= 31) {
735
+ Log?.debug('requesting permissions, please wait...');
694
736
 
695
- const resultConnect = await PermissionsAndroid.requestMultiple([
696
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
697
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
698
- ]);
737
+ const resultConnect = await PermissionsAndroid.requestMultiple([
738
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
739
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
740
+ ]);
699
741
 
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
- }
742
+ Log?.debug('requesting permissions, result: ', resultConnect);
743
+ if (
744
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
745
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
746
+ ) {
747
+ throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
708
748
  }
749
+ }
750
+
751
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
752
+ return new Promise<IOneKeyDevice[]>((resolve, reject) => {
753
+ const deviceList: IOneKeyDevice[] = [];
754
+ let finished = false;
755
+ let scanCleanup: Promise<void> | undefined;
756
+ const finishScan = (error?: unknown) => {
757
+ if (scanCleanup) return scanCleanup;
758
+ finished = true;
759
+ clearScanTimer();
760
+ scanCleanup = this.runNativeTeardown('scan', blePlxManager, async () => {
761
+ await blePlxManager.stopDeviceScan();
762
+ }).then(() => {
763
+ this.scanCleanups.delete(cancelScan);
764
+ if (error) reject(error);
765
+ else resolve(deviceList);
766
+ });
767
+ return scanCleanup;
768
+ };
769
+ const cancelScan = () =>
770
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
771
+ this.scanCleanups.add(cancelScan);
772
+
773
+ const clearScanTimer = timer.timeout(() => {
774
+ Log?.debug('[ReactNativeBleTransport] scan completed', {
775
+ elapsedMs: Date.now() - scanStartedAt,
776
+ firstDeviceMs,
777
+ deviceCount: deviceList.length,
778
+ scanWindowMs: this.scanTimeout,
779
+ });
780
+ finishScan();
781
+ }, this.scanTimeout);
709
782
 
710
783
  blePlxManager.startDeviceScan(
711
784
  getBluetoothServiceUuids(),
@@ -721,18 +794,17 @@ export default class ReactNativeBleTransport {
721
794
  error.errorCode
722
795
  )
723
796
  ) {
724
- reject(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
797
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
725
798
  } else if (error.errorCode === BleErrorCode.BluetoothUnauthorized) {
726
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
799
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
727
800
  } else if (error.errorCode === BleErrorCode.LocationServicesDisabled) {
728
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
801
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
729
802
  } else if (error.errorCode === BleErrorCode.ScanStartFailed) {
730
803
  // Android Bluetooth will report an error when the search frequency is too fast,
731
804
  // then nothing is processed and an empty array of devices is returned.
732
805
  // Then the next search will be back to normal
733
- timer.timeout(() => {}, this.scanTimeout);
734
806
  } else {
735
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
807
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
736
808
  }
737
809
  return;
738
810
  }
@@ -764,6 +836,7 @@ export default class ReactNativeBleTransport {
764
836
  }
765
837
  );
766
838
 
839
+ if (finished) return;
767
840
  getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
768
841
  devices => {
769
842
  for (const device of devices) {
@@ -783,11 +856,13 @@ export default class ReactNativeBleTransport {
783
856
  addDevice(device as unknown as Device);
784
857
  }
785
858
  }
786
- }
859
+ },
860
+ error => Log?.debug('search connected peripheral failed:', error)
787
861
  );
788
862
 
789
863
  const addDevice = (device: Device) => {
790
- if (deviceList.every(d => d.id !== device.id)) {
864
+ if (!finished && deviceList.every(d => d.id !== device.id)) {
865
+ firstDeviceMs ??= Date.now() - scanStartedAt;
791
866
  const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
792
867
 
793
868
  deviceList.push({
@@ -802,11 +877,6 @@ export default class ReactNativeBleTransport {
802
877
  });
803
878
  }
804
879
  };
805
-
806
- timer.timeout(() => {
807
- blePlxManager.stopDeviceScan();
808
- resolve(deviceList);
809
- }, this.scanTimeout);
810
880
  });
811
881
  }
812
882
 
@@ -817,6 +887,7 @@ export default class ReactNativeBleTransport {
817
887
  ) {
818
888
  const { writeCharacteristic, notifyCharacteristic } =
819
889
  characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
890
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
820
891
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
821
892
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
822
893
  const monitorToken = this.nextMonitorToken;
@@ -845,6 +916,7 @@ export default class ReactNativeBleTransport {
845
916
  } else if (Platform.OS === 'android') {
846
917
  await delay(ANDROID_NOTIFY_READY_DELAY_MS);
847
918
  }
919
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
848
920
 
849
921
  const initialMtu = transport.mtuSize;
850
922
  let refreshAttempts = 0;
@@ -858,12 +930,14 @@ export default class ReactNativeBleTransport {
858
930
  'servicesAndNotifyReady',
859
931
  1
860
932
  );
933
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
861
934
  transport.device = refreshedDevice;
862
935
  transport.mtuSize =
863
936
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
864
937
 
865
938
  if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
866
939
  await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
940
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
867
941
  refreshAttempts += 1;
868
942
  refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
869
943
  transport.device = refreshedDevice;
@@ -872,6 +946,7 @@ export default class ReactNativeBleTransport {
872
946
  }
873
947
  }
874
948
 
949
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
875
950
  Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
876
951
  platform: Platform.OS,
877
952
  requested: getRequestedBleMtu(),
@@ -894,6 +969,7 @@ export default class ReactNativeBleTransport {
894
969
  }
895
970
 
896
971
  private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
972
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
897
973
  const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
898
974
  const shouldMapProtocolV2StaleBond = expectedProtocol
899
975
  ? expectedProtocol === 'V2'
@@ -949,6 +1025,7 @@ export default class ReactNativeBleTransport {
949
1025
  cachedProtocol &&
950
1026
  (!expectedProtocol || cachedProtocol === expectedProtocol)
951
1027
  ) {
1028
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
952
1029
  Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
953
1030
  return { uuid, protocolType: cachedProtocol };
954
1031
  }
@@ -980,14 +1057,26 @@ export default class ReactNativeBleTransport {
980
1057
  throw error;
981
1058
  }
982
1059
 
1060
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
983
1061
  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');
1062
+ // Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
1063
+ try {
1064
+ const bondState = await pairDevice(uuid);
1065
+ if (bondState.bonding) {
1066
+ await onDeviceBondState(uuid, this.bondAbortController.signal);
1067
+ } else if (!bondState.bonded) {
1068
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1069
+ }
1070
+ } catch (error) {
1071
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1072
+ await this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
1073
+ blePlxManager.cancelDeviceConnection(uuid)
1074
+ );
1075
+ });
1076
+ throw error;
989
1077
  }
990
1078
  }
1079
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
991
1080
 
992
1081
  if (!device) {
993
1082
  const devices = await blePlxManager.devices([uuid]);
@@ -1024,7 +1113,7 @@ export default class ReactNativeBleTransport {
1024
1113
  Log?.debug('device already connected');
1025
1114
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
1026
1115
  } else {
1027
- remapError(e, shouldMapProtocolV2StaleBond);
1116
+ remapError(e);
1028
1117
  }
1029
1118
  }
1030
1119
  }
@@ -1055,28 +1144,48 @@ export default class ReactNativeBleTransport {
1055
1144
  device = await this.connectWithTimeout(uuid, () =>
1056
1145
  disconnectedDevice.connect(fallbackConnectOptions)
1057
1146
  );
1058
- } catch (e) {
1059
- Log?.debug('last try to reconnect error: ', e);
1147
+ } catch (fallbackError) {
1148
+ Log?.debug('last try to reconnect error: ', fallbackError);
1060
1149
  // last try to reconnect device if this issue exists
1061
1150
  // https://github.com/dotintent/react-native-ble-plx/issues/426
1062
- if (e.errorCode === BleErrorCode.OperationCancelled) {
1151
+ if (fallbackError.errorCode === BleErrorCode.OperationCancelled) {
1063
1152
  Log?.debug('last try to reconnect');
1064
1153
  await disconnectedDevice.cancelConnection();
1065
1154
  device = await this.connectWithTimeout(uuid, () =>
1066
1155
  disconnectedDevice.connect(fallbackConnectOptions)
1067
1156
  );
1157
+ } else {
1158
+ remapError(fallbackError);
1068
1159
  }
1069
1160
  }
1070
1161
  } else {
1071
- remapError(e, shouldMapProtocolV2StaleBond);
1162
+ remapError(e);
1072
1163
  }
1073
1164
  }
1074
1165
  }
1075
1166
 
1167
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1168
+ if (Platform.OS === 'android' && !(await device.isConnected().catch(() => false))) {
1169
+ const disconnectedDevice = device;
1170
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1171
+ await Promise.all([
1172
+ this.runBestEffortNativeOperation('connect failure: cancel manager connection', () =>
1173
+ blePlxManager.cancelDeviceConnection(uuid)
1174
+ ),
1175
+ this.runBestEffortNativeOperation('connect failure: cancel device connection', () =>
1176
+ disconnectedDevice.cancelConnection()
1177
+ ),
1178
+ ]);
1179
+ });
1180
+ throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1181
+ }
1182
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1076
1183
  device = await resolveNegotiatedMtu(device);
1184
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1077
1185
  const acquiredDevice = device;
1078
1186
  const { writeCharacteristic, notifyCharacteristic } =
1079
1187
  await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1188
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1080
1189
 
1081
1190
  const protocolHint = expectedProtocol
1082
1191
  ? undefined
@@ -1138,6 +1247,7 @@ export default class ReactNativeBleTransport {
1138
1247
  await this.installTransportForAcquire(uuid, acquiredDevice);
1139
1248
  }
1140
1249
  );
1250
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1141
1251
  const currentTransport = transportCache[uuid];
1142
1252
  if (!currentTransport) {
1143
1253
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
@@ -1145,7 +1255,7 @@ export default class ReactNativeBleTransport {
1145
1255
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1146
1256
  return { uuid, protocolType };
1147
1257
  } catch (error) {
1148
- if (isBleStaleBondHardwareError(error)) {
1258
+ if (isBleStaleBondHardwareError(error) || shouldRethrowProtocolProbeError(error)) {
1149
1259
  await this.disconnectUnlocked(uuid);
1150
1260
  } else {
1151
1261
  await this.releaseUnlocked(uuid, true);
@@ -1678,7 +1788,49 @@ export default class ReactNativeBleTransport {
1678
1788
  }
1679
1789
 
1680
1790
  stop() {
1791
+ if (this.stopPromise) return this.stopPromise;
1681
1792
  this.stopped = true;
1793
+ // Bonding precedes GATT, so cancelDeviceConnection cannot end this wait.
1794
+ this.bondAbortController.abort();
1795
+ const deviceIds = new Set([
1796
+ ...this.monitorTokens.keys(),
1797
+ ...this.sessionProtocols.keys(),
1798
+ ...this.lifecycleOperations.keys(),
1799
+ ...(this.runPromiseDeviceId ? [this.runPromiseDeviceId] : []),
1800
+ ]);
1801
+ const scans = Array.from(this.scanCleanups, cleanup => cleanup());
1802
+ this.androidPriorityResetTimers.forEach(timeout => clearTimeout(timeout));
1803
+ this.androidPriorityResetTimers.clear();
1804
+ this.androidHighPriorityDevices.clear();
1805
+ const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1806
+ this.runPromise?.reject(error);
1807
+ this.runPromise = null;
1808
+ this.runPromiseDeviceId = null;
1809
+ deviceIds.forEach(uuid => this.rejectProtocolV2Frames(uuid, error));
1810
+ const manager = this.blePlxManager;
1811
+ // Cancel native setup before waiting for its lifecycle lock. Otherwise stop
1812
+ // waits for the very connect/MTU/GATT operation it needs to interrupt.
1813
+ const pendingConnections = manager
1814
+ ? Array.from(this.lifecycleOperations.keys(), uuid =>
1815
+ this.runNativeTeardown(uuid, manager, async () => {
1816
+ await this.runBestEffortNativeOperation('stop: cancel pending device connection', () =>
1817
+ manager.cancelDeviceConnection(uuid)
1818
+ );
1819
+ })
1820
+ )
1821
+ : [];
1822
+ // Release only this transport's endpoints; other connectors may share ble-plx.
1823
+ this.stopPromise = Promise.all([
1824
+ ...scans,
1825
+ ...pendingConnections,
1826
+ ...Array.from(deviceIds, uuid => this.disconnect(uuid)),
1827
+ ]).then(async () => {
1828
+ await this.protocolV2Links.invalidateAllLinks('React Native BLE transport stopped');
1829
+ await this.waitForManagerReset();
1830
+ this.blePlxManager = undefined;
1831
+ this.emitter = undefined;
1832
+ });
1833
+ return this.stopPromise;
1682
1834
  }
1683
1835
 
1684
1836
  async disconnect(session: string) {
@@ -1830,17 +1982,27 @@ export default class ReactNativeBleTransport {
1830
1982
  }
1831
1983
  }
1832
1984
 
1833
- cancel() {
1985
+ async cancel() {
1834
1986
  Log?.debug('transport-react-native transport cancel');
1835
- if (this.runPromise) {
1836
- // this.runPromise.reject(new Error('Transport_CallCanceled'));
1987
+ const pending = this.runPromise;
1988
+ const deviceId = this.runPromiseDeviceId;
1989
+ if (pending) {
1990
+ pending.reject(ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled));
1991
+ if (this.runPromise === pending) {
1992
+ this.runPromise = null;
1993
+ this.runPromiseDeviceId = null;
1994
+ }
1995
+ // A V1 read cannot be safely reused after abandoning its response.
1996
+ // Drain native teardown before DeviceCommands releases the operation.
1997
+ if (deviceId) await this.disconnect(deviceId);
1837
1998
  }
1838
- this.runPromise = null;
1839
- this.runPromiseDeviceId = null;
1840
1999
  }
1841
2000
 
1842
2001
  /** Run a native connect under the JS backstop budget. */
1843
2002
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2003
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2004
+ const startedAt = Date.now();
2005
+ let succeeded = false;
1844
2006
  let timer: ReturnType<typeof setTimeout> | undefined;
1845
2007
  let timedOut = false;
1846
2008
  const pending = connect();
@@ -1862,6 +2024,7 @@ export default class ReactNativeBleTransport {
1862
2024
  }, BLE_CONNECT_TIMEOUT_MS);
1863
2025
  }),
1864
2026
  ]);
2027
+ succeeded = true;
1865
2028
  return result;
1866
2029
  } catch (error) {
1867
2030
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1876,6 +2039,12 @@ export default class ReactNativeBleTransport {
1876
2039
  throw error;
1877
2040
  } finally {
1878
2041
  if (timer) clearTimeout(timer);
2042
+ Log?.debug('[ReactNativeBleTransport] connect completed', {
2043
+ connectIdSuffix: uuid.slice(-8),
2044
+ elapsedMs: Date.now() - startedAt,
2045
+ succeeded,
2046
+ backstopExpired: timedOut,
2047
+ });
1879
2048
  }
1880
2049
  }
1881
2050
 
@@ -1884,6 +2053,8 @@ export default class ReactNativeBleTransport {
1884
2053
  uuid: string,
1885
2054
  device: Device
1886
2055
  ): Promise<ResolvedBleCharacteristics> {
2056
+ const startedAt = Date.now();
2057
+ let succeeded = false;
1887
2058
  let timer: ReturnType<typeof setTimeout> | undefined;
1888
2059
  let timedOut = false;
1889
2060
  const pending = this.resolveCharacteristics(device);
@@ -1904,6 +2075,7 @@ export default class ReactNativeBleTransport {
1904
2075
  }),
1905
2076
  ]);
1906
2077
  this.connectionSetupTimeoutCounts.delete(uuid);
2078
+ succeeded = true;
1907
2079
  return result;
1908
2080
  } catch (error) {
1909
2081
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1918,6 +2090,12 @@ export default class ReactNativeBleTransport {
1918
2090
  throw error;
1919
2091
  } finally {
1920
2092
  if (timer) clearTimeout(timer);
2093
+ Log?.debug('[ReactNativeBleTransport] GATT setup completed', {
2094
+ connectIdSuffix: uuid.slice(-8),
2095
+ elapsedMs: Date.now() - startedAt,
2096
+ succeeded,
2097
+ backstopExpired: timedOut,
2098
+ });
1921
2099
  }
1922
2100
  }
1923
2101
 
@@ -2061,6 +2239,7 @@ export default class ReactNativeBleTransport {
2061
2239
  }
2062
2240
 
2063
2241
  private resetPlxManager() {
2242
+ if (bleManagerResetPromise) return;
2064
2243
  const manager = this.blePlxManager;
2065
2244
  this.blePlxManager = undefined;
2066
2245
  const reason = 'React Native BLE manager reset';
@@ -2107,11 +2286,21 @@ export default class ReactNativeBleTransport {
2107
2286
  this.connectionSetupTimeoutCounts.clear();
2108
2287
  this.monitorTokens.clear();
2109
2288
  this.protocolV2Assemblers.clear();
2289
+ let reset: Promise<void>;
2110
2290
  try {
2111
- manager?.destroy();
2291
+ reset = Promise.resolve(manager?.destroy());
2112
2292
  } catch (error) {
2113
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
2293
+ reset = Promise.reject(error);
2114
2294
  }
2295
+ bleManagerResetPromise = reset;
2296
+ reset.then(
2297
+ () => {
2298
+ if (bleManagerResetPromise === reset) bleManagerResetPromise = undefined;
2299
+ },
2300
+ error => {
2301
+ Log?.error('[ReactNativeBleTransport] BLE manager destroy failed:', error);
2302
+ }
2303
+ );
2115
2304
  }
2116
2305
 
2117
2306
  private createProtocolMismatchError(expected: ProtocolType) {
@@ -2314,9 +2503,7 @@ export default class ReactNativeBleTransport {
2314
2503
  } catch (error) {
2315
2504
  this.clearProbeProtocol(uuid, 'V1');
2316
2505
  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)) {
2506
+ if (shouldRethrowProtocolProbeError(error)) {
2320
2507
  throw error;
2321
2508
  }
2322
2509
  return false;
@@ -2341,7 +2528,7 @@ export default class ReactNativeBleTransport {
2341
2528
  this.protocolV2Assemblers.get(uuid)?.reset();
2342
2529
  this.resetProtocolV2Frames(uuid);
2343
2530
  },
2344
- shouldRethrow: isBleStaleBondHardwareError,
2531
+ shouldRethrow: shouldRethrowProtocolProbeError,
2345
2532
  });
2346
2533
  if (!detected) {
2347
2534
  this.clearProbeProtocol(uuid, 'V2');
@@ -2488,6 +2675,9 @@ export default class ReactNativeBleTransport {
2488
2675
  this.rememberStaleBondError(uuid, bondError);
2489
2676
  throw bondError;
2490
2677
  }
2678
+ if (isNativeBleDisconnectError(error)) {
2679
+ throw toBleDisconnectHardwareError(error);
2680
+ }
2491
2681
  if (
2492
2682
  getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2493
2683
  attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES