@onekeyfe/hd-transport-react-native 1.2.2-alpha.105 → 1.2.2-alpha.107

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;
@@ -450,6 +470,10 @@ export default class ReactNativeBleTransport {
450
470
  /** Serializes transport lifecycle changes for the same physical device. */
451
471
  private lifecycleOperations: Map<string, Promise<void>> = new Map();
452
472
 
473
+ private stopPromise?: Promise<void>;
474
+
475
+ private scanCleanups = new Set<() => Promise<void>>();
476
+
453
477
  constructor(options: TransportOptions) {
454
478
  this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
455
479
  }
@@ -485,10 +509,34 @@ export default class ReactNativeBleTransport {
485
509
  // empty
486
510
  }
487
511
 
488
- getPlxManager(): Promise<BlePlxManager> {
489
- if (this.blePlxManager) return Promise.resolve(this.blePlxManager);
490
- this.blePlxManager = new BlePlxManager();
491
- return Promise.resolve(this.blePlxManager);
512
+ async getPlxManager(): Promise<BlePlxManager> {
513
+ while (bleManagerResetPromise) {
514
+ await this.waitForManagerReset();
515
+ }
516
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
517
+ if (!this.blePlxManager) this.blePlxManager = new BlePlxManager();
518
+ return this.blePlxManager;
519
+ }
520
+
521
+ private async waitForManagerReset(): Promise<void> {
522
+ if (!bleManagerResetPromise) return;
523
+ let timeout: ReturnType<typeof setTimeout> | undefined;
524
+ try {
525
+ await Promise.race([
526
+ bleManagerResetPromise,
527
+ new Promise<never>((_, reject) => {
528
+ timeout = setTimeout(
529
+ () => reject(this.createWedgedBleSetupError()),
530
+ BLE_CONNECT_TIMEOUT_MS
531
+ );
532
+ }),
533
+ ]);
534
+ } catch {
535
+ // A timeout or failed destroy is not permission to reuse the old singleton.
536
+ throw this.createWedgedBleSetupError();
537
+ } finally {
538
+ if (timeout) clearTimeout(timeout);
539
+ }
492
540
  }
493
541
 
494
542
  async resolveCharacteristics(device: Device): Promise<ResolvedBleCharacteristics> {
@@ -677,35 +725,58 @@ export default class ReactNativeBleTransport {
677
725
  * @returns
678
726
  */
679
727
  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...');
728
+ const scanStartedAt = Date.now();
729
+ let firstDeviceMs: number | undefined;
730
+ const blePlxManager = await this.getPlxManager();
731
+ await subscribeBleOn(blePlxManager);
732
+ if (Platform.OS === 'android' && Platform.Version >= 31) {
733
+ Log?.debug('requesting permissions, please wait...');
694
734
 
695
- const resultConnect = await PermissionsAndroid.requestMultiple([
696
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
697
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
698
- ]);
735
+ const resultConnect = await PermissionsAndroid.requestMultiple([
736
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
737
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
738
+ ]);
699
739
 
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
- }
740
+ Log?.debug('requesting permissions, result: ', resultConnect);
741
+ if (
742
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
743
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
744
+ ) {
745
+ throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
708
746
  }
747
+ }
748
+
749
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
750
+ return new Promise<IOneKeyDevice[]>((resolve, reject) => {
751
+ const deviceList: IOneKeyDevice[] = [];
752
+ let finished = false;
753
+ let scanCleanup: Promise<void> | undefined;
754
+ const finishScan = (error?: unknown) => {
755
+ if (scanCleanup) return scanCleanup;
756
+ finished = true;
757
+ clearScanTimer();
758
+ scanCleanup = this.runNativeTeardown('scan', blePlxManager, async () => {
759
+ await blePlxManager.stopDeviceScan();
760
+ }).then(() => {
761
+ this.scanCleanups.delete(cancelScan);
762
+ if (error) reject(error);
763
+ else resolve(deviceList);
764
+ });
765
+ return scanCleanup;
766
+ };
767
+ const cancelScan = () =>
768
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
769
+ this.scanCleanups.add(cancelScan);
770
+
771
+ const clearScanTimer = timer.timeout(() => {
772
+ Log?.debug('[ReactNativeBleTransport] scan completed', {
773
+ elapsedMs: Date.now() - scanStartedAt,
774
+ firstDeviceMs,
775
+ deviceCount: deviceList.length,
776
+ scanWindowMs: this.scanTimeout,
777
+ });
778
+ finishScan();
779
+ }, this.scanTimeout);
709
780
 
710
781
  blePlxManager.startDeviceScan(
711
782
  getBluetoothServiceUuids(),
@@ -721,18 +792,17 @@ export default class ReactNativeBleTransport {
721
792
  error.errorCode
722
793
  )
723
794
  ) {
724
- reject(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
795
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
725
796
  } else if (error.errorCode === BleErrorCode.BluetoothUnauthorized) {
726
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
797
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
727
798
  } else if (error.errorCode === BleErrorCode.LocationServicesDisabled) {
728
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
799
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
729
800
  } else if (error.errorCode === BleErrorCode.ScanStartFailed) {
730
801
  // Android Bluetooth will report an error when the search frequency is too fast,
731
802
  // then nothing is processed and an empty array of devices is returned.
732
803
  // Then the next search will be back to normal
733
- timer.timeout(() => {}, this.scanTimeout);
734
804
  } else {
735
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
805
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
736
806
  }
737
807
  return;
738
808
  }
@@ -764,6 +834,7 @@ export default class ReactNativeBleTransport {
764
834
  }
765
835
  );
766
836
 
837
+ if (finished) return;
767
838
  getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
768
839
  devices => {
769
840
  for (const device of devices) {
@@ -783,11 +854,13 @@ export default class ReactNativeBleTransport {
783
854
  addDevice(device as unknown as Device);
784
855
  }
785
856
  }
786
- }
857
+ },
858
+ error => Log?.debug('search connected peripheral failed:', error)
787
859
  );
788
860
 
789
861
  const addDevice = (device: Device) => {
790
- if (deviceList.every(d => d.id !== device.id)) {
862
+ if (!finished && deviceList.every(d => d.id !== device.id)) {
863
+ firstDeviceMs ??= Date.now() - scanStartedAt;
791
864
  const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
792
865
 
793
866
  deviceList.push({
@@ -802,11 +875,6 @@ export default class ReactNativeBleTransport {
802
875
  });
803
876
  }
804
877
  };
805
-
806
- timer.timeout(() => {
807
- blePlxManager.stopDeviceScan();
808
- resolve(deviceList);
809
- }, this.scanTimeout);
810
878
  });
811
879
  }
812
880
 
@@ -817,6 +885,7 @@ export default class ReactNativeBleTransport {
817
885
  ) {
818
886
  const { writeCharacteristic, notifyCharacteristic } =
819
887
  characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
888
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
820
889
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
821
890
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
822
891
  const monitorToken = this.nextMonitorToken;
@@ -845,6 +914,7 @@ export default class ReactNativeBleTransport {
845
914
  } else if (Platform.OS === 'android') {
846
915
  await delay(ANDROID_NOTIFY_READY_DELAY_MS);
847
916
  }
917
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
848
918
 
849
919
  const initialMtu = transport.mtuSize;
850
920
  let refreshAttempts = 0;
@@ -858,12 +928,14 @@ export default class ReactNativeBleTransport {
858
928
  'servicesAndNotifyReady',
859
929
  1
860
930
  );
931
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
861
932
  transport.device = refreshedDevice;
862
933
  transport.mtuSize =
863
934
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
864
935
 
865
936
  if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
866
937
  await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
938
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
867
939
  refreshAttempts += 1;
868
940
  refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
869
941
  transport.device = refreshedDevice;
@@ -872,6 +944,7 @@ export default class ReactNativeBleTransport {
872
944
  }
873
945
  }
874
946
 
947
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
875
948
  Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
876
949
  platform: Platform.OS,
877
950
  requested: getRequestedBleMtu(),
@@ -894,6 +967,7 @@ export default class ReactNativeBleTransport {
894
967
  }
895
968
 
896
969
  private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
970
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
897
971
  const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
898
972
  const shouldMapProtocolV2StaleBond = expectedProtocol
899
973
  ? expectedProtocol === 'V2'
@@ -949,6 +1023,7 @@ export default class ReactNativeBleTransport {
949
1023
  cachedProtocol &&
950
1024
  (!expectedProtocol || cachedProtocol === expectedProtocol)
951
1025
  ) {
1026
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
952
1027
  Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
953
1028
  return { uuid, protocolType: cachedProtocol };
954
1029
  }
@@ -980,6 +1055,27 @@ export default class ReactNativeBleTransport {
980
1055
  throw error;
981
1056
  }
982
1057
 
1058
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1059
+ if (Platform.OS === 'android') {
1060
+ // Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
1061
+ try {
1062
+ const bondState = await pairDevice(uuid);
1063
+ if (bondState.bonding) {
1064
+ await onDeviceBondState(uuid);
1065
+ } else if (!bondState.bonded) {
1066
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1067
+ }
1068
+ } catch (error) {
1069
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1070
+ await this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
1071
+ blePlxManager.cancelDeviceConnection(uuid)
1072
+ );
1073
+ });
1074
+ throw error;
1075
+ }
1076
+ }
1077
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1078
+
983
1079
  if (!device) {
984
1080
  const devices = await blePlxManager.devices([uuid]);
985
1081
  [device] = devices;
@@ -1066,42 +1162,28 @@ export default class ReactNativeBleTransport {
1066
1162
  }
1067
1163
  }
1068
1164
 
1069
- if (Platform.OS === 'android') {
1070
- // Establish the LE link before createBond(). Without an existing LE ACL,
1071
- // Android TRANSPORT_AUTO can choose BR/EDR for a BLE-only device.
1072
- const connectedDevice = device;
1073
- try {
1074
- if (!(await connectedDevice.isConnected().catch(() => false))) {
1075
- throw ERRORS.TypedError(
1076
- HardwareErrorCode.BleConnectedError,
1077
- `Device ${uuid} is not connected before bonding`
1078
- );
1079
- }
1080
- const bondState = await pairDevice(uuid);
1081
- if (bondState.bonding) {
1082
- await onDeviceBondState(uuid);
1083
- } else if (!bondState.bonded) {
1084
- throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1085
- }
1086
- } catch (error) {
1087
- await this.runNativeTeardown(uuid, blePlxManager, async () => {
1088
- await Promise.all([
1089
- this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
1090
- blePlxManager.cancelDeviceConnection(uuid)
1091
- ),
1092
- this.runBestEffortNativeOperation('bond failure: cancel device connection', () =>
1093
- connectedDevice.cancelConnection()
1094
- ),
1095
- ]);
1096
- });
1097
- throw error;
1098
- }
1165
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1166
+ if (Platform.OS === 'android' && !(await device.isConnected().catch(() => false))) {
1167
+ const disconnectedDevice = device;
1168
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1169
+ await Promise.all([
1170
+ this.runBestEffortNativeOperation('connect failure: cancel manager connection', () =>
1171
+ blePlxManager.cancelDeviceConnection(uuid)
1172
+ ),
1173
+ this.runBestEffortNativeOperation('connect failure: cancel device connection', () =>
1174
+ disconnectedDevice.cancelConnection()
1175
+ ),
1176
+ ]);
1177
+ });
1178
+ throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1099
1179
  }
1100
-
1180
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1101
1181
  device = await resolveNegotiatedMtu(device);
1182
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1102
1183
  const acquiredDevice = device;
1103
1184
  const { writeCharacteristic, notifyCharacteristic } =
1104
1185
  await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1186
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1105
1187
 
1106
1188
  const protocolHint = expectedProtocol
1107
1189
  ? undefined
@@ -1163,6 +1245,7 @@ export default class ReactNativeBleTransport {
1163
1245
  await this.installTransportForAcquire(uuid, acquiredDevice);
1164
1246
  }
1165
1247
  );
1248
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1166
1249
  const currentTransport = transportCache[uuid];
1167
1250
  if (!currentTransport) {
1168
1251
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
@@ -1170,7 +1253,7 @@ export default class ReactNativeBleTransport {
1170
1253
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1171
1254
  return { uuid, protocolType };
1172
1255
  } catch (error) {
1173
- if (isBleStaleBondHardwareError(error)) {
1256
+ if (isBleStaleBondHardwareError(error) || shouldRethrowProtocolProbeError(error)) {
1174
1257
  await this.disconnectUnlocked(uuid);
1175
1258
  } else {
1176
1259
  await this.releaseUnlocked(uuid, true);
@@ -1703,7 +1786,47 @@ export default class ReactNativeBleTransport {
1703
1786
  }
1704
1787
 
1705
1788
  stop() {
1789
+ if (this.stopPromise) return this.stopPromise;
1706
1790
  this.stopped = true;
1791
+ const deviceIds = new Set([
1792
+ ...this.monitorTokens.keys(),
1793
+ ...this.sessionProtocols.keys(),
1794
+ ...this.lifecycleOperations.keys(),
1795
+ ...(this.runPromiseDeviceId ? [this.runPromiseDeviceId] : []),
1796
+ ]);
1797
+ const scans = Array.from(this.scanCleanups, cleanup => cleanup());
1798
+ this.androidPriorityResetTimers.forEach(timeout => clearTimeout(timeout));
1799
+ this.androidPriorityResetTimers.clear();
1800
+ this.androidHighPriorityDevices.clear();
1801
+ const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1802
+ this.runPromise?.reject(error);
1803
+ this.runPromise = null;
1804
+ this.runPromiseDeviceId = null;
1805
+ deviceIds.forEach(uuid => this.rejectProtocolV2Frames(uuid, error));
1806
+ const manager = this.blePlxManager;
1807
+ // Cancel native setup before waiting for its lifecycle lock. Otherwise stop
1808
+ // waits for the very connect/MTU/GATT operation it needs to interrupt.
1809
+ const pendingConnections = manager
1810
+ ? Array.from(this.lifecycleOperations.keys(), uuid =>
1811
+ this.runNativeTeardown(uuid, manager, async () => {
1812
+ await this.runBestEffortNativeOperation('stop: cancel pending device connection', () =>
1813
+ manager.cancelDeviceConnection(uuid)
1814
+ );
1815
+ })
1816
+ )
1817
+ : [];
1818
+ // Release only this transport's endpoints; other connectors may share ble-plx.
1819
+ this.stopPromise = Promise.all([
1820
+ ...scans,
1821
+ ...pendingConnections,
1822
+ ...Array.from(deviceIds, uuid => this.disconnect(uuid)),
1823
+ ]).then(async () => {
1824
+ await this.protocolV2Links.invalidateAllLinks('React Native BLE transport stopped');
1825
+ await this.waitForManagerReset();
1826
+ this.blePlxManager = undefined;
1827
+ this.emitter = undefined;
1828
+ });
1829
+ return this.stopPromise;
1707
1830
  }
1708
1831
 
1709
1832
  async disconnect(session: string) {
@@ -1855,17 +1978,27 @@ export default class ReactNativeBleTransport {
1855
1978
  }
1856
1979
  }
1857
1980
 
1858
- cancel() {
1981
+ async cancel() {
1859
1982
  Log?.debug('transport-react-native transport cancel');
1860
- if (this.runPromise) {
1861
- // this.runPromise.reject(new Error('Transport_CallCanceled'));
1983
+ const pending = this.runPromise;
1984
+ const deviceId = this.runPromiseDeviceId;
1985
+ if (pending) {
1986
+ pending.reject(ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled));
1987
+ if (this.runPromise === pending) {
1988
+ this.runPromise = null;
1989
+ this.runPromiseDeviceId = null;
1990
+ }
1991
+ // A V1 read cannot be safely reused after abandoning its response.
1992
+ // Drain native teardown before DeviceCommands releases the operation.
1993
+ if (deviceId) await this.disconnect(deviceId);
1862
1994
  }
1863
- this.runPromise = null;
1864
- this.runPromiseDeviceId = null;
1865
1995
  }
1866
1996
 
1867
1997
  /** Run a native connect under the JS backstop budget. */
1868
1998
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1999
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2000
+ const startedAt = Date.now();
2001
+ let succeeded = false;
1869
2002
  let timer: ReturnType<typeof setTimeout> | undefined;
1870
2003
  let timedOut = false;
1871
2004
  const pending = connect();
@@ -1887,6 +2020,7 @@ export default class ReactNativeBleTransport {
1887
2020
  }, BLE_CONNECT_TIMEOUT_MS);
1888
2021
  }),
1889
2022
  ]);
2023
+ succeeded = true;
1890
2024
  return result;
1891
2025
  } catch (error) {
1892
2026
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1901,6 +2035,12 @@ export default class ReactNativeBleTransport {
1901
2035
  throw error;
1902
2036
  } finally {
1903
2037
  if (timer) clearTimeout(timer);
2038
+ Log?.debug('[ReactNativeBleTransport] connect completed', {
2039
+ connectIdSuffix: uuid.slice(-8),
2040
+ elapsedMs: Date.now() - startedAt,
2041
+ succeeded,
2042
+ backstopExpired: timedOut,
2043
+ });
1904
2044
  }
1905
2045
  }
1906
2046
 
@@ -1909,6 +2049,8 @@ export default class ReactNativeBleTransport {
1909
2049
  uuid: string,
1910
2050
  device: Device
1911
2051
  ): Promise<ResolvedBleCharacteristics> {
2052
+ const startedAt = Date.now();
2053
+ let succeeded = false;
1912
2054
  let timer: ReturnType<typeof setTimeout> | undefined;
1913
2055
  let timedOut = false;
1914
2056
  const pending = this.resolveCharacteristics(device);
@@ -1929,6 +2071,7 @@ export default class ReactNativeBleTransport {
1929
2071
  }),
1930
2072
  ]);
1931
2073
  this.connectionSetupTimeoutCounts.delete(uuid);
2074
+ succeeded = true;
1932
2075
  return result;
1933
2076
  } catch (error) {
1934
2077
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1943,6 +2086,12 @@ export default class ReactNativeBleTransport {
1943
2086
  throw error;
1944
2087
  } finally {
1945
2088
  if (timer) clearTimeout(timer);
2089
+ Log?.debug('[ReactNativeBleTransport] GATT setup completed', {
2090
+ connectIdSuffix: uuid.slice(-8),
2091
+ elapsedMs: Date.now() - startedAt,
2092
+ succeeded,
2093
+ backstopExpired: timedOut,
2094
+ });
1946
2095
  }
1947
2096
  }
1948
2097
 
@@ -2086,6 +2235,7 @@ export default class ReactNativeBleTransport {
2086
2235
  }
2087
2236
 
2088
2237
  private resetPlxManager() {
2238
+ if (bleManagerResetPromise) return;
2089
2239
  const manager = this.blePlxManager;
2090
2240
  this.blePlxManager = undefined;
2091
2241
  const reason = 'React Native BLE manager reset';
@@ -2132,11 +2282,21 @@ export default class ReactNativeBleTransport {
2132
2282
  this.connectionSetupTimeoutCounts.clear();
2133
2283
  this.monitorTokens.clear();
2134
2284
  this.protocolV2Assemblers.clear();
2285
+ let reset: Promise<void>;
2135
2286
  try {
2136
- manager?.destroy();
2287
+ reset = Promise.resolve(manager?.destroy());
2137
2288
  } catch (error) {
2138
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
2289
+ reset = Promise.reject(error);
2139
2290
  }
2291
+ bleManagerResetPromise = reset;
2292
+ reset.then(
2293
+ () => {
2294
+ if (bleManagerResetPromise === reset) bleManagerResetPromise = undefined;
2295
+ },
2296
+ error => {
2297
+ Log?.error('[ReactNativeBleTransport] BLE manager destroy failed:', error);
2298
+ }
2299
+ );
2140
2300
  }
2141
2301
 
2142
2302
  private createProtocolMismatchError(expected: ProtocolType) {
@@ -2339,9 +2499,7 @@ export default class ReactNativeBleTransport {
2339
2499
  } catch (error) {
2340
2500
  this.clearProbeProtocol(uuid, 'V1');
2341
2501
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2342
- // A wedged write already dropped the link, so probing another protocol on it
2343
- // would only fail against a torn-down transport: surface the real cause.
2344
- if (isWedgedWriteError(error)) {
2502
+ if (shouldRethrowProtocolProbeError(error)) {
2345
2503
  throw error;
2346
2504
  }
2347
2505
  return false;
@@ -2366,7 +2524,7 @@ export default class ReactNativeBleTransport {
2366
2524
  this.protocolV2Assemblers.get(uuid)?.reset();
2367
2525
  this.resetProtocolV2Frames(uuid);
2368
2526
  },
2369
- shouldRethrow: isBleStaleBondHardwareError,
2527
+ shouldRethrow: shouldRethrowProtocolProbeError,
2370
2528
  });
2371
2529
  if (!detected) {
2372
2530
  this.clearProbeProtocol(uuid, 'V2');
@@ -2513,6 +2671,9 @@ export default class ReactNativeBleTransport {
2513
2671
  this.rememberStaleBondError(uuid, bondError);
2514
2672
  throw bondError;
2515
2673
  }
2674
+ if (isNativeBleDisconnectError(error)) {
2675
+ throw toBleDisconnectHardwareError(error);
2676
+ }
2516
2677
  if (
2517
2678
  getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2518
2679
  attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES