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

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
@@ -5,7 +5,6 @@ import {
5
5
  BleError,
6
6
  BleErrorCode,
7
7
  BleManager as BlePlxManager,
8
- ConnectionPriority,
9
8
  ScanMode,
10
9
  } from 'react-native-ble-plx';
11
10
  import ByteBuffer from 'bytebuffer';
@@ -33,17 +32,11 @@ import {
33
32
  } from '@onekeyfe/hd-shared';
34
33
 
35
34
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
36
- import {
37
- hasWritableCapability,
38
- resolveProtocolV2PacketCapacity,
39
- shouldWriteProtocolV2WithResponse,
40
- } from './bleStrategy';
35
+ import { hasWritableCapability, resolveProtocolV2PacketCapacity } from './bleStrategy';
41
36
  import { subscribeBleOn } from './subscribeBleOn';
42
37
  import {
43
38
  ANDROID_PACKET_LENGTH,
44
- ANDROID_PROTOCOL_V2_PACKET_LENGTH,
45
39
  IOS_PACKET_LENGTH,
46
- IOS_PROTOCOL_V2_PACKET_LENGTH,
47
40
  getBluetoothServiceUuids,
48
41
  getInfosForServiceUuid,
49
42
  isSameBleUuid,
@@ -68,6 +61,7 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
68
61
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
69
62
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
70
63
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
64
+ const IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS = 5;
71
65
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
72
66
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
73
67
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
@@ -141,6 +135,22 @@ const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, max
141
135
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
142
136
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
143
137
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
138
+ /**
139
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
140
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
141
+ * stops reporting ready while staying connected, so the write promise never settles.
142
+ * Response timeouts cannot cover that — they are armed after the writes complete —
143
+ * and an unbounded write leaves the whole transport unusable until the process dies.
144
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
145
+ */
146
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
147
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
148
+ const isWedgedWriteError = (error: unknown): boolean =>
149
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
150
+ typeof (error as { message?: unknown })?.message === 'string' &&
151
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
152
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
153
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
144
154
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
145
155
  const IOS_NOTIFY_READY_DELAY_MS = 150;
146
156
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -152,8 +162,8 @@ export type ProtocolV2BleTuning = {
152
162
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
153
163
 
154
164
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
155
- iosPacketLength: IOS_PROTOCOL_V2_PACKET_LENGTH,
156
- androidPacketLength: ANDROID_PROTOCOL_V2_PACKET_LENGTH,
165
+ iosPacketLength: IOS_PACKET_LENGTH,
166
+ androidPacketLength: ANDROID_PACKET_LENGTH,
157
167
  };
158
168
 
159
169
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -195,21 +205,54 @@ function getDeviceDisplayName(device?: Device | null) {
195
205
  return device?.name || device?.localName || null;
196
206
  }
197
207
 
198
- const IOS_REQUEST_MTU = 247;
199
- const ANDROID_REQUEST_MTU = 517;
200
- const BLE_MTU_REFRESH_THRESHOLD = 247;
201
- const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
202
- const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
208
+ const ANDROID_REQUEST_MTU = 256;
203
209
 
204
- const getRequestedBleMtu = () =>
205
- Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
210
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
206
211
 
207
212
  const connectOptions: Record<string, unknown> = {
208
- requestMTU: getRequestedBleMtu(),
209
- timeout: 3000,
213
+ requestMTU: ANDROID_REQUEST_MTU,
214
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
210
215
  refreshGatt: 'OnConnected',
211
216
  };
212
217
 
218
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
219
+ const fallbackConnectOptions: Record<string, unknown> = {
220
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
221
+ };
222
+
223
+ /**
224
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
225
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
226
+ * firmware install tears the link down) can leave the promise unsettled — observed
227
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
228
+ * inside the native budget, so this only fires when the native timeout did not.
229
+ */
230
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
231
+ /**
232
+ * Service discovery and characteristic resolution run after connect() succeeds, but
233
+ * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
234
+ * device reboot, these calls can remain pending forever unless they have their own
235
+ * budget.
236
+ */
237
+ export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
238
+ /**
239
+ * How many times a known device may fail its own protocol before we probe the others
240
+ * again. Reconnect polling during a device reboot repeats this every few seconds, and
241
+ * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
242
+ * device we just spoke V1 to dominates the wait. A firmware update can legitimately
243
+ * change a device's protocol, so the shortcut has to expire rather than stick.
244
+ */
245
+ export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
246
+ /** BLE setup timeouts since the last successful setup before the manager is recreated. */
247
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
248
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
249
+ const isConnectTimeoutError = (error: unknown): boolean =>
250
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
251
+ typeof (error as { message?: unknown })?.message === 'string' &&
252
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
253
+ const isNativeOperationTimeoutError = (error: unknown): boolean =>
254
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
255
+
213
256
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
214
257
 
215
258
  const tryToGetConfiguration = (device: Device) => {
@@ -221,32 +264,23 @@ const tryToGetConfiguration = (device: Device) => {
221
264
  return infos;
222
265
  };
223
266
 
224
- const requestNegotiatedMtu = async (
225
- device: Device,
226
- stage: 'connected' | 'servicesAndNotifyReady',
227
- attempt: number
228
- ) => {
229
- if (Platform.OS !== 'ios' && Platform.OS !== 'android') return device;
267
+ const requestAndroidMtu = async (device: Device) => {
268
+ if (Platform.OS !== 'android') return device;
230
269
 
231
270
  try {
232
- // iOS ignores the requested value but react-native-ble-plx returns a fresh
233
- // Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
234
- const mtuDevice = await device.requestMTU(getRequestedBleMtu());
271
+ const mtuDevice = await device.requestMTU(ANDROID_REQUEST_MTU);
272
+ Log?.debug('[ReactNativeBleTransport] MTU configured', {
273
+ deviceId: device.id,
274
+ requested: ANDROID_REQUEST_MTU,
275
+ actual: mtuDevice.mtu,
276
+ });
235
277
  return mtuDevice;
236
278
  } catch (error) {
237
- Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
238
- platform: Platform.OS,
239
- stage,
240
- attempt,
241
- actual: device.mtu,
242
- error: error instanceof Error ? error.message : String(error),
243
- });
279
+ Log?.debug('[ReactNativeBleTransport] Android MTU request failed:', error);
244
280
  return device;
245
281
  }
246
282
  };
247
283
 
248
- const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'connected', 0);
249
-
250
284
  type IOBleErrorRemap = Error | BleError | null | undefined;
251
285
 
252
286
  function remapError(error: IOBleErrorRemap) {
@@ -307,8 +341,28 @@ export default class ReactNativeBleTransport {
307
341
  /** Per-device protocol type detected by active wire-level probe after connect. */
308
342
  private deviceProtocol: Map<string, ProtocolType> = new Map();
309
343
 
344
+ /**
345
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
346
+ * must route with it, but acquire() must not treat it as a detected protocol: a
347
+ * probe that never answers would otherwise leave the reuse fast path handing out a
348
+ * transport that was never validated.
349
+ */
350
+ private probingProtocols: Map<string, ProtocolType> = new Map();
351
+
352
+ /** Consecutive write timeouts per device; reset by any write that completes. */
353
+ private writeTimeoutCounts: Map<string, number> = new Map();
354
+
355
+ /** BLE setup timeouts per device since the last complete characteristic resolution. */
356
+ private connectionSetupTimeoutCounts: Map<string, number> = new Map();
357
+
310
358
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
311
359
 
360
+ /** Protocol this device actually answered on, kept across reconnects of one session. */
361
+ private sessionProtocols: Map<string, ProtocolType> = new Map();
362
+
363
+ /** Consecutive detections that failed while trusting sessionProtocols. */
364
+ private protocolReprobeFailures: Map<string, number> = new Map();
365
+
312
366
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
313
367
 
314
368
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -340,12 +394,6 @@ export default class ReactNativeBleTransport {
340
394
 
341
395
  private disconnectEventTokens: Map<string, number> = new Map();
342
396
 
343
- private protocolV2HighVolumeLogSignatures: Map<string, Set<string>> = new Map();
344
-
345
- private androidHighPriorityDevices: Set<string> = new Set();
346
-
347
- private androidPriorityResetTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
348
-
349
397
  private nextMonitorToken = 1;
350
398
 
351
399
  constructor(options: TransportOptions) {
@@ -532,22 +580,21 @@ export default class ReactNativeBleTransport {
532
580
  const isConnected = await device.isConnected().catch(() => false);
533
581
  if (!isConnected) {
534
582
  try {
535
- device = await device.connect(connectOptions);
583
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
536
584
  } catch (e) {
537
585
  if (
538
586
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
539
587
  e.errorCode === BleErrorCode.OperationCancelled
540
588
  ) {
541
- device = await device.connect();
589
+ device = await this.connectWithTimeout(uuid, () => device.connect());
542
590
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
543
591
  throw e;
544
592
  }
545
593
  }
546
594
  }
547
595
 
548
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
549
- device
550
- );
596
+ const { writeCharacteristic, notifyCharacteristic } =
597
+ await this.resolveCharacteristicsWithTimeout(uuid, device);
551
598
 
552
599
  transport.device = device;
553
600
  transport.writeCharacteristic = writeCharacteristic;
@@ -726,9 +773,11 @@ export default class ReactNativeBleTransport {
726
773
  characteristics?: ResolvedBleCharacteristics
727
774
  ) {
728
775
  const { writeCharacteristic, notifyCharacteristic } =
729
- characteristics ?? (await this.resolveCharacteristics(device));
776
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
730
777
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
731
- transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
778
+ if (Platform.OS === 'android') {
779
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
780
+ }
732
781
  const monitorToken = this.nextMonitorToken;
733
782
  this.nextMonitorToken += 1;
734
783
  const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
@@ -742,7 +791,6 @@ export default class ReactNativeBleTransport {
742
791
  notifyTransactionId
743
792
  );
744
793
  transportCache[uuid] = transport;
745
- this.protocolV2HighVolumeLogSignatures.set(uuid, new Set());
746
794
  this.protocolV2Assemblers.set(
747
795
  uuid,
748
796
  new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
@@ -756,40 +804,6 @@ export default class ReactNativeBleTransport {
756
804
  await delay(ANDROID_NOTIFY_READY_DELAY_MS);
757
805
  }
758
806
 
759
- const initialMtu = transport.mtuSize;
760
- let refreshAttempts = 0;
761
- if (
762
- (Platform.OS === 'ios' || Platform.OS === 'android') &&
763
- (typeof transport.mtuSize !== 'number' || transport.mtuSize < BLE_MTU_REFRESH_THRESHOLD)
764
- ) {
765
- refreshAttempts += 1;
766
- let refreshedDevice = await requestNegotiatedMtu(
767
- transport.device,
768
- 'servicesAndNotifyReady',
769
- 1
770
- );
771
- transport.device = refreshedDevice;
772
- transport.mtuSize =
773
- typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
774
-
775
- if (typeof transport.mtuSize !== 'number' || transport.mtuSize < BLE_MTU_REFRESH_THRESHOLD) {
776
- await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
777
- refreshAttempts += 1;
778
- refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
779
- transport.device = refreshedDevice;
780
- transport.mtuSize =
781
- typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
782
- }
783
- }
784
-
785
- Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
786
- platform: Platform.OS,
787
- requested: getRequestedBleMtu(),
788
- initial: initialMtu,
789
- actual: transport.mtuSize,
790
- refreshAttempts,
791
- });
792
-
793
807
  return transport;
794
808
  }
795
809
 
@@ -863,15 +877,22 @@ export default class ReactNativeBleTransport {
863
877
  if (!device) {
864
878
  Log?.debug('try to connect to device: ', uuid);
865
879
  try {
866
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
880
+ device = await this.connectWithTimeout(uuid, () =>
881
+ blePlxManager.connectToDevice(uuid, connectOptions)
882
+ );
867
883
  } catch (e) {
868
884
  Log?.debug('try to connect to device has error: ', e);
885
+ if (isConnectTimeoutError(e)) {
886
+ throw e;
887
+ }
869
888
  if (
870
889
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
871
890
  e.errorCode === BleErrorCode.OperationCancelled
872
891
  ) {
873
892
  Log?.debug('first try to reconnect without params');
874
- device = await blePlxManager.connectToDevice(uuid);
893
+ device = await this.connectWithTimeout(uuid, () =>
894
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
895
+ );
875
896
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
876
897
  Log?.debug('device already connected');
877
898
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -887,26 +908,36 @@ export default class ReactNativeBleTransport {
887
908
 
888
909
  if (!(await device.isConnected())) {
889
910
  Log?.debug('not connected, try to connect to device: ', uuid);
911
+ const disconnectedDevice = device;
890
912
 
891
913
  try {
892
- device = await device.connect(connectOptions);
914
+ device = await this.connectWithTimeout(uuid, () =>
915
+ disconnectedDevice.connect(connectOptions)
916
+ );
893
917
  } catch (e) {
894
918
  Log?.debug('not connected, try to connect to device has error: ', e);
919
+ if (isConnectTimeoutError(e)) {
920
+ throw e;
921
+ }
895
922
  if (
896
923
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
897
924
  e.errorCode === BleErrorCode.OperationCancelled
898
925
  ) {
899
926
  Log?.debug('second try to reconnect without params');
900
927
  try {
901
- device = await device.connect();
928
+ device = await this.connectWithTimeout(uuid, () =>
929
+ disconnectedDevice.connect(fallbackConnectOptions)
930
+ );
902
931
  } catch (e) {
903
932
  Log?.debug('last try to reconnect error: ', e);
904
933
  // last try to reconnect device if this issue exists
905
934
  // https://github.com/dotintent/react-native-ble-plx/issues/426
906
935
  if (e.errorCode === BleErrorCode.OperationCancelled) {
907
936
  Log?.debug('last try to reconnect');
908
- await device.cancelConnection();
909
- device = await device.connect();
937
+ await disconnectedDevice.cancelConnection();
938
+ device = await this.connectWithTimeout(uuid, () =>
939
+ disconnectedDevice.connect(fallbackConnectOptions)
940
+ );
910
941
  }
911
942
  }
912
943
  } else {
@@ -915,11 +946,10 @@ export default class ReactNativeBleTransport {
915
946
  }
916
947
  }
917
948
 
918
- device = await resolveNegotiatedMtu(device);
949
+ device = await requestAndroidMtu(device);
919
950
  const acquiredDevice = device;
920
- const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
921
- acquiredDevice
922
- );
951
+ const { writeCharacteristic, notifyCharacteristic } =
952
+ await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
923
953
 
924
954
  const protocolHint = expectedProtocol
925
955
  ? undefined
@@ -951,7 +981,7 @@ export default class ReactNativeBleTransport {
951
981
  if (!currentTransport) {
952
982
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
953
983
  }
954
- this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
984
+ this.attachDisconnectSubscription(currentTransport, acquiredDevice, uuid);
955
985
  return { uuid, protocolType };
956
986
  } catch (error) {
957
987
  await this.release(uuid, true);
@@ -983,7 +1013,7 @@ export default class ReactNativeBleTransport {
983
1013
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
984
1014
  return;
985
1015
  }
986
- if (this.deviceProtocol.get(uuid) === 'V2') {
1016
+ if (this.getActiveProtocol(uuid) === 'V2') {
987
1017
  let errorCode:
988
1018
  | typeof HardwareErrorCode.BleDeviceBondError
989
1019
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1053,7 +1083,7 @@ export default class ReactNativeBleTransport {
1053
1083
 
1054
1084
  try {
1055
1085
  const data = Buffer.from(c.value as string, 'base64');
1056
- const protocol = this.deviceProtocol.get(uuid);
1086
+ const protocol = this.getActiveProtocol(uuid);
1057
1087
  if (!protocol) {
1058
1088
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
1059
1089
  return;
@@ -1087,7 +1117,7 @@ export default class ReactNativeBleTransport {
1087
1117
  } catch (error) {
1088
1118
  Log?.debug('monitor data error: ', error);
1089
1119
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1090
- if (this.deviceProtocol.get(uuid) === 'V2') {
1120
+ if (this.getActiveProtocol(uuid) === 'V2') {
1091
1121
  this.rejectProtocolV2Frames(uuid, notifyError);
1092
1122
  } else if (this.runPromiseDeviceId === uuid) {
1093
1123
  this.runPromise?.reject(notifyError);
@@ -1121,8 +1151,6 @@ export default class ReactNativeBleTransport {
1121
1151
  return Promise.resolve(true);
1122
1152
  }
1123
1153
 
1124
- await this.restoreAndroidConnectionPriority(uuid, transport);
1125
-
1126
1154
  if (transport) {
1127
1155
  if (this.monitorTokens.get(uuid) === transport.monitorToken) {
1128
1156
  this.monitorTokens.delete(uuid);
@@ -1152,9 +1180,8 @@ export default class ReactNativeBleTransport {
1152
1180
  delete transportCache[uuid];
1153
1181
  }
1154
1182
 
1155
- this.protocolV2HighVolumeLogSignatures.delete(uuid);
1156
-
1157
1183
  this.deviceProtocol.delete(uuid);
1184
+ this.probingProtocols.delete(uuid);
1158
1185
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1159
1186
  this.protocolV2Assemblers.get(uuid)?.reset();
1160
1187
  this.protocolV2Assemblers.delete(uuid);
@@ -1221,8 +1248,25 @@ export default class ReactNativeBleTransport {
1221
1248
  const transport = this.getCachedTransport(uuid);
1222
1249
  const runPromise = createDeferred<string>();
1223
1250
  runPromise.promise.catch(() => undefined);
1251
+ const supersededRunPromise = this.runPromise;
1252
+ if (supersededRunPromise) {
1253
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1254
+ // the superseded deferred now so its response race resolves and its finally block
1255
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1256
+ // tear down the shared connection while another call is using it.
1257
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1258
+ }
1224
1259
  this.runPromise = runPromise;
1225
1260
  this.runPromiseDeviceId = uuid;
1261
+ // A superseded call's late write failure must not clear the successor's ownership;
1262
+ // only the call that still owns the slot may release it.
1263
+ const releaseOwnershipIfCurrent = () => {
1264
+ if (this.runPromise === runPromise) {
1265
+ this.runPromise = null;
1266
+ this.runPromiseDeviceId = null;
1267
+ }
1268
+ };
1269
+ const isCurrentOwner = () => this.runPromise === runPromise;
1226
1270
  const messages = this._messages;
1227
1271
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1228
1272
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1248,6 +1292,9 @@ export default class ReactNativeBleTransport {
1248
1292
  chunk = ByteBuffer.allocate(packetCapacity);
1249
1293
  } catch (e) {
1250
1294
  onError(e);
1295
+ if (isWedgedWriteError(e)) {
1296
+ throw e;
1297
+ }
1251
1298
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1252
1299
  }
1253
1300
  }
@@ -1279,6 +1326,9 @@ export default class ReactNativeBleTransport {
1279
1326
  }
1280
1327
  } catch (e) {
1281
1328
  onError(e);
1329
+ if (isWedgedWriteError(e)) {
1330
+ throw e;
1331
+ }
1282
1332
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1283
1333
  }
1284
1334
  }
@@ -1292,9 +1342,15 @@ export default class ReactNativeBleTransport {
1292
1342
  if (name === 'EmmcFileWrite') {
1293
1343
  await writeChunkedData(
1294
1344
  buffers,
1295
- data => transport.writeWithRetry(data),
1345
+ data =>
1346
+ this.writeBlePacket(
1347
+ uuid,
1348
+ data,
1349
+ payload => transport.writeWithRetry(payload),
1350
+ isCurrentOwner
1351
+ ),
1296
1352
  e => {
1297
- this.runPromise = null;
1353
+ releaseOwnershipIfCurrent();
1298
1354
  Log?.error('writeCharacteristic write error: ', e);
1299
1355
  }
1300
1356
  );
@@ -1315,7 +1371,12 @@ export default class ReactNativeBleTransport {
1315
1371
  // eslint-disable-next-line no-constant-condition
1316
1372
  while (true) {
1317
1373
  try {
1318
- await transport.writeWithRetry(data);
1374
+ await this.writeBlePacket(
1375
+ uuid,
1376
+ data,
1377
+ payload => transport.writeWithRetry(payload),
1378
+ isCurrentOwner
1379
+ );
1319
1380
  return;
1320
1381
  } catch (error) {
1321
1382
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1334,7 +1395,7 @@ export default class ReactNativeBleTransport {
1334
1395
  }
1335
1396
  },
1336
1397
  e => {
1337
- this.runPromise = null;
1398
+ releaseOwnershipIfCurrent();
1338
1399
  Log?.error('writeCharacteristic write error: ', e);
1339
1400
  }
1340
1401
  );
@@ -1345,14 +1406,21 @@ export default class ReactNativeBleTransport {
1345
1406
  try {
1346
1407
  const shouldUseWriteWithResponse =
1347
1408
  Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1348
- if (shouldUseWriteWithResponse) {
1349
- await transport.writeCharacteristic.writeWithResponse(outData);
1350
- } else {
1351
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1352
- }
1409
+ await this.writeBlePacket(
1410
+ uuid,
1411
+ outData,
1412
+ payload =>
1413
+ shouldUseWriteWithResponse
1414
+ ? transport.writeCharacteristic.writeWithResponse(payload)
1415
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
1416
+ isCurrentOwner
1417
+ );
1353
1418
  } catch (e) {
1354
1419
  Log?.debug('writeCharacteristic write error: ', e);
1355
- this.runPromise = null;
1420
+ releaseOwnershipIfCurrent();
1421
+ if (isWedgedWriteError(e)) {
1422
+ throw e;
1423
+ }
1356
1424
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1357
1425
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1358
1426
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1395,8 +1463,13 @@ export default class ReactNativeBleTransport {
1395
1463
  }
1396
1464
  const isProbeTimeout =
1397
1465
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1466
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1467
+ // transport; its late timeout must not tear down the connection the current
1468
+ // call is actively using.
1469
+ const isStaleCall = this.runPromise !== runPromise;
1398
1470
  if (
1399
1471
  !isProbeTimeout &&
1472
+ !isStaleCall &&
1400
1473
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1401
1474
  ) {
1402
1475
  await this.disconnect(uuid);
@@ -1476,7 +1549,10 @@ export default class ReactNativeBleTransport {
1476
1549
  delete transportCache[session];
1477
1550
  }
1478
1551
  this.deviceProtocol.delete(session);
1552
+ this.probingProtocols.delete(session);
1479
1553
  this.deviceProtocolHints.delete(session);
1554
+ this.sessionProtocols.delete(session);
1555
+ this.protocolReprobeFailures.delete(session);
1480
1556
  this.protocolV2Assemblers.delete(session);
1481
1557
  this.resetProtocolV2Frames(session);
1482
1558
 
@@ -1502,6 +1578,113 @@ export default class ReactNativeBleTransport {
1502
1578
  this.runPromiseDeviceId = null;
1503
1579
  }
1504
1580
 
1581
+ /** Run a native connect under the JS backstop budget. */
1582
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1583
+ let timer: ReturnType<typeof setTimeout> | undefined;
1584
+ let timedOut = false;
1585
+ const pending = connect();
1586
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1587
+ // surface as an unhandled rejection after we have already given up on it.
1588
+ pending.catch(() => undefined);
1589
+ try {
1590
+ const result = await Promise.race([
1591
+ pending,
1592
+ new Promise<never>((_, reject) => {
1593
+ timer = setTimeout(() => {
1594
+ timedOut = true;
1595
+ reject(
1596
+ ERRORS.TypedError(
1597
+ HardwareErrorCode.BleConnectedError,
1598
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1599
+ )
1600
+ );
1601
+ }, BLE_CONNECT_TIMEOUT_MS);
1602
+ }),
1603
+ ]);
1604
+ return result;
1605
+ } catch (error) {
1606
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1607
+ this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1608
+ }
1609
+ throw error;
1610
+ } finally {
1611
+ if (timer) clearTimeout(timer);
1612
+ }
1613
+ }
1614
+
1615
+ /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1616
+ private async resolveCharacteristicsWithTimeout(
1617
+ uuid: string,
1618
+ device: Device
1619
+ ): Promise<ResolvedBleCharacteristics> {
1620
+ let timer: ReturnType<typeof setTimeout> | undefined;
1621
+ let timedOut = false;
1622
+ const pending = this.resolveCharacteristics(device);
1623
+ pending.catch(() => undefined);
1624
+ try {
1625
+ const result = await Promise.race([
1626
+ pending,
1627
+ new Promise<never>((_, reject) => {
1628
+ timer = setTimeout(() => {
1629
+ timedOut = true;
1630
+ reject(
1631
+ ERRORS.TypedError(
1632
+ HardwareErrorCode.BleConnectedError,
1633
+ `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1634
+ )
1635
+ );
1636
+ }, BLE_GATT_SETUP_TIMEOUT_MS);
1637
+ }),
1638
+ ]);
1639
+ this.connectionSetupTimeoutCounts.delete(uuid);
1640
+ return result;
1641
+ } catch (error) {
1642
+ if (timedOut || isNativeOperationTimeoutError(error)) {
1643
+ this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1644
+ }
1645
+ throw error;
1646
+ } finally {
1647
+ if (timer) clearTimeout(timer);
1648
+ }
1649
+ }
1650
+
1651
+ /**
1652
+ * Give up on a BLE setup operation the native layer did not settle. The abandoned
1653
+ * operation still owns native connection/GATT state that can poison the next attempt,
1654
+ * so it is cleared here without awaiting the same queue that stopped responding.
1655
+ */
1656
+ private abandonStalledConnection(
1657
+ uuid: string,
1658
+ stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1659
+ ) {
1660
+ const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1661
+ this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1662
+ Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1663
+ stage,
1664
+ setupTimeoutsSinceSuccess: timeouts,
1665
+ });
1666
+
1667
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1668
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1669
+ });
1670
+ const stalled = transportCache[uuid];
1671
+ if (stalled) {
1672
+ delete transportCache[uuid];
1673
+ }
1674
+ this.deviceProtocol.delete(uuid);
1675
+ this.probingProtocols.delete(uuid);
1676
+ this.protocolV2Assemblers.delete(uuid);
1677
+ this.resetProtocolV2Frames(uuid);
1678
+
1679
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1680
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1681
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1682
+ Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1683
+ this.resetPlxManager();
1684
+ this.connectionSetupTimeoutCounts.delete(uuid);
1685
+ }
1686
+ }
1687
+
1505
1688
  private getCachedTransport(uuid: string) {
1506
1689
  const transport = transportCache[uuid];
1507
1690
  if (!transport) {
@@ -1510,6 +1693,107 @@ export default class ReactNativeBleTransport {
1510
1693
  return transport;
1511
1694
  }
1512
1695
 
1696
+ /**
1697
+ * Write one packet under a bounded budget. A write that never settles means the
1698
+ * peripheral is wedged even though the GATT link still reports connected, so the
1699
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1700
+ * cached and every later call would hang on it again.
1701
+ */
1702
+ private async writeBlePacket(
1703
+ uuid: string,
1704
+ data: string,
1705
+ write: (payload: string) => Promise<unknown>,
1706
+ isCurrentOwner?: () => boolean
1707
+ ) {
1708
+ let timer: ReturnType<typeof setTimeout> | undefined;
1709
+ let timedOut = false;
1710
+ try {
1711
+ await Promise.race([
1712
+ write(data),
1713
+ new Promise<never>((_, reject) => {
1714
+ timer = setTimeout(() => {
1715
+ timedOut = true;
1716
+ reject(
1717
+ ERRORS.TypedError(
1718
+ HardwareErrorCode.BleWriteCharacteristicError,
1719
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1720
+ )
1721
+ );
1722
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1723
+ }),
1724
+ ]);
1725
+ this.writeTimeoutCounts.delete(uuid);
1726
+ } catch (error) {
1727
+ if (timedOut) {
1728
+ // A superseded call's late write must not tear down the link the current
1729
+ // call is using; only the owner of the transport may declare it dead.
1730
+ if (isCurrentOwner && !isCurrentOwner()) {
1731
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1732
+ } else {
1733
+ this.tearDownWedgedLink(uuid);
1734
+ }
1735
+ }
1736
+ throw error;
1737
+ } finally {
1738
+ if (timer) clearTimeout(timer);
1739
+ }
1740
+ }
1741
+
1742
+ /**
1743
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1744
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1745
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1746
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1747
+ */
1748
+ private tearDownWedgedLink(uuid: string) {
1749
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1750
+ this.writeTimeoutCounts.set(uuid, timeouts);
1751
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1752
+ consecutiveWriteTimeouts: timeouts,
1753
+ });
1754
+
1755
+ const wedged = transportCache[uuid];
1756
+ this.disconnect(uuid).catch(error => {
1757
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1758
+ });
1759
+ if (wedged && transportCache[uuid] === wedged) {
1760
+ delete transportCache[uuid];
1761
+ }
1762
+ this.deviceProtocol.delete(uuid);
1763
+ this.probingProtocols.delete(uuid);
1764
+ this.protocolV2Assemblers.delete(uuid);
1765
+ this.resetProtocolV2Frames(uuid);
1766
+
1767
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1768
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1769
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1770
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1771
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1772
+ this.resetPlxManager();
1773
+ this.writeTimeoutCounts.delete(uuid);
1774
+ }
1775
+ }
1776
+
1777
+ private resetPlxManager() {
1778
+ const manager = this.blePlxManager;
1779
+ this.blePlxManager = undefined;
1780
+ // Every cached transport belongs to the destroyed manager's peripherals.
1781
+ Object.keys(transportCache).forEach(key => {
1782
+ delete transportCache[key];
1783
+ });
1784
+ this.deviceProtocol.clear();
1785
+ this.probingProtocols.clear();
1786
+ this.sessionProtocols.clear();
1787
+ this.protocolReprobeFailures.clear();
1788
+ this.monitorTokens.clear();
1789
+ this.protocolV2Assemblers.clear();
1790
+ try {
1791
+ manager?.destroy();
1792
+ } catch (error) {
1793
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1794
+ }
1795
+ }
1796
+
1513
1797
  private createProtocolMismatchError(expected: ProtocolType) {
1514
1798
  return ERRORS.TypedError(
1515
1799
  HardwareErrorCode.RuntimeError,
@@ -1525,11 +1809,19 @@ export default class ReactNativeBleTransport {
1525
1809
  }
1526
1810
 
1527
1811
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1812
+ if (this.probingProtocols.get(uuid) === protocol) {
1813
+ this.probingProtocols.delete(uuid);
1814
+ }
1528
1815
  if (this.deviceProtocol.get(uuid) === protocol) {
1529
1816
  this.deviceProtocol.delete(uuid);
1530
1817
  }
1531
1818
  }
1532
1819
 
1820
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1821
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1822
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1823
+ }
1824
+
1533
1825
  private async detectProtocol(
1534
1826
  uuid: string,
1535
1827
  expectedProtocol?: ProtocolType,
@@ -1549,6 +1841,7 @@ export default class ReactNativeBleTransport {
1549
1841
  if (expectedProtocol === 'V1') {
1550
1842
  if (await this.probeProtocolV1(uuid)) {
1551
1843
  this.deviceProtocol.set(uuid, 'V1');
1844
+ this.sessionProtocols.set(uuid, 'V1');
1552
1845
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1553
1846
  deviceId: uuid,
1554
1847
  protocol: 'V1',
@@ -1562,6 +1855,7 @@ export default class ReactNativeBleTransport {
1562
1855
  if (expectedProtocol === 'V2') {
1563
1856
  if (await this.probeProtocolV2(uuid)) {
1564
1857
  this.deviceProtocol.set(uuid, 'V2');
1858
+ this.sessionProtocols.set(uuid, 'V2');
1565
1859
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1566
1860
  deviceId: uuid,
1567
1861
  protocol: 'V2',
@@ -1574,8 +1868,18 @@ export default class ReactNativeBleTransport {
1574
1868
 
1575
1869
  // Protocol must be actively probed after connection. Name, PID, and descriptors only
1576
1870
  // influence probe order; a V2 hint probes V2 first and falls back to V1.
1577
- const probeOrder: ProtocolType[] =
1871
+ const sessionProtocol = this.sessionProtocols.get(uuid);
1872
+ const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1873
+ const fullProbeOrder: ProtocolType[] =
1578
1874
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1875
+ // A device that already answered on a protocol in this session keeps answering on
1876
+ // it; while it is rebooting nothing answers at all, so probing the other protocol
1877
+ // only adds its timeout to every poll.
1878
+ const trustSessionProtocol =
1879
+ sessionProtocol !== undefined &&
1880
+ !protocolHint &&
1881
+ reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1882
+ const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1579
1883
 
1580
1884
  for (let i = 0; i < probeOrder.length; i += 1) {
1581
1885
  const protocol = probeOrder[i];
@@ -1593,6 +1897,8 @@ export default class ReactNativeBleTransport {
1593
1897
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1594
1898
  if (detected) {
1595
1899
  this.deviceProtocol.set(uuid, protocol);
1900
+ this.sessionProtocols.set(uuid, protocol);
1901
+ this.protocolReprobeFailures.delete(uuid);
1596
1902
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1597
1903
  deviceId: uuid,
1598
1904
  protocol,
@@ -1602,7 +1908,16 @@ export default class ReactNativeBleTransport {
1602
1908
  }
1603
1909
  }
1604
1910
 
1911
+ if (trustSessionProtocol) {
1912
+ // Still silent on its own protocol: count it, and let the streak expire the
1913
+ // shortcut so a device that genuinely switched protocols is found again.
1914
+ this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1915
+ } else {
1916
+ this.protocolReprobeFailures.delete(uuid);
1917
+ }
1918
+
1605
1919
  this.deviceProtocol.delete(uuid);
1920
+ this.probingProtocols.delete(uuid);
1606
1921
  throw this.createProtocolDetectionError();
1607
1922
  }
1608
1923
 
@@ -1664,14 +1979,20 @@ export default class ReactNativeBleTransport {
1664
1979
  }
1665
1980
 
1666
1981
  try {
1667
- this.deviceProtocol.set(uuid, 'V1');
1982
+ this.probingProtocols.set(uuid, 'V1');
1668
1983
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1669
1984
  // session before Core has a chance to restore a hidden wallet.
1670
1985
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1986
+ this.probingProtocols.delete(uuid);
1671
1987
  return true;
1672
1988
  } catch (error) {
1673
1989
  this.clearProbeProtocol(uuid, 'V1');
1674
1990
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1991
+ // A wedged write already dropped the link, so probing another protocol on it
1992
+ // would only fail against a torn-down transport: surface the real cause.
1993
+ if (isWedgedWriteError(error)) {
1994
+ throw error;
1995
+ }
1675
1996
  return false;
1676
1997
  }
1677
1998
  }
@@ -1681,7 +2002,7 @@ export default class ReactNativeBleTransport {
1681
2002
  return false;
1682
2003
  }
1683
2004
 
1684
- this.deviceProtocol.set(uuid, 'V2');
2005
+ this.probingProtocols.set(uuid, 'V2');
1685
2006
  this.protocolV2Assemblers.get(uuid)?.reset();
1686
2007
  const detected = await probeProtocolV2Helper({
1687
2008
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1696,6 +2017,8 @@ export default class ReactNativeBleTransport {
1696
2017
  });
1697
2018
  if (!detected) {
1698
2019
  this.clearProbeProtocol(uuid, 'V2');
2020
+ } else {
2021
+ this.probingProtocols.delete(uuid);
1699
2022
  }
1700
2023
  return detected;
1701
2024
  }
@@ -1777,17 +2100,15 @@ export default class ReactNativeBleTransport {
1777
2100
  }
1778
2101
 
1779
2102
  private async writeProtocolV2Packet(
2103
+ uuid: string,
1780
2104
  transport: BleTransport,
1781
2105
  base64: string,
1782
2106
  context: ProtocolV2CallContext,
1783
2107
  assertCurrentGeneration: () => void
1784
2108
  ) {
1785
- const shouldUseWriteWithResponse = shouldWriteProtocolV2WithResponse({
1786
- platform: Platform.OS,
1787
- highVolume: context.highVolume,
1788
- requestedWithResponse: context.writeWithResponse,
1789
- characteristic: transport.writeCharacteristic,
1790
- });
2109
+ const shouldUseWriteWithResponse =
2110
+ transport.writeCharacteristic.isWritableWithResponse &&
2111
+ (context.writeWithResponse === true || (Platform.OS === 'ios' && !context.highVolume));
1791
2112
  let attempt = 0;
1792
2113
  for (;;) {
1793
2114
  assertCurrentGeneration();
@@ -1795,11 +2116,24 @@ export default class ReactNativeBleTransport {
1795
2116
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1796
2117
  }
1797
2118
  try {
1798
- if (shouldUseWriteWithResponse) {
1799
- await transport.writeCharacteristic.writeWithResponse(base64);
1800
- } else {
1801
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1802
- }
2119
+ await this.writeBlePacket(
2120
+ uuid,
2121
+ base64,
2122
+ payload =>
2123
+ shouldUseWriteWithResponse
2124
+ ? transport.writeCharacteristic.writeWithResponse(payload)
2125
+ : transport.writeCharacteristic.writeWithoutResponse(payload),
2126
+ // Same rule as Protocol V1: a write from a superseded generation must not
2127
+ // tear down the link that the current generation is using.
2128
+ () => {
2129
+ try {
2130
+ assertCurrentGeneration();
2131
+ return !context.signal.aborted;
2132
+ } catch {
2133
+ return false;
2134
+ }
2135
+ }
2136
+ );
1803
2137
  assertCurrentGeneration();
1804
2138
  return;
1805
2139
  } catch (error) {
@@ -1822,6 +2156,7 @@ export default class ReactNativeBleTransport {
1822
2156
  }
1823
2157
 
1824
2158
  private async writeProtocolV2Frame(
2159
+ uuid: string,
1825
2160
  transport: BleTransport,
1826
2161
  frame: Uint8Array,
1827
2162
  context: ProtocolV2CallContext,
@@ -1832,17 +2167,28 @@ export default class ReactNativeBleTransport {
1832
2167
  platform: Platform.OS,
1833
2168
  iosPacketLength: tuning.iosPacketLength,
1834
2169
  androidPacketLength: tuning.androidPacketLength,
1835
- mtu: transport.mtuSize,
2170
+ mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1836
2171
  });
2172
+ // Match Desktop BLE pacing so Pro2 firmware can finish the previous response
2173
+ // before the next single-packet control command is written.
2174
+ const initialDelayMs =
2175
+ Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
2176
+ ? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
2177
+ : 0;
1837
2178
  await writeProtocolV2BleFrame({
1838
2179
  frame,
1839
2180
  packetCapacity,
1840
2181
  assertActive: assertCurrentGeneration,
1841
2182
  signal: context.signal,
1842
2183
  abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
2184
+ initialDelayMs,
2185
+ burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
2186
+ burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
2187
+ flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1843
2188
  wait: delay,
1844
2189
  writePacket: packet =>
1845
2190
  this.writeProtocolV2Packet(
2191
+ uuid,
1846
2192
  transport,
1847
2193
  Buffer.from(packet).toString('base64'),
1848
2194
  context,
@@ -1866,39 +2212,11 @@ export default class ReactNativeBleTransport {
1866
2212
 
1867
2213
  if (highVolumeWrite) {
1868
2214
  const tuning = getProtocolV2BleTuning();
1869
- const currentTransport = this.getCachedTransport(uuid);
1870
- const writeWithResponse = shouldWriteProtocolV2WithResponse({
1871
- platform: Platform.OS,
1872
- highVolume: true,
1873
- requestedWithResponse: options?.writeWithResponse,
1874
- characteristic: currentTransport.writeCharacteristic,
1875
- });
1876
- const packetCapacity = resolveProtocolV2PacketCapacity({
1877
- platform: Platform.OS,
1878
- iosPacketLength: tuning.iosPacketLength,
1879
- androidPacketLength: tuning.androidPacketLength,
1880
- mtu: currentTransport.mtuSize,
2215
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2216
+ name,
2217
+ writeMode: options?.writeWithResponse ? 'withResponse' : 'withoutResponse',
2218
+ packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
1881
2219
  });
1882
- const writeMode = writeWithResponse ? 'withResponse' : 'withoutResponse';
1883
- const logSignature = `${name}:${writeMode}:${String(
1884
- currentTransport.mtuSize
1885
- )}:${packetCapacity}`;
1886
- const loggedSignatures =
1887
- this.protocolV2HighVolumeLogSignatures.get(uuid) ?? new Set<string>();
1888
- if (!loggedSignatures.has(logSignature)) {
1889
- loggedSignatures.add(logSignature);
1890
- this.protocolV2HighVolumeLogSignatures.set(uuid, loggedSignatures);
1891
- Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1892
- name,
1893
- writeMode,
1894
- reportedMtu: currentTransport.mtuSize,
1895
- packetCapacity,
1896
- });
1897
- }
1898
- }
1899
-
1900
- if (highVolumeWrite) {
1901
- await this.enableAndroidHighConnectionPriority(uuid);
1902
2220
  }
1903
2221
 
1904
2222
  try {
@@ -1912,73 +2230,6 @@ export default class ReactNativeBleTransport {
1912
2230
  } catch (e) {
1913
2231
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
1914
2232
  throw e;
1915
- } finally {
1916
- if (highVolumeWrite) {
1917
- this.scheduleAndroidBalancedConnectionPriority(uuid);
1918
- }
1919
- }
1920
- }
1921
-
1922
- private clearAndroidPriorityResetTimer(uuid: string) {
1923
- const timerId = this.androidPriorityResetTimers.get(uuid);
1924
- if (timerId !== undefined) {
1925
- clearTimeout(timerId);
1926
- this.androidPriorityResetTimers.delete(uuid);
1927
- }
1928
- }
1929
-
1930
- private async enableAndroidHighConnectionPriority(uuid: string) {
1931
- if (Platform.OS !== 'android') return;
1932
-
1933
- this.clearAndroidPriorityResetTimer(uuid);
1934
- if (this.androidHighPriorityDevices.has(uuid)) return;
1935
-
1936
- const transport = transportCache[uuid];
1937
- if (!transport) return;
1938
-
1939
- try {
1940
- transport.device = await transport.device.requestConnectionPriority(ConnectionPriority.High);
1941
- this.androidHighPriorityDevices.add(uuid);
1942
- Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
1943
- priority: 'high',
1944
- });
1945
- } catch (error) {
1946
- Log?.debug('[ReactNativeBleTransport] Android BLE high priority request failed', {
1947
- error: error instanceof Error ? error.message : String(error),
1948
- });
1949
- }
1950
- }
1951
-
1952
- private scheduleAndroidBalancedConnectionPriority(uuid: string) {
1953
- if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.has(uuid)) return;
1954
-
1955
- this.clearAndroidPriorityResetTimer(uuid);
1956
- const timerId = setTimeout(() => {
1957
- this.androidPriorityResetTimers.delete(uuid);
1958
- this.restoreAndroidConnectionPriority(uuid, transportCache[uuid]).catch(error =>
1959
- Log?.debug('[ReactNativeBleTransport] Android BLE priority restore failed', error)
1960
- );
1961
- }, ANDROID_HIGH_PRIORITY_IDLE_MS);
1962
- this.androidPriorityResetTimers.set(uuid, timerId);
1963
- }
1964
-
1965
- private async restoreAndroidConnectionPriority(uuid: string, transport?: BleTransport) {
1966
- this.clearAndroidPriorityResetTimer(uuid);
1967
- if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.delete(uuid) || !transport) {
1968
- return;
1969
- }
1970
-
1971
- try {
1972
- transport.device = await transport.device.requestConnectionPriority(
1973
- ConnectionPriority.Balanced
1974
- );
1975
- Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
1976
- priority: 'balanced',
1977
- });
1978
- } catch (error) {
1979
- Log?.debug('[ReactNativeBleTransport] Android BLE balanced priority request failed', {
1980
- error: error instanceof Error ? error.message : String(error),
1981
- });
1982
2233
  }
1983
2234
  }
1984
2235
 
@@ -2002,7 +2253,13 @@ export default class ReactNativeBleTransport {
2002
2253
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
2003
2254
  assertCurrentGeneration();
2004
2255
  const currentTransport = this.getCachedTransport(uuid);
2005
- await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
2256
+ await this.writeProtocolV2Frame(
2257
+ uuid,
2258
+ currentTransport,
2259
+ frame,
2260
+ context,
2261
+ assertCurrentGeneration
2262
+ );
2006
2263
  },
2007
2264
  readFrame: async () => {
2008
2265
  assertCurrentGeneration();
@@ -2027,6 +2284,6 @@ export default class ReactNativeBleTransport {
2027
2284
  }
2028
2285
 
2029
2286
  getProtocolType(path: string): ProtocolType | undefined {
2030
- return this.deviceProtocol.get(path);
2287
+ return this.getActiveProtocol(path);
2031
2288
  }
2032
2289
  }