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

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
@@ -1,7 +1,6 @@
1
1
  import { PermissionsAndroid, Platform } from 'react-native';
2
2
  import { Buffer } from 'buffer';
3
3
  import {
4
- BleATTErrorCode,
5
4
  BleError,
6
5
  BleErrorCode,
7
6
  BleManager as BlePlxManager,
@@ -29,7 +28,6 @@ import {
29
28
  HardwareErrorCode,
30
29
  createDeferred,
31
30
  isOnekeyBluetoothDevice,
32
- isPro2FindMyAdvertisementName,
33
31
  } from '@onekeyfe/hd-shared';
34
32
 
35
33
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
@@ -49,17 +47,30 @@ import {
49
47
  getInfosForServiceUuid,
50
48
  isSameBleUuid,
51
49
  } from './constants';
50
+ import {
51
+ isBleStaleBondHardwareError,
52
+ isNativeBleStaleBondError,
53
+ toBleStaleBondHardwareError,
54
+ } from './bleStaleBond';
52
55
  import { isHeaderChunk } from './utils/validateNotify';
53
56
  import BleTransport from './BleTransport';
54
57
  import timer from './utils/timer';
55
58
  import { bleLogger, setBleLogger } from './logger';
56
- import { createTransportCallLog } from './transportLog';
57
59
 
58
60
  import type { Deferred } from '@onekeyfe/hd-shared';
59
61
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
60
62
  import type EventEmitter from 'events';
61
63
  import type { BleAcquireInput, TransportOptions } from './types';
62
64
 
65
+ type FirmwareInstallBleAcquireInput = BleAcquireInput & {
66
+ /**
67
+ * Reuse the already-verified protocol after an expected firmware-install
68
+ * disconnect. The install loader accepts status requests but may not answer
69
+ * the generic protocol probe used by a normal acquire.
70
+ */
71
+ skipProtocolProbe?: boolean;
72
+ };
73
+
63
74
  const { check, ProtocolV1, parseConfigure } = transport;
64
75
 
65
76
  const Log = bleLogger;
@@ -140,7 +151,7 @@ export const getFirmwareUploadWriteRetryType = (
140
151
 
141
152
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
142
153
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
143
- const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
154
+ const PROTOCOL_PROBE_TIMEOUT_MS = 3000;
144
155
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
145
156
  /**
146
157
  * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
@@ -151,6 +162,7 @@ const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
151
162
  * A healthy packet completes in milliseconds, so this only fires on a dead link.
152
163
  */
153
164
  export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
165
+ export const BLE_NATIVE_TEARDOWN_TIMEOUT_MS = 3_000;
154
166
  const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
155
167
  const isWedgedWriteError = (error: unknown): boolean =>
156
168
  (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
@@ -204,10 +216,6 @@ export function getProtocolV2BleTuning() {
204
216
  return { ...protocolV2BleTuning };
205
217
  }
206
218
 
207
- function inferProtocolHintFromDeviceName(name?: string | null): ProtocolType | undefined {
208
- return /\bpro\s*2\b/i.test(name ?? '') ? 'V2' : undefined;
209
- }
210
-
211
219
  function getDeviceDisplayName(device?: Device | null) {
212
220
  return device?.name || device?.localName || null;
213
221
  }
@@ -259,10 +267,17 @@ export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
259
267
  /** BLE setup timeouts since the last successful setup before the manager is recreated. */
260
268
  export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
261
269
  const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
270
+ export const BLE_SETUP_WEDGED_MESSAGE = 'BLE setup wedged repeatedly';
262
271
  const isConnectTimeoutError = (error: unknown): boolean =>
263
272
  (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
264
273
  typeof (error as { message?: unknown })?.message === 'string' &&
265
274
  (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
275
+ const isWedgedBleSetupError = (error: unknown): boolean =>
276
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.PollingTimeout &&
277
+ typeof (error as { message?: unknown })?.message === 'string' &&
278
+ (error as { message: string }).message.startsWith(BLE_SETUP_WEDGED_MESSAGE);
279
+ const shouldRethrowBleSetupError = (error: unknown): boolean =>
280
+ isConnectTimeoutError(error) || isWedgedBleSetupError(error);
266
281
  const isNativeOperationTimeoutError = (error: unknown): boolean =>
267
282
  (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
268
283
 
@@ -305,15 +320,10 @@ const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'c
305
320
 
306
321
  type IOBleErrorRemap = Error | BleError | null | undefined;
307
322
 
308
- function remapError(error: IOBleErrorRemap) {
323
+ function remapError(error: IOBleErrorRemap, mapProtocolV2StaleBond: boolean) {
309
324
  if (error instanceof BleError) {
310
- if (
311
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
312
- // @ts-expect-error
313
- error.iosErrorCode === BleATTErrorCode.UnlikelyError ||
314
- error.reason === 'Peer removed pairing information'
315
- ) {
316
- throw ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
325
+ if (mapProtocolV2StaleBond && isNativeBleStaleBondError(error)) {
326
+ throw toBleStaleBondHardwareError(error);
317
327
  }
318
328
 
319
329
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -382,9 +392,22 @@ export default class ReactNativeBleTransport {
382
392
  /** Protocol this device actually answered on, kept across reconnects of one session. */
383
393
  private sessionProtocols: Map<string, ProtocolType> = new Map();
384
394
 
395
+ /** Endpoints that answered a V2 probe in this transport lifetime. Survives disconnect. */
396
+ private confirmedProtocolV2 = new Set<string>();
397
+
385
398
  /** Consecutive detections that failed while trusting sessionProtocols. */
386
399
  private protocolReprobeFailures: Map<string, number> = new Map();
387
400
 
401
+ /**
402
+ * Native encryption/pairing failures seen before Protocol V2 probe starts.
403
+ * Pro2/Neo GATT connect can succeed on a stale iOS bond; the CCCD write then
404
+ * fails with ATT 5/15. Remember it so detectProtocol fails immediately.
405
+ */
406
+ private staleBondErrors: Map<string, Error> = new Map();
407
+
408
+ /** Strict or previously confirmed V2 target while acquire installs notifications. */
409
+ private acquiringProtocolV2 = new Set<string>();
410
+
388
411
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
389
412
 
390
413
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -424,6 +447,9 @@ export default class ReactNativeBleTransport {
424
447
 
425
448
  private nextMonitorToken = 1;
426
449
 
450
+ /** Serializes transport lifecycle changes for the same physical device. */
451
+ private lifecycleOperations: Map<string, Promise<void>> = new Map();
452
+
427
453
  constructor(options: TransportOptions) {
428
454
  this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
429
455
  }
@@ -715,17 +741,15 @@ export default class ReactNativeBleTransport {
715
741
  // iOS may report a service-only advertisement before the named scan response.
716
742
  // Do not cache that incomplete advertisement as an unknown device.
717
743
  const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
718
- const isFindMyPeripheral =
719
- isPro2FindMyAdvertisementName(device?.name) ||
720
- isPro2FindMyAdvertisementName(device?.localName);
721
744
  const isOneKey =
722
745
  !isUnnamedIOSPeripheral &&
723
- !isFindMyPeripheral &&
724
746
  isOnekeyBluetoothDevice({
725
747
  id: device?.id,
726
748
  name: device?.name,
727
749
  localName: device?.localName,
728
- serviceUuids: device?.serviceUUIDs,
750
+ // The native scan is already restricted to the OneKey communication service,
751
+ // but ble-plx permits the returned advertisement field to be null.
752
+ serviceUuids: device?.serviceUUIDs ?? getBluetoothServiceUuids(),
729
753
  });
730
754
  if (isOneKey) {
731
755
  addDevice(device as unknown as Device);
@@ -747,12 +771,7 @@ export default class ReactNativeBleTransport {
747
771
  'localName' in device && typeof device.localName === 'string'
748
772
  ? device.localName
749
773
  : null;
750
- const isFindMyPeripheral =
751
- isPro2FindMyAdvertisementName(device.name) ||
752
- isPro2FindMyAdvertisementName(localName);
753
-
754
774
  if (
755
- !isFindMyPeripheral &&
756
775
  isOnekeyBluetoothDevice({
757
776
  id: device.id,
758
777
  name: device.name,
@@ -770,10 +789,7 @@ export default class ReactNativeBleTransport {
770
789
  const addDevice = (device: Device) => {
771
790
  if (deviceList.every(d => d.id !== device.id)) {
772
791
  const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
773
- const protocolHint = inferProtocolHintFromDeviceName(displayName);
774
- if (protocolHint) {
775
- this.deviceProtocolHints.set(device.id, protocolHint);
776
- }
792
+
777
793
  deviceList.push({
778
794
  ...device,
779
795
  name: displayName,
@@ -783,7 +799,6 @@ export default class ReactNativeBleTransport {
783
799
  deviceId: device.id,
784
800
  name: displayName,
785
801
  serviceUUIDs: device.serviceUUIDs,
786
- protocolHint,
787
802
  });
788
803
  }
789
804
  };
@@ -868,32 +883,83 @@ export default class ReactNativeBleTransport {
868
883
  return transport;
869
884
  }
870
885
 
871
- async acquire(input: BleAcquireInput) {
872
- const { uuid, forceCleanRunPromise, expectedProtocol } = input;
886
+ async acquire(input: FirmwareInstallBleAcquireInput) {
887
+ const { uuid } = input;
873
888
 
874
889
  if (!uuid) {
875
890
  throw ERRORS.TypedError(HardwareErrorCode.BleRequiredUUID);
876
891
  }
877
892
 
893
+ return this.runLifecycleOperation(uuid, () => this.acquireUnlocked(input));
894
+ }
895
+
896
+ private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
897
+ const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
898
+ const shouldMapProtocolV2StaleBond = expectedProtocol
899
+ ? expectedProtocol === 'V2'
900
+ : this.confirmedProtocolV2.has(uuid);
901
+
878
902
  const cachedTransport = transportCache[uuid];
903
+ if (skipProtocolProbe && !cachedTransport && this.blePlxManager) {
904
+ Log?.debug(
905
+ '[ReactNativeBleTransport] refresh uncached BLE connection for firmware install:',
906
+ uuid
907
+ );
908
+ const manager = this.blePlxManager;
909
+ await this.runNativeTeardown(uuid, manager, async () => {
910
+ await this.runBestEffortNativeOperation(
911
+ 'firmware install reconnect: cancel uncached device connection',
912
+ () => manager.cancelDeviceConnection(uuid)
913
+ );
914
+ });
915
+ }
879
916
  if (cachedTransport) {
880
- const cachedProtocol = this.deviceProtocol.get(uuid);
881
- const isCachedDeviceConnected = await cachedTransport.device.isConnected().catch(() => false);
882
- if (
883
- isCachedDeviceConnected &&
884
- cachedProtocol &&
885
- (!expectedProtocol || cachedProtocol === expectedProtocol)
886
- ) {
887
- Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
888
- return { uuid, protocolType: cachedProtocol };
889
- }
917
+ if (skipProtocolProbe) {
918
+ Log?.debug(
919
+ '[ReactNativeBleTransport] refresh cached BLE connection for firmware install:',
920
+ uuid
921
+ );
922
+ const manager = this.blePlxManager;
923
+ await this.releaseUnlocked(uuid, true);
924
+ await this.runNativeTeardown(uuid, manager, async () => {
925
+ const operations: Promise<unknown>[] = [];
926
+ if (manager) {
927
+ operations.push(
928
+ this.runBestEffortNativeOperation(
929
+ 'firmware install reconnect: cancel device connection',
930
+ () => manager.cancelDeviceConnection(uuid)
931
+ )
932
+ );
933
+ }
934
+ operations.push(
935
+ this.runBestEffortNativeOperation(
936
+ 'firmware install reconnect: device cancel connection',
937
+ () => cachedTransport.device.cancelConnection()
938
+ )
939
+ );
940
+ await Promise.all(operations);
941
+ });
942
+ } else {
943
+ const cachedProtocol = this.deviceProtocol.get(uuid);
944
+ const isCachedDeviceConnected = await cachedTransport.device
945
+ .isConnected()
946
+ .catch(() => false);
947
+ if (
948
+ isCachedDeviceConnected &&
949
+ cachedProtocol &&
950
+ (!expectedProtocol || cachedProtocol === expectedProtocol)
951
+ ) {
952
+ Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
953
+ return { uuid, protocolType: cachedProtocol };
954
+ }
890
955
 
891
- /**
892
- * If the transport is not reusable due to a protocol mismatch or stale
893
- * connection, clean it up before creating a new transport instance.
894
- */
895
- Log?.debug('transport not reusable, will release: ', uuid);
896
- await this.release(uuid, true);
956
+ /**
957
+ * If the transport is not reusable due to a protocol mismatch or stale
958
+ * connection, clean it up before creating a new transport instance.
959
+ */
960
+ Log?.debug('transport not reusable, will release: ', uuid);
961
+ await this.releaseUnlocked(uuid, true);
962
+ }
897
963
  }
898
964
 
899
965
  let device: Device | null = null;
@@ -943,7 +1009,7 @@ export default class ReactNativeBleTransport {
943
1009
  );
944
1010
  } catch (e) {
945
1011
  Log?.debug('try to connect to device has error: ', e);
946
- if (isConnectTimeoutError(e)) {
1012
+ if (shouldRethrowBleSetupError(e)) {
947
1013
  throw e;
948
1014
  }
949
1015
  if (
@@ -958,7 +1024,7 @@ export default class ReactNativeBleTransport {
958
1024
  Log?.debug('device already connected');
959
1025
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
960
1026
  } else {
961
- remapError(e);
1027
+ remapError(e, shouldMapProtocolV2StaleBond);
962
1028
  }
963
1029
  }
964
1030
  }
@@ -977,7 +1043,7 @@ export default class ReactNativeBleTransport {
977
1043
  );
978
1044
  } catch (e) {
979
1045
  Log?.debug('not connected, try to connect to device has error: ', e);
980
- if (isConnectTimeoutError(e)) {
1046
+ if (shouldRethrowBleSetupError(e)) {
981
1047
  throw e;
982
1048
  }
983
1049
  if (
@@ -1002,7 +1068,7 @@ export default class ReactNativeBleTransport {
1002
1068
  }
1003
1069
  }
1004
1070
  } else {
1005
- remapError(e);
1071
+ remapError(e, shouldMapProtocolV2StaleBond);
1006
1072
  }
1007
1073
  }
1008
1074
  }
@@ -1014,22 +1080,56 @@ export default class ReactNativeBleTransport {
1014
1080
 
1015
1081
  const protocolHint = expectedProtocol
1016
1082
  ? undefined
1017
- : input.protocolHint ??
1018
- this.deviceProtocolHints.get(uuid) ??
1019
- inferProtocolHintFromDeviceName(getDeviceDisplayName(acquiredDevice));
1083
+ : input.protocolHint ?? this.deviceProtocolHints.get(uuid);
1020
1084
 
1021
1085
  // release transport before new transport instance
1022
- await this.release(uuid, true);
1086
+ await this.releaseUnlocked(uuid, true);
1023
1087
  if (protocolHint) {
1024
1088
  this.deviceProtocolHints.set(uuid, protocolHint);
1025
1089
  }
1026
1090
 
1027
- await this.installTransportForAcquire(uuid, acquiredDevice, {
1028
- writeCharacteristic,
1029
- notifyCharacteristic,
1030
- });
1091
+ if (shouldMapProtocolV2StaleBond) {
1092
+ this.acquiringProtocolV2.add(uuid);
1093
+ }
1031
1094
 
1032
1095
  try {
1096
+ await this.installTransportForAcquire(uuid, acquiredDevice, {
1097
+ writeCharacteristic,
1098
+ notifyCharacteristic,
1099
+ });
1100
+
1101
+ if (skipProtocolProbe) {
1102
+ if (!expectedProtocol) {
1103
+ throw ERRORS.TypedError(
1104
+ HardwareErrorCode.RuntimeError,
1105
+ 'skipProtocolProbe requires an expected BLE protocol'
1106
+ );
1107
+ }
1108
+ const hasConfirmedProtocol =
1109
+ this.sessionProtocols.get(uuid) === expectedProtocol ||
1110
+ (expectedProtocol === 'V2' && this.confirmedProtocolV2.has(uuid));
1111
+ if (!hasConfirmedProtocol) {
1112
+ throw ERRORS.TypedError(
1113
+ HardwareErrorCode.RuntimeError,
1114
+ 'skipProtocolProbe requires a previously confirmed protocol for this BLE endpoint'
1115
+ );
1116
+ }
1117
+ this.deviceProtocol.set(uuid, expectedProtocol);
1118
+ this.sessionProtocols.set(uuid, expectedProtocol);
1119
+ this.protocolReprobeFailures.delete(uuid);
1120
+ Log?.debug('[ReactNativeBleTransport] protocol selected without probe', {
1121
+ deviceId: uuid,
1122
+ protocol: expectedProtocol,
1123
+ source: 'firmware-install-reconnect',
1124
+ });
1125
+ const currentTransport = transportCache[uuid];
1126
+ if (!currentTransport) {
1127
+ throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1128
+ }
1129
+ this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1130
+ return { uuid, protocolType: expectedProtocol };
1131
+ }
1132
+
1033
1133
  const protocolType = await this.detectProtocol(
1034
1134
  uuid,
1035
1135
  expectedProtocol,
@@ -1045,8 +1145,14 @@ export default class ReactNativeBleTransport {
1045
1145
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1046
1146
  return { uuid, protocolType };
1047
1147
  } catch (error) {
1048
- await this.release(uuid, true);
1148
+ if (isBleStaleBondHardwareError(error)) {
1149
+ await this.disconnectUnlocked(uuid);
1150
+ } else {
1151
+ await this.releaseUnlocked(uuid, true);
1152
+ }
1049
1153
  throw error;
1154
+ } finally {
1155
+ this.acquiringProtocolV2.delete(uuid);
1050
1156
  }
1051
1157
  }
1052
1158
 
@@ -1074,17 +1180,21 @@ export default class ReactNativeBleTransport {
1074
1180
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
1075
1181
  return;
1076
1182
  }
1183
+ if (
1184
+ (this.getActiveProtocol(uuid) === 'V2' || this.acquiringProtocolV2.has(uuid)) &&
1185
+ isNativeBleStaleBondError(error)
1186
+ ) {
1187
+ this.rememberStaleBondError(uuid, toBleStaleBondHardwareError(error));
1188
+ return;
1189
+ }
1077
1190
  if (this.getActiveProtocol(uuid) === 'V2') {
1078
1191
  let errorCode:
1079
- | typeof HardwareErrorCode.BleDeviceBondError
1080
1192
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
1081
1193
  | typeof HardwareErrorCode.BleCharacteristicNotifyChangeFailure
1082
1194
  | typeof HardwareErrorCode.BleTimeoutError =
1083
1195
  HardwareErrorCode.BleCharacteristicNotifyError;
1084
1196
  if (error.reason?.includes('The connection has timed out unexpectedly')) {
1085
1197
  errorCode = HardwareErrorCode.BleTimeoutError;
1086
- } else if (error.reason?.includes('Encryption is insufficient')) {
1087
- errorCode = HardwareErrorCode.BleDeviceBondError;
1088
1198
  } else if (
1089
1199
  error.reason?.includes('Cannot write client characteristic config descriptor') ||
1090
1200
  error.reason?.includes('Cannot find client characteristic config descriptor') ||
@@ -1099,16 +1209,12 @@ export default class ReactNativeBleTransport {
1099
1209
  }
1100
1210
  if (this.runPromise && this.runPromiseDeviceId === uuid) {
1101
1211
  let ERROR:
1102
- | typeof HardwareErrorCode.BleDeviceBondError
1103
1212
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
1104
1213
  | typeof HardwareErrorCode.BleTimeoutError =
1105
1214
  HardwareErrorCode.BleCharacteristicNotifyError;
1106
1215
  if (error.reason?.includes('The connection has timed out unexpectedly')) {
1107
1216
  ERROR = HardwareErrorCode.BleTimeoutError;
1108
1217
  }
1109
- if (error.reason?.includes('Encryption is insufficient')) {
1110
- ERROR = HardwareErrorCode.BleDeviceBondError;
1111
- }
1112
1218
  if (
1113
1219
  error.reason?.includes('Cannot write client characteristic config descriptor') ||
1114
1220
  error.reason?.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
@@ -1190,12 +1296,17 @@ export default class ReactNativeBleTransport {
1190
1296
  }
1191
1297
 
1192
1298
  async release(uuid: string, onclose = false) {
1299
+ return this.runLifecycleOperation(uuid, () => this.releaseUnlocked(uuid, onclose));
1300
+ }
1301
+
1302
+ private async releaseUnlocked(uuid: string, onclose = false) {
1193
1303
  await this.protocolV2Links.invalidateLink(uuid, 'React Native BLE transport released');
1194
1304
  return this.releaseNative(uuid, onclose);
1195
1305
  }
1196
1306
 
1197
1307
  private async releaseNative(uuid: string, onclose = false) {
1198
1308
  const transport = transportCache[uuid];
1309
+ const manager = this.blePlxManager;
1199
1310
  if (this.runPromise && this.runPromiseDeviceId === uuid) {
1200
1311
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
1201
1312
  this.runPromise.reject(error);
@@ -1212,8 +1323,6 @@ export default class ReactNativeBleTransport {
1212
1323
  return Promise.resolve(true);
1213
1324
  }
1214
1325
 
1215
- await this.restoreAndroidConnectionPriority(uuid, transport);
1216
-
1217
1326
  if (transport) {
1218
1327
  if (this.monitorTokens.get(uuid) === transport.monitorToken) {
1219
1328
  this.monitorTokens.delete(uuid);
@@ -1231,37 +1340,58 @@ export default class ReactNativeBleTransport {
1231
1340
  );
1232
1341
  transport.notifySubscription?.remove();
1233
1342
  transport.notifySubscription = undefined;
1234
-
1235
- if (transport.notifyTransactionId) {
1236
- try {
1237
- await this.blePlxManager?.cancelTransaction(transport.notifyTransactionId);
1238
- } catch (e) {
1239
- Log?.debug('release: cancel notify transaction error (ignored): ', e?.message || e);
1240
- }
1343
+ if (transportCache[uuid] === transport) {
1344
+ delete transportCache[uuid];
1241
1345
  }
1242
-
1243
- delete transportCache[uuid];
1244
1346
  }
1245
1347
 
1246
1348
  this.protocolV2HighVolumeLogSignatures.delete(uuid);
1247
1349
 
1248
1350
  this.deviceProtocol.delete(uuid);
1249
1351
  this.probingProtocols.delete(uuid);
1250
- // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1352
+ this.staleBondErrors.delete(uuid);
1353
+ this.acquiringProtocolV2.delete(uuid);
1354
+ // Confirmed protocol and caller hints stay in deviceProtocol / protocolHint.
1251
1355
  this.protocolV2Assemblers.get(uuid)?.reset();
1252
1356
  this.protocolV2Assemblers.delete(uuid);
1253
1357
  this.resetProtocolV2Frames(uuid);
1254
1358
 
1255
- try {
1256
- await this.blePlxManager?.cancelTransaction(uuid);
1257
- } catch (e) {
1258
- Log?.debug('release: cancel transaction error (ignored): ', e?.message || e);
1259
- }
1359
+ await this.runNativeTeardown(uuid, manager, async () => {
1360
+ const operations: Promise<unknown>[] = [
1361
+ this.runBestEffortNativeOperation('release: restore connection priority', () =>
1362
+ this.restoreAndroidConnectionPriority(uuid, transport)
1363
+ ),
1364
+ ];
1365
+ if (transport?.notifyTransactionId && manager) {
1366
+ operations.push(
1367
+ this.runBestEffortNativeOperation('release: cancel notify transaction', () =>
1368
+ manager.cancelTransaction(transport.notifyTransactionId as string)
1369
+ )
1370
+ );
1371
+ }
1372
+ if (manager) {
1373
+ operations.push(
1374
+ this.runBestEffortNativeOperation('release: cancel transaction', () =>
1375
+ manager.cancelTransaction(uuid)
1376
+ )
1377
+ );
1378
+ }
1379
+ await Promise.all(operations);
1380
+ });
1260
1381
 
1261
1382
  return Promise.resolve(true);
1262
1383
  }
1263
1384
 
1264
1385
  async post(session: string, name: string, data: Record<string, unknown>) {
1386
+ if (this.getProtocolType(session) === 'V2') {
1387
+ await this.protocolV2Links.sendFlowControl(
1388
+ session,
1389
+ () => this.createProtocolV2Adapter(session),
1390
+ name,
1391
+ data
1392
+ );
1393
+ return;
1394
+ }
1265
1395
  await this.call(session, name, data);
1266
1396
  }
1267
1397
 
@@ -1286,8 +1416,6 @@ export default class ReactNativeBleTransport {
1286
1416
  `Device protocol has not been detected for ${uuid}`
1287
1417
  );
1288
1418
  }
1289
- Log?.debug('transport call', createTransportCallLog(name, protocol, data));
1290
-
1291
1419
  if (protocol === 'V2') {
1292
1420
  return this.callProtocolV2(uuid, name, data, options);
1293
1421
  }
@@ -1554,8 +1682,13 @@ export default class ReactNativeBleTransport {
1554
1682
  }
1555
1683
 
1556
1684
  async disconnect(session: string) {
1685
+ return this.runLifecycleOperation(session, () => this.disconnectUnlocked(session));
1686
+ }
1687
+
1688
+ private async disconnectUnlocked(session: string) {
1557
1689
  await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
1558
1690
  const transport = transportCache[session];
1691
+ const manager = this.blePlxManager;
1559
1692
  const monitorToken = transport?.monitorToken ?? this.monitorTokens.get(session);
1560
1693
 
1561
1694
  // Clean up disconnect subscription first to prevent onDisconnected callback
@@ -1584,37 +1717,13 @@ export default class ReactNativeBleTransport {
1584
1717
  }
1585
1718
  }
1586
1719
 
1587
- // cancel the ble transaction
1588
- if (session) {
1589
- try {
1590
- await this.blePlxManager?.cancelTransaction(session);
1591
- } catch (e) {
1592
- Log?.debug('resetSession: cancel transaction error (ignored): ', e?.message || e);
1593
- }
1594
- }
1595
-
1596
- // disconnect the device via the device object
1597
- if (transport?.device) {
1598
- try {
1599
- await transport.device.cancelConnection();
1600
- } catch (e) {
1601
- Log?.debug('resetSession: device.cancelConnection error (ignored): ', e?.message || e);
1602
- }
1603
- }
1604
-
1605
- // disconnect the device via the ble manager
1606
- try {
1607
- await this.blePlxManager?.cancelDeviceConnection(session);
1608
- } catch (e) {
1609
- Log?.debug('resetSession: manager.cancelDeviceConnection error (ignored): ', e?.message || e);
1610
- }
1611
-
1612
1720
  // clear the transport cache
1613
- if (transportCache[session]) {
1721
+ if (!transport || transportCache[session] === transport) {
1614
1722
  delete transportCache[session];
1615
1723
  }
1616
1724
  this.deviceProtocol.delete(session);
1617
1725
  this.probingProtocols.delete(session);
1726
+ this.staleBondErrors.delete(session);
1618
1727
  this.deviceProtocolHints.delete(session);
1619
1728
  this.sessionProtocols.delete(session);
1620
1729
  this.protocolReprobeFailures.delete(session);
@@ -1630,10 +1739,97 @@ export default class ReactNativeBleTransport {
1630
1739
  if (monitorToken !== undefined && this.monitorTokens.get(session) === monitorToken) {
1631
1740
  this.monitorTokens.delete(session);
1632
1741
  }
1742
+
1743
+ await this.runNativeTeardown(session, manager, async () => {
1744
+ const operations: Promise<unknown>[] = [];
1745
+ if (manager) {
1746
+ operations.push(
1747
+ this.runBestEffortNativeOperation('disconnect: cancel transaction', () =>
1748
+ manager.cancelTransaction(session)
1749
+ )
1750
+ );
1751
+ operations.push(
1752
+ this.runBestEffortNativeOperation('disconnect: cancel device connection', () =>
1753
+ manager.cancelDeviceConnection(session)
1754
+ )
1755
+ );
1756
+ }
1757
+ if (transport?.device) {
1758
+ operations.push(
1759
+ this.runBestEffortNativeOperation('disconnect: device cancel connection', () =>
1760
+ transport.device.cancelConnection()
1761
+ )
1762
+ );
1763
+ }
1764
+ await Promise.all(operations);
1765
+ });
1766
+
1633
1767
  // eslint-disable-next-line no-promise-executor-return
1634
1768
  await new Promise<void>(resolve => setTimeout(() => resolve(), 100));
1635
1769
  }
1636
1770
 
1771
+ private async runNativeTeardown(
1772
+ uuid: string,
1773
+ manager: BlePlxManager | undefined,
1774
+ teardown: () => Promise<void>
1775
+ ) {
1776
+ let timer: ReturnType<typeof setTimeout> | undefined;
1777
+ let timedOut = false;
1778
+ const pending = Promise.resolve()
1779
+ .then(teardown)
1780
+ .catch(error => {
1781
+ Log?.debug('BLE native teardown error (ignored): ', error?.message || error);
1782
+ });
1783
+ try {
1784
+ await Promise.race([
1785
+ pending,
1786
+ new Promise<void>(resolve => {
1787
+ timer = setTimeout(() => {
1788
+ timedOut = true;
1789
+ resolve();
1790
+ }, BLE_NATIVE_TEARDOWN_TIMEOUT_MS);
1791
+ }),
1792
+ ]);
1793
+ } finally {
1794
+ if (timer) clearTimeout(timer);
1795
+ }
1796
+
1797
+ if (timedOut) {
1798
+ Log?.error('[ReactNativeBleTransport] BLE native teardown timed out:', uuid);
1799
+ if (this.blePlxManager === manager) {
1800
+ this.resetPlxManager();
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ private runBestEffortNativeOperation(label: string, operation: () => Promise<unknown>) {
1806
+ return Promise.resolve()
1807
+ .then(operation)
1808
+ .catch(error => {
1809
+ Log?.debug(`${label} error (ignored): `, error?.message || error);
1810
+ });
1811
+ }
1812
+
1813
+ private async runLifecycleOperation<T>(uuid: string, operation: () => Promise<T>): Promise<T> {
1814
+ const previousOperation = this.lifecycleOperations.get(uuid) ?? Promise.resolve();
1815
+ let completeOperation!: () => void;
1816
+ const operationGate = new Promise<void>(resolve => {
1817
+ completeOperation = resolve;
1818
+ });
1819
+ const operationTail = previousOperation.catch(() => undefined).then(() => operationGate);
1820
+ this.lifecycleOperations.set(uuid, operationTail);
1821
+
1822
+ await previousOperation.catch(() => undefined);
1823
+ try {
1824
+ return await operation();
1825
+ } finally {
1826
+ completeOperation();
1827
+ if (this.lifecycleOperations.get(uuid) === operationTail) {
1828
+ this.lifecycleOperations.delete(uuid);
1829
+ }
1830
+ }
1831
+ }
1832
+
1637
1833
  cancel() {
1638
1834
  Log?.debug('transport-react-native transport cancel');
1639
1835
  if (this.runPromise) {
@@ -1669,7 +1865,13 @@ export default class ReactNativeBleTransport {
1669
1865
  return result;
1670
1866
  } catch (error) {
1671
1867
  if (timedOut || isNativeOperationTimeoutError(error)) {
1672
- this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1868
+ const resetManager = this.abandonStalledConnection(
1869
+ uuid,
1870
+ timedOut ? 'connect-backstop' : 'connect-native'
1871
+ );
1872
+ if (resetManager) {
1873
+ throw this.createWedgedBleSetupError();
1874
+ }
1673
1875
  }
1674
1876
  throw error;
1675
1877
  } finally {
@@ -1705,7 +1907,13 @@ export default class ReactNativeBleTransport {
1705
1907
  return result;
1706
1908
  } catch (error) {
1707
1909
  if (timedOut || isNativeOperationTimeoutError(error)) {
1708
- this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1910
+ const resetManager = this.abandonStalledConnection(
1911
+ uuid,
1912
+ timedOut ? 'gatt-backstop' : 'gatt-native'
1913
+ );
1914
+ if (resetManager) {
1915
+ throw this.createWedgedBleSetupError();
1916
+ }
1709
1917
  }
1710
1918
  throw error;
1711
1919
  } finally {
@@ -1717,11 +1925,12 @@ export default class ReactNativeBleTransport {
1717
1925
  * Give up on a BLE setup operation the native layer did not settle. The abandoned
1718
1926
  * operation still owns native connection/GATT state that can poison the next attempt,
1719
1927
  * so it is cleared here without awaiting the same queue that stopped responding.
1928
+ * Returns true when the manager itself was reset so the caller can stop Core retries.
1720
1929
  */
1721
1930
  private abandonStalledConnection(
1722
1931
  uuid: string,
1723
1932
  stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1724
- ) {
1933
+ ): boolean {
1725
1934
  const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1726
1935
  this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1727
1936
  Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
@@ -1738,6 +1947,8 @@ export default class ReactNativeBleTransport {
1738
1947
  }
1739
1948
  this.deviceProtocol.delete(uuid);
1740
1949
  this.probingProtocols.delete(uuid);
1950
+ this.staleBondErrors.delete(uuid);
1951
+ this.acquiringProtocolV2.delete(uuid);
1741
1952
  this.protocolV2Assemblers.delete(uuid);
1742
1953
  this.resetProtocolV2Frames(uuid);
1743
1954
 
@@ -1747,7 +1958,15 @@ export default class ReactNativeBleTransport {
1747
1958
  Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1748
1959
  this.resetPlxManager();
1749
1960
  this.connectionSetupTimeoutCounts.delete(uuid);
1961
+ return true;
1750
1962
  }
1963
+ return false;
1964
+ }
1965
+
1966
+ private createWedgedBleSetupError() {
1967
+ // PollingTimeout is not retried by connectDeviceForBle and already maps
1968
+ // to the App "connection failed" help text.
1969
+ return ERRORS.TypedError(HardwareErrorCode.PollingTimeout, BLE_SETUP_WEDGED_MESSAGE);
1751
1970
  }
1752
1971
 
1753
1972
  private getCachedTransport(uuid: string) {
@@ -1826,6 +2045,8 @@ export default class ReactNativeBleTransport {
1826
2045
  }
1827
2046
  this.deviceProtocol.delete(uuid);
1828
2047
  this.probingProtocols.delete(uuid);
2048
+ this.staleBondErrors.delete(uuid);
2049
+ this.acquiringProtocolV2.delete(uuid);
1829
2050
  this.protocolV2Assemblers.delete(uuid);
1830
2051
  this.resetProtocolV2Frames(uuid);
1831
2052
 
@@ -1842,13 +2063,45 @@ export default class ReactNativeBleTransport {
1842
2063
  private resetPlxManager() {
1843
2064
  const manager = this.blePlxManager;
1844
2065
  this.blePlxManager = undefined;
1845
- // Every cached transport belongs to the destroyed manager's peripherals.
1846
- Object.keys(transportCache).forEach(key => {
1847
- delete transportCache[key];
2066
+ const reason = 'React Native BLE manager reset';
2067
+ // Destroying the shared manager invalidates every peripheral it owns. Notify
2068
+ // each cached session before clearing generations so Core cannot retain a
2069
+ // silently stale connection for an unrelated device.
2070
+ Object.entries(transportCache).forEach(([uuid, cachedTransport]) => {
2071
+ try {
2072
+ cachedTransport.disconnectSubscription?.remove();
2073
+ } catch (error) {
2074
+ Log?.debug('BLE manager reset disconnect subscription removal failed:', error);
2075
+ }
2076
+ cachedTransport.disconnectSubscription = undefined;
2077
+ try {
2078
+ cachedTransport.notifySubscription?.remove();
2079
+ } catch (error) {
2080
+ Log?.debug('BLE manager reset notify subscription removal failed:', error);
2081
+ }
2082
+ cachedTransport.notifySubscription = undefined;
2083
+ this.rejectProtocolV2Frames(uuid, new Error(reason));
2084
+ try {
2085
+ this.emitDeviceDisconnect(
2086
+ uuid,
2087
+ cachedTransport.device?.name,
2088
+ cachedTransport.monitorToken ?? this.monitorTokens.get(uuid)
2089
+ );
2090
+ } catch (error) {
2091
+ Log?.debug('BLE manager reset disconnect event failed:', error);
2092
+ }
2093
+ delete transportCache[uuid];
2094
+ });
2095
+ this.protocolV2Links.invalidateAllLinks(reason).catch(error => {
2096
+ Log?.debug('[ReactNativeBleTransport] BLE manager link invalidation failed:', error);
1848
2097
  });
1849
2098
  this.deviceProtocol.clear();
1850
2099
  this.probingProtocols.clear();
2100
+ this.staleBondErrors.clear();
2101
+ this.acquiringProtocolV2.clear();
1851
2102
  this.sessionProtocols.clear();
2103
+ // Keep transport-lifetime V2 proof so the same endpoint can finish a no-probe
2104
+ // firmware reconnect after the native BLE manager is recreated.
1852
2105
  this.protocolReprobeFailures.clear();
1853
2106
  this.writeTimeoutCounts.clear();
1854
2107
  this.connectionSetupTimeoutCounts.clear();
@@ -1862,6 +2115,8 @@ export default class ReactNativeBleTransport {
1862
2115
  }
1863
2116
 
1864
2117
  private createProtocolMismatchError(expected: ProtocolType) {
2118
+ // A protocol probe miss alone does not prove that the OS bond is stale.
2119
+ // Native authentication/encryption failures are mapped separately.
1865
2120
  return ERRORS.TypedError(
1866
2121
  HardwareErrorCode.RuntimeError,
1867
2122
  `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
@@ -1895,34 +2150,35 @@ export default class ReactNativeBleTransport {
1895
2150
  protocolHint?: ProtocolType,
1896
2151
  rebuildTransport?: () => Promise<void>
1897
2152
  ): Promise<ProtocolType> {
1898
- if (Platform.OS === 'ios' && expectedProtocol) {
1899
- this.deviceProtocol.set(uuid, expectedProtocol);
2153
+ // A declared V1 is taken at face value on every platform, as iOS has done
2154
+ // since protocol probing arrived: the caller reads the protocol off its own
2155
+ // device record, so the probe re-asks a question that is already answered
2156
+ // and costs a round trip on every acquire. Expected V2 must still Ping so
2157
+ // USB-priority `link disabled` surfaces here instead of as a later unmapped
2158
+ // RuntimeError. sessionProtocols is deliberately NOT stamped here: that map
2159
+ // records protocols the device actually answered on (it gates the
2160
+ // trustSessionProtocol narrowing in forced detection), and this branch has
2161
+ // received no response. skipProtocolProbe does not need it either — its only
2162
+ // caller is the V2 firmware-install reconnect.
2163
+ if (expectedProtocol === 'V1') {
2164
+ this.deviceProtocol.set(uuid, 'V1');
1900
2165
  Log?.debug('[ReactNativeBleTransport] protocol selected', {
1901
2166
  deviceId: uuid,
1902
- protocol: expectedProtocol,
2167
+ protocol: 'V1',
1903
2168
  source: 'expected',
1904
2169
  });
1905
- return expectedProtocol;
2170
+ return 'V1';
1906
2171
  }
1907
2172
 
1908
- if (expectedProtocol === 'V1') {
1909
- if (await this.probeProtocolV1(uuid)) {
1910
- this.deviceProtocol.set(uuid, 'V1');
1911
- this.sessionProtocols.set(uuid, 'V1');
1912
- Log?.debug('[ReactNativeBleTransport] protocol detected', {
1913
- deviceId: uuid,
1914
- protocol: 'V1',
1915
- source: 'expected',
1916
- });
1917
- return 'V1';
1918
- }
1919
- throw this.createProtocolMismatchError(expectedProtocol);
2173
+ if (expectedProtocol === 'V2' || this.acquiringProtocolV2.has(uuid)) {
2174
+ this.throwIfStaleBondError(uuid);
1920
2175
  }
1921
2176
 
1922
2177
  if (expectedProtocol === 'V2') {
1923
2178
  if (await this.probeProtocolV2(uuid)) {
1924
2179
  this.deviceProtocol.set(uuid, 'V2');
1925
2180
  this.sessionProtocols.set(uuid, 'V2');
2181
+ this.confirmedProtocolV2.add(uuid);
1926
2182
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1927
2183
  deviceId: uuid,
1928
2184
  protocol: 'V2',
@@ -1965,6 +2221,9 @@ export default class ReactNativeBleTransport {
1965
2221
  if (detected) {
1966
2222
  this.deviceProtocol.set(uuid, protocol);
1967
2223
  this.sessionProtocols.set(uuid, protocol);
2224
+ if (protocol === 'V2') {
2225
+ this.confirmedProtocolV2.add(uuid);
2226
+ }
1968
2227
  this.protocolReprobeFailures.delete(uuid);
1969
2228
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1970
2229
  deviceId: uuid,
@@ -2071,6 +2330,7 @@ export default class ReactNativeBleTransport {
2071
2330
 
2072
2331
  this.probingProtocols.set(uuid, 'V2');
2073
2332
  this.protocolV2Assemblers.get(uuid)?.reset();
2333
+ this.throwIfStaleBondError(uuid);
2074
2334
  const detected = await probeProtocolV2Helper({
2075
2335
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
2076
2336
  this.callProtocolV2(uuid, name, data, options),
@@ -2081,6 +2341,7 @@ export default class ReactNativeBleTransport {
2081
2341
  this.protocolV2Assemblers.get(uuid)?.reset();
2082
2342
  this.resetProtocolV2Frames(uuid);
2083
2343
  },
2344
+ shouldRethrow: isBleStaleBondHardwareError,
2084
2345
  });
2085
2346
  if (!detected) {
2086
2347
  this.clearProbeProtocol(uuid, 'V2');
@@ -2149,6 +2410,21 @@ export default class ReactNativeBleTransport {
2149
2410
  }
2150
2411
  }
2151
2412
 
2413
+ private rememberStaleBondError(uuid: string, error: Error) {
2414
+ this.staleBondErrors.set(uuid, error);
2415
+ this.rejectProtocolV2Frames(uuid, error);
2416
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
2417
+ this.runPromise.reject(error);
2418
+ }
2419
+ }
2420
+
2421
+ private throwIfStaleBondError(uuid: string) {
2422
+ const error = this.staleBondErrors.get(uuid);
2423
+ if (error) {
2424
+ throw error;
2425
+ }
2426
+ }
2427
+
2152
2428
  private async readProtocolV2Frame(uuid: string) {
2153
2429
  const queuedFrame = this.getProtocolV2FrameQueue(uuid).shift();
2154
2430
  if (queuedFrame) {
@@ -2207,6 +2483,11 @@ export default class ReactNativeBleTransport {
2207
2483
  assertCurrentGeneration();
2208
2484
  return;
2209
2485
  } catch (error) {
2486
+ if (isNativeBleStaleBondError(error) || isBleStaleBondHardwareError(error)) {
2487
+ const bondError = toBleStaleBondHardwareError(error);
2488
+ this.rememberStaleBondError(uuid, bondError);
2489
+ throw bondError;
2490
+ }
2210
2491
  if (
2211
2492
  getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2212
2493
  attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
@@ -2443,6 +2724,7 @@ export default class ReactNativeBleTransport {
2443
2724
  return rxFrame;
2444
2725
  },
2445
2726
  reset: (reason: string) => {
2727
+ if (this.monitorTokens.get(uuid) !== generation) return;
2446
2728
  this.protocolV2Assemblers.get(uuid)?.reset();
2447
2729
  this.rejectProtocolV2Frames(uuid, new Error(reason));
2448
2730
  },